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