How to Implement Multi-Currency Pricing in a Stripe-Powered SaaS with Node.js

How to Implement Multi-Currency Pricing in a Stripe-Powered SaaS with Node.js

by | Nov 10, 2026 | Uncategorized | 0 comments

Most articles about Stripe multi-currency pricing stop at “enable it in the Dashboard and you are done”. That works until your first JPY invoice is off by one unit, a Kuwaiti customer gets a rejected amount, or your EU prices suddenly display VAT-exclusive while your marketing site promises tax included.

This walkthrough covers the part that usually gets skipped: how to model prices in Postgres, how to detect the buyer’s region, how to cache FX rates, and how to avoid the rounding and tax traps that break checkout in production. All examples use Node.js with the official stripe SDK. See finexer.com for their take.

Quick answer: how Stripe multi-currency pricing actually works

Stripe gives you three distinct mechanisms, and choosing the wrong one is the root cause of most pricing bugs:

  1. Automatic conversion (presentment in your settlement currency): you charge 29 USD, the card network converts for the cardholder. You control nothing about the displayed amount.
  2. Multi-currency Prices via currency_options: one Price object holds a fixed amount per currency (29 USD, 29 EUR, 4500 JPY). You control every digit.
  3. Adaptive Pricing in Checkout: Stripe auto-localizes your amount using its own FX rate. Fast to ship, but you do not control price psychology or round numbers.
Approach Who sets the amount Best for Main drawback
Single currency + network conversion Card network MVPs, very low international volume Ugly displayed amounts, higher cart abandonment abroad
currency_options on a Price You SaaS with published pricing pages You own FX drift and price maintenance
Adaptive Pricing in Checkout Stripe One-off purchases, long tail markets Displayed price will not match your pricing page

For a subscription SaaS with a public pricing page, option 2 is almost always correct. Option 3 is a good fallback for the 100+ currencies you do not want to maintain by hand.

world currencies money

Step 1: Decide your presentment currencies before writing code

Do not localize into 80 currencies because a tool can generate them. Every presentment currency adds a price to maintain, a rounding rule, a tax behavior and a support ticket surface.

A sane starting set for a B2B SaaS:

  • USD as your anchor and fallback for everything unmapped
  • EUR and GBP for Europe
  • CAD, AUD for English-speaking markets
  • JPY only if you actually have Japanese demand (zero-decimal rules apply)

Then let Adaptive Pricing or plain USD cover the rest. Fewer currencies, fewer bugs.

Step 2: The Postgres schema (minor units only, never floats)

The single most common defect in multi-currency billing is storing amounts as numeric or, worse, float, then multiplying by 100 at checkout. Store integer minor units, exactly as Stripe expects them, and never compute a chargeable amount at request time.

-- Catalog
create table plans (
  id              bigserial primary key,
  code            text not null unique,          -- 'pro_monthly'
  stripe_product  text not null,                 -- 'prod_...'
  billing_period  text not null check (billing_period in ('month','year')),
  is_active       boolean not null default true,
  created_at      timestamptz not null default now()
);

-- Reference data: how many minor units in one major unit, and the
-- smallest increment Stripe will accept for this currency.
create table currencies (
  code              char(3) primary key,         -- 'usd', 'jpy', 'kwd'
  exponent          smallint not null,           -- 2, 0, 3
  min_increment     integer  not null default 1, -- 10 for 3-decimal currencies
  symbol            text not null,
  display_position  text not null default 'before'
);

-- One row per (plan, currency). This is your source of truth,
-- not the Stripe Dashboard.
create table plan_prices (
  id                 bigserial primary key,
  plan_id            bigint not null references plans(id),
  currency           char(3) not null references currencies(code),
  unit_amount_minor  bigint not null check (unit_amount_minor >= 0),
  tax_behavior       text not null check (tax_behavior in ('inclusive','exclusive')),
  stripe_price_id    text,                       -- 'price_...'
  fx_rate_used       numeric(18,8),              -- audit trail
  fx_captured_at     timestamptz,
  effective_from     timestamptz not null default now(),
  effective_to       timestamptz,
  unique (plan_id, currency, effective_from)
);

create index plan_prices_live_idx
  on plan_prices (plan_id, currency)
  where effective_to is null;

-- FX cache. Rates are an input to price generation, never to checkout.
create table fx_rates (
  base        char(3) not null,
  quote       char(3) not null,
  rate        numeric(18,8) not null,
  source      text not null,                     -- 'ecb', 'openexchange'
  fetched_at  timestamptz not null default now(),
  expires_at  timestamptz not null,
  primary key (base, quote, fetched_at)
);

create index fx_rates_lookup_idx on fx_rates (base, quote, fetched_at desc);

Why effective_from / effective_to matter

Stripe Price objects are immutable in their amount. When you reprice EUR from 29.00 to 31.00, you create a new Price and archive the old one. Your database must mirror that history, otherwise you cannot answer “what was this customer promised when they signed up?” during a dispute or a revenue recognition audit.

world currencies money

Step 3: Detect the user’s region without guessing wrong

Region detection should be a cascade, not a single signal. Order matters, from most authoritative to least:

  1. Explicit user choice stored on the account (a currency switcher in the UI). Always wins.
  2. Existing Stripe Customer currency. Once a Customer has an invoice or subscription, their currency is locked. Never show them a different one.
  3. Billing country already on file (from a previous address or tax ID).
  4. Edge geo header: CF-IPCountry on Cloudflare, x-vercel-ip-country on Vercel, or your CDN equivalent. Cheap and accurate enough.
  5. Accept-Language as a weak hint only. A French speaker in Montreal should see CAD, not EUR.
  6. USD fallback.
const COUNTRY_TO_CURRENCY = {
  US: 'usd', CA: 'cad', GB: 'gbp', AU: 'aud', NZ: 'aud', JP: 'jpy',
  DE: 'eur', FR: 'eur', ES: 'eur', IT: 'eur', NL: 'eur', BE: 'eur',
  IE: 'eur', PT: 'eur', AT: 'eur', FI: 'eur'
};

const SUPPORTED = new Set(['usd','eur','gbp','cad','aud','jpy']);

function resolveCurrency({ userPreference, stripeCustomerCurrency, billingCountry, req }) {
  if (userPreference && SUPPORTED.has(userPreference)) return userPreference;
  if (stripeCustomerCurrency) return stripeCustomerCurrency; // locked by Stripe

  const country =
    billingCountry ||
    req.headers['cf-ipcountry'] ||
    req.headers['x-vercel-ip-country'] ||
    null;

  const mapped = country ? COUNTRY_TO_CURRENCY[country.toUpperCase()] : null;
  return mapped && SUPPORTED.has(mapped) ? mapped : 'usd';
}

Critical rule: the currency used for the pricing page is a display decision. The currency used at checkout must be re-resolved server side right before creating the Checkout Session, because the billing address collected by Stripe may contradict the IP. If the two disagree, the address wins for tax, and the currency stays as presented to avoid a bait-and-switch.

Step 4: Generate amounts with integer-safe rounding

Here is where tutorials hand-wave. Converting 29.00 USD at 0.9183 gives 26.6307 EUR. Shipping “26.63 EUR” on a pricing page looks amateurish and signals to buyers that you are converting on the fly. Round to a price ladder instead.

The decimal rules you must encode

Currency group Examples Exponent Stripe amount for 29 units Constraint
Standard USD, EUR, GBP, CAD, AUD 2 2900 Any integer
Zero-decimal JPY, KRW, VND, CLP, XAF, XOF 0 29 Do not multiply by 100
Three-decimal BHD, JOD, KWD, OMR, TND 3 29000 Must be a multiple of 10

A JPY amount sent as 2900 instead of 29 is a 100x overcharge. A KWD amount of 29005 is simply rejected by the API. Encode both rules once, in a single helper, and never inline the conversion again.

// All math in integers. rate is a string to avoid float drift.
import Big from 'big.js';

const CURRENCY_META = {
  usd: { exponent: 2, increment: 1,  ladder: [900, 500, 0] },   // .99 / .95 / .00
  eur: { exponent: 2, increment: 1,  ladder: [900, 500, 0] },
  gbp: { exponent: 2, increment: 1,  ladder: [900, 500, 0] },
  jpy: { exponent: 0, increment: 1,  ladder: [80, 0] },         // x,x80 / round hundred
  kwd: { exponent: 3, increment: 10, ladder: [0] }
};

function convertMinor(amountMinorBase, baseCode, quoteCode, rate) {
  const base = CURRENCY_META[baseCode];
  const quote = CURRENCY_META[quoteCode];

  // Normalize to major units, convert, then re-expand to quote minor units.
  const major = new Big(amountMinorBase).div(new Big(10).pow(base.exponent));
  const converted = major.times(new Big(rate));
  const minor = converted.times(new Big(10).pow(quote.exponent));

  return BigInt(minor.round(0, Big.roundHalfUp).toString());
}

// Snap to a human-looking price that is still at or above the converted value.
function applyLadder(minorAmount, currencyCode) {
  const meta = CURRENCY_META[currencyCode];
  const unit = BigInt(10 ** meta.exponent);
  const wholeUnits = minorAmount / unit;

  const candidates = meta.ladder.map(tail => wholeUnits * unit + BigInt(tail));
  const next = candidates.filter(c => c >= minorAmount).sort((a, b) => (a < b ? -1 : 1));

  let chosen = next.length ? next[0] : (wholeUnits + 1n) * unit + BigInt(meta.ladder[0]);

  // Enforce Stripe's increment constraint (10 for three-decimal currencies).
  const inc = BigInt(meta.increment);
  if (chosen % inc !== 0n) chosen = ((chosen / inc) + 1n) * inc;

  return chosen;
}

Rounding up to the ladder, never down, protects your margin against the FX spread and Stripe’s conversion fee. Over a year of rate movement that single decision is worth more than most pricing experiments.

Step 5: Cache FX rates, and separate “rate” from “price”

Rates change every second. Your prices must not. The correct architecture:

  • A nightly job fetches rates from a single authoritative source and writes to fx_rates with a 24 hour expires_at.
  • A repricing job runs on a slow cadence (monthly or quarterly) and only proposes a new price when drift crosses a threshold, for example 5 percent.
  • Repricing proposals go to a human or to a feature flag. Never auto-publish a new public price.
  • Checkout reads plan_prices only. It never touches fx_rates.
async function getRate(db, base, quote) {
  if (base === quote) return '1';

  const { rows } = await db.query(
    `select rate from fx_rates
      where base = $1 and quote = $2 and expires_at > now()
      order by fetched_at desc limit 1`,
    [base, quote]
  );
  if (rows.length) return rows[0].rate;

  const fresh = await fetchRateFromProvider(base, quote);
  await db.query(
    `insert into fx_rates (base, quote, rate, source, expires_at)
     values ($1, $2, $3, $4, now() + interval '24 hours')`,
    [base, quote, fresh.rate, fresh.source]
  );
  return fresh.rate;
}

async function shouldReprice(db, planId, currency, baseCurrency) {
  const { rows: [live] } = await db.query(
    `select unit_amount_minor, fx_rate_used from plan_prices
      where plan_id = $1 and currency = $2 and effective_to is null`,
    [planId, currency]
  );
  if (!live) return true;

  const current = Number(await getRate(db, baseCurrency, currency));
  const drift = Math.abs(current - Number(live.fx_rate_used)) / Number(live.fx_rate_used);
  return drift > 0.05; // 5 percent threshold
}

Add a buffer. If your anchor currency is USD and you publish EUR prices quarterly, build in 2 to 4 percent of headroom. Between FX drift and Stripe’s conversion fee of roughly 1 to 2 percent on cross-currency settlement, a price converted at spot is already underwater on day one.

world currencies money

Step 6: Create the Stripe Price with currency_options

One Product, one Price per billing interval, multiple currencies inside it. This keeps your Checkout and Subscription code currency-agnostic. The piece 多币种结算| Stripe 文档 makes a good next read.

import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

async function publishPrice(db, planId) {
  const { rows } = await db.query(
    `select pp.currency, pp.unit_amount_minor, pp.tax_behavior, p.stripe_product, p.billing_period
       from plan_prices pp join plans p on p.id = pp.plan_id
      where pp.plan_id = $1 and pp.effective_to is null`,
    [planId]
  );

  const base = rows.find(r => r.currency === 'usd');
  const others = rows.filter(r => r.currency !== 'usd');

  const currency_options = {};
  for (const r of others) {
    currency_options[r.currency] = {
      unit_amount: Number(r.unit_amount_minor),
      tax_behavior: r.tax_behavior
    };
  }

  const price = await stripe.prices.create({
    product: base.stripe_product,
    currency: 'usd',
    unit_amount: Number(base.unit_amount_minor),
    tax_behavior: base.tax_behavior,
    recurring: { interval: rows[0].billing_period },
    currency_options,
    metadata: { plan_id: String(planId), managed_by: 'billing-service' }
  }, { idempotencyKey: `price:${planId}:${Date.now()}` });

  await db.query(
    `update plan_prices set stripe_price_id = $1
      where plan_id = $2 and effective_to is null`,
    [price.id, planId]
  );

  return price;
}

Two gotchas with currency_options

  • It is not expanded by default when you retrieve a Price. You must pass expand: ['currency_options'] or the field comes back undefined, which leads to “my EUR price disappeared” bug reports. This is a well known friction point in the Stripe SDKs.
  • Every currency you list must be enabled on your account and supported by your settlement setup. Listing a currency you cannot settle means the charge converts at Stripe’s rate with the conversion fee applied.
const price = await stripe.prices.retrieve(priceId, {
  expand: ['currency_options']
});

Step 7: Tax behavior is the pitfall nobody warns you about

Tax behavior tells Stripe whether unit_amount already contains tax. It is immutable once set to inclusive or exclusive. You get exactly one chance per Price.

The practical consequence for multi-currency SaaS:

Market Customer expectation tax_behavior Note
United States Tax added at checkout exclusive Sales tax varies by state and ZIP
EU / UK consumer (B2C) Displayed price includes VAT inclusive Legally required for consumers
EU B2B with valid VAT ID Net price, reverse charge exclusive Collect and validate the VAT ID
Japan Consumption tax usually shown included inclusive Combine with zero-decimal rounding

Because tax_behavior can be set per entry inside currency_options, you can run an inclusive EUR price and an exclusive USD price from the same Price object. That is the single most useful and least documented feature in Stripe multi-currency pricing.

With inclusive pricing, a 29.00 EUR headline price at 21 percent VAT nets you 23.97 EUR, while at 19 percent it nets 24.37 EUR. Your revenue per customer now varies by country even though the sticker price is identical. Model this in your financial plan before you commit to inclusive pricing across the EU.

world currencies money

Step 8: Creating the Checkout Session

With a multi-currency Price, you do not pick a different price ID per currency. You pass the currency on the session.

async function createCheckout({ db, user, planId, req }) {
  const currency = resolveCurrency({
    userPreference: user.preferred_currency,
    stripeCustomerCurrency: user.stripe_customer_currency,
    billingCountry: user.billing_country,
    req
  });

  const { rows: [row] } = await db.query(
    `select stripe_price_id from plan_prices
      where plan_id = $1 and currency = $2 and effective_to is null`,
    [planId, currency]
  );

  return stripe.checkout.sessions.create({
    mode: 'subscription',
    customer: user.stripe_customer_id,
    currency,                                   // selects the currency_options entry
    line_items: [{ price: row.stripe_price_id, quantity: 1 }],
    automatic_tax: { enabled: true },
    customer_update: { address: 'auto', name: 'auto' },
    tax_id_collection: { enabled: true },
    billing_address_collection: 'required',
    success_url: 'https://app.example.com/billing/done?s={CHECKOUT_SESSION_ID}',
    cancel_url: 'https://app.example.com/pricing'
  });
}

Customer currency locking

Once a Customer has an invoice, a subscription or a saved payment intent in a given currency, Stripe locks that Customer to that currency. Passing a different one returns an error. Design for it:

  • Store the locked currency on your user record as soon as the first invoice is created, via the invoice.created or customer.subscription.created webhook.
  • Hide the currency switcher for customers with an active subscription.
  • If a customer genuinely relocates, the correct migration is: cancel, create a new Customer object, resubscribe, and link both records in your database.

Step 9: Settlement, fees and what lands in the bank

Presenting in EUR does not mean receiving EUR. Stripe converts non-settlement currencies into your account’s default currency, with a conversion fee of roughly 1 to 2 percent depending on your region, on top of standard processing fees. Cross-border card fees stack on that.

Two mitigations worth the engineering time once you cross meaningful volume:

  1. Add settlement bank accounts in your main currencies so EUR charges pay out as EUR and skip conversion entirely.
  2. Record the settled amount, not the presented amount, in your revenue tables. Stripe Revenue Recognition uses the settled figure: if you collect 10 EUR and it settles as 12 USD, the recognized transaction amount is 12 USD. If your internal reporting uses 10 EUR converted at your own rate, your books will never reconcile.
-- Store both sides from the balance_transaction on every charge
alter table payments
  add column presented_currency char(3) not null,
  add column presented_amount_minor bigint not null,
  add column settled_currency char(3) not null,
  add column settled_amount_minor bigint not null,
  add column stripe_fee_minor bigint not null,
  add column exchange_rate numeric(18,8);

Pre-launch checklist

  • All amounts stored as bigint minor units, zero floats anywhere in the billing path
  • Zero-decimal currencies verified with a real test charge (JPY 1000 should bill 1000 yen, not 10 yen)
  • Three-decimal amounts are multiples of 10
  • tax_behavior set explicitly for every currency option, never left unspecified
  • expand: ['currency_options'] used everywhere you read a Price
  • Idempotency keys on every price and session creation call
  • Currency locking handled in the UI for existing subscribers
  • FX cache TTL and repricing threshold documented and monitored
  • Webhook handler persists settled amount, fee and exchange rate
  • Pricing page and Checkout show the identical number, to the cent, in every supported currency

FAQ

Does Stripe support multi-currency pricing?

Yes. Stripe supports payments in 130+ currencies and lets you define fixed amounts per currency on a single Price object using currency_options. Availability of specific presentment and settlement currencies depends on your account country and your connected bank accounts.

What are Stripe fees for international payments?

Expect your standard processing rate, plus a cross-border fee for cards issued outside your account country, plus a currency conversion fee of roughly 1 to 2 percent when the charge currency differs from your settlement currency. Exact rates vary by country, so confirm against your own Stripe pricing page before modeling margins.

How do I change the currency in Stripe?

You can change your account’s default settlement currency in Dashboard settings if your country supports it. You cannot change the currency of an existing Price object or of a Customer who already has invoices in another currency. The solution is to create a new Price, or a new Customer, and migrate.

Should I use Adaptive Pricing or currency_options?

Use currency_options for your core markets where the published price matters and you want clean round numbers. Enable Adaptive Pricing as a fallback for long tail countries you do not want to maintain manually. The two can coexist.

How often should I update converted prices?

Quarterly is a good default for most SaaS, with an exception trigger when drift exceeds about 5 percent. Updating more often confuses prospects and invalidates cached marketing pages. Updating less often erodes margin in volatile currencies.

What are the downsides of multi-currency pricing on Stripe?

Three real costs: conversion and cross-border fees, the operational overhead of maintaining a price matrix across currencies and tax behaviors, and the complexity of reconciling presented versus settled amounts in your accounting. For a product with meaningful international revenue, localized pricing still converts well enough to justify all three.

Need help shipping this?

We build and audit billing systems on Stripe for SaaS teams, including price modeling, tax configuration, webhook reliability and reconciliation. If your multi-currency rollout is stuck somewhere between a pricing page and a reconciliation spreadsheet, get in touch.