DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Nine countries pay more for our product, and the entire uplift is one constant worth £2.50

CogniPrep's list prices used to be one table. As of this week they are three: standard, higher and india. The India one is the interesting engineering problem and I wrote about it separately, because a region priced below standard has to be checked against the buyer's card. This post is about the boring one, which turned out to have all the judgement calls in it.

Nine countries, and two conditions both have to hold

/**
 * Countries on the higher prices: those where both the price level and income
 * per head are above the UK's (World Bank, 2025 figures, checked 2026-11-02).
 */
export const HIGHER_PRICE_COUNTRIES: ReadonlySet<string> = new Set([
  'US', // United States
  'CH', // Switzerland
  'NO', // Norway
  'IS', // Iceland
  'DK', // Denmark
  'LU', // Luxembourg
  'FO', // Faroe Islands
  'BM', // Bermuda
  'KY', // Cayman Islands
]);
Enter fullscreen mode Exit fullscreen mode

Two conditions, both required: price level above the UK's, and income per head above the UK's. The second alone is the obvious filter and it gives the wrong answer. The Netherlands and Ireland both earn more per head than the UK and both have a lower price level, so a price that reads as ordinary in London reads as expensive in Rotterdam. They stay standard. Australia, Canada and New Zealand are at or below the UK on one of the two, so they stay standard as well, which surprised me enough to write the reason into the source rather than leave the list looking arbitrary.

The comment above that set carries a date, because it is a claim about the world and not about the code. Anything in a codebase that quotes an external figure should say when somebody last looked.

The region is keyed on the country, not on the currency

The temptation is to hang the higher prices off the currency, because the currency is already resolved for display. It is wrong in both directions. The US dollar is also the currency of Ecuador, El Salvador and Panama. The euro covers most of the EU. Keying on either would sweep in countries the higher prices were never meant for.

So the resolver takes a country and nothing else:

export function priceRegionForCountry(country: string | null | undefined): PriceRegion {
  if (!country) return STANDARD_PRICE_REGION;
  const code = country.trim().toUpperCase();
  if (code === INDIA_COUNTRY) return 'india';
  return HIGHER_PRICE_COUNTRIES.has(code) ? 'higher' : STANDARD_PRICE_REGION;
}
Enter fullscreen mode Exit fullscreen mode

Unknown, empty and malformed input all land on standard. There is no "unplaceable" branch anywhere above this, because every caller wants a region and none of them wants to handle a null.

There is a test for exactly the mistake I was tempted by:

it('keys on the country, not the currency it shares', () => {
  for (const country of ['PR', 'EC', 'SV', 'PA', 'LI']) {
    expect(priceRegionForCountry(country), country).toBe(STANDARD_PRICE_REGION);
  }
});
Enter fullscreen mode Exit fullscreen mode

Puerto Rico, Ecuador, El Salvador and Panama for the dollar, Liechtenstein for the Swiss franc.

The whole uplift is one number, and it is capped

/**
 * What the higher region adds to each price it raises, in pence.
 *
 * Capped at £2.50: the higher region should never pay more than that over the
 * standard price for anything. One flat step on every raised price also keeps
 * each bundle the same distance below buying its two parts separately.
 */
export const HIGHER_REGION_UPLIFT = 250; // £2.50
Enter fullscreen mode Exit fullscreen mode

A percentage would have been the default choice and it is worse here. A flat step is defensible in a sentence to a buyer, it cannot drift into a strange figure when a base price changes, and it preserves the arithmetic relationship between the products. Our bundle is £5 below buying its two parts separately in the standard region. Add the same £2.50 to the unlock and to the bundle and it is still exactly £5 below in the higher region. Add 15 percent to everything instead and that £5 becomes £5.75 for no reason a buyer would ever be told.

The table ends up readable, which is the real test of a pricing model:

higher: {
  provider: PROVIDER_TOKEN_PRICE + HIGHER_REGION_UPLIFT,                       // £17.50
  smallProvider: SMALL_PROVIDER_TOKEN_PRICE + HIGHER_REGION_UPLIFT,            // £12.50
  providerBundle: PROVIDER_SCORES_BUNDLE_PRICE + HIGHER_REGION_UPLIFT,         // £22.50
  smallProviderBundle: SMALL_PROVIDER_SCORES_BUNDLE_PRICE + HIGHER_REGION_UPLIFT, // £17.50
  premiumUpgrade: PREMIUM_UPGRADE_PRICE,
  interviewAccess: INTERVIEW_ACCESS_PRICE + HIGHER_REGION_UPLIFT,              // £7.50
  assessmentCentre: ASSESSMENT_CENTRE_PRICE + HIGHER_REGION_UPLIFT,            // £17.50
},
Enter fullscreen mode Exit fullscreen mode

One product is deliberately left out of the uplift

premiumUpgrade is the same £10 in every region above standard. It is the upgrade that unlocks full scores and feedback, and rival platforms include scores in their base price rather than selling them separately. Charging more for it in the US would widen a comparison that already counts against us. That is a commercial decision, not an engineering one, but it has to survive contact with the code, and the way it does that is a test:

it('charges the higher region more for everything but Premium', () => {
  for (const product of PRODUCTS) {
    const standard = REGIONAL_PRICES.standard[product];
    const higher = REGIONAL_PRICES.higher[product];
    if (product === 'premiumUpgrade') {
      expect(higher, product).toBe(standard);
    } else {
      expect(higher, product).toBeGreaterThan(standard);
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

An exception with no test is a bug waiting for whoever regenerates the table.

Fifteen tests and almost no price figures in them

The test file imports the table and derives its own loops from it:

const REGIONS = Object.keys(REGIONAL_PRICES) as PriceRegion[];
const PRODUCTS = Object.keys(REGIONAL_PRICES.standard) as (keyof RegionalPrices)[];
Enter fullscreen mode Exit fullscreen mode

Which means a fourth region, or an eighth product, is covered by every property in the file the moment it exists. The four properties that matter:

it('only prices a card-checked region below standard', () => {
  for (const region of REGIONS.filter((r) => !CARD_COUNTRY_FOR_REGION[r])) {
    for (const product of PRODUCTS) {
      expect(pricesFor(region)[product]).toBeGreaterThanOrEqual(
        REGIONAL_PRICES.standard[product]
      );
    }
  }
});

it('never raises a price by more than £2.50 over standard', () => { /* ... */ });

it('keeps the bundle the same saving below buying separately, in every region', () => {
  for (const region of REGIONS) {
    const savings = ALL_PROVIDERS.map(
      (p) =>
        getProviderPrice(p, region) +
        pricesFor(region).premiumUpgrade -
        getProviderBundlePrice(p, region)
    );
    expect(new Set(savings).size, region).toBe(1);
    expect(savings[0], region).toBeGreaterThan(0);
  }
});
Enter fullscreen mode Exit fullscreen mode

That last one is my favourite assertion in the repository. It does not care what the saving is, only that there is exactly one of it across every provider in a region and that it is positive. A bundle that costs more than its parts, or that saves £5 on one provider and 50p on another, fails it without anyone having to maintain a list of expected numbers. In the standard and higher regions the single value is £5. In the India region it is £1.50.

The first property is the load-bearing one. It says a region may only be priced below standard if it appears in CARD_COUNTRY_FOR_REGION, the table of regions whose price is verified against the buyer's card at payment time. Adding a cheap region without that enforcement is not a judgement call anyone gets to make quietly; it is a failing test.

Crawlers are charged nothing and quoted pounds

The region and the currency are resolved from the same country, by the same function, which is also called by the checkout routes. That is what makes the page and the charge agree.

Crawlers are the exception. A crawler is geolocated wherever its datacentre is, so Googlebot in Iowa would index the United States prices, in dollars, for a business that bills in pounds. Bot user agents resolve to a null country, which means GBP and the standard region.

See it

cogniprep.app/pricing renders per request, so what it says depends on who asks. Ask as a crawler:

curl -s -A "Googlebot/2.1" https://cogniprep.app/pricing | grep -o '£[0-9][0-9.]*' | sort | uniq -c
Enter fullscreen mode Exit fullscreen mode

I get four £15, three £10, one £5 and one £20: the standard table, in the currency the money actually settles in.

Now ask with your own user agent and your own IP. I am in the Netherlands, so I get €18.99, €12.99, €6.99 and €24.99, which is the standard region converted, because the Netherlands is one of the two countries in the comment above that look like they belong on the higher prices and deliberately do not.

You can also see the country the request was placed in, on both of those calls:

curl -sD - -o /dev/null https://cogniprep.app/pricing | grep -i set-cookie
Enter fullscreen mode Exit fullscreen mode

cp_country=NL, for me. Worth noticing that the crawler fetch sets that cookie too: the cookie records where the request came from, and the decision to ignore it for a bot happens one layer up, in the price resolver, not at the point the country is read. If you are in the US, Switzerland, Norway, Iceland, Denmark, Luxembourg, the Faroes, Bermuda or the Cayman Islands, the figures you get are £2.50 up on mine, converted, on everything except the Premium upgrade.

The part I cannot test

The source carries one more comment, under the country list:

Denmark and Luxembourg are in the EU, and Norway and Iceland in the EEA. The
EU geo-blocking regulation (2018/302) expects a buyer from one member state to
be able to buy at the price offered in another, and nothing here offers that.
Enter fullscreen mode Exit fullscreen mode

I would rather have that written down next to the list than discovered later by someone reading the regulation. Nothing in the code can resolve it, and a future version of that set may well be shorter because of it.

Top comments (0)