Dunning Event invoice.payment_failed invoice.paid recompute TRIGGER
State Stores subs_dunning subs_mandate subs_stuck_events subs_account subs_customer invoices FULL CONTEXT — 6 STORES
Dunning Brain read-state evaluate-rules compute-delta decide ONE FUNCTION — IDEMPOTENT
Consequence Actions flag ban cancel unflag SIDE EFFECTS
Notifications send-notification ALWAYS
dunning event full account context coordinated decision notification state mutation

The dunning brain is a single idempotent function in subscriptions-api. On every trigger it reads all six stores, evaluates account-type rules, computes the minimal diff from current state, and dispatches typed consequence actions. Gather → Compute → Apply → Decide. One code path for all dunning decisions.

Dunning is event-driven, not cron. Stripe fires webhooks → Pub/Sub → billing-webhooksPOST /client/v4/dunning/eventssubscriptions-api. Five event types drive all dunning decisions.

Stripe EventDunning Effect
invoice.payment_failedEvaluate retry — increment retry count, apply account-type retry rules
invoice.marked_uncollectibleFlag + ban + cancel coordinated — two parallel Pub/Sub messages
invoice.payment_succeededEvaluate lifting — check all outstanding invoices, potentially unflag
invoice.finalizedTrack invoice state — no dunning action, audit trail only
charge.refundedEvaluate impact — reassess dunning status after refund

When Stripe marks an invoice uncollectible, two parallel Pub/Sub messages are emitted: flag-as-bad-debt (operational — triggers cancel, flag, and ban) and dunning-event (audit — records to subs_dunning). The operational path sets bad_debt=true in subs_account, bans the account, and cancels active subscriptions. All three actions are dispatched as a coordinated unit — never partial.

Bad Debt Is a Coordinated Action

Flag, ban, and cancel happen together or not at all. The consequence orchestration layer dispatches all three as a single atomic decision. Partial state — flagged but not banned, or banned but not cancelled — indicates a bug, not a race.

When a payment succeeds on a previously uncollectible invoice, PayBadDebt sets bad_debt=lifting — a transitional state, not clearance. Actual unflagging happens via drift remediation, not directly from the payment event. The time between lifting and cleared is undefined: there is no guaranteed SLA, no scheduled job, and no alert if the transition stalls.

Account TypeDunning Treatment
PayGo StandardFull dunning — all retry rules, bad debt flagging, ban, cancel
StartupSame as PayGo Standard — full dunning applies
Enterprise LegacyExcluded from dunning — no retry, no flag, no ban
ContractExcluded from flagAsBadDebt — retries may still apply
Partner PayGoSkipped entirely — dunning engine returns early
Academic / NonprofitSingle retry only, then manual review — no automatic escalation
Unsupported: cloudflare_ent, msp, partners_pay_go, sfccRestrictionUnsupportedAccountType returned — no action taken

PayPal payments use Stripe's send_invoice flow rather than automatic collection. When a PayPal payment fails, Stripe's automatic dunning never fires — the invoice.payment_failed webhook is never emitted. The dunning brain never sees the failure. No retry is scheduled, no bad debt flag is set, no notification is sent to the customer.