Notifio is a one-time purchase: £20 for a lifetime licence, and £20 again for the optional auto-reply upgrade. The list prices live in one module with no Stripe SDK behind them, as integer pence, and the rule the rest of the pricing code exists to keep is in the first line of its doc comment: the figure on the page is the figure on the card.
If you are in a country with its own price, you see that price and you are charged that price. Nothing is converted at checkout, nothing says "approximately", and nothing recalculates between the page and Stripe. Load the pricing page and the number you see is the number the Checkout Session is created with.
That constraint is only interesting because of where it leads. One function has to produce two things: the string a page renders, and the integer Stripe charges. Those two want different scales, and the currencies where they differ are not the ones you would guess.
24 currencies, 58 countries, fixed rates
export const CURRENCIES = {
gbp: { code: "GBP", locale: "en-GB", rate: 1 },
eur: { code: "EUR", locale: "en-IE", rate: 1.1696 },
chf: { code: "CHF", locale: "de-CH", rate: 1.0981 },
// ... 21 more
} as const satisfies Record<string, CurrencyDefinition>;
Twenty-four entries, and a country table of fifty-eight codes mapping into them. Anywhere unlisted pays in pounds. The rates are hardcoded, and the comment on the field is the argument for that:
/**
* Units per £1.
*
* Fixed on purpose, because these set the charge: a live feed would let the
* price of the same licence change from one page load to the next, and could
* change between the page and Checkout. Drift is absorbed by
* {@link LOCAL_PRICE_MARGIN} and the round-up.
*
* From the ECB euro reference rates of 2026-08-17, crossed through the euro
* (units per euro divided by pounds per euro). Refresh them if one moves more
* than about 3%; changing a rate changes what new customers pay.
*/
rate: number;
A local price is the pence figure times the rate, times a 1.04 margin that covers Stripe's conversion fee on the payout, rounded up to the next retail-looking number. For £20 that gives €24.99, $28.99, 267,99 kr in Sweden and ₺1.350,99 in Turkey.
The round-up is where the decimal question first appears:
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;
}
There is no .99 to round up to in a currency that has no decimal places, so those round up to the next whole unit instead. And digits is not assumed:
/**
* 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. Quoting ¥2,999 as "¥29.99" would be wrong by two orders of
* magnitude, and silently so.
*/
export function minorUnitDigits(currency: DisplayCurrency): number {
const { code, locale } = CURRENCIES[currency];
return (
new Intl.NumberFormat(locale, { style: "currency", currency: code }).resolvedOptions()
.maximumFractionDigits ?? 2
);
}
Three of the twenty-four come back as zero: the Icelandic krona, the Hungarian forint and the yen.
Stripe does not agree
Stripe's unit_amount is in the currency's smallest unit, and Stripe keeps its own list of which currencies it counts in whole units. That list is shorter than Intl's:
/**
* Currencies Stripe counts in whole units. Every other currency in the table is
* counted in hundredths, including the forint and the Icelandic krona, which
* are written without decimals but which Stripe still takes as two-decimal
* amounts (a multiple of 100). The yen is the only one where both agree on none.
*/
const STRIPE_ZERO_DECIMAL: ReadonlySet<DisplayCurrency> = new Set<DisplayCurrency>(["jpy"]);
So the same price has two correct integer representations depending on who is reading it, and the conversion is a rescale between the two scales rather than a recalculation:
export function stripeUnitAmount(pence: number, currency: DisplayCurrency): number {
const scale = Math.pow(10, stripeDigits(currency) - minorUnitDigits(currency));
return Math.round(localPrice(pence, currency) * scale);
}
Printed out for the three that matter, with the other 21 for contrast:
| Currency | Intl digits | Stripe digits | Page shows | unit_amount |
|---|---|---|---|---|
| ISK | 0 | 2 | 3.460 kr. | 346000 |
| HUF | 0 | 2 | 8805 Ft | 880500 |
| JPY | 0 | 0 | ¥4,491 | 4491 |
| EUR | 2 | 2 | €24.99 | 2499 |
| GBP | 2 | 2 | £20 | 2000 |
Get the forint wrong in either direction and you charge a Hungarian customer either 88 Ft or 88,050,000 Ft for a £20 app. There is no plausible middle. This is the kind of bug that cannot be caught by eye in a dashboard, because 880500 looks exactly as arbitrary as 8805 does, so it is pinned by a test instead.
The reason the hardcoded set is a set of one rather than a list of three is worth stating plainly: Intl is telling you how a human writes the number, and Stripe is telling you how its ledger counts it. Those are different questions and they happen to have different answers for two of the twenty-four. Reading one and assuming it answers the other is the whole bug.
The figure in the title tag is not the figure in the body
Here is the consequence I like least, and it is deliberate. The pricing page renders per request so the price is right in the first frame:
/**
* Rendered per request so the price is in the visitor's currency in the first
* frame. It is the price Checkout charges them.
*
* `getDisplayCurrency` reads the visitor's country from the request, which
* forfeits this page's static rendering. That is the intended trade: showing a
* visitor a price other than the one they will pay, or flickering from one to
* the other after hydration, costs more than a per-request render of a page
* whose whole job is to state a price.
*
* The metadata above stays in GBP, as does anything a crawler reads, so search
* results carry the GBP list price rather than a datacentre's currency.
*/
export const dynamic = "force-dynamic";
The metadata above it is a static object, because page metadata cannot be per-visitor without making every social preview and every search snippet depend on which datacentre fetched it. So the title tag says one thing and the body says another. From a machine in the euro area:
$ curl -s https://notifio.app/pricing | grep -o '<title>[^<]*'
<title>Pricing – £20 Lifetime License | Notifio
$ curl -s https://notifio.app/pricing \
| grep -o 'tracking-\[-0.04em\] text-\[#fafafa\]">[^<]*' | head -1
tracking-[-0.04em] text-[#fafafa]">€24.99
Run that from the US and the second line says $28.99. The first line says £20 everywhere, forever, and so does the structured data on every page that carries it:
$ curl -s https://notifio.app/alerts | grep -o '"offers".\{0,90\}'
"offers":{"@type":"Offer","price":"20.00","priceCurrency":"GBP","url":"https://notifio.app/pricing"
Crawlers are explicitly steered to pounds for exactly this reason, so that the price a search engine indexes matches the price the metadata and the structured data state:
/**
* Crawlers get {@link BASE_CURRENCY}: they are geolocated wherever their
* datacentre is, and search results should carry the GBP price that the page
* metadata and structured data state, not a datacentre's currency.
*/
The detection behind that is one regex, which is its own small story: a user agent containing bot, crawler, spider, headless or vercel gets pounds. An empty user agent does not, so a request with no UA at all is treated as a person and geolocated normally.
Net effect: a Dutch reader sees "£20" in their browser tab, "€24.99" in the page, and is charged €24.99. Two of those three agree, and the one that does not is the one nobody reads out loud. I would rather have that than a page that quotes a figure Stripe will not charge.
The desktop app cannot read the request at all
The app itself has the same problem with no headers to read. It is an Electron app on the user's machine; it has no idea what Vercel thinks of their IP. So the server hands it a rendered label plus the currency code, and the app sends the code back when it starts a purchase:
/**
* The upgrade price in the user's currency, as the server will charge it. The
* server decides the currency from the request's country, which the app cannot
* see, so the app shows `label` and sends `currency` back with the checkout
* request; the server refuses to charge a currency other than the one shown.
*/
export interface UpgradePrice {
currency: string;
label: string;
}
That is the same invariant as the web page, enforced across a process boundary: the only currency the server will charge is the one the user was shown. If they disagree, the checkout request fails and the app refreshes the price rather than charging anything.
What I would change
Two things.
The rates carry a date in a comment and nothing ages them. "Refresh them if one moves more than about 3%" is a sentence, not a mechanism, and the one on 2026-08-17 is now over two months old. The honest version is a test that fails when the recorded date is more than a quarter behind the build, because the failure mode is not a wrong page, it is a slowly thinning margin on every non-GBP sale.
The second is the title tag. It is correct for a crawler and misleading for a euro-area human reading their own browser tab, and I do not think those two needs are actually in conflict: the metadata could carry no figure at all. "Pricing" with the price in the body would be true for everybody, and the only thing it would cost is a number in a search snippet that is already wrong for most of the people reading it.
If you want to check any of this, the pricing page states the price you would pay and the download page is what it buys. Whatever currency that page quotes you, it is the figure Stripe charges, which is the only claim in this post that actually matters.
Top comments (1)
the two-correct-integer-representations problem is exactly why 'zero-decimal' should be treated as a per-processor fact, not a currency fact. ISO 4217 says JPY has no minor unit, Stripe agrees on JPY but keeps ISK and HUF at two digits, and the Intl list says all three are zero, so any code that trusts a single authority is wrong somewhere.
the rescale in stripeUnitAmount is the safe shape for that. the version that actually bites is a legacy table that multiplies everything by 100 and someone 'fixes' ISK by dropping the multiplication for one path but not the refunds path. keeping the doc-comment invariant that the page figure equals the card figure is doing more work than it looks like it should.