diff --git a/.claude/commands/syncro.md b/.claude/commands/syncro.md index 187fb33..6b1947a 100644 --- a/.claude/commands/syncro.md +++ b/.claude/commands/syncro.md @@ -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//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/.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//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
for line breaks. No
    /
  • .", "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 `
    ` for line breaks. `
      `/`
    • ` 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=&per_page=25` | -| Get customer | GET | `/customers/` | -| 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//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 (on a ticket) | GET | `/tickets/` → `.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=&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": } -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 @- </add_line_item` | -| Remove line item | POST | `/tickets//remove_line_item` | -| Update line item | PUT | `/tickets//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 @- <` → `.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//timer_entry` | -| Charge timer → line item | POST | `/tickets//charge_timer_entry` | -| Update timer | PUT | `/tickets//update_timer_entry` | -| Delete timer | POST | `/tickets//delete_timer_entry` | -| List timers (on a ticket) | GET | `/tickets/` → `.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/` | — | -| Create from ticket | POST | `/invoices` | `{"ticket_id": N, "customer_id": N, "category": "Standard"}` | -| Delete invoice | DELETE | `/invoices/` | — | +```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 @- <` 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.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": }`. -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 — +Customer: [prepay: X hrs remaining / none] +Labor: hrs @ $ = $ +Comment: +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": "", "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 @- <", + "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 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" \ - --data-binary @- < () — ${QTY}h , \$${INVOICE_TOTAL} → https://computerguru.syncromsp.com/tickets/${ID}" ``` -The two heredocs that interpolate `${TIMER_ID}` / `${LINE_ID}` / `${ID}` / `${CUST}` / `${INVOICE_ID}` use unquoted `< 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 ` 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" "" +ALERT_OUT=$(bash "$CLAUDETOOLS_ROOT/.claude/scripts/post-bot-alert.sh" "") +echo "$ALERT_OUT" ``` -**Message format:** `[SYNCRO] ` -- `` 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: ` 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: ") +- 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] # () — ` + +- ``: `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/` | -| Invoice — created during billing | `https://computerguru.syncromsp.com/invoices/` | -| Customer — created | `https://computerguru.syncromsp.com/customers/` | -| 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/` | +| Customer (create) | `https://computerguru.syncromsp.com/customers/` | **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. diff --git a/.claude/memory/feedback_syncro_timer_first.md b/.claude/memory/feedback_syncro_timer_first.md index e25b40e..3b5b0ca 100644 --- a/.claude/memory/feedback_syncro_timer_first.md +++ b/.claude/memory/feedback_syncro_timer_first.md @@ -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.