syncro: invoice-note policy — block hours remaining, low-block (<4hr) renew + Winter tag, recurring sweep

Extends the invoice Message (note) automation into a single reusable helper
set_invoice_note <invoice_id> <customer_id> [pre_billing_prepay]:
  - no block (prepay_hours==0)  -> "Interested in discounted labor? Ask us about block-rate pricing."
  - block, >=4 hrs left         -> "Block hours remaining: N."
  - block, <4 hrs left          -> remaining + renew line, AND tags Winter (<@624666486362996755>)
                                   in #bot-alerts (low-block heads-up; mentions ping, no allowed_mentions)
Pre-billing prepay arg keeps a just-depleted block counted as a block customer (shows renew, not upsell).
Never clobbers a non-empty note.

Wired into billing Step 3 (set_invoice_note "$INVOICE_ID" "$CUST_ID" "$PREPAY"), and a new
"Recurring invoice note sweep" applies the same policy to Syncro's auto-generated recurring invoices
(schedule_id != null, recent, current balance) — idempotent, run after each recurring run.

Branch logic + a real e2e note set/restore validated on the ACG internal test account (#67741); the
<4hr Winter alert was stubbed in testing so no real ping fired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 08:56:42 -07:00
parent 864f4d0a33
commit a32dfc33fa

View File

@@ -719,20 +719,48 @@ curl -s -X DELETE "${BASE}/invoices/${INV_ID}?api_key=${API_KEY}"
POST `/invoices` pulls all current line items from the ticket into the invoice automatically. The POST response includes `.invoice.id` and `.invoice.total` — if either is null, GET `/invoices?customer_id=${CUST_ID}&per_page=5` and find the invoice by `ticket_id` match before taking any other action. POST `/invoices` pulls all current line items from the ticket into the invoice automatically. The POST response includes `.invoice.id` and `.invoice.total` — if either is null, GET `/invoices?customer_id=${CUST_ID}&per_page=5` and find the invoice by `ticket_id` match before taking any other action.
**Invoice Message (the on-screen "Invoice Message" text block) = the invoice `note` field.** It is per-invoice, prints on the invoice, and is set/edited with `PUT /invoices/{id}` body `{"note": "..."}` (response `{"invoice": {...}}`). Blank by default. Verified 2026-06-16 on the ACG internal test account (invoice #67741 set/verified/restored). **Invoice Message (the on-screen "Invoice Message" text block) = the invoice `note` field.** Per-invoice, prints on the invoice, set with `PUT /invoices/{id}` body `{"note":"..."}` (response `{"invoice":{...}}`). Blank by default. Verified 2026-06-16 on the ACG internal test account (#67741 set/verify/restore).
**Block-rate upsell hint — non-block customers ONLY.** When you invoice a customer with **no prepaid block** (`customer.prepay_hours == 0`), set the invoice note to the one-line hint so it prints on their invoice. Customers who already have a block (`prepay_hours > 0`) must **not** get it. Add this right after the invoice is created (`$PREPAY` = `customer.prepay_hours`, already fetched in billing Step 1): **Auto invoice-note policy** — every invoice we generate gets a one-line note driven by the customer's prepaid block (`customer.prepay_hours`):
| Customer | Note set on the invoice |
|---|---|
| **No block** (`prepay_hours == 0`) | `Interested in discounted labor? Ask us about block-rate pricing.` |
| **Block, ≥ 4 hrs left** | `Block hours remaining: N.` |
| **Block, < 4 hrs left** | `Block hours remaining: N. You're running low — reply to renew...` **+ tags Winter in #bot-alerts** |
Reusable helper — call it once the invoice exists (needs `$BASE`, `$API_KEY`, `$REPO_ROOT`):
```bash ```bash
# Block-rate hint on the invoice — only for customers with NO prepaid block. # set_invoice_note <invoice_id> <customer_id> [<pre_billing_prepay_hours>]
if [ "$(awk "BEGIN{print ((${PREPAY:-0})+0>0)?1:0}")" = "0" ]; then # Arg 3 matters for AD-HOC labor billing: pass the prepay fetched in billing Step 1 so a block
curl -s -X PUT "${BASE}/invoices/${INVOICE_ID}?api_key=${API_KEY}" \ # JUST depleted to 0 still counts as a block customer (shows "remaining: 0 + renew", not the upsell).
-H "Content-Type: application/json" \ # Omit arg 3 for recurring (recurring invoices don't debit the block → current balance is correct).
--data-binary '{"note": "Interested in discounted labor? Ask us about block-rate pricing."}' >/dev/null set_invoice_note() {
fi local INV_ID="$1" CID="$2" PRE="${3:-}" CUST REMAIN NAME CHECK NOTE CUR R
CUST=$(curl -s "${BASE}/customers/${CID}?api_key=${API_KEY}" | tr -d '\000-\037')
REMAIN=$(echo "$CUST" | jq -r '.customer.prepay_hours // "0"')
NAME=$(echo "$CUST" | jq -r '.customer.business_name // .customer.fullname // "customer"')
CHECK="${PRE:-$REMAIN}" # block status source (pre-billing if given)
R=$(awk "BEGIN{printf \"%g\",(${REMAIN:-0})+0}") # tidy: 20.5, 3, 0
if [ "$(awk "BEGIN{print ((${CHECK:-0})+0>0)?1:0}")" = "0" ]; then
NOTE="Interested in discounted labor? Ask us about block-rate pricing."
elif [ "$(awk "BEGIN{print ((${REMAIN:-0})+0<4)?1:0}")" = "1" ]; then
NOTE="Block hours remaining: ${R}. You're running low — reply to renew your block and keep your discounted rate."
bash "$REPO_ROOT/.claude/scripts/post-bot-alert.sh" \
"[SYNCRO] <@624666486362996755> LOW BLOCK — ${NAME} has ${R} prepaid hrs left (< 4) on invoice #${INV_ID} (cust ${CID}). Reach out about renewal."
else
NOTE="Block hours remaining: ${R}."
fi
CUR=$(curl -s "${BASE}/invoices/${INV_ID}?api_key=${API_KEY}" | tr -d '\000-\037' | jq -r '.invoice.note // ""')
if [ -n "$CUR" ] && [ "$CUR" != "null" ]; then echo "[note] skip ${INV_ID}: already has a note"; return 0; fi
curl -s -X PUT "${BASE}/invoices/${INV_ID}?api_key=${API_KEY}" -H "Content-Type: application/json" \
--data-binary "$(jq -nc --arg n "$NOTE" '{note:$n}')" >/dev/null
echo "[note] ${INV_ID} (${NAME}): ${NOTE}"
}
``` ```
Keep it to that one line. If the invoice `note` is already non-empty (a real per-invoice message), do **not** clobber it — only set the hint when the note is blank. Winter's Discord id `624666486362996755` in the alert content (`<@...>`) pings her in #bot-alerts (post-bot-alert sets no `allowed_mentions`, so content mentions ping). **Never clobber a non-empty note** — a tech may have typed a real per-invoice message. One alert per low-block invoice.
#### Recurring Invoice Schedules #### Recurring Invoice Schedules
@@ -834,6 +862,26 @@ curl -s -X DELETE "${BASE}/schedules/${SCHED_ID}/line_items/${LI_ID}?api_key=${A
**Pax8 link field:** `recurring_type_id` on a schedule line item holds the Pax8 subscription UUID. Pax8 sets this when syncing subscriptions and uses it to identify which line to PUT when a subscription changes quantity or price. **Pax8 link field:** `recurring_type_id` on a schedule line item holds the Pax8 subscription UUID. Pax8 sets this when syncing subscriptions and uses it to identify which line to PUT when a subscription changes quantity or price.
#### Recurring invoice note sweep (block hours / low-block alerts on auto-generated invoices)
Recurring invoices are generated by Syncro **automatically** — we don't run code at generation time — so to put the same invoice-note policy (block hours remaining / low-block renew + Winter tag / non-block upsell) on them, run a **sweep** after the recurring run that scans recently-generated recurring invoices and applies `set_invoice_note` (the helper in the Invoices section). Recurring invoices carry a non-null `schedule_id`; they do **not** debit the block, so the current `prepay_hours` is the right remaining figure (omit the pre-billing arg).
```bash
# Sweep recurring invoices from the last N days and set their block-hours note.
SINCE=$(date -d '8 days ago' +%Y-%m-%d 2>/dev/null || date -v-8d +%Y-%m-%d)
PAGE=1
while :; do
BATCH=$(curl -s "${BASE}/invoices?per_page=100&page=${PAGE}&api_key=${API_KEY}" | tr -d '\000-\037')
ROWS=$(echo "$BATCH" | jq -r --arg since "$SINCE" '.invoices[] | select(.schedule_id != null) | select(.date >= $since) | "\(.id) \(.customer_id)"')
[ -z "$ROWS" ] && break
echo "$ROWS" | while read -r INV CID; do set_invoice_note "$INV" "$CID"; done # no pre-billing arg
CNT=$(echo "$BATCH" | jq -r '.invoices | length'); [ "${CNT:-0}" -lt 100 ] && break
PAGE=$((PAGE+1))
done
```
Idempotent (skips invoices that already have a note). Run it on demand after monthly billing, or schedule it (a cron/scheduled agent the day after each recurring run). Low-block customers still get the renewal line + a Winter ping, once per invoice.
#### Estimates #### Estimates
Estimates (quotes) always require a linked ticket — create the ticket first, then the estimate with `ticket_id` set. This enables private notes (purchase links, sourcing details) on the ticket using the standard hidden comment endpoint. Estimates created without a ticket have no notes surface accessible via API. Verified 2026-05-22 against ACG internal account. Estimates (quotes) always require a linked ticket — create the ticket first, then the estimate with `ticket_id` set. This enables private notes (purchase links, sourcing details) on the ticket using the standard hidden comment endpoint. Estimates created without a ticket have no notes surface accessible via API. Verified 2026-05-22 against ACG internal account.
@@ -1068,13 +1116,9 @@ INVOICE_ID=$(echo "$INV_RESP" | jq -r '.invoice.id')
INVOICE_TOTAL=$(echo "$INV_RESP" | jq -r '.invoice.total') INVOICE_TOTAL=$(echo "$INV_RESP" | jq -r '.invoice.total')
# If INVOICE_ID is null: GET /invoices?customer_id=${CUST_ID}&per_page=5, find by ticket_id # If INVOICE_ID is null: GET /invoices?customer_id=${CUST_ID}&per_page=5, find by ticket_id
# 3b. Block-rate hint on the invoice note — ONLY for customers with NO prepaid block. # 3b. Invoice-note policy: non-block -> upsell hint; block -> hours remaining; block <4hr -> renew + tag Winter.
# ($PREPAY = customer.prepay_hours, fetched in Step 1. Block customers don't get the hint.) # Pass the PRE-billing prepay ($PREPAY from Step 1) so a just-depleted block still counts as block.
if [ "$(awk "BEGIN{print ((${PREPAY:-0})+0>0)?1:0}")" = "0" ]; then set_invoice_note "$INVOICE_ID" "$CUST_ID" "$PREPAY" # helper defined in the Invoices section
curl -s -X PUT "${BASE}/invoices/${INVOICE_ID}?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
--data-binary '{"note": "Interested in discounted labor? Ask us about block-rate pricing."}' >/dev/null
fi
# 4. Mark Invoiced # 4. Mark Invoiced
curl -s -X PUT "${BASE}/tickets/${ID}?api_key=${API_KEY}" \ curl -s -X PUT "${BASE}/tickets/${ID}?api_key=${API_KEY}" \