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

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.

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.

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

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/pay is a documented transition from uncollectible. 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.

  • 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": null in Stripe’s mark_uncollectible API 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.

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. finalized is 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 → open edge; its only non-void exit is straight to paid. 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.

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.

  • Any uncollectible → finalized edge. 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.
  • Both public pages agree, from one predicate, guarded by web-v2/tests/invoiceTerminal.test.ts — mutation-verified: dropping uncollectible fails 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/lib hold every read of the two fields, most of them in invoiceTerminal.ts. invoiceDueCents says what is owed: nothing on a void, amount_due otherwise (a write-off stays on the books, as above). invoiceRowAmount names 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 lowers amount_due and never reaches amount_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-syntax rule in web-v2/eslint.config.js fails a read of payment_status or amount_due_cents outside src/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_recovered with amount_paid_cents > 0. A run settled with no payment also closes as payment_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/stats adds recovered_count; resolved_count keeps its meaning (every resolved run).