The collections morning routine, formalized
Before this workflow, AR collections meant Mike opened the NetSuite AR aging report, mentally sorted customers into "need to call" / "send statement" / "escalate", drafted a few emails, and lost the rest of the morning. The AR aging action plan formalizes that into a daily cron that does the grouping, drafts the emails, and surfaces the priority queue.
Contract: workflow_definitions WHERE workflow_type='ar_aging_action_plan' — trigger is cron (weekdays 7am ET), risk level 3, expected duration 30 minutes including two HITL gates.
The defining property: lagging indicator. AR aging is what happens after a customer disengages. The proper leading-indicator pairing is the customer health substrate. This workflow handles the "already in the bucket" cases; customer health handles the "about to fall in" prediction.
Trigger conditions
- Daily cron at 7am ET — the default trigger. Mike sees the action plan at his desk.
- Manual trigger when a large customer crosses 90 days mid-cycle.
- After a payment batch posts and we want to recompute who's still owing.
- End-of-month close — Mike wants the full picture before booking the period.
The four tiers and their actions
| Bucket | Days past due | Default action | Tone |
|---|---|---|---|
| Current | 0–29 | No action — just monitor | — |
| 30-day | 30–59 | Friendly statement email with PDF | "just a reminder" |
| 60-day | 60–89 | Direct collection email + flag in customer_health | "this is now overdue" |
| 90-day | 90–119 | Hold further orders, escalate to Mike for phone call | "we need to talk" |
| 120+ day | 120+ | Credit memo review + collections escalation + legal flag | red zone — Mike personal touch |
Strategic accounts (Driscoll, top-10 by revenue) get softer default actions — the workflow respects customers.collection_tone_override. Cardinal at 90+ gets a "we need to talk" treatment; Driscoll at 90+ gets a polite reminder while Mike phones the AP contact directly.
Cardinal $5K invoice goes 90+
Tuesday 7am. The cron fires. Cardinal Foods has invoice INV-2026-0341 for $5,142.18 dated Feb 19, 2026 — that's 96 days past due, landing in the 90-day bucket. Cardinal's total open AR is $14,820 across three invoices, two of which are in 30-day. No collection_tone_override is set; standard treatment applies.
The workflow drafts: (1) a phone-call prompt for Mike with talking points and AP contact info, (2) an order hold flag on Cardinal pending payment, (3) a polite-but-firm escalation email referencing all three invoices with PDFs attached. Three rows land in proposed_actions. Mike approves the phone-call task and the email, defers the order hold pending the call. By 9am, the email is out, Cardinal's AP contact replies by 11am, and a check is in the mail by Thursday.
The four beats
-
01
Aging snapshot (auto)
get_ar_aging_summaryqueriesar_aging(refreshed nightly from NS), buckets by 30/60/90/120, and computes per-customer totals. The result is materialized intoar_action_snapshotsfor the day so the rest of the workflow operates against a stable read. -
02
Prioritize + draft (auto + HITL)
Each customer in the snapshot gets a recommended action based on its highest bucket. Strategic-account overrides apply. The AI drafts the corresponding email/phone-prompt with full context — invoices, dates, amounts, last contact attempt. Drafts land in
proposed_actionswithaction_type='collection_email'or'phone_prompt'. -
03
Mike reviews + approves (HITL)
The morning queue is sorted by bucket severity (120 → 90 → 60 → 30). Mike can bulk-approve all 30-day reminders (low risk) and single-decide the 90/120 cases. R560 atomic claim prevents double-fire.
-
04
Send + record (HITL approve → auto execute)
Approved emails go via Email Routing. Phone-prompt rows land on Mike's task list. Order holds (if approved) write to
customer_holds. Eventsar.collection_sentandar.hold_placedare emitted so customer health can react.
What's different after the workflow runs
- Every customer with past-due AR has either an action taken or a deliberate skip recorded.
ar_action_snapshotsretains the daily bucket distribution for trend analysis.- Customer health signals receive the
ar.bucket_movedevent and adjust risk scores. - Strategic accounts get individualized treatment per their override.
- Mike's collections morning is now ~15 minutes of review, not 90 minutes of building.
What can go wrong and how to recover
If the overnight NS sync of ar_aging didn't run, the workflow uses yesterday's data and tags the snapshot stale=true. Daily digest highlights this; manual refresh: POST /admin/sync/tier?tier=hot.
A customer may have wired payment that hasn't posted yet. Mike has a "skip — payment expected" option per row; the workflow records the deferral in customer_payments.expected_at and re-checks next cron.
If the customer's AP contact has stale email, the bounce lands in inbound triage. The customer health watcher picks up email.bounced and lowers the score. Fix in NS customer record; sync picks it up.
Adjacent workflows + diagrams
Code paths + invariants
| Concern | Where |
|---|---|
| Workflow contract | workflow_definitions WHERE workflow_type='ar_aging_action_plan' |
| Cron schedule | wrangler.toml — weekday 7am ET |
| Aging query | src/chat_tools/impls.ts get_ar_aging_summary |
| Action proposer | src/chat_tools/impls.ts propose_collection_action |
| Strategic overrides | customers.collection_tone_override column |
| Daily snapshot | ar_action_snapshots — historical reproducibility |
| HITL invariant | ADR-031 — never send dunning email without approval |
| Event emission | ar.collection_sent, ar.hold_placed, ar.bucket_moved |