Skip to content
Back to blog

What 1,234 Means Depends On Where You Are

Homeprice rewrites every price on a page into your own currency and estimates the tourist tax refund. Almost all of the work is deciding what the number on the screen actually says.

homeprice chrome-extension i18n

Homeprice converts every price on a page into your own currency, and if you are a visitor buying something abroad, it estimates how much tax you get back on the way out.

Converting a number is arithmetic. Working out what the number on the screen means is the product. Three separate ambiguities have to be resolved before an exchange rate is any use at all.

A currency symbol is not a currency

$ is not the dollar. It is the US, Canadian, Australian, Singapore, Hong Kong, New Zealand, and Mexican dollar. kr is Swedish, Norwegian, Danish, or Icelandic. A marker gives you a list of candidates, not an answer.

So the marker resolves through a ladder of page signals, ordered by how much they deserve to be trusted:

  1. An explicit per-site setting the user chose
  2. Currency declared in the page’s own metadata
  3. The site’s domain, mapped to a region
  4. The country in the URL path, because global shops keep one .com and put the country in the address
  5. The page language
  6. Otherwise, the first candidate, marked low confidence

Every result carries the reason alongside the code, so the UI can say site domain or page language instead of asking the user to take it on faith.

One asymmetry in there took a while to get right. An explicit site setting wins even when the marker does not normally stand for that currency, because that is the user telling you directly. Page metadata does not get the same privilege. A shop that declares JPY in its metadata while printing $50 on the page is overwhelmingly more likely to be quoting dollars than yen, so the visible marker keeps its veto.

Word boundaries are a Latin-script assumption

The obvious way to stop kr matching inside krone is a word boundary. That is correct, and it is also the bug.

Thai and Japanese do not put spaces between words. Demanding a boundary around บาท or ¥ throws away real prices:

490.00 บาทต่อชิ้น     the marker runs straight into the next word
価格¥12,000           and the one before it

A single \b is a decision that the whole world separates words with spaces. Most of it does not.

So the guard is applied per marker, based on the script the marker itself starts or ends in:

const SPACED_SCRIPT_START = /^[\p{Script=Latin}\p{Script=Cyrillic}\p{Script=Greek}]/u;

Markers are then split into bounded and free groups at each position, and the two groups get different lookaround. Latin-script markers demand a boundary. Everything else does not. That single split is what keeps all four of these correct at once:

  • PLUS$100 must not read as S$
  • 100 krone must not read as kr
  • 価格¥12,000 must still read as yen
  • 45,000원입니다 must still read as won

1,234 is genuinely ambiguous

Is that one thousand two hundred and thirty four, or is it one point two three four? Both are written that way somewhere.

When both separators appear, it resolves itself. Whichever one comes last is the decimal point, and the other is grouping. 1.234.567,89 and 1,234,567.89 both fall out of that rule with no locale table involved.

The hard case is a single separator followed by exactly three digits, which is exactly where the two conventions collide. Grouping is the right default, with two exceptions:

  • Currencies with three decimal places. Kuwaiti dinar has three, so KWD 1.234 really is 1.234 dinars and not 1,234 of them.
  • An integer part already longer than three digits. 1234.567 has to be a decimal, because a genuinely grouped number that large would have carried an earlier separator too.

Indian grouping needs its own pattern rather than an exception, since 1,23,456.78 groups in twos after the first three and matches nothing written for Western grouping. The number patterns are tried longest first so it wins before the more general one gets a chance.

The refund is on tax that is already in the price

Outside the US the shelf price includes the tax, and that changes the arithmetic more than people expect. The tax inside a gross price is not the price times the rate:

const vatIncluded = amount * (info.vat / (1 + info.vat));

At 20% VAT, a 120 EUR price contains 20 EUR of tax, not 24. Using the wrong one overstates every refund by the rate itself.

Getting that number right still does not give you the refund, because how much of it comes back depends on the scheme:

  • Operator: Global Blue, Planet, or a bank processes it and keeps a cut, so roughly 70% of the tax reaches you
  • In-store: the tax is simply never charged at the till, so you keep all of it
  • None: no tourist refund exists

On top of that, most countries set a minimum spend, denominated in their own currency, which is its own conversion before it can even be compared against.

Too many independent estimates stack up there for the output to pretend to be a quote, so it is presented as an estimate and says so.

Keeping the hard parts out of the DOM

Everything above lives in two pure modules. They take a string and a bag of page signals and return values. Nothing in them touches the DOM; the content script’s job is to supply text and signals and then apply the result.

That is the only reason any of this is verifiable. Thai boundary handling, Kuwaiti decimals, and Hungarian minimum spend are all just function calls in a Node test, and there are 77 of them.

What I took from it

\b encodes an assumption about writing systems. It is right for Latin script and wrong for a large share of the world’s prices. Reach for Unicode script properties before word boundaries.

Ambiguity deserves a confidence level, not a coin flip. Returning the reason next to the answer costs one field and turns an unexplainable guess into something a user can correct.

Trust what the user told you over what the page claims about itself. But not over what the page visibly shows, because the marker on screen is what they are actually looking at.

Tax-inclusive pricing is the default nearly everywhere. If you learned prices in a country that adds tax at the register, every tax calculation you write will be off by a factor of 1 + rate until you notice.

Keep the ambiguous logic pure. The parts that are genuinely hard to get right are the parts you most need to be able to test without a browser.