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

Handling currencies and FX: patterns, pitfalls and fixes

Money across borders.

Expona Admin8 min read

International payment flows expose a deceptively complex surface: multiply your transaction volume across borders and suddenly currency decisions become architectural. We've watched teams ship currency logic hastily, then spend months untangling rounding errors, FX loss attribution, and reporting chaos. Handling currencies and FX: patterns, pitfalls and fixes is the discipline that separates a working payment system from one that scales reliably across markets.

Charge in the customer's currency

The user-facing experience demands simplicity: a customer in Tokyo should see a JPY amount, not a USD equivalent. Charging in the customer's currency reduces friction, improves conversion rates, and feels natural. The architectural challenge is that your backend likely normalizes everything to a base currency internally.

Start by establishing the customer's currency at signup or order creation. Store it explicitly on the order record—never derive it from geolocation alone, since VPNs and travel lie. When you hit Stripe's API, pass the currency parameter and the amount in the smallest unit of that currency (cents for USD, no decimals for JPY).

typescript
interface Order {
  id: string;
  customerId: string;
  amount: number; // in smallest unit (e.g., 1000 = $10.00 or ¥1000)
  currency: string; // 'USD', 'JPY', 'EUR'
  chargedAt: Date;
}

async function chargeCustomer(order: Order) {
  const charge = await stripe.charges.create({
    amount: order.amount,
    currency: order.currency,
    customer: order.customerId,
  });
  
  return charge;
}

The risk here is mixing currencies in one operation. If you're processing a multi-currency cart, split it into separate payment intents per currency, or convert everything upfront and store that conversion rate on the order. We've seen teams apply FX rates asynchronously, only to discover the customer was charged at one rate while the order recorded another.

Hold balances in multiple currencies

As you grow internationally, you'll often hold customer balances—escrow, prepaid accounts, rewards. A single-currency balance model forces continuous conversion and FX loss. Multi-currency balances let you match inflows to outflows naturally and reduce hedging costs.

The database schema becomes crucial. Instead of a single balance column, use a document or separate ledger entries keyed by currency.

typescript
interface CustomerBalance {
  customerId: string;
  balances: Map<string, number>; // { 'USD': 5000, 'EUR': 2300, 'JPY': 100000 }
  updatedAt: Date;
}

async function creditCustomerBalance(
  customerId: string,
  amount: number,
  currency: string
) {
  const customer = await db.customer.findById(customerId);
  const currentBalance = customer.balances[currency] || 0;
  customer.balances[currency] = currentBalance + amount;
  await customer.save();
  
  // Log the ledger entry for audit
  await ledger.create({
    customerId,
    type: 'credit',
    amount,
    currency,
    timestamp: new Date(),
  });
}

This approach clarifies accounting: a customer with EUR balance pays out in EUR without forced conversion. When they do need to convert—say, from EUR to USD—that's an explicit, logged transaction with a known rate and fee.

Watch for the "phantom balance" trap: if you're tracking balances in your database and also in Stripe Connect (via connected accounts), reconcile them daily. A race condition during refund can cause the two systems to diverge.

Account for conversion fees

Every currency conversion incurs a cost. Stripe's interchange rates, your processor's markup, and bank fees compound. The decision of who bears that cost—you or the customer—must be intentional and consistent.

If the customer initiates a cross-currency transaction (e.g., EUR balance to USD payout), you might pass the fee through. If you're converting on your own ledger to settle operations, that's a cost center. Either way, log the fee separately so you can analyze FX impact over time.

typescript
interface FXConversion {
  id: string;
  fromCurrency: string;
  toCurrency: string;
  fromAmount: number;
  toAmount: number;
  rate: number; // e.g., 1.085 for EUR to USD
  feeAmount: number; // in toCurrency
  feePercent: number; // e.g., 0.015 for 1.5%
  conversionProvider: 'stripe' | 'wise' | 'xe'; // which service provided the rate
  recordedAt: Date;
}

async function convertAndDebit(
  customerId: string,
  fromAmount: number,
  fromCurrency: string,
  toCurrency: string
) {
  const rate = await getRateFromStripe(fromCurrency, toCurrency);
  const feePercent = 0.015; // your markup
  
  const toAmount = Math.floor(fromAmount * rate);
  const feeAmount = Math.floor(toAmount * feePercent);
  const netToAmount = toAmount - feeAmount;
  
  // Record the conversion for audit and analytics
  await fxConversion.create({
    fromCurrency,
    toCurrency,
    fromAmount,
    toAmount,
    rate,
    feeAmount,
    feePercent,
    conversionProvider: 'stripe',
    recordedAt: new Date(),
  });
  
  // Debit from source, credit to destination
  await debitBalance(customerId, fromAmount, fromCurrency);
  await creditBalance(customerId, netToAmount, toCurrency);
}

The hidden pitfall: not accounting for Stripe's own fees. When Stripe converts, they apply a markup on top of the interbank rate. Call Stripe's API or check your settlement details to understand the exact rate they used, then model your own margin on top. Skipping this step means your margin assumptions are wrong.

Report in one base currency

Internally and for stakeholders, you need a single lens. Revenue reports, profit dashboards, and tax filings all demand one currency. Choose a base currency (usually USD or your operating currency) and convert everything at report-time using historical rates.

Do not convert transactions at the time of charging and lock in that rate. Do convert at report-time using published rates (e.g., ECB daily rates, your bank's rate for that day). This decouples operational FX decisions from reporting and makes it easier to restate reports if you ever change methodologies.

typescript
interface DailyFXRate {
  date: string; // 'YYYY-MM-DD'
  fromCurrency: string;
  toCurrency: string;
  rate: number; // e.g., 0.92157 for EUR to USD on 2024-01-15
}

async function fetchAndStoreECBRates(date: string) {
  // Call ECB API or your provider
  const rates = await ecbClient.getRates(date);
  
  for (const [currency, rate] of Object.entries(rates)) {
    if (currency === 'USD') continue; // base currency
    
    await db.dailyFXRate.create({
      date,
      fromCurrency: currency,
      toCurrency: 'USD',
      rate,
    });
  }
}

async function getRevenueInBaseCurrency(
  startDate: Date,
  endDate: Date
): Promise<number> {
  const transactions = await db.transaction.find({
    chargedAt: { $gte: startDate, $lte: endDate },
  });
  
  let totalUSD = 0;
  
  for (const tx of transactions) {
    if (tx.currency === 'USD') {
      totalUSD += tx.amount;
    } else {
      const rate = await db.dailyFXRate.findOne({
        date: formatDate(tx.chargedAt),
        fromCurrency: tx.currency,
      });
      
      if (!rate) {
        throw new Error(`No FX rate found for ${tx.currency} on ${formatDate(tx.chargedAt)}`);
      }
      
      totalUSD += tx.amount * rate.rate;
    }
  }
  
  return totalUSD;
}

Store the rate you use for each report. If a regulator or auditor asks why revenue changed month-to-month, you can point to the FX impact separately from operational changes. This transparency is especially important for SaaS companies with global customers, where large FX swings can obscure real business metrics.

Common mistakes

  • Rounding errors compound. Converting 100 EUR at 1.085 to USD gives 108.5, which doesn't exist. Floor or round consistently, and audit your rounding choice monthly. A 1-cent error per transaction becomes $1,000 loss at scale.
  • Ignoring Stripe's fee structure. Stripe's cross-border rates include a markup. If you don't account for it, your margin assumptions are baked into a black box. Request itemized settlement reports to see exactly what Stripe charged.
  • Converting at the wrong time. If you convert a balance to a report currency and then refund, did you refund in the original currency or the converted amount? Be explicit. Refund in the original currency, always.
  • Mixing operational and financial FX. Don't use the rate you charged the customer for internal balance accounting. Use published rates for internal accounting, and track FX gain/loss as a separate line item.
  • Forgetting about zero-decimal currencies. JPY, KWD, and others don't have cents. Your smallest unit is 1 JPY, not 0.01 JPY. Stripe handles this, but your code must respect it. Test with JPY early.
  • No audit trail. Six months later, you won't remember why a customer's balance is off by 50 cents. Log every FX operation—rate used, fee applied, timestamp, who triggered it—so you can reconstruct the history.

Key takeaways

  • Always charge in the customer's currency to reduce friction and improve UX; store the currency explicitly on every order or charge.
  • Use multi-currency balance models to avoid forced conversions and to match currency inflows with outflows naturally.
  • Account for conversion fees separately and consistently, whether you're converting via Stripe, Wise, or an internal ledger; document the FX provider and markup.
  • Report revenue in one base currency using historical published rates, not the rates you used for charging; decouple operational FX from reporting FX.
  • Log every currency conversion, rate, and fee for audit and compliance; rounding errors and FX loss attribution become impossible to debug without a clear ledger.
  • Test with zero-decimal currencies (JPY, KWD) and multi-currency refunds early; they expose assumptions that single-currency teams often miss.
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