ADR-111: A write-off has no tax leg
Status: Accepted (2026-08-05)
ℹ️ Reader note (2026-08-06): this ADR’s decision — a write-off has no tax leg — stands in full. But its prose describes
RecoveryBlocksCharge, the recovery tax gate, and the operator card route as live; ADR-113 deleted all three one day later. Sentences below about “the tax gate”, “the card route”, or re-keying the gate are point-in-time records, not current code. Relates to: ADR-110 (written-off invoices close self-service payment), ADR-107 (parked invoices), ADR-108 (parked search-and-adopt)
Summary
Section titled “Summary”Writing an invoice off as bad debt (MarkUncollectible) no longer reverses
its tax with the tax provider. Void and credit notes still reverse tax,
because they change what was sold. A write-off only records that collection
failed. Reversing on write-off was also unsafe: a written-off invoice can
still settle as paid when a later provider search finds the charge
succeeded, and nothing re-reports the reversed tax. The tenant may now pay
more tax than they owe until they claim bad-debt relief themselves, so the
invoice CSV export adds tax_provider and tax_transaction_id. Invoices
written off before this change keep their reversal.
Context
Section titled “Context”MarkUncollectible reverses the invoice’s tax with the provider. Void and
credit notes do too. This ADR says the write-off should not, and records why
the other two still should.
It was written after a design that got most of its own reasoning wrong. Three of four proposed principles were abandoned or refuted on inspection; what survives is one rule and a set of corrections. The refutations are kept below because each is a claim someone will otherwise make again.
Decision
Section titled “Decision”A write-off has no tax leg. MarkUncollectible does not reverse tax.
A write-off changes who paid, not what was sold. The service was delivered, the invoice was correct, the tax was correctly reported on accrual. The entry is Dr bad debt / Cr AR — no tax line.
Void and credit note keep reversing, and that asymmetry is the whole point:
| Event | What changed | Tax |
|---|---|---|
| Void | the supply is annulled | reverse |
| Credit note | what was supplied changed | reverse |
| Write-off | only collection failed | nothing |
Why not the alternative
Section titled “Why not the alternative”The coherent alternative is Stripe’s: reverse on write-off and re-report on
recovery. Stripe does exactly this — mark_uncollectible decreases reported
tax, and “Transitioning the state of an invoice from uncollectible to paid
through the Pay Invoices API” increases it again
(https://docs.stripe.com/tax/reports, verified verbatim).
Hardcount shipped the first half and then blocked the second half with a 409. That is the actual defect the tax gate was papering over.
The round trip is buildable — a re-report is a fresh calculation and a fresh transaction, effective the date of the call, which is what the authorities require anyway. It is rejected because it is more code, is legally premature in at least one supported jurisdiction (below), and buys nothing at a tenant base of zero.
The decisive argument is in our own tree, not in tax law
Section titled “The decisive argument is in our own tree, not in tax law”Independent of any jurisdiction, this path is live today and has no human in it:
- an ambiguous charge parks an invoice (ADR-107)
- write-off is the only legal exit, and the product’s own attention banner advises it
- the reversal fires — tax un-reported
- the ADR-108 provider search keeps written-off parked invoices in scope
(
internal/invoice/postgres.go,status IN ('finalized','uncollectible'), shipped 2026-08-05 in #735) - the search finds the charge succeeded;
markPaidReportingTransitionadmitsuncollectible → paidunconditionally (internal/invoice/postgres.go:1018) - the invoice settles paid, with its tax reversed, and nothing re-reports it
No gate stands on that path — the tax gate guards the card route, not this one. #735 was a correct fix for a real scan-exclusion sink, and it made this reachable. Removing the reversal closes it by construction rather than by adding a third guard.
Note the codebase had already found the same failure mode and guarded only its
narrow case: MarkUncollectible’s in-flight guard exists because *“reverse tax
- flip to uncollectible now and the charge then settles, the tax for a real
collected sale has been reversed → the tenant under-remits”*
(
internal/invoice/service.go:1305-1308). That guard covers a payment in flight at write-off time. It does not cover the payment that arrives later, which is the same failure with a longer gap.
The jurisdictional argument (corroborating, not load-bearing)
Section titled “The jurisdictional argument (corroborating, not load-bearing)”Recovering tax remitted on an uncollected debt is bad-debt relief — a separate claim, conditioned on facts Hardcount does not hold:
- UK — HMRC VAT Notice 700/18 §2.2: the VAT must already have been “accounted for and paid to HMRC”; the debt must be “written off in your day to day VAT accounts and transferred to a separate bad debt account”; and it must have “remained unpaid for a period of 6 months”. https://www.gov.uk/guidance/relief-from-vat-on-bad-debts-notice-70018 A reversal fired at write-off time is therefore premature by construction in the UK for every invoice, not merely optional.
- California (CDTFA Reg. 1642), New York (20 NYCRR 534.7), Texas (34 TAC §3.302) all condition relief on the debt being charged off for federal income tax purposes under IRC §166 — an accounting event on the tenant’s calendar, not ours.
- Recovery after relief requires re-reporting: HMRC 700/18 §3.14 — “you must repay to us the VAT element included in the payment”; CDTFA 1642 — the amount goes in “the first return filed after receipt”.
Deliberately corroborating rather than load-bearing: it is contested, and the
vendors split. Anrok directs users to void an invoice they do not want to
owe tax on, i.e. its sync keys on void and credit notes rather than on
uncollectible (help-center.anrok.com — direct fetch 403, UNVERIFIED
verbatim). Stripe reverses. Two serious vendors disagree, which is itself the
finding: this is a policy choice the platform makes on the tenant’s behalf, and
it is not settled practice.
Two false citations, deleted
Section titled “Two false citations, deleted”The current behaviour was justified by two things that are not true:
internal/invoice/service.go:1050attributed to Stripe: “When an invoice is voided or marked uncollectible, you must reverse the corresponding tax transaction.” That string does not exist in Stripe’s documentation. Stripe documents its own reporting behaviour and imposes no obligation on an external integrator.internal/invoice/service.go:1049cited EU VAT Directive Art. 90 as authority for reversing automatically. Art. 90 says the opposite: reduction on non-payment happens “under conditions which shall be determined by the Member States”, and Art. 90(2) lets member states derogate entirely for non-payment.
Both are removed. Prose born false, load-bearing for a money decision.
What this does NOT change, and the claims that were refuted
Section titled “What this does NOT change, and the claims that were refuted”The subscription still keeps billing after a write-off. A proposal to pause
it was abandoned: the parity claim behind it was backwards. Stripe — “Marking
an invoice as uncollectible results in the following: Stripe treats the
subscription as if the user paid and stops attempting to collect payment. The
user’s subscription continues as normal.” The pairing runs the other way at
Stripe: a cancel-on-retry-exhaustion setting is what marks the invoice
uncollectible. Hardcount’s current behaviour is already parity. Pausing would also
have deleted a choice the dialog explicitly offers, and would have re-stranded
parked invoices on terminal subscriptions, where SetPauseCollection refuses
(internal/subscription/postgres.go:625) and write-off is the only exit.
The tax gate is not dissolved by this ADR — a claim made and refuted during
design. RecoveryBlocksCharge keys on tax_transaction_id != ''
(internal/domain/invoice_payment_gate.go), and MarkTaxReversed never clears
that column. Not reversing changes the gate’s behaviour on zero rows; it
only falsifies the gate’s stated reason. Re-keying it to tax_reversed_at is a
separate, explicit edit carried by the same PR — verified necessary: 2 live rows
would still fire it.
The rule for block-vs-warn is the one already written in the file, not a new one: refuse to CREATE a bad money event; never refuse to RECORD one that already happened. It splits by who moved the money, which is decidable. A proposed alternative — split by who is harmed — was refuted because both remaining gates harm both parties.
“No machine charges a written-off debt” already holds and needs no code:
every machine claim pins status = 'finalized'. It must NOT be restated as “no
machine writes to a written-off invoice” — the ADR-108 search and the payment
webhook settle written-off invoices deliberately, and that is recording a
payment that already happened, not initiating one.
Consequences
Section titled “Consequences”- Under-remittance on recovery is impossible: nothing was ever un-reported.
- The tenant over-remits until they claim relief themselves. That is the safe direction — over-remitting is the tenant’s money temporarily, under-remitting is a liability — and the claim is jurisdiction-conditioned, so it belongs to them.
- The relief hatch, minimum version: the invoice CSV export gains
tax_providerandtax_transaction_id. It already carriesstatus,tax_amount_cents,uncollectible_at,due_at,paid_atandamount_paid_cents— every input the UK/CA/NY/TX tests need. What it lacked was whether Hardcount had already reversed at the provider, which is the one fact that decides whether the tenant’s own claim would be a double claim. - Invoices written off before this change keep their reversal. Nothing can un-reverse them, and nothing breaks: the gate keys on a column this ADR does not touch.
Deferred, with triggers
Section titled “Deferred, with triggers”- An operator-initiated “claim bad-debt relief” action that fires the reversal when the tenant is actually entitled. Trigger: the first tax-registered tenant on accrual basis in a jurisdiction granting relief.
- Re-report on recovery (Stripe’s model). Only needed if the above ships, since relief-then-recovery is what creates the repayment obligation.
Separate defect found while measuring — FIXED in its own PR
Section titled “Separate defect found while measuring — FIXED in its own PR”ListPendingTaxReversal was bounded by updated_at > now() - interval '24 hours'. A reversal that failed and was not retried inside that window was
stranded permanently. Measured on the live database: 4 voided invoices
carrying $46.69 of tax with a committed transaction, no tax_reversed_at,
and past the window. For voided invoices the reversal is unambiguously
correct, so that was a live over-remittance with no recovery path.
The window had been copied from ListPendingTaxCommit, where 24h matches a
HARD ceiling (the Stripe Tax calculation TTL, after which a re-commit is
impossible). A reversal has no such ceiling, so the window only ever
discarded work. It is removed: the set is self-draining (success stamps
tax_reversed_at) and the drive is idempotent at Stripe (the invoice-stable
inv_taxrev_<id> reference dedups), so nothing needs the bound.
This ADR’s own change — narrowing the sweep to voided-only — is unrelated to the window and unaffected.