☰ MenuMulti-currency plan

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


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:

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:

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.

Option 2-JSONB — migrate to prices jsonb keyed by currency ({"gbp":4500,"eur":5200, "usd":5500}).

2c. Recommendation

Add price_usd (Option 2-COL) AND introduce one resolverpriceInCurrency(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.NumberFormatUSD-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)

  1. 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.)
  2. Editor fare is ALWAYS GBP pence. jobs.fare_snapshot / orders.fare_snapshot (0009), read by capturePayment/attemptPayout with currency:"gbp" (transitions.ts:736). USD does not touch this. Editor payout FX is a separate deliverable (§4c / decisions).
  3. One currency per order. The charge currency is resolved once (price.currency) and frozen on orders.currency / jobs.currency. No order mixes currencies in its own arithmetic.
  4. 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_review hold (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.
  5. 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 correct currency values flowing in (already sourced from Stripe objects: webhook-money.ts:157,240,316, 399,495).
  6. Headless API exposes no money. api-v1-jobs.ts / api-v1-previews.ts deliberately exclude fare_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)

Packet B — Checkout / order plumbing (the currency resolver)

Packet C — The money spine (ledger correctness, refunds, commission)

Packet D — Tax (EU VAT vs US sales tax)

Packet E — Surfaces (display, upsell visibility, ops finance reporting)


5. Risk + sequencing


6. DECISIONS-NEEDED (operator)

  1. Which currencies at launch? Confirm GBP + EUR + USD, or a different set. (Plan assumes these three, columns; >4 or arbitrary → jsonb pivot, §2c.)
  2. 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.
  3. One currency per tenant, or per-order buyer choice? v1 recommends per-tenant (billing_config.currency, unchanged mechanism, now accepting USD). Per-order buyer selection (or buyer-geo auto-pick) is a larger UX + a bigger test surface — decide if v1 or later.
  4. 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.)
  5. 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).
  6. Commission FX rate source. For USD/EUR partner-tenant commissions: keep the needs_fx_review hold (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).
  7. 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.)
  8. 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.