Files
claudetools/.claude/commands/syncro.md
Mike Swanson fd12ba247f syncro skill: document appointment move/edit — PUT /appointments/{id} verified
Added /syncro move-appointment to usage table; added Appointments CRUD section
to endpoints reference documenting GET/PUT/DELETE with verified move workflow
(verified 2026-04-24).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 20:13:20 -07:00

659 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# /syncro — Syncro PSA ticket management
Create, update, close, comment on, and bill tickets in Syncro PSA.
## Usage
```
/syncro Show open tickets summary
/syncro ticket <number> View ticket details + comments
/syncro create <customer> <subject> Create new ticket
/syncro update <number> <status> Update ticket status
/syncro close <number> Close/resolve a ticket
/syncro comment <number> <text> Add a comment to a ticket
/syncro bill <number> Add billable time and create invoice
/syncro search <query> Search tickets by subject/customer
/syncro customers <query> Search customers
/syncro move-appointment <customer> Find and reschedule an existing appointment
```
## API Configuration
**Base URL:** `https://computerguru.syncromsp.com/api/v1`
**API Key:** per-user tokens in SOPS vault — see "Get API key" below
**Rate limit:** 180 requests/minute per IP
**Docs:** https://api-docs.syncromsp.com/
## Hard Rules (violations have occurred — no exceptions)
**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.
**Ticket response shape:** `{"ticket": {...}}` — always use `.ticket.id`, never `.id`. The flat-object jq pattern silently returns nulls and looks like failure when it isn't.
**Billing:** Always ask for minutes and labor type before adding any line item. Never assume a default.
**Emergency/after-hours billing — check prepaid first:** Before adding a `26184` (Emergency) line item, `GET /customers/<id>` and read `prepay_hours`. If `prepay_hours > 0`, the customer has a prepaid block — bill `26118` (Onsite) at `quantity × 1.5` instead (prepaid debits by quantity, not by dollars). Never stack `26118` + `26184` for the same hours — the Emergency product rate already has the 1.5× multiplier baked in. Verified 2026-04-23 on ticket #32203 (Desert Auto Tech) after Winter caught the bug.
**Line-item `price_retail` MUST be set explicitly:** Earlier guidance to "omit `price_retail` and let Syncro auto-calc from the product rate" was wrong — the rate does NOT populate automatically. Fetch it with `GET /products/<id>``.product.price_retail` and pass it on `add_line_item`. Omitting it leaves the line at $0.00 and the invoice posts at $0.00 (verified 2026-04-23 on #32203).
## Implementation
When invoked, use the Syncro REST API via `curl`. All requests include `?api_key=<key>` as query parameter (NOT in header — Syncro uses query param auth).
### Attribution rule (CRITICAL)
Every Syncro API call is attributed to the **owner of the API key**. Comments, line items, timer entries, and invoices created by the API are logged as the API user — regardless of who is running the command. So the skill MUST use a per-user API key that matches the actual tech running it, or comments will be misattributed.
| identity.json user | Syncro user | user_id |
|---|---|---|
| `mike` | Michael Swanson | 1735 |
| `howard` | Howard Enos | 1750 |
Keys are baked into the skill below. To add a new user: generate a token in Syncro → Admin → API Tokens, add a case to the key-select block, and store a backup copy in the vault at `msp-tools/syncro-<user>.sops.yaml`.
### Get API key
```bash
BASE="https://computerguru.syncromsp.com/api/v1"
# Per-user keys — actions in Syncro are attributed to the key owner
USER_ID=$(jq -r '.user // empty' "$CLAUDETOOLS_ROOT/.claude/identity.json")
case "$USER_ID" in
mike) API_KEY="T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3" ;;
howard) API_KEY="Tde5174a6e9e312d14-02fd5bfe0f0ee40c87d027507c680e18" ;;
*) echo "[ERROR] Unknown user '$USER_ID' in identity.json — cannot select Syncro API key" >&2; exit 1 ;;
esac
```
### Ollama drafting
Ollama handles prose drafting for write operations. Claude reviews the output against the hard rules below, then presents a preview. User confirms. Claude executes.
**Availability check** — run once at the start of any write operation, reuse `$OLLAMA` for the rest of the session:
```bash
if curl -s -m 2 http://localhost:11434/api/tags >/dev/null 2>&1; then
OLLAMA="http://localhost:11434"
elif curl -s -m 3 http://100.92.127.64:11434/api/tags >/dev/null 2>&1; then
OLLAMA="http://100.92.127.64:11434"
else
OLLAMA="" # fallback: Claude drafts directly
fi
```
**Draft call:**
```bash
# Write prompt to temp file to avoid quoting hell
cat > /tmp/ollama_prompt.txt <<'ENDPROMPT'
<prompt content here>
ENDPROMPT
if [ -n "$OLLAMA" ]; then
DRAFT=$(py -c "
import urllib.request, json, sys
prompt = open('/tmp/ollama_prompt.txt').read()
body = json.dumps({
'model': 'qwen3:14b',
'messages': [{'role': 'user', 'content': prompt}],
'stream': False,
'think': False
}).encode()
res = json.loads(urllib.request.urlopen(
urllib.request.Request('$OLLAMA/api/chat', body), timeout=60
).read())
print(res['message']['content'])
")
else
echo "[INFO] Ollama unavailable — Claude will draft directly."
DRAFT=""
fi
```
**When to use Ollama:**
- Comment body drafting (`/syncro comment`, `/syncro close`, billing resolution notes)
- Billing `description` field (line item billing narrative)
- Ticket initial description during `/syncro create`
**When NOT to use Ollama:**
- JSON field selection (product_id, quantity, price_retail) — Claude owns this using the local rate table and rules
- Read operations (GET)
- Auth, credential, or security decisions
#### Billing draft prompt template
```
You are a Syncro PSA billing assistant. Draft a resolution comment and billing description.
TICKET #<id>: <subject>
CUSTOMER: <customer_name>
TECH: <tech_name>
WORK DONE: <user description of work>
LABOR: <product_name> — <minutes> min (<quantity> hrs) @ $<price_retail>/hr = $<total>
Rules:
- comment_body must use <br> for line breaks. Do NOT use <ul> or <li> — they do not render.
- Keep it professional and factual. No filler phrases.
- line_item_description is one plain-text line, billing-facing.
Return ONLY valid JSON, no prose before or after:
{
"comment_subject": "Resolution",
"comment_body": "<HTML with <br> line breaks>",
"line_item_description": "<one line plain text>",
"preview": "<2-3 sentence plain-text summary for tech review>"
}
```
#### Comment draft prompt template
```
You are a Syncro PSA tech assistant. Draft a ticket comment.
TICKET #<id>: <subject>
CUSTOMER: <customer_name>
NOTE: <user's note or description>
VISIBILITY: <"Internal only" | "Customer-visible">
Rules:
- Use <br> for line breaks. Do NOT use <ul> or <li>.
- Professional and factual. No filler.
Return ONLY valid JSON:
{
"subject": "Update",
"body": "<HTML with <br> line breaks>",
"preview": "<plain text for tech review>"
}
```
#### Claude review checklist (always run before presenting to user)
Whether the draft came from Ollama or Claude wrote it directly:
1. `price_retail` matches the local rate table for the selected `product_id`
2. `quantity` = minutes ÷ 60 — verify the arithmetic (e.g. 45 min = 0.75, not 0.77)
3. Computed total = `price_retail × quantity` — matches what was communicated to user
4. If labor_type is `emergency` and `prepay_hours > 0`: product must be `26118`, qty must be actual_hours × 1.5
5. `comment_body` uses `<br>`, not `<ul>/<li>`
6. No internal notes or credential data in a customer-visible comment body
If a check fails: correct it and note the fix in the preview so the user can see what changed.
#### Fallback behavior
If `OLLAMA` is empty (neither endpoint reachable): Claude drafts the comment body and billing description directly from the same variables. All other logic — review checklist, confirmation, execution — is identical. Announce `[INFO] Ollama unavailable — drafting directly.`
---
### Adding a per-user key
1. User logs into Syncro → Admin → API Tokens → New (`/api_tokens/new`)
2. Type: Integration API Token (or Custom with all standard scopes: asset/customer/ticket/invoice/payment read+write+delete, worksheet add+manage+delete, chat + script.execute)
3. Copy the token once (Syncro only shows it on creation)
4. Encrypt to vault:
```bash
cat > $VAULT_ROOT/msp-tools/syncro-<user>.sops.yaml <<YAML
kind: api-key
name: Syncro (<Full Name>)
subdomain: computerguru
api-base-url: https://computerguru.syncromsp.com/api/v1
api-docs: https://api-docs.syncromsp.com/
status: active
owner: <user>
syncro_user_id: <id>
tags: [msp-tools, per-user]
credentials:
credential: <TOKEN>
notes: Per-user Syncro API token for <Full Name>. Created YYYY-MM-DD.
YAML
# MUST run from vault root so sops picks up .sops.yaml
(cd "$VAULT_ROOT" && sops --encrypt --in-place "msp-tools/syncro-<user>.sops.yaml")
```
5. Commit + push vault repo.
### Endpoints reference
#### Tickets
| Operation | Method | Endpoint | Body |
|---|---|---|---|
| List tickets | GET | `/tickets?status=<status>&per_page=25` | — |
| Get ticket | GET | `/tickets/<id>` | — |
| Create ticket | POST | `/tickets` | see full create workflow below |
| Update ticket | PUT | `/tickets/<id>` | `{"status": "In Progress", "priority": "..."}` |
| Delete ticket | DELETE | `/tickets/<id>` | — |
**Ticket statuses:** `New`, `In Progress`, `Waiting on Customer`, `Waiting on Vendor`, `Scheduled`, `Resolved`, `Invoiced`, `Closed`
**Priority format** (number-prefixed string): `"1 High"`, `"2 Normal"`, `"3 Low"`, `"4 Urgent"`
Default: `"2 Normal"`. Use `"4 Urgent"` for emergency/after-hours.
**Problem types (Issue Type dropdown — use closest match, else "Not determined"):**
`API`, `Email`, `Emergency Service`, `File Services / Permissions`, `Hardware`, `Maintenance`,
`New User / M365 Account Creation`, `New User / Workstation Deployment`, `Not determined`,
`Onsite`, `Other`, `Phone/VOIP`, `Remote`, `Security`, `Server Migration`, `Service Request`,
`Software`, `Website`
**Appointment types:**
| Name | ID | location_type |
|---|---|---|
| In Shop | 4321 | shop |
| Onsite | 4322 | customer |
| Phone Call | 4323 | pre_defined |
| Reminder | 193053 | manual_entry |
| Remote | 59289 | pre_defined |
**Tech user IDs:** Mike = 1735, Howard = 1750, Winter = 1737, Rob = 1760
#### Appointments
| Operation | Method | Endpoint | Notes |
|---|---|---|---|
| List (today) | GET | `/appointments?start_at=YYYY-MM-DD` | Filter by date; use `.summary` to match customer |
| Get | GET | `/appointments/<id>` | Returns `{"appointment": {...}}` |
| Create | POST | `/appointments` | Used in ticket creation flow (Call 3) |
| Move / edit | PUT | `/appointments/<id>` | Verified 2026-04-24 — updates `start_at`/`end_at` |
| Delete | DELETE | `/appointments/<id>` | Not yet verified |
**Finding an appointment by customer:** `GET /appointments?start_at=<date>` returns all appointments — filter client-side with `select(.summary | test("customer name"; "i"))` or `select(.ticket.customer_id == N)`. The `customer_id` query param does not filter correctly.
**Move workflow:**
1. `GET /appointments?start_at=<date>` — find appointment ID
2. Confirm new date/time with user
3. `PUT /appointments/<id>` with `{"start_at": "ISO8601", "end_at": "ISO8601"}`
4. Verify response: `.appointment.start_at` matches intended time
**Response shape:** `{"appointment": {...}}` — parse as `.appointment.id`, `.appointment.start_at`, etc.
---
### Ticket creation workflow (full — 3 API calls)
Ticket creation in Syncro maps to three separate API calls. Gather all inputs first, show a full preview, wait for confirmation, then execute in order.
#### Step 1 — Gather inputs
Collect in one pass (do not ask field by field):
| # | Field | Notes |
|---|---|---|
| 1 | **Subject** | Brief title: reason for the ticket |
| 2 | **Issue Type** (`problem_type`) | From dropdown above; "Not determined" if unclear |
| 3 | **Priority** | "2 Normal" default; "4 Urgent" for emergencies |
| 4 | **Description** | Expanded detail — becomes the "Initial Issue" comment body |
| 5 | **Do Not Email** | Suppress customer notification on ticket create? (yes for internal/reminder tickets) |
| 6 | **Due Date** | ISO date |
| 7 | **Assigned Tech** | Who owns the ticket |
| 8 | **Contact** | Look up from `GET /customers/{id}` → `.contacts[]`; show list, ask user to pick |
| 9 | **Address/Site** | `address_id` — also comes from customer contacts with address data |
| 10 | **Appointment Type** | From table above; omit section if no appointment needed |
| 11 | **Location** | Free text; usually blank unless onsite at non-primary address |
| 12 | **Start Time** | ISO8601 datetime; omit if no scheduled appointment |
| 13 | **End Time** | Default: start + 90 minutes |
| 14 | **Appointment Owner** | Usually same as assigned tech; noted for calendar attribution (not a separate API field — inherits from ticket `user_id`) |
| 15 | **Do Not Invite** | If not onsite, suppress calendar invite — note: not directly controllable via API; inform user if they need this set manually |
| 16 | **Asset** | Search `GET /customer_assets?customer_id=N&query=<name>` if a specific device is involved |
#### Step 2 — Look up customer data
Before showing the preview, fetch what you need:
```bash
# Get contacts and addresses
curl -s "${BASE}/customers/${CUST_ID}?api_key=${API_KEY}" | jq '{contacts: [.customer.contacts[] | {id, name, address1, email}]}'
# Search assets
curl -s "${BASE}/customer_assets?customer_id=${CUST_ID}&query=<name>&api_key=${API_KEY}" | jq '[.assets[] | {id, name, asset_type}]'
```
#### Step 3 — Show preview and confirm
Display the full ticket before posting. Include all populated fields. Wait for explicit confirmation.
```
TICKET PREVIEW
--------------
Customer: <name>
Subject: <subject>
Issue Type: <problem_type>
Priority: <priority>
Description: <description>
Due Date: <due_date>
Assigned To: <tech name>
Contact: <contact name>
Address: <address>
Do Not Email: <yes/no>
APPOINTMENT
-----------
Type: <type name>
Start: <start_at>
End: <end_at> (90 min)
Location: <location or blank>
ASSET: <asset name or none>
Confirm? (yes/no)
```
#### Step 4 — Execute (after confirmation)
**Call 1 — Create ticket:**
```bash
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
{
"customer_id": N,
"subject": "...",
"problem_type": "...",
"status": "New",
"priority": "2 Normal",
"user_id": N,
"due_date": "YYYY-MM-DD",
"contact_id": N,
"address_id": N,
"start_at": "ISO8601",
"end_at": "ISO8601",
"asset_ids": [N]
}
```
**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
{
"subject": "Initial Issue",
"body": "<the full description>",
"hidden": false,
"do_not_email": true
}
```
Set `do_not_email: true` if "Do Not Email" was checked; `false` otherwise.
**Call 3 — Create appointment (only if start_at provided):**
```bash
curl -s -X POST "${BASE}/appointments?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
-d @/tmp/appt_payload.json
```
Payload:
```json
{
"ticket_id": N,
"customer_id": N,
"appointment_type_id": N,
"start_at": "ISO8601",
"end_at": "ISO8601",
"location": ""
}
```
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).
#### 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}" \
-H "Content-Type: application/json" \
-d @/tmp/payload.json)
echo "$RESP" | jq '{id: .comment.id, subject: .comment.subject, created_at: .comment.created_at}'
```
**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.
#### 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
Two verified ways to add billable time. Both produce ticket line items that transfer to invoices.
**Option A — Direct line item (simpler):**
| 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
# Add (always include price_retail — 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}'
# 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}'
# 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:**
- `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)
- `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
**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.
**Labor product IDs and rates** (rates pulled from Syncro API 2026-04-24):
| product_id | Name | price_retail ($/hr) | Notes |
|---|---|---|---|
| `1190473` | Labor - Remote Business | `150.00` | Standard remote work |
| `26118` | Labor - Onsite Business | `175.00` | Base onsite rate |
| `26184` | Labor - Emergency or After Hours Business | `262.50` | **1.5× onsite; time-and-a-half baked into the rate.** Non-prepaid customers only. Do NOT stack with `26118` for the same hours. |
| `9269129` | Labor - Prepaid Project Labor | `0.00` | Debits from customer `prepay_hours` bank |
| `9269124` | Labor - Internal Labor | `0.00` | Non-billable internal time |
| `26117` | Fee - Travel Time | `40.00` | Per travel event (not hourly) |
| `68055` | Labor - Website Labor | `150.00` | Website-related work |
`price_retail` is the per-unit rate. Line item total = `price_retail × quantity`.
**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 |
|---|---|---|
| `0` / null (no prepaid) | `26118`, qty = actual_hours | `26184`, qty = actual_hours (rate already 1.5×) |
| `> 0` (has prepaid block) | `26118`, qty = actual_hours | `26118`, qty = actual_hours × **1.5** |
**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 | GET | `/ticket_timers?ticket_id=<id>` |
#### 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>` | — |
**"Make Invoice" flow:** `POST /invoices` pulls all `add_line_item` entries from the ticket into the invoice. Timer entries are NOT included.
**Note:** The `POST /invoices` response body does not include `line_items` — do `GET /invoices/{id}` to verify line items transferred correctly.
### Display formatting
When showing ticket lists, format as:
```
#32164 New Jerry Burger Own cloud thing again
#32163 New LeeAnn Parkinson Remote - Jim cant access his email
#32162 Invoiced Len's Auto Brokerage Server upgrade
```
When showing ticket detail, include:
- Ticket number, subject, status, priority
- Customer name + contact
- Created date, due date, last updated
- Assigned tech
- Comments (most recent first, truncated to last 5)
- Line items / billing status
### 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 read `customer.prepay_hours` before choosing the labor product for emergency work.**
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)"
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
- 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`
**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).
**Correct pattern:**
```bash
# Step 1: Post 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}'
# 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}" \
-H "Content-Type: application/json" \
-d '{"product_id": 1190473, "quantity": 1.0, "price_retail": 150.00, "name": "Labor - Remote Business", "description": "...", "taxable": false}'
# Step 3: Create invoice
curl -s -X POST "${BASE}/invoices?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
-d '{"ticket_id": '"${ID}"', "customer_id": '"${CUST}"', "category": "Standard"}'
# Step 4: Verify line items transferred
curl -s "${BASE}/invoices/${INVOICE_ID}?api_key=${API_KEY}" | jq '.invoice.line_items'
# Step 5: Mark ticket Invoiced
curl -s -X PUT "${BASE}/tickets/${ID}?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
-d '{"status": "Invoiced"}'
```
`--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.
### Error handling
- 401: API key invalid or expired
- 404: ticket/customer/invoice not found
- 422: validation error (show the error message from response body)
- 429: rate limited (wait 60s and retry)
### Integration with session logs
When closing a ticket (`/syncro close`), offer to create a session log entry in `clients/<customer>/session-logs/` documenting what was resolved. Pull the ticket subject, comments, and resolution into a structured log.