Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 038 / 100

Multi-currency payments for Payments & Stripe developers

Sell globally.

Expona Admin9 min read

Multi-currency payments for Payments & Stripe developers

Building a global payment system means more than just accepting cards from every country—it means respecting how your customers think about money. When a UK customer sees a price in GBP instead of USD, conversion rates jump. When you lock prices upfront instead of relying on live FX rates, you avoid margin bleed and customer disputes. Multi-currency payments for Payments & Stripe developers is the difference between a platform that feels local and one that feels foreign, no matter where the user is.

Present prices locally — Customers convert better in their own currency

The Psychology of Currency is simple: people spend more confidently when they see their home currency. A €50 purchase feels cheaper than a $54 one, even if they're equivalent. This is price anchoring, and it works.

From an implementation angle, you need to detect the customer's location and render prices in their local currency. This can happen client-side via geolocation, or server-side via billing address, IP, or explicit selection.

typescript
// Client-side currency detection
async function getPricingForCustomer(customerId: string) {
  const customer = await stripe.customers.retrieve(customerId);
  const billingCountry = customer.address?.country;
  
  const currencyMap: Record<string, string> = {
    'GB': 'gbp',
    'DE': 'eur',
    'JP': 'jpy',
    'US': 'usd',
    'CA': 'cad'
  };
  
  const currency = currencyMap[billingCountry] || 'usd';
  
  // Fetch product pricing in that currency
  const pricing = await db.query(
    `SELECT price_in_${currency} FROM products WHERE active = true`
  );
  
  return { pricing, currency };
}

Stripe makes this straightforward. Each Product in your Stripe dashboard can have multiple Prices, one per currency. When you create a checkout session or invoice, specify the currency, and Stripe handles the rest.

The key insight: detection is not enough. You also need to make currency switching frictionless. A sidebar toggle, a select dropdown, or an auto-detect flow all work. Test with real users in different regions to see which converts highest.

Price per currency — Set prices explicitly, not by live conversion

Here's where many teams stumble: they set a price in USD, then naively convert it to other currencies using the spot FX rate. This breaks down in weeks.

Why? FX rates move. Your 100 USD product might be 92 EUR on Monday and 89 EUR on Friday. Your customers see inconsistent pricing. Your P&L swings. Your margins erode if you're not hedging.

Instead, set prices explicitly per currency. This means deciding, upfront, what a product costs in GBP, EUR, JPY, and so on. You can still use FX rates as a starting point, but then you apply business logic: regional pricing power, VAT considerations, payment processor fees, and competitive landscape.

typescript
// Explicit multi-currency pricing setup
interface ProductPricing {
  productId: string;
  currency: string;
  amount: number; // in smallest unit (cents for USD, yen for JPY)
  effectiveFrom: Date;
}

async function createProductWithPricing(
  productName: string,
  pricingByRegion: Record<string, number>
) {
  // Create product in Stripe
  const product = await stripe.products.create({
    name: productName,
    type: 'service'
  });
  
  // Create explicit prices for each currency
  for (const [currency, amount] of Object.entries(pricingByRegion)) {
    const price = await stripe.prices.create({
      product: product.id,
      unit_amount: amount,
      currency: currency,
      recurring: { interval: 'month' } // if subscription
    });
    
    // Store in your DB for easy lookup
    await db.insert('product_prices', {
      product_id: product.id,
      stripe_price_id: price.id,
      currency,
      amount,
      effective_from: new Date()
    });
  }
  
  return product;
}

// Example: A SaaS plan priced differently across regions
await createProductWithPricing('Pro Plan', {
  'usd': 9900,   // $99/month
  'eur': 8900,   // €89/month (slightly lower, regional pricing)
  'gbp': 7900,   // £79/month
  'jpy': 10900   // ¥10,900/month (no decimal places)
});

Once you have explicit prices in Stripe, always reference them by stripe_price_id, not by doing math at payment time. This ensures consistency across your checkout, invoices, subscriptions, and reporting.

Update prices on a schedule—monthly or quarterly—based on FX movements, cost changes, or strategic decisions. Version your prices so you can track what customers paid and when.

Settlement currency — Decide which currencies you hold

When a customer pays in EUR, does your bank account receive EUR, or does Stripe convert it to USD before you touch it?

This is the settlement currency question, and it matters for cash flow, hedging, and reporting. Stripe's default is to convert everything to your "primary" account currency. But you can override this.

There are three strategies:

  • Single settlement currency (simplest): All payments convert to USD (or your home currency) before deposit. Stripe charges a conversion fee (~1.5%). Pro: one currency to manage, easy accounting. Con: you eat the FX fee and any adverse moves.
  • Multi-currency settlement (best for volume): Keep EUR in EUR, GBP in GBP, etc. You need a Stripe Atlas account or equivalent. Pro: no Stripe conversion fee, you control FX timing. Con: you need to manage multiple bank accounts and currency exposure.
  • Hybrid (common for growth stage): Settle in your home currency and a few high-volume currencies (EUR, GBP). Stripe can be configured to do this natively.
typescript
// Check settlement and conversion details
async function analyzePaymentSettlement(chargeId: string) {
  const charge = await stripe.charges.retrieve(chargeId);
  
  console.log({
    chargeId: charge.id,
    charged_currency: charge.currency,
    charged_amount: charge.amount,
    
    // Balance transaction shows what hit your account
    balance_transaction: charge.balance_transaction
  });
  
  // Retrieve the actual settlement
  const balanceTransaction = await stripe.balanceTransactions.retrieve(
    charge.balance_transaction as string
  );
  
  console.log({
    settlement_currency: balanceTransaction.currency,
    settlement_amount: balanceTransaction.amount, // after Stripe fees and FX
    conversion_fee: balanceTransaction.fee,
    exchange_rate: balanceTransaction.exchange_rate || 1.0
  });
}

// Multi-currency account example
async function setupMultiCurrencySettlement() {
  // This requires Stripe API configuration on your account
  // Usually done via dashboard, but shown here for clarity
  
  const settlementCurrencies = ['usd', 'eur', 'gbp'];
  
  // In practice, configure this in Stripe Dashboard:
  // Settings → Connect → Payment settings → Default Currency
  // Or per-country via Stripe.com/gb for GBP settlements, etc.
  
  // Then route customers by currency:
  function getStripeAccountByCurrency(currency: string): string {
    const accountMap: Record<string, string> = {
      'eur': 'acct_eur_12345',
      'gbp': 'acct_gbp_67890',
      'usd': 'acct_usd_default'
    };
    return accountMap[currency] || accountMap['usd'];
  }
}

For most teams starting out, single-currency settlement to USD is fine. As you scale and transaction volume in specific currencies grows, revisit this. The FX fee alone can justify multi-currency settlement when you're doing 7-figure turnover in EUR.

Rounding rules — Respect zero-decimal currencies

Stripe uses the smallest unit for each currency: cents for USD and EUR, but yen for JPY (no decimals). This is a source of silent bugs.

If you naively calculate a price as $19.99 * 1.10 (tax), you get $21.989. Cents don't exist. You must round.

The rounding direction matters. Should $21.989 become $21.98 (down) or $21.99 (up)? Most payment processors round half-up for customer-facing prices, but some use banker's rounding. Check your requirements and be consistent.

typescript
// Currency-aware rounding
function getDecimalPlaces(currency: string): number {
  // Zero-decimal currencies: JPY, KRW, etc.
  const zeroDecimal = ['jpy', 'krw', 'vnd', 'clp', 'pyg'];
  return zeroDecimal.includes(currency.toLowerCase()) ? 0 : 2;
}

function roundPrice(amount: number, currency: string): number {
  const decimals = getDecimalPlaces(currency);
  const multiplier = Math.pow(10, decimals);
  
  // Round half-up
  return Math.round(amount * multiplier) / multiplier;
}

// Calculate tax-inclusive price
async function calculateTotal(
  subtotal: number,
  taxRate: number,
  currency: string
) {
  const taxAmount = subtotal * taxRate;
  const total = roundPrice(subtotal + taxAmount, currency);
  
  return {
    subtotal,
    tax: roundPrice(taxAmount, currency),
    total,
    currency
  };
}

// Example
const cartTotal = calculateTotal(100.00, 0.10, 'usd');  // $100 + $10 tax = $110
const jpy = calculateTotal(10000, 0.10, 'jpy');         // ¥10,000 + ¥1,000 = ¥11,000 (no decimals)

console.log(cartTotal); // { subtotal: 100, tax: 10, total: 110, currency: 'usd' }
console.log(jpy);       // { subtotal: 10000, tax: 1000, total: 11000, currency: 'jpy' }

When displaying prices in UI, always use a number formatting library that respects the currency:

typescript
function formatPrice(amount: number, currency: string): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: currency.toUpperCase()
  }).format(amount / 100); // Stripe stores in smallest units
}

Common mistake: storing prices as floats in your database. Use integers (the smallest unit) instead. A float 19.99 can become 19.989999999 due to binary representation. Stripe does this correctly—always store and pass integers to Stripe APIs.

Common mistakes

  • Live FX conversion at payment time: Rates change between quote and payment. Lock prices upfront instead.
  • Floating-point arithmetic: Never use floats for money. Store and calculate in the smallest unit (cents, yen) as integers.
  • Ignoring zero-decimal currencies: JPY, KRW, and others have no cents. Stripe expects the full amount as an integer. A JPY price of ¥1,000 is passed as amount: 1000, not amount: 10.
  • No explicit price versioning: If you update a price for a new month, old subscriptions should keep the old rate. Use Stripe's price versioning and billing cycles to enforce this.
  • Forgetting to test edge cases: Test with JPY, with rounding (like tax), and with very small amounts. Edge cases expose gaps fast.
  • Not syncing pricing across systems: If you have pricing in your DB, in Stripe, and in a marketing site, they can drift. Version and sync deliberately.

Key takeaways

  • Detect customer location and render prices in their local currency to improve conversion rates.
  • Create explicit Stripe Prices per currency—don't calculate FX on the fly. This avoids margin bleed and customer confusion.
  • Choose a settlement strategy: single-currency (simple, Stripe takes a fee) or multi-currency (complex, but you control FX timing and save fees at scale).
  • Always round prices correctly. Use the correct decimal places for each currency (0 for JPY, 2 for USD/EUR). Store amounts as integers in your database, never floats.
  • Version your prices and sync them across Stripe, your database, and public-facing pages to maintain consistency.
  • Test with real-world scenarios: subscriptions across currencies, tax calculations, and edge cases like rounding and zero-decimal currencies.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles