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>
659 lines
29 KiB
Markdown
659 lines
29 KiB
Markdown
# /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.
|