Skip to content
Hardcount
Run it locally
Pre-1.0 · one maintainer · Stripe and cards only · no SDKs (client libraries) yet · no production users yet. Docs describe v0.9.1 (29 Oct 2026) · What’s new →

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)

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

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

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.

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)

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.

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.

  1. Skip-if-done, read from the entity. A narrow SubscriptionStateReader answers “is this subscription already terminal”. Chosen over matching the movers’ error strings (a heuristic proxy) and over recording applied-actions on dunning_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).

  2. 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 already uncollectible; 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 by TestExhaust_PartialFailure_ReattemptSkipsTheDoneHalf, which fails when the gate is removed.

  3. “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.

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.

  • 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_action is 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/json drops unknown fields silently, so a caller still sending final_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.escalated now carries final_subscription_action, final_invoice_action, and applied. 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 aggregate final_action could 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.
  • Stripe’s unpaid subscription state (a third subscription fate: keep the subscription, keep generating invoices, stop collecting). Hardcount’s pause covers the operator intent with keep_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 from ListDueRuns while 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 and ClaimExhaustion guards re-entry.) exhaustRun overwrites it on both outcomes (NULL + escalated, or +24h + action_failed).
  • Exhaust-on-entry takes ClaimExhaustion first: a CAS that moves next_action_at from the listed value to a fresh lease. Exactly one processor wins; a loser fires nothing.
  • UpdateRunIfActive guards on state = 'active' (escalated is terminal for the automated path; ResolveRun keeps state <> 'resolved' because escalated -> resolved after a late payment is legitimate) and refuses to write an active run without next_action_at. The automated resolves inside processRun and exhaustRun (the paid/terminal pre-check, the late re-check, a successful retry) resolve with ResolveRun(..., 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 lowered max_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_action surfaces 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 on mark_uncollectible who 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.escalated webhook are written after the escalation commits (a crash between loses them). Trigger: the one-attempt-path outbox work.
  • InvoiceDetail (“Next retry”) and the /dunning list’s “Next Retry” column render any next_action_at as 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 stays active with the lease it already carries as next_action_at, so a dunning tick after the lease looks again. Both ways into exhaustRun pass 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 is payment_status processing or unknown; the #976 record joins it there. The seven former callers of InvoicePaymentStatus.IsInFlight() ask it instead, with no change in what they do, and TestPaymentInFlight_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 pending or failed. Only the 5-minute charge hold says a charge is running, and PaymentInFlight() 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 in PaymentBlocksActionHeld. The record closes this, because it exists for the whole call.
  • A final retry that itself comes back processing resolves 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.