Session log: desertrat.com Mailprotector SBR repair + Syncro API corrections

- Added desertrat.com to /etc/mailprotector_domains on Websvr (outbound SBR now active)
- Created Mailprotector bulk user import CSV (38 desertrat.com accounts/forwarders)
- Created Syncro ticket #32181 + invoice #67437 for Furrier (30 min remote, $81.53)
- Corrected syncro.md skill doc: add_line_item for billing, remove_line_item to delete,
  charge_timer_entry to convert timers, comment DELETE impossible via API
- Created clients/furrier/ with session log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 12:24:15 -07:00
parent db4e3c25a5
commit 9143eb6262
2 changed files with 304 additions and 41 deletions

View File

@@ -11,7 +11,7 @@ Create, update, close, comment on, and bill tickets in Syncro PSA.
/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> Create invoice from ticket time entries
/syncro bill <number> Add billable time and create invoice
/syncro search <query> Search tickets by subject/customer
/syncro customers <query> Search customers
```
@@ -70,13 +70,20 @@ API_KEY=$(sops -d D:/vault/msp-tools/syncro.sops.yaml | py -c "import sys,yaml;
|---|---|---|---|
| Add comment | POST | `/tickets/<id>/comment` | `{"subject": "Update", "body": "...", "hidden": false, "do_not_email": false}` |
**Comment fields:**
- `subject` — comment header (e.g., "Update", "Resolution", "Internal Note")
- `body` — comment text (HTML supported)
- `hidden` — if true, internal-only (customer can't see)
- `do_not_email` — if true, don't email customer about this comment
**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
**WARNING:** The comment endpoint accepts but silently ignores `product_id`, `minutes_spent`, and `bill_time_now` fields — they are not saved. Verified 2026-04-20. Always use the timer_entry endpoint to log time.
**Silently ignored (do not use):** `product_id`, `minutes_spent`, `bill_time_now` — accepted but not saved. Verified 2026-04-21.
**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.** The `Idempotency-Key` header is silently ignored.
**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
@@ -86,14 +93,59 @@ API_KEY=$(sops -d D:/vault/msp-tools/syncro.sops.yaml | py -c "import sys,yaml;
| Get customer | GET | `/customers/<id>` |
| Create customer | POST | `/customers` |
#### Timer Entries (add time to ticket)
#### Billable Line Items
| Operation | Method | Endpoint | Body |
|---|---|---|---|
| Add time | POST | `/tickets/<id>/timer_entry` | `{"start_at": "ISO8601", "end_at": "ISO8601", "notes": "...", "billable": true, "product_id": N}` |
| List timers | GET | `/ticket_timers?ticket_id=<id>` |
Two verified ways to add billable time. Both produce ticket line items that transfer to invoices.
**IMPORTANT:** `product_id` must be a **labor product**, not an invoice product. Common labor products:
**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
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"}'
# 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 list below)
- `quantity` — decimal hours (0.5 = 30 min, 1.0 = 1 hour)
- `price_retail`**only price field that saves**; `price`, `retail_price`, `rate`, `price_cents` all silently ignored and leave line at $0.00
**Labor product IDs:**
- `1190473` — Labor - Remote Business (standard remote work)
- `26118` — Labor - Onsite Business
- `26184` — Labor - Emergency or After Hours Business
@@ -102,22 +154,28 @@ API_KEY=$(sops -d D:/vault/msp-tools/syncro.sops.yaml | py -c "import sys,yaml;
- `26117` — Fee - Travel Time
- `68055` — Labor - Website Labor
#### 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>` |
| 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:** Timer entries on the ticket become invoice line items when you POST `/invoices` with the ticket_id. This is the equivalent of clicking "Make Invoice" in the GUI.
**"Make Invoice" flow:** `POST /invoices` pulls all `add_line_item` entries from the ticket into the invoice. Timer entries are NOT included.
#### Invoice Line Items
| Operation | Method | Endpoint | Body |
|---|---|---|---|
| Add line item | POST | `/invoices/<id>/line_items` | `{"item": "...", "quantity": 1, "price": 125.00, "product_id": N}` |
**Note:** The `POST /invoices` response body does not include `line_items` — do `GET /invoices/{id}` to verify line items transferred correctly.
### Display formatting
@@ -135,41 +193,51 @@ When showing ticket detail, include:
- Created date, due date, last updated
- Assigned tech
- Comments (most recent first, truncated to last 5)
- Time entries if any
- Billing status
- Line items / billing status
### Billing workflow
**ALWAYS ask the user for minutes and labor type before logging any time entry. Never assume a default.**
**ALWAYS show a preview of the ticket comment/notes to the user before posting. Wait for confirmation.**
**ALWAYS ask the user for minutes and labor type before logging any time. Never assume a default.**
**ALWAYS show a preview of the comment to the user before posting. Wait for confirmation.**
When `/syncro bill <number>` is called:
1. Get ticket details
2. Ask: "How many minutes should I bill, and what labor type? (remote / onsite / emergency / project / internal)"
3. Draft the comment body and show it to the user for review before posting
3. Add comment: `POST /tickets/{id}/comment` with work notes as body (no time fields — they are broken)
4. Add timer entry: `POST /tickets/{id}/timer_entry` with `start_at`, `end_at`, `billable: true`, `product_id`, `notes`
5. Create invoice: `POST /invoices` with `{"ticket_id": N, "customer_id": N, "category": "Standard"}`
6. Update ticket status to "Invoiced"
4. Post comment: `POST /tickets/{id}/comment`
5. Add billable line item: `POST /tickets/{id}/add_line_item` with quantity in decimal hours, `price_retail`, `name`, `description`
6. Create invoice: `POST /invoices` with `{"ticket_id": N, "customer_id": N, "category": "Standard"}`
7. Verify invoice: `GET /invoices/{id}` to confirm line items transferred
8. Update ticket status to `Invoiced`
**Correct two-call pattern for comment + time:**
**Correct pattern:**
```bash
# Step 1: comment (notes only)
curl -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \
# 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": true}'
-d '{"subject": "Resolution", "body": "...", "hidden": false, "do_not_email": false}'
# Step 2: timer entry (billable time) — compute start_at as end_at minus minutes
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
START=$(date -u -d "60 minutes ago" +"%Y-%m-%dT%H:%M:%SZ")
curl -X POST "${BASE}/tickets/${ID}/timer_entry?api_key=${API_KEY}" \
# Step 2: Add billable line item (convert minutes to decimal hours)
# 60 min = 1.0, 30 min = 0.5, 45 min = 0.75, etc.
curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"start_at\": \"${START}\", \"end_at\": \"${NOW}\", \"notes\": \"...\", \"billable\": true, \"product_id\": 1190473}"
-d '{"product_id": 1190473, "quantity": 1.0, "price_retail": 150.00, "name": "Labor - Remote Business", "description": "..."}'
# 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"}'
```
When `/syncro comment <number> <text> --time 60 --labor remote` is called:
- Post the comment first, then post a separate timer_entry
- `--labor` maps to product IDs: `remote` → 1190473, `onsite` → 26118, `emergency` → 26184, `project` → 9269129, `internal` → 9269124, `travel` → 26117, `website` → 68055
`--labor` maps to product IDs: `remote` → 1190473, `onsite` → 26118, `emergency` → 26184, `project` → 9269129, `internal` → 9269124, `travel` → 26117, `website` → 68055
### Error handling