DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our desktop app has to tell us which price it showed, and a build that cannot is charged £20 instead of €24.99

Notifio is a desktop app that watches rental search pages for new listings, sold as a one-time licence. The licence is £20 and the auto-reply upgrade is another £20, and those two numbers are the only prices in the codebase:

export const PRICE_CURRENCY = "gbp";
export const LICENSE_PRICE_PENCE = 2000;
export const UPGRADE_PRICE_PENCE = 2000;
Enter fullscreen mode Exit fullscreen mode

Everything else a visitor sees is derived. A visitor in Ireland reads €24.99 on the pricing page and is charged €24.99, in euros, by Stripe. Not "about €24", not £20 converted at checkout time by Stripe's Adaptive Pricing, but the same integer the page rendered.

That is one function and a table of 24 currencies, which I have written about from the Stripe side before. This post is about the one client in the system that cannot work its own price out, which is our own app, and what that forced into the API.

How the figure is decided

export function localPrice(pence: number, currency: DisplayCurrency): number {
  if (pence === 0) return 0;
  if (currency === BASE_CURRENCY) return Math.round(pence);

  const digits = minorUnitDigits(currency);
  const major = (pence / 100) * CURRENCIES[currency].rate * LOCAL_PRICE_MARGIN;

  return Math.round(ceilToRetail(major, digits) * Math.pow(10, digits));
}
Enter fullscreen mode Exit fullscreen mode

The rate is fixed in the table, taken from the ECB euro reference rates of a particular day and crossed through the euro. LOCAL_PRICE_MARGIN is 1.04, which covers Stripe's fee for converting the payout back into pounds and drift in those fixed rates. ceilToRetail rounds up to the next .99, or the next whole unit in a currency written without decimals.

Run £20 through it:

Currency Shown and charged £20 at the table's own rate
EUR €24.99 23.39
USD $28.99 27.12
SEK 267,99 kr 257.33
PLN 104,99 zł 100.73
INR ₹2,696.99 2592.80
HUF 8805 Ft 8466.20
JPY ¥4,491 4317.80

The right-hand column is why the fixed rate is defensible: it is not a conversion and the site never calls it one. It is a price list, in 24 currencies, that happens to have been generated from one pound figure, and the rates are fixed precisely so the price of the same licence cannot change between one page load and the next.

The last two rows are where Stripe gets interesting. Intl says the forint and the yen are written without decimals, and Stripe agrees about the yen only, so ¥4,491 is sent as 4491 and 8805 Ft as 880500. That rescaling is one line, and it has its own post.

On the website, nobody is asked

The currency comes from the request, on the server, for both the page and the checkout:

export async function getDisplayCurrency(): Promise<DisplayCurrency> {
  const headerList = await headers();
  return currencyForVisitor(headerList.get("x-vercel-ip-country"), headerList.get("user-agent"));
}
Enter fullscreen mode Exit fullscreen mode

58 country codes map to the 24 currencies, and anything unlisted gets GBP. The checkout route calls the same currencyForVisitor with the same two headers, so the browser never sends a currency and never gets to choose one. Three pages give up static rendering for this, because reading a request header makes them per-request: the homepage, the download page and the pricing page are all force-dynamic.

Crawlers are pinned to GBP by a user-agent check, so the price Google indexes is the price in the page's own structured data rather than the currency of whichever datacentre fetched it.

And on every session we create without Managed Payments, Adaptive Pricing is explicitly switched off:

...(managed
  ? { managed_payments: { enabled: true } }
  : { adaptive_pricing: { enabled: false } }),
Enter fullscreen mode Exit fullscreen mode

Adaptive Pricing is a good feature in general and the wrong feature here: it exists to convert a price into the buyer's currency, and our price is already in the buyer's currency. Leaving it on means Stripe gets a chance to show a different number on the Checkout page from the one on ours. EU sales go through Managed Payments, where Stripe rejects the field outright, and the fact that the price is already local is exactly what gives it nothing to do.

The client that cannot read the headers

The auto-reply upgrade is bought from inside the desktop app, because it needs the licence that is already on the machine. The app does not render our pages, so it cannot learn the visitor's country from a Vercel header on a page request. It learns the price from the licence check it already makes:

const currency = currencyForVisitor(countryFromRequest(req), req.headers.get("user-agent"));
return NextResponse.json({
  ...
  upgradePrice: priceQuote(UPGRADE_PRICE_PENCE, currency),
});
Enter fullscreen mode Exit fullscreen mode

priceQuote returns two fields: the currency, and the formatted label the app puts on the button. Then, when somebody clicks it, the app sends that currency back, and the upgrade route only accepts it if it agrees:

let currency: DisplayCurrency = BASE_CURRENCY;
if (shownCurrency !== undefined) {
  const shown = typeof shownCurrency === "string" ? parseDisplayCurrency(shownCurrency) : null;
  if (shown !== localCurrency) {
    return NextResponse.json(
      {
        error: "The price for your country has been updated. Check it and try again.",
        upgradePrice: priceQuote(UPGRADE_PRICE_PENCE, localCurrency),
      },
      { status: 409 }
    );
  }
  currency = localCurrency;
}
Enter fullscreen mode Exit fullscreen mode

This is the only client-supplied currency in the system, and it is not trusted, it is only allowed to confirm. The server works out the currency the same way it always does, and the client's value is a claim about what was on screen. If the two disagree, nobody is charged: the response carries the current price so the app can show it first. Quoting one figure and charging another is the single thing this whole module exists to prevent, and a mismatch is the one case where the app genuinely cannot know which figure is right.

Three things I do not like about that block

The 409 message describes the wrong cause. "The price for your country has been updated" is the rarest reason to get here. The common ones are a quote cached from a previous trip, a VPN that is on for one request and off for the next, and a mobile connection that moves the user between two geolocations. A user on a VPN can retry forever and get the same answer, because nothing in the app tells them the two requests are being seen from different countries. The response has room for both currencies, and the copy should say which one we think they are in.

Nothing is logged. The 409 returns without a console.error, so the one failure in the purchase path that depends on something outside our code is invisible. One log line with the two currency codes in it would turn "a user says the upgrade button does nothing" into a two-minute answer.

The compatibility branch is also an opt-out. shownCurrency !== undefined exists because app builds from before local prices send no currency and display a fixed £20, so charging them £20 is the honest thing to do. The consequence is that the field being absent selects the base price, and for all 23 non-GBP currencies the base price is the cheaper one by construction, since the local price is the conversion plus 4% rounded up. Anybody who removes one field from one request pays £20 instead of €24.99.

The amount at stake is the FX margin on a £20 sale, so this is not a fraud problem, it is a correctness one: the same product has two prices and the client picks. The obvious fix is to accept a missing currency only from the builds that predate the feature, and I cannot write it, because the app never tells the server which version it is. Nothing in the licence check carries a version string, so the absence of the currency field is currently our only signal that we are talking to an older build, which is the same thing as having no signal at all.

So the honest shape of the fix is a floor rather than a gate: never charge less than the local price, and let the quote be the only thing that decides which it is. And the version field is going in, because the app has no auto-update, those old builds will keep arriving for years, and right now the server cannot tell one from the other.

What the tests pin

The pricing and checkout rules are the one part of this codebase with real test coverage: 42 cases on the currency module, 14 on the session parameters, and 23 on the Managed Payments decision. The ones worth stealing are not the arithmetic ones. They are:

  • Every EU-27 country is listed with its own currency, so no EU sale can reach Managed Payments with a currency Stripe would want to convert.
  • localPrice never returns less than the same sale in GBP, for every currency in the table.
  • Zero stays zero in all 24, because a retail round-up on a free tier would invent a price of €0.99 for something that costs nothing.

That last one is my favourite kind of test: it guards a line that exists only because a general rule was wrong in one specific case, and it is the sort of rule a refactor is otherwise very happy to tidy away.

You can check the whole thing from the outside, which is the nice part about prices being in the HTML: the figures are on the pricing page and repeated on the download page, with the currency note under each. What you are buying is on the per-site alert pages, such as Kamernet or Idealista, and the comparisons against the subscription services are at notifio.app/compare.

Top comments (0)