Back to Blog

SaaS Multi-Currency Pricing: FX Rates, Stripe Multi-Currency, and Display vs Charge Currency

Implement multi-currency pricing in your SaaS. Covers storing prices in multiple currencies, Stripe multi-currency charges, live FX rate caching, display vs charge currency separation, and tax-inclusive pricing.

Viprasol Tech Team
March 22, 2027
13 min read

Most SaaS products launch with USD-only pricing and discover the problem when European users start complaining about unexpected currency conversion fees on their credit cards. Multi-currency isn't just about showing prices in local currency — it's about charging in local currency so users pay exactly what they see, with no surprise FX markup from their bank.

This guide covers the complete multi-currency stack: pricing tables, Stripe presentment currencies, FX rate caching, and the critical distinction between display currency and charge currency.

The Core Distinction: Display vs Charge Currency

Display currency: What you show on the pricing page ("€49/mo"). Affects purchase intent.

Charge currency: What Stripe actually charges. Affects whether the user pays FX fees.

If you show €49 but charge USD, Stripe converts to USD at its rate, and the user's bank may add another 1–3% FX fee on top. Users in EUR, GBP, AUD, CAD, and JPY all expect to be charged in their currency.

Stripe supports this natively via presentment currency — you set the charge currency per PaymentIntent or Subscription, and Stripe handles the settlement to your account currency.

Database Schema

-- Supported currencies with configuration
CREATE TABLE currencies (
  code          CHAR(3) PRIMARY KEY,       -- 'USD', 'EUR', 'GBP'
  name          TEXT NOT NULL,
  symbol        TEXT NOT NULL,
  stripe_minimum INTEGER NOT NULL,         -- Stripe minimum charge in subunits
  decimal_places INTEGER NOT NULL DEFAULT 2,
  is_active     BOOLEAN NOT NULL DEFAULT TRUE,
  sort_order    INTEGER NOT NULL DEFAULT 99
);

INSERT INTO currencies VALUES
  ('USD', 'US Dollar',       '$',  50,  2, TRUE,  1),
  ('EUR', 'Euro',            '€',  50,  2, TRUE,  2),
  ('GBP', 'British Pound',  '£',  30,  2, TRUE,  3),
  ('AUD', 'Australian Dollar', 'A$', 50, 2, TRUE, 4),
  ('CAD', 'Canadian Dollar', 'CA$', 50, 2, TRUE, 5),
  ('INR', 'Indian Rupee',   '₹', 5000, 2, TRUE, 6),
  ('JPY', 'Japanese Yen',   '¥',  50,  0, TRUE,  7);  -- JPY has 0 decimal places

-- Prices per plan per currency (explicit, not FX-computed at runtime)
CREATE TABLE plan_prices (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  plan_id       UUID NOT NULL REFERENCES plans(id),
  currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
  amount        INTEGER NOT NULL,          -- In subunits (cents)
  stripe_price_id TEXT,                   -- Stripe Price ID for this currency
  is_active     BOOLEAN NOT NULL DEFAULT TRUE,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),

  UNIQUE(plan_id, currency_code)
);

-- Cached FX rates (refreshed hourly)
CREATE TABLE fx_rates (
  base_currency  CHAR(3) NOT NULL,
  quote_currency CHAR(3) NOT NULL,
  rate           NUMERIC(16,8) NOT NULL,
  fetched_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),

  PRIMARY KEY (base_currency, quote_currency)
);

-- Subscription with currency locked at creation
ALTER TABLE subscriptions ADD COLUMN currency_code CHAR(3) REFERENCES currencies(code);
ALTER TABLE subscriptions ADD COLUMN stripe_currency TEXT;  -- Stripe's lowercase version

-- User currency preference
ALTER TABLE users ADD COLUMN preferred_currency CHAR(3) REFERENCES currencies(code);

🚀 SaaS MVP in 8 Weeks — Seriously

We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment — all handled by one senior team.

  • Week 1–2: Architecture design + wireframes
  • Week 3–6: Core features built + tested
  • Week 7–8: Launch-ready on AWS/Vercel with CI/CD
  • Post-launch: Maintenance plans from month 3

Pricing Configuration

// lib/pricing/currencies.ts

export interface CurrencyConfig {
  code: string;
  name: string;
  symbol: string;
  decimalPlaces: number;
  stripeMinimum: number; // in subunits
}

export const SUPPORTED_CURRENCIES: CurrencyConfig[] = [
  { code: "USD", name: "US Dollar",       symbol: "$",   decimalPlaces: 2, stripeMinimum: 50 },
  { code: "EUR", name: "Euro",            symbol: "€",   decimalPlaces: 2, stripeMinimum: 50 },
  { code: "GBP", name: "British Pound",   symbol: "£",   decimalPlaces: 2, stripeMinimum: 30 },
  { code: "AUD", name: "Australian Dollar", symbol: "A$", decimalPlaces: 2, stripeMinimum: 50 },
  { code: "CAD", name: "Canadian Dollar", symbol: "CA$", decimalPlaces: 2, stripeMinimum: 50 },
  { code: "INR", name: "Indian Rupee",    symbol: "₹",   decimalPlaces: 2, stripeMinimum: 5000 },
  { code: "JPY", name: "Japanese Yen",    symbol: "¥",   decimalPlaces: 0, stripeMinimum: 50 },
];

// Explicit prices per plan — set by the business, not auto-calculated from FX
// This avoids "€49.37" when you should show "€49"
export const PLAN_PRICES: Record<string, Record<string, number>> = {
  starter: {
    USD: 1900,  // $19/mo
    EUR: 1800,  // €18/mo
    GBP: 1600,  // £16/mo
    AUD: 2900,  // A$29/mo
    CAD: 2600,  // CA$26/mo
    INR: 159900, // ₹1,599/mo
    JPY: 2900,  // ¥2,900/mo
  },
  pro: {
    USD: 4900,
    EUR: 4500,
    GBP: 3900,
    AUD: 7500,
    CAD: 6500,
    INR: 399900,
    JPY: 7400,
  },
  enterprise: {
    USD: 14900,
    EUR: 13900,
    GBP: 11900,
    AUD: 22900,
    CAD: 19900,
    INR: 1199900,
    JPY: 22000,
  },
};

export function formatPrice(
  amountSubunits: number,
  currencyCode: string,
  locale = "en-US"
): string {
  const config = SUPPORTED_CURRENCIES.find((c) => c.code === currencyCode);
  if (!config) return String(amountSubunits);

  const amount = amountSubunits / Math.pow(10, config.decimalPlaces);
  return new Intl.NumberFormat(locale, {
    style: "currency",
    currency: currencyCode,
    minimumFractionDigits: config.decimalPlaces === 0 ? 0 : 2,
    maximumFractionDigits: config.decimalPlaces === 0 ? 0 : 2,
  }).format(amount);
}

FX Rate Service

// lib/pricing/fx-rates.ts
import { prisma } from "@/lib/prisma";
import { redis } from "@/lib/redis";

const FX_CACHE_KEY = "fx:rates:usd";
const FX_CACHE_TTL = 3600; // 1 hour
const BASE_CURRENCY = "USD";

interface FxRates {
  base: string;
  rates: Record<string, number>;
  fetchedAt: string;
}

export async function getFxRates(): Promise<FxRates> {
  // Check Redis cache first
  const cached = await redis.get(FX_CACHE_KEY);
  if (cached) {
    return JSON.parse(cached);
  }

  // Fetch from Open Exchange Rates (or Fixer.io, ExchangeRate-API)
  const response = await fetch(
    `https://openexchangerates.org/api/latest.json?app_id=${process.env.OPEN_EXCHANGE_RATES_API_KEY}&base=USD&symbols=EUR,GBP,AUD,CAD,INR,JPY`,
    { next: { revalidate: 3600 } }
  );

  if (!response.ok) {
    // Fall back to DB cached rates
    return getFxRatesFromDb();
  }

  const data = await response.json();
  const rates: FxRates = {
    base: "USD",
    rates: data.rates,
    fetchedAt: new Date().toISOString(),
  };

  // Cache in Redis
  await redis.setex(FX_CACHE_KEY, FX_CACHE_TTL, JSON.stringify(rates));

  // Persist to DB for fallback
  await upsertFxRatesToDb(rates);

  return rates;
}

async function getFxRatesFromDb(): Promise<FxRates> {
  const rows = await prisma.fxRate.findMany({
    where: { baseCurrency: BASE_CURRENCY },
  });

  return {
    base: BASE_CURRENCY,
    rates: Object.fromEntries(rows.map((r) => [r.quoteCurrency, Number(r.rate)])),
    fetchedAt: rows[0]?.fetchedAt?.toISOString() ?? new Date().toISOString(),
  };
}

async function upsertFxRatesToDb(rates: FxRates): Promise<void> {
  const now = new Date();
  await prisma.$transaction(
    Object.entries(rates.rates).map(([quoteCurrency, rate]) =>
      prisma.fxRate.upsert({
        where: {
          baseCurrency_quoteCurrency: {
            baseCurrency: BASE_CURRENCY,
            quoteCurrency,
          },
        },
        create: { baseCurrency: BASE_CURRENCY, quoteCurrency, rate, fetchedAt: now },
        update: { rate, fetchedAt: now },
      })
    )
  );
}

// Convert an amount from one currency to another using cached rates
export async function convert(
  amount: number,
  fromCurrency: string,
  toCurrency: string
): Promise<number> {
  if (fromCurrency === toCurrency) return amount;

  const { rates } = await getFxRates();

  // Convert to USD first (base), then to target
  const toUsd = fromCurrency === "USD" ? amount : amount / rates[fromCurrency];
  const result = toCurrency === "USD" ? toUsd : toUsd * rates[toCurrency];

  return Math.round(result); // Round to subunits
}
SaaS - SaaS Multi-Currency Pricing: FX Rates, Stripe Multi-Currency, and Display vs Charge Currency

💡 The Difference Between a SaaS Demo and a SaaS Business

Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments — with architecture that does not need to be rewritten at 1,000 users.

  • Multi-tenant PostgreSQL with row-level security
  • Stripe subscriptions, usage billing, annual plans
  • SOC2-ready infrastructure from day one
  • We own zero equity — you own everything

Currency Detection

// lib/pricing/detect-currency.ts
import { headers } from "next/headers";
import type { NextRequest } from "next/server";

// Map country codes to preferred currencies
const COUNTRY_CURRENCY_MAP: Record<string, string> = {
  US: "USD", CA: "CAD", AU: "AUD", NZ: "AUD",
  GB: "GBP", IE: "EUR",
  DE: "EUR", FR: "EUR", ES: "EUR", IT: "EUR", NL: "EUR",
  PT: "EUR", AT: "EUR", BE: "EUR", FI: "EUR", GR: "EUR",
  IN: "INR",
  JP: "JPY",
};

const FALLBACK_CURRENCY = "USD";

export async function detectCurrency(
  userPreference?: string | null
): Promise<string> {
  // 1. User's explicit preference wins
  if (userPreference) return userPreference;

  // 2. Detect from Cloudflare / Vercel geo header
  const headersList = await headers();
  const country =
    headersList.get("cf-ipcountry") ??   // Cloudflare
    headersList.get("x-vercel-ip-country") ?? // Vercel
    null;

  if (country && COUNTRY_CURRENCY_MAP[country]) {
    return COUNTRY_CURRENCY_MAP[country];
  }

  return FALLBACK_CURRENCY;
}

// In middleware
export function detectCurrencyFromRequest(req: NextRequest): string {
  const country =
    req.geo?.country ??
    req.headers.get("cf-ipcountry") ??
    null;

  return (country && COUNTRY_CURRENCY_MAP[country]) ?? FALLBACK_CURRENCY;
}

Pricing Page Component

// app/pricing/page.tsx
import { detectCurrency } from "@/lib/pricing/detect-currency";
import { PLAN_PRICES, formatPrice } from "@/lib/pricing/currencies";
import { CurrencySelector } from "@/components/pricing/currency-selector";
import { auth } from "@/auth";

export default async function PricingPage() {
  const session = await auth();
  const currency = await detectCurrency(session?.user?.preferredCurrency);

  const plans = [
    {
      id: "starter",
      name: "Starter",
      description: "For individuals and small teams",
      features: ["5 projects", "Up to 10 members", "Basic integrations", "Email support"],
    },
    {
      id: "pro",
      name: "Pro",
      description: "For growing teams",
      features: ["Unlimited projects", "Up to 50 members", "All integrations", "Priority support"],
      highlighted: true,
    },
    {
      id: "enterprise",
      name: "Enterprise",
      description: "For large organisations",
      features: ["Everything in Pro", "Unlimited members", "SSO", "Dedicated support", "SLA"],
    },
  ];

  return (
    <section className="py-20">
      <div className="max-w-5xl mx-auto px-4">
        <div className="flex items-center justify-between mb-12">
          <h1 className="text-4xl font-bold">Simple, transparent pricing</h1>
          <CurrencySelector currentCurrency={currency} />
        </div>

        <div className="grid md:grid-cols-3 gap-6">
          {plans.map((plan) => {
            const priceSubunits = PLAN_PRICES[plan.id]?.[currency] ?? PLAN_PRICES[plan.id]?.USD;
            const formattedPrice = formatPrice(priceSubunits, currency);

            return (
              <div
                key={plan.id}
                className={`rounded-2xl border p-8 ${
                  plan.highlighted
                    ? "border-blue-500 shadow-lg shadow-blue-100"
                    : "border-gray-200"
                }`}
              >
                <h2 className="text-lg font-semibold">{plan.name}</h2>
                <p className="text-sm text-gray-500 mt-1">{plan.description}</p>

                <div className="mt-6">
                  <span className="text-4xl font-bold">{formattedPrice}</span>
                  <span className="text-gray-500">/month</span>
                </div>

                <ul className="mt-6 space-y-3">
                  {plan.features.map((f) => (
                    <li key={f} className="flex items-center gap-2 text-sm text-gray-700">
                      <span className="text-green-500"></span> {f}
                    </li>
                  ))}
                </ul>

                <a
                  href={`/checkout?plan=${plan.id}&currency=${currency}`}
                  className={`mt-8 block w-full text-center py-3 rounded-lg font-medium transition-colors ${
                    plan.highlighted
                      ? "bg-blue-600 text-white hover:bg-blue-700"
                      : "bg-gray-100 text-gray-900 hover:bg-gray-200"
                  }`}
                >
                  Get started
                </a>

                <p className="mt-3 text-xs text-center text-gray-400">
                  Charged in {currency} · No FX fees
                </p>
              </div>
            );
          })}
        </div>
      </div>
    </section>
  );
}

Stripe Checkout with Presentment Currency

// app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { stripe } from "@/lib/stripe";
import { prisma } from "@/lib/prisma";
import { PLAN_PRICES } from "@/lib/pricing/currencies";

export async function POST(req: NextRequest) {
  const session = await auth();
  if (!session?.user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { plan, currency } = await req.json();
  const upperCurrency = (currency as string).toUpperCase();

  const priceSubunits = PLAN_PRICES[plan]?.[upperCurrency];
  if (!priceSubunits) {
    return NextResponse.json({ error: "Invalid plan or currency" }, { status: 400 });
  }

  // Get or create Stripe Price for this plan + currency combination
  const stripePriceId = await getOrCreateStripePrice(plan, upperCurrency, priceSubunits);

  // Create Stripe Checkout Session
  const checkoutSession = await stripe.checkout.sessions.create({
    customer_email: session.user.email!,
    line_items: [{ price: stripePriceId, quantity: 1 }],
    mode: "subscription",
    currency: upperCurrency.toLowerCase(), // Stripe expects lowercase
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?subscription=success`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
    subscription_data: {
      metadata: {
        userId: session.user.id,
        plan,
        currency: upperCurrency,
      },
    },
    // Tax: use Stripe Tax or pass tax_id_collection
    automatic_tax: { enabled: true },
    allow_promotion_codes: true,
  });

  return NextResponse.json({ url: checkoutSession.url });
}

async function getOrCreateStripePrice(
  plan: string,
  currency: string,
  amountSubunits: number
): Promise<string> {
  // Check if we have this Stripe Price ID already
  const existing = await prisma.planPrice.findUnique({
    where: { planId_currencyCode: { planId: plan, currencyCode: currency } },
  });

  if (existing?.stripePriceId) return existing.stripePriceId;

  // Fetch product (or create if first time for this plan)
  const product = await getOrCreateStripeProduct(plan);

  const price = await stripe.prices.create({
    product: product.id,
    unit_amount: amountSubunits,
    currency: currency.toLowerCase(),
    recurring: { interval: "month" },
    metadata: { plan, currency },
  });

  // Cache the Stripe Price ID
  await prisma.planPrice.upsert({
    where: { planId_currencyCode: { planId: plan, currencyCode: currency } },
    create: {
      planId: plan,
      currencyCode: currency,
      amount: amountSubunits,
      stripePriceId: price.id,
    },
    update: { stripePriceId: price.id },
  });

  return price.id;
}

async function getOrCreateStripeProduct(plan: string) {
  const PRODUCT_IDS: Record<string, string> = {
    starter: process.env.STRIPE_PRODUCT_STARTER!,
    pro: process.env.STRIPE_PRODUCT_PRO!,
    enterprise: process.env.STRIPE_PRODUCT_ENTERPRISE!,
  };

  return { id: PRODUCT_IDS[plan] };
}

Currency Selector Component

// components/pricing/currency-selector.tsx
"use client";

import { useState } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { SUPPORTED_CURRENCIES } from "@/lib/pricing/currencies";

interface CurrencySelectorProps {
  currentCurrency: string;
}

export function CurrencySelector({ currentCurrency }: CurrencySelectorProps) {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  const handleChange = async (currency: string) => {
    // Update user preference via API
    await fetch("/api/user/currency", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ currency }),
    });

    // Refresh the page to pick up new currency
    const params = new URLSearchParams(searchParams.toString());
    params.set("currency", currency);
    router.push(`${pathname}?${params.toString()}`);
    router.refresh();
  };

  const current = SUPPORTED_CURRENCIES.find((c) => c.code === currentCurrency);

  return (
    <div className="flex items-center gap-2">
      <span className="text-sm text-gray-500">Price in:</span>
      <select
        value={currentCurrency}
        onChange={(e) => handleChange(e.target.value)}
        className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
      >
        {SUPPORTED_CURRENCIES.map((c) => (
          <option key={c.code} value={c.code}>
            {c.symbol} {c.code} — {c.name}
          </option>
        ))}
      </select>
    </div>
  );
}

The Numbers

ScopeTeamTimelineCost Range
Display-only multi-currency (no charge currency)1 dev1–2 days$300–600
Full multi-currency with Stripe presentment1 dev3–5 days$800–1,800
Multi-currency + FX caching + tax1–2 devs1–2 weeks$2,500–5,000
Enterprise (VAT/GST by country, tax reporting)2 devs3–5 weeks$8,000–20,000

Key decisions:

  • Set explicit prices per currency (not FX-computed) for clean pricing like €49 not €47.23
  • Lock subscription currency at creation — never change mid-subscription
  • Use Stripe Tax (automatic_tax: enabled) unless you need full custom VAT control
  • Zero-decimal currencies (JPY, KRW) require special handling in subunit math

Related Reading


Our Capabilities

Multi-currency billing touches every layer of your stack — pricing display, Stripe configuration, FX caching, tax handling, and subscription lifecycle management. Our team has built multi-currency billing systems for SaaS products serving customers in Europe, Asia-Pacific, and North America, handling the nuances of VAT, GST, and zero-decimal currencies.

What we deliver:

  • Explicit per-currency pricing tables (no ugly FX-computed prices)
  • Stripe presentment currency setup with Price ID caching
  • FX rate caching with Redis and DB fallback
  • Country → currency detection from Cloudflare/Vercel geo headers
  • Stripe Tax integration for automatic VAT/GST handling

Talk to our team about multi-currency billing →

Or explore our SaaS development services.

SaaSStripeMulti-CurrencyPaymentsTypeScriptPostgreSQLBilling
Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours — fast.

Free consultation • No commitment • Response within 24 hours

Viprasol · AI Agent Systems

Add AI automation to your SaaS product?

Viprasol builds custom AI agent crews that plug into any SaaS workflow — automating repetitive tasks, qualifying leads, and responding across every channel your customers use.