CogniPrep sells one-off unlocks, no subscription, and it quotes them in the visitor's own currency. That means two separate decisions get made about a single buyer, and both of them are answers to the same question: which country is this person in?
- Which currency do we quote? A Dutch visitor should see euros.
- Do we route this sale through Stripe's Managed Payments (merchant of record), so Stripe collects and remits the VAT? A Dutch sale owes Dutch VAT from the first euro.
For a long time those two decisions were made by two functions that resolved the country differently. That is the whole bug, and it is worth the walk through, because the failure it produced is the kind nobody notices: the sale completes, the customer is happy, the money arrives, and the tax that was due was never collected.
Where the country comes from
The pricing pages are statically rendered, which is the point: they are marketing pages and they should come off the CDN. A static page cannot read a per-request header, so the middleware publishes the header as a cookie instead:
function setCountryCookie(response: NextResponse, request: NextRequest): NextResponse {
const country = request.headers.get('x-vercel-ip-country');
if (!country) return response;
response.cookies.set(COUNTRY_COOKIE, country.slice(0, 2).toUpperCase(), {
...LONG_LIVED_COOKIE_OPTIONS,
httpOnly: false,
maxAge: 60 * 60 * 24 * 30,
});
return response;
}
Two things in there are deliberate. It is re-set on every request rather than only when missing, so someone who travels stops seeing the currency of the country they left. And it is explicitly not httpOnly, because a blocking inline script in the root layout has to read it to pick the currency before the first paint. It carries a two letter country code and nothing else, so there is nothing in it worth hiding from the page's own JavaScript, and it is only ever written from the platform header, never from anything the client sends.
See it: open cogniprep.app/pricing, then run document.cookie in the console. You will find a cp_country entry with your two letter code, and the prices on the page will be denominated to match it. From the Netherlands the provider unlock reads €18.99.
Where the tax decision comes from
Managed Payments is Stripe acting as the legal seller of record: it calculates, collects, files and remits the indirect tax and carries the liability, in exchange for a fee on top of normal processing. So you do not want it on everywhere. Ours is an allowlist:
export const MANAGED_PAYMENTS_COUNTRIES: ReadonlySet<string> = new Set([
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR',
'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK',
'SI', 'ES', 'SE',
]);
export function shouldEnableManagedPayments(country: string | null | undefined): boolean {
if (!isManagedPaymentsEnabled()) return false;
if (!country) return false;
return MANAGED_PAYMENTS_COUNTRIES.has(country.toUpperCase());
}
The EU-27 are in it because VAT is due in the customer's country from the first sale. The UK is not, because as a UK seller under the VAT registration threshold there is nothing to collect, and switching it on would throw that away. The US is not, because there is no sales tax nexus at this volume. Everything outside the list is off, which costs nothing and charges nobody tax that is not owed.
There is also a decision about how the price relates to the tax:
export const MANAGED_PAYMENTS_TAX_BEHAVIOR: 'inclusive' | 'exclusive' = 'inclusive';
Inclusive means the price on the page is the price on the card. It was exclusive for a while, which meant an EU buyer read one figure in the app and then watched 19 to 27 percent get added at checkout. That was one of the reasons about half of all checkout sessions were expiring unpaid. The cost of inclusive is margin: a £15 unlock sold into Germany nets about £12.61, so anything that pays out a share of a sale has to work from the net figure, not the gross one.
export function netOfTaxRatio(amountTotal, amountTax): number {
if (!amountTotal || amountTotal <= 0) return 1;
if (!amountTax || amountTax <= 0) return 1;
if (amountTax >= amountTotal) return 1;
return (amountTotal - amountTax) / amountTotal;
}
Returning 1 for the no-tax case means non-MoR sales flow through untouched instead of needing a branch at every call site.
The gap
Here is the function that decided the tax routing, as it originally stood:
export function countryFromRequest(request: Request): string | null {
return request.headers.get('x-vercel-ip-country');
}
And here is the shape of the resolver that had already decided the currency: read the header, and if it is missing, fall back to the country cookie.
Those two agree almost always. They disagree in exactly one state: the header is missing but the cookie is present. That is not a hypothetical, it is what you get when IP geolocation fails on one request from a visitor whose earlier request geolocated fine. The earlier request wrote the cookie; this one has no header.
In that state the currency resolver says "NL, quote euros" and the tax router says "unknown country, no MoR". The buyer is quoted in euros, pays in euros, and no VAT is collected on a sale that owes it. Nothing errors. Nothing looks wrong on either side.
The fix is not clever, it is just the recognition that these are not two questions:
export function countryFromRequest(request: Request): string | null {
const header = request.headers.get('x-vercel-ip-country');
if (header) return header;
return countryFromCookieHeader(request.headers.get('cookie'));
}
Same two sources, same order, same answer. If the price was in a euro country's currency, the tax decision saw that country too.
The cookie parser stays paranoid about its input, because that cookie is deliberately readable and therefore writable by page JavaScript:
const value = part.slice(eq + 1).trim();
return /^[A-Za-z]{2}$/.test(value) ? value.toUpperCase() : null;
Anything that is not two letters is discarded. The worst a tampered cookie can do is name a different real country, and the authoritative tax country is still the billing address the customer types on Stripe's own checkout page. The IP signal only picks the coarse on/off routing.
Two things worth stealing
A post-hoc check for the case you cannot prevent. IP geolocation is best effort forever, so the webhook flags completed sales that look wrong rather than pretending they cannot happen:
export function isPossibleMoRMisroute(country, taxAmount): boolean {
if (!country) return false;
if ((taxAmount ?? 0) > 0) return false;
return MANAGED_PAYMENTS_COUNTRIES.has(country.toUpperCase());
}
An EU billing address with zero tax collected gets reviewed. A valid B2B reverse charge also shows zero tax and will occasionally trip it, which is fine, because this is review-only.
A retry that is allowed to strip exactly one field. If Stripe rejects a session because Managed Payments is not activated or the tax code is not eligible, the sale should still go through without MoR. If it rejects because the card or the amount is wrong, it absolutely should not:
if (e.type !== undefined && e.type !== 'StripeInvalidRequestError') return false;
const param = typeof e.param === 'string' ? e.param : '';
if (param === 'managed_payments' || param.startsWith('managed_payments')) return true;
A retry that silently drops a field is a retry that can hide a real failure. Narrowing it to one error type and one parameter name is what makes it safe.
The general version
If two parts of your system independently answer the same question about a request, they are not two decisions, they are one decision with two implementations. Give them the same resolver, or accept that one day they will disagree and the disagreement will be invisible.
You can watch the first half of this working right now: load cogniprep.app/pricing and compare document.cookie against the currency on the cards.
Top comments (0)