DEV Community

Kadokey
Kadokey

Posted on

How to Build a Multi-Currency Price Engine for a Digital Storefront

Selling digital goods across borders sounds simple until you try to show the right price, in the right currency, at the right moment. A fixed exchange-rate multiplier slapped onto your catalog will quietly leak money through rounding errors, stale rates, and prices that feel wrong to local buyers.

I learned this the hard way while working on pricing infrastructure for a digital storefront. Here is the architecture that finally worked — and the traps to avoid.

1. Store one source of truth

Every product gets a single base price in a single base currency (USD, EUR, whatever you settle in). All local prices are derived, never stored independently:

// Never do this:
product.price_eur = 9.99;
product.price_brl = 54.90; // stale the moment the real moves

// Do this:
product.base_price = { amount: 999, currency: 'USD' }; // minor units!
Enter fullscreen mode Exit fullscreen mode

Storing money in minor units (cents) as integers kills an entire class of floating-point bugs. 0.1 + 0.2 should never touch your money path.

2. Treat FX rates as a stream, not a constant

Exchange rates move constantly. Your engine should:

  • Fetch rates on a schedule (hourly is fine for most storefronts; use a provider like the ECB feed or a commercial API) and store each fetch with a timestamp.
  • Serve the latest snapshot from your own cache — never hit the FX API in the request path of a product page.
  • Version every rate snapshot so you can audit exactly which rate produced a given price. This matters enormously for refunds and support tickets.

A simple table works: fx_rates(base, quote, rate, fetched_at). Your pricing service reads the newest row per pair.

3. Convert, then round to a price point

Raw conversion produces nonsense prices: $9.99 at 0.92 EUR/USD is €9.1908. Nobody trusts a price like that. The fix is a two-step pipeline:

  1. Convert with full precision: base_amount * rate.
  2. Snap to a price point: map the result to the nearest psychological price ending (.99, .95, or whole units depending on the market).
function pricePoint(amount, currency) {
  const endings = { USD: 0.99, EUR: 0.99, BRL: 0.90, JPY: 0 };
  const ending = endings[currency] ?? 0.99;
  const major = Math.floor(amount / 100);
  return ending === 0 ? major * 100 : major * 100 + Math.round(ending * 100);
}
Enter fullscreen mode Exit fullscreen mode

Different markets have different conventions — .99 works in the US and EU, but several Latin American and Asian markets prefer round numbers. Make the endings configurable per currency, not hardcoded.

4. Cache derived prices, invalidate deliberately

Recomputing thousands of product prices on every rate tick is wasteful. Cache the derived price per (product_id, currency) and invalidate when:

  • A new FX snapshot arrives (bulk invalidate per currency pair).
  • The base price changes.
  • The rounding rules change.

This is one of the rare cases where cache invalidation is genuinely manageable, because the invalidation triggers are explicit events, not guesses.

5. Format for the locale, not just the currency

Intl.NumberFormat does the heavy lifting — currency symbol placement, decimal separators, and grouping all vary by locale:

new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' })
  .format(9.99); // "9,99 €"
Enter fullscreen mode Exit fullscreen mode

A common bug: formatting EUR prices with en-US conventions. Pair every currency with its actual shopper locales, and test with real locale strings.

6. Decide what happens at checkout vs. browsing

This is the design decision most teams skip:

  • Browsing prices can be approximate and cached — they are informational.
  • Checkout prices must be locked to a specific rate snapshot at the moment the order is created, and that snapshot ID must be stored on the order.

If a rate moves between "add to cart" and "pay", the customer should pay the price they saw. Locking the snapshot makes this deterministic and auditable. Marketplaces that sell cross-region game keys, like Kadokey, live or die by this: a shopper in one country comparing prices against another region needs the checkout total to match the listing exactly, or trust evaporates. When we built price displays for instant-delivery digital storefronts, snapshot-locked checkout totals eliminated a whole category of "the price changed!" support tickets.

7. Handle the edge cases

  • Zero-decimal currencies (JPY, KRW): no cents at all. Your minor-units logic must know which currencies have no subunit.
  • Hyperinflation / volatile currencies: consider refreshing rates more often or pricing in a stable reference currency.
  • Rounding drift on bundles: convert the bundle total, not the sum of converted line items — otherwise totals won't add up on the receipt.
  • Tax-inclusive vs. tax-exclusive: decide per region whether the displayed price includes VAT. In the EU it legally must.

The takeaway

A multi-currency price engine is really four small systems: a canonical base price, a versioned FX rate stream, a conversion-plus-rounding pipeline, and a checkout locking mechanism. Keep them separate, keep money in integers, and audit every rate that touches an order. Your future self — and your support team — will thank you.

Top comments (0)