DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Prices round up, discounts round down, and one package decides both

Munchable Premium is £10 a month. The price list has 24 currencies in it, and the rule the whole thing is built around is that the number you read is the number your card is charged. Not approximately, not after conversion at the last screen. The same number.

That rule is easy to state and easy to break, because "show a price" and "charge a price" are normally two code paths in two parts of an app, written months apart. So they are one function in one package now:

packages/pricing
├── package.json      # zero runtime dependencies
└── src
    ├── currency.ts   # 24 currencies, country map, the rounding
    ├── products.ts   # £10.00, as an integer number of pence
    └── index.ts
Enter fullscreen mode Exit fullscreen mode

Sixteen files import it: thirteen in the Next.js marketing site, three in the Expo app. This post is about three decisions inside it that I have not seen written down elsewhere.

Why a package and not a lib file

The price used to live next to the Stripe client. That is the natural home for it, and it is wrong for one specific reason: a <Price> component on a landing page needs the number, and nothing else. Import it from the module that constructs a Stripe client and you have put the Stripe SDK in the module graph of a component that renders four characters of text.

So the package has zero runtime dependencies, and the Stripe-facing module re-exports it so no existing import had to change. The Expo app depends on it too, which is the other half of the argument: a React Native bundle cannot import a server module at all, and a paywall that quotes a different price from the landing page is the single most expensive bug in this category.

export const PREMIUM_PRICE = {
  currency: 'gbp',
  unitAmount: 1000,   // integer pence, never a float, never a display string
  interval: 'month',
  productName: 'Munchable Premium',
} as const;
Enter fullscreen mode Exit fullscreen mode

Everything else is derived from those 1000 pence.

One fixed price per currency, not a conversion

Each currency has exactly one Premium price: £10 in the UK, €12.99 in the euro area, and so on. That figure is what the landing page prints, what the app's paywall prints, and what the Checkout Session is created for.

It replaced an arrangement where we charged GBP and let Stripe's Adaptive Pricing convert on its own page. That produced a different local amount every time the rate moved, which meant nobody could quote the price in an email, a support reply or an ad. I wrote about the removal itself in We stopped letting Stripe convert our price, because nobody could quote it, and about the two different ways the site and the app work out which country you are in, in Two apps, two ways of knowing where you are, one price table.

The rates are hardcoded, with a source date in the comment. That is a deliberate trade: they feed a figure that is explicitly approximate and never a live charge, so a rate feed would add a network dependency to rendering a landing page in exchange for precision the number does not claim.

Up for prices, down for discounts

Here is the part worth the post.

A local price is rounded up, twice over. First a buffer:

const CONVERSION_FEE_BUFFER = 1.04;
Enter fullscreen mode Exit fullscreen mode

We are paid in the customer's currency and settle in GBP, so Stripe's conversion takes a percentage on the way back, and the hardcoded rates drift between refreshes. The buffer sits at the top of Stripe's stated 2 to 4 percent band, so £10 of list price is still about £10 of settled revenue rather than £9.60.

Then a retail round-up, in two shapes, because currencies are written differently. Where a currency has decimals, up to the next .99. Where it does not, up to 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. Yen land on ¥2,300 and forint on 4,500 Ft, the way prices on a shelf there do.

Now the asymmetry. We also hand out credit: a contributor who adds a product gets money off. That amount uses the opposite rounding, at the plain mid-market rate with no buffer:

/**
 * The opposite rounding to priceIn, for the opposite reason. 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.
 */
export function discountIn(pence: number, currency: DisplayCurrency): number
Enter fullscreen mode Exit fullscreen mode

Twenty pence of reward credit reads as "£0.20 off" in London and "€0.23 off" in Dublin. Run it through the price rounding instead and the same 20p would read as "€0.99 off", which is a number we would then have to honour or explain.

Both directions follow one rule: err towards the customer. If you only remember one thing from this post, it is that a rounding helper is not reusable across money that flows in and money that flows out. Sharing it is how a marketing page ends up promising a bigger discount than the ledger issues.

There is also a guard that looks trivial and is not:

// Zero is passed straight through in every currency. Without that guard the
// round-up would turn the free tier's £0 into "€0.99".
Enter fullscreen mode Exit fullscreen mode

The free tier is the entire top of our pricing table. Advertising a price on the plan whose whole point is that it does not have one is the sort of bug that gets screenshotted. You can check that one live: on munchable.app/#pricing the free column reads €0, or $0, or ¥0, and the Premium column next to it ends in .99 or a round local step.

The trap that charges a hundred times the price

Stripe wants amounts in the currency's smallest unit. Intl will tell you how many decimal digits a currency has. They disagree, and not in the way you would guess.

export const STRIPE_ZERO_DECIMAL: ReadonlySet<string> = new Set([
  'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA',
  'PYG', 'RWF', 'VND', 'VUV', 'XAF', 'XOF', 'XPF',
]);

/**
 * Stripe's special cases: written without decimals in their own locales, but
 * sent to Stripe as hundredths that must divide evenly by 100. A 4,500 forint
 * price is `unit_amount: 450000`.
 */
export const STRIPE_DIVISIBLE_BY_100: ReadonlySet<string> = new Set([
  'HUF', 'ISK', 'TWD', 'UGX',
]);
Enter fullscreen mode Exit fullscreen mode

Yen are zero-decimal in both systems. Icelandic krónur and forint are written without decimals, as Intl correctly reports, but are sent to Stripe in hundredths that must divide evenly by 100. Get that wrong and you do not see an error, you charge somebody a hundred times the price, or one hundredth of it.

Which is exactly why the conversion is one function, with its own test file, and an explicit throw rather than a silent rounding:

if (STRIPE_DIVISIBLE_BY_100.has(code) && unitAmount % 100 !== 0) {
  throw new Error(`checkoutPrice: ${code} amounts must divide by 100, got ${unitAmount}`);
}
Enter fullscreen mode Exit fullscreen mode

Failing to create a Checkout Session is recoverable. Creating one for the wrong amount is a refund, an apology and a chargeback.

Tax comes out of the number, never on top

Every price in the table is tax-inclusive: whatever VAT is due in the customer's country comes out of the figure. That is a pricing decision as much as a tax one, because the alternative is a checkout screen where the number grows. Where Stripe acts as merchant of record and collects that tax is decided separately, and country by country, which I wrote up in Merchant of record for 27 countries, and deliberately off for our own.

One last note on reuse across repositories. A sibling product of ours has its own copy of this currency table, kept in step by hand, quoting the same rates from the same source date. That is not laziness. Publishing a shared package would couple two products' release cycles to a rate refresh, and the thing that actually has to match is the numbers, which a review can check in a minute.

Try the geography: open munchable.app/#pricing, note the figure, then load it again through a VPN in another country. The currency changes, the free tier stays at zero, and whatever Premium says is what the Checkout Session will be created for.

Top comments (1)

Collapse
 
devsupport profile image
Dev Support •

Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support

‍​‍‌‍