diff --git a/.claude/commands/syncro.md b/.claude/commands/syncro.md index c1bbd50..6e21727 100644 --- a/.claude/commands/syncro.md +++ b/.claude/commands/syncro.md @@ -26,6 +26,10 @@ Create, update, close, comment on, and bill tickets in Syncro PSA. ## Hard Rules (violations have occurred — no exceptions) +**All work-time billing MUST go through `timer_entry → charge_timer_entry`.** Bare `add_line_item` for time-bearing work bypasses Syncro's time tracking and breaks reporting (hours per client, tech productivity, prepay burn). Bare `add_line_item` is reserved for non-time items only (hardware, flat-fee services). Even warranty/free work needs a time entry — set `billable: false`. Only cancelled tickets are exempt. Mike caught the bare-`add_line_item` bug across 31 tickets on 2026-04-30; it was repeated on 3 more tickets on 2026-05-01 — see `.claude/memory/feedback_syncro_timer_first.md`. + +**JSON payloads to curl: use heredoc with `--data-binary @-`, not `/tmp/*.json` files.** On Windows the Write tool resolves `/tmp/foo.json` to `C:\tmp\foo.json` while Git Bash resolves it to `%LOCALAPPDATA%\Temp\foo.json` — different real directories, so a payload written by Write may not be the file curl reads. Heredoc with `<<'JSON'` (single-quoted to suppress bash variable expansion inside the payload) avoids the file handoff entirely. See `.claude/memory/feedback_tmp_path_windows.md` — caused a wrong-comment incident on ticket #32225 on 2026-05-01 (rogue payload from a prior session). + **Before any POST:** Always show the full payload to the user and wait for explicit confirmation. This applies to tickets, comments, line items, and invoices — including hidden/internal notes. **After any ambiguous POST result** (null fields, jq error, curl error, timeout): Do NOT retry. GET the resource first to confirm whether the action succeeded. Syncro has no idempotency on any endpoint — one POST always creates one record. Duplicate tickets and comments cannot be deleted via API; comments require manual GUI removal. @@ -86,15 +90,19 @@ fi **Draft call:** ```bash -# Write prompt to temp file to avoid quoting hell -cat > /tmp/ollama_prompt.txt <<'ENDPROMPT' +# Write prompt to a workspace path both the Write tool and Git Bash agree on +# (do NOT use /tmp on Windows — see Hard Rules: /tmp resolves differently in +# Write vs Git Bash). Use $CLAUDETOOLS_ROOT/.claude/tmp/ or pipe via heredoc. +PROMPT_FILE="$CLAUDETOOLS_ROOT/.claude/tmp/ollama_prompt.txt" +mkdir -p "$(dirname "$PROMPT_FILE")" +cat > "$PROMPT_FILE" <<'ENDPROMPT' ENDPROMPT if [ -n "$OLLAMA" ]; then - DRAFT=$(py -c " -import urllib.request, json, sys -prompt = open('/tmp/ollama_prompt.txt').read() + DRAFT=$(PROMPT_FILE="$PROMPT_FILE" py -c " +import os, urllib.request, json, sys +prompt = open(os.environ['PROMPT_FILE']).read() body = json.dumps({ 'model': 'qwen3:14b', 'messages': [{'role': 'user', 'content': prompt}], @@ -345,15 +353,9 @@ Confirm? (yes/no) **Call 1 — Create ticket:** ```bash -curl -s -X POST "${BASE}/tickets?api_key=${API_KEY}" \ +RESP=$(curl -s -X POST "${BASE}/tickets?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ - -d @/tmp/ticket_payload.json -# Parse: TICKET_ID=$(... | jq -r '.ticket.id') -# Parse: CUST_ID=$(... | jq -r '.ticket.customer_id') -``` - -Payload fields (omit null/blank): -```json + --data-binary @- <<'JSON' { "customer_id": N, "subject": "...", @@ -368,26 +370,30 @@ Payload fields (omit null/blank): "end_at": "ISO8601", "asset_ids": [N] } +JSON +) +TICKET_ID=$(echo "$RESP" | jq -r '.ticket.id') +CUST_ID=$(echo "$RESP" | jq -r '.ticket.customer_id') ``` +Omit null/blank fields from the payload before piping. The `'JSON'` quoting on the heredoc opener is required — it suppresses bash variable and backtick expansion inside, which matters when descriptions contain `$` (passwords, prices, regex, etc.). + **Call 2 — Post initial description as "Initial Issue" comment:** ```bash curl -s -X POST "${BASE}/tickets/${TICKET_ID}/comment?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ - -d @/tmp/comment_payload.json -# Parse: .comment.id (NOT .id — see Hard Rules) -``` - -Payload: -```json + --data-binary @- <<'JSON' { "subject": "Initial Issue", "body": "", "hidden": false, "do_not_email": true } +JSON +# Parse: .comment.id (NOT .id — see Hard Rules) ``` + Set `do_not_email: true` if "Do Not Email" was checked; `false` otherwise. **Call 3 — Create appointment (only if start_at provided):** @@ -395,11 +401,7 @@ Set `do_not_email: true` if "Do Not Email" was checked; `false` otherwise. ```bash curl -s -X POST "${BASE}/appointments?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ - -d @/tmp/appt_payload.json -``` - -Payload: -```json + --data-binary @- <<'JSON' { "ticket_id": N, "customer_id": N, @@ -408,11 +410,12 @@ Payload: "end_at": "ISO8601", "location": "" } +JSON ``` Note: "Do Not Invite" (suppress calendar invite email) is not API-controllable. Tell the user to toggle it in the Syncro GUI if needed. -**Always use temp files for payloads** — never inline JSON in curl -d with ticket data (special characters, newlines in description will break the shell). +**Payload handoff: prefer heredoc with `--data-binary @-` and `<<'JSON'` quoting** — never use `/tmp/.json` for piping payloads from the Write tool to curl. On Windows, the Write tool resolves `/tmp/foo.json` to `C:\tmp\foo.json` while Git Bash resolves it to `%LOCALAPPDATA%\Temp\foo.json` — different real directories, so curl reads a different (or stale) file than Write created. Heredoc avoids the file handoff entirely, and the `'JSON'` quoting prevents bash from expanding `$` characters inside the payload (passwords, regex, jq queries, etc.). See `.claude/memory/feedback_tmp_path_windows.md` for the full failure mode. #### Comments @@ -437,7 +440,15 @@ Note: "Do Not Invite" (suppress calendar invite email) is not API-controllable. # Correct pattern — always check .comment.id RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ - -d @/tmp/payload.json) + --data-binary @- <<'JSON' +{ + "subject": "Update", + "body": "...", + "hidden": false, + "do_not_email": false +} +JSON +) echo "$RESP" | jq '{id: .comment.id, subject: .comment.subject, created_at: .comment.created_at}' ``` @@ -463,9 +474,73 @@ curl -s "${BASE}/tickets/${ID}?api_key=${API_KEY}" | \ #### Billable Line Items -Two verified ways to add billable time. Both produce ticket line items that transfer to invoices. +There are two verified mechanisms for putting a billable charge on a ticket. They are NOT interchangeable. -**Option A — Direct line item (simpler):** +**Default — `timer_entry → charge_timer_entry` (REQUIRED for any work that has a time component):** + +This is the documented billing path. It records hours into Syncro's time-tracking system AND creates the line item, so reporting (hours per client, tech productivity, prepay burn rate, average resolution time) stays accurate. Bare `add_line_item` skips the time-tracking system and leaves Syncro showing `00:00:00` worked even though the invoice posts correctly — which is what produced the 31-ticket gap on 2026-04-30 and three more on 2026-05-01. + +| Operation | Method | Endpoint | +|---|---|---| +| Create timer | POST | `/tickets//timer_entry` | +| Charge timer (creates line item) | POST | `/tickets//charge_timer_entry` | +| Update timer | PUT | `/tickets//update_timer_entry` | +| Delete timer | POST | `/tickets//delete_timer_entry` | +| List timers | GET | `/ticket_timers?ticket_id=` | + +```bash +# 1. Create timer entry — records hours in Syncro's time-tracking system. +# For warranty / no-charge work, set "billable": false (time still records). +curl -s -X POST "${BASE}/tickets/${ID}/timer_entry?api_key=${API_KEY}" \ + -H "Content-Type: application/json" \ + --data-binary @- <<'JSON' +{ + "start_at": "ISO8601", + "end_at": "ISO8601", + "notes": "...", + "billable": true, + "product_id": 1190473 +} +JSON + +# 2. Charge the timer — sets recorded:true and auto-generates a linked line +# item with the timer's product_id and computed quantity (hours). +curl -s -X POST "${BASE}/tickets/${ID}/charge_timer_entry?api_key=${API_KEY}" \ + -H "Content-Type: application/json" \ + --data-binary @- <<'JSON' +{"timer_entry_id": N} +JSON + +# 3. Verify the auto-generated line item picked up the rate. Syncro sometimes +# creates the line at $0.00 even though the product has a price_retail set +# (same root cause as the bare add_line_item bug). If price_retail is 0, +# patch it via update_line_item. +curl -s "${BASE}/tickets/${ID}?api_key=${API_KEY}" | jq '.ticket.line_items[] | {id, product_id, quantity, price_retail}' + +# 4. (only if needed) Patch a $0 line: +curl -s -X PUT "${BASE}/tickets/${ID}/update_line_item?api_key=${API_KEY}" \ + -H "Content-Type: application/json" \ + --data-binary @- <<'JSON' +{ + "ticket_line_item_id": NNN, + "price_retail": 150.00 +} +JSON + +# Delete timer (rarely needed): +curl -s -X POST "${BASE}/tickets/${ID}/delete_timer_entry?api_key=${API_KEY}" \ + -H "Content-Type: application/json" \ + --data-binary @- <<'JSON' +{"timer_entry_id": N} +JSON +# Returns: {"success": true} +``` + +`charge_timer_entry` produces the line item; you do NOT call `add_line_item` afterward — that would double-bill. + +**Fallback — bare `add_line_item` (NON-TIME items only):** + +Use this ONLY when there is genuinely no labor time component to bill — selling a hardware product, a flat-fee service, a recurring subscription line. For ANY work with a time component, including warranty/free work (where time should record at `billable: false`), use the timer path above. Cancelled tickets are the only exemption from creating a time entry. | Operation | Method | Endpoint | |---|---|---| @@ -474,45 +549,37 @@ Two verified ways to add billable time. Both produce ticket line items that tran | Update line item | PUT | `/tickets//update_line_item` | ```bash -# Add (always include price_retail — API does not auto-apply product rates) +# Non-time line item (hardware, flat-fee). Always include price_retail — +# the API does not auto-apply product rates. curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ - -d '{"product_id": 1190473, "quantity": 0.5, "price_retail": 150.00, "name": "Labor - Remote Business", "description": "Work description", "taxable": false}' + --data-binary @- <<'JSON' +{ + "product_id": 1190473, + "quantity": 1, + "price_retail": 150.00, + "name": "Hardware - Replacement Drive", + "description": "Item description", + "taxable": true +} +JSON # Remove curl -s -X POST "${BASE}/tickets/${ID}/remove_line_item?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ - -d '{"ticket_line_item_id": 12345}' + --data-binary @- <<'JSON' +{"ticket_line_item_id": 12345} +JSON # Returns: {"success": true, "message": ""} ``` -**Option B — Timer then charge (for time-tracking workflows):** - -```bash -# 1. Create timer entry -curl -s -X POST "${BASE}/tickets/${ID}/timer_entry?api_key=${API_KEY}" \ - -H "Content-Type: application/json" \ - -d '{"start_at": "ISO8601", "end_at": "ISO8601", "notes": "...", "billable": true, "product_id": 1190473}' - -# 2. Charge timer — sets recorded:true and creates linked line item -curl -s -X POST "${BASE}/tickets/${ID}/charge_timer_entry?api_key=${API_KEY}" \ - -H "Content-Type: application/json" \ - -d '{"timer_entry_id": N}' - -# Delete timer (if needed) -curl -s -X POST "${BASE}/tickets/${ID}/delete_timer_entry?api_key=${API_KEY}" \ - -H "Content-Type: application/json" \ - -d '{"timer_entry_id": N}' -# Returns: {"success": true} -``` - -**add_line_item required fields:** +**add_line_item required fields** (also apply to the auto-generated line from `charge_timer_entry` — verify after charging and patch via `update_line_item` if needed): - `name` — required (422 if missing) - `description` — required (422 if missing) -- `product_id` — labor product ID (see table below) -- `quantity` — decimal hours (0.5 = 30 min, 1.0 = 1 hour) +- `product_id` — product ID (labor product table below for time-based work, or any other product for hardware / flat-fee items) +- `quantity` — units of the product. For labor products, this is decimal hours (0.5 = 30 min, 1.0 = 1 hour). For hardware, the unit count. - `price_retail` — **must always be set explicitly**; `price`, `retail_price`, `rate`, `price_cents` all silently ignored and leave line at $0.00. Syncro does NOT auto-calculate rates via API even though it does in the web UI. Omitting it leaves the line at $0.00 and the invoice generates at $0 (verified 2026-04-23 on #32203). Always pass the rate from the table below. -- `taxable: false` — **always set explicitly**; labor products default to no-tax in GUI but the API applies tax if this is omitted +- `taxable` — **always set explicitly**; labor products default to no-tax in GUI but the API applies tax if this is omitted. Use `false` for labor, `true` for taxable hardware. **Do NOT remove ticket line items after invoicing.** Leave them on the ticket — the "Add/View Charges" button and billing verification by techs depends on seeing line items there. @@ -566,7 +633,7 @@ Winter caught this on #32203 (Desert Auto Tech) 2026-04-23 after a stack of 1hr | Create from ticket | POST | `/invoices` | `{"ticket_id": N, "customer_id": N, "category": "Standard"}` | | Delete invoice | DELETE | `/invoices/` | — | -**"Make Invoice" flow:** `POST /invoices` pulls all `add_line_item` entries from the ticket into the invoice. Timer entries are NOT included. +**"Make Invoice" flow:** `POST /invoices` pulls all line items currently on the ticket into the invoice. Line items are produced by `charge_timer_entry` (the default path for time-based work) or by bare `add_line_item` (the fallback path for non-time items). A bare timer entry that has not been charged is NOT pulled in — the timer must be converted to a line item via `charge_timer_entry` first. **Note:** The `POST /invoices` response body does not include `line_items` — do `GET /invoices/{id}` to verify line items transferred correctly. @@ -591,60 +658,113 @@ When showing ticket detail, include: ### Billing workflow **ALWAYS ask the user for minutes and labor type before logging any time. Never assume a default.** -**ALWAYS show a preview of the comment + line items to the user before posting. Wait for confirmation.** +**ALWAYS show a preview of the comment + timer entry to the user before posting. Wait for confirmation.** **ALWAYS read `customer.prepay_hours` before choosing the labor product for emergency work.** +**ALWAYS bill via `timer_entry → charge_timer_entry`. Bare `add_line_item` for time-based work bypasses Syncro's time-tracking system and is forbidden — see Hard Rules.** When `/syncro bill ` is called: 1. `GET /tickets/{id}` for ticket detail, then `GET /customers/{customer_id}` to read `prepay_hours` 2. Check Ollama availability (see "Ollama drafting" above) — do this once, reuse `$OLLAMA` -3. Ask: "How many minutes should I bill, and what labor type? (remote / onsite / emergency / project / internal)" +3. Ask: "How many minutes should I bill, and what labor type? (remote / onsite / emergency / project / internal / warranty)" 4. Decide product + quantity using the emergency-branching table above: - Non-prepaid + emergency → product `26184`, qty = actual hours - - Prepaid + emergency → product `26118`, qty = actual hours × 1.5 + - Prepaid + emergency → product `26118`, qty = actual hours × 1.5 + - Warranty / no-charge → use the closest labor product (e.g. `1190473` remote, `26118` onsite) with `billable: false` on the timer; qty = actual hours - Otherwise → per `--labor` mapping below, qty = actual hours 5. Look up `price_retail` from the local rate table (do NOT fetch live — rates are baked in) -6. Send billing draft prompt to Ollama (or draft directly if `$OLLAMA` is empty) — see prompt template above -7. Run Claude review checklist on the draft output -8. Present preview to user: product, quantity, rate, computed total, comment body, line item description. Wait for confirmation. -9. Post comment: `POST /tickets/{id}/comment` -10. Add billable line item: `POST /tickets/{id}/add_line_item` with `product_id`, `quantity`, `price_retail`, `name`, `description`, `taxable: false` -11. Create invoice: `POST /invoices` with `{"ticket_id": N, "customer_id": N, "category": "Standard"}` -12. Verify invoice: `GET /invoices/{id}` → confirm `.invoice.total` matches `qty × price_retail` -13. Update ticket status to `Invoiced` +6. Compute `start_at` and `end_at` for the timer (use ISO8601; the `end_at − start_at` interval should equal `quantity` hours so Syncro's reporting math matches what you bill) +7. Send billing draft prompt to Ollama (or draft directly if `$OLLAMA` is empty) — see prompt template above +8. Run Claude review checklist on the draft output +9. Present preview to user: product, quantity, rate, computed total, comment body, timer notes / line item description. Wait for confirmation. +10. Post resolution comment: `POST /tickets/{id}/comment` +11. Create timer entry: `POST /tickets/{id}/timer_entry` with `start_at`, `end_at`, `billable` (true for paid work, false for warranty/no-charge), `product_id`, `notes`. Capture the returned timer ID. +12. Charge the timer: `POST /tickets/{id}/charge_timer_entry` with `{"timer_entry_id": N}` — this records the time AND auto-generates a linked line item with the timer's `product_id` and computed `quantity` (hours). +13. Verify the auto-generated line item picked up the rate: `GET /tickets/{id}` and inspect the new entry in `.ticket.line_items[]`. If `price_retail` came in at `0.00` (Syncro sometimes drops it on auto-generated lines), patch it: `PUT /tickets/{id}/update_line_item` with `{"ticket_line_item_id": N, "price_retail": }`. +14. Create invoice: `POST /invoices` with `{"ticket_id": N, "customer_id": N, "category": "Standard"}` +15. Verify invoice: `GET /invoices/{id}` → confirm `.invoice.total` matches `qty × price_retail`. (For prepaid customers, the line totals against the prepay block — `.invoice.total` will reflect any non-prepaid items only.) +16. Update ticket status to `Invoiced` -**If `.invoice.total` comes back $0.00** (line items went in with null price): `PUT /tickets/{id}/update_line_item` with `price_retail` on each item, then `DELETE /invoices/{bad_id}` and re-POST `/invoices`. Recovery verified on #32203 (2026-04-23). +**If `.invoice.total` comes back $0.00** (auto-generated line item went in with null price and you missed step 13): `PUT /tickets/{id}/update_line_item` with `price_retail` on each item, then `DELETE /invoices/{bad_id}` and re-POST `/invoices`. Recovery verified on #32203 (2026-04-23). **Correct pattern:** ```bash -# Step 1: Post comment +# Step 1: Post resolution comment curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ - -d '{"subject": "Resolution", "body": "...", "hidden": false, "do_not_email": false}' + --data-binary @- <<'JSON' +{ + "subject": "Resolution", + "body": "...", + "hidden": false, + "do_not_email": false +} +JSON -# Step 2: Add billable line item (convert minutes to decimal hours) -# 60 min = 1.0, 30 min = 0.5, 45 min = 0.75, etc. -# Always include price_retail — Syncro does NOT auto-apply rates via API -curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \ +# Step 2: Create timer entry — records hours in Syncro's time-tracking system. +# Convert minutes to decimal hours (60 min = 1.0, 30 min = 0.5, 45 min = 0.75). +# Set start_at/end_at so end - start equals the billed duration. +# For warranty / no-charge work, set "billable": false (time still records). +TIMER_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/timer_entry?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ - -d '{"product_id": 1190473, "quantity": 1.0, "price_retail": 150.00, "name": "Labor - Remote Business", "description": "...", "taxable": false}' + --data-binary @- <<'JSON' +{ + "start_at": "2026-05-01T13:00:00-07:00", + "end_at": "2026-05-01T14:00:00-07:00", + "notes": "Resolved customer issue — see ticket comment for detail.", + "billable": true, + "product_id": 1190473 +} +JSON +) +TIMER_ID=$(echo "$TIMER_RESP" | jq -r '.timer.id // .timer_entry.id') -# Step 3: Create invoice -curl -s -X POST "${BASE}/invoices?api_key=${API_KEY}" \ +# Step 3: Charge the timer — creates the linked line item automatically. +curl -s -X POST "${BASE}/tickets/${ID}/charge_timer_entry?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ - -d '{"ticket_id": '"${ID}"', "customer_id": '"${CUST}"', "category": "Standard"}' + --data-binary @- < 0`. See the Emergency billing branching table above. +**Override:** `emergency` becomes `26118` with `quantity × 1.5` when the customer has `prepay_hours > 0`. See the Emergency billing branching table above. The override applies to the timer's `product_id` field and the timer's interval (set `end_at − start_at` to `actual_hours × 1.5` so the auto-generated line gets the right quantity). ### Error handling diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 0ed02b6..6c0583d 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -31,6 +31,7 @@ - [1Password — always use service token](feedback_1password_service_token.md) — Source OP_SERVICE_ACCOUNT_TOKEN from SOPS for every `op` call. Desktop-app integration prompts are unacceptable in agent flows. - [/tmp path mismatch on Windows](feedback_tmp_path_windows.md) — Write tool and Git Bash resolve `/tmp` to DIFFERENT real dirs. Use heredoc or workspace path for JSON payloads handed to curl. Caused wrong-comment incident on Syncro #32225. - [Syncro — never set contact on Cascades tickets](feedback_syncro_cascades_contact.md) — Cascades tickets must have `contact_id` blank; Syncro routes to the correct email distribution that way. Setting contact (often defaults to Meredith) overrides and breaks notifications. Never include the contact field in create or edit payloads for Cascades. +- [Syncro — log time entries first, never bare add_line_item](feedback_syncro_timer_first.md) — All Syncro work-time billing MUST go through `timer_entry → charge_timer_entry`. Bare `add_line_item` leaves Syncro time tracking at 00:00:00 and breaks reporting. Mike caught this on 2026-04-30 across 31 tickets; I repeated the bug on 2026-05-01 across 3 more. ## Machine - [ACG-5070 Workstation Setup](reference_workstation_setup.md) - Windows 11 Pro clean install 2026-03-30, replaced CachyOS. All tools installed. diff --git a/.claude/memory/feedback_syncro_timer_first.md b/.claude/memory/feedback_syncro_timer_first.md new file mode 100644 index 0000000..e25b40e --- /dev/null +++ b/.claude/memory/feedback_syncro_timer_first.md @@ -0,0 +1,31 @@ +--- +name: Syncro — log time entries first, never bare add_line_item +description: All Syncro tickets must have a Syncro time entry recorded for any work done. Use timer_entry + charge_timer_entry to bill, NOT bare add_line_item. Bare add_line_item leaves Syncro time tracking at 00:00:00 and breaks reporting (hours per client, tech productivity, prepay burn rate). This applies even to warranty/free work; only cancelled tickets are exempt. +type: feedback +--- + +**Rule:** When billing a Syncro ticket, the workflow MUST be: + +1. Do the work. +2. POST `/tickets/{id}/timer_entry` with `start_at`, `end_at`, `billable`, `product_id`, `notes`. This records hours in Syncro's time-tracking system. +3. POST `/tickets/{id}/charge_timer_entry` with `{"timer_entry_id": N}` to convert the timer into a billable line item on the ticket. +4. POST `/invoices` to roll the line item onto a customer invoice. +5. PUT ticket status as needed. + +**Why:** Syncro's reporting (hours per client, technician productivity, average resolution time, prepay burn rates) is built on the **time-entries** table, not on invoice line items. If we use bare `add_line_item` and type hours into the description ("Applied 1.5 Prepay Hours"), the invoice posts but Syncro's time tracking shows `00:00:00`. We lose all reporting visibility on actual work performed. + +**How to apply:** + +- **Default billing path:** `timer_entry → charge_timer_entry → invoice`. Always. +- **Bare `add_line_item` is NOT a default option.** Only acceptable when there is genuinely no time component to bill — e.g. selling a hardware product or a flat-fee service with zero labor. For any work-time billing, use the timer path. +- **Even warranty/free work needs a time entry.** Set `billable: false` (or appropriate type) on the timer entry. Time still records, just doesn't generate a paid line item. +- **Only cancelled tickets are exempt** from time entries. + +**Real-world incident — 2026-04-30:** Mike audited 31 closed tickets and found ALL 31 had `00:00:00` in Syncro time tracking. 29 had proper invoices with revenue captured correctly, but the underlying time data was bypassed entirely. Examples: #32156 (Cascades) "Applied 8.0 Prepay Hours" — should have been an 8.0 hr time entry. #32218 (Instrumental) "Applied 1.5 Prepay Hours" — should have been a 1.5 hr time entry. + +**Repeat incident — 2026-05-01:** I (Claude, Howard's session) billed three tickets the same broken way (#32225 Sombra $525, #32229 Mineralogical Record $262.50, #32214 Cascades $0 prepaid). Winter retroactively added time entries to fix them. The skill examples need to be updated to make timer-first the default, and that's tracked in the syncro skill rewrite work. + +**Where the fix needs to land:** +- `.claude/commands/syncro.md` — promote the timer-entry workflow to be the documented default. Demote `add_line_item` to a clearly-labeled fallback for non-time work only. Every example in the "Billing workflow" section should use the timer path. + +**Skill author note:** Currently the skill presents both patterns as Option A (simpler — add_line_item) and Option B (timer + charge). That framing is wrong. Option B is the only correct path for time-bearing work; Option A is a fallback at best.