POS SystemsPayment InfrastructureMulti-CurrencyInternationalArchitecture

Multi-Currency Payment Processing: Beyond Simple Conversion

By Farnaz Bagheri··13 min read

The shortest path to a multi-currency bug is to store amounts in dollars. It sounds obvious when written out, but almost every payment system I've seen that didn't start multi-currency has this bug in it somewhere, because the assumption "amount is in dollars" is so deeply embedded that nobody notices until a Japanese merchant signs up and the system quietly loses two decimal places on every transaction.

Multi-currency in payment systems is not a conversion problem. Currency conversion is a small piece of it. The large pieces are data modeling, precision, rounding, timing, reconciliation, and the handful of currencies whose rules violate every assumption that worked for USD and EUR. Adding multi-currency support to a system that wasn't designed for it is one of the larger rearchitectures a payment platform can go through, and it's almost always underestimated.

Precision is not universal

The first assumption that breaks is that every currency has two decimal places. Most do. Some don't.

  • Japanese Yen (JPY) has zero decimal places. One yen is the smallest unit. There are no fractional yen.
  • Bahraini Dinar (BHD), Kuwaiti Dinar (KWD), Jordanian Dinar (JOD), and a few others have three decimal places. The smallest unit is one-thousandth.
  • Some specialized currencies like Chilean Unidad de Fomento (CLF) have four decimal places.
  • Certain historical or unusual currencies have fractional minor units — the Mauritanian Ouguiya (MRU) has five khoums to the ouguiya, not a power of ten.

If your data model stores amount_cents as an integer, you are implicitly asserting that every amount is two decimal places from the "real" value. For JPY, this means every amount is 100x too large. For BHD, it means every amount is 10x too small. Both produce silent corruption.

The fix is to store the amount in the minor unit of the currency in question — not "cents," but "minor units of whatever currency this is." The number of minor units per major unit is a property of the currency, looked up from a reference table. The schema:

amount_minor   BIGINT NOT NULL,
currency_code  CHAR(3) NOT NULL REFERENCES currencies,

With a currencies table that encodes precision:

CREATE TABLE currencies (
  code                CHAR(3) PRIMARY KEY,
  minor_unit_exponent SMALLINT NOT NULL,  -- 0 for JPY, 2 for USD, 3 for BHD
  display_name        TEXT NOT NULL,
  ...
);

When displaying an amount to a user, the system divides amount_minor by 10 ^ minor_unit_exponent. When parsing an amount from user input, it multiplies. The logic is in one place. Every other part of the system just passes (amount_minor, currency_code) pairs around and doesn't try to interpret them.

Rounding is where correctness lives

Arithmetic on currency amounts — fees, taxes, conversions, splits — produces results that aren't whole numbers of minor units. What you do with those fractions determines whether your books balance.

There are several rounding modes, and choosing the wrong one costs real money at scale:

  • Round half up (arithmetic rounding): 0.5 rounds to 1. Most common, but introduces a systematic upward bias because 0.5 always rounds up.
  • Round half to even (banker's rounding): 0.5 rounds to the nearest even number — 0.5 → 0, 1.5 → 2, 2.5 → 2. Eliminates the upward bias. This is what financial systems typically want.
  • Round half down: 0.5 rounds to 0. Rarely correct.
  • Round toward zero (truncation): Drops the fractional part. Produces systematic bias but is sometimes required for tax reasons in specific jurisdictions.

The choice isn't just aesthetic. If you split a $1.00 transaction three ways with standard rounding, you get $0.33 + $0.33 + $0.34 = $1.00. With banker's rounding, you get $0.33 + $0.33 + $0.34 = $1.00. Same result. But split a $100.00 transaction a thousand ways, and the accumulated rounding error between the two methods becomes real money.

The deeper issue is that rounding has to happen at specific points, not continuously. If you apply a 2.9% fee to a $10.00 transaction and then a 3.5% fee on top of that, the order of rounding matters: round(10.00 * 0.029) + round(10.00 * 1.029 * 0.035) is not the same as round(10.00 * 0.029 + 10.00 * 1.029 * 0.035). The system has to have a documented rounding policy and apply it consistently. I've debugged accounting discrepancies that turned out to be two services rounding at different points in the same calculation.

Three currencies, not one

Any cross-border transaction involves at least three currencies:

  • Presentment currency: the currency shown to the customer. What they see on the terminal display, in their receipt, in their confirmation.
  • Processing currency: the currency the payment is authorized in. Usually the merchant's account currency, but sometimes different for dynamic currency conversion (DCC) transactions.
  • Settlement currency: the currency the merchant actually receives in their bank account. Often the same as processing currency, but not always — some processors settle in a single currency regardless of what the processing currency was.

Your data model has to represent all three. A single amount and currency pair is insufficient. The transaction record needs at minimum:

presentment_amount_minor     BIGINT NOT NULL,
presentment_currency_code    CHAR(3) NOT NULL,
processing_amount_minor      BIGINT NOT NULL,
processing_currency_code     CHAR(3) NOT NULL,
settlement_amount_minor      BIGINT,  -- NULL until settlement
settlement_currency_code     CHAR(3),
processing_to_settlement_fx  NUMERIC(20,10),

The exchange rate used for conversion is part of the record. The rate at the time of authorization might differ from the rate at settlement, and the difference is either absorbed by the merchant, passed to the customer, or charged as a fee — depending on configuration. Without storing the rate, you can't audit any of this later.

rendering…

When do you lock the rate?

Exchange rates move. For a transaction that takes milliseconds, this doesn't matter. For a transaction that involves a hold, adjustment, and settlement across multiple days, it matters enormously. The question is: what exchange rate governs the transaction, and when is that rate locked in?

There are several policies:

  • Lock at authorization. The rate shown at the moment of authorization is the rate for the entire transaction lifecycle, including captures, refunds, and settlements. Simple to reason about, but means the merchant bears FX risk between authorization and settlement.
  • Lock at capture. The rate is set when funds are captured, not when they're authorized. Better matches the accounting reality, but adjustments and tips complicate things.
  • Lock at settlement. The rate is set when the funds settle. The customer's statement shows the rate at settlement, which can confuse them if it differs from the authorization rate.
  • Re-quote per operation. Each operation (authorize, capture, refund) uses the rate at the moment it happens. Simplest from an accounting perspective but produces customer confusion when refund amounts don't match original charges due to rate movement.

There's no universally correct answer. What matters is that your system has an explicit policy, stores the rate with each operation, and surfaces rate differences to merchants and customers when they exist. The silent version — where rates differ between operations and nobody is told — produces support tickets and reconciliation headaches.

Where do rates come from? They come from a market data feed, usually provided by your processor, your bank, or a specialized vendor (Bloomberg, OANDA, Wise). They update continuously. Your system either queries the feed on demand (adds latency to every transaction) or caches recent rates (introduces a freshness question). For most POS use cases, cached rates refreshed every few seconds are a reasonable tradeoff.

The partial refund problem

A customer pays €100 in London, and the merchant settles in GBP at the day's rate. A week later, the merchant needs to refund €50 — half the original. What exchange rate applies?

If you use the original transaction's locked rate, the merchant's books are consistent: they receive half of what they got, paying it back in the same amount. But the customer might receive a different amount in euros than they paid, because the rate has moved.

If you use the current rate, the customer gets exactly €50 back, matching what they paid. But the merchant's books are unbalanced — they paid back more or less than half of what they received.

Most processors handle this with a policy: refunds default to the original transaction's rate (merchant-friendly), with an override for special cases. But the policy has to be explicit and visible in your data model. A refund record needs to know which rate was used and why, because the reconciliation pipeline has to account for the rate differential.

I have seen reconciliation reports with hundreds of thousands of dollars in "unexplained FX variance" that turned out to be the accumulated rounding and rate-timing differences on refunds across a year of international transactions. The variance was real; it was just distributed across so many small transactions that nobody saw it building up.

Dynamic currency conversion is a trap

DCC is the feature where, at the point of sale, the customer is offered the chance to pay in their home currency instead of the merchant's currency. The terminal shows "pay USD 100 or EUR 92" and the customer chooses.

From a technical perspective, DCC transactions are different animals. The merchant's processor quotes the FX rate and handles the conversion. The settlement is in the merchant's currency, but the authorization is in the customer's currency with a different exchange rate markup than normal conversions.

The data model has to represent DCC explicitly. A DCC transaction has:

  • An authorization in the customer's currency (presentment)
  • A processing record with the DCC-marked rate
  • A settlement in the merchant's currency at that specific DCC rate
  • A DCC markup fee (usually 3-8%) that's separately tracked

Lumping DCC transactions in with normal international transactions breaks reconciliation, makes fee reporting wrong, and obscures the specific regulatory requirements around DCC (it must be voluntary, the rate has to be disclosed, specific consent has to be recorded). Every major processor handles DCC differently, and your abstraction layer has to either hide this or expose it — but it has to do one of them consistently.

Regional compliance

Some compliance concerns are currency-adjacent. Payment methods available in one region aren't available in others. KYC requirements differ by merchant region and customer region. Reporting obligations (FATCA, CRS, regional tax authorities) are tied to currencies and regions. PSD2 and Strong Customer Authentication apply to EUR transactions in the EU but not to transactions outside that scope.

Your multi-currency architecture has to accommodate all of this. The merchant's configuration includes their region and the regions they operate in. Transactions are tagged with applicable regulatory frameworks based on the combination of merchant region, customer region, currency, and payment method. The downstream systems — compliance reporting, fraud screening, tax calculation — consume these tags.

This is not optional. A multi-currency system that doesn't track regional regulatory context will eventually process a transaction that violated a regulation nobody thought to check, and the consequences scale from fines to processor deactivation.

Storage of FX rates

Exchange rates are time-series data with reference semantics — you need to look up "what was the USD/EUR rate at 2:37 PM on March 14, 2025?" and get the exact answer that was used for transactions at that moment. This is different from "what's the current USD/EUR rate?" which is also useful but solves a different problem.

CREATE TABLE fx_rates (
  source        TEXT NOT NULL,  -- 'stripe', 'bloomberg', 'oanda', etc.
  from_currency CHAR(3) NOT NULL,
  to_currency   CHAR(3) NOT NULL,
  rate          NUMERIC(20, 10) NOT NULL,
  effective_at  TIMESTAMPTZ NOT NULL,
  recorded_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (source, from_currency, to_currency, effective_at)
);

Rates are looked up by "the most recent rate before this timestamp" — the historical rate that would have been applied at the time. The table grows large (there's a rate pair for every currency combination at every update interval) but lookups stay fast with a proper index, and the space cost is manageable for reasonable retention periods.

Do not overwrite rates. Always append. A rate at 2:37 PM is a fact that stays true forever, even if the same rate is published again later with slightly different numbers. Historical queries need historical truth.

What changes when you add a new currency

Adding a new currency to an existing system should be a configuration change, not a code change. If it requires code, the system isn't multi-currency — it's hardcoded-currency with some variation.

The checklist for a new currency:

  • Add the currency to the currencies table with the correct precision
  • Configure FX rate sources for all relevant pairs
  • Update the display formatting library (locale-specific symbols, separators, positions)
  • Verify processor support for authorization in the new currency
  • Configure settlement handling if merchants will settle in this currency
  • Review regional compliance implications
  • Test with rounding edge cases and zero-decimal / three-decimal math
  • Verify reconciliation pipeline handles the new currency

If any of these require code changes, your multi-currency architecture is incomplete. Adding currencies should be boring. The first time it's exciting is when you hit a precision or regional issue your system doesn't handle, and that's a signal to fix the architecture, not to special-case the new currency.

The deeper insight

Multi-currency is the payment feature that forces you to confront how deeply currency assumptions are embedded in a system. Most systems assume a single currency implicitly — in variable names, in API contracts, in report formats, in rounding logic, in fee calculations, in reconciliation scripts. Adding multi-currency support is largely the work of finding every one of these assumptions and making it explicit.

The payoff is that once you've done this, your system is genuinely portable across regions and scales globally without further rearchitecture. But the cost is higher than the feature seems like it should have, because the feature is really "audit every currency assumption in the system and fix it."

Do it early. The longer you wait, the more assumptions accumulate, and the larger the eventual refactor becomes. A system that starts life storing amount_minor and currency_code for every amount is a system that can add its tenth currency in a weekend. A system that starts life storing amount as a dollar figure is a system where the tenth currency is a six-month project.

The choice between these two futures is made on day one.


This is part of a series on payment systems architecture. See also database patterns for payment systems that actually work and the hardest part of payment systems is reconciliation.