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.
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}¤cy=${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
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Display-only multi-currency (no charge currency) | 1 dev | 1โ2 days | $300โ600 |
| Full multi-currency with Stripe presentment | 1 dev | 3โ5 days | $800โ1,800 |
| Multi-currency + FX caching + tax | 1โ2 devs | 1โ2 weeks | $2,500โ5,000 |
| Enterprise (VAT/GST by country, tax reporting) | 2 devs | 3โ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
- Stripe Webhook Handling with Idempotency
- SaaS Usage-Based Billing with Stripe Meters
- SaaS Subscription Upgrade and Downgrade
- SaaS Dunning Management
- Stripe Connect Platform Payments
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.
About the Author
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.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
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.