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:
@@ -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 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 @- <<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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user