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