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
}

๐Ÿ’ก 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>
  );
}

Cost and Timeline Estimates

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

See Also


Working With Viprasol

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.

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 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

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.