Munchable sells one thing: Premium, monthly. The price appears in four places, and until recently three of them were in the website and one of them was in the phone app, which is exactly the shape of problem that produces two different numbers in front of the same customer.
The price is live on munchable.app, and the app's paywall quotes the same figure. The fix was boring in the good way: the price list is now a workspace package that both apps import. What was interesting were the two questions that came out of the move.
Why it had to be a package, not a module
The price used to live in apps/web/lib/stripe.ts, next to the Stripe client. That is a reasonable place for it, until you notice who wants to read it:
- the pricing table on the landing page
- the post-signup plan chooser
- the rewards maths, which converts contribution credit into money off
- structured data for search engines
- the app's paywall
- and, yes, the checkout route that actually charges it
Exactly one of those needs the Stripe SDK. The other five only want to know what the number is. When the number lives in the same module as the SDK, every one of them drags a payments client into its module graph, and the phone app cannot import it at all.
So the price list became @munchable/pricing, with one property I would recommend for this kind of package: it has no runtime dependencies at all.
{
"name": "@munchable/pricing",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": { ".": "./src/index.ts" },
"sideEffects": false,
"scripts": { "test": "node --test 'src/**/*.test.ts'" }
}
No build step, no dist, no bundler. It points at TypeScript source, the Next.js app lists it in transpilePackages, Metro resolves it through the workspace link, and its own test suite is node --test over the source files. Everything it needs is in the language and in Intl.
The old module did not disappear either. apps/web/lib/stripe.ts re-exports PREMIUM_PRICE, so nothing had to be rewritten to move it:
/** The list price lives in the @munchable/pricing package so display-only
* callers do not pull the Stripe SDK into their module graph. */
export { PREMIUM_PRICE } from '@munchable/pricing';
The package holds the price in GBP pence as an integer, and every other currency is derived from it. There is no Stripe Price object and no environment variable. Checkout builds an inline line item from the same constant the landing page prints, which means a price change is a one-line diff rather than a dashboard visit, a deploy, and a mismatch in between.
Question one: how does each app know where you are?
Prices are fixed per currency rather than converted at checkout, so both surfaces have to answer "which currency is this person reading in". They answer it with completely different mechanisms, and that was a deliberate decision rather than an inconsistency.
The website reads the country from the CDN's geo header, because a server render already has the request in its hands.
The app does not. When the paywall opens there is no request to read, and asking our own API for a country would put a round trip, a spinner and a failure mode in front of a number we already know well enough. So the app reads the device region out of the locale:
Website: request -> geo header -> country -> currency
App: device locale -> region subtag -> country -> currency
The two disagree for somebody on holiday, and the device wins. A British phone in Spain belongs to someone who still pays in pounds, so that is not a bug to reconcile, it is the better answer arriving from the cheaper signal.
Two practical notes if you do this. The device region has to be parsed out of shapes these platform APIs really return, including underscored forms and locales with no region at all, so the parser returns null rather than guessing. And an unknown or unparseable region falls back to the currency the price list is authored in, which is also the currency we settle in, so the fallback is a real price rather than a placeholder.
The last detail is the one that keeps the number honest: the app's region choice decides only which of our prices is shown. The purchase happens on the web, where the Checkout Session is created from the country on that request. Because every currency has exactly one fixed price rather than a conversion, the figure on the paywall is the figure that gets charged.
Question two: which way does rounding go?
A currency table needs a rounding rule, and the rule is not "round to two decimals". Two things in the package convert money, and they round in opposite directions.
A price rounds up:
/**
* Rounds up to the next retail-looking price for the currency.
*
* Two shapes, because currencies are written differently. Where a currency has
* decimals, the next .99. Where it does not, the next round step scaled to the
* size of the number, because a yen or forint price runs into thousands and
* "¥2,246" reads as the output of a conversion rather than as a price.
*/
function ceilToRetail(amount: number, digits: number): number {
const EPSILON = 1e-9;
if (digits === 0) {
const step = amount >= 1000 ? 100 : amount >= 100 ? 10 : 1;
return Math.ceil((amount - EPSILON) / step) * step;
}
const step = 1 - Math.pow(10, -digits); // 0.99 for 2dp
return Math.ceil(amount - step - EPSILON) + step;
}
A discount rounds down:
/**
* A price is rounded up so the figure covers the conversion back to sterling;
* a discount rounded up is a promise we then fail to keep, so this converts at
* the plain mid-market rate and rounds DOWN.
*
* Both directions share one rule: err towards the customer.
*/
"Err towards the customer" is the whole invariant, and writing it down as a sentence in the module is what makes the asymmetry obviously intentional instead of looking like two developers disagreeing.
Three more things the tests exist to pin down, each of which was a real way to get this wrong:
Zero passes straight through. Without a guard, the retail round-up turns the free tier's zero into "0.99", advertising a price for the plan whose entire point is that it does not have one.
Decimal places come from Intl, not from an assumption of two. Icelandic krónur, forint and yen are written without decimals in their own locales. Quoting 2999 yen as a two-decimal figure is wrong by two orders of magnitude, and silently so.
Stripe disagrees with Intl about some of those. Yen are sent in whole units, but forint and krónur are sent in hundredths that must divide evenly by 100. So checkoutPrice is its own function with its own tests, rather than a line in the checkout route:
export function checkoutPrice(pence: number, currency: DisplayCurrency) {
const { code } = CURRENCIES[currency];
const displayed = priceIn(pence, currency);
const stripeDigits = STRIPE_ZERO_DECIMAL.has(code) ? 0 : 2;
// ...
}
The zero-decimal set in the package is Stripe's full list, not only the currencies we sell in, so adding a currency to the table cannot silently miss its case. Getting this wrong does not throw, it charges somebody a hundred times the price, which is the class of mistake that has to be impossible rather than caught.
What the package refuses to do
No live exchange rate feed. The rates are hardcoded with the source and the date they came from, and they feed a fixed price rather than a live conversion, so the landing page renders without a network call and the price you were quoted last month is the price today. The buffer and the round-up absorb the drift.
No per-currency override list either. Every price is derived from one GBP figure plus one rate, which means there is exactly one place to change the price and no chance of a table where seven currencies agree and the eighth is stale.
See it
- munchable.app shows the pricing section with the currency for wherever you are reading from. Reload through a VPN in another country and the figure changes to a fixed local price rather than a converted one.
- munchable.app/terms quotes the same constant in prose, from the same import.
If you have split a price out of a payments module, I am interested in what else you found downstream of it. The rewards maths was the one I did not expect.
Top comments (0)