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-100: Currency coherence is enforced at every write — a guard ring, not engine reconciliation

Date: 2026-07-26 Status: Accepted (amended 2026-10-23 by ADR-129); superseded in part by ADR-130 (G1, G2, G2b, G4 and the override currency clause are gone), built in PR-4 (#1386)

Superseded in part by ADR-130, decided 2026-10-23 and built in PR-4 (#1386). Where this ADR differs, ADR-130 describes the code. G1, G2, G2b and ADR-070’s override currency clause go, because a price and an override have no currency and take the plan’s (PR-4). G4 goes whole: its CreateRatingRuleTx normalization, allowlist and republish clauses go with the rating-rule tables, and its recipe meter probe goes because recipe prices take the new plan’s currency (PR-4). G3, G5 to G8 and the ADR-129 amendment stay.

Hardcount refuses mismatched currencies at every write that could create them (rule republishes, plans, meter prices, subscriptions and recipes), so the billing engine never needs to convert. A customer’s subscriptions, their plans, the meters’ prices and the billing profile share one currency, so selling in two currencies needs separate meters, plans and customers. Zero-decimal currencies (JPY, KRW, VND, CLP) are refused, because Hardcount amounts are cents and those charges would be 100 times too large. A settled Stripe charge in a currency other than the invoice’s raises a payment.currency_mismatch anomaly. Status: active; multi-currency catalogs, zero-decimal support, a currency on the credit ledger and locks against concurrent writes are deferred.

Hardcount amounts are unit-less minor-unit cents; every currency LABEL is attached downstream of the math. The billing engine resolves ONE invoice currency (billing profile > first item’s plan > tenant settings > USD) and stamps it over rule-computed cents without conversion or comparison — its own comment called mismatches “a pricing config error, not a billing problem to solve here.” A full site-set enumeration (money-path playbook, 2026-07-26; six parallel readers over every currency writer/reader) found that config error was writable from eight directions, three of them wrong-money paths that reach a Stripe PaymentIntent:

  1. Rule republish could change a key’s currency while plans billed it (the ADR-070 guard checked only customer overrides) — and the dashboard made it likely: the rule-edit form stamped the tenant’s current default currency with no picker, so a Settings currency switch turned any later rate edit into an unintended currency change.
  2. Profile-vs-plan divergence by ordering: ADR-067 blocked changing a profile’s currency while subs billed, but setting the profile FIRST and creating the sub SECOND passed — subscription create validated interval, timing, archival, and meter overlap, but not currency. The engine then relabels plan-computed cents under the profile currency unconditionally ($100 invoiced as €100). Drafts were also invisible to the ADR-067 checker.
  3. Zero-decimal currencies (JPY/KRW/VND/CLP) passed validation while every renderer divides by 100 and Stripe reads their amounts as WHOLE units — a latent 100× overcharge.

Plus: meter bindings (pricing-rule rows and the default binding) accepted any-currency rules with no republish or plan-create involved; plan update could attach divergent meters; mixed-currency item sets passed every subscription guard; a cross-currency swap subtracted old-plan cents from new-plan cents raw; the credit ledger is a currency-blind per-customer pot draining 1:1 across denominations; and the engine’s precedence comments were born false at all three sites (code is profile > plan; the settings branch is dead because plans always carry currency).

An adversarial panel (three lenses: legitimate-flow breakage, completeness, blast radius) reshaped the first design with seven blockers — the binding writers, the plan-update bypass, the single-item swap hole, the FE write-path stamps, an archived-plan repair deadlock, key-resolution semantics, and honest repositioning of the settle-time check.

Refuse divergence loudly at every write point; change nothing in the engine. The engine’s single-currency stamping is correct once divergence is unwritable — reconciliation/conversion machinery would be complexity without a customer. The ring:

  • G1 — rule republish (guardRuleCurrencyChange): a currency-changing republish is refused while the key is bound — through either edge (meter pricing rules or a meter’s default binding) — to any in-scope plan in a different currency. In-scope = non-archived plans plus archived plans still carrying non-terminal subs (those keep billing); archived sub-less plans are excluded so the repair flow (archive the wrong-currency plan, republish the key) stays open. The ADR-070 overrides clause stays.
  • G2 — plan create AND update-with-meters (guardPlanMeterCurrencies): a plan’s currency must equal the resolved currency of every rule key its wired meters bill. Resolution mirrors the engine (resolveRatedRule): latest ACTIVE version by key — never the binding row’s pinned version, which can lag a legal unbound republish. Keys whose active versions mix currencies are refused outright.
  • G2b — binding writes (guardMeterBinding): UpsertMeterPricingRule and UpdateMeter’s default-binding rebind refuse a rule whose key resolves to a different currency than the meter’s other bound keys or any in-scope plan wiring the meter. A meter’s prices share one currency — this is the pre-launch stance (see Deferred).
  • G3 — customer currency pin (rejectCurrencyMismatch, Stripe-parity): at sub create, add-item, both swap paths, and Activate, the plan’s currency must match (i) the sub’s other items, (ii) the swap’s outgoing plan (proration is raw cents), (iii) the profile currency if set, (iv) the customer’s other non-terminal subs including pending_plan_id. Activate closes the pre-existing-divergent-draft window; the ADR-067 profile checker now includes drafts (statuses parameter — the archive guard keeps its narrower set so “still bill” copy stays true).
  • G4 — recipe path: CreateRatingRuleTx gains normalization + allowlist + the republish clauses (closing the archived-key edge); recipe apply probes an adopted meter for cross-currency rules (AdoptedMeterCurrencyConflict) — two recipes with disjoint keys must not stack currencies on the shared tokens meter. UpsertMeterPricingRuleTx is deliberately unguarded: inside the coordinator tx the bound rules were created/adopted in the same call and committed-state reads can’t see them; recipe-level coherence covers it.
  • G5/G6 — entry validation: billing-profile currency is allowlist-validated (was format-only, and it’s the highest-precedence header source); manual invoice currency is allowlist-validated when non-empty (the empty→USD default is a working API contract and stays).
  • G7 — zero-decimal cut: JPY/KRW/VND/CLP removed from the allowlist with a distinct “no minor unit — charging would be off by 100×” refusal. FE currency list trimmed in the same change (plus TRY, which the backend never accepted — a pre-existing lying dropdown).
  • G8 — settle tripwire: the webhook settle path — the one entry point carrying Stripe’s independently-reported charge currency — compares it case-folded against the invoice header and raises a payment.currency_mismatch anomaly (report-only, finance-readable attention copy). Positioned honestly: Hardcount creates the PI from the header, so this catches out-of-band/mutation cases only, not ring escapes.
  • FE write-path truth: the rule-edit dialog preserves an existing key’s currency instead of stamping the tenant default; plan create inherits currency from its wired meters’ rules when resolvable.
  • G9 — prose: the three engine precedence comments rewritten to the real order; the "usd" fallback literals uppercased (canonical-case invariant; pre-insert consumers see the in-memory value).
  • A tenant selling in two currencies needs separate meters/rule keys per currency and separate customers per currency — coherent-by-construction rather than ambiguous. This is a real product limitation, accepted pre-launch (see Deferred: multi-currency catalog).
  • Wrong-currency installs are repairable: archive the sub-less plan → republish the key → recreate the plan.
  • All guards fail CLOSED for single writers. Concurrent writer pairs (profile-save racing sub-create, republish racing plan-create) are check-then-act TOCTOU windows — deferred with the serialization design named (customer-scoped and catalog-scoped pg_advisory_xact_lock), trigger: first multi-operator tenant.
  • Credit-ledger currency column — closed 2026-10-23 by ADR-129: each ledger row stores its currency and credit pays only same-currency invoices, and a manual invoice is held to the customer’s currency.
  • Multi-currency price catalogs (Stripe currency_options parity — Hardcount is BEHIND here, knowingly). Trigger: first DP selling one product in two currencies.
  • Zero-decimal currency support (amount semantics per currency at Stripe boundary + renderers). Trigger: first JPY/KRW-pricing DP.
  • TOCTOU serialization locks as above.

Amendment 2026-10-23: credit carries its own currency (ADR-129)

Section titled “Amendment 2026-10-23: credit carries its own currency (ADR-129)”

ADR-129 closes the “Credit-ledger currency column” deferral above. Every ledger row stores its currency, and the drain pays only invoices in it, so neither a canceled subscription’s invoices nor a manual invoice can draw credit in another currency. It also closes the manual-invoice gap: an explicit currency on POST /v1/invoices must agree with the billing profile and every live subscription’s plan (409 customer_currency_conflict), and an empty one takes the profile’s, then the plan currency, then the workspace default. G5/G6’s “empty→USD default” no longer exists. It supersedes the 2026-10-22 amendment below: a balance’s currency is the stored one, not one derived at read time, and the live balance is per currency too.

Amendment 2026-10-22: a credit balance reads in the customer’s currency

Section titled “Amendment 2026-10-22: a credit balance reads in the customer’s currency”

Superseded 2026-10-23 by the amendment above (ADR-129): the ledger now stores a currency on each row.

The ledger still has no currency column (see Deferred). Without one, the dashboard printed every credit amount with the workspace symbol: a EUR customer’s €20 read “$20.00”, and the Credits page added it into one dollar total.

A credit balance from GET /v1/credits/balances and GET /v1/credits/balance/{customer_id} now carries currency, read at query time and never stored. The live balance (/balance/{customer_id}/live) does not: it is a stop-serving gate in plain cents, and its guide says so. It is the first of: the billing profile’s currency, the plan of the customer’s active or trialing subscription, the newest invoice’s currency, the workspace default. The first two are the order the engine stamps an invoice’s currency in, so a balance reads in the currency the next invoice uses. The customer page resolves the same order in the browser (web-v2/src/lib/customerCurrency.ts).

The dashboard has no default currency any more: formatCents and getCurrencySymbol require one, so every amount names the currency of its record. A symbol typed into the markup is invisible to the type checker, so web-v2/tests/noHardCodedCurrency.test.ts fails on a literal $ before an amount. A figure the server scoped to the default (Home’s MRR and Revenue) says “in USD only”.

This changes no money path. Credit still drains 1:1 into whatever invoice it pays; the Deferred entry stands.

  • Guards: internal/pricing/currency_coherence.go, internal/subscription/service.go (rejectCurrencyMismatch), internal/customer/service.go, internal/payment/stripe.go (tripwire)
  • Tests: TestCurrencyRing_* (pricing), TestCurrencyPin (subscription), TestService_Instantiate_RefusesCrossCurrencyMeterAdoption (recipe)
  • Amends: ADR-067 (profile guard: drafts included, one-directionality closed), ADR-070 (republish guard: plans clause added), ADR-068 (settle truth-check: currency tripwire sibling)