Founder & Lead Engineer, RAITHub
RAITHub ships and tests production software. See QA as a Service or talk to us.
Add multi-currency to a SaaS with four rules: store every amount as an integer in its smallest unit (cents, pence, yen), never a float; store the ISO 4217 currency code in the same row; use the correct number of decimal places per currency, because not every currency has two; and whenever you convert, record the rate and the moment you used. Break any one and the numbers stop reconciling.
This is engineering guidance for SaaS on PostgreSQL. If you would rather have it built and tested for you, see how RAITHub would build this below.
Why can't you store money as a decimal or a float?
Because a floating-point number cannot represent most decimal fractions exactly, so sums drift by a cent and totals fail to reconcile. The standard fix is to store money as an integer count of the currency's smallest unit: 10 US dollars is 1000 cents. Stripe follows this: its currencies guide states that amounts are in the currency's smallest unit, so a charge of 10 USD is sent as 1000.
| Choice | Example of 10.00 | Risk | Verdict |
|---|---|---|---|
| Floating point | 10.0 (approx) | Rounding drift; totals do not reconcile | Never for money |
| Decimal / numeric type | 10.00 | Safe, but easy to mix scales across currencies | Workable with discipline |
| Integer minor units + currency code | 1000, 'USD' | Must track decimals per currency | Recommended |
How many decimal places does each currency have?
Not always two. ISO 4217 assigns a minor-unit exponent per currency: the Japanese yen has none (the minor unit is the whole yen), most currencies have two, and a few, such as the Kuwaiti dinar, have three. So "move the decimal two places" is wrong for yen and for dinars. Keep a small table of exponents and format and parse with the one for that currency.
// Minor-unit exponent per ISO 4217 currency. 0 = no decimals (JPY), 3 = three (KWD).
const EXPONENT: Record<string, number> = {
USD: 2, EUR: 2, GBP: 2, BDT: 2, AED: 2, JPY: 0, KWD: 3,
}
// 'USD' + 1000 -> "10.00"; 'JPY' + 1000 -> "1000"; 'KWD' + 1000 -> "1.000"
export function format(amountMinor: number, currency: string, locale = 'en'): string {
const exp = EXPONENT[currency] ?? 2
return new Intl.NumberFormat(locale, {
style: 'currency', currency,
minimumFractionDigits: exp, maximumFractionDigits: exp,
}).format(amountMinor / 10 ** exp)
}
// Parse a user-entered "10.00" into integer minor units, rounding once, at the edge.
export function toMinor(input: string, currency: string): number {
const exp = EXPONENT[currency] ?? 2
return Math.round(Number(input) * 10 ** exp)
}
Intl.NumberFormat knows each currency's symbol, decimals and placement, so you format for display without hard-coding "$" or two decimals. Round exactly once, when a human amount enters the system; after that, every amount is already an integer and arithmetic is exact.
How should money be stored in the database?
The amount and its currency live together in the same row. An amount with no currency beside it is meaningless, and a column that assumes one currency will be wrong the day you add a second.
CREATE TABLE invoice_lines (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id),
invoice_id uuid NOT NULL REFERENCES invoices(id),
description text NOT NULL,
amount_minor bigint NOT NULL, -- integer, in the currency's smallest unit
currency char(3) NOT NULL, -- ISO 4217: 'USD', 'JPY', 'KWD'
CHECK (amount_minor >= 0)
);
-- An invoice is in exactly one currency; lines cannot mix currencies within it.
ALTER TABLE invoice_lines
ADD CONSTRAINT one_currency_per_invoice
FOREIGN KEY (invoice_id, currency) REFERENCES invoices (id, currency);
Do not sum amounts in different currencies as if they were one number: 100 JPY and 100 USD are not 200 of anything. Totals are per currency, and a report that spans currencies either shows each separately or converts them explicitly, with a rate.
When you convert currencies, what do you have to record?
Record the source amount and currency, the target amount and currency, the exact rate, its source, and the timestamp. An exchange rate is only true at a moment, so a conversion with no recorded rate cannot be audited or reproduced. Pick which amount is authoritative: usually you charge in the customer's currency and keep that as the real figure, with any reporting conversion marked as derived.
CREATE TABLE fx_conversions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
from_minor bigint NOT NULL,
from_currency char(3) NOT NULL,
to_minor bigint NOT NULL,
to_currency char(3) NOT NULL,
rate numeric(18,8) NOT NULL, -- units of target per unit of source
rate_source text NOT NULL, -- which provider, which feed
converted_at timestamptz NOT NULL DEFAULT now()
);
Decide display currency, billing currency and settlement currency separately. A customer may see prices in euros, be billed in euros, while your payment provider settles to you in dollars. Each is a real currency amount; do not collapse them into one column. Billing itself, and how plans and prices sit above this, is in the SaaS billing models guide, and the numbers reach customers through the PDF invoice generator.
Do-it-yourself estimate: 1–2 weeks to move an existing single-currency product to integer minor units with per-currency decimals and conversion records, and more if money is already stored as floats and must be migrated carefully. The main risk is rounding more than once, which quietly loses or invents fractions of a cent.
Buy, build or hire?
| Option | Examples | Choose this when | Watch out for |
|---|---|---|---|
| Let the payment provider handle it | A provider that presents and settles multiple currencies | You only charge; you do not hold your own ledger | You still store amounts and currencies correctly on your side |
| A money library in your app | An integer-minor-unit money library for your language | You want safe arithmetic and formatting without writing it | Libraries differ on rounding; test the edges yourself |
| Custom build on minor units | The design in this guide | You run your own invoices, ledger or reporting | You own the exponent table, conversions and per-currency totals |
| Hire a team to build it | RAITHub or another studio | Money must reconcile and an auditor may check it | Get the rounding and reconciliation tests in the handover |
How do you test multi-currency money?
- Zero- and three-decimal currencies: format and parse JPY (0) and KWD (3), not just two-decimal currencies.
- Round once: assert a human amount is rounded only on entry, and integer arithmetic after that is exact.
- No cross-currency sums: assert that summing mixed currencies raises an error or groups by currency.
- Conversion record: assert every conversion stores the rate, source and timestamp, and that the result is reproducible.
- Reconciliation: add many line amounts and assert the invoice total matches to the minor unit.
How RAITHub would build this
- Money model: integer minor units with the ISO 4217 code beside every amount, and a per-currency exponent table driving formatting and parsing.
- Conversions: recorded rates with source and timestamp, and a clear split of display, billing and settlement currencies.
- Constraints: database checks that keep an invoice in one currency and block cross-currency sums.
- Tests: rounding, zero- and three-decimal, and reconciliation tests in CI.
Timeline: multi-currency inside a new SaaS build fits the 4–6 week fixed scope; retrofitting an existing product is a bounded piece in the 6–12 week backend range. You receive: money-correctness tests in CI, handover docs and runbooks, and full IP under NDA.
Next step: a free 15-minute technical audit, then a written fixed quote. See the SaaS development service, read how the dashboard sums these amounts in the SaaS dashboard guide, and book the audit.
Frequently asked questions
How should I store money in a multi-currency SaaS?
As an integer count of the currency's smallest unit, with the ISO 4217 currency code in the same row. Ten dollars is 1000 cents, 'USD'. Floats drift and totals stop reconciling, and an amount without its currency is meaningless.
Do all currencies have two decimal places?
No. ISO 4217 sets a minor-unit exponent per currency: the Japanese yen has zero, most have two, and a few like the Kuwaiti dinar have three. Keep an exponent table and format and parse using the right one for each currency.
Can I add amounts in different currencies together?
No. 100 JPY and 100 USD are not 200 of anything. Totals are per currency. A report that spans currencies shows each separately or converts them explicitly with a recorded rate.
What must I record when I convert currencies?
The source and target amounts and currencies, the exact rate, the rate's source, and the timestamp. A rate is only true at a moment, so an unrecorded conversion cannot be audited or reproduced.
Should the customer be billed in their currency or mine?
Treat display, billing and settlement currencies as separate decisions. A customer may see and be billed in euros while your provider settles to you in dollars. Store each as a real amount rather than collapsing them into one field.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.