syncro skill: timer-entry-first workflow + heredoc payloads

- Promote timer_entry → charge_timer_entry to default billing path; demote
  bare add_line_item to a clearly-labeled fallback for non-time items only.
  Mike caught the bare-add_line_item bug across 31 tickets on 2026-04-30;
  repeated on 3 tickets 2026-05-01. Time entries are required for Syncro
  reporting (hours per client, tech productivity, prepay burn).
- Replace /tmp/*.json payload pattern with heredoc throughout. /tmp resolves
  to C:\tmp\ in the Write tool but %LOCALAPPDATA%\Temp\ in Git Bash on
  Windows — different real directories. Caused a wrong-comment incident on
  ticket #32225 2026-05-01 (rogue payload from prior session). Heredoc
  avoids the file handoff entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 10:58:20 -07:00
parent 4f4491e7da
commit ec98c6c636
3 changed files with 235 additions and 83 deletions

View File

@@ -26,6 +26,10 @@ Create, update, close, comment on, and bill tickets in Syncro PSA.
## Hard Rules (violations have occurred — no exceptions) ## Hard Rules (violations have occurred — no exceptions)
**All work-time billing MUST go through `timer_entry → charge_timer_entry`.** Bare `add_line_item` for time-bearing work bypasses Syncro's time tracking and breaks reporting (hours per client, tech productivity, prepay burn). Bare `add_line_item` is reserved for non-time items only (hardware, flat-fee services). Even warranty/free work needs a time entry — set `billable: false`. Only cancelled tickets are exempt. Mike caught the bare-`add_line_item` bug across 31 tickets on 2026-04-30; it was repeated on 3 more tickets on 2026-05-01 — see `.claude/memory/feedback_syncro_timer_first.md`.
**JSON payloads to curl: use heredoc with `--data-binary @-`, not `/tmp/*.json` files.** On Windows the Write tool resolves `/tmp/foo.json` to `C:\tmp\foo.json` while Git Bash resolves it to `%LOCALAPPDATA%\Temp\foo.json` — different real directories, so a payload written by Write may not be the file curl reads. Heredoc with `<<'JSON'` (single-quoted to suppress bash variable expansion inside the payload) avoids the file handoff entirely. See `.claude/memory/feedback_tmp_path_windows.md` — caused a wrong-comment incident on ticket #32225 on 2026-05-01 (rogue payload from a prior session).
**Before any POST:** Always show the full payload to the user and wait for explicit confirmation. This applies to tickets, comments, line items, and invoices — including hidden/internal notes. **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. **After any ambiguous POST result** (null fields, jq error, curl error, timeout): Do NOT retry. GET the resource first to confirm whether the action succeeded. Syncro has no idempotency on any endpoint — one POST always creates one record. Duplicate tickets and comments cannot be deleted via API; comments require manual GUI removal.
@@ -86,15 +90,19 @@ fi
**Draft call:** **Draft call:**
```bash ```bash
# Write prompt to temp file to avoid quoting hell # Write prompt to a workspace path both the Write tool and Git Bash agree on
cat > /tmp/ollama_prompt.txt <<'ENDPROMPT' # (do NOT use /tmp on Windows — see Hard Rules: /tmp resolves differently in
# Write vs Git Bash). Use $CLAUDETOOLS_ROOT/.claude/tmp/ or pipe via heredoc.
PROMPT_FILE="$CLAUDETOOLS_ROOT/.claude/tmp/ollama_prompt.txt"
mkdir -p "$(dirname "$PROMPT_FILE")"
cat > "$PROMPT_FILE" <<'ENDPROMPT'
<prompt content here> <prompt content here>
ENDPROMPT ENDPROMPT
if [ -n "$OLLAMA" ]; then if [ -n "$OLLAMA" ]; then
DRAFT=$(py -c " DRAFT=$(PROMPT_FILE="$PROMPT_FILE" py -c "
import urllib.request, json, sys import os, urllib.request, json, sys
prompt = open('/tmp/ollama_prompt.txt').read() prompt = open(os.environ['PROMPT_FILE']).read()
body = json.dumps({ body = json.dumps({
'model': 'qwen3:14b', 'model': 'qwen3:14b',
'messages': [{'role': 'user', 'content': prompt}], 'messages': [{'role': 'user', 'content': prompt}],
@@ -345,15 +353,9 @@ Confirm? (yes/no)
**Call 1 — Create ticket:** **Call 1 — Create ticket:**
```bash ```bash
curl -s -X POST "${BASE}/tickets?api_key=${API_KEY}" \ RESP=$(curl -s -X POST "${BASE}/tickets?api_key=${API_KEY}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d @/tmp/ticket_payload.json --data-binary @- <<'JSON'
# Parse: TICKET_ID=$(... | jq -r '.ticket.id')
# Parse: CUST_ID=$(... | jq -r '.ticket.customer_id')
```
Payload fields (omit null/blank):
```json
{ {
"customer_id": N, "customer_id": N,
"subject": "...", "subject": "...",
@@ -368,26 +370,30 @@ Payload fields (omit null/blank):
"end_at": "ISO8601", "end_at": "ISO8601",
"asset_ids": [N] "asset_ids": [N]
} }
JSON
)
TICKET_ID=$(echo "$RESP" | jq -r '.ticket.id')
CUST_ID=$(echo "$RESP" | jq -r '.ticket.customer_id')
``` ```
Omit null/blank fields from the payload before piping. The `'JSON'` quoting on the heredoc opener is required — it suppresses bash variable and backtick expansion inside, which matters when descriptions contain `$` (passwords, prices, regex, etc.).
**Call 2 — Post initial description as "Initial Issue" comment:** **Call 2 — Post initial description as "Initial Issue" comment:**
```bash ```bash
curl -s -X POST "${BASE}/tickets/${TICKET_ID}/comment?api_key=${API_KEY}" \ curl -s -X POST "${BASE}/tickets/${TICKET_ID}/comment?api_key=${API_KEY}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d @/tmp/comment_payload.json --data-binary @- <<'JSON'
# Parse: .comment.id (NOT .id — see Hard Rules)
```
Payload:
```json
{ {
"subject": "Initial Issue", "subject": "Initial Issue",
"body": "<the full description>", "body": "<the full description>",
"hidden": false, "hidden": false,
"do_not_email": true "do_not_email": true
} }
JSON
# Parse: .comment.id (NOT .id — see Hard Rules)
``` ```
Set `do_not_email: true` if "Do Not Email" was checked; `false` otherwise. Set `do_not_email: true` if "Do Not Email" was checked; `false` otherwise.
**Call 3 — Create appointment (only if start_at provided):** **Call 3 — Create appointment (only if start_at provided):**
@@ -395,11 +401,7 @@ Set `do_not_email: true` if "Do Not Email" was checked; `false` otherwise.
```bash ```bash
curl -s -X POST "${BASE}/appointments?api_key=${API_KEY}" \ curl -s -X POST "${BASE}/appointments?api_key=${API_KEY}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d @/tmp/appt_payload.json --data-binary @- <<'JSON'
```
Payload:
```json
{ {
"ticket_id": N, "ticket_id": N,
"customer_id": N, "customer_id": N,
@@ -408,11 +410,12 @@ Payload:
"end_at": "ISO8601", "end_at": "ISO8601",
"location": "" "location": ""
} }
JSON
``` ```
Note: "Do Not Invite" (suppress calendar invite email) is not API-controllable. Tell the user to toggle it in the Syncro GUI if needed. Note: "Do Not Invite" (suppress calendar invite email) is not API-controllable. Tell the user to toggle it in the Syncro GUI if needed.
**Always use temp files for payloads** — never inline JSON in curl -d with ticket data (special characters, newlines in description will break the shell). **Payload handoff: prefer heredoc with `--data-binary @-` and `<<'JSON'` quoting** — never use `/tmp/<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.
#### Comments #### Comments
@@ -437,7 +440,15 @@ Note: "Do Not Invite" (suppress calendar invite email) is not API-controllable.
# Correct pattern — always check .comment.id # Correct pattern — always check .comment.id
RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \ RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d @/tmp/payload.json) --data-binary @- <<'JSON'
{
"subject": "Update",
"body": "...",
"hidden": false,
"do_not_email": false
}
JSON
)
echo "$RESP" | jq '{id: .comment.id, subject: .comment.subject, created_at: .comment.created_at}' echo "$RESP" | jq '{id: .comment.id, subject: .comment.subject, created_at: .comment.created_at}'
``` ```
@@ -463,9 +474,73 @@ curl -s "${BASE}/tickets/${ID}?api_key=${API_KEY}" | \
#### Billable Line Items #### Billable Line Items
Two verified ways to add billable time. Both produce ticket line items that transfer to invoices. There are two verified mechanisms for putting a billable charge on a ticket. They are NOT interchangeable.
**Option A — Direct line item (simpler):** **Default — `timer_entry → charge_timer_entry` (REQUIRED for any work that has a time component):**
This is the documented billing path. It records hours into Syncro's time-tracking system AND creates the line item, so reporting (hours per client, tech productivity, prepay burn rate, average resolution time) stays accurate. Bare `add_line_item` skips the time-tracking system and leaves Syncro showing `00:00:00` worked even though the invoice posts correctly — which is what produced the 31-ticket gap on 2026-04-30 and three more on 2026-05-01.
| Operation | Method | Endpoint |
|---|---|---|
| Create timer | POST | `/tickets/<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 | GET | `/ticket_timers?ticket_id=<id>` |
```bash
# 1. Create timer entry — records hours in Syncro's time-tracking system.
# For warranty / no-charge work, set "billable": false (time still records).
curl -s -X POST "${BASE}/tickets/${ID}/timer_entry?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{
"start_at": "ISO8601",
"end_at": "ISO8601",
"notes": "...",
"billable": true,
"product_id": 1190473
}
JSON
# 2. Charge the timer — sets recorded:true and auto-generates a linked line
# item with the timer's product_id and computed quantity (hours).
curl -s -X POST "${BASE}/tickets/${ID}/charge_timer_entry?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{"timer_entry_id": N}
JSON
# 3. Verify the auto-generated line item picked up the rate. Syncro sometimes
# creates the line at $0.00 even though the product has a price_retail set
# (same root cause as the bare add_line_item bug). If price_retail is 0,
# patch it via update_line_item.
curl -s "${BASE}/tickets/${ID}?api_key=${API_KEY}" | jq '.ticket.line_items[] | {id, product_id, quantity, price_retail}'
# 4. (only if needed) Patch a $0 line:
curl -s -X PUT "${BASE}/tickets/${ID}/update_line_item?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{
"ticket_line_item_id": NNN,
"price_retail": 150.00
}
JSON
# Delete timer (rarely needed):
curl -s -X POST "${BASE}/tickets/${ID}/delete_timer_entry?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{"timer_entry_id": N}
JSON
# Returns: {"success": true}
```
`charge_timer_entry` produces the line item; you do NOT call `add_line_item` afterward — that would double-bill.
**Fallback — bare `add_line_item` (NON-TIME items only):**
Use this ONLY when there is genuinely no labor time component to bill — selling a hardware product, a flat-fee service, a recurring subscription line. For ANY work with a time component, including warranty/free work (where time should record at `billable: false`), use the timer path above. Cancelled tickets are the only exemption from creating a time entry.
| Operation | Method | Endpoint | | Operation | Method | Endpoint |
|---|---|---| |---|---|---|
@@ -474,45 +549,37 @@ Two verified ways to add billable time. Both produce ticket line items that tran
| Update line item | PUT | `/tickets/<id>/update_line_item` | | Update line item | PUT | `/tickets/<id>/update_line_item` |
```bash ```bash
# Add (always include price_retail — API does not auto-apply product rates) # Non-time line item (hardware, flat-fee). Always include price_retail —
# the API does not auto-apply product rates.
curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \ curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"product_id": 1190473, "quantity": 0.5, "price_retail": 150.00, "name": "Labor - Remote Business", "description": "Work description", "taxable": false}' --data-binary @- <<'JSON'
{
"product_id": 1190473,
"quantity": 1,
"price_retail": 150.00,
"name": "Hardware - Replacement Drive",
"description": "Item description",
"taxable": true
}
JSON
# Remove # Remove
curl -s -X POST "${BASE}/tickets/${ID}/remove_line_item?api_key=${API_KEY}" \ curl -s -X POST "${BASE}/tickets/${ID}/remove_line_item?api_key=${API_KEY}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"ticket_line_item_id": 12345}' --data-binary @- <<'JSON'
{"ticket_line_item_id": 12345}
JSON
# Returns: {"success": true, "message": ""} # Returns: {"success": true, "message": ""}
``` ```
**Option B — Timer then charge (for time-tracking workflows):** **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):
```bash
# 1. Create timer entry
curl -s -X POST "${BASE}/tickets/${ID}/timer_entry?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
-d '{"start_at": "ISO8601", "end_at": "ISO8601", "notes": "...", "billable": true, "product_id": 1190473}'
# 2. Charge timer — sets recorded:true and creates linked line item
curl -s -X POST "${BASE}/tickets/${ID}/charge_timer_entry?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
-d '{"timer_entry_id": N}'
# Delete timer (if needed)
curl -s -X POST "${BASE}/tickets/${ID}/delete_timer_entry?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
-d '{"timer_entry_id": N}'
# Returns: {"success": true}
```
**add_line_item required fields:**
- `name` — required (422 if missing) - `name` — required (422 if missing)
- `description` — required (422 if missing) - `description` — required (422 if missing)
- `product_id` — labor product ID (see table below) - `product_id` — product ID (labor product table below for time-based work, or any other product for hardware / flat-fee items)
- `quantity` — decimal hours (0.5 = 30 min, 1.0 = 1 hour) - `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. - `price_retail` — **must always be set explicitly**; `price`, `retail_price`, `rate`, `price_cents` all silently ignored and leave line at $0.00. Syncro does NOT auto-calculate rates via API even though it does in the web UI. Omitting it leaves the line at $0.00 and the invoice generates at $0 (verified 2026-04-23 on #32203). Always pass the rate from the table below.
- `taxable: false` — **always set explicitly**; labor products default to no-tax in GUI but the API applies tax if this is omitted - `taxable` — **always set explicitly**; labor products default to no-tax in GUI but the API applies tax if this is omitted. Use `false` for labor, `true` for taxable hardware.
**Do NOT remove ticket line items after invoicing.** Leave them on the ticket — the "Add/View Charges" button and billing verification by techs depends on seeing line items there. **Do NOT remove ticket line items after invoicing.** Leave them on the ticket — the "Add/View Charges" button and billing verification by techs depends on seeing line items there.
@@ -566,7 +633,7 @@ Winter caught this on #32203 (Desert Auto Tech) 2026-04-23 after a stack of 1hr
| Create from ticket | POST | `/invoices` | `{"ticket_id": N, "customer_id": N, "category": "Standard"}` | | Create from ticket | POST | `/invoices` | `{"ticket_id": N, "customer_id": N, "category": "Standard"}` |
| Delete invoice | DELETE | `/invoices/<id>` | — | | Delete invoice | DELETE | `/invoices/<id>` | — |
**"Make Invoice" flow:** `POST /invoices` pulls all `add_line_item` entries from the ticket into the invoice. Timer entries are NOT included. **"Make Invoice" flow:** `POST /invoices` pulls all line items currently on the ticket into the invoice. Line items are produced by `charge_timer_entry` (the default path for time-based work) or by bare `add_line_item` (the fallback path for non-time items). A bare timer entry that has not been charged is NOT pulled in — the timer must be converted to a line item via `charge_timer_entry` first.
**Note:** The `POST /invoices` response body does not include `line_items` — do `GET /invoices/{id}` to verify line items transferred correctly. **Note:** The `POST /invoices` response body does not include `line_items` — do `GET /invoices/{id}` to verify line items transferred correctly.
@@ -591,60 +658,113 @@ When showing ticket detail, include:
### Billing workflow ### Billing workflow
**ALWAYS ask the user for minutes and labor type before logging any time. Never assume a default.** **ALWAYS ask the user for minutes and labor type before logging any time. Never assume a default.**
**ALWAYS show a preview of the comment + line items to the user before posting. Wait for confirmation.** **ALWAYS show a preview of the comment + timer entry to the user before posting. Wait for confirmation.**
**ALWAYS read `customer.prepay_hours` before choosing the labor product for emergency work.** **ALWAYS read `customer.prepay_hours` before choosing the labor product for emergency work.**
**ALWAYS bill via `timer_entry → charge_timer_entry`. Bare `add_line_item` for time-based work bypasses Syncro's time-tracking system and is forbidden — see Hard Rules.**
When `/syncro bill <number>` is called: When `/syncro bill <number>` is called:
1. `GET /tickets/{id}` for ticket detail, then `GET /customers/{customer_id}` to read `prepay_hours` 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` 2. Check Ollama availability (see "Ollama drafting" above) — do this once, reuse `$OLLAMA`
3. Ask: "How many minutes should I bill, and what labor type? (remote / onsite / emergency / project / internal)" 3. Ask: "How many minutes should I bill, and what labor type? (remote / onsite / emergency / project / internal / warranty)"
4. Decide product + quantity using the emergency-branching table above: 4. Decide product + quantity using the emergency-branching table above:
- Non-prepaid + emergency → product `26184`, qty = actual hours - Non-prepaid + emergency → product `26184`, qty = actual hours
- Prepaid + emergency → product `26118`, qty = actual hours × 1.5 - Prepaid + emergency → product `26118`, qty = actual hours × 1.5
- Warranty / no-charge → use the closest labor product (e.g. `1190473` remote, `26118` onsite) with `billable: false` on the timer; qty = actual hours
- Otherwise → per `--labor` mapping below, qty = actual hours - Otherwise → per `--labor` mapping below, qty = actual hours
5. Look up `price_retail` from the local rate table (do NOT fetch live — rates are baked in) 5. Look up `price_retail` from the local rate table (do NOT fetch live — rates are baked in)
6. Send billing draft prompt to Ollama (or draft directly if `$OLLAMA` is empty) — see prompt template above 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. Run Claude review checklist on the draft output 7. Send billing draft prompt to Ollama (or draft directly if `$OLLAMA` is empty) — see prompt template above
8. Present preview to user: product, quantity, rate, computed total, comment body, line item description. Wait for confirmation. 8. Run Claude review checklist on the draft output
9. Post comment: `POST /tickets/{id}/comment` 9. Present preview to user: product, quantity, rate, computed total, comment body, timer notes / line item description. Wait for confirmation.
10. Add billable line item: `POST /tickets/{id}/add_line_item` with `product_id`, `quantity`, `price_retail`, `name`, `description`, `taxable: false` 10. Post resolution comment: `POST /tickets/{id}/comment`
11. Create invoice: `POST /invoices` with `{"ticket_id": N, "customer_id": N, "category": "Standard"}` 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. Verify invoice: `GET /invoices/{id}` → confirm `.invoice.total` matches `qty × price_retail` 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. Update ticket status to `Invoiced` 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 `.invoice.total` matches `qty × price_retail`. (For prepaid customers, the line totals against the prepay block — `.invoice.total` will reflect any non-prepaid items only.)
16. Update ticket status to `Invoiced`
**If `.invoice.total` comes back $0.00** (line items went in with null price): `PUT /tickets/{id}/update_line_item` with `price_retail` on each item, then `DELETE /invoices/{bad_id}` and re-POST `/invoices`. Recovery verified on #32203 (2026-04-23). **If `.invoice.total` comes back $0.00** (auto-generated line item went in with null price and you missed step 13): `PUT /tickets/{id}/update_line_item` with `price_retail` on each item, then `DELETE /invoices/{bad_id}` and re-POST `/invoices`. Recovery verified on #32203 (2026-04-23).
**Correct pattern:** **Correct pattern:**
```bash ```bash
# Step 1: Post comment # Step 1: Post resolution comment
curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \ curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"subject": "Resolution", "body": "...", "hidden": false, "do_not_email": false}' --data-binary @- <<'JSON'
{
"subject": "Resolution",
"body": "...",
"hidden": false,
"do_not_email": false
}
JSON
# Step 2: Add billable line item (convert minutes to decimal hours) # Step 2: Create timer entry — records hours in Syncro's time-tracking system.
# 60 min = 1.0, 30 min = 0.5, 45 min = 0.75, etc. # Convert minutes to decimal hours (60 min = 1.0, 30 min = 0.5, 45 min = 0.75).
# Always include price_retail — Syncro does NOT auto-apply rates via API # Set start_at/end_at so end - start equals the billed duration.
curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \ # For warranty / no-charge work, set "billable": false (time still records).
TIMER_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/timer_entry?api_key=${API_KEY}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"product_id": 1190473, "quantity": 1.0, "price_retail": 150.00, "name": "Labor - Remote Business", "description": "...", "taxable": false}' --data-binary @- <<'JSON'
{
"start_at": "2026-05-01T13:00:00-07:00",
"end_at": "2026-05-01T14:00:00-07:00",
"notes": "Resolved customer issue — see ticket comment for detail.",
"billable": true,
"product_id": 1190473
}
JSON
)
TIMER_ID=$(echo "$TIMER_RESP" | jq -r '.timer.id // .timer_entry.id')
# Step 3: Create invoice # Step 3: Charge the timer — creates the linked line item automatically.
curl -s -X POST "${BASE}/invoices?api_key=${API_KEY}" \ curl -s -X POST "${BASE}/tickets/${ID}/charge_timer_entry?api_key=${API_KEY}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"ticket_id": '"${ID}"', "customer_id": '"${CUST}"', "category": "Standard"}' --data-binary @- <<JSON
{"timer_entry_id": ${TIMER_ID}}
JSON
# Step 4: Verify line items transferred # Step 4: Verify auto-generated line item — price_retail should equal the
curl -s "${BASE}/invoices/${INVOICE_ID}?api_key=${API_KEY}" | jq '.invoice.line_items' # 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: Mark ticket Invoiced # 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
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"}
JSON
)
INVOICE_ID=$(echo "$INV_RESP" | jq -r '.invoice.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
curl -s -X PUT "${BASE}/tickets/${ID}?api_key=${API_KEY}" \ curl -s -X PUT "${BASE}/tickets/${ID}?api_key=${API_KEY}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"status": "Invoiced"}' --data-binary @- <<'JSON'
{"status": "Invoiced"}
JSON
``` ```
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.
`--labor` maps to product IDs: `remote` → 1190473, `onsite` → 26118, `emergency` → 26184, `project` → 9269129, `internal` → 9269124, `travel` → 26117, `website` → 68055 `--labor` maps to product IDs: `remote` → 1190473, `onsite` → 26118, `emergency` → 26184, `project` → 9269129, `internal` → 9269124, `travel` → 26117, `website` → 68055
**Override:** `emergency` becomes `26118` with `quantity × 1.5` when the customer has `prepay_hours > 0`. See the Emergency billing branching table above. **Override:** `emergency` becomes `26118` with `quantity × 1.5` when the customer has `prepay_hours > 0`. See the Emergency billing branching table above. The override applies to the timer's `product_id` field and the timer's interval (set `end_at start_at` to `actual_hours × 1.5` so the auto-generated line gets the right quantity).
### Error handling ### Error handling

View File

@@ -31,6 +31,7 @@
- [1Password — always use service token](feedback_1password_service_token.md) — Source OP_SERVICE_ACCOUNT_TOKEN from SOPS for every `op` call. Desktop-app integration prompts are unacceptable in agent flows. - [1Password — always use service token](feedback_1password_service_token.md) — Source OP_SERVICE_ACCOUNT_TOKEN from SOPS for every `op` call. Desktop-app integration prompts are unacceptable in agent flows.
- [/tmp path mismatch on Windows](feedback_tmp_path_windows.md) — Write tool and Git Bash resolve `/tmp` to DIFFERENT real dirs. Use heredoc or workspace path for JSON payloads handed to curl. Caused wrong-comment incident on Syncro #32225. - [/tmp path mismatch on Windows](feedback_tmp_path_windows.md) — Write tool and Git Bash resolve `/tmp` to DIFFERENT real dirs. Use heredoc or workspace path for JSON payloads handed to curl. Caused wrong-comment incident on Syncro #32225.
- [Syncro — never set contact on Cascades tickets](feedback_syncro_cascades_contact.md) — Cascades tickets must have `contact_id` blank; Syncro routes to the correct email distribution that way. Setting contact (often defaults to Meredith) overrides and breaks notifications. Never include the contact field in create or edit payloads for Cascades. - [Syncro — never set contact on Cascades tickets](feedback_syncro_cascades_contact.md) — Cascades tickets must have `contact_id` blank; Syncro routes to the correct email distribution that way. Setting contact (often defaults to Meredith) overrides and breaks notifications. Never include the contact field in create or edit payloads for Cascades.
- [Syncro — log time entries first, never bare add_line_item](feedback_syncro_timer_first.md) — All Syncro work-time billing MUST go through `timer_entry → charge_timer_entry`. Bare `add_line_item` leaves Syncro time tracking at 00:00:00 and breaks reporting. Mike caught this on 2026-04-30 across 31 tickets; I repeated the bug on 2026-05-01 across 3 more.
## Machine ## Machine
- [ACG-5070 Workstation Setup](reference_workstation_setup.md) - Windows 11 Pro clean install 2026-03-30, replaced CachyOS. All tools installed. - [ACG-5070 Workstation Setup](reference_workstation_setup.md) - Windows 11 Pro clean install 2026-03-30, replaced CachyOS. All tools installed.

View File

@@ -0,0 +1,31 @@
---
name: Syncro — log time entries first, never bare add_line_item
description: All Syncro tickets must have a Syncro time entry recorded for any work done. Use timer_entry + charge_timer_entry to bill, NOT bare add_line_item. Bare add_line_item leaves Syncro time tracking at 00:00:00 and breaks reporting (hours per client, tech productivity, prepay burn rate). This applies even to warranty/free work; only cancelled tickets are exempt.
type: feedback
---
**Rule:** When billing a Syncro ticket, the workflow MUST be:
1. Do the work.
2. POST `/tickets/{id}/timer_entry` with `start_at`, `end_at`, `billable`, `product_id`, `notes`. This records hours in Syncro's time-tracking system.
3. POST `/tickets/{id}/charge_timer_entry` with `{"timer_entry_id": N}` to convert the timer into a billable line item on the ticket.
4. POST `/invoices` to roll the line item onto a customer invoice.
5. PUT ticket status as needed.
**Why:** Syncro's reporting (hours per client, technician productivity, average resolution time, prepay burn rates) is built on the **time-entries** table, not on invoice line items. If we use bare `add_line_item` and type hours into the description ("Applied 1.5 Prepay Hours"), the invoice posts but Syncro's time tracking shows `00:00:00`. We lose all reporting visibility on actual work performed.
**How to apply:**
- **Default billing path:** `timer_entry → charge_timer_entry → invoice`. Always.
- **Bare `add_line_item` is NOT a default option.** Only acceptable when there is genuinely no time component to bill — e.g. selling a hardware product or a flat-fee service with zero labor. For any work-time billing, use the timer path.
- **Even warranty/free work needs a time entry.** Set `billable: false` (or appropriate type) on the timer entry. Time still records, just doesn't generate a paid line item.
- **Only cancelled tickets are exempt** from time entries.
**Real-world incident — 2026-04-30:** Mike audited 31 closed tickets and found ALL 31 had `00:00:00` in Syncro time tracking. 29 had proper invoices with revenue captured correctly, but the underlying time data was bypassed entirely. Examples: #32156 (Cascades) "Applied 8.0 Prepay Hours" — should have been an 8.0 hr time entry. #32218 (Instrumental) "Applied 1.5 Prepay Hours" — should have been a 1.5 hr time entry.
**Repeat incident — 2026-05-01:** I (Claude, Howard's session) billed three tickets the same broken way (#32225 Sombra $525, #32229 Mineralogical Record $262.50, #32214 Cascades $0 prepaid). Winter retroactively added time entries to fix them. The skill examples need to be updated to make timer-first the default, and that's tracked in the syncro skill rewrite work.
**Where the fix needs to land:**
- `.claude/commands/syncro.md` — promote the timer-entry workflow to be the documented default. Demote `add_line_item` to a clearly-labeled fallback for non-time work only. Every example in the "Billing workflow" section should use the timer path.
**Skill author note:** Currently the skill presents both patterns as Option A (simpler — add_line_item) and Option B (timer + charge). That framing is wrong. Option B is the only correct path for time-bearing work; Option A is a fallback at best.