ADR-112: Dunning exhaustion settles two questions, not one
Status: Accepted (2026-08-05; amended 2026-10-08, 2026-10-19 and 2026-10-22)
Amends: ADR-036 (dunning campaigns —
final_action and its 2026-05-16 four-value enum)
Relates to: ADR-110
(a written-off invoice closes self-service payment),
ADR-111 (a write-off has no tax leg)
Summary
Section titled “Summary”When dunning (automatic retries of a failed payment) runs out of retries, two
independent settings now decide what happens: final_subscription_action
(none, pause, cancel) and final_invoice_action (none,
mark_uncollectible). They replace the single final_action enum, which
could not express cancel plus write-off. Three of its four values also left
the unpaid invoice open forever without telling the operator. A policy created
without these fields gets (pause, none), because a write-off is an accounting decision the
platform should not make unasked, and the policy form now says the invoice
stays open. Automatic void is refused, and every entry point rejects the old
final_action key by name. The policy every new account starts with changes
nothing (ADR-123).
Neither action fires while a payment on the invoice may still take money: its
payment is processing or unknown, or a charge hold is set (a charge running,
or one whose result was lost, ADR-127).
The final action takes the hold itself, so no charge starts while it cancels.
The run waits and is checked again (amendments of 2026-10-19).
Context
Section titled “Context”When a dunning run exhausted its retries, one column decided everything:
final_action ∈ { manual_review, pause, mark_uncollectible, cancel_subscription }Three of those four names describe the subscription. One describes the invoice. They were never alternatives — they answer different questions — and forcing a single choice produced two defects.
The combination nobody could express. Stripe’s own default outcome is cancel the subscription and write the invoice off. In Hardcount you picked one.
The debt that never closed. In manual_review, pause, and
cancel_subscription — three of the four — the unpaid invoice was left
finalized / payment_status='failed' / amount_due > 0 permanently.
Verified, not assumed: MarkUncollectible has exactly three callers (the
operator endpoint, the dunning terminal action, the dunning resolve dialog),
and subscription.Service.Cancel never touches the outstanding invoice. No
sweep closes it. Cancel the subscription and the receivable sits open with no
closer anywhere in the tree.
The second defect is the worse one, and it was invisible in the UI: an operator picking “Cancel subscription” had nothing telling them the debt would outlive the subscription.
Industry evidence (verified verbatim 2026-08-05)
Section titled “Industry evidence (verified verbatim 2026-08-05)”Every peer that has a write-off concept configures both axes. Hardcount was the only one that made them exclusive.
| Subscription fate | Invoice fate | |
|---|---|---|
| Stripe | “Cancel the subscription” / “Mark the subscription as unpaid” / “Leave the subscription overdue” | derived — cancel ⇒ invoice uncollectible |
| Recurly | subscriptions are “only automatically canceled when an invoice fails dunning — and only if your dunning settings are configured to expire subscriptions at the end of the dunning cycle” | “Recurly marks an invoice as failed at the end of the cycle if unpaid. A failed invoice is written off as bad debt” — default on; or “configure dunning to never auto-fail the invoice, leaving it overdue indefinitely” |
| Chargebee | “you can define the final action taken on the subscription. You can choose to let the subscription remain active or cancel the subscription at once” | “Mark the invoice as not paid, Void Invoice, Write-off the invoice, Reverse the invoice…” |
| Hardcount (before) | one enum — exactly one of four |
- Stripe: https://docs.stripe.com/billing/revenue-recovery/smart-retries — the three subscription settings, quoted above. The pairing to write-off is documented separately: “If your payment retry setting for Stripe subscriptions is to cancel the subscription, it marks the associated invoice as uncollectible.”
- Recurly: https://docs.recurly.com/docs/dunning-management
- Chargebee: https://www.chargebee.com/docs/payments/2.0/dunning/dunning-v2 — “these are two separate choices.”
Lago and Orb have no write-off state at all (ADR-110’s finding: zero hits across Lago’s 401 doc pages + OpenAPI and Orb’s 309 doc pages + API reference), so they are not evidence either way on this question.
On the value itself, Hardcount was already at parity. mark_uncollectible
leaving the subscription running matches Stripe verbatim — “Stripe treats
the subscription as if the user paid and stops attempting to collect
payment. The user’s subscription continues as normal.” The gap was never
the semantics of the value; it was the shape of the menu.
Decision
Section titled “Decision”Split final_action into two independent columns.
final_subscription_action ∈ { none, pause, cancel }final_invoice_action ∈ { none, mark_uncollectible }manual_review is not missing — it is (none, none), which is exactly
Chargebee’s “remain active” + “not paid”.
Migration 0171 backfills behaviour-preservingly. No existing policy changes what it does:
| was | becomes |
|---|---|
manual_review |
(none, none) |
pause |
(pause, none) |
cancel_subscription |
(cancel, none) |
mark_uncollectible |
(none, mark_uncollectible) |
The default stays (pause, none)
Section titled “The default stays (pause, none)”0071’s default is kept exactly, and the invoice half deliberately does not default to a write-off — even though Recurly defaults its auto-fail on.
A write-off is an accounting assertion that a debt has gone bad. A machine should not make one on a fresh tenant’s behalf, unasked, on a policy they never opened. Same reasoning as ADR-111, which removed the write-off’s tax leg rather than have the platform decide a jurisdictional relief question for the tenant.
What replaces the default is saying so: the policy form now states the consequence of the chosen pair in plain language, including “The unpaid invoice stays open and due. Nothing closes it automatically — you collect it or write it off yourself.” The old form said nothing at all about the debt.
Auto-void and Chargebee’s “Reverse” are refused
Section titled “Auto-void and Chargebee’s “Reverse” are refused”Chargebee offers four invoice fates; Hardcount offers two. Void is not a harsher
write-off — it is a different assertion: this sale never happened. It
annuls the supply and reverses tax (ADR-111 keeps that behaviour precisely
because void means annulment). Having a machine assert that delivered
usage was never sold is the bad money event the governing rule refuses to
CREATE. Chargebee’s “Reverse” (mark paid + adjustment credit note) is the
same category, further along — it makes the invoice read Paid to a
customer who never paid.
Deferred with a trigger: a design partner with a contractual auto-void policy, at which point it is an operator-chosen action on a real contract rather than a default the platform invented.
Applying two actions where there was one
Section titled “Applying two actions where there was one”Splitting one terminal action into two created a convergence problem that
did not exist before, because a failed exhaustion is re-attempted 24h later
and both subscription movers refuse an already-terminal subscription
(cancelSpec() allows only draft/trialing/active; SetPauseCollection
rejects canceled/archived), while MarkUncollectible returns “invoice is
already uncollectible” rather than a no-op.
Without care, a re-attempt would fail forever on the half that already succeeded and never reach the half that did not.
Three rules make it converge.
-
Skip-if-done, read from the entity. A narrow
SubscriptionStateReaderanswers “is this subscription already terminal”. Chosen over matching the movers’ error strings (a heuristic proxy) and over recording applied-actions ondunning_runs(a second source of truth — and if an operator canceled the sub by hand, skipping is the correct behaviour, which only the entity knows). -
Subscription first, invoice second — and the invoice half is gated on the subscription half succeeding. Not stylistic.
exhaustRun’s late-paid re-check resolves any run whose invoice is alreadyuncollectible; so writing the invoice off while the subscription action is still outstanding makes the re-attempt return at that check and strand the cancel permanently. Pinned byTestExhaust_PartialFailure_ReattemptSkipsTheDoneHalf, which fails when the gate is removed. -
“Applied” means the requested end state HOLDS, not that this call moved it. The escalation email describes the subscription’s and invoice’s state, and “your subscription has been canceled” is true whoever canceled it. Keying on authorship would silently drop the sentence on exactly the re-attempt paths this split introduced.
What the customer is told
Section titled “What the customer is told”The escalation email composes from the outcome, never the policy — a one-off
invoice under a cancel policy cancels nothing and must say nothing. It
still refuses to offer a “Pay invoice” link on a written-off invoice, since
the hosted page has no Pay button for one (ADR-110); the invoice half
alone decides that, because the subscription half never changes whether this
invoice can be paid online.
Consequences
Section titled “Consequences”- Cancel-and-write-off is expressible — Stripe’s default outcome, previously unreachable.
- The permanently-open debt is now a stated, chosen outcome rather than an unstated side effect of three of four menu items.
- Breaking, pre-launch (0 customers).
final_actionis gone from the API, the recipe DSL, and the webhook payload. Every entry point rejects the old key by name rather than ignoring it —encoding/jsondrops unknown fields silently, so a caller still sendingfinal_action: "cancel_subscription"would otherwise have stored a policy that pauses and never writes off, with a 200 on the way out. Reference recipes take a MAJOR version bump (openai/anthropic 4.0.0 → 5.0.0, replicate 2.0.0 → 3.0.0) because a recipe pinned at the old version no longer parses. dunning.escalatednow carriesfinal_subscription_action,final_invoice_action, andapplied. The third is the one subscribers reconciling their own records need: a one-off invoice under a cancel policy escalates with nothing canceled, and an aggregatefinal_actioncould not say so.- The down migration is lossy by construction — the two columns express
combinations the single enum has no value for. Precedence: the
subscription action wins. A rollback rolls code back too, and that code
fires one action; losing the subscription half means a policy whose
operator asked to STOP billing keeps billing, while losing the invoice
half only leaves a debt open on the books. Leave the debt, never keep
charging. Asserted, and mutation-verified, in
TestMigration0171_DunningActionMapping.
Still deferred
Section titled “Still deferred”- Stripe’s
unpaidsubscription state (a third subscription fate: keep the subscription, keep generating invoices, stop collecting). Hardcount’spausecovers the operator intent withkeep_as_draft. Trigger: a design partner who needs the drafted invoices finalized rather than held. - A dunning-exhaustion outcome per customer segment. Stripe reaches this through automations. Trigger: the first tenant who needs different terminal outcomes for enterprise vs self-serve on the same retry schedule.
Amendment 2026-10-08 — an interrupted exhaustion is finished, and escalates once
Section titled “Amendment 2026-10-08 — an interrupted exhaustion is finished, and escalates once”Defect (SB-1). The final failed retry wrote next_action_at = NULL and
committed before exhaustRun ran the two terminal actions. Anything that
interrupted exhaustRun after that commit (a deploy SIGTERM, leader-lease loss,
an operator pause, a crash, a DB error on the action_failed or escalation
write) left the run active with no next action. Both due-run pickers select on
next_action_at, the per-invoice UNIQUE blocks re-enrolment, and the doctor’s
stale-run check required next_action_at IS NOT NULL, so nothing ever finished
it: the subscription was never paused or canceled, the invoice never written
off, no escalation email.
Defect (SB-2). UpdateRunIfActive guarded on state <> 'resolved', which
also matched escalated rows, and exhaust-on-entry fired terminal actions with
no claim. A second processor could escalate a run twice or reopen it.
Decision.
- The final attempt writes an exhaustion lease (
next_action_at= the attempt’s anchored instant + 15 min), both at the pre-charge persist and at the reschedule. On the wall clock the run stays hidden fromListDueRunswhile the charge and the inline exhaustion run, and is due again if they never finish. (Under test-clock catchup the lease is in simulated time and an advance can pass it; there the clock’s single-flight advance is the exclusion andClaimExhaustionguards re-entry.)exhaustRunoverwrites it on both outcomes (NULL + escalated, or +24h + action_failed). - Exhaust-on-entry takes
ClaimExhaustionfirst: a CAS that movesnext_action_atfrom the listed value to a fresh lease. Exactly one processor wins; a loser fires nothing. UpdateRunIfActiveguards onstate = 'active'(escalated is terminal for the automated path;ResolveRunkeepsstate <> 'resolved'because escalated -> resolved after a late payment is legitimate) and refuses to write an active run withoutnext_action_at. The automated resolves inside processRun and exhaustRun (the paid/terminal pre-check, the late re-check, a successful retry) resolve withResolveRun(..., fromActiveOnly=true), so a stale processor cannot flip an escalated run to resolved either. Settle-driven and operator resolves keep escalated -> resolved.- A re-driven escalation stamps the final retry’s instant (
LastAttemptAt) only when the run was listed carrying that retry’s lease (the proof the final retry was the exhausting one), the record the uninterrupted path writes. Every other exhaust-on-entry (an action_failed re-attempt, a loweredmax_retry_attempts) stamps the instant it ran (playbook class J). Scheduling bases (the lease, +24h) use the attempt instant, clamped to now on the wall clock. - New doctor check
dunning_active_run_without_next_actionsurfaces runs stranded before this fix. No backfill: the check names the one-line repair.
Deferred, with triggers.
- A crash after the write-off committed but before the escalation landed
recovers as
invoice_not_collectible(the late-paid re-check sees the uncollectible invoice), so the run closes without the escalation email. Every terminal action did apply. Trigger: the first tenant onmark_uncollectiblewho reports a missing final notice. - A holder frozen longer than the 15-minute lease can repeat terminal actions
alongside the claim winner (the escalation itself stays exactly-once on
state). Trigger: two or more dunning replicas in production. Since 2026-10-22 the pause is skip-if-done: a pause already in force writes nothing (ADR-036 amendment 2026-10-22). - The escalated timeline row and
dunning.escalatedwebhook are written after the escalation commits (a crash between loses them). Trigger: the one-attempt-path outbox work. - InvoiceDetail (“Next retry”) and the
/dunninglist’s “Next Retry” column render anynext_action_atas a retry, including the lease and the action_failed re-attempt. Copy fix; UI needs a show-before-PR walk.
Amendment 2026-10-19 — no final action while a payment may be in flight
Section titled “Amendment 2026-10-19 — no final action while a payment may be in flight”Gap. Just before the final actions, exhaustRun reads the invoice again.
That check closed the run when the invoice was paid, voided or written off.
It did not ask whether a payment could still take money. So if the invoice’s
payment went processing or unknown after the run was picked, the
subscription was still canceled or paused, and a cancel cannot be undone.
Today that is a short race. The due-run pickers skip an open invoice that is
processing or unknown. The final retry’s own processing or unclear
answer never reaches the final actions either: the first resolves the run as
recovered, and the second is a skipped retry that does not count. The fix for
#976 makes the hold a common
path. It records each charge on the invoice before Hardcount calls Stripe. A crash
during the final retry leaves that record, the payment status still failed,
and the run at its last retry with its 15-minute exhaustion lease. Without
this amendment, exhaust-on-entry would cancel the subscription when that lease
ends, before the check with Stripe can find the payment.
A timeout on the final retry takes a different path. It is an unclear answer, so the retry is skipped: the attempt and the lease are rewound, and the run never reaches the final actions. #976 handles that path in its own design.
Decision.
- The check asks
Invoice.PaymentInFlight(), after the paid, voided and written-off cases. While it is true, neither final action fires and nothing is written. The run staysactivewith the lease it already carries asnext_action_at, so a dunning tick after the lease looks again. Both ways intoexhaustRunpass this check: inline after the final retry, and exhaust-on-entry. Invoice.PaymentInFlight()is the one answer to “may a payment be open on this invoice?”. Today it ispayment_statusprocessingorunknown; the #976 record joins it there. The seven former callers ofInvoicePaymentStatus.IsInFlight()ask it instead, with no change in what they do, andTestPaymentInFlight_AskedOfTheInvoice(internal/arch) fails on a new direct call.
Consequences.
- A payment that succeeds closes the run as recovered, and the subscription is untouched. One that fails gets the final actions on the next dunning tick once the lease has ended.
- A parked invoice (ADR-107) holds its run until an operator writes it off. The write-off closes the run as not collectible, without the subscription action. That was already true, because the pickers skip a parked invoice. The parked-invoices runbook now says to apply the subscription action by hand, but only when the run had used every retry. An invoice parked before dunning started, or with retries left, never reached its final action.
- A policy with no final actions waits too, so the escalation email is not sent while the payment may still succeed.
- Pinned on real Postgres by
internal/dunning/exhaust_inflight_integration_test.go, both ways in, with a payment that definitely failed as the control.
Not covered yet.
- A charge call still waiting on Stripe, until the #976 record ships
(it has, as the charge hold: see the second amendment of 2026-10-19 below).
During the call the payment status is still
pendingorfailed. Only the 5-minute charge hold says a charge is running, andPaymentInFlight()does not read the hold. So an exhaust-on-entry that lands during an operator’s Collect payment still cancels or pauses the subscription. The write-off half is refused, by the charge-hold check inPaymentBlocksActionHeld. The record closes this, because it exists for the whole call. - A final retry that itself comes back
processingresolves the run as recovered at once. If that payment later fails, nothing retries the invoice (#1289).
Deferred, with a trigger. After a hold that began inline, the escalation
records the final retry’s instant: exhaustStampAt reads the untouched lease
as an uninterrupted exhaustion. The run’s resolved_at and the escalated
timeline row both get that instant, which can be days before the failure that
ended the hold. So the dunning API shows the run resolved too early, and on
the invoice timeline the escalation can sit before that failure. Trigger: a
walk or a tenant who reports the order.
Amendment 2026-10-19 (2) — the final action takes the charge hold
Section titled “Amendment 2026-10-19 (2) — the final action takes the charge hold”ADR-127 makes the
charge hold (auto_charge_claimed_until) mean “a charge may be taking money”
for as long as it is set, live or stale. After the status checks above,
exhaustRun now takes that hold (ClaimChargeHoldForFinalAction) before
either action:
- Refused (any hold is set, or a hosted page is live): neither action
fires and nothing is written, as for
processing. The run keeps its lease, the due-run pickers skip a run whose open invoice has a hold, and the first tick after the hold clears looks again. A lost charge’s hold clears once the stale-hold sweep has asked Stripe: a payment it finds closes the run as recovered. - Taken: the subscription action runs, the hold is released, then the invoice action. While the cancel runs no charge can start — this closes the race in which the hourly sweep charged a no-card invoice whose card had just been attached while dunning canceled its subscription. The cancel runs as the hold’s owner, so its void of this same invoice, when it is a wholly unused prebill, is not refused by its own hold.
That closes the first item under “Not covered yet” above, for a Collect in
progress and for a crash on the final retry. Pinned by
internal/dunning/stale_hold_integration_test.go.
Amendment 2026-10-22 — money-path create routes refuse unknown fields
Section titled “Amendment 2026-10-22 — money-path create routes refuse unknown fields”Money-path create routes and the subscription change routes refuse unknown fields (422, param = key); policy routes keep the by-name refusal.
Amendment 2026-10-22 — a cancel that voids the run’s invoice resolves the run
Section titled “Amendment 2026-10-22 — a cancel that voids the run’s invoice resolves the run”When the final action’s cancel voids the run’s own invoice (a wholly unused
prebill, ADR-118), the void now resolves the run as invoice_voided
itself, in its post-commit legs. The run then neither escalates nor sends an
escalation email, whatever the final invoice action is. Before, a policy
with final invoice action none escalated over the voided invoice. See the
ADR-118 amendment of 2026-10-22.
Amendment 2026-10-22 — the subscription action can wait for the invoice grace
Section titled “Amendment 2026-10-22 — the subscription action can wait for the invoice grace”A pause or cancel that would close a period still inside its invoice grace waits: both actions run when the grace ends (ADR-120 amendment of 2026-10-22). The run stays active and the wait writes only next_action_at. A first exhaustion that waits is not marked action_failed. A re-attempt of an earlier failed action that waits keeps its action_failed resolution until the action runs: the last attempt of that action did fail, and the wait did not attempt it.
The list reads of a run (GET /v1/dunning/runs, and the invoice notices) carry the bound policy’s max_retry_attempts. A run whose attempt_count has reached it retries no charge, so its next_action_at is labelled the final step, not a retry, and the invoice notices no longer promise a retry for it. Before, they keyed on action_failed alone and promised a retry during the grace wait and the exhaustion lease.