Notifio sells two things, both one-time payments: a licence, and an auto-reply upgrade on top of it. No subscriptions, which means there is no billing state to keep in sync and the database has one table for all of it.
The second purchase happens inside the desktop app rather than on the website, because it upgrades a licence the app already holds. That means the figure on the Unlock button has to travel from a server, through a cache on disk, into a React component, and back again before anybody is charged. Yesterday I made the server refuse to charge anything that disagrees with what came back.
Where the number on the button comes from
The app asks /api/validate whether its licence is real. Since the website prices in the visitor's currency, that endpoint also answers with the upgrade price:
// 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),
});
priceQuote is two fields, and the second one is the point:
export function priceQuote(pence: number, currency: DisplayCurrency) {
return { currency, label: formatPrice(pence, currency) };
}
A machine-readable currency and a human-readable label. The app renders the label and never does arithmetic on it:
{upgradePrice ? `Unlock · ${upgradePrice.label} one-time` : "Unlock · one-time"}
The country comes out of the request, which the app cannot see from inside itself, so there is no version of this where the app works the price out locally. It has to be told.
And because the app has to keep working when the network does not, what it is told goes on disk:
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;
}
That one comment is the entire problem. A cache is a price from some earlier moment, possibly some earlier country, possibly written by a build of the app that had never heard of local prices at all.
The question the server has to answer
There are 24 currencies in the table and 58 countries mapped onto them. Somebody who buys a licence on a work trip, opens the app at home three weeks later and clicks Unlock is showing me a number I may no longer agree with.
The wrong fix is to trust the client and charge what it says. That turns a cache into a price list, and a price list anybody can edit. The other wrong fix is to ignore the client and charge the current local price, which means the checkout page can show a different number from the button that opened it. Charging somebody a figure they were never shown is the one outcome worth writing code to prevent.
So the app sends back what it displayed, and the server treats it as a claim to be checked rather than an instruction:
// 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;
}
One field, three outcomes.
undefined is an old copy of the app. Those builds show a hardcoded £20 and have no field to send, so they get charged in pounds, which is exactly the number they put on screen. Nothing to reconcile and nobody to annoy. This is the only reason the check is on undefined rather than on falsiness: an empty string is not an old client, it is a client sending nonsense.
A string that parses to the currency this request resolves to is the agreement case, and the only one that reaches Stripe.
Everything else is a 409. Note what happens to a non-string: typeof shownCurrency === "string" fails, shown becomes null, and null !== localCurrency is always true, so { currency: 42 } takes the same exit as a stale cache. A purist would want a 400 there, because a number in a currency field is a malformed request and not a conflict. I am comfortable with it, because the only client that can produce it is one I wrote, and the response it gets back is the correct price plus an instruction to look at it again. A 400 would be more accurate and less useful.
The other 409
Twenty lines above, the same route already returns a 409:
// The upgrade is permanent, so there is never a reason to buy it twice.
if (license.autoReply) {
return NextResponse.json(
{ error: "Auto-reply is already unlocked on this licence." },
{ status: 409 }
);
}
For a while this bothered me. One of those is "you are trying to buy something you own" and the other is "your copy of the price is out of date". They do not feel like the same class of problem, and I went looking for a reason to make the second one a 402 or a 409 with a problem-type body or anything that would tell them apart.
Then I wrote the two sentences out and they turned out to be the same sentence:
- The state you believe in is not the state here, and the request only makes sense in the state you believe in.
- The state you believe in is not the state here, and the request only makes sense in the state you believe in.
That is what 409 is for. The difference is not in the status, it is in whether the client can fix it, and the response bodies already say: one of them hands back a price, and the other does not hand back anything because there is nothing to retry.
What makes this work in practice is that the client does not need to tell them apart either:
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;
}
Any failure shows the server's sentence and then forces a refresh with the cache bypassed. If the price moved, the button relabels itself behind the toast and the second click works. If the upgrade was already bought on another machine, the refresh replaces the whole upgrade card with the unlocked panel, and the thing the user was trying to buy is now visibly theirs. One error path, two correct outcomes, because the fix for a stale belief is the same fix in both cases: go and ask again.
Then the part Stripe could still get wrong
Agreeing on a currency is not the same as charging in it. Stripe has a feature called Adaptive Pricing that will helpfully convert a session into the buyer's local currency, which is a lovely feature for anybody who has not already done it themselves, and a way to invent a third number for anybody who has:
...(managed
? { managed_payments: { enabled: true } }
: { adaptive_pricing: { enabled: false } }),
line_items: [
{
price_data: {
currency,
product_data: { name: product.name, ...(managed ? { tax_code: MANAGED_PAYMENTS_TAX_CODE } : {}) },
unit_amount: stripeUnitAmount(product.pence, currency),
tax_behavior: MANAGED_PAYMENTS_TAX_BEHAVIOR,
},
quantity: 1,
},
],
metadata: {
...input.metadata,
gbpPence: String(product.pence),
managedPayments: String(managed),
},
Switched off on every session we create ourselves, and left out entirely on the EU sessions that go through Managed Payments, because Stripe rejects the field there and always runs Adaptive Pricing on those. A price already in the buyer's currency gives it nothing to convert, which is why every EU country has to be listed with its own currency rather than being allowed to fall through to pounds.
The last two lines are the bookkeeping. amount_total on a completed session is in minor units of whatever currency was charged, so a pile of sales is a pile of incomparable numbers unless something records the list price they were all priced from. gbpPence is that stamp, written at creation, and it means a completed session can be read back without anybody converting 2,299 HUF-ish minor units into pounds at a rate nobody wrote down.
What I would take from this
A price you displayed is a promise, and a promise needs an enforcement point. Putting that point on the server and having the client echo back what it showed costs about fifteen lines and turns an entire category of support conversation into a toast.
The thing I would push back on in my own code is the temptation to make the client's echo authoritative, because it is so convenient: the client already knows, just charge what it says. The echo is a token to be compared, never a parameter to be used. The only line that actually charges anything reads currency = localCurrency, the server's own value, after the comparison passed. The client's string is never the thing that reaches Stripe, even when it is right.
You can see both prices from the outside: the licence on notifio.app/pricing, in your own currency if you are in one of the 58 countries, and what the upgrade actually does on the help page.
Top comments (0)