# /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.
