refactor(syncro): replace timer workflow with add_line_item, lock API sequences

- Billing now uses add_line_item directly; timer_entry/charge_timer_entry removed
- Added Verified Response Shapes table for all endpoints (tested live against ACG internal customer)
- Billing workflow rewritten as strict 5-step locked script with no branches
- Added STOP rule: never try alternative endpoints/formats on unexpected responses
- bot-alerts section: explicit success ([OK] + message_id) and failure ([WARNING]) criteria
- Updated feedback memory to supersede the old timer-first rule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 09:46:27 -07:00
parent 3a09746468
commit 64a0ba77c2
2 changed files with 204 additions and 356 deletions

View File

@@ -26,10 +26,12 @@ 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`.
**Billing uses `add_line_item` directly — do NOT use `timer_entry → charge_timer_entry`.** The timer workflow is not used. For all billable work (labor, warranty, internal), POST directly to `/tickets/<id>/add_line_item` with the correct `product_id`, `quantity` (decimal hours), `price_retail`, `description`, and `taxable: false`.
**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).
**If any API call returns an unexpected response: STOP and report — do NOT try alternative endpoints, payload formats, or retries.** The Syncro API does not change between calls. An unexpected result means either the call failed cleanly (check the error field) or it succeeded with a known-quirky response shape (see Verified Response Shapes below). Experimenting with alternatives creates duplicates that cannot be cleaned up via API.
**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.
@@ -425,268 +427,160 @@ Note: "Do Not Invite" (suppress calendar invite email) is not API-controllable.
**Payload handoff: prefer heredoc with `--data-binary @-` and `<<'JSON'` quoting** — never use `/tmp/<file>.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.
---
### Verified Response Shapes
Every endpoint's response shape, verified against the live API. Parse exactly as shown — no guessing.
| Operation | Endpoint | Response shape | Key to parse |
|---|---|---|---|
| Create ticket | POST `/tickets` | `{"ticket": {...}}` | `.ticket.id`, `.ticket.number` |
| Update ticket | PUT `/tickets/{id}` | `{"ticket": {...}}` | `.ticket.status` |
| Add comment | POST `/tickets/{id}/comment` | `{"comment": {...}}` | `.comment.id` |
| Add line item | POST `/tickets/{id}/add_line_item` | **FLAT** `{"id": N, ...}` | `.id` |
| Update line item | PUT `/tickets/{id}/update_line_item` | `{"ticket_line_item": {...}}` | `.ticket_line_item.id` |
| Remove line item | POST `/tickets/{id}/remove_line_item` | `{"success": true, "message": ""}` | — |
| Create invoice | POST `/invoices` | `{"invoice": {...}}` | `.invoice.id`, `.invoice.total` |
| Get invoice | GET `/invoices/{id}` | `{"invoice": {"line_items": [...]}}` | `.invoice.line_items[].price` (not `price_retail`) |
| Delete invoice | DELETE `/invoices/{id}` | `{"message": "ID: We deleted # N."}` | — |
| Create appointment | POST `/appointments` | `{"appointment": {...}}` | `.appointment.id` |
| Update appointment | PUT `/appointments/{id}` | `{"appointment": {...}}` | `.appointment.start_at` |
| Delete appointment | DELETE `/appointments/{id}` | `{"message": "deleted"}` | — |
**Invoice GET line_items field names differ from ticket line_items:** `item` = product name, `name` = description, `price` = unit rate. Do not use `price_retail` when reading invoice line items.
**`start_at` in ticket POST is unreliable** — always create appointments via separate POST `/appointments`. Do not rely on `start_at`/`end_at` on the ticket object itself.
---
#### Comments
| Operation | Method | Endpoint | Body |
|---|---|---|---|
| Add comment | POST | `/tickets/<id>/comment` | `{"subject": "Update", "body": "...", "hidden": false, "do_not_email": false}` |
**Comment fields (verified):**
- `subject` — required; comment header (e.g., "Update", "Resolution", "Internal Note")
- `body` — required; comment text (HTML supported)
- `hidden` — bool; if true, internal-only (customer can't see)
- `do_not_email` — bool; if true, suppresses customer email notification
- `tech` — string; overrides the authenticated user's name shown on the comment
**Drafting comment bodies:** Use Ollama (comment draft prompt template above) to generate `body` content. Run Claude review checklist. Present preview and wait for confirmation before POST. Fallback to Claude direct draft if `$OLLAMA` is empty.
**Silently ignored (do not use):** `product_id`, `minutes_spent`, `bill_time_now` — accepted but not saved. Verified 2026-04-21.
**CRITICAL — response wrapper:** POST /comment returns `{"comment": {"id": ..., "subject": ..., ...}}` — NOT a flat object. Always parse as `.comment.id`, `.comment.created_at`, etc. Using `.id` returns null and looks like failure even when the comment posted successfully. This caused duplicate comments on 2026-04-22 (#32185) and 2026-04-23 (#32142) — both times the POST succeeded but null `.id` triggered a retry.
```bash
# Correct pattern — always check .comment.id
RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \
# POST comment — response: {"comment": {...}}
COMMENT_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{
"subject": "Update",
"body": "...",
"subject": "Resolution",
"body": "Work summary here. Use <br> for line breaks. No <ul>/<li>.",
"hidden": false,
"do_not_email": false
}
JSON
)
echo "$RESP" | jq '{id: .comment.id, subject: .comment.subject, created_at: .comment.created_at}'
COMMENT_ID=$(echo "$COMMENT_RESP" | jq -r '.comment.id')
```
**CRITICAL — duplicate prevention:** The server has no idempotency. One POST = one comment, always. Duplicates are caused by calling the endpoint twice (retry after a perceived timeout, double tool invocation, etc.). **Never retry a POST /comment without first GET /tickets/{id} to confirm the comment did not already land.** When verifying, search all comments by subject — do not rely on `[-3:]` tail. The `Idempotency-Key` header is silently ignored.
```bash
# Correct verification pattern after ambiguous response
curl -s "${BASE}/tickets/${ID}?api_key=${API_KEY}" | \
jq '.ticket.comments[] | select(.subject == "Your Subject Here") | {id, created_at}'
```
**Comments cannot be deleted via API.** No DELETE endpoint exists in the Syncro API for comments — confirmed against official swagger spec. Duplicate comments require manual removal in the GUI.
**Do NOT wrap body in `{"comment": {...}}`** — returns 422 "Body can't be blank". POST flat JSON directly.
- `hidden: true` = internal only (customer can't see)
- `do_not_email: true` = suppress email to customer
- Body is HTML; use `<br>` for line breaks. `<ul>`/`<li>` do not render in Syncro.
- Do NOT wrap the payload in `{"comment": {...}}` — returns 422.
- **If `COMMENT_ID` is null:** GET `/tickets/{id}` and check `.ticket.comments[]` by subject before doing anything else. Comments cannot be deleted via API — duplicates require manual GUI removal.
#### Customers
| Operation | Method | Endpoint |
|---|---|---|
| List/search | GET | `/customers?query=<search>&per_page=25` |
| Get customer | GET | `/customers/<id>` |
| Create customer | POST | `/customers` |
#### Billable Line Items
There are two verified mechanisms for putting a billable charge on a ticket. They are NOT interchangeable.
**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/<id>/timer_entry` |
| Charge timer (creates line item) | POST | `/tickets/<id>/charge_timer_entry` |
| Update timer | PUT | `/tickets/<id>/update_timer_entry` |
| Delete timer | POST | `/tickets/<id>/delete_timer_entry` |
| List timers (on a ticket) | GET | `/tickets/<id>` → `.ticket.ticket_timers` |
**CRITICAL — response shapes are FLAT:** Both `POST /timer_entry` and `POST /charge_timer_entry` return a flat object — `{"id": N, "ticket_id": ..., "product_id": ..., ...}` — NOT wrapped in `{"timer": {...}}` or `{"timer_entry": {...}}`. Parse as `.id` directly. The wrapped pattern silently returns `null`, breaks `charge_timer_entry` ("Not found"), and triggers a duplicate-timer retry. Hit on ticket #32253 on 2026-05-05; recovery via `delete_timer_entry`. Verified shape:
```json
// POST /tickets/{id}/timer_entry response
{"id": 39031258, "ticket_id": 109895882, "user_id": 1750, "start_time": "...", "end_time": "...",
"recorded": false, "billable": true, "notes": "...", "product_id": 26118,
"comment_id": null, "ticket_line_item_id": null, "active_duration": 1800, ...}
// POST /tickets/{id}/charge_timer_entry response (also flat)
{"id": 39031258, "recorded": true, "ticket_line_item_id": 42313052, ...}
```bash
# Search
curl -s "${BASE}/customers?query=<name>&per_page=25&api_key=${API_KEY}" | jq '[.customers[] | {id, business_name, email}]'
# Get one
curl -s "${BASE}/customers/${CUST_ID}?api_key=${API_KEY}" | jq '{id: .customer.id, prepay_hours: .customer.prepay_hours}'
```
**CRITICAL — duplicate prevention:** Syncro has no idempotency on `/timer_entry`. **Never retry the POST without first GET `/tickets/{id}` and inspecting `.ticket.ticket_timers[]`.** The standalone `GET /ticket_timers?ticket_id=N` query parameter does NOT filter — it returns the entire global timer history. Use the ticket object instead.
#### Line Items
All billing uses `add_line_item` directly. Do not use `timer_entry → charge_timer_entry`.
```bash
# Verification pattern after ambiguous timer_entry response
curl -s "${BASE}/tickets/${ID}?api_key=${API_KEY}" | \
jq '.ticket.ticket_timers[] | select(.recorded == false) | {id, start_time, end_time, product_id, notes}'
```
If duplicates exist, delete the older one(s) before charging:
```bash
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": <older_duplicate_id>}
JSON
# Returns: {"success": true}
```
```bash
# 1. Create timer entry — records hours in Syncro's time-tracking system.
# For warranty / no-charge work, set "billable": false (time still records).
# Capture .id directly — response is FLAT (see above).
TIMER_RESP=$(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
)
TIMER_ID=$(echo "$TIMER_RESP" | jq -r '.id')
# 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": ${TIMER_ID}}
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 |
|---|---|---|
| Add line item | POST | `/tickets/<id>/add_line_item` |
| Remove line item | POST | `/tickets/<id>/remove_line_item` |
| Update line item | PUT | `/tickets/<id>/update_line_item` |
```bash
# 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}" \
# Add line item — response is FLAT: {"id": N, "ticket_id": N, "product_id": N, "price_retail": N, ...}
LINE_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{
"product_id": 1190473,
"quantity": 1,
"name": "Labor - Remote Business",
"description": "One-line billing description.",
"quantity": 1.0,
"price_retail": 150.00,
"name": "Hardware - Replacement Drive",
"description": "Item description",
"taxable": true
"taxable": false
}
JSON
)
LINE_ID=$(echo "$LINE_RESP" | jq -r '.id')
# Remove
# Update line item — response: {"ticket_line_item": {...}}
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": ${LINE_ID}, "price_retail": 175.00}
JSON
# Remove line item — response: {"success": true, "message": ""}
curl -s -X POST "${BASE}/tickets/${ID}/remove_line_item?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{"ticket_line_item_id": 12345}
--data-binary @- <<JSON
{"ticket_line_item_id": ${LINE_ID}}
JSON
# Returns: {"success": true, "message": ""}
```
**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` — 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` — **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.
**Required fields for add_line_item:**
- `name` — required (422 if missing); use the product name
- `description` — required (422 if missing); one-line billing narrative
- `product_id` — see labor product table below
- `quantity` — decimal hours for labor (0.5 = 30 min, 0.75 = 45 min, 1.0 = 60 min)
- `price_retail` — **must be set explicitly**; Syncro does NOT auto-populate from product rate via API
- `taxable` — **must be set explicitly**; always `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.
**Do NOT remove line items after invoicing.** Leave them on the ticket.
**Labor product IDs** — always fetch `price_retail` live from Syncro before billing. Never hardcode rates; they vary by contract and change over time.
**Labor product IDs** — always fetch `price_retail` live, never hardcode:
```bash
RATE=$(curl -s "${BASE}/products/${PRODUCT_ID}?api_key=${API_KEY}" | jq -r '.product.price_retail')
```
| product_id | Name | Notes |
| product_id | Name | Use when |
|---|---|---|
| `1190473` | Labor - Remote Business | Standard remote work |
| `26118` | Labor - Onsite Business | Base onsite rate |
| `573881` | Labor - In Shop Business | Hardware brought into ACG's shop |
| `26184` | Labor - Emergency or After Hours Business | **1.5× onsite; time-and-a-half baked into the rate.** Non-prepaid customers only. Do NOT stack with `26118` for the same hours. |
| `1049360` | **Labor- Warranty work** | **Use this for ANY warranty / no-charge work.** Do NOT use a billable labor product + `billable: false` or a patched price. See `feedback_syncro_warranty_product.md`. |
| `9269129` | Labor - Prepaid Project Labor | **DO NOT USE for normal or prepaid work.** Exempt Labor category — does NOT deduct from `prepay_hours` block despite the name. Billing a prepaid customer with this product gives a $0.00 invoice AND silently skips the block decrement. Verified 2026-05-04 (see `feedback_syncro_labor_type.md`). Only use if explicitly directed. |
| `9269124` | Labor - Internal Labor | Non-billable internal ACG time (not customer-facing). |
| `26117` | Fee - Travel Time | Per travel event (not hourly) |
| `68055` | Labor - Website Labor | Website-related work |
| `1190473` | Labor - Remote Business | Remote work |
| `26118` | Labor - Onsite Business | Onsite work |
| `573881` | Labor - In Shop Business | Device brought to ACG shop |
| `26184` | Labor - Emergency or After Hours | Non-prepaid emergency only; 1.5× rate baked in |
| `1049360` | Labor - Warranty work | Any warranty / no-charge work |
| `9269124` | Labor - Internal Labor | Internal ACG time, not customer-facing |
| `26117` | Fee - Travel Time | Per travel event |
| `68055` | Labor - Website Labor | Website work |
| `9269129` | Labor - Prepaid Project Labor | **DO NOT USE** — does not deduct prepay block |
`price_retail` is the per-unit rate fetched from Syncro. Line item total = `price_retail × quantity`. **Never patch `price_retail` to convert one product into another** (e.g. don't take Remote Labor and patch to $0 to mimic warranty — pick the correct product). The only legitimate `update_line_item price_retail` use is the auto-gen-zero recovery (when `charge_timer_entry` creates a line at $0 instead of the product's rate).
**Emergency billing — branch on prepay_hours:**
**Emergency / after-hours billing branches by whether customer has prepaid labor:**
Check: `GET /customers/<id>` → `.customer.prepay_hours` (string; `"0.0"` means no prepaid, any non-zero means prepaid block exists).
| `prepay_hours` | Regular hours | Emergency / after-hours |
| prepay_hours | Regular | Emergency |
|---|---|---|
| `0` / null (no prepaid) | delivery-channel product, qty = actual_hours | `26184`, qty = actual_hours (rate already 1.5×) |
| `> 0` (has prepaid block) | delivery-channel product, qty = actual_hours | delivery-channel product, qty = actual_hours × **1.5** |
| `0` / null | delivery-channel product, qty = actual_hours | `26184`, qty = actual_hours |
| `> 0` | delivery-channel product, qty = actual_hours | delivery-channel product, qty = actual_hours × **1.5** |
"Delivery-channel product" = `1190473` remote, `26118` onsite, `573881` in-shop, `68055` web — match to how work was actually delivered.
**Rationale (Winter, 2026-04-23):** Prepaid blocks debit by QUANTITY, not dollars. To charge time-and-a-half against a prepaid block we bump the quantity to 1.5× on the Onsite product rather than switching to the Emergency product — switching would double-count because the Emergency product has the 1.5× already built into its dollar rate.
**Example — 2 hour emergency onsite job:**
- Non-prepaid customer: one line of 2.0 hrs × `26184` @ $262.50 → $525.00 billed
- Prepaid customer: one line of 3.0 hrs × `26118` @ $175.00 → debits 3 hrs from prepaid block
Winter caught this on #32203 (Desert Auto Tech) 2026-04-23 after a stack of 1hr `26118` + 1hr `26184` for a single hour of emergency work — the $ doubled because the 1.5× was applied twice.
#### Timer Entries (time tracking reference)
| Operation | Method | Endpoint |
|---|---|---|
| Add timer | POST | `/tickets/<id>/timer_entry` |
| Charge timer → line item | POST | `/tickets/<id>/charge_timer_entry` |
| Update timer | PUT | `/tickets/<id>/update_timer_entry` |
| Delete timer | POST | `/tickets/<id>/delete_timer_entry` |
| List timers (on a ticket) | GET | `/tickets/<id>` → `.ticket.ticket_timers` |
Both `POST /timer_entry` and `POST /charge_timer_entry` return FLAT objects — parse `.id` directly. See "Billable Line Items → Default" above for the full response-shape note and duplicate-prevention pattern.
Prepaid blocks debit by quantity not dollars — for emergency prepaid, use the normal delivery-channel product at 1.5× qty, not `26184` (which has 1.5× already in the dollar rate).
#### Invoices
| Operation | Method | Endpoint | Body |
|---|---|---|---|
| List invoices | GET | `/invoices?per_page=25` | — |
| Get invoice | GET | `/invoices/<id>` | — |
| Create from ticket | POST | `/invoices` | `{"ticket_id": N, "customer_id": N, "category": "Standard"}` |
| Delete invoice | DELETE | `/invoices/<id>` | — |
```bash
# Create invoice from ticket — response: {"invoice": {"id": N, "total": "N.N", "ticket_id": N, ...}}
INV_RESP=$(curl -s -X POST "${BASE}/invoices?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @- <<JSON
{"ticket_id": ${ID}, "customer_id": ${CUST_ID}}
JSON
)
INVOICE_ID=$(echo "$INV_RESP" | jq -r '.invoice.id')
**"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.
# Verify line items transferred (note: field is "price" not "price_retail" in invoice line_items)
curl -s "${BASE}/invoices/${INVOICE_ID}?api_key=${API_KEY}" | \
jq '{total: .invoice.total, lines: [.invoice.line_items[] | {item, name, quantity, price}]}'
**Note:** The `POST /invoices` response body does not include `line_items` — do `GET /invoices/{id}` to verify line items transferred correctly.
# Delete invoice response: {"message": "ID: We deleted # N."}
curl -s -X DELETE "${BASE}/invoices/${INV_ID}?api_key=${API_KEY}"
```
POST `/invoices` pulls all current line items from the ticket into the invoice automatically. The POST response includes `.invoice.id` and `.invoice.total` — if either is null, GET `/invoices?customer_id=${CUST_ID}&per_page=5` and find the invoice by `ticket_id` match before taking any other action.
### Display formatting
@@ -708,119 +602,91 @@ 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 + timer entry to the user before posting. Wait for confirmation.**
**ALWAYS read `customer.prepay_hours` before billing ANY work** — not just emergency. Prepaid customers get $0.00 invoices with block deductions; non-prepaid customers get dollar invoices. Knowing this before you start prevents false "zero invoice" panic mid-workflow.
**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.**
**Step 1 — Gather (ask user once, run GETs in parallel)**
When `/syncro bill <number>` 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 / 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
- Warranty / no-charge → product **`1049360` (Labor- Warranty work)**, `billable: true`, qty = actual hours. Do NOT pick a regular labor product with `billable: false` — Syncro silently overrides the flag and generates a billable line. (Verified 2026-05-06 on #32225 — see `feedback_syncro_warranty_product.md`.)
- Otherwise → per `--labor` mapping below, qty = actual hours
5. Fetch `price_retail` live: `GET /products/<product_id>` → `.product.price_retail` — never use the table below for rates, it may be stale
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": <rate>}`.
14. Create invoice: `POST /invoices` with `{"ticket_id": N, "customer_id": N, "category": "Standard"}`
15. Verify invoice: `GET /invoices/{id}` → confirm line items transferred. **For prepaid customers, `.invoice.total` will be $0.00 — this is correct.** The line item name is annotated "- Applied X Prepay Hours" and the block is debited. Confirm by re-fetching `customer.prepay_hours` and checking it dropped by `quantity`. For non-prepaid customers, `.invoice.total` must equal `qty × price_retail`.
16. Update ticket status to `Invoiced`
17. **Post to #bot-alerts** (see "Post to #bot-alerts" below): one line linking the ticket, with the invoice total (note the prepay deduction if the customer is prepaid). Example:
```bash
bash "$CLAUDETOOLS_ROOT/.claude/scripts/post-bot-alert.sh" \
"[SYNCRO] Mike billed #32164 (Jerry Burger) — 1.0h remote, \$150.00 invoiced → https://computerguru.syncromsp.com/tickets/${TICKET_DB_ID}"
```
Ask: minutes to bill + labor type (remote / onsite / emergency / in-shop / warranty). Then fetch:
**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 resolution comment
curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{
"subject": "Resolution",
"body": "...",
"hidden": false,
"do_not_email": false
}
JSON
TICKET=$(curl -s "${BASE}/tickets/${ID}?api_key=${API_KEY}")
CUST_ID=$(echo "$TICKET" | jq -r '.ticket.customer_id')
CUST=$(curl -s "${BASE}/customers/${CUST_ID}?api_key=${API_KEY}")
PREPAY=$(echo "$CUST" | jq -r '.customer.prepay_hours // "0.0"')
# Determine product_id from labor type + prepay, then:
RATE=$(curl -s "${BASE}/products/${PRODUCT_ID}?api_key=${API_KEY}" | jq -r '.product.price_retail')
QTY=$(echo "scale=4; ${MINUTES}/60" | bc)
TOTAL=$(echo "scale=2; ${RATE}*${QTY}" | bc)
```
# 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}" \
**Step 2 — Draft + confirm**
Use Ollama (or draft directly) for comment body and line item description. Show preview:
```
Ticket: #NNNNN — <subject>
Customer: <name> [prepay: X hrs remaining / none]
Labor: <product name> <qty> hrs @ $<rate> = $<total>
Comment: <body>
Description: <line item description>
```
Wait for explicit confirmation before any write.
**Step 3 — Execute (in order, no branching)**
```bash
# 1. Post comment — response: {"comment": {...}}
COMMENT_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{"subject": "Resolution", "body": "<body>", "hidden": false, "do_not_email": false}
JSON
)
COMMENT_ID=$(echo "$COMMENT_RESP" | jq -r '.comment.id')
# STOP if null: GET ticket, check .ticket.comments[] by subject
# 2. Add line item — response is FLAT: {"id": N, ...}
LINE_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
--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
"product_id": ${PRODUCT_ID},
"name": "<product name>",
"description": "<description>",
"quantity": ${QTY},
"price_retail": ${RATE},
"taxable": false
}
JSON
)
TIMER_ID=$(echo "$TIMER_RESP" | jq -r '.id') # response is FLAT — see "response shapes" note above
LINE_ID=$(echo "$LINE_RESP" | jq -r '.id')
# STOP if null: GET ticket, check .ticket.line_items[]
# Step 3: Charge the timercreates the linked line item automatically.
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": ${TIMER_ID}}
JSON
# Step 4: Verify auto-generated line item — price_retail should equal the
# product rate. If it's 0.00, patch with update_line_item before invoicing.
LINE=$(curl -s "${BASE}/tickets/${ID}?api_key=${API_KEY}" | \
jq '.ticket.line_items | map(select(.product_id == 1190473)) | last')
echo "$LINE" | jq '{id, product_id, quantity, price_retail}'
# Step 5: (only if price_retail came in at 0) patch it
LINE_ID=$(echo "$LINE" | jq -r '.id')
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": ${LINE_ID}, "price_retail": 150.00}
JSON
# Step 6: Create invoice
# 3. Create invoice — response: {"invoice": {"id": N, "total": "N.N", ...}}
INV_RESP=$(curl -s -X POST "${BASE}/invoices?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @- <<JSON
{"ticket_id": ${ID}, "customer_id": ${CUST}, "category": "Standard"}
{"ticket_id": ${ID}, "customer_id": ${CUST_ID}}
JSON
)
INVOICE_ID=$(echo "$INV_RESP" | jq -r '.invoice.id')
INVOICE_TOTAL=$(echo "$INV_RESP" | jq -r '.invoice.total')
# If INVOICE_ID is null: GET /invoices?customer_id=${CUST_ID}&per_page=5, find by ticket_id
# Step 7: Verify line items transferred and total is correct
curl -s "${BASE}/invoices/${INVOICE_ID}?api_key=${API_KEY}" | \
jq '{total: .invoice.total, line_items: .invoice.line_items}'
# Step 8: Mark ticket Invoiced
# 4. Mark Invoiced
curl -s -X PUT "${BASE}/tickets/${ID}?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{"status": "Invoiced"}
JSON
# 5. Bot alert
bash "$CLAUDETOOLS_ROOT/.claude/scripts/post-bot-alert.sh" \
"[SYNCRO] Mike billed #<number> (<customer>) — ${QTY}h <labor_type>, \$${INVOICE_TOTAL} → https://computerguru.syncromsp.com/tickets/${ID}"
```
The two heredocs that interpolate `${TIMER_ID}` / `${LINE_ID}` / `${ID}` / `${CUST}` / `${INVOICE_ID}` use unquoted `<<JSON` (allows expansion). The static-payload heredocs use `<<'JSON'` (single-quoted, no expansion) so any `$` inside the body — passwords, regex, jq queries — comes through as a literal. Pick the right form per heredoc.
**Prepaid invoice total will be $0.00 — this is correct.** The line item is annotated "- Applied X Prepay Hours." Confirm the block decremented by re-fetching `customer.prepay_hours`.
`--labor` maps to product IDs: `remote` → 1190473, `onsite` → 26118, `emergency` → 26184, `project` → 9269129, `internal` → 9269124, `travel` → 26117, `website` → 68055
**Heredoc quoting:** heredocs that interpolate shell variables (`${ID}`, `${CUST_ID}`, etc.) use unquoted `<<JSON`. Static-payload heredocs use `<<'JSON'` (single-quoted, suppresses `$` expansion). Pick the right form per heredoc.
**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).
`--labor` → product IDs: `remote` → 1190473, `onsite` → 26118, `emergency` → 26184, `in-shop` → 573881, `warranty` → 1049360, `internal` → 9269124, `travel` → 26117, `website` → 68055
### Error handling
@@ -835,60 +701,55 @@ When closing a ticket (`/syncro close`), offer to create a session log entry in
### Post to #bot-alerts (after every write) — MANDATORY
After ANY successful write, post a one-line summary + a direct link to the affected item
into the Discord `#bot-alerts` channel. This is the team's live activity feed: every ticket
created / updated / closed / commented, every billing run, and every customer created shows
up there with who did it and a clickable link. It runs from a workstation or from the Discord
bot — the helper reads the bot token from the SOPS vault, so it works on any machine.
Post after every successful Syncro write. Never post before the write completes. Never post for read-only operations (list, view, search).
**Scope — write operations only.** Pure reads (`/syncro` list, `ticket <n>` view, `search`,
`customers` search) create nothing and post no alert. Post only after the write call returns
success — never announce an action that did not complete.
**Helper** (soft-fails if Discord is down; never blocks the workflow):
**Invocation:**
```bash
bash "$CLAUDETOOLS_ROOT/.claude/scripts/post-bot-alert.sh" "<message>"
ALERT_OUT=$(bash "$CLAUDETOOLS_ROOT/.claude/scripts/post-bot-alert.sh" "<message>")
echo "$ALERT_OUT"
```
**Message format:** `[SYNCRO] <Tech> <action> <entity> — <short summary> → <link>`
- `<Tech>` is the Syncro user from the Attribution table (the `identity.json` user running
the skill): `mike` → Mike, `howard` → Howard, etc.
- One line. Display the ticket **number** (`#32164`) in the text, but build the link from the
numeric **id** (`.ticket.id`), not the number.
**Success:** script prints `[OK] post-bot-alert: posted to #bot-alerts (message_id=N)` and exits 0. Done.
**Link by entity** (subdomain `computerguru.syncromsp.com`):
**Failure:** script prints `[WARNING] post-bot-alert: <reason>` and exits 0. The script always exits 0 — check the output text, not the exit code. On a warning:
- Surface the warning text to the user ("bot-alert failed: <reason>")
- Do NOT retry
- Do NOT redo any Syncro writes
- The Syncro work is complete; the missed alert is informational only
| Entity affected | Link |
**Message format:** one line — `[SYNCRO] <Tech> <verb> #<number> (<customer>) — <summary> → <link>`
- `<Tech>`: `mike` → Mike, `howard` → Howard (matches `identity.json` user)
- Use ticket **number** (`#32164`) in the text; build the link from the numeric **id**
- One alert per workflow — billing touches ticket + invoice, send one alert linked to the ticket
**Links:**
| Entity | URL |
|---|---|
| Ticket create / update / close / comment | `https://computerguru.syncromsp.com/tickets/<ticket.id>` |
| Invoice — created during billing | `https://computerguru.syncromsp.com/invoices/<invoice.id>` |
| Customer — created | `https://computerguru.syncromsp.com/customers/<customer.id>` |
| Comment / timer / line item on a ticket | link to the parent ticket |
For a billing run that touches both a ticket and an invoice, send ONE alert: link the ticket
and state the invoice total (and prepay deduction if the customer is prepaid).
| Ticket (create / update / close / comment / bill) | `https://computerguru.syncromsp.com/tickets/<ticket.id>` |
| Customer (create) | `https://computerguru.syncromsp.com/customers/<customer.id>` |
**Examples:**
```bash
# Ticket created
bash "$CLAUDETOOLS_ROOT/.claude/scripts/post-bot-alert.sh" \
"[SYNCRO] Howard created ticket #32301 (Desert Auto Tech — \"Server won't boot\") → https://computerguru.syncromsp.com/tickets/12350"
"[SYNCRO] Howard created #32301 (Desert Auto Tech) — Server won't boot → https://computerguru.syncromsp.com/tickets/110736645"
# Success output: [OK] post-bot-alert: posted to #bot-alerts (message_id=1507055781780918404)
# Ticket closed + billed (one alert)
# Billed + invoiced
bash "$CLAUDETOOLS_ROOT/.claude/scripts/post-bot-alert.sh" \
"[SYNCRO] Mike closed + invoiced #32164 (Jerry Burger) — 1.0h remote, \$150.00 → https://computerguru.syncromsp.com/tickets/12345"
"[SYNCRO] Mike billed #32164 (Jerry Burger) — 1.0h remote, \$150.00 → https://computerguru.syncromsp.com/tickets/110169036"
# Prepaid billing (zero-dollar invoice is correct — note the deduction)
# Prepaid billing
bash "$CLAUDETOOLS_ROOT/.claude/scripts/post-bot-alert.sh" \
"[SYNCRO] Mike billed #32203 (Desert Auto Tech) — 1.5h onsite, applied 1.5 prepay hours, \$0.00 → https://computerguru.syncromsp.com/tickets/12300"
"[SYNCRO] Mike billed #32203 (Desert Auto Tech) — 1.5h onsite, applied 1.5 prepay hrs, \$0.00 → https://computerguru.syncromsp.com/tickets/109895882"
# Customer created
# Ticket status updated
bash "$CLAUDETOOLS_ROOT/.claude/scripts/post-bot-alert.sh" \
"[SYNCRO] Mike created customer \"Acme Plumbing\" → https://computerguru.syncromsp.com/customers/67890"
"[SYNCRO] Mike resolved #32271 (Peaceful Spirit Massage) — IKEv2 VPN setup complete → https://computerguru.syncromsp.com/tickets/110169036"
```
The helper escapes the message via `jq`, so quotes and `$` inside the text are safe. If the
bot token or network is unavailable it prints a `[WARNING]` and exits 0 — the alert is
best-effort and must never fail the Syncro write it follows.
The script uses `jq` to build the JSON payload, so quotes and `$` in the message are safe. The script has a 15-second curl timeout and soft-fails on token missing, network error, or non-200 Discord response.

View File

@@ -1,31 +1,18 @@
---
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.
name: Syncro — use add_line_item for billing, not timers
description: Syncro billing uses add_line_item directly. Timer workflow (timer_entry charge_timer_entry) is not used. Overrides previous rule about timers being required.
type: feedback
---
**Rule:** When billing a Syncro ticket, the workflow MUST be:
**Rule:** Bill Syncro tickets with `POST /tickets/{id}/add_line_item` directly. Do NOT use `timer_entry → charge_timer_entry`.
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.
**Why:** Mike confirmed 2026-05-21 that the timer workflow is not used. The previous rule requiring timers was wrong and caused repeated billing failures (wrong product on the timer, product_id silently ignored by charge_timer_entry, etc.).
**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.
- `add_line_item` is the billing path for all work: labor, warranty, internal, hardware.
- Set `product_id`, `quantity` (decimal hours), `price_retail` (fetched live), `name`, `description`, `taxable: false`.
- Do not create timer entries as part of billing.
- Timer endpoints still exist in Syncro but are not part of the ACG billing workflow.
**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.
**Previous rule (SUPERSEDED):** "All work-time billing MUST go through timer_entry → charge_timer_entry." That rule is no longer in effect as of 2026-05-21.