Munchable is priced at £10 a month. Open munchable.app/#pricing from a German IP and it says €12.99, from an American one $14.99, from a Japanese one ¥2,300. Twenty-four currencies, 58 countries mapped into them, everything else falling back to pounds.
None of those figures is a conversion performed at page load. Each one is a fixed price that was set once, from a rate on a date, and then written into a table:
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 },
// ...21 more
} as const satisfies Record<string, CurrencyDefinition>;
with this above the rate field:
Hardcoded on purpose: the rate is how the price was chosen, not something the price follows. A live feed would make the price move with the market, which is exactly what a fixed price per currency exists to stop, and nothing customer-facing quotes it.
That package has 25 tests. Most of them are the arithmetic you would expect, and I will come back to those. The three I want to talk about first do not test arithmetic at all. They test the English next to the number.
The test that reads the copy
const CONVERSION_WORDS =
/convert|approx|estimat|≈|exchange|\brate\b|sterling|pounds?\b|GBP|charged in/i;
test('no price copy talks about conversion', () => {
assert.ok(!CONVERSION_WORDS.test(VAT_NOTICE), VAT_NOTICE);
for (const currency of DISPLAY_CURRENCIES) {
const notice = priceChangedNotice(PREMIUM_PRICE.unitAmount, PREMIUM_PRICE.interval, currency);
assert.ok(!CONVERSION_WORDS.test(notice), notice);
}
});
Every user-visible string the pricing package produces is run through that regex, in all 24 currencies, and the test fails if any of them matches. The banned list includes "approximately", "exchange", the bare word "rate", "sterling", "pounds" and "GBP". Our own base currency is a forbidden word in our own price copy.
This looks like style policing. It is not. It is the one test that keeps a factual claim true.
If the euro price were a conversion, then "€12.99" would be an approximation of a pound amount, and the honest way to write it would be "about €12.99" with a rate and a date somewhere nearby. Because it is a fixed euro price, those words would be false: there is nothing being approximated, no rate being applied at checkout, and no pound figure in the transaction at all. A copywriter reaching for "approx." to hedge, or a developer adding "converted from £10" to be transparent, would each be describing a system we deliberately do not have.
So the regex is the boundary between two designs. While it passes, the fixed-price design is intact. The day someone wants to go back to converting at checkout, this test fails, and failing is the correct behaviour, because the sentences would have to change too.
There is a sibling test for the VAT line, which is the one sentence printed under every price:
VAT is included where it applies, so the price you see is the price you pay.
assert.ok(!/added|on top|excl|approximate/i.test(VAT_NOTICE), VAT_NOTICE);
And a third, which asserts that no em dash reaches a price string, because the house style bans them and a price is the one piece of copy that gets pasted into other people's pages.
The test that names 27 countries by hand
The one I did not expect to need is this one:
test('every EU-27 country is priced in its own currency', () => {
const EU_27: Record<string, DisplayCurrency> = {
AT: 'eur', BE: 'eur', BG: 'eur', CY: 'eur', DE: 'eur', EE: 'eur', ES: 'eur',
FI: 'eur', FR: 'eur', GR: 'eur', HR: 'eur', IE: 'eur', IT: 'eur', LT: 'eur',
LU: 'eur', LV: 'eur', MT: 'eur', NL: 'eur', PT: 'eur', SI: 'eur', SK: 'eur',
CZ: 'czk', DK: 'dkk', HU: 'huf', PL: 'pln', RO: 'ron', SE: 'sek',
};
assert.equal(Object.keys(EU_27).length, 27);
assert.equal(Object.values(EU_27).filter((c) => c === 'eur').length, 21);
for (const [country, currency] of Object.entries(EU_27)) {
assert.equal(currencyForCountry(country), currency, country);
}
});
Twenty-seven ISO codes typed out, with two assertions whose only job is to catch a typo in the literal itself: there must be 27 keys, and 21 of them must be the euro.
The reason this test exists is a Stripe behaviour rather than a bug of ours. Where Stripe acts as merchant of record in the EU, Adaptive Pricing is always on, and it re-prices a Checkout Session into the buyer's local currency whenever the session currency differs from theirs. Put a Bulgarian customer in a session priced in pounds and Stripe will helpfully convert it, at its own rate, on its own page. Which means the page says one number and Stripe says another, and the difference is not something we control or can even predict.
The fix is not code. The fix is that no EU country may be left off the map. Hence a test that enumerates all 27 rather than asserting a property, because the property I actually need is membership of a list maintained in Brussels, and the only honest way to hold that in a test suite is to write it down and let it break when it changes. The comment next to it carries the two dates that have moved it recently: Croatia joined the euro in 2023, Bulgaria on 1 January 2026.
The arithmetic, briefly
The other 22 tests are the boring ones, and they are boring in a useful shape: two bounds rather than one. The displayed figure may never fall below the buffered conversion, so nobody reads a price and is then asked for more. And it may never exceed it by more than one retail step, so a scale error cannot hide inside a round-up. One euro is that step for a decimal currency; one hundred yen is that step for one written without them.
Then there is the units problem, which is where real money gets lost. Stripe takes most currencies in hundredths, yen in whole units, and forint and krónur in hundredths that must divide by 100. Getting that last case wrong charges a hundred times the price and raises no error at all. There is a test asserting the two Stripe lists are disjoint, because the branch reading them would otherwise be a coin toss, and one that reads every amount back the way Stripe reads it and insists it is worth between £10 and £12.
Twenty-five tests, 158 milliseconds, no build step, and no network. The package exists so that the landing page, the post-signup chooser, the rewards maths and the mobile paywall can all print a price without pulling in the Stripe SDK.
The thing I would tell myself a year ago
I used to think copy tests were a smell: if a string matters, put it in a constant and let a human own it. What changed my mind is that some sentences are not decoration on a behaviour, they are an assertion about it. "VAT is included where it applies, so the price you see is the price you pay" is a promise with a testable implementation behind it, and the test that it never says "on top" is the cheapest possible proof the implementation and the promise have not drifted.
The price and the sentence next to it are one artefact. Go and read it in your own currency, then read the terms where the same promise is written the long way. If you find a page of ours that hedges with "approximately", one of these tests was deleted.
Top comments (0)