ADR-110: A written-off invoice closes the customer's self-service payment route
Status: Accepted (2026-08-05; amended 2026-10-22; its 2026-08-05 amendment was superseded by ADR-113 on 2026-08-06)
Relates to: ADR-036 (dunning campaigns),
ADR-112 (mark_uncollectible
is the terminal INVOICE action, set independently of the subscription’s),
ADR-107
(the other “terminal until a human” state)
Shipped by: PR #734
Summary
Section titled “Summary”When an invoice is written off (status uncollectible), the customer can no
longer pay it through Hardcount’s public pages. The hosted invoice page and the
payment-update page both say collection is closed and point to support. They
keep the money figure but label it “Invoice amount” instead of “Amount Due”.
The payment-update page keeps its add-a-payment-method button, because a saved
card still serves the next invoice. Both pages read one shared check in
web-v2/src/lib/invoiceTerminal.ts. Since 2026-10-22 every dashboard screen
reads an invoice’s payment status and amount due through it too, and a lint
rule fails any other read. Nothing in Hardcount starts a charge against a
written-off invoice (see ADR-113), so it is settled only by recording money
that has already arrived: an offline payment the operator enters
(RecordOfflinePayment), or a provider charge that the ADR-108 search finds
already succeeded and adopts.
Context
Section titled “Context”Hardcount has three terminal invoice outcomes. Two are unambiguous — paid, and
voided (annulled; the debt never existed). The third, uncollectible, is
not: it is a write-off, the invoice stays on the books for audit, and
RecordOfflinePayment remains legal against it.
Two customer-facing pages disagreed about it. The hosted invoice page had said “This invoice is closed” and dropped its Pay button since before this ADR; the tokenized payment-update page had never been taught the state and kept showing an amber “Payment method needed · Amount Due $30.00” demand. One invoice, two links from the same dunning email, opposite answers (found live on TC Walk Co VLX-000152; VLX-000136 had been sitting in that state with a live token).
Making them agree required deciding which page was right — which is a product question, not a consistency one, so it was checked against the industry before being settled.
Decision
Section titled “Decision”A written-off invoice ends the customer’s ability to pay it themselves. Both public surfaces say collection is closed and point to support. The money figure is retained and relabelled from “Amount Due” to “Invoice amount” — the number stays true, only its framing as a demand is dropped.
The add-a-payment-method button stays on the payment-update page even though the hosted invoice page drops its Pay button. These are different actions: paying this invoice is closed, saving a card still serves the next one. Payment-method capture is account-scoped, not invoice-scoped.
Both pages read one predicate (web-v2/src/lib/invoiceTerminal.ts) rather
than re-typing the status list, because this rule had been missed three times
— #651 relabelled one figure and left its twin, the FLOW D4 walk found the
payment-update page ignoring status entirely, and that fix then stopped at
paid || voided.
The decisive argument is internal, not parity
Section titled “The decisive argument is internal, not parity”At the time of this decision Hardcount could not charge a written-off invoice at
all: chargeInvoice refused anything but finalized, and the status machine
had no edge back. A customer Pay button would have called a gate that rejects
it — so removing it removed a button pointing at a closed door.
(History: the 2026-08-05 amendment below briefly made an operator able to charge a written-off invoice; ADR-113 removed that the next day, so the flat “cannot charge” is TRUE again — now by decision rather than by limitation. The customer-facing closure this section argues for was never touched.)
Industry evidence (verified 2026-08-05)
Section titled “Industry evidence (verified 2026-08-05)”2 verified closed · 0 verified open · 1 unverified.
- Chargebee — CLOSED. Write Off produces an adjustment credit note and
“the invoice’s status will be marked as Paid”;
amount_due→ 0. Its customer surfaces pay only from the unpaid set (“the list of unpaid invoices”). https://www.chargebee.com/docs/billing/2.0/invoices-credit-notes-and-quotes/invoice-operations - Recurly — CLOSED. “An invoice is Failed when it is deemed uncollectable and written off as bad debt”; the write-off “removes the amount from the customer’s account balance.” Dunning halts entirely: “Stopping collection also halts all dunning activity.” https://docs.recurly.com/docs/invoices
- Stripe — model verified, UI UNVERIFIED. Stripe’s model is the one Hardcount
mirrors: “An uncollectible invoice might still be paid”
(https://docs.stripe.com/revenue-recognition/methodology/subscriptions-and-invoicing),
in explicit contrast to void — “the invoice can no longer be paid.”
POST /v1/invoices/:id/payis a documented transition fromuncollectible. But Stripe never documents what its Hosted Invoice Page renders for uncollectible — it documents that page’s behaviour for void and for URL expiry, and is silent here. - Lago and Orb — the state does not exist. Zero hits for uncollectible/write-off/bad-debt across Lago’s 401 doc pages plus its official OpenAPI, and Orb’s 309 doc pages plus its full API reference. “Written off but still on the books” is a Stripe concept two of the four usage-based peers declined to copy.
On the label, Hardcount is ahead of the closers. Stripe preserves the amount
on write-off (amount_due unchanged), which anchors keeping the figure.
Chargebee and Recurly zero it, and Chargebee’s surface then tells the customer
the invoice is “Paid” — false to someone who never paid.
Do NOT cite as parity
Section titled “Do NOT cite as parity”- Any peer’s customer-facing copy. No vendor publishes hosted-page strings for any invoice state. Hardcount’s wording rests on its own reasoning.
- Stripe’s hosted page. Unverified in both directions. The
"hosted_invoice_url": nullin Stripe’smark_uncollectibleAPI example is a trap, not evidence — that example invoice was never finalized (number: null,finalized_at: null), and the field is null for any unfinalized invoice. - Chargebee/Recurly closure is entailed from mechanism (balance → 0) plus their payable-set wording, not from a sentence saying “the Pay button is hidden.” Strong, but entailment. Recorded this way deliberately: ADR-078 once carried an inference written as a citation and had to be corrected.
The counter-signal, answered
Section titled “The counter-signal, answered”Orb resolves the same tension the opposite way. When its dunning exhausts,
the invoice stays issued and the customer portal — which “embed[s] a form
… that allows your user to pay invoices manually” — stays payable. Orb
separates “we stopped chasing you” from “you can’t pay.”
Orb can do that because it never claims a write-off happened; it has no such state. Hardcount does, and a write-off is an accounting assertion — bad debt recognised. Offering a self-service payment button on an invoice the business has formally written off invites the state to be reversed by a route that cannot currently reverse it. The honest surface is the one that matches what the system can actually do.
Amendment 2026-08-05 — the limitation is closed, by charging in place
Section titled “Amendment 2026-08-05 — the limitation is closed, by charging in place”⚠️ SUPERSEDED by ADR-113 (2026-08-06). Charge-in-place recovery shipped on 2026-08-05 and was removed the next day on industry evidence: 1 of 6 verified platforms (Stripe alone) charges the written-off object. The section below is kept as the state this was decided in. The core decision of THIS ADR — public pages closed, add-a-card kept — stands, and the “three refusals” table below describes gates that no longer exist.
The limitation this ADR recorded — “Hardcount’s write-off is a card dead end where Stripe’s is not” — is closed. Its trigger fired the same day it was written, so the section it replaced is deleted rather than appended to: leaving a “deferred with a trigger” block standing next to the thing that closed it is the kind of doc rot this repo pays for.
The remedy this ADR prescribed was wrong. It named an operator-side
reopen (uncollectible → finalized). What shipped instead charges the
invoice in place, and the reopen edge is now explicitly rejected:
- A reopen would erase the write-off, not reverse it.
grep -rn uncollectible internal/analytics/returns zero hits — AR and open-invoice counts are recomputed from CURRENT status with no time dimension. After a flip, no query in the system could tell an accountant the invoice had ever been written off. - It would re-enter automation.
finalizedis the state every sweep and claim predicate admits, so a reopen silently re-arms dunning enrolment and the auto-charge sweeps on a debt the business had given up on. - It is not Stripe’s shape either. Stripe has no
uncollectible → openedge; its only non-void exit is straight topaid. Charging in place is the closer parallel, not the further one.
So the invoice stays uncollectible until money actually arrives, then settles
uncollectible → paid — a transition markPaidReportingTransition already
allowed — ending with uncollectible_at AND paid_at both set, which is
Stripe’s status_transitions shape.
The customer-facing closure this ADR decided is untouched. Recovery is
operator-initiated only: ClaimChargeForManualCollect admits uncollectible
while the dunning and auto-charge claims still require finalized. An operator
may charge a written-off invoice; no machine may. The public pages are
unchanged, so §“Decision” above still holds in full.
Three refusals, enforced in the claim CAS
Section titled “Three refusals, enforced in the claim CAS”A service-side pre-read would be a TOCTOU against the very sweeps that create these states, so each gate is SQL in the claim; the handler repeats them only to answer why with a typed 409 rather than a bare claim miss.
| Refusal | Code | Why |
|---|---|---|
| tax already reversed | tax_reversed_unrecoverable |
MarkUncollectible always attempts an upstream tax reversal — irreversible at the provider, and not re-committable here (23h calculation TTL; tax computation is draft-only). Charging would collect tax the tenant has already reported as not collected. |
| threshold usage re-billed | recovery_superseded |
Writing off a threshold invoice does not stop the next cycle close re-billing that usage window; collecting this one double-bills it. (Removed 2026-10-23: a written-off threshold invoice now keeps its window billed, so nothing re-bills it. See the ADR-066 amendment.) |
| unapplied clawback relief | relief_not_reissued |
An issue_pending credit note that was voided before issuing never reduced amount_due, so the figure is stale-HIGH and charging over-collects. |
The tax gate keys on the structural fact (tax_transaction_id != ''), not
the best-effort tax_reversed_at stamp, which is written post-commit and can
lag its own sweep.
Parity note, verified: Stripe also decreases reported tax on
mark_uncollectible. The difference is that Stripe owns its tax ledger and can
re-report on recovery; Hardcount reversed an external Stripe Tax transaction it
cannot restore. So the refusal is forced by architecture rather than by a Hardcount
defect — and tax re-commit on recovery remains deferred, trigger: the first
tax-registered tenant to hit tax_reversed_unrecoverable.
Still deferred
Section titled “Still deferred”- Any
uncollectible → finalizededge. Trigger: a DP needs written-off invoices back in automated collection — which needs a bad-debt journal entry first, not a status flip. - Restarting dunning on recovery. A failed recovery deliberately starts no campaign (both dunning-start sites are guarded). Trigger: recovery becomes a collections motion rather than a one-off.
RecordOfflinePayment’s own tax and threshold exposure. The same two hazards pre-exist on the offline route and are NOT gated there, deliberately: the money already arrived, and refusing to record it would only make the books wrong too. Governing rule — refuse to CREATE a bad money event; never refuse to RECORD one that already happened. Tracked as its own defect. The threshold half is closed (2026-10-23): the cause was the re-bill, not the payment. A written-off threshold invoice keeps its window billed, so a payment on it settles that window once, on any route (ADR-066 amendment). The tax half stands.
Consequences
Section titled “Consequences”- Both public pages agree, from one predicate, guarded by
web-v2/tests/invoiceTerminal.test.ts— mutation-verified: droppinguncollectiblefails 3 tests, and a predicate returning true for everything fails the 2 negative controls (an unknown status must stay collectible, since defaulting to terminal would silently stop asking for money that is owed). - Written-off invoices are settleable only out of band until the trigger above fires.
- The “Amount Due”/“Amount due” casing split on the hosted invoice page is gone; both labels come from the shared helper.
Amendment 2026-10-22 — the dashboard reads the same helpers
Section titled “Amendment 2026-10-22 — the dashboard reads the same helpers”The rule this ADR names, “a closed invoice must not read as owed”, was kept on
the two public pages and missed on the operator dashboard. A voided invoice
showed “pending” and “$29.00 due” on six screens, among them Home and the
Invoices and Dunning lists. Its own page said $0.00. Home counted it as a
failed payment. The Dunning page counted it at risk, and counted a run closed
by a void as recovered (product sweep 2026-10-21, A6/A7, E1–E3, E7). Each screen read
payment_status and amount_due_cents itself, and most forgot the terminal
states.
What changes:
- The helpers in
src/libhold every read of the two fields, most of them ininvoiceTerminal.ts.invoiceDueCentssays what is owed: nothing on a void,amount_dueotherwise (a write-off stays on the books, as above).invoiceRowAmountnames the figure (“$29.00 due”, “$29.00 paid”, or the bare amount on a draft, void or write-off). “Paid” is the payment plus the customer credit applied, because credit lowersamount_dueand never reachesamount_paid: an invoice a prepaid commit paid in full read “$0.00 paid”. The action gates (invoiceCanCollect,invoiceAwaitingPayment,invoicePaymentInFlight) moved there from the invoice page. - The payment chip beside an invoice’s badge is gone on a write-off too, not only on a void: “Pending” or “Failed” beside “Uncollectible” read as a payment still expected. “Unknown” stays: a parked charge can be written off and is marked paid later if it succeeded.
- A void shows $0.00 where a figure is headed “Amount due”: the Invoices list, which also sorts it as 0, and the Dunning list. A row that names its own figure (Home, the command palette, “Last invoice”) shows the invoice amount beside the “Void” badge.
- A
no-restricted-syntaxrule inweb-v2/eslint.config.jsfails a read ofpayment_statusoramount_due_centsoutsidesrc/lib. The frontend CI job runs it. - The server counts the same way. Home’s failed payments and the dunning
at-risk total cover open invoices only. “Recovered” and the recovery rate
count only runs a payment closed:
resolution = payment_recoveredwithamount_paid_cents > 0. A run settled with no payment also closes aspayment_recovered: by a credit note that leaves nothing due (ADR-061, amendment 2026-10-19), or by customer credit that a retry applies. That run counts as resolved, not recovered. Recovered means a payment collected money; credit is not a payment, even credit the customer bought.GET /v1/dunning/statsaddsrecovered_count;resolved_countkeeps its meaning (every resolved run).