How to Handle Multi-Currency Billing in a SaaS App

We earn commissions when you shop through the links below.

If you’re selling software globally, knowing how to handle multi-currency billing in a SaaS app is not optional — it’s a core product requirement. Customers in Europe expect to pay in EUR, customers in Japan expect JPY, and showing everyone a USD price with a disclaimer that says “billed in USD” erodes trust and kills conversions. I’ve shipped multi-currency billing on multiple SaaS products and this guide covers the architecture decisions, the gotchas, and the actual code you need to get it right.

Why Multi-Currency Billing Is Hard

On the surface it seems simple: look up the exchange rate, multiply the price, display the result. In practice, you’re dealing with:

  • Floating exchange rates — the rate at signup differs from the rate at renewal
  • Currency rounding rules — JPY has no decimals, KWD has three
  • Tax compliance per country — VAT, GST, and local tax rules vary
  • Stripe or payment processor limits — not every currency supports every payment method
  • Reporting complexity — your accountant wants numbers in one currency

Let’s tackle these one by one.

The Database Design

The first decision is whether to store prices in a single base currency and convert on the fly, or store explicit prices per currency. I strongly recommend storing explicit prices per currency for subscription products. Floating conversions lead to subscription amounts changing at renewal, which creates customer support chaos.

Here’s a schema that works well:

CREATE TABLE plans (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(100) NOT NULL,
  base_price_cents INT NOT NULL,  -- always in USD cents
  base_currency CHAR(3) NOT NULL DEFAULT 'USD',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE plan_prices (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  plan_id BIGINT NOT NULL REFERENCES plans(id),
  currency CHAR(3) NOT NULL,
  amount_cents INT NOT NULL,       -- in smallest unit of currency
  interval ENUM('month','year') NOT NULL,
  stripe_price_id VARCHAR(100),    -- Stripe Price object ID
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uq_plan_currency_interval (plan_id, currency, interval)
);

CREATE TABLE subscriptions (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id BIGINT NOT NULL,
  plan_id BIGINT NOT NULL,
  currency CHAR(3) NOT NULL,       -- locked at signup
  amount_cents INT NOT NULL,       -- locked at signup
  stripe_subscription_id VARCHAR(100),
  status VARCHAR(50),
  current_period_end TIMESTAMP,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

The key insight: currency and amount_cents are locked on the subscription row at the time the customer subscribes. Renewals always use the original locked price, not a live conversion. If you change pricing, existing subscribers are grandfathered until you explicitly migrate them.

Setting Up Stripe for Multi-Currency

Stripe supports presentment currencies natively, which makes this significantly easier. For each plan, you create a separate Stripe Price object per currency:

import Stripe from 'stripe';

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

async function createPlanPrices(stripeProductId: string) {
  const prices = [
    { currency: 'usd', unit_amount: 1900 },  // $19.00
    { currency: 'eur', unit_amount: 1790 },  // €17.90
    { currency: 'gbp', unit_amount: 1590 },  // £15.90
    { currency: 'jpy', unit_amount: 2800 },  // ¥2800 (no decimals)
    { currency: 'aud', unit_amount: 2990 },  // A$29.90
  ];

  const createdPrices = [];

  for (const price of prices) {
    const stripePrice = await stripe.prices.create({
      product: stripeProductId,
      currency: price.currency,
      unit_amount: price.unit_amount,
      recurring: { interval: 'month' },
    });

    createdPrices.push({
      currency: price.currency.toUpperCase(),
      amount_cents: price.unit_amount,
      stripe_price_id: stripePrice.id,
    });
  }

  return createdPrices;
}

Notice that JPY uses 2800 as the amount with no decimal shift — Stripe treats JPY as a zero-decimal currency. Always check Stripe’s zero-decimal currency list before assuming a currency uses cents.

Currency Detection and Selection

When a new user lands on your pricing page, you need to pick a default currency. My approach:

  1. Check if the user has an account with a stored currency preference
  2. Otherwise, use the IP geolocation country to map to a currency
  3. Always let the user override with a dropdown
const COUNTRY_TO_CURRENCY: Record = {
  US: 'USD', CA: 'CAD', GB: 'GBP',
  AU: 'AUD', NZ: 'NZD', JP: 'JPY',
  DE: 'EUR', FR: 'EUR', IT: 'EUR', ES: 'EUR', NL: 'EUR',
  IN: 'INR', BR: 'BRL', MX: 'MXN',
  SG: 'SGD', HK: 'HKD',
  // Add more as you expand
};

async function detectCurrency(req: Request): Promise {
  // Cloudflare adds CF-IPCountry header automatically
  const country = req.headers.get('CF-IPCountry') ?? 'US';
  const detected = COUNTRY_TO_CURRENCY[country] ?? 'USD';

  // Only use detected currency if we have a price for it
  const supported = await getSupportedCurrencies();
  return supported.includes(detected) ? detected : 'USD';
}

I use Cloudflare for this because the CF-IPCountry header is free and available on every request with no extra API call. If you’re deploying on DigitalOcean App Platform or Droplets without Cloudflare, you’ll want a lightweight geo-IP library like maxmind or an API like ipapi.co.

Creating the Checkout Session

When the user clicks subscribe, you look up the correct Stripe Price ID for their currency and create a checkout session:

async function createCheckoutSession(userId: string, planId: number, currency: string) {
  // Fetch the price row for this plan + currency
  const planPrice = await db.query(
    'SELECT * FROM plan_prices WHERE plan_id = ? AND currency = ? AND interval = ?',
    [planId, currency, 'month']
  );

  if (!planPrice) {
    // Fallback to USD if the currency isn't supported
    return createCheckoutSession(userId, planId, 'USD');
  }

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [{
      price: planPrice.stripe_price_id,
      quantity: 1,
    }],
    success_url: `${process.env.APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/pricing`,
    metadata: {
      user_id: userId,
      plan_id: planId.toString(),
      currency: currency,
    },
  });

  return session.url;
}

Handling Webhooks and Locking Currency

When Stripe fires checkout.session.completed, you lock the currency on the subscription record:

async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
  const stripeSubscription = await stripe.subscriptions.retrieve(
    session.subscription as string
  );

  await db.query(
    `INSERT INTO subscriptions 
     (user_id, plan_id, currency, amount_cents, stripe_subscription_id, status, current_period_end)
     VALUES (?, ?, ?, ?, ?, ?, ?)`,
    [
      session.metadata.user_id,
      session.metadata.plan_id,
      session.metadata.currency,
      stripeSubscription.items.data[0].price.unit_amount,
      stripeSubscription.id,
      stripeSubscription.status,
      new Date(stripeSubscription.current_period_end * 1000),
    ]
  );
}

Displaying Prices Correctly

Use the browser’s built-in Intl.NumberFormat for price display — it handles decimal rules automatically:

function formatCurrency(amountCents: number, currency: string, locale = 'en'): string {
  // Zero-decimal currencies
  const zeroDecimal = ['JPY', 'KRW', 'VND', 'BIF', 'CLP', 'GNF', 'MGA', 'PYG', 'RWF', 'UGX', 'XAF', 'XOF'];
  
  const amount = zeroDecimal.includes(currency.toUpperCase())
    ? amountCents
    : amountCents / 100;

  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: currency.toUpperCase(),
    minimumFractionDigits: zeroDecimal.includes(currency.toUpperCase()) ? 0 : 2,
  }).format(amount);
}

// Usage
formatCurrency(1900, 'USD'); // "$19.00"
formatCurrency(2800, 'JPY'); // "¥2,800"
formatCurrency(1790, 'EUR'); // "€17.90"

Revenue Reporting in a Single Currency

Your accountant and your MRR dashboard need numbers in one currency. The pattern I use: store a usd_equivalent_cents column on every invoice row, populated at the time of the transaction using the exchange rate at that moment. Never recalculate historical revenue with today’s rates.

async function recordInvoice(stripeInvoice: Stripe.Invoice) {
  // Stripe provides amount_paid in the invoice's currency
  const currency = stripeInvoice.currency.toUpperCase();
  const amountPaid = stripeInvoice.amount_paid;

  // Fetch exchange rate (cache this — don't hit the API per invoice)
  const rate = await getExchangeRate(currency, 'USD');
  const usdEquivalent = Math.round(amountPaid * rate);

  await db.query(
    `INSERT INTO invoices (stripe_invoice_id, currency, amount_cents, usd_equivalent_cents, paid_at)
     VALUES (?, ?, ?, ?, ?)`,
    [stripeInvoice.id, currency, amountPaid, usdEquivalent, new Date()]
  );
}

For exchange rates, I use Make to run a daily automation that fetches rates from the European Central Bank (free) and caches them in my database. This way I’m never making live external API calls during payment processing.

Common Pitfalls to Avoid

  • Don’t recalculate historical MRR with current rates. Lock the USD equivalent at transaction time.
  • Don’t forget minimum charge amounts. Stripe has minimum charge amounts per currency — $0.50 USD, ¥50 JPY, etc. Your discount logic needs to respect these.
  • Don’t offer every currency immediately. Start with USD, EUR, GBP, and AUD. Add more based on where your signups are coming from.
  • Always test with Stripe test mode. Use Stripe’s test card numbers with different billing countries to verify your currency detection flows.
  • Communicate currency on invoices. Make the currency obvious on every invoice and in every email. Customers dispute charges when they’re confused about currency.

Wrapping Up

Knowing how to handle multi-currency billing in a SaaS app properly comes down to a few core principles: lock prices at subscription time, store explicit prices per currency rather than converting on the fly, use Stripe’s native multi-currency support, and always record revenue in a normalized base currency for reporting. The implementation is more mechanical than magical — it’s mostly careful data modeling and following Stripe’s documentation closely.

If you’re just getting started with your SaaS infrastructure, deploying on DigitalOcean gives you a straightforward environment to run your billing backend with predictable pricing — no surprise cloud bills on top of your Stripe fees. Get the foundation right early, and adding currencies later becomes a simple database operation rather than a re-architecture project.