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

Multi-currency correctness rests on one rule enforced everywhere: no amount without a currency. The prerequisites operationalize it — a per-currency price book, minor-unit money, a PSP with presentment support, regional tax display, and a ledger that records both amounts. The stack lists them.

Multi-currency prerequisites A per-currency price book, minor-unit money with ISO codes, a presentment-capable PSP, regional tax display rules, and a two-amount ledger underpin multi-currency checkout. Multi-currency checkout Price book per currency Minor units + ISO code PSP presentment Tax display inc / exc Ledger both + rate
Five foundations enforcing one rule — no monetary amount ever exists without its currency code.

Two of these prerequisites deserve a sharper definition before you write any code. The price book is not a table of “USD prices with a multiplier column” — it is a set of independently authored amounts, one row per (price_id, currency) pair, each chosen by a human (or a pricing job) so it reads well in that market. A €29.00 plan is not 2900 / 1.08 from a USD anchor; it is 2900 because someone decided €29 is the right number in the eurozone. That distinction matters the first time finance asks “why did the German price change overnight?” — with a price book the answer is “because someone edited a row,” and that row has an audit trail. With runtime conversion the answer is “because the ECB reference rate moved 0.4%,” which is not an answer any pricing owner will accept.

The second prerequisite worth pinning down is the minor-unit exponent. ISO-4217 assigns each currency a number of minor-unit digits: 2 for USD, EUR, and GBP; 0 for JPY, KRW, and CLP; 3 for BHD, KWD, and TND. Store that exponent in a small reference table keyed by currency code and treat it as the single source of truth for how many trailing digits a display formatter should render and how the PSP expects the integer. A surprising number of currency bugs trace back to a hardcoded / 100 somewhere deep in a serializer; once JPY flows through that path you have inflated a ¥2,900 charge into the equivalent of ¥290,000 in a downstream report. Centralize the exponent and the / 100 disappears from every call site.

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.

Multi-currency checkout and settlement flow A price book resolves a presentment price by region; the customer is charged in presentment currency; the PSP settles in settlement currency; the ledger records both amounts and the FX rate. Price book by currency Tax + display (inc / exc) Charge in presentment ccy PSP settles + FX rate Ledger: both amounts + rate Local pay methods network FX offered by region
Prices come from an explicit per-currency book; the ledger records presentment, settlement, and the FX rate that links them.

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.

Where the FX actually happens

It is worth being precise about which party performs the conversion, because the answer determines who bears the spread and where you record it. When you charge in a presentment currency the PSP does not hold, the card network converts at authorization or at settlement using its own reference rate plus a margin, and the amount that lands in your payout is the settled figure. When you settle in the same currency you presented — you charged EUR and you hold a EUR balance with your PSP — there is no conversion on the payment leg at all; the FX only appears later, if and when you sweep that EUR balance back to a USD operating account through your bank or treasury provider. These are two different FX events with two different rates and two different owners, and conflating them is the most common source of a reconciliation that is “off by a bit” every single day. The ledger’s job is to record the presentment amount as the immutable truth of what the customer agreed to pay, and to attach the settlement amount and rate as a separate, later-populated fact.

Why the price book is the stability boundary

The architectural reason the price book sits at the front of the flow is that it is the only component whose output must be identical between the moment the customer sees a price and the moment they are charged. Everything downstream — tax, the charge call, settlement — can tolerate being recomputed, but the displayed price cannot change under the customer’s feet, or you have quietly committed a bait-and-switch that fails consumer-protection rules in most of the EU. By making the price a static lookup rather than a computed value, you collapse an entire class of race conditions: there is no window in which an FX tick, a cache miss, or a rounding-mode difference between two services can produce two different numbers for the same price_id in the same session. The price book is, in effect, a snapshot isolation boundary for money.

Implementation Walkthrough

The five steps trace one price from the book to the ledger: resolve the presentment amount, render regional tax, charge and capture the FX rate, offer local methods, and record both amounts. The diagram distinguishes the four numbers a single price becomes.

One price, four numbers A price book amount becomes the displayed price, the presentment charge, the settlement deposit after network FX, and the ledger record of all three. Displayed price book + tax Charged presentment ccy Settled after network FX Ledgered both + rate
One price becomes four numbers — the ledger records presentment, settlement, and the rate so all four reconcile.

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

The mapping from billing country to currency is a policy decision, not a lookup you can outsource to a locale library. Several countries transact in a currency that is not their own — much of the Gulf pegs to USD for B2B SaaS, and plenty of Latin American buyers prefer to be billed in USD despite living in a local-currency economy — so the resolution order should be: an explicit per-customer override, then a country-to-currency default table you control, then a fallback currency for regions you have not yet priced. That fallback is the important part. If a customer from a country with no price_book row reaches checkout, you must degrade to a supported currency (usually USD) rather than 500 the request or, worse, silently show a zero. Make the missing-row case loud in staging and graceful in production: log it so pricing knows to author the row, and serve the fallback so the sale still closes.

There is also a subtle correctness trap in resolving the currency from the browser rather than the billing address. Geolocation by IP tells you where the request originated, which is frequently not where the customer’s card is issued or where they will claim residence for tax. Resolve currency from the billing country the customer actually enters, and re-resolve if they change it. Using IP as a first guess to pre-fill the country selector is fine; using it as the authoritative input to the price book is how you end up charging a London customer in USD because they were on a VPN.

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 };
}

The inclusive branch hides a decision that has real revenue consequences: when a price is tax-inclusive and the tax rate varies by region, do you hold the gross (what the customer pays) constant or the net (what you recognize as revenue) constant? Hold the gross constant and a €29.00 plan is always €29.00 to the buyer, but your recognized net swings — €29.00 nets €23.97 at 21% German VAT and €24.14 at 20% in another jurisdiction. Hold the net constant instead and the buyer sees €29.00 in one country and €28.96 in another, which looks broken on a pricing page that advertises “€29/month.” Most SaaS chooses gross-constant for B2C because a clean advertised price matters more than a few cents of net variance, but you must choose deliberately and encode it, because the two policies diverge on every invoice and finance will eventually ask which one you picked. Note that the Math.round(amountMinor / (1 + taxRate)) in the code takes the gross-constant path: the displayed integer is fixed and the net is derived from it.

One more inclusive-pricing hazard: the net you back out is a rounded integer, so net + tax must be reasserted to equal the displayed gross exactly. Because tax = display - net, the code above is self-consistent by construction — the tax is whatever is left after subtracting the rounded net — but if you ever compute tax independently as net * rate and add it back, you will occasionally be one minor unit off the advertised price. Always derive one component by subtraction from the fixed total so the parts sum to the whole.

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

The critical detail is when the rate becomes final. At authorization the PSP may report an indicative rate, but the exchange rate that governs your payout is the one applied at settlement, which can be hours or a day later. Design the ledger so fx_rate and settlement_minor start null and are populated by the settlement webhook — do not copy the authorization-time indicative rate into the settlement columns, or your reconcile will disagree with the payout by the spread between the two rates. Store the settled figures against the same ledger_entry_id you created at charge time, keyed by the PSP’s balance_transaction id so the update is idempotent and a replayed webhook cannot double-write. This is why the schema below leaves settlement_minor nullable: the row is born with only the presentment truth and grows the settlement facts asynchronously.

Capture the rate as a decimal with enough precision to reproduce the amounts, not as a rounded display value. A NUMERIC(18,8) column holds a rate like 1.08734211 faithfully; storing 1.09 and multiplying loses cents at scale. When you need to verify a payout, the test is simple: round(presentment_minor * fx_rate) in the settlement currency’s minor unit should equal settlement_minor within a tolerance of one minor unit for network rounding. If it does not, either the stored rate is wrong or the PSP applied a fee you have not booked as a separate line.

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.

Beyond conversion rate, local methods change the shape of the money flow in ways your ledger and dunning logic must anticipate. iDEAL and Bancontact are effectively instant bank transfers that either succeed or never start, so there is no meaningful “pending” state and almost no chargeback surface — but they are single-use, so you cannot vault them for the next month’s renewal the way you vault a card. SEPA Direct Debit is the opposite: it authorizes now but can be reversed by the payer for up to eight weeks (and up to thirteen months for an unauthorized mandate), which means a subscription paid by SEPA can go negative long after you have recognized the revenue and provisioned the service. Pix settles in seconds but is push-based, so the customer initiates and you reconcile against an inbound event rather than capturing an intent you control. Each of these implies a different retry and reminder cadence when a renewal fails, so the presentment currency does not just gate which buttons render — it selects an entire recovery playbook downstream.

Because a mandate-based method like SEPA can reverse weeks later, the ledger_entry for such a charge should not be treated as final on the success webhook the way a card capture is. Leave room for a reversal event that books a compensating entry rather than editing the original — the original charge really happened, and the reversal really happened, and both belong in the ledger as separate immutable facts referencing the same invoice_id.

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()
);

Notice what is not in this table: no amount column without a currency, and no single “converted USD value” that pretends to be authoritative. Teams under reporting pressure often add a usd_equivalent_minor column so dashboards can sum across currencies without a join. That is fine as long as everyone understands it is a derived, lossy reporting convenience recomputed from presentment_minor and a reporting rate — never the basis for a refund, a tax filing, or a payout match. The instant someone treats the USD-equivalent column as money owed rather than as a chart input, you have reintroduced the runtime-conversion bug at the ledger layer, where it is far more expensive to unwind. Keep the presentment and settlement amounts as the only two real numbers, and mark any rolled-up currency as explicitly non-authoritative in the column comment.

The fx_rate column direction also needs a documented convention, because “the rate” is ambiguous without one. Store it as presentment-to-settlement — the multiplier that turns presentment_minor into settlement_minor — and write that in the schema comment. Half of all FX reconciliation confusion comes from one service treating the rate as EUR-per-USD and another as USD-per-EUR; the numbers are reciprocals and both look plausible, so the mistake survives code review and only surfaces when a payout is off by the square of the spread. Pick a direction, name it in the column, and assert it in a test.

Edge Cases & Failure Modes

The multi-currency edge cases split into rounding/decimals, FX timing, and region/currency mismatches. The map groups them so the defense — round-once, price-book stability, or per-currency validation — is obvious.

Multi-currency edge cases Rounding drift and zero-decimal currencies need round-once and per-currency exponents, FX timing needs price-book stability, and region mismatches need per-currency validation. Rounding / decimals per-line drift JPY as 2-decimal → round once + exponent FX timing rate moves mid-session refund rate differs → price book + FX line Region mismatch tax display wrong method × currency → per-currency validate
Three categories — round-once and the price-book stability guarantee are the two that prevent silent reconciliation drift.
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

The refund-rate case in that table is the one that quietly costs money, so it is worth expanding. When you issue a full refund on an invoice that was presented in EUR and settled in USD, you refund the customer the exact presentment_minor they paid — €29.00 is refunded as €29.00, because refunding a different number would be indefensible to the cardholder. But the PSP debits your USD balance at the refund-day rate, not the original authorization rate. If EUR strengthened against USD in the interim, the USD you give back exceeds the USD you originally received, and that delta is a real FX loss you must book — as its own ledger line referencing the invoice_id, not by silently editing the original charge. Over thousands of refunds these deltas net out to a small but nonzero number that appears on the P&L; if you never booked them, your ledger and your bank balance drift apart by exactly that amount and reconciliation fails without an obvious cause.

The “customer changes country mid-checkout” row has a second-order effect worth calling out: changing country can change not just the currency but the tax treatment, the available payment methods, and whether the price is shown inclusive or exclusive of tax. A naive implementation that swaps only the currency amount leaves a UK customer looking at a tax-exclusive US layout, or offers iDEAL to someone now billing in GBP. Treat a country change as a full re-resolution of the checkout state machine, not a field update — re-fetch the price-book row, re-run tax display, and re-filter payment methods atomically so the customer never sees a half-updated mixture of two regions.

Performance & Scale

The performance shape is a cached read-mostly price book on the hot path, scheduled FX fetches off it, and a nightly reconcile grouped by settlement currency. The diagram shows where each lives.

Multi-currency scale profile The price book is cached with a version key, FX rates are fetched hourly off the hot path, and reconciliation batches by settlement currency on a replica. Price book cached + version key one indexed read FX rates hourly fetch off hot path Reconcile group by settlement ccy nightly, replica
Never call an FX API on the checkout hot path — the price book is the stable source; reconcile per currency.

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

The tests target the classic multi-currency bugs: round-once versus per-line, zero-decimal exponents, settlement population, and FX gain/loss on refunds — all with fixed FX fixtures. The panel lists them.

Multi-currency tests Round-once equals per-total, JPY uses a zero-decimal exponent, a settlement webhook populates the rate once, and a refund records an FX gain/loss line. Round once 3 lines VAT = total round Zero-decimal JPY 2900 not 290000 Settlement webhook rate once Refund FX rate differs gain/loss line
Fixed FX fixtures keep these deterministic — the round-once test catches the most common reconciliation bug.

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.

Do I refund at the original FX rate or the current one? You refund the customer the exact presentment amount they paid, so from their side the rate is irrelevant — €29.00 in becomes €29.00 back. But your PSP settles the refund at the current rate, so the settlement-side debit rarely matches the original settlement-side credit. Book that difference as an FX gain or loss line against the same invoice_id; do not adjust the original charge, which remains an immutable record of what actually happened at authorization.

Should I use Dynamic Currency Conversion offered by the card networks? Generally no. DCC lets the cardholder pay in their home currency at the point of sale, but the rate is set by the acquiring side and is usually worse for the customer, and it muddies your presentment record by inserting a conversion you did not author. Prefer explicit multi-currency pricing from your own price book so the number the customer agrees to is the number you chose, and let the network handle any settlement-side conversion transparently behind the payout.

How do I keep the FX rate used for reporting from contaminating billing? Keep two entirely separate rate stores. The billing path never reads a reporting rate — it uses the static price book for presentment and the PSP-reported rate for settlement. The reporting rate exists only to roll disparate currencies into a single dashboard number and is explicitly lossy. Physically separating the two stores, and never importing one into the other’s code path, is the cleanest way to guarantee a reporting refresh can never move a customer’s bill.