diff --git a/.claude/commands/syncro.md b/.claude/commands/syncro.md index bd51c0be..e47d0a64 100644 --- a/.claude/commands/syncro.md +++ b/.claude/commands/syncro.md @@ -45,6 +45,8 @@ Create, update, close, comment on, and bill tickets in Syncro PSA. **Emergency/after-hours billing — check prepaid first:** Before adding a `26184` (Emergency) line item, `GET /customers/` and read `prepay_hours`. Emergency = time-and-a-half (×1.5), applied ONCE — never bill a separate regular + emergency line for the same hours. **No prepaid (`prepay_hours == 0`):** `26184` at qty = actual hours; set `price_retail` by delivery channel — **Onsite $262.50** (175×1.5, 26184's default), **Remote / In-Shop $225** (150×1.5, override price_retail). The rate carries the 1.5×; do NOT also ×1.5 the qty. **Prepaid (`prepay_hours > 0`):** still use `26184`, at qty = actual hours **× 1.5** (premium goes in the quantity since prepaid debits by quantity; invoice nets $0, block debits hours×1.5). e.g. 1.5 emergency hrs prepaid → `26184` @ 2.25. (Rule updated 2026-05-27 by Mike: prepaid emergency uses `26184`, NOT the old `26118`×1.5 — keeps the line labeled emergency + mapping right in QuickBooks. Original ×1.5-not-additive lesson: #32203 Desert Auto Tech 2026-04-23, Winter.) +**`prepay_hours` is ONLY reliable from `GET /customers/{id}` — the customer SEARCH/LIST endpoint lies.** `GET /customers?query=...` (and any list endpoint) returns `prepay_hours: null` (or stale) even when the customer HAS a block. **NEVER read `prepay_hours` from a search/list result, and NEVER assert "no prepaid block" / "real charge" / a dollar total in a preview built from search data.** Before ANY billing preview or decision that mentions prepay, do a full `GET /customers/{id}` and read `.customer.prepay_hours` from THAT response. If you only have search data, fetch the full record first — do not guess. The billing-flow Step-1 GET is mandatory and must happen BEFORE the preview, not just before the invoice. (Recurring miss flagged by Mike 2026-06-23: previews repeatedly said "$300, no block," then the block surfaced at invoice time and netted $0 — Dataforth, Grabb & Durando #32455.) + **Prepaid customers — ALL billing (not just emergency):** `GET /customers/` → `prepay_hours` before creating ANY invoice for a prepaid customer. When you bill a prepaid customer using a billable labor product (remote / onsite / in-shop / web), Syncro automatically deducts from their prepay block and the invoice total shows $0.00. The line item name is annotated "- Applied X Prepay Hours". This is correct behavior — do NOT treat a $0.00 invoice as an error. Verify the deduction by re-fetching `customer.prepay_hours` after invoicing and confirming it dropped by `quantity`. **`9269129` (Labor - Prepaid Project Labor) is EXEMPT — it does NOT deduct from prepay blocks:** Despite the name, this product is categorized as Exempt Labor at $0.00 and contains no prepay-deduction logic. Billing a prepaid customer with this product results in a $0.00 invoice AND no block decrement — silent accounting drift. Discovered 2026-05-04 (see `feedback_syncro_labor_type.md`). NEVER use `9269129` for normal or prepaid work. Only use it if explicitly directed. The correct approach for prepaid customers is a billable labor product matching the delivery channel (remote / onsite / in-shop / web). @@ -610,9 +612,9 @@ COMMENT_ID=$(echo "$COMMENT_RESP" | jq -r '.comment.id') #### Customers ```bash -# Search +# Search — returns id/name/email ONLY. Do NOT read prepay_hours from this list (it is null/stale here). curl -s "${BASE}/customers?query=&per_page=25&api_key=${API_KEY}" | jq '[.customers[] | {id, business_name, email}]' -# Get one +# Get one — this is the ONLY trustworthy source of prepay_hours. Always GET this before any billing preview. curl -s "${BASE}/customers/${CUST_ID}?api_key=${API_KEY}" | jq '{id: .customer.id, prepay_hours: .customer.prepay_hours}' ``` diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index a09b9739..fd53c7bd 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -31,6 +31,7 @@ - [Unraid VM no-IP causes](unraid-windows-vm-virtio-no-ip.md) — PRIMARY (general "new VMs stopped getting IPs lately"): Docker sets bridge-nf-call-iptables=1, so br0 VM DHCP OFFERs hit DOCKER-FORWARD (no br0 ACCEPT) and get dropped; new VMs can't complete DORA (existing renew via ESTABLISHED). Fix `=0` runtime (needs persistent post-Docker hook; not yet persisted on Jupiter). SECONDARY (Windows VM): virtio-net has no in-box driver -> use e1000 or virtio-win. Diagnose: tcpdump DHCP on pfSense; /sys vnetN rx_packets. - [Starr Pass mail routing](reference_starrpass_mail_routing.md) — starrpass.com is DIRECT to MS (EOP/Defender, tenant 222450dd…); only devconllc.com is on Mailprotector (MP acct 16170). Check @starrpass.com quarantine/rejects via remediation-tool, not Mailprotector. - [INKY outbound breaks DMARC](reference_inky_outbound_breaks_dmarc.md) — Reverse-resolve DMARC rua failing IPs before blaming a sender: ipw-outbound.inkyphishfence.com / us.cloud-sec-av.com = INKY re-injection breaking DKIM+SPF. INKY is in-M365 (connectors+transport rules) per enrolled tenant, but hosting-level (IX/cPanel website) outbound also routes through it independent of M365 enrollment. Fix is INKY-side (outbound DKIM/SPF/ARC), not cPanel DNS. +- [Syncro prepay: full-GET only](feedback_syncro_prepay_full_get_only.md) — read prepay_hours ONLY from GET /customers/{id}; the customer search/list endpoint returns null/stale prepay. Never assert "no block" in a billing preview from search data. - [AAD Connect msDS-KeyCredentialLink writeback](reference_aadconnect_keycredlink_writeback.md) — "completed-export-errors" + 8344 INSUFF_ACCESS_RIGHTS on a protected admin account = WHfB key writeback blocked by AdminSDHolder. Diagnose with csexport /f:x; fix with dsacls WP;msDS-KeyCredentialLink on AdminSDHolder + SDProp. - [UniFi Site Manager cloud API](reference_unifi_site_manager_api.md) — `api.ui.com` + `X-API-KEY` (vault `services/unifi-site-manager`) = remote access to the WHOLE ACG UniFi fleet (~36 consoles) outside UOS. Tier1 `/v1/hosts|sites|devices|isp-metrics` = inventory+health+WAN. Tier2 CONNECTOR `/v1/connector/consoles/{id}/proxy/network/api/s/default/stat/{device,sta}` = **full UOS parity** (per-radio cu_total airtime + per-client RSSI) for ANY console, remote. Backend `unifi-wifi/scripts/gw-sitemanager.sh` (`fleet|devices|sites|isp|net`). Standalone UDM WAN SSH usually firewalled; per-console SSH pw at `clients//udm-ssh`. - [reference_sqlx_migrations_immutable](reference_sqlx_migrations_immutable.md) -- NEVER edit an already-applied sqlx migration file — even a comment. sqlx::migrate! checksums each file at compile time and validates against _sqlx_migrations at startup; a changed checksum crash-loops the server with "migration N was previously applied but has been modified". Code review MUST flag any edit to an applied migration. diff --git a/.claude/memory/feedback_syncro_prepay_full_get_only.md b/.claude/memory/feedback_syncro_prepay_full_get_only.md new file mode 100644 index 00000000..944fae8b --- /dev/null +++ b/.claude/memory/feedback_syncro_prepay_full_get_only.md @@ -0,0 +1,12 @@ +--- +name: feedback-syncro-prepay-full-get-only +description: Syncro prepay_hours is only reliable from GET /customers/{id}; never read it from the customer search/list endpoint +metadata: + type: feedback +--- + +When billing in Syncro, read `prepay_hours` ONLY from the full `GET /customers/{id}` response (`.customer.prepay_hours`). The customer **search/list** endpoint (`GET /customers?query=...`) returns `prepay_hours: null` (or stale) even when the customer HAS a prepaid block. Never read prepay from a search result, and never assert "no prepaid block" / "real charge $N" in a billing preview built from search data. + +**Why:** Repeated misfires — previews said "$300, no block," then the block surfaced during the invoice POST and the invoice netted $0 (block debited). Mike flagged this 2026-06-23 as a reliability problem that keeps recurring (Dataforth, Grabb & Durando #32455). The wrong figure in a preview the user confirms is the failure mode. + +**How to apply:** In the billing gather step, ALWAYS `GET /customers/{id}` and pull `prepay_hours` from there BEFORE composing the preview — not just before the invoice. If you only have search/list data, fetch the full customer record first; do not guess. The /syncro skill hard rules now encode this. See [[feedback_syncro_labor_type.md]]. diff --git a/errorlog.md b/errorlog.md index 3831a4c9..b8e79304 100644 --- a/errorlog.md +++ b/errorlog.md @@ -17,6 +17,8 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure · +2026-06-24 | GURU-5070 | syncro/billing-prepay | [friction] customer SEARCH endpoint returned prepay_hours=null so preview wrongly said 'no block / $300'; the customer actually had a 20.5h block. ALWAYS read prepay via GET /customers/{id} (full record), never the search-list field [ctx: cust=14232794 ticket=32455] + 2026-06-24 | GURU-5070 | unifi-wifi/controller-rest | [friction] CSRF token missed because read via dict(resp.headers) (case-sensitive); UniFi returns X-Csrf-Token mixed-case -> PUT got 403. Use resp.headers.get() (case-insensitive) to capture X-CSRF-Token/X-Updated-Csrf-Token 2026-06-24 | GURU-5070 | unifi-wifi/gw-control block-ips | [friction] block-ips clones an existing WAN_IN rule's schema; if it clones the PPTP GRE rule it creates a DROP rule with proto=gre -> ineffective against TCP/UDP brute-force. Had to PUT protocol=all. Fix: block-ips should force protocol=all on the new rule