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 |
Dated trail · spot stale claims
Dated trail of when this doc was last touched, what changed, and what to look at if it feels stale.
| Date | Round | Change | Touched by |
|---|---|---|---|
2026-05-26 | R586 | Added CHANGELOG · SCHEMA · RUNBOOK · BACKLOG sections — wiki became best-in-class operating documentation. | Mike + Claude |
2026-05-25 | R584/R585 | Wiki originally shipped — 8-section structure (hero / what / when / steps / outcomes / failure-modes / related / for-developers). | Mike + Claude |
workflow_definitions WHERE workflow_type='ar_aging_action_plan' before acting on these claims.The machine-readable spec
Canonical fields, table names, endpoint signatures. What code should match, what tests should assert. workflow_type · ar_aging_action_plan · risk_level · 3.
Inputs (required + optional)
| Field | Type | Description |
|---|---|---|
run_date | date | When the aging snapshot was taken. Required. |
aging_buckets | json | {'1-30':n, '31-60':n, '61-90':n, '90+':n} |
threshold_total_open | decimal | Min open balance to qualify; default $5000. |
threshold_days_overdue | integer | Min days overdue; default 30. |
D1 tables written
| Table | Operation | Trigger |
|---|---|---|
proposed_actions | INSERT (one per tier) | draft_per_tier loop_over_aging fan-out |
customer_program_proposals | INSERT | pause_on_hold_check flag |
outbound_email_log | INSERT (status=pending_review) | sales_rep_notify hitl_email_draft |
workflow_run_log | INSERT | runner automatic |
reflexion_log | INSERT | runner automatic (reflexion_enabled=1) |
ar_aging_summary | INSERT | daily_digest declared action |
Endpoints called
| Method | Path | Purpose |
|---|---|---|
POST | /api/workflow/execute | workflow_type=ar_aging_action_plan |
GET | /api/ar-aging/snapshot | Live aging snapshot |
POST | /api/proposed-actions/:id/decide | Mike approves per-tier drafts |
Events fired
| event_type | When | Subscribers |
|---|---|---|
ar.aging.scanned | Post-run | customer_health |
customer.service_hold.proposed | When pause_on_hold_check fires | service_hold_trigger |
It broke at 2am — what now
Different from "how do I use this." This is the page Mike pulls up when something is wrong: logs to check, recovery steps, who to escalate to.
Scenario · Same customer flagged every day for 30 days
Customer not paying, but no escalation — the workflow only proposes a hold at 90+ days AND $25K open.
- Check thresholds:
SELECT days_overdue, total_open FROM customer_aging WHERE customer_id=<id> - Manual escalate: Mike calls; or stage a propose_customer_program with type=service_hold manually.
- Long-term: Backlog item — tiered escalation (30 → email, 60 → call, 90 → hold).
Scenario · Service hold proposed, Mike approved, customer still received new SO
Service hold isn't enforced at SO creation time — it's only a flag on customer_program_proposals.
- Verify hold is set:
SELECT * FROM customer_programs WHERE customer_id=<id> AND program_type='service_hold' AND status='active' - Patch SO creation: Add a pre-check in propose_sales_order that blocks if customer has active service_hold.
- Immediate fix: Cancel the SO manually in NS, refund deposit.
Scenario · AR aging cron didn't run — no proposed_actions today
Cron sweep failed or sync was stale.
- Preconditions:
SELECT MAX(completed_at) FROM sync_log WHERE sync_type='scheduled_hot_customer_payments'— must be < 2 hours old. - Manual trigger:
POST /api/workflow/executewith workflow_type=ar_aging_action_plan. - Investigate:
npx wrangler tailwhile triggering; look for sync errors.
Scenario · Mike approved a dunning draft but customer never got the email
outbound_email_log might have status=approved but sent_at is null.
- Check:
SELECT id, status, approved_at, sent_at, error FROM outbound_email_log WHERE entity_ref LIKE 'ar:%' ORDER BY created_at DESC LIMIT 5 - Recover: Re-trigger send via
POST /admin/email/<id>/resend
Logs to check
workflow_run_log· top-level run auditworkflow_step_log· per-step traceworkflow_verify_results· post-window verify outcomescron_locks· stuck cron lock detectionevents· workflow.completed / workflow.failed event trailreflexion_log· per-run narrative (if reflexion_enabled)npx wrangler tail· live Worker logs
Kill switch · emergency stop
If this workflow is misbehaving in a high-impact way (creating bad proposed_actions in volume, pushing wrong things to NS), flip a kill switch:
kill:ns_writes· stops every NS push platform-widekill:proposed_apply· stops HITL approvals from executing fan-outkill:high_risk_ops· stops risk_level >= 4 fan-out
See kill-switches-state-machine.html for the full state machine + recovery procedure.
Escalation
Primary: Mike Levine (single-admin) · mikelevine@globalfoodsolutions.co. For prolonged outage during business hours, notify warehouse lead + accounting lead so they can defer dependent work.
What's not done · what's uncertain
What's not done, what's uncertain, what we punted. Captured so it survives context switches and doesn't die in someone's head.
-
STUB
loop_over_agingkind not implemented in runnerdraft_per_tier emits status='stub'. Real implementation needs the loop to iterate per aging bucket and stage a draft per tier.
-
STUB
flagkind not wiredpause_on_hold_check is documented but not executable; Mike manually flags holds today.
-
STUB
hitl_email_draftkind not wiredsales_rep_notify documented but emails are drafted via separate chat tool, not the runner.
-
OPEN
Tiered escalation thresholds
Today thresholds are static (90d + $25K). Should be configurable per customer (DOE has 60-day terms, commercial has 30).
-
DECISION
Cron timing — daily or weekly?
Daily means 30 nags for same delinquent customer. Weekly means 4. Mike's call.
-
DEFER
Service hold enforcement at SO creation
Today service_hold is a flag but doesn't block SO creation. Needs a precondition in propose_sales_order.