ADR-129: A credit carries its own currency and pays only invoices in it
Date: 2026-10-23 Status: Accepted Amends: ADR-100 (closes its “Credit-ledger currency column” deferral and the manual-invoice pin gap; supersedes its 2026-10-22 amendment), ADR-078 §D8 (balance events are per currency).
Summary
Section titled “Summary”Every credit ledger row stores its currency. A credit pays only invoices in
that currency and is never converted. A customer with credit in two
currencies has two balances, and no figure adds them together. A manual
invoice uses the customer’s currency: an explicit currency must agree with the
billing profile and every live subscription’s plan, and an empty one is taken
from them. Design and review: velox-ops design-credit-currency-2026-10-23.md.
Context
Section titled “Context”Before this, the ledger had no currency column. The drain summed a customer’s whole ledger and paid any invoice with it, one cent for one cent. ADR-100’s guards keep a customer’s live subscriptions in one currency, but two routes still moved credit across currencies:
- Manual invoices were not pinned. A EUR invoice for a USD customer drew the customer’s USD credit. A EUR prepaid-commit invoice paid in cash minted a commit grant that then drained against the customer’s USD cycle invoices.
- Currency changes. A canceled USD subscription’s unpaid invoices beside a new EUR subscription passed ADR-100’s guard (it reads non-terminal subscriptions only). The USD credit then paid EUR invoices, and EUR credit paid the old USD ones.
The 2026-10-22 amendment to ADR-100 made the dashboard show a balance in a currency derived at read time. That fixed the symbol, not the money.
Decision
Section titled “Decision”- Each ledger row stores its currency (
customer_credit_ledger.currency, NOT NULL,CHECK (currency ~ '^[A-Z]{3}$'), migrations 00199/00200). The store’s append is the chokepoint: it uppercases the currency and refuses a row without one with a plain error, since that is a caller bug. - Every writer names the currency from a stored fact. An operator grant
or adjustment takes a required, allowlist-validated
currency. A credit-note grant takes the invoice’s currency, whether the note pays out to the balance (paid invoice) or returns credits applied to it (unpaid invoice). A commit grant takes its funding invoice’s. An expiry entry and a commit retire take the grant’s. A void reversal takes the currency of the usage rows it reverses and refuses rows in two currencies. - The drain is per currency.
ApplyToInvoiceAtomicreadsUPPER(i.currency)from the locked invoice row (invoices never change currency after insert) and sums, drains and writes only that currency’s rows. A deduction draws only on its currency’s blocks. - Balances, events and reads are per currency.
balance_afteris the balance in the row’s currency. The balance-crossing events (credit.balance_low,_depleted,_recovered) andcredit.commit_retiredcarrycurrency, and a crossing is computed on that currency’s balance. The balance, live balance, grants rollup, cost dashboard and analytics overview return one entry per currency. - A same-source entry in another currency is a mismatch, not a replay
(
credit_source_amount_mismatch). - Manual invoice currency (ADR-100 G1/G2 grafts,
invoice.Service.Create): an explicit currency that differs from the billing-profile currency or any non-terminal subscription’s plan currency is refused with 409customer_currency_conflict. An empty one takes the profile’s, then the plan currency, then the workspace default, the order the engine stamps a cycle invoice. A customer with neither fact may be invoiced in any allowed currency. - Three doctor checks:
balance_exceeds_block_capacityis per currency;ledger_currency_matches_invoiceanddrained_block_currency_matchesare new.
Rejected alternative: one pinned credit currency per customer
Section titled “Rejected alternative: one pinned credit currency per customer”A credit_currency on the customer, set by the first grant, with ledger rows
left without a currency. Rejected:
- It adds a race: a USD first grant and a EUR subscription create in two transactions both commit, and the pin can never be undone, so the USD credit is stranded.
- It blocks money. A pin missing while the balance is above zero fails every collector; a credit note on a paid invoice in another currency fails at Issue; a draft commit invoice in another currency fails at finalize.
- Ledger history would be right only while the pin never changes.
- Stripe, Lago and Orb all put the currency on the credit (Orb also pins the customer, and still stores a currency on each ledger entry).
Consequences
Section titled “Consequences”- Accepted: stranded credit. A grant in a currency the customer is not billed in, or credit left after the customer moved to another currency, stays as its own balance. No invoice draws it. The operator deducts it or refunds it outside Hardcount. Nothing converts it.
- Accepted: one low threshold for every currency. The tenant’s
credit_balance_low_threshold_centsapplies in each currency’s own units (1000 is $10 or €10). Every allowed currency has two decimals. A per-currency threshold waits until a tenant asks. - Accepted: the expiry hold ignores currency.
domain.CreditDrawOwedSQLcan hold a USD grant past its expiry while a EUR draw is owed. The drain filters by both currency and expiry, so no wrong money moves; only the write-off and its webhook arrive later. - Accepted: a credit note on an old invoice in the customer’s previous currency credits that currency. It is the currency of the money refunded; the operator can refund to the card instead.
- Deferred (ADR-100’s TOCTOU): the manual-invoice check reads the profile
and subscriptions in their own transactions, so a concurrent currency change
can pass it. Credit money stays safe because the drain is per currency.
Named fix: a customer-scoped
pg_advisory_xact_lock; trigger: the first multi-operator tenant. - Breaking, pre-launch:
GET /v1/credits/balance/{id}returns{data: [...]}; the live balance returnsbalances[]; the grants rollup isremaining_by_currency[]; analytics returnscredit_balance_by_currencyinstead ofcredit_balance_total; the cost dashboard’screditsis an array; grant and adjust requirecurrency. - Dev data: 00199 gave each customer’s existing rows the one currency a
balance was read in before (profile, live plan, newest invoice, workspace
default). History really in another currency is relabeled, and
ledger_currency_matches_invoicereports it.
References
Section titled “References”- Stripe: customer balance transactions (“currency (enum, required) … Specifies the invoice_credit_balance that this transaction will apply to”) and the customer object (“Stripe only applies a balance in a specific currency to an invoice after that invoice (which is in the same currency) finalizes”).
- Lago: create a wallet and prepaid credits (“Prepaid credits only apply to invoices sharing the same currency as the wallet”).
- Orb: update customer and create ledger entry.
- Code:
internal/credit/postgres.go(appendEntryInTx,drainPositiveBlocks,ApplyToInvoiceAtomic,AdjustAtomicAudited),internal/invoice/service.go(resolveCurrency),internal/doctor/checks.go. - Tests:
internal/credit/currency_integration_test.go,internal/api/credit_currency_integration_test.go,TestCollect_CreditInAnotherCurrencyIsNotApplied(billing),TestDunningRetry_AppliesOnlyTheInvoiceCurrency(api),TestCreditLedgerCurrency_BackfillValidateAndRollback(migrate).