Notifio prices visitors in their own currency, and the figure on the page is the figure the card is charged. If you open the pricing page you will see your own country's number, not a conversion of ours.
I have written that machinery up twice before, for two other products in the same family: one authored price per currency rather than a conversion at checkout, and why Stripe's Adaptive Pricing has to be switched off when you have already chosen the number. Both of those posts are about a web page and a Checkout session, which share a request. This one is about the part those two do not have: a buyer who is a desktop app.
The page knows, the app does not
The currency comes from one header:
export async function getDisplayCurrency(): Promise<DisplayCurrency> {
const headerList = await headers();
// Set by Vercel's edge network on every request. Absent in local development,
// where we correctly fall back to GBP.
return currencyForVisitor(headerList.get("x-vercel-ip-country"), headerList.get("user-agent"));
}
That works for every page on the site. It does nothing at all for the window on your machine, which is an Electron app with an Express server inside its main process and no request from a visitor to read. The app needs a price because the auto-reply upgrade is bought from inside it, by someone who is already activated, so there is no web page in the flow to put a number on.
The app could have been given the pricing table: the rates, the retail round-up, the zero-decimal currencies, the Intl formatting. Then it would need a country, which it cannot get, and it would need the table to stay in step with the server's copy, which it cannot guarantee across installed versions. So it is given neither. It is given the answer:
/**
* A price for the desktop app, which cannot read the request's country itself:
* the figure to show, and the currency to send back to checkout so the server
* can confirm it is charging what was shown.
*/
export function priceQuote(
pence: number,
currency: DisplayCurrency
): { currency: DisplayCurrency; label: string } {
return { currency, label: formatPrice(pence, currency) };
}
Two fields. label is already formatted, in the locale that currency is written in, so the app renders a string it does not understand. currency is a token whose only job is to come back.
It rides along on the licence check, which the app is making anyway:
// The upgrade price in this visitor's currency, resolved exactly as the
// upgrade checkout resolves it, so the app can show the figure it will charge.
const currency = currencyForVisitor(countryFromRequest(req), req.headers.get("user-agent"));
return NextResponse.json({
valid: true,
plan: "lifetime",
autoReply: license.autoReply,
upgradePrice: priceQuote(UPGRADE_PRICE_PENCE, currency),
});
The app caches that whole response to disk, because a licence that has been paid for must not switch itself off when the laptop is on a train. Which means the price is cached too, and a cached price is a price that can be wrong.
So the app has to say what it showed you
/**
* The upgrade price in the user's currency, as the server will charge it. The
* server decides the currency from the request's country, which the app cannot
* see, so the app shows `label` and sends `currency` back with the checkout
* request; the server refuses to charge a currency other than the one shown.
*/
export interface UpgradePrice {
currency: string;
label: string;
}
And the refusal, in the upgrade checkout route:
// App versions from before local prices send no currency and show £20.
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;
}
A 409 rather than a correction. The server could silently charge its own answer, which is the amount it believes is right, and the user would see a Stripe page quoting a figure the app never showed them. Instead the request is refused and the current quote is handed back in the error body, so the app can put the real number on screen before anyone's card is involved. The renderer does exactly that:
async function handleUpgrade() {
setBusy(true);
// Checkout charges the price on screen, in its currency, and refuses if that
// is no longer this user's price. Refreshing then shows the current one.
const { ok, data } = await upgradeAutoReply(upgradePrice?.currency ?? null);
setBusy(false);
if (!ok || !data.url) {
showToast(data.error || "Could not start checkout");
void refresh(true);
return;
}
Fly from Amsterdam to London with a cached euro price, press Unlock, and you get a toast and a page showing pounds. Press it again and you buy at the figure you can see. That is the whole point of the round trip, and it works.
Four states on one optional field
The field is optional on the way in:
export interface ValidateResult {
valid: boolean;
plan?: string;
/** Has the auto-reply upgrade been purchased on this licence? */
autoReply: boolean;
/** Absent from servers and caches that predate local prices. */
upgradePrice?: UpgradePrice;
}
Which gives four possible things the server can receive, and the route was written for three of them:
| What the app sends | Server sees | What happens |
|---|---|---|
no currency key at all |
undefined |
charged £20, the base price, as intended for old builds |
"eur" matching this request |
"eur" |
charged €24.99, the figure on screen |
"eur" from another country |
"eur" vs "gbp"
|
409, with the current quote attached |
null |
null |
409, with the current quote attached |
That last row is the current app, in the case where it has no cached price yet. upgradePrice?.currency ?? null produces null, JSON.stringify keeps a null (it only drops undefined), so the key arrives present and empty. shownCurrency !== undefined is true, the string check fails, shown is null, and null !== "eur" sends back a 409.
The comment above that branch says app versions from before local prices send no currency and show £20. True, and they do. But the branch is reached by the current app too, on the first click, whenever the panel is showing its fallback label:
{upgradePrice ? `Unlock · ${upgradePrice.label} one-time` : "Unlock · one-time"}
A button that says "Unlock · one-time" with no figure, clicked once, answers with a toast reading "The price for your country has been updated. Check it and try again." Nothing was updated. Nothing was shown in the first place.
The bug is the behaviour I want, and the message is the bug
The obvious patch is to make null take the undefined path, so a client that showed nothing is charged the base price. I do not want that patch. Charging someone £20 on a click where the app displayed no figure at all is the exact arrangement the round trip exists to prevent, and the refusal is doing its job: it will not sell anything until a price has been on screen.
What is wrong is the two sentences wrapped around it. The route's comment describes a world with two kinds of client, old and new, when there are three: old, new with a price, and new without one. And the error string guesses at a reason rather than stating the condition. Something closer to "Check the price and try again" costs nothing and is true in all three cases that produce a 409.
There is a smaller version of the same lesson one screen earlier. An earlier post about the activation screen ended on the app asking its local server which website to link to, then printing a hardcoded price next to the link. That price is gone. The screen now reads:
<p className="mt-5 text-center text-[13px] text-[#525252]">
Don't have a license?{" "}
<a href={pricingLink} className="text-[#a3a3a3] underline">
Get one
</a>
</p>
No figure, one link. The window cannot know what you will be charged, so it stopped claiming to, and sends you to the page that can instead. The upgrade panel is the one place in the app where a price still appears, and the only reason it is allowed to is that the server wrote the string.
Top comments (0)