Wiki · Workflow companion

Customer invoice dispute

A customer emails saying an invoice is wrong. Resolve the invoice ref, load 90 days of payment + invoice history, classify the dispute reason, draft a response, route to a sales rep if needed, and flag AR for the hold-or-continue decision.

Mixed · Some steps live, others stubbed
What this is

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.

When to use it

Trigger conditions

Heuristic

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.

Step-by-step what happens

The 3 beats

  1. 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.

    Writes outbound_email_log, proposed_actions
    Time ~3s draft + Mike-paced review
    Kind hitl_email_draft
    Status stub
  2. 02

    Escalate to sales rep (HITL)

    A proposed_actions row stages with action_type=customer_escalation assigned to the customer's sales rep. Mike taps approve to route.

    Writes proposed_actions
    Time ~50ms
    Kind stage_proposed_action
    Status real
  3. 03

    AR hold-or-continue review (conditional)

    If prior_disputes >= 2, an ar_hold_review row stages so AR can decide whether to keep shipping. This is the chokepoint that prevents repeat-dispute customers from accumulating uncollectible AR.

    Writes proposed_actions
    Time ~50ms
    Kind stage_proposed_action
    Status real
    Note conditional · prior_disputes >= 2
Outcomes

What's different after the workflow runs

Response drafted
≤ 30s
AI-drafted, Mike-approved
Sales rep routed
Staged
one_per dispute
AR review
Conditional
prior_disputes >= 2
Audit row
ar_disputes
persistent record
Failure modes

What can go wrong and how to recover

Invoice ref doesn't resolve

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.

Customer has no prior dispute history

The conditional AR hold review is skipped. Single-dispute customers don't auto-flag — they get the friendly path.

Sales rep field missing on customer

Escalation has no target. Default routing falls back to Mike's queue. Cleanup task: populate customers.sales_rep.

Dispute is meritorious

Outcome: a credit memo proposed_action stages from the response. That feeds the standard credit-memo workflow, not this one.

Related

Adjacent workflows + diagrams

For developers

Code paths + invariants

ConcernWhere
Workflow contractworkflow_definitions WHERE workflow_type='customer_invoice_dispute'
Dispute tablear_disputes
ClassifierAI classification in inbound_email_triage
Memo-field trickleading '%' on tranid → memo search for PO numbers
Reflexion taginvoice_dispute,customer:<id>
Risk level3
Expected duration~20 min
Triggerevent · inbound_email_triage_classification
// Memo-field search trick — Mike's heuristic if (invoice_ref.startsWith('%')) { // search memo field for PO numbers customers cite sql = "SELECT * FROM transactions WHERE memo LIKE ?"; } // Conditional AR hold review if (prior_disputes >= 2) stageAction('ar_hold_review', { customer_id });
Changelog

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.

DateRoundChangeTouched by
2026-05-26R586Added CHANGELOG · SCHEMA · RUNBOOK · BACKLOG sections — wiki became best-in-class operating documentation.Mike + Claude
2026-05-25R584/R585Wiki originally shipped — 8-section structure (hero / what / when / steps / outcomes / failure-modes / related / for-developers).Mike + Claude
If today is more than 60 days past the latest changelog row, treat live system behavior as the source of truth. The doc may have drifted — verify against the workflow contract in workflow_definitions WHERE workflow_type='customer_invoice_dispute' before acting on these claims.
Schema · data contract

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)

FieldTypeDescription
invoice_idintegerNS invoice internal ID. Required.
dispute_typestring'pricing' | 'qty' | 'delivery' | 'damaged'
customer_claimstringWhat the customer says is wrong.
attachment_idsjson?Supporting photos/docs.

D1 tables written

TableOperationTrigger
invoice_disputesINSERTDispute record with claim + evidence
proposed_actionsINSERTResolution proposal (credit, replacement, etc.)
ns_pending_pushesINSERT (post-HITL)Credit memo or replacement SO
eventsINSERT (invoice.disputed | invoice.resolved)customer_health + AR

Endpoints called

MethodPathPurpose
POST/api/invoice-dispute/openOpen dispute
POST/api/invoice-dispute/:id/resolveMark resolved
GET/api/invoice-dispute/openOpen disputes dashboard

Events fired

event_typeWhenSubscribers
invoice.disputedOn opencustomer_health
invoice.resolvedOn resolveAR + analytics
Runbook · when it breaks

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.

  1. Inspect: SELECT * FROM invoice_disputes WHERE id=<id>
  2. Manual proposal: Use propose_credit_memo or propose_replacement_so.
  3. 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.

  1. Verify in NS: Check the credit memo's 'apply' tab in NS UI.
  2. Manual apply: Apply credit to invoice in NS.
  3. 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.

  1. Find existing: SELECT * FROM invoice_disputes WHERE invoice_id=<id> AND status='open'
  2. Merge: Close newer; add note referencing existing.
  3. Prevent: Add UNIQUE on (invoice_id, status='open').

Logs to check

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:

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.

Backlog · open questions

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.