Customer invoice dispute
Invoice disputes are the most common high-friction inbound. They're also one of the easiest workflows to mishandle — Mike's firsthand pain after the May 6 Dave Jordan $786K alignment call was the trigger for making this a first-class workflow.
The cascade does the unglamorous work: pull the invoice, pull the customer's recent history, classify the reason (pricing / qty / quality / delivery / other), draft a response Mike can polish, route to a sales rep if escalation is needed, and stage an AR hold-or-continue review if the customer has prior disputes.
Risk level 3 (medium). Most rows write to proposed_actions and ar_disputes — read-only from NS's perspective unless escalated.
Trigger conditions
- Email triage classifies an inbound as
invoice_disputeand routes here. - A customer mentions an invoice number in a phone call and Mike opens the dispute manually.
- Sales rep flags an invoice during AR review and triggers the workflow.
- A persistent customer email thread references "invoice" and the AI classifier scores it as a dispute.
prior_disputes >= 2 conditionally stages an ar_hold_review proposed action. This catches the pattern of repeat-dispute customers who tend to use disputes as a payment delay tactic.
The 3 beats
-
01
Draft response to the customer
AI drafts a response email addressing the specific dispute reason, citing the invoice ref, and offering a next step (credit, copy of paperwork, schedule a call). Status=pending_review in
outbound_email_log. -
02
Escalate to sales rep (HITL)
A
proposed_actionsrow stages withaction_type=customer_escalationassigned to the customer's sales rep. Mike taps approve to route. -
03
AR hold-or-continue review (conditional)
If
prior_disputes >= 2, anar_hold_reviewrow stages so AR can decide whether to keep shipping. This is the chokepoint that prevents repeat-dispute customers from accumulating uncollectible AR.
What's different after the workflow runs
ar_disputeshas a row capturing the customer, invoice, reason, and timestamps.- Mike has a draft customer response waiting for approval.
- The sales rep has an escalation in their queue.
- If pattern repeats, AR has a hold-or-continue decision queued.
- reflexion_log carries an
invoice_disputetagged row with customer_id.
What can go wrong and how to recover
Precondition warns. The response draft can still proceed using context but flags "no matching invoice found." Often the customer cites a PO number rather than an invoice number — the trick of leading-% on memo search applies.
The conditional AR hold review is skipped. Single-dispute customers don't auto-flag — they get the friendly path.
Escalation has no target. Default routing falls back to Mike's queue. Cleanup task: populate customers.sales_rep.
Outcome: a credit memo proposed_action stages from the response. That feeds the standard credit-memo workflow, not this one.
Adjacent workflows + diagrams
Code paths + invariants
| Concern | Where |
|---|---|
| Workflow contract | workflow_definitions WHERE workflow_type='customer_invoice_dispute' |
| Dispute table | ar_disputes |
| Classifier | AI classification in inbound_email_triage |
| Memo-field trick | leading '%' on tranid → memo search for PO numbers |
| Reflexion tag | invoice_dispute,customer:<id> |
| Risk level | 3 |
| Expected duration | ~20 min |
| Trigger | event · inbound_email_triage_classification |
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='customer_invoice_dispute' 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 · customer_invoice_dispute · risk_level · 3.
Inputs (required + optional)
| Field | Type | Description |
|---|---|---|
invoice_id | integer | NS invoice internal ID. Required. |
dispute_type | string | 'pricing' | 'qty' | 'delivery' | 'damaged' |
customer_claim | string | What the customer says is wrong. |
attachment_ids | json? | Supporting photos/docs. |
D1 tables written
| Table | Operation | Trigger |
|---|---|---|
invoice_disputes | INSERT | Dispute record with claim + evidence |
proposed_actions | INSERT | Resolution proposal (credit, replacement, etc.) |
ns_pending_pushes | INSERT (post-HITL) | Credit memo or replacement SO |
events | INSERT (invoice.disputed | invoice.resolved) | customer_health + AR |
Endpoints called
| Method | Path | Purpose |
|---|---|---|
POST | /api/invoice-dispute/open | Open dispute |
POST | /api/invoice-dispute/:id/resolve | Mark resolved |
GET | /api/invoice-dispute/open | Open disputes dashboard |
Events fired
| event_type | When | Subscribers |
|---|---|---|
invoice.disputed | On open | customer_health |
invoice.resolved | On resolve | AR + analytics |
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 · Dispute opened but no proposed_action for resolution
The proposal kind based on dispute_type isn't wired.
- Inspect:
SELECT * FROM invoice_disputes WHERE id=<id> - Manual proposal: Use propose_credit_memo or propose_replacement_so.
- Wire it: Add per-dispute_type proposal logic in workflow_runner.
Scenario · Credit memo issued but invoice still shows as unpaid in NS
NS credit didn't apply against the invoice — usually a missing 'apply to' linkage.
- Verify in NS: Check the credit memo's 'apply' tab in NS UI.
- Manual apply: Apply credit to invoice in NS.
- Fix the push: OAuth1 RESTlet payload for credit memo needs the 'apply' clause.
Scenario · Customer disputes same invoice twice
Existing dispute record not surfaced when new one opens.
- Find existing:
SELECT * FROM invoice_disputes WHERE invoice_id=<id> AND status='open' - Merge: Close newer; add note referencing existing.
- Prevent: Add UNIQUE on (invoice_id, status='open').
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.
-
OPEN
Photo evidence storage
Attachments referenced but storage path isn't standardized. R2 path scheme needs definition.
-
STUB
Auto-suggest resolution from history
If we've credited 5 prior pricing disputes for this customer, suggest a credit. Today every dispute starts fresh.
-
DECISION
Time-bar on disputes
Today no limit. Should disputes for invoices > 90 days be auto-declined?