Multi-Currency Checkout & Localization
The moment your SaaS sells outside one country, a single number — the price — splits into several: what the customer sees, what the card network charges, what lands in your bank account, and what your ledger records. Conflate any two of them and your reconciliation breaks. This page sits under Frontend Checkout UX & Dunning Recovery Flows and covers multi-currency checkout end to end: presentment versus settlement currency, where FX and rounding happen, how tax-inclusive display differs by region, which local payment methods you must offer, and — most importantly — how currency is recorded immutably in the ledger.
The discipline that makes this tractable: store every monetary amount as integer minor units paired with an ISO-4217 currency code, and never let a number exist in your system without its currency. A bare amount column is a latent bug. From there, presentment (what you charge) and settlement (what you receive) are tracked separately, and the FX rate that links them is captured at authorization, not recomputed later. For the deepest localization question — pricing by local purchasing power — see presenting localized prices with purchasing power parity.
Prerequisites
Architecture & Data Flow
Presentment currency is what the customer is billed in; settlement currency is what the PSP deposits to you. They may differ — a customer billed in EUR while you settle in USD — and the card network applies FX between them. You do not convert prices at runtime from a base currency; you publish explicit per-currency prices in a price book so a rounding choice or FX wobble never changes the displayed price between page load and charge.
Inputs: a customer’s region and a price_id. Processing: resolve presentment price, apply regional tax display, charge, settle. Outputs: a ledger entry carrying both amounts and the rate, so every cent reconciles against the bank statement.
Implementation Walkthrough
1. Resolve presentment currency from a price book
Map each price_id to an explicit amount per currency. Do not divide a USD base price by a live FX rate — that produces ugly, drifting prices and breaks the “same price all session” guarantee.
CREATE TABLE price_book (
price_id UUID NOT NULL REFERENCES price(price_id),
currency CHAR(3) NOT NULL, -- ISO-4217
amount_minor BIGINT NOT NULL, -- integer minor units, e.g. 2900 = €29.00
tax_inclusive BOOLEAN NOT NULL, -- region convention captured per row
PRIMARY KEY (price_id, currency)
);
-- Resolve the presentment row for a billing country's currency
SELECT amount_minor, currency, tax_inclusive
FROM price_book
WHERE price_id = $1 AND currency = $2; -- $2 derived from billing country
2. Render tax per regional convention
EU/UK B2C prices are displayed tax-inclusive; US prices are typically tax-exclusive with tax added at checkout. The tax_inclusive flag drives display so the same engine renders both. The tax calculation itself belongs to the VAT/GST tax calculation layer.
function renderTotal(amountMinor: number, currency: string, taxRate: number, inclusive: boolean) {
if (inclusive) {
const net = Math.round(amountMinor / (1 + taxRate)); // ✅ VAT already inside the price
const tax = amountMinor - net;
return { display: amountMinor, net, tax, currency };
}
const tax = Math.round(amountMinor * taxRate); // ⚠️ tax added on top (US style)
return { display: amountMinor + tax, net: amountMinor, tax, currency };
}
3. Charge in presentment currency and capture the rate
Create the PaymentIntent in the presentment currency (this reuses Payment Element Integration). When presentment differs from settlement, capture the FX rate the PSP reports at authorization — it is part of the financial record, not a derived value.
const intent = await stripe.paymentIntents.create({
amount: presentmentMinor, // integer minor units
currency: presentmentCurrency, // e.g. 'eur'
customer: customerId,
metadata: { invoice_id, settlement_currency: 'usd' },
});
// On payment_intent.succeeded the balance transaction reports exchange_rate + settled amount
4. Offer local payment methods
Card-only checkout leaves conversion on the table in markets that prefer iDEAL (NL), SEPA (EU), Bancontact (BE), or Pix (BR). Local methods are currency-scoped, so present them only when the presentment currency supports them.
5. Record both amounts in the ledger
The ledger entry carries presentment amount, settlement amount, and the rate — never just one. This is what lets a nightly reconcile match your books to the PSP payout to the cent.
CREATE TABLE ledger_entry (
ledger_entry_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL,
presentment_minor BIGINT NOT NULL,
presentment_currency CHAR(3) NOT NULL,
settlement_minor BIGINT, -- null until settled
settlement_currency CHAR(3) NOT NULL,
fx_rate NUMERIC(18,8), -- presentment→settlement at auth
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Edge Cases & Failure Modes
| Failure scenario | Mitigation |
|---|---|
| Half-cent rounding drift accumulates across line items | Round once on the final total, not per line; store the rounded integer as the charged amount |
| FX moves between page load and charge | Prices come from the static price book, so the displayed amount never moves mid-session |
| Zero-decimal currency (JPY, KRW) treated as 2-decimal | Per-currency minor-unit exponent (JPY = 0); never assume cents |
| Customer changes country mid-checkout | Re-resolve the price book row and re-render; never silently keep the old currency |
| Refund in presentment currency settles at a different rate | Refund the original presentment amount; record the FX delta as an FX gain/loss ledger line |
| Tax-inclusive price shown to a tax-exclusive region | tax_inclusive flag is per price-book row, keyed to the region’s currency — not a global toggle |
| Settlement currency unsupported by PSP for a method | Validate method × currency at render time; hide unsupported combinations |
Performance & Scale
The price book is small and read-mostly — cache it in-process with a short TTL and a version key so a price change invalidates cleanly. The presentment lookup is a single indexed read on price_book(price_id, currency). FX rates for reporting should be fetched on a schedule (hourly is ample) and stored timestamped; do not call an FX API on the checkout hot path. Reconciliation is the heavy job: match ledger settlement_minor against PSP payout reports in nightly batches against a read replica, grouping by settlement currency so each currency’s books balance independently.
Testing Strategy
Test rounding with adversarial inputs: three line items at €9.99 with 21% inclusive VAT must round identically whether you round per line or on the total — assert you only do the latter. Assert a JPY charge uses a zero-decimal exponent (¥2900 is 2900, not 290000). Replay a settlement webhook and assert the ledger entry gets settlement_minor and fx_rate populated exactly once. Verify a refund records an FX gain/loss line when the settlement rate differs from authorization. Use fixed FX fixtures (not a live API) so currency tests are deterministic, and assert that no code path produces an amount without an accompanying currency code.
Frequently Asked Questions
Should I convert a base price at runtime or maintain per-currency prices? Maintain explicit per-currency prices in a price book. Runtime conversion produces prices that drift with FX, round to unappealing values (€27.43), and can change between page load and charge. A price book gives you stable, locally-sensible prices and a clean place to apply purchasing-power adjustments.
What is the difference between presentment and settlement currency? Presentment currency is what the customer is billed in and sees on their statement; settlement currency is what the PSP deposits into your bank account. When they differ, the card network or PSP applies FX between them. Your ledger must record both amounts plus the rate, or you cannot reconcile payouts.
How do I handle zero-decimal currencies like JPY?
Store a minor-unit exponent per currency (JPY and KRW are 0, most are 2, a few are 3). The integer you store and send to the PSP is already in minor units, so ¥2,900 is 2900 with exponent 0 — never multiply by 100 blindly.
Where should rounding happen? On the final total, once, after tax. Rounding each line item independently introduces sub-cent drift that accumulates and makes invoices fail to sum to the charged amount. Compute in full precision, round the grand total to the currency’s minor unit, and persist that integer as the authoritative charged amount.