Skip to main content
Back to AI Commerce Lab
Commerce·December 2025·10 min read

Designing Multi-Currency Backends

Multi-currency commerce fails for the same reason most money bugs happen: someone stored an amount as a float, or conflated the currency a customer paid in with the currency a ledger reports in. Fix those two decisions first and the rest of the system, FX rates, rounding, refunds, gets much easier to reason about.

Supporting multiple currencies looks like a frontend problem: show the price in the customer's currency, convert at checkout, done. It isn't. Every currency you support touches pricing, checkout, payment settlement, refunds, tax, and financial reporting, and each of those systems needs to agree on what an amount actually means.

Get the foundational decisions wrong and the failures don't show up in a demo. They show up months later, in a refund that doesn't match the original charge, or a ledger that won't reconcile at close.

Money is an integer, not a float

Binary floating-point numbers, defined by IEEE 754, can't represent most decimal fractions exactly. 0.1 plus 0.2 in IEEE 754 double precision isn't 0.3 — it's off by a tiny amount that's invisible in a single calculation and compounds across millions of transactions.

The fix is standard and well established: store money as an integer count of the currency's smallest unit, cents for USD, pence for GBP, and apply currency-specific scaling everywhere you convert to or from a human-readable display value.

Not every currency has two decimal places

ISO 4217, maintained on ISO's behalf by SIX Group, defines the minor unit for every currency, and it isn't always two decimal places. The Japanese yen and Korean won have zero decimal places; the Bahraini dinar and Kuwaiti dinar have three. Stripe's API documents this directly: its charge amounts are always expressed in the currency's minor unit, with zero-decimal currencies like JPY taking the amount as-is and two-decimal currencies like USD taking amount times 100.

// naive: breaks for JPY, KWD, and anything not 2 decimals
const amountInMinorUnits = amount * 100;

// correct: minor-unit exponent comes from the currency, not a constant
const minorUnitExponent = { USD: 2, JPY: 0, KWD: 3 };
const toMinorUnits = (amount, currency) =>
  Math.round(amount * 10 ** minorUnitExponent[currency]);

A codebase that hardcodes "multiply by 100" will silently overcharge or undercharge every zero-decimal or three-decimal currency it ever touches. This is one of the most common multi-currency bugs, and it's entirely avoidable by keying scaling logic off the ISO 4217 minor-unit table instead of a constant.

If your money type doesn't know its own currency's minor unit, it isn't a money type. It's an integer that happens to represent cash some of the time.

Base currency versus presentment currency

A customer in London might see prices in GBP, pay through a gateway that settles in EUR, while the company's internal ledger runs in USD. Without a clear model for which currency means what at each stage, refunds and reporting drift apart almost immediately.

Three currencies, three jobs

Shopify's Markets architecture is a useful concrete reference here: it distinguishes shop money (the merchant's base currency) from presentment money (the currency shown to and charged from the customer), exposed through its MoneyBag type on nearly every monetary field in its API. That distinction, base versus presentment, is the same one every multi-currency backend needs, regardless of platform.

  • Base currency — the single currency all internal accounting, reporting, and reconciliation run through
  • Presentment currency — what the customer sees and pays in, which can vary by storefront, market, or customer preference
  • Settlement currency — what the payment processor actually deposits, which may differ from both if the processor converts on its own schedule

A refund has to reverse the same path the original charge took, presentment amount back to the customer, base-currency amount reversed in the ledger, using the FX rate that applied to the original transaction rather than today's rate. Refunding at a different rate than the original charge is a common source of ledger drift that only surfaces at reconciliation.

FX rate provenance

Every transaction that involves a conversion needs to record which rate was used, from which provider, and at what timestamp, not just the converted amount. Without that record, a refund, a chargeback, or an audit six months later has no way to reproduce the original calculation.

{
  "amount_minor": 4999,
  "currency": "GBP",
  "base_amount_minor": 5876,
  "base_currency": "USD",
  "fx_rate": 1.1754,
  "fx_rate_source": "provider_x",
  "fx_rate_timestamp": "2026-08-01T09:03:12Z"
}

This is the same discipline as recording which tax rate applied to a transaction at the time it happened, even after the rate changes later. Money math that can't be reproduced after the fact isn't auditable, and unauditable money math eventually becomes a finance team's problem instead of an engineering one.

FX PROVENANCERECORDrate · source · timestampPRESENTMENTcustomer paysCONVERSION POINTFX appliedBASE / LEDGERreporting currencymay differSETTLEMENTprocessor deposits

The conversion point is the one place the FX rate, source, and timestamp get recorded — every downstream refund replays from that record instead of today's rate.

Rounding rules and reconciliation

Rounding has to happen somewhere, and the choice of when and how changes the total by fractions of a unit that add up at volume. Round too early, at the line-item level, and the sum of rounded lines can differ from the rounded total. Round too late, and per-unit prices displayed to a customer might not match what a manual recalculation produces.

ApproachHow it worksWhere it's usedFailure mode
Round-half-up per line itemEach line rounds independently before summingSimple invoicing, most retail cartsSum of rounded lines can drift from a directly rounded total
Round the total onlyFull precision kept through calculation, rounded once at the endFinancial reporting, tax calculationLine items shown to a customer may not individually sum to the displayed total
Banker's rounding (round-half-to-even)Ties round to the nearest even digit instead of always upAccounting systems minimizing cumulative rounding biasUnintuitive to anyone expecting standard rounding; needs clear documentation

Whichever approach a system picks, the requirement is consistency: the same rounding rule applied the same way in checkout, in the ledger, and in customer-facing receipts. A mismatch between what checkout displays and what the ledger records is a support ticket waiting to happen.

Formatting is a display concern, not a storage concern

How a currency amount is displayed, symbol placement, decimal separator, grouping, is governed by locale, not by the currency itself. The Unicode Common Locale Data Repository's number and currency formatting specification defines exactly this separation: a currency has a fixed minor unit, but its display format varies by locale, so €1.234,56 and €1,234.56 can represent the same stored value shown to different audiences.

Conflating storage format with display format is a common source of parsing bugs when an amount round-trips through a system boundary. Store the integer minor-unit amount and the ISO 4217 currency code; format for display only at the point of rendering, using locale data rather than hardcoded symbol logic.

Idempotency for FX-sensitive operations

Any operation that touches an FX rate, a charge, a refund, a currency conversion, needs an idempotency key. Retrying a failed charge request without one risks charging a customer twice if the first request actually succeeded upstream but the response was lost.

  1. Generate an idempotency key per logical operation, not per HTTP request, so a client-side retry reuses the same key.
  2. Store the key alongside the FX rate and amount used, so a retried request returns the original result instead of recalculating against a rate that may have moved.
  3. Set a reasonable expiry on stored idempotency records, since keeping them forever turns a safety mechanism into unbounded storage growth.
  4. Apply the same discipline to refunds as to charges. A duplicate refund is as damaging as a duplicate charge, just harder to notice quickly.
An idempotency key isn't a nice-to-have for payment retries. Without one, "network timeout, please retry" and "double-charge the customer" are the same code path.

Payment gateway currency limitations

Not every gateway supports every currency, and some that display a currency at checkout still settle in a different one behind the scenes. Intelligent routing, checking which gateways support a given presentment currency and whether conversion happens before or after settlement, avoids surprises that otherwise show up as reconciliation mismatches weeks later.

This is also where the base-currency ledger earns its keep. If every gateway's settlement, regardless of currency, converts back to the same base currency using recorded rates, reconciliation becomes a matter of comparing recorded conversions against actual bank deposits rather than untangling five different currency reports by hand.

A minimal money value object

Most of the failure modes above trace back to treating money as a bare number somewhere in the codebase. A small value object, enforced at every boundary, closes most of them at the type level.

class Money {
  constructor(amountMinorUnits, currencyCode) {
    if (!Number.isInteger(amountMinorUnits)) {
      throw new Error("Money must be stored in integer minor units");
    }
    this.amount = amountMinorUnits;
    this.currency = currencyCode; // ISO 4217 alpha code
  }

  add(other) {
    if (other.currency !== this.currency) {
      throw new Error("Cannot add different currencies without an explicit conversion");
    }
    return new Money(this.amount + other.amount, this.currency);
  }
}

The explicit currency check on add() is the important part. Silently adding GBP to USD because both are represented as plain numbers is exactly the kind of bug that a type system, or even a runtime guard, should make impossible rather than relying on a developer to remember.

Architecture checklist

  • Store all amounts as integers in the currency's minor unit, keyed off ISO 4217, never as floats
  • Separate base, presentment, and settlement currency explicitly in the data model
  • Record the FX rate, source, and timestamp on every transaction that involves conversion
  • Pick one rounding rule and apply it identically across checkout, ledger, and receipts
  • Keep formatting logic (locale, symbols, separators) entirely separate from storage logic
  • Require an idempotency key on every charge, refund, and conversion operation
  • Reverse refunds using the original transaction's recorded rate, not the current rate

FAQ

Should prices be stored in every supported currency, or converted on the fly?

Store the base-currency price as the source of truth and convert to presentment currencies at read time using a recorded rate, unless the business explicitly wants fixed regional pricing that doesn't move with FX. Mixing the two approaches in the same catalog creates ambiguity about which number is authoritative.

How often should FX rates refresh?

It depends on volatility tolerance and the FX provider's update frequency, but whatever cadence is chosen, the rate used for a given transaction must be recorded with that transaction. Refreshing hourly is common; the recording discipline matters more than the exact interval.

What happens if the FX provider is down at checkout?

The system needs a documented fallback: typically the last successfully cached rate with a defined maximum staleness, past which checkout in affected currencies should degrade gracefully rather than charge against a rate that might be badly out of date.

Can floating-point be safe for money if you're careful with rounding?

No. The imprecision is in the binary representation itself, defined by IEEE 754, not in how carefully rounding is applied afterward. Integer minor units or a fixed-point decimal type are the only approaches that avoid the problem at its source.

Does every market need its own presentment currency?

Not necessarily. Some businesses deliberately price in a small number of major currencies rather than every local one, trading some local-market conversion for simpler pricing and fewer FX exposure points to manage.

How does multi-currency change tax calculation?

Tax is usually calculated and reported in the jurisdiction's local currency and rate, independent of what currency the customer paid in. That means the ledger often needs a third figure alongside base and presentment amounts: the tax-jurisdiction amount used for filing.

References

From the Destm engineering archive. For current work on this topic, start at Solutions or the blog.