Multi-currency (GBP / EUR / USD, extensible) — scope + decisions
Status: SCOPE / DECISIONS-NEEDED. Docs only — no product code lands with this PR.
Audience: the operator (decisions checklist at the end) + whoever dispatches the build packets.
Grounding: every claim below cites file:line at HEAD d4fae99. Stripe-native claims are
confirmed against live Stripe docs (searched 2026-07-18) and marked accordingly.
The engine already ships GBP + EUR (a per-tenant single-currency model). This document scopes adding USD as a first-class third currency and generalising so a fourth is cheap, answers the operator's core question — "can Stripe handle it natively?" — and breaks the work into dispatchable, invariant-gated packets.
0. TL;DR
- Stripe cannot do this natively for us. Its automatic localisation (Adaptive Pricing) is
disabled by our
capture_method: manualCheckout (src/lib/orders.ts:279) — a Stripe- documented restriction. The engine already owns the price catalog and picks the charge currency server-side; that ownership does not go away under any Stripe option. - Recommended: Option E — extend the engine-native per-currency catalog to USD and plumb it through, the house-consistent path. Adaptive Pricing (Option S) is off the table while we manual- capture; a hybrid (Option H) buys presentment for the long tail but changes nothing about our spine and is a later, optional add.
- Generalisation: add a
price_usdcolumn + a singlepriceInCurrency()resolver that retires the scatteredcurrency === "EUR" ? … : …binary ternary (the actual debt). Reserve aprices jsonbmigration for the day the operator wants arbitrary currencies (BRL/AUD/…). - Editor fare stays GBP — a confirmed invariant (
0009comment,transitions.ts:812editorPayoutCurrency:"gbp"). Editor payout FX is a separate, deliberate future decision. - The dangerous class is mixed-currency arithmetic (the EUR commission bug already flagged with
the
needs_fx_reviewhold,0036). USD widens the blast radius; the plan quarantines it.
1. "Can Stripe do it natively?" — the precise answer
1a. What Stripe gives for free (confirmed against live docs)
| Capability | What it does | The catch for us |
|---|---|---|
| Presentment currencies | Charge in 135+ currencies; USD/EUR/GBP all first-class. | None — we already pass currency + unit_amount to Checkout (orders.ts:271-277). |
| Adaptive Pricing | ML-picks the buyer's local currency at Checkout, auto-converts, 24h-guaranteed rate, unlocks local payment methods. Complexity 1/5. | BLOCKED for us. Stripe's restrictions list "Checkout Sessions that use capture_method as manual" — which is exactly our flow (orders.ts:279). Also FX-priced (customer pays a 2–4% conversion fee on a mid-market rate), not market/psychological USD pricing. |
Manual currency prices (currency_options on Price objects) |
You define per-currency amounts on a Stripe Price; Checkout presents the buyer's if present. 0% Stripe FX fee, you bear FX risk. |
We don't use Stripe Price objects at all — we build inline price_data per session. This is the Stripe-recommended fallback when Adaptive Pricing isn't supported, and it is functionally what the engine already does by hand (owns the catalog, picks currency server-side). |
| FX Quotes API | Stripe's live rate + lockable 5min/1h/24h quotes (ECB reference rates available); works with PaymentIntents AND Transfers/Connect. | The natural rate source for our EUR/USD→GBP commission conversion and any future non-GBP payout. Preview API version. |
| Multi-currency settlement | Accrue balances + pay out per-currency without FX, given a bank account per currency. GB/EU/US supported. | Optional: lets Redpoint hold USD/EUR instead of auto-converting to GBP. An operator/Stripe-account config choice, not code. |
| Stripe Tax | Computes US sales tax (and EU VAT) at Checkout; "integration currency" drives tax recording. | Exists and is the sane path for US sales tax (we model none today — see §6). |
The load-bearing fact: manual capture is fundamental to the product (authorize at Checkout,
capture at client approval — Phase 6, orders.ts:279 + payouts.ts capturePayment). So Adaptive
Pricing is essentially unavailable to us unless the capture model changes — which it should not.
(Our Checkout uses separate charges and transfers, not destination charges — no
application_fee_amount/transfer_data on the session — so that second Adaptive-Pricing/manual-
price restriction does not bite; only the manual-capture one does.)
1b. What the ENGINE owns regardless of any Stripe choice
None of the following move to Stripe under any option:
- The per-market price catalog — packages/extras priced per currency (
packages.price_gbp/ price_eur0001_init.sql:95-96,package_extras.price_gbp/price_eur0013). - Editor-fare currency — always GBP pence (
jobs.fare_snapshot0009), independent of charge currency. - Commission math —
computePartnerCommission(partner-commission.ts:81) and the FX hold. - Per-currency VAT / US sales-tax rules —
vat.ts(EU reverse-charge model today). - Money book of record —
ledger_entriesper-row currency (0015),job_pnl/monthly_ financegrouped by currency, realised-margin views.
1c. Options table
| Option S — Stripe presentment / Adaptive Pricing | Option E — engine-native per-currency catalog (USD) ✅ | Option H — hybrid | |
|---|---|---|---|
| What | Let Stripe auto-convert one base price into local currencies at Checkout. | Add price_usd + editor/tenant/seed/plumbing; the engine owns every USD amount. |
Engine catalog for GBP/EUR/USD; Stripe presentment for the long-tail currencies. |
| Effort | Days if it worked — but incompatible with manual capture. | ~5 packets (§4), mechanical + one resolver refactor. | Option E + a later presentment layer. |
| Pricing control | FX-converted (2–4% fee to buyer), no round USD prices. | Full — operator sets each market's price. | Full for the 3 named; FX for the rest. |
| Reporting | presentment_details differs from PaymentIntent currency → reconciliation fuzz. |
Clean — one currency per order, ledger already per-currency. | Clean for the 3; fuzz for the tail. |
| Verdict | Rejected (manual-capture blocker + priced-not-set). | Recommended. | Optional future add; not needed for GBP/EUR/USD. |
Recommendation: Option E. It is the only option compatible with manual capture, it keeps the money spine single-currency-per-order (the property that makes the ledger and P&L honest), and it matches the house rule "no clever abstractions until forced." Option H is a clean later extension if Redpoint ever wants to accept a currency it hasn't set a price in — it does not change any spine work below, so it is explicitly out of scope for v1.
2. The generalisation decision: price_usd column vs prices jsonb
2a. The actual read sites (counted at HEAD)
Files referencing price_gbp / price_eur / editor_payout_gbp / editor_fare_delta_gbp:
15 files, 85 lines (grep at HEAD). They split into two kinds:
(A) The binary currency ternary — the real debt (5 code sites): everywhere that picks EUR-or-
GBP as currency === "EUR" ? price_eur : price_gbp:
src/lib/billing.ts:42(computePricebase)src/lib/billing.ts:106(computeExtrasper-extra price)src/app/order/order-form.tsx:59, 158, 331-333, 374(package price, specify-extra, extras display)src/app/order/page.tsx:52(tenant currency resolution)
These silently break for USD: a third currency falls through the !== "EUR" branch to GBP, so a
USD buyer would be charged GBP amounts labelled USD. This is the correctness core of the whole change.
(B) Column readers that don't branch on currency (the rest): the ops finance/pricing surfaces
(ops/finance/pricing/*, margins/margin-builder.tsx, finance/page.tsx), the pricing server
action (ops/finance/pricing/actions.ts), orders.ts (SELECT list), payouts.ts/transitions.ts
(read editor_payout_gbp as the always-GBP fare). These need the new column added to their
SELECT/edit surface but carry no EUR/GBP branch to fix.
2b. Two shapes
Option 2-COL — add price_usd (+ keep editor columns GBP). Continues the existing per-currency-
column model.
- Migration:
alter table packages add column price_usd integer;+ same onpackage_extras(price_usd, nullable,check (price_usd is null or price_usd >= 0)— mirrorsprice_eurin0013). Editor columns unchanged (fares stay GBP). - Touch: extend the 5 ternary sites to a lookup; extend the (B) SELECT/edit surfaces; extend
seed + the pricing editor UI. Typed,
NOT NULL/CHECKguarantees preserved. - Cost of a 4th currency later: another column + touch the same sites again.
Option 2-JSONB — migrate to prices jsonb keyed by currency ({"gbp":4500,"eur":5200, "usd":5500}).
- One migration adds the column + backfills from the existing columns; a follow-up drops the old columns once every reader is cut over.
- Touches all 15 files (every reader changes shape), loses the per-column
NOT NULL/CHECKintegrity (must re-assert in code/trigger), and rewrites the pricing editor. - Cost of a 4th currency later: zero migration — just a new key.
2c. Recommendation
Add price_usd (Option 2-COL) AND introduce one resolver —
priceInCurrency(row, currency): number | null — as the single home that maps a currency to its
column, retiring the 5 scattered ternaries. This is the minimal, house-consistent path for a known,
small currency set (GBP/EUR/USD), keeps DB-level integrity, and the resolver is the extension point
that makes a 4th currency a one-line change in code even while the column set grows.
Pivot rule (write it down): if the operator ever needs arbitrary / >4 currencies, migrate to
prices jsonb — at that point the column-per-currency model is the wrong shape and the one-migration
jsonb cost is justified. Not before. (Matches CLAUDE.md rule 4/5.)
money() (src/lib/format.ts:17) already takes a currency and delegates to Intl.NumberFormat —
USD-safe as written, no change needed (it formats $ correctly; only the en-GB locale styles
the grouping, which is fine).
3. Confirmed invariants (do not regress)
- Byte-identical GBP behaviour. Every existing GBP order/job/payout/commission path must be
unchanged. New currency logic is additive; a GBP row takes the same branch it takes today.
(Sentinel: a GBP order's Checkout params,
amount_total,fare_snapshot, ledger rows are diff-clean vs HEAD.) - Editor fare is ALWAYS GBP pence.
jobs.fare_snapshot/orders.fare_snapshot(0009), read bycapturePayment/attemptPayoutwithcurrency:"gbp"(transitions.ts:736). USD does not touch this. Editor payout FX is a separate deliverable (§4c / decisions). - One currency per order. The charge currency is resolved once (
price.currency) and frozen onorders.currency/jobs.currency. No order mixes currencies in its own arithmetic. - No mixed-currency arithmetic anywhere. The one place two currencies meet — commission (GBP
fare netted from a non-GBP gross) — is already quarantined by the
needs_fx_reviewhold (partner-commission.ts:221, 295;0036). USD gross hits the same hold. Extending it, or replacing it with real FX, is the only sanctioned way two currencies interact. - Money only moves via verified Stripe webhooks into the ledger (
ledger.ts,webhook-money.ts). The ledger is already per-row-currency (0015) — USD entries need no schema change, only correctcurrencyvalues flowing in (already sourced from Stripe objects:webhook-money.ts:157,240,316, 399,495). - Headless API exposes no money.
api-v1-jobs.ts/api-v1-previews.tsdeliberately excludefare_snapshot/price_snapshot/ledger columns (api-v1-jobs.ts:7-8,api-v1-previews.ts:28). Multi-currency adds nothing to the API surface — verify this stays true.
4. Phased packets
Each packet is independently dispatchable (Opus agent, isolated worktree, PR-not-merge). Migration
numbering: next free is 0041 (latest on disk is 0040_supervision_actor.sql).
Packet A — Catalog + config (USD prices, tenant currency options, seed)
- Scope: Migration
0041addsprice_usdtopackages+package_extras(nullable,>= 0check, mirroringprice_eur). Extend seed placeholders with USD dummies. Addusdto the acceptedbilling_config.currencyvalues conceptually (it's free-text today; the validation lives in code — see the resolver in Packet B). - Invariant: purely additive; existing rows/policies/grants untouched (
0013ethos — new column needs no new anon grant sincepackages/package_extrasalready scope by tenant/RLS). A tenant withcurrency:"GBP"behaves byte-identically. - Non-scope: no read-site changes (Packet B), no checkout wiring, no tax.
- Fail-under-broken gate: a probe test that a USD-priced package row round-trips
price_usdover REST (live-DB probe pattern, CLAUDE.md rule 8); seed applies clean under CI's all-migrations-by-glob reset. - Operator placeholders: USD market prices per package/extra (dummies until set) — see decisions.
Packet B — Checkout / order plumbing (the currency resolver)
- Scope: Introduce
priceInCurrency(row, currency)(one home, inbilling.ts) and routecomputePrice,computeExtras, the order-form display sites, andorder/page.tsxthrough it, replacing the 5 binary ternaries (§2a-A). Decide + implement how the order currency is chosen (see decisions — v1 recommend per-tenantbilling_config.currency, unchanged mechanism, now acceptingUSD). Extras filtering must offer only extras priced in the order currency (the existingprice_eur != nullguard atorder-form.tsx:59generalises to "priced in this currency"). - Invariant: GBP and EUR paths byte-identical to HEAD; the resolver returns the same column the
ternary did for those two. An unpriced currency drops the item (never guesses — the existing
computeExtrascontract,billing.ts:106-107). - Non-scope: commission FX, tax, payout FX.
- Fail-under-broken gate: unit test (node --test,
billing-style) proving a USD tenant produces USDprice_data.currency+ USDunit_amountin the Checkout params, and a USD extra unpriced in USD is dropped — the test must FAIL if the resolver falls through to GBP.
Packet C — The money spine (ledger correctness, refunds, commission)
- Scope:
- Ledger: confirm USD
capture/stripe_fee/refundentries flow with correct per-row currency (no code change expected —webhook-money.tsalready sources currency from Stripe objects; add a test that pins it for a USD charge). Notestripe_feeis denominated in the balance-transaction / settlement currency, not the charge currency (webhook-money.ts:70-72) — for a USD charge settling to GBP the fee row is GBP. This is a pre-existing single-currency-per- row correctness point thatjob_pnlalready documents; USD does not worsen it but the P&L view's per-currency grouping (0015) keeps it honest. - Refunds fire in the charge currency already — the specify-editor sweep passes the frozen
snapshot amount and Stripe refunds in the PI's own currency (
specify-editor.ts:498-505, 536); paid-revision / pushback-boost buildprice_data.currencyfromjob.currency(paid-revision/route.ts:47-74,pushback/boost/route.ts:48-71). Confirm these carry USD with no change; add a test. - Commission: a USD gross netting a GBP editor fare hits the same
needs_fx_reviewhold as EUR (partner-commission.ts:221) — it is currency-agnostic already. Either (i) leave the hold (USD partner commissions accrue-for-review, never auto-transfer — safe default), or (ii) implement the real FX conversion (EUR/USD gross → GBP at the operator's rate source) that retires the hold. (ii) depends on the rate-source decision (§6) and Stripe's FX Quotes API is the natural source (§1a).
- Ledger: confirm USD
- Invariant (LOAD-BEARING): no mixed-currency arithmetic ever produces a transfer. A non-GBP gross with a GBP fare must NEVER auto-transfer a raw-subtracted number — today's hold guarantees this; any FX work must convert before the subtraction, not relax the guard.
- Non-scope: editor payout currency (stays GBP — a separate future decision; if ever changed,
it is its own packet touching
attemptPayout+ Connect multi-currency settlement). - Fail-under-broken gate: test that a USD-gross partner job yields
needs_fx_review(armed OR not) and fires zero transfers; if FX conversion is built, a test that the transferred amount equals the converted GBP figure, never the raw mixed subtraction.
Packet D — Tax (EU VAT vs US sales tax)
- Scope (decision-heavy — accountant items):
- Today
vat.tsmodels EU VAT reverse-charge for a GB supplier below the registration threshold (default: no VAT line). It is currency-agnostic (splits a gross in any currency). - US sales tax is not modelled at all and cannot be bolted onto the EU VAT model: it is nexus-based, destination-sourced, per-state, with no reverse-charge concept.
- Recommended path: Stripe Tax for US sales tax (and optionally EU VAT once registered) — it computes at Checkout and records against the integration currency. Alternative: manual per- jurisdiction rules (do not — this is the classic accountant-owned surface).
- Today
- Invariant: UK/EU behaviour unchanged; a US sale gains a sales-tax line only once the operator confirms nexus + enables Stripe Tax. No tax is invented in code.
- Non-scope: everything else.
- Fail-under-broken gate:
computeVatBreakdownunchanged for GB/EU inputs (regression test); any US path is gated behind explicit config and defaults to no tax line until enabled. - Note: flagged as needing an accountant + live Stripe Tax decision — do not build US tax logic from code assumptions.
Packet E — Surfaces (display, upsell visibility, ops finance reporting)
- Scope:
money()everywhere — already currency-safe; audit call sites pass the row's currency, not a hard-coded one.- The extras / specify-editor visibility rules generalise:
#75/#79hid the specify-editor upsell when unpriced in EUR — now "hide when unpriced in the order currency" (the resolver from Packet B;order-form.tsx:59, 331). - Ops finance multi-currency reporting:
job_pnl/monthly_finance/package_marginare already grouped by currency (0015) — verify the finance pages render USD groups and never sum across currencies into one integer. The pricing editor (ops/finance/pricing/*) gains a USD input.
- Invariant: no view sums two currencies into one number (the
0015rule); GBP-only tenants' finance pages are visually unchanged. - Non-scope: new FX-normalised "all-in-GBP" reporting (a separate future ask — would need the rate source and is explicitly not v1).
- Fail-under-broken gate: a sentinel-binding test (house pattern,
editor-landing.spec.ts) that a USD package renders a$price on the order surface and a USD job shows in the finance rollup under its own currency group.
5. Risk + sequencing
- Sequencing: A → B are the spine (catalog + resolver); they are the minimum to offer USD
safely. C hardens the money movement. D and E are parallelisable after B. Nothing in A–C exposes
USD to a real buyer until the operator sets real USD prices (dummies until then), so A–C can land
dry (behind unset prices) exactly like the retention/commission dry-run precedent (
0030,0035). - The bug class to fear: mixed-currency arithmetic. USD widens the existing EUR exposure. The
only two-currency meeting point (commission) is quarantined by
needs_fx_review; the plan forbids any code path that subtracts a GBP fare from a non-GBP gross and then moves money. Any FX must convert first. - The silent-fallthrough bug: the
currency === "EUR" ? … : GBPternary charges GBP amounts under a USD label if USD isn't wired at every site. Packet B's resolver + its fail-under-broken test is the guard; do not merge B without the test confirmed RUN (not skipped) in CI. - Migration numbering: next free is 0041. Probe-and-skip loudly for any spec depending on it against the shared dev/prod DB (CLAUDE.md CI conventions).
- Unconfirmable-from-code (needs live checks): (a) whether Redpoint's Stripe account has USD presentment + settlement enabled — a Dashboard/account fact, not in the repo; (b) US sales-tax nexus + Stripe Tax enablement — accountant + Dashboard; (c) the commission FX rate source (Stripe FX Quotes API is available and preview-versioned — confirm SDK/API version before building).
6. DECISIONS-NEEDED (operator)
- Which currencies at launch? Confirm GBP + EUR + USD, or a different set. (Plan assumes these three, columns; >4 or arbitrary → jsonb pivot, §2c.)
- Market prices per currency. Set the USD price for every package and extra (and confirm the existing EUR prices). Seeds use obvious dummies until you do — no USD sale is possible until set.
- One currency per tenant, or per-order buyer choice? v1 recommends per-tenant
(
billing_config.currency, unchanged mechanism, now acceptingUSD). Per-order buyer selection (or buyer-geo auto-pick) is a larger UX + a bigger test surface — decide if v1 or later. - Settlement currency / Stripe account config. Do you want to hold USD/EUR balances (enable multi-currency settlement + a bank account per currency), or auto-convert everything to GBP at settlement? (Account-level Stripe config; affects your FX cost, not the code.)
- Editor payout stays GBP for v1? Recommend yes (invariant #2). Non-GBP editor payouts are a separate future deliverable (Connect multi-currency settlement + fare-currency model).
- Commission FX rate source. For USD/EUR partner-tenant commissions: keep the
needs_fx_reviewhold (safe, no money moves — recommended for v1), or wire real FX conversion? If the latter, name the rate source (Stripe FX Quotes API is the natural, in-band choice). - US tax stance. Do you have US sales-tax nexus, and will you use Stripe Tax (recommended) or defer US sales entirely until registered? (Accountant + Stripe Dashboard — do not infer from code.)
- Rounding / display conventions. Confirm minor-units-everywhere holds (it does today); confirm round USD price points are set by hand (not FX-derived), consistent with the engine-native choice.
Recommended path in one line: Option E — add price_usd + a priceInCurrency() resolver,
keep the editor fare GBP, extend the needs_fx_review quarantine to USD, and treat US tax as a
Stripe-Tax + accountant decision. Adaptive Pricing is off the table while we manual-capture.