Notifio charges £20, once. Every Checkout Session is created in GBP pence and nothing about that varies by country. But a visitor in Amsterdam reading "£20" has to convert it in their head before they can decide whether it is cheap, and that is the exact moment you do not want to give somebody arithmetic homework. It matters more than usual here because the comparison pages quote competitors priced in euros, €29 and €29.95 a month, so the reader is already holding one currency when they meet ours.
So prices are displayed in the visitor's currency and charged in GBP. That sentence hides a decision: the displayed number will not be exactly the charged number, so which direction should it be wrong in?
Up. Always up.
Somebody performs the conversion, and whoever it is takes a cut. If Stripe quotes a local figure through Adaptive Pricing, that figure includes its spread; if it does not, the customer's own card issuer converts and takes 2 to 4%. Either way, a straight mid-market conversion under-quotes what the customer actually pays.
That is the same surprise we were trying to remove, just smaller and later, which is worse. So the module has one invariant and everything else serves it:
The number we display is never lower than the amount the customer will be asked for.
Two mechanisms enforce it. A flat buffer:
/** * 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; And then a round up, which is where it gets interesting.
Rounding up to something that looks like a price
£20 at a mid-market rate of 1.1696 euros to the pound is €23.39. With the buffer it is €24.3277. Displaying €24.33 would be honest and would look like a bug, because nothing is priced at €24.33.
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; } Shift down by the fractional part you want to land on, ceil to an integer, shift back up. €24.3277 becomes €24.99. The useful property is that it only ever moves the number up, so it cannot break the invariant it sits inside.
The live pricing page is showing me €24.99 right now with the footnote "Approximate EUR (£1 ≈ €1.22, incl. card fees, rounded up). Charged in GBP", and £1 ≈ €1.22 is 1.1696 × 1.04 rounded for display. The arithmetic reconciles, which is the point of printing it.
The three currencies with no .99
digits is a parameter rather than a constant because 2 is not the answer everywhere:
export function minorUnitDigits(currency: DisplayCurrency): number { const { code, locale } = CURRENCIES[currency]; return ( new Intl.NumberFormat(locale, { style: "currency", currency: code }) .resolvedOptions().maximumFractionDigits ?? 2 ); } Three currencies in our table are written without decimals in their own locales: Icelandic króna, Hungarian forint and Japanese yen. Hardcoding two decimals would quote ¥2,999 as "¥29.99", wrong by two orders of magnitude, and silently so, because both strings look like prices.
Reading it from Intl instead of keeping a list of zero-decimal currencies is the part I would defend hardest. A hardcoded list is duplicated knowledge: correct the day you write it, stale the day somebody adds a currency and does not know the list exists. resolvedOptions() already knows. You can check it in any Node REPL:
> new Intl.NumberFormat("ja-JP", { style: "currency", currency: "JPY" }).resolvedOptions().maximumFractionDigits 0 > new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR" }).resolvedOptions().maximumFractionDigits 2 For a zero-decimal currency the retail step collapses to nothing, which is why ceilToRetail returns a plain Math.ceil in that branch. The next "retail" yen is simply the next yen.
Running our real table through it, £20 displays as:
| Currency | Mid-market | Buffered | Displayed |
|---|---|---|---|
| GBP | 20.00 | n/a | £20 |
| EUR | 23.39 | 24.3277 | €24.99 |
| USD | 27.12 | 28.2027 | $28.99 |
| JPY | 4317.80 | 4490.5120 | ¥4,491 |
| ISK | 3326.40 | 3459.4560 | 3.460 kr. |
| HUF | 8466.20 | 8804.8480 | 8805 Ft |
The Icelandic row is a free lesson in not formatting currencies by hand. is-IS uses a full stop as the thousands separator, so three thousand four hundred and sixty króna is written 3.460 kr., which an English-reading developer building that string themselves would almost certainly produce as 3,460.
Zero is the one case where rounding up is wrong
if (pence === 0) return 0; Without that line the retail round-up turns a free tier into "€0.99", advertising a price for something that costs nothing. It is the single place where the module's safe direction is the unsafe one.
It is handled once, in the converter, rather than at each call site, for the usual reason: every caller wants the same answer and only one of them would remember to ask.
The guard I could not make fail
The comment on the epsilon says it "stops binary float error from nudging a value that is already exactly on a boundary up by a whole unit". That is a plausible story, and 28.99 - 0.99 is exactly the kind of expression that produces 28.000000000000004 and then ceils to 29, turning $28.99 into $29.99.
Before writing this I wanted an example. So I ran the function with and without the epsilon across every rate in the table, at every price from 1p to £100, in both the two-decimal and zero-decimal branches. Divergences: zero. In this JavaScript engine, 28.99 - 0.99 is exactly 28.
I kept the epsilon. Not because it is doing something, but because of what it costs and what it protects: one subtraction, against a class of error whose symptom is a customer being quoted a whole unit too much and the arithmetic in our own footnote not adding up. A guard that is currently inert on the inputs we have is cheap insurance on the inputs we might add. What the comment should not do is imply it fixed an observed bug, and rewording it to say "guards against" rather than "stops" is the edit this exercise actually produced.
The bit that is not arithmetic
Two more things fall out of the invariant rather than the maths.
Whole amounts drop their decimals, so GBP renders as £20 and not £20.00:
const isWhole = Number.isInteger(major); return new Intl.NumberFormat(locale, { style: "currency", currency: code, minimumFractionDigits: isWhole ? 0 : digits, maximumFractionDigits: isWhole ? 0 : digits, }).format(major); Every list price here is a round number, and .00 on all of them reads as more precision than the number has.
And crawlers are quoted the base currency, not their datacentre's:
if (isBotUserAgent(headerList.get("user-agent"))) { return BASE_CURRENCY; } Request the pricing page with a Googlebot user agent and you get £20; request it from an EUR country as a browser and you get €24.99. Without that branch, a search engine would index dollar prices for a business that charges in pounds, and those prices would disagree with the GBP figures in the page's own FAQ text and structured data.
The rates themselves are hardcoded from a dated ECB reference set rather than fetched. That is a deliberate trade: they feed a label that already says "approximate", and a live rate feed would put a network dependency on the critical path of rendering a price in exchange for precision the label does not claim. Drift gets absorbed by the 4% buffer and the round up, both of which push the displayed figure above what is charged.
Takeaways
- If you display a currency you do not charge in, pick a direction for the error and write the invariant down. Up is the only one that cannot surprise a customer at checkout.
- Get the decimal count from
Intl.NumberFormat().resolvedOptions(). Do not keep your own list of zero-decimal currencies. - Never format a currency by concatenating strings.
is-ISwill catch you. - Guard zero explicitly. Rounding rules that are right for every price are usually wrong for no price.
- Before you explain a defensive line in a blog post, try to make it fire. Finding that you cannot is worth knowing, and worth saying.
The app this is pricing is on the download page, and the Rentbird comparison is where the two currencies actually sit next to each other.