DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our reward label stopped quoting money, because the phone cannot know which currency the bill is in

Munchable is a barcode scanner for people with gut conditions. When you scan something that is not in the catalogue yet, you can photograph the ingredients panel and the product is there for whoever scans it next. That contribution earns a discount on your next Premium bill: 2% per new product, up to 50 in a calendar month, at which point the bill is free. The promise is written into the terms of service, under "Contribution rewards", so the number in the app has to be the number in the contract.

Until last week the app wrote that reward as money, and the money was wrong in a way that nobody had complained about yet.

Three facts that do not fit together

One. The reward is stored and earned against the GBP list price. PREMIUM_PRICE.unitAmount is 1000, an integer number of pence, and NEW_PRODUCT_CREDIT_PENCE is 2% of it:

export const FREE_MONTH_THRESHOLD = 50;
export const MAX_PERCENT = 100;
export const NEW_PRODUCT_CREDIT_PENCE = Math.round(
  (PREMIUM_PRICE.unitAmount * NEW_PRODUCT_PERCENT) / 100,
);
Enter fullscreen mode Exit fullscreen mode

Twenty pence.

Two. The price list has exactly one fixed figure per currency, and no conversion happens at checkout. GBP 10 in the UK, EUR 12.99 in the euro area, and so on down a table of hardcoded rates with a buffer baked in. You can read the figure for wherever you are on the pricing section of the landing page. That was a deliberate move away from letting the payment processor convert a pound price on its own page, because the local amount changed every time the rate moved and nobody could quote it.

Three. The currency the app shows and the currency the subscription is billed in come from different signals. The app picks a display currency from the device region, synchronously, on the first render, because the paywall has no request in its hands and a round trip in front of a number is a spinner in front of a price:

function deviceRegion(): string | null {
  try {
    const fromIntl = regionFromLocale(Intl.DateTimeFormat().resolvedOptions().locale);
    if (fromIntl) return fromIntl;
  } catch {
    // An Intl-less runtime is not a reason to fail to render a price.
  }
  if (typeof navigator !== 'undefined') return regionFromLocale(navigator.language);
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Premium is bought on the web, where the Checkout Session is created in the currency of the country the request came from. Those two signals agree for almost everybody and disagree for anybody who moved, travels, or runs their phone in a different language than their billing address.

The label that sat on top of them

export function rewardOffLabel(creditPence: number, freeMonth: boolean): string {
  if (freeMonth) return 'Next month free!';
  if (creditPence <= 0) return 'No discount yet this month';
  return `${discountLabel(creditPence)} off next month`;
}
Enter fullscreen mode Exit fullscreen mode

discountLabel converted the pence at the plain rate and rounded down, which is its own small piece of care: prices round up to a retail figure, discounts round down, so a converted discount can never be a bigger discount than the one that was earned.

It was still wrong. A subscriber billed in pounds, holding a phone set to Ireland, read "EUR 0.23 off next month" about a bill that was going to be reduced by 20p. The arithmetic was right and the sentence was about the wrong bill.

A percentage is right in every currency

The thing the label was describing was never a money amount. A reward is a Stripe coupon with percent_off and duration: 'once'. The processor takes that share off whatever the next invoice is, in whatever currency that subscription runs in. Nothing in that sentence needs to know where the phone is.

So the component now takes the percent the API already returns:

-  standing: Pick<Contributions, 'newProducts' | 'threshold' | 'creditPence' | 'freeMonth'>;
+  standing: Pick<Contributions, 'newProducts' | 'threshold' | 'percent' | 'freeMonth'>;
Enter fullscreen mode Exit fullscreen mode
export function rewardOffLabel(percent: number, freeMonth: boolean): string {
  if (freeMonth) return 'Next month free!';
  if (percent <= 0) return 'No discount yet this month';
  return `${percent}% off next month`;
}
Enter fullscreen mode Exit fullscreen mode

"4% off next month" is exactly true for a subscriber in Lisbon, a subscriber in Leeds, and a subscriber who has not subscribed yet. The import of discountLabel went with it, which is the part I like: the currency conversion is still there for prices, and it is no longer reachable from the rewards screen at all.

The pence figure did not get deleted. The API still returns it, and the admin view still reads it, because an operator looking at what a month of contributions cost the business genuinely wants one currency and genuinely wants it to be the one we settle in. It just stopped being something a user is shown.

The rule I took from it

A derived display value should be computed in the unit the underlying system actually operates in. The coupon operates in percent. Every pence figure downstream of it was a second representation that had to be kept honest by hand, across a currency boundary the app cannot see, for a bill it does not own.

The giveaway was that the conversion needed a rounding rule at all. Rounding a discount down so it cannot overstate itself is a sensible rule, and writing it is a sign that the number should not have been converted in the first place.

If you want to see what the app is actually quoting, the pricing block shows the figure for your region, and the terms state the reward in percent now too. They agree, which they did not entirely do a week ago.

Top comments (0)