Skip to main content

Prices, currencies and country

We sell the same plan at different prices in different countries. That makes "which country is this buyer in" a money question: get it wrong and people pay the cheapest country's price. Read this before you show a price, add a currency, or touch a header that carries a country.

How it works​

Every arrow is a trust step. Break any link and the request is refused, never "use the browser's value" and never "fall back to the cheapest country".

How to build it​

One price list per plan, by country​

  • A plan has a versioned price list: one row for "everyone else" and one row per country, each in its own currency.
  • A new price applies to new purchases only. Existing agreements and mandates keep their amount.
  • Routing picks the rail. Prices belong to the plan. We once tied price to the routing rule, and every routing change became a pricing change.
  • Store rails return no price from us. The app shows the store's own localised price.
  • A strike-through "was" price is display only, and never shown on store rails.

Money in minor units, with the right exponent​

const EXPONENT: Record<string, number> = { JPY: 0, KWD: 3, BHD: 3, OMR: 3 }; // others: 2
const exp = EXPONENT[currency] ?? 2;
const display = (amountMinor / 10 ** exp).toFixed(exp);

Use Intl.NumberFormat for the symbol and grouping. Do not hard-code a symbol: the new UAE dirham sign (U+20C3) is missing from most phone fonts, so show the code until fonts catch up.

When pricing is unavailable​

Show no price and a "coming soon" state. A wrong price is worse than none. Never fall back to hard-coded prices in one currency.

Cache the public price list for a few minutes, and cache errors for about 30 seconds, so a pricing outage does not become a flood.

Rules​

Rule

The country of purchase is decided on the server, from the CDN's viewer-country header, through a chain of secrets. A header the browser can set is never trusted. On dev and staging without a CDN, turn header trust off explicitly.

Rule

An unknown or untrusted country is refused (403). It never defaults to a market. A default to the cheapest market is an arbitrage path.

Rule

Pin the country, rail and currency on the subscription at purchase. Upgrades and renewals use them, even if the user travels or uses a VPN. A VPN cannot be stopped by geo-location; pinning limits what it can do.

Mistakes we made​

  • Buyers could choose their own price. The website proxy forwarded browser-set CDN headers on dev and staging, where there is no CDN. Fixed with an explicit TRUST_CDN_HEADERS switch that is off unless the CDN is really in front.
  • An untrusted checkout fell back to the cheapest country. Now a 403.
  • 30.000 KWD showed as KWD 300.00. The code divided every amount by 100.
  • When the pricing service was down, old hard-coded prices came back in the wrong currency. Now no price is shown.
  • The "was" price and caption stayed in rupees on a dirham card. Every price-shaped string on a card must come from the same price row.
  • An open checkout was reused after the buyer switched country, so they paid the first country's price on the second country's gateway. Checkout reuse now requires the same price.

Checklist​

  • The country comes from the CDN through the secret chain; browser headers are ignored.
  • Untrusted or missing country is refused, never defaulted.
  • Prices are versioned per plan and country, in minor units.
  • Display uses the currency's real exponent.
  • Pricing outages show no price.
  • Country, rail and currency are pinned on the subscription.