PaymentsAugust 4, 2026 · 8 min

Multi-currency billing done right: ECB FX sync and cent-accurate invoicing across 18 locales

Selling across Europe means six currencies, 18 locales, live exchange rates and country-specific rules — all reconciled to the cent. On E-Vignettes I learned that the trick isn't converting at checkout; it's syncing ECB rates on a schedule into persisted prices, so billing stays stable and auditable even when the rate moves mid-session.

Why not just convert at checkout?

The naive approach converts a base price to the customer's currency at the moment of payment using the live rate. It feels correct and it's a trap: the displayed price can differ from the charged price if the rate ticks between page load and payment, refunds computed later use a different rate than the sale, and your invoices become impossible to reconcile because every one used a slightly different number. Cent-accuracy and auditability both break.

Sync rates on a schedule, persist the price

Instead, a cron pulls the ECB reference rates once per day and writes concrete per-currency prices into the database. Checkout reads a persisted price, not a live conversion. The rate a customer paid at is a stored fact, not a re-derivation — so the invoice, the refund and the report all agree.

// Daily cron: fetch ECB rates, persist derived prices
const rates = await fetchEcbRates();          // base EUR
for (const product of products) {
  for (const cur of SUPPORTED) {
    const amount = toMinorUnits(product.eur * rates[cur], cur);
    await db.upsertPrice({ productId: product.id, currency: cur, amount, rateDate: rates.date });
  }
}

Cent-accuracy means integer minor units

Money never lives in a float. Prices are stored and computed in minor units (cents, and the right number of decimals per currency — some have zero). Rounding happens once, at the moment a price is derived from a rate, and then never again. Every downstream number is integer arithmetic on that stored amount.

Floating-point money is a latent bug. 0.1 + 0.2 is not 0.3, and across thousands of invoices those fractions become real reconciliation discrepancies. Store minor-unit integers; round exactly once.

Locale is data, not branches

18 locales and country-specific validity rules do not mean 18 code paths. Currency formatting, invoice localization and the country rules live in configuration keyed by locale/country, so adding a market is a data change, not a deploy full of new if-statements. The commerce engine stays the same; the config grows.

Attribution and analytics still work

Because prices are persisted and orders carry their currency and rate date, GA4 ecommerce events and lead-to-sale attribution report in a consistent way — the analytics layer isn't guessing what the customer actually paid.

Key takeaways

  • Persist prices from scheduled rates; never convert at checkout.
  • Store money as integer minor units and round exactly once.
  • Keep locale and country rules in config so a new market is data, not code.
  • A stored rate-date makes sales, refunds and reports reconcile to the cent.

FAQ

How often should exchange rates be synced?

For most commerce, daily against a reference source like the ECB is plenty and keeps billing stable. High-frequency conversion at checkout looks precise but destroys reconciliation; a stored daily rate is both accurate enough and auditable.

How do you avoid rounding errors in multi-currency billing?

Keep all money in integer minor units, derive the per-currency amount once when you persist the price, round at that single point, and do only integer arithmetic afterward. Never let a float represent money.

AA
Ali Asghar

Senior software engineer & technical lead — 6+ years shipping production multi-tenant SaaS, payments and AI integration in Next.js, Node & TypeScript.

Keep reading
Payment webhooks the hard parts: idempotency, retries and reliabilityServer-side conversion tracking: recovering the revenue GA4 can't see