Notifio is a £20 one-time purchase. Not £20 in the UK and something else elsewhere: every Checkout Session is created in GBP pence, the webhook records GBP, the licence is GBP. There is exactly one currency in this business.
There are twenty-four currencies on the website, though, because a visitor in Berlin reading "£20" has to do a conversion in their head before they can decide whether that is cheap, and they have to do it at the precise moment they are deciding whether to buy.
So prices are displayed in an approximate local figure. That introduces a number that is not true, which needs a rule, and the rule is the only interesting thing in the module:
The number we display is never lower than the amount the customer will be asked for.
Everything below is in service of that one sentence.
Why the naive conversion breaks it
Mid-market times amount is the obvious implementation and it under-quotes every single time. Whoever actually performs the conversion takes a cut: Stripe, if Adaptive Pricing quotes a local figure at checkout, or the customer's own card issuer if it does not. That is 2 to 4 percent, and it lands after our number is on screen.
Under-quoting is the exact failure we are trying to fix. A visitor who reads €23.39 and gets charged €24.10 has had the same surprise as the one who had to convert in their head, just smaller and later, which is worse because now it looks like a trick.
/**
* Multiplier covering the 2-4% card/Adaptive-Pricing conversion fee plus a
* little headroom for rate drift. Set at the top of that band so the displayed
* price stays an upper bound rather than a best case.
*/
const CONVERSION_FEE_BUFFER = 1.04;
Top of the band, not the middle. The whole point is that the error has a sign.
The decimals are not two
This is the bug I would have shipped. Converting pence to minor units and assuming two decimal places is wrong for three currencies already in the table:
/**
* How many decimal places this currency is written with, according to Intl.
*
* Read from Intl rather than assumed to be 2, because zero-decimal currencies
* exist in this table: ISK, HUF and JPY are all written without decimals in
* their own locales.
*/
export function minorUnitDigits(currency: DisplayCurrency): number {
const { code, locale } = CURRENCIES[currency];
return (
new Intl.NumberFormat(locale, { style: "currency", currency: code }).resolvedOptions()
.maximumFractionDigits ?? 2
);
}
resolvedOptions() on a currency NumberFormat tells you what the runtime thinks the currency's minor unit is, which means the answer is ICU's rather than mine. Rendering ¥4,491 as "¥44.91" is wrong by two orders of magnitude, and silently so: it looks like a price, it is just the wrong one by a factor of a hundred.
A hardcoded list of zero-decimal currencies would also have worked, right up until the table grew. The ICU data is the thing that stays correct without me.
Rounding up to a price that looks like a price
Having decided the figure must land above the real cost, there is a free choice about where above. It goes to the next retail-looking number:
/**
* Rounds up to the next "retail" price for the currency: the next `.99` where
* the currency has decimals, the next whole unit where it does not.
*
* Always returns a value >= `amount`, which is the property the whole module
* rests on. The epsilon stops binary float error from nudging a value that is
* already exactly on a boundary up by a whole unit.
*/
function ceilToRetail(amount: number, digits: number): number {
const EPSILON = 1e-9;
if (digits === 0) return Math.ceil(amount - EPSILON);
const step = 1 - Math.pow(10, -digits); // 0.99 for 2dp, 0.9 for 1dp
return Math.ceil(amount - step - EPSILON) + step;
}
The trick is to subtract the step, ceil to a whole unit, then add the step back, which lands you on the next x.99 at or above the input. The epsilon matters for inputs that are already exactly on a boundary: Math.ceil(24.99 - 0.99) is 24 in exact arithmetic but the subtraction does not produce exactly 24 in binary floating point, and without the epsilon a price sitting precisely on 24.99 gets pushed to 25.99. One free whole unit of over-quote, for one input in a few hundred, is the kind of bug that gets reported as "your pricing page is weird sometimes".
The full conversion:
export function convertForDisplay(pence: number, currency: DisplayCurrency): number {
if (!Number.isFinite(pence)) {
throw new Error(`convertForDisplay: pence must be finite, got ${pence}`);
}
if (pence === 0) return 0;
if (currency === BASE_CURRENCY) return Math.round(pence);
const digits = minorUnitDigits(currency);
const major = (pence / 100) * CURRENCIES[currency].rate * CONVERSION_FEE_BUFFER;
return Math.round(ceilToRetail(major, digits) * Math.pow(10, digits));
}
GBP short-circuits before any of it. There is nothing approximate about the base currency, so it must not acquire a buffer or a round-up on the way to the screen. £20 has to stay £20.
The zero that would have cost us
if (pence === 0) return 0;
Four tokens, and the comment on them is longer than the rest of the function:
* Zero is passed through untouched in every currency. Without that guard the
* retail round-up turns a free tier into "€0.99", advertising a price for
* something that costs nothing, the one case where rounding up is not the safe
* direction. It is handled here, once, rather than at each call site, because
* every caller wants the same answer and only one of them would remember to ask.
Run ceilToRetail(0, 2) and you get 0.99, because the next retail price at or above zero is 99 cents. Every rule in this module says round up and that one input is where rounding up produces a lie. "Free" and "€0.99" are not approximations of each other.
It would have been easy to put that check at the call site that renders a free row and call it done. The reason it lives in the conversion instead is that there is only one correct answer for zero and every caller wants it, so the function that knows about rounding is the function that should know about the exception to it.
Saying the arithmetic out loud
A converted price carries a footnote, and the footnote has to include the word that makes the sums add up:
export function rateNote(currency: DisplayCurrency): string {
if (!isApproximate(currency)) return "";
return `£1 ≈ ${formatEffectiveRate(currency)}, including card conversion fees. Rounded up.`;
}
The rate quoted is not the mid-market rate. It is the mid-market rate times the buffer, because that is the number that was actually used:
export function effectiveRate(currency: DisplayCurrency): number {
if (!isApproximate(currency)) return 1;
return CURRENCIES[currency].rate * CONVERSION_FEE_BUFFER;
}
You can check this on notifio.app/pricing. From a euro country the licence reads €24.99 with the line "Approximate EUR (£1 ≈ €1.22, incl. card fees, rounded up). Charged in GBP." under it. Multiply: £20 at €1.22 is €24.40, and the page says €24.99. Without the words "rounded up" a customer who checks our maths finds it wrong, and a price whose arithmetic does not reconcile is less trustworthy than one with no working shown at all.
The small print is deliberately one line rather than three:
export function priceFootnote(currency: DisplayCurrency): string {
const vat = "VAT is added at checkout where it applies, based on your country.";
if (!isApproximate(currency)) return vat;
return `Approximate ${CURRENCIES[currency].code} (£1 ≈ ${formatEffectiveRate(currency)}, incl. card fees, rounded up). Charged in GBP. ${vat}`;
}
A stack of grey caveats under a price buries the one fact the customer needs, which is that the figure is approximate and the real total is at checkout. For GBP the conversion sentence is absent entirely, because there is no conversion to disclose and a caveat about an exchange rate that was not applied is just noise.
Formatting drops decimals when there are none to show:
const isWhole = Number.isInteger(major);
return new Intl.NumberFormat(locale, {
style: "currency",
currency: code,
minimumFractionDigits: isWhole ? 0 : digits,
maximumFractionDigits: isWhole ? 0 : digits,
}).format(major);
£20 rather than £20.00. Every list price here is a round number, and ".00" on all of them reads as more precision than the number has.
The rates are hardcoded, on purpose
/**
* Mid-market units per £1.
*
* Hardcoded on purpose: these feed an explicitly approximate label, never a
* charge, so a live rate feed would add a network dependency to price
* rendering in exchange for precision the label does not claim. Drift is
* absorbed by CONVERSION_FEE_BUFFER and the round-up, both of which push the
* displayed figure above what is actually charged.
*
* Sourced from the ECB reference rates on 2026-08-17. Worth refreshing if a
* rate moves more than ~5%; for the major pairs that is a matter of years.
*/
A live FX feed on the pricing page means the pricing page can fail to render because an API is down. For a number the page itself describes as approximate, that is an unreasonable thing to accept. The date is in the comment so the staleness is visible rather than mysterious, and the direction of any drift is covered by the same buffer that covers the card fee.
Adding a currency is two table entries:
export const CURRENCIES = {
gbp: { code: "GBP", locale: "en-GB", rate: 1 },
eur: { code: "EUR", locale: "en-IE", rate: 1.1696 },
jpy: { code: "JPY", locale: "ja-JP", rate: 215.89 },
// ...
} as const satisfies Record<string, CurrencyDefinition>;
export const COUNTRY_CURRENCY: Record<string, DisplayCurrency> = {
NL: "eur",
JP: "jpy",
// ...
};
and the rendered spans, the disclosures and the tests all derive from it. An unlisted country falls back to GBP, which is both the safe default and the exactly-correct one, since GBP is what gets charged. The gap for an unlisted country therefore points in the honest direction: they see the real number and do their own conversion, which is where everyone started.
One detail in the country lookup that is not about money at all:
export function currencyForCountry(country: string | null | undefined): DisplayCurrency {
if (!country) return BASE_CURRENCY;
const key = country.trim().toUpperCase();
// Own-property check, because the input is a header value we do not control
// and `"constructor"` must not resolve to something inherited.
return Object.prototype.hasOwnProperty.call(COUNTRY_CURRENCY, key)
? COUNTRY_CURRENCY[key]
: BASE_CURRENCY;
}
The country comes from a request header. COUNTRY_CURRENCY["CONSTRUCTOR"] is undefined so that particular example is harmless, but a plain-object lookup on attacker-influenced keys is a habit worth not having.
What it costs
Honesty about the downside: we over-quote. £20 at the mid-market euro rate is €23.39. The page says €24.99. A euro customer is told a number about 7 percent above the mid-market conversion, of which 4 points are the fee buffer and the rest is the climb to the next .99.
I think that is the right way round, but it is a real cost and not a free win. The alternative is a figure that is sometimes under the amount charged, and the reason that is worse is asymmetric: being pleasantly surprised at checkout costs us nothing, and being unpleasantly surprised at checkout costs us the sale and some goodwill. When an error cannot be eliminated, the useful question is which direction it should point, and then whether the code actually guarantees it points that way. Here the guarantee is one function, ceilToRetail, and one documented property: it always returns a value greater than or equal to its input.
Related, from the other side of the same money: the figure in the structured data is not localised at all, for reasons I wrote up in The price in our structured data comes from the constant Stripe charges, and the question of who the merchant of record is turns out to be a separate decision again, covered in Merchant of record for 27 countries, and deliberately off for our own.
If you want to see the output: notifio.app/pricing is the page where all of the above applies. The place it deliberately does not apply is notifio.app/compare, where competitors' subscription prices sit next to ours. Those are quoted verbatim as published, with the month they were read and a link to the page they came from, and our own £20 is a literal there too. Converting a rival's published figure and leaving ours in pounds, or converting both and matching neither source, would make a comparison table that nobody can check. On that one page the right number is the one the other company printed.
Top comments (1)
The zero-decimal currency case is where this gets interesting. Say a UK SaaS subscription is £49/month, and a customer in Japan sees a JPY estimate. If the converted amount is ¥9,842.37, rounding it to ¥9,842 can put the displayed price below the eventual card charge once the processor's conversion spread kicks in.
I'd test the invariant against the actual conversion and rounding pipeline, not just
Intl.NumberFormatin isolation. In particular, I'd want boundary tests around values just below and above the next whole yen, plus a check that the formatted estimate can never fall below the amount we're trying to cover. That's whereroundingMode: "ceil"becomes relevant, rather than relying on the default rounding behavior.