DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our paywall guesses your currency from the phone, and checkout is allowed to answer 409

Munchable's paywall has to print a price before it has asked any server anything. It is a screen somebody lands on when they run out of free scans, and a spinner where the number should be is not an option.

So it guesses, from the phone:

/**
 * The Premium price, in this device's currency, or in `currency` once checkout
 * has said which currency it charges in.
 */
export function premiumPriceLabel(currency: DisplayCurrency = displayCurrency()): string {
Enter fullscreen mode Exit fullscreen mode

And the server does not guess. It reads the country from a CDN header, looks up that country's currency, and there is exactly one fixed price in it. Not a conversion: £10 a month in the UK, €12.99 in the euro area, each a figure chosen once and written in a table. You can see whichever one applies where you are on the pricing section of the homepage.

Two sources for one fact, which is a bug waiting for a passport.

The disagreement is a real person, not an edge case

A phone set to English (United States), being held in the Netherlands. The device locale says dollars. The request arrives from an IP in the Netherlands, so checkout charges euros. Neither signal is wrong, they are answering different questions, and the old code had the paywall print the device's answer and the Checkout Session use the server's.

Nobody is defrauded by that, since the amounts are both published prices. It is still the worst thing a checkout can do, which is show one number and then charge another. People abandon at that screen and they are right to.

The server's answer: open nothing

The fix inverts who is allowed to be authoritative. The client now tells the server which currency it printed, and the server refuses to proceed if that is not what it is about to charge:

// POST { currency? } -> { url } | 409 { error: 'price_changed', currency }

const body = (await request.json().catch(() => null)) as { currency?: unknown } | null;
const country = countryFromRequest(request);
const currency = currencyForCountry(country);

// Refuse before any Stripe work if the screen showed another currency.
if (body?.currency !== undefined) {
  const shown = parseDisplayCurrency(typeof body.currency === 'string' ? body.currency : null);
  if (shown !== currency) {
    return NextResponse.json({ error: 'price_changed', currency }, { status: 409 });
  }
}
Enter fullscreen mode Exit fullscreen mode

Four things about that block are deliberate.

It happens before any Stripe call. No session is created, nothing is reserved, no webhook will ever arrive about it. The cheapest possible way to not sell something at the wrong price is to not create the object that represents selling it.

The response names the currency it would charge, so the client does not have to guess a second time or round-trip again to find out.

currency is optional. A request that does not send one is trusted to be the website, which resolves the currency from the same header the server does and therefore cannot disagree with it. Only the app's own paywall, with its independent guess, opts into being checked.

And the status is 409 Conflict, which is the rare case where the semantically fussy choice is also the practically useful one: it is not a client error in the sense of a malformed request, and it is not a server error. The state the client believed in is stale. Conflict is exactly that.

The client's answer: change the price, then let them press again

// The currency the price below is written in: the phone's region at first,
// then whatever checkout says it charges in, if that turns out to differ.
const [currency, setCurrency] = useState(displayCurrency);
const [priceNotice, setPriceNotice] = useState<string | null>(null);

const onGetPremium = async () => {
  setBusy(true);
  setCheckoutError(null);
  const { error, priceChanged } = await startWebCheckout(currency);
  if (priceChanged) {
    // Nothing was opened. Show the price this person is charged, and let the
    // next press pay it.
    setBusy(false);
    setCurrency(priceChanged);
    setPriceNotice(priceChangedNotice(PREMIUM_PRICE.unitAmount, PREMIUM_PRICE.interval, priceChanged));
    return;
  }
  // ... error handling; on success the tab is navigating to Stripe
};
Enter fullscreen mode Exit fullscreen mode

The screen's currency starts as a guess and becomes a fact. useState(displayCurrency) passes the function rather than calling it, so the device lookup happens once on mount; after a 409 the state holds the server's answer and the same premiumPriceLabel(currency) call renders a different price with no other change.

The second press goes through, because by then the client is sending the currency the server just named.

The notice it shows alongside is one sentence, and what it leaves out took longer than what it says:

/**
 * ... This is what the screen then says, before anyone is asked to pay: the
 * price for where they are, stated plainly, and that pressing again pays it.
 * No rate and no "converted from", because the figure is not a conversion of
 * anything; it is that country's price.
 */
export function priceChangedNotice(pence: number, interval: string, currency: DisplayCurrency): string {
  return `The price where you are is ${formatPrice(pence, currency)} a ${interval}. Press again to continue at that price.`;
}
Enter fullscreen mode Exit fullscreen mode

No exchange rate, no "was", no strikethrough. Those are the furniture of a conversion, and there is no conversion here: each currency's price was set once and does not move with the market. Explaining a rate would be explaining a mechanism that is not involved.

The one assumption, in one place

The device side leans on Intl, which Hermes ships on both platforms, and the whole app funnels through one function so that assumption has one place to fail:

function money(format: () => string, pence: number): string {
  try {
    return format();
  } catch {
    return `£${(pence / 100).toFixed(2).replace(/\.00$/, '')}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

A runtime without Intl renders the pound figure, which is the price list's own currency and a real price somebody is charged, rather than throwing inside a render and taking the paywall down with it. A fallback that shows a wrong number is worse than a crash; a fallback that shows a different true number is not.

Rewards are a percent, and that is not a presentation choice

Munchable has a contribution reward: add products the catalogue does not have yet, and a share comes off your next bill. The app used to print that as money.

/**
 * "4% off next month" / "Next month free!" from the reward's percentage.
 *
 * A reward is a percent-off coupon on the next bill, and the percentage is the
 * one figure that is exactly right for everybody: Stripe takes that share off
 * whatever the bill is, in whatever currency the subscription is in. A money
 * figure would have to guess the currency from the phone, and a subscriber's
 * bill is in the currency they subscribed in, which the phone does not know.
 */
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

The deleted version converted a pence credit into the device's currency at the plain rate and rounded down, which was careful, defensible and wrong. Not wrong in its arithmetic. Wrong in its premise: the device's currency is not the subscription's currency. Somebody who subscribed in the UK and is now holding a phone set to euros has a bill in pounds, and a label that says "€0.24 off next month" is a number that will never appear on any invoice they receive.

The percentage dodges the entire problem, because a percent-off coupon is applied by Stripe to whatever the bill is. It is the same true statement in every currency, including the currencies we have not added yet.

The test pins the property rather than the strings:

test('a reward reads as the share it takes off the next bill', () => {
  assert.equal(rewardOffLabel(4, false), '4% off next month');
  assert.equal(rewardOffLabel(100, true), 'Next month free!');
  assert.equal(rewardOffLabel(0, false), 'No discount yet this month');
  assert.ok(!/[£€$]/.test(rewardOffLabel(2, false)));
});
Enter fullscreen mode Exit fullscreen mode

That last assertion is the one I actually care about. The first three would pass again if somebody reintroduced currency formatting with a different set of arguments. The fourth says: no currency symbol may appear in this label, for any input, ever. It is a four-character regex standing in for a paragraph of reasoning, which is the best kind of test to leave behind.

The API still computes a pence figure, incidentally, because the admin view needs to see what a month of rewards is costing in the currency the business settles in. It is just no longer shown to the person earning it. The terms of the whole arrangement, including what counts and how it is applied, are on the terms page, and the conditions Munchable covers are what the paywall is actually gating access to.

Top comments (0)