ADR-050: Unpaid-source proration — block charges, adjust credits
Date: 2026-06-08 Status: Accepted (amended 2026-10-12; amended 2026-10-22: the net-credit side uses the one crediting rule of ADR-118; amended 2026-10-23: a written-off invoice is unpaid)
Summary
Section titled “Summary”When a mid-period change prorates against an in_advance invoice that is still unpaid, the direction of the change decides. A change that would charge more (upgrade, added item, higher quantity) is refused with 409 unpaid_invoice_blocks_change until that invoice is paid or voided. A change that gives money back (downgrade, removed item, lower quantity) issues an adjustment credit note that reduces the invoice’s amount due, with no cash refund. Amended 2026-10-12: the note is capped at the amount due plus customer credits applied to the invoice, and the credits it displaces return to the balance. Status: active, amended; cancel and cross-interval swaps settle an unpaid invoice the same way.
Context
Section titled “Context”A mid-period subscription change (plan up/downgrade, quantity change, add/remove
item, cross-interval swap) on an in_advance plan prorates against the
current-period prebill invoice. Every Hardcount proration path resolves that source
invoice and, until now, silently deferred when it was finalized but not
paid — handleItemProration returned nil with an info log, and
BillOnPlanSwapImmediate skipped its credit. The change still applied; no
invoice, credit, or operator-visible artifact was produced.
A cross-flow audit (2026-06-08) found this is both inconsistent and a money-correctness bug:
- Inconsistent. Mid-period cancel already does the right thing —
BillOnCancel→relieveUnpaidPrebillOnCancelsettles an unpaid prebill by voiding it (fully unused) or reducingamount_duevia an adjustment credit note (partially used). But the plan-change / add / remove / quantity / swap paths bailed to a silent defer. The same condition produced opposite behavior, and the correct behavior was the one not reused. - Lost credit. On a downgrade/removal with an unpaid source, the deferred
credit was never re-fired if the invoice was later paid or voided — the
customer’s credit simply vanished. (Violates
feedback_billing_accuracy.) - Invisible. All defers were info-only — no audit row, no event, no UI.
(Violates
feedback_no_silent_fallbacks.)
Industry convergence (verified 2026-06-08 across six platforms; the rule is unanimous):
- Stripe — “If a customer changes their subscription while having an unpaid
invoice for the current period, they might receive a credit for unused time on
the higher-priced plan, even if they haven’t paid for that time yet… To avoid
crediting for unpaid time, you can disable prorations when the subscription’s
latest invoice is unpaid” (set
proration_behavior=none). Stripe has no automatic guard — the integrator must special-case unpaid. (prorations) - Chargebee — “Adjustment credits reduce the amount a customer owes on an invoice that has already been issued but not paid… If the invoice for the current term has been paid, then the credits are created as Refundable Credits. [Otherwise] we first create Adjustment Credits and reduce the unpaid amount of the invoice.” The branch is automatic and accounting-correct. (credit notes, proration)
- Recurly — “Require that all invoices have been successfully paid in order to complete the downgrade. If any invoice is past due, the subscription will be kept on the original plan.” Upgrades attempt collection first, else block. (change subscription)
- Lago — “prepaid credits and credit notes applied cannot be refunded; these can only be credited back to the customer’s account balance”; on termination “any unpaid unused amount is credited back to the customer” (refund only on a succeeded-payment invoice). (credit notes)
- Orb — an issued-but-unpaid invoice credit note is an
adjustmentthat “reduce[s] the invoice’s amount due directly”; only apaidinvoice yields arefundcredit note. (credit notes) - Metronome — finalized invoices are immutable (void + regenerate); unused prepaid value rolls forward, never auto-refunds.
The single distilled rule: never mint a refundable credit, and never stack a second charge, against money the customer hasn’t paid. Unpaid-source credits are non-refundable adjustments bound to the open invoice; unpaid-source charges are gated (block or pay-first), not piled on.
Decision
Section titled “Decision”One unpaid-source policy, applied at the proration choke point
(handleItemProration) and at the engine swap path, branching on the sign of
the net proration:
-
Net charge (upgrade / add-item / quantity-increase) → block.
settleUnpaidSourceProrationreturnserrs.InvalidState(codeunpaid_invoice_blocks_change, HTTP 409): “invoice <N> for the current period is unpaid (<amount> outstanding); settle or void it before changing this subscription.” In production every charge-causing mutation runs the atomic proration tx, so the error rolls the item change back — nothing half-applies. The atomic wrappers passErrInvalidStatethrough unwrapped so the operator sees the clean message; the legacy non-atomic paths map it to the same 409. -
Net credit (downgrade / removal / quantity-decrease) → adjust in place. Issue a tax-reversing adjustment credit note against the open invoice, reducing
amount_due, grossed up by the invoice’sTotal/Subtotalratio and capped atamount_dueplus the customer credits applied to the invoice. No cash was funded, so nothing is refunded. (Amended 2026-10-12, ADR-061. The original rule capped atamount_duealone and granted no balance credit, so customer credits already applied to the invoice were spent on time the customer never used. The note now reduces what is due first and returns the applied credits the rest of it displaces to the balance.) This is the exactCreateAndIssueAdjustmentprimitive the unpaid cancel relief already uses — carried onProrationDetail.Clawback*and issued after the tx commits, the same post-commit pattern as the paid clawback (ADR-048).ProrationDetail.Typegains the value"adjustment".
The engine’s relieveUnpaidPrebillOnCancel is generalized to
relieveUnpaidPrebill(reason, desc) and now also backs
BillOnPlanSwapImmediate’s unpaid branch (previously a silent skip), so a
cross-interval swap on an unpaid prebill settles consistently with cancel and the
item-mutation paths.
PAID-source behavior is unchanged: real refundable credit (downgrade) or proration charge invoice (upgrade).
Why this design
Section titled “Why this design”- Reuses the correct primitive Hardcount already had.
CreateAndIssueAdjustmentis payment-status-aware: on a finalized/unpaid invoice it only reducesamount_due+ reverses proportional tax (no refund/credit allocation); on a paid invoice it produces a refundable credit. Routing every unpaid-source settlement through it makes cancel, swap, and item-mutation byte-consistent by construction — the same philosophy as ADR-049’s single settlement primitive. - Strict superset of the industry rule (
feedback_stripe_parity_framing): it contains Chargebee’s automatic adjust-on-unpaid, Recurly’s block-on-past-due (for the charge half), Stripe’snone-on-unpaid recommendation, and Lago/Orb’s “adjustment, not refund” — as one coherent policy rather than per-path special-casing. - Fixes the money bug, not the symptom (
feedback_longterm_fixes,feedback_billing_accuracy): the downgrade credit now lands as a realamount_duereduction immediately, independent of whether the invoice is ever paid — it can no longer evaporate. - Loud, not silent (
feedback_no_silent_fallbacks): the blocked charge is a 409 the operator sees; the credit is a first-class credit note visible on the invoice and credit-note surfaces — the same way the unpaid-cancel relief is already surfaced, so no new event type is invented.
Alternatives considered
Section titled “Alternatives considered”- A. Pay-gated apply (Stripe
pending_updateshape). Issue the upgrade proration invoice and apply the swap only if it’s paid. More UX-complete, but a whole pending-update state machine — disproportionate for a pre-launch product with zero design partners (feedback_pre_launch_scoping,feedback_no_overengineering). Recorded as the post-launch evolution path below; block is the simpler correct stance now. - B. Visible defer (apply, defer the delta to next cycle, just add audit + event). Lowest friction, but weakest revenue capture and it keeps a deferred-credit-that-can-vanish for downgrades. Rejected: it preserves the money bug for the credit half.
- C. Pre-flight guard in the handler before mutating. Cleaner “no half-apply”
on the (test-only) non-atomic path, but duplicates the source-lookup + sign
computation that already live in
handleItemProration. Rejected as belt-and-suspenders (feedback_no_belt_and_suspenders): the atomic tx is the production mechanism for clean rollback; the choke-point block is the single policy location.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Cancel, cross-interval swap, and every item-mutation path settle an unpaid source identically — one rule, one primitive.
- Downgrade/removal credit against an unpaid period is captured now (amount_due reduced) instead of silently lost.
- Upgrades can’t stack a second receivable on an unpaid period; the operator gets an actionable 409.
Risks / open items
Section titled “Risks / open items”- Operator-UX change: an upgrade/add/quantity-increase on a sub whose current prebill is unpaid now fails with a 409 instead of silently applying. This is intentional (settle or void the outstanding invoice first) and matches Recurly, but it is a behavior change for that edge.
- Add-item is always a charge, so adding an item to a sub with an unpaid current-period invoice is blocked too — consistent, but worth noting.
- Cross-cadence swaps (
in_advance ↔ in_arrears) remain rejected upstream (service.go), so they don’t reach this policy. - Deferred: pay-gated apply (alternative A) for frictionless upgrades — re-add trigger is a design partner who needs to upgrade without first settling an outstanding invoice.
- No schema migration.
References
Section titled “References”- ADR-048 (credit-clawback tax reversal — the
CreateAndIssueAdjustmentprimitive) - ADR-049 (single payment-settlement primitive — same discover-then-settle ethos)
- ADR-042 (integer day-ratio proration), ADR-031 (in_advance cancel proration, #22)
- Memory:
feedback_billing_accuracy,feedback_no_silent_fallbacks,feedback_longterm_fixes,feedback_stripe_parity_framing,feedback_audit_overlapping_flows,feedback_no_belt_and_suspenders,feedback_pre_launch_scoping - Tests:
TestUpdateItem_Downgrade_UnpaidSource_AdjustsNotRefundableCredit,TestUpdateItem_Downgrade_UnpaidSource_CapsAtAmountDue,TestUpdateItem_Upgrade_UnpaidSource_Blocked,TestBillOnPlanSwapImmediate(unpaid-adjustment subtest),TestBillOnCancel_UnpaidPrebillRelief
Amendment 2026-10-22: the net-credit side uses the one crediting rule
Section titled “Amendment 2026-10-22: the net-credit side uses the one crediting rule”The block on a net charge is unchanged: it still reads the period’s base
invoice (FindBaseInvoiceForPeriod, a routing gate only), and an unpaid base
refuses the change with unpaid_invoice_blocks_change. An in-flight base is
not unpaid, so the charge goes ahead.
The net-credit side no longer reads the base invoice. It credits through
domain.AllocateUnusedCredit over every funding invoice of the period, the
same rule as a cancel or swap (ADR-118 amendment 2026-10-22): the invoices
that charged the item, newest first. An unpaid invoice’s share is still an
adjustment that reduces what it asks for, capped at amount due plus credits
applied (and at total − already credited); a paid invoice’s share is still a
balance credit. Before, an unpaid
base sent the whole credit to the base invoice alone
(settleUnpaidSourceProration, now deleted), so an upgrade invoice paid
while an async base charge was processing was never credited when that charge
later failed. ProrationDetail.Type is "credit" when any share goes to
the balance (a paid invoice, or one whose charge is in flight, which normally
succeeds), otherwise "adjustment". A credit that went both ways records the
open-invoice part as adjusted_cents, and the timeline shows both parts.
Amendment 2026-10-23: a written-off invoice is unpaid
Section titled “Amendment 2026-10-23: a written-off invoice is unpaid”A written-off (uncollectible) invoice is billed but unpaid. A write-off
ends collection, not the sale (ADR-111), and it can still be paid later
(ADR-113). So this policy treats it as it treats an unpaid invoice: the
same refusals and the same money. The one difference is its status: an
unpaid invoice relieved to nothing due is settled as paid, and a
written-off one stays written off (below).
Scope: the lookups below find the invoices that billed the subscription’s current period in advance (the base invoice, and for credits also that period’s proration invoices). A written-off invoice from an earlier period, or a one-off invoice, plays no part in a subscription change, written off or not.
- Net charge.
FindBaseInvoiceForPeriodnow returns a written-off base, so an upgrade, added item or seat increase over it is refused withunpaid_invoice_blocks_change, the same 409 as over an unpaid one: record the payment or void the invoice first. Before, the lookup skipped it, the change took the “no base invoice yet” branch, and nothing was charged for the rest of the period. As for an unpaid base, a charge still being confirmed with Stripe (in flight, a park included) does not block. A base with nothing left to pay (no amount due, tax done) does not block either: an unpaid one is settled as paid at that point, and a written-off one is owed nothing (review round 1: it used to be refused with “0.00 outstanding”). - Net credit.
FindFundingInvoicesForPeriodnow returns written-off invoices. A cancel, swap, downgrade, removed item or seat decrease credits one as it credits an unpaid one. It writes an adjustment credit note that lowers what the invoice asks for, capped at amount due plus credits applied. A cancel or a change of billing interval on the period’s first day voids the invoice instead, under the same conditions as an unpaid one (reliefTx): nothing paid on it, no charge on it in flight (a park included) and nothing billed on it but the period’s base. A customer who pays the written-off invoice later pays only for the time used, the same money as paying the unpaid invoice after the same change. Before, the lookup skipped it and no relief was written, so a later payment paid for the unused time too.
The unpaid path is the right one, not the paid one: nothing was received, so a balance credit would be a refundable credit for money never paid (the rule above).
Known limit (review round 2). ADR-113’s way to collect a written-off debt
is a fresh recovery invoice, and nothing links that invoice to the
written-off one. After the customer pays it, the written-off invoice still
has its amount due: an increase over it is refused, and a cancel or
downgrade lowers what it asks for instead of crediting the customer. A
payment recorded on the written-off invoice itself ends in the same money
as paying the unpaid one; one collected on a recovery invoice does not. The
runbook (manual-interventions.md) gives the ways round: a plan change
scheduled for the period’s end, and a manual credit grant for the unused
time. Not decided here: whether a recovery invoice should name the
invoice it recovers, or a written-off base should stop blocking increases.
What it took beyond the two lookups (one predicate, baseFundsPeriod,
status <> 'voided'):
- The credit-note writer accepts an engine relief note (
issue_pending) on a written-off invoice, andIssueno longer voids a draft as an orphan when its invoice is written off; only a void does. The note takes the unpaid branch:ApplyCreditNoteTxlowers a written-off invoice’s amount due, and the note reverses its own slice of tax, which a write-off never reversed (ADR-111). An operator credit note on a written-off invoice is still refused at create. - A written-off invoice left with nothing due stays written off.
SettleZeroDueTxsettles only a finalized invoice: a credit note moves no money, and ADR-113 lets only a recorded payment settle a written-off one. - The reconciler scan for held relief drafts (
ListPendingClawbackDrafts) releases only a voided source. A draft on a written-off invoice whose charge is in flight (a park, ADR-107) waits until the stale-hold sweep ends the park (ADR-127), then issues on the branch the payment status picks. So that the sweep can end it, a written-off park keeps its charge hold when Stripe refuses the check for good (not connected, key rejected, customer missing); the sweep used to clear it, and then nothing looked at the invoice again (ADR-127 amendment 2026-10-23). While Stripe cannot be asked, the draft waits, with an ERROR each check.
Tests: TestE2E_WrittenOffBase_UpgradeIsRefusedAsUnpaid,
TestE2E_WrittenOffBase_CancelThenLatePayment,
TestE2E_WrittenOffBase_RemovedItemLowersWhatIsDue (each with the unpaid and
paid controls), TestE2E_WrittenOffBase_NothingDue_UpgradeGoesAhead (with the
unpaid control), TestListPendingClawbackDrafts_ParkedWrittenOffSourceWaitsThenIssues,
TestParked_WrittenOff_StripeRefusedForGood_KeptUntilStripeAnswers,
TestIssue_WrittenOffWhileWaitingForItsRow_StillRelieves.