Skip to content
jccbbb
Go back

Parsing Money From Receipts: Read It From the Right

This summer we put on a hardstyle and hardcore festival just outside Kristianstad, in Skåne in the south of Sweden. It’s called One Vision Festival. We run it as a non-profit association, with a dedicated team on the board putting in their own private time to try to arrange a mini Defqon.1 on home ground.

Arranging a festival is extremely expensive and high risk. So at most events and festivals, in Sweden at least, other associations and dedicated volunteers are involved and help out to build something unique.

For One Vision Festival, over 80 volunteers were involved. It was everything from driving artists, rigging the stage and working the bar to pulling electricity, fetching things and building infrastructure.

Table of contents

Open Table of contents

Eighty volunteers, eighty wallets

With that many volunteers doing that many different kinds of work, it’s unavoidable that situations come up where a volunteer has to make a temporary outlay: pay for something out of their own pocket, for the festival, and get it back later.

Two from this year:

Those are just two small examples. With 80 different volunteers and everything from paper receipts to digital ones, we needed a way to collect all of it properly.

So I built an internal expenses app for the festival, where volunteers could upload or send in their receipts. The app reads the receipt, the board approves it, and the volunteer gets paid back. This post is about one function in that app, and how it managed to be wrong while passing every test we had.

What the app does with a receipt

The pipeline is short. A photo goes through a vision model, a PDF goes through text extraction, and both end up as a JSON object from a Llama model on Workers AI: merchant, date, total, VAT, a confidence score. Then a small TypeScript file, parse.ts, turns that JSON into something the database will accept. The most important function in there is toCents. It takes whatever the model returned for an amount and produces an integer in öre.

Money as integers in the smallest unit is not negotiable. 0.1 + 0.2 !== 0.3 in floating point, and when you owe a volunteer 1 337,69 kr for cable ties, you pay 1 337,69 kr. Not 1 337,68. Not “close enough”.

The problem is what the model hands you. We tell it to copy amounts exactly as printed, and receipts are printed by whoever built the point-of-sale system:

"1 419,00"      Swedish: space for thousands, comma for decimals
"1,419.00"      US: comma for thousands, dot for decimals
"1.419,00"      German/EU: the opposite
"39,5"          one decimal
"1,419"         ambiguous - see below
215.34          a number, because the model ignored the instruction

The first version of toCents handled this the way most parsers do: figure out which character is the decimal separator, rewrite the string into something parseFloat accepts, multiply by 100, round. About thirty lines of branching on “is there a dot”, “is there a comma”, “are there both”, “which one comes last”.

It worked on every receipt we had ever uploaded.

The receipt that came in at 0%

One evening I was going through the queue and a receipt had come in with AI confidence 0% and every field empty. The amounts were right there in the PDF. That turned out to be a separate, dumb bug: the model had returned "confidence": "100" as a string, the code only accepted numbers. Two lines, fixed.

But while I had parse.ts open I read toCents properly for the first time. Claude Code had written it months earlier. I’d skimmed it then, seen green tests, and moved on.

Thirty lines of separator logic for something that, when I said it out loud, was one sentence:

Read the number from the right. The first separator you hit tells you where the decimals start.

So I asked Claude the naive version: wouldn’t it be simpler to strip everything and just count from the right? "1 456,78" becomes 145678. "1,500.43" becomes 150043. "1 433,1" needs one zero padded, "1456" needs two. No parseFloat anywhere.

I expected to be told why that was too simple. Instead we ran both versions side by side.

The comparison

IN               OLD          NEW          SAME?
"1 419,00"       141900       141900       yes
"1,419.00"       141900       141900       yes
"1.419,00"       141900       141900       yes
"39,5"           3950         3950         yes
"1,234,567"      123          123456700    *** DIFF ***
"12.345.678"     1235         1234567800   *** DIFF ***
"215,34 kr"      21534        21534        yes
"-125,50"        -12550       -12550       yes

Twenty of twenty-three cases identical. Two of the three differences were bugs in the old code. An amount with repeated thousands separators and no decimals, "1,234,567", a perfectly normal US-formatted invoice total, was being parsed as 123 öre. The old code replaced every comma with a dot, got "1.234.567", and parseFloat happily read that as 1.234 and stopped.

Nobody had noticed because a festival in Skåne doesn’t get many invoices over 999 dollars with no cents. The tests were green because the tests only contained the cases someone had thought of. The parser was “bulletproof” against exactly the inputs it had been shown.

The algorithm

Here’s what replaced it. It’s shorter than the original, and it never touches a float.

export function toCents(n: unknown): number | null {
  if (n == null) return null;
  if (typeof n === 'number') {
    if (isNaN(n)) return null;
    return Number.isInteger(n) ? n : Math.round(n * 100);
  }
  if (typeof n !== 'string') return null;

  // Right-to-left: find the LAST separator. 0-2 digits after it are
  // the decimals (pad to two); 3+ means it's a thousands separator.
  // Then drop every separator and read the digits directly as öre.
  let s = n.replace(/\s/g, '').replace(/[^\d.,\-]/g, '');
  const negative = s.includes('-');
  s = s.replace(/-/g, '');
  if (!s || !/\d/.test(s)) return null;

  const sepIdx = Math.max(s.lastIndexOf('.'), s.lastIndexOf(','));
  let integerPart = s;
  let decimals = '';
  if (sepIdx !== -1) {
    const after = s.slice(sepIdx + 1);
    if (after.length <= 2) {
      decimals = after;
      integerPart = s.slice(0, sepIdx);
    }
  }
  const digits = integerPart.replace(/[.,]/g, '') + decimals.padEnd(2, '0');
  const ore = parseInt(digits, 10);
  return isNaN(ore) ? null : negative ? -ore : ore;
}

Read top to bottom, the function does three things: clean the input, find the decimals, assemble the öre. Each is a handful of lines, and each line has a reason.

Step 1: clean the input, remember the sign

let s = n.replace(/\s/g, '').replace(/[^\d.,\-]/g, '');
const negative = s.includes('-');
s = s.replace(/-/g, '');
if (!s || !/\d/.test(s)) return null;

At this point we know n is a string, but nothing about what’s in it. The model copies the amount as printed, so "1 419,00", "215,34 kr" and "-125,50" are all things it will hand us.

Two regular expressions do the cleaning. If you don’t read regex every day, here is every character:

/\s/g

PieceMeaning
/ … /The slashes mark the start and end of a regular expression, the same way quotes mark a string.
\sOne whitespace character: a space, a tab, a line break, a non-breaking space.
gThe “global” flag. Without it, replace only touches the first match. With it, every match.

n.replace(/\s/g, '') means: find every whitespace character and replace it with '', the empty string. In other words, delete it.

/[^\d.,\-]/g

PieceMeaning
[ … ]Square brackets make a character class: “one character that is any of these”.
^As the first character inside the brackets it flips the class: “one character that is not any of these”. (Outside brackets ^ means “start of the string”, which is something else entirely.)
\dOne digit, 0 to 9.
.A literal dot. Outside brackets a dot means “any character at all”, but inside a class it’s just a dot.
,A literal comma.
\-A literal hyphen. Inside brackets an unescaped hyphen means a range, like a-z, so it’s escaped with a backslash to mean the hyphen itself.
gGlobal again: every match.

Read together: “one character that is not a digit, a dot, a comma or a hyphen”. With .replace(…, '') after it, every such character is deleted. That’s what removes kr, SEK, € and anything else that isn’t part of a number.

s.includes('-') is not a regex, just a string method. It returns true if there is a hyphen anywhere in the string.

/-/g is the simplest one: a literal hyphen (outside a character class it needs no backslash) with the global flag. s.replace(/-/g, '') deletes every hyphen.

if (!s || !/\d/.test(s)) return null; is the guard, and it has two halves:

Step 2: find the decimals

const sepIdx = Math.max(s.lastIndexOf('.'), s.lastIndexOf(','));
let integerPart = s;
let decimals = '';
if (sepIdx !== -1) {
  const after = s.slice(sepIdx + 1);
  if (after.length <= 2) {
    decimals = after;
    integerPart = s.slice(0, sepIdx);
  }
}

This is the “read it from the right” part. lastIndexOf returns the position of the last dot and the last comma (or -1 if there is none), and Math.max picks whichever sits further to the right. We don’t care whether the winner is a dot or a comma. The only thing that matters is that it’s the rightmost separator, because the rightmost one is the only one that can possibly be a decimal separator.

No regular expressions in this block, only string methods:

CallWhat it does
s.lastIndexOf('.')The position of the last dot in the string, counting from 0. Returns -1 if there is no dot.
s.lastIndexOf(',')Same thing for the comma.
Math.max(a, b)The larger of the two numbers. Since a missing separator gives -1, the one that exists always wins.
s.slice(sepIdx + 1)Everything from the position after the separator to the end of the string.
after.length <= 2True when there are at most two characters after the separator.
s.slice(0, sepIdx)Everything from the start up to, but not including, the separator.

Then the function makes its one decision: count the digits after it.

Notice that the defaults are the “no decimals” case. The code only commits to decimals when it has positively identified them. Every other path falls through to “this is a whole number”.

Step 3: assemble the öre

const digits = integerPart.replace(/[.,]/g, '') + decimals.padEnd(2, '0');
const ore = parseInt(digits, 10);
return isNaN(ore) ? null : negative ? -ore : ore;

One more regex, and two string functions worth spelling out:

/[.,]/g

PieceMeaning
[.,]A character class of two literal characters: a dot or a comma. Inside brackets the dot is just a dot.
gGlobal: every match.

integerPart.replace(/[.,]/g, '') deletes every dot and comma that is still in the integer part. Those can only be thousands separators, because the decimal separator, if there was one, was cut off in step 2.

CallWhat it does
decimals.padEnd(2, '0')Pads the string on the right with '0' until it is 2 characters long. A string that is already 2 characters long comes back unchanged.
parseInt(digits, 10)Reads the string as a whole number in base 10. The 10 says “decimal”, so the digits are never interpreted as anything else.

integerPart may still contain thousands separators ("1.234.567", "1,419"). They’re deleted, leaving only digits. decimals is padded on the right to exactly two characters: "5" becomes "50", "" becomes "00", "69" stays "69". Concatenating the two gives a string of digits that already is the amount in öre: the integer part shifted two places to the left, plus the decimals. parseInt turns it into a number, the sign from step 1 goes back on, done. There’s no multiplication by 100 at the end because the digits were assembled in öre from the start.

The isNaN check shouldn’t be reachable after the guard in step 1, since at least one digit always survives. It stays because the function’s contract is “integer or null”, and I’d rather enforce that at the return statement than trust it from a dozen lines up.

Walking through “1337,69”

A receipt total of 1 337,69 kr, printed without the thousands space, arrives as the string "1337,69".

input             "1337,69"
strip whitespace  "1337,69"    nothing to remove
strip junk        "1337,69"    nothing to remove
negative          false        no hyphen anywhere
strip hyphens     "1337,69"
guard             has digits, continue

lastIndexOf('.')  -1           no dot
lastIndexOf(',')   4           the comma sits at index 4
sepIdx             4
after             "69"         s.slice(5), two characters
decimals          "69"         two digits or fewer, so these are decimals
integerPart       "1337"       s.slice(0, 4)

digits            "1337" + "69" = "133769"
ore               133769
return            133769       not negative

133769 öre is 1 337,69 kr. Correct, and no floating point in sight.

The same total printed the Swedish way, "1 337,69 kr", only differs in step 1: the first two replaces turn it into "1337,69" and everything after that is identical. With one decimal, "1337,6", the decimals are "6", padded to "60", and the result is 133760. As a refund, "-1337,69", negative is true and the result is -133769.

The one assumption

The reason this works comes down to a single fact: a thousands group is always exactly three digits. That means the two possible readings of a separator, “decimals follow” or “a thousands group follows”, can only collide in one case.

Digits after the last separatorCould be decimals?Could be thousands?Decided by
0 - "1000,"yesnomechanics
1 - "1000,1"yesno, groups are 3mechanics
2 - "1000,10"yesnomechanics
3 - "1,419"technicallyyesthe assumption
4+ - "12,3456"no, max 2no, groups are 3broken input

Rows 0, 1, 2 and 4+ are pure arithmetic. There’s no alternative reading, so there’s nothing to decide. The only cell where both readings are legal is exactly three digits after the last separator, and there a domain rule settles it: a printed money total never has three decimals. The app accepts SEK, EUR, USD, NOK and DKK, all of which have two. Inside the app’s own universe the rule holds.

That’s an assumption, not a guess. A guess is a choice without grounds. An assumption is a rule you can write down, test, and list the exceptions to. And for the receipts we had, we knew nothing strange was coming. Everyone involved came from Sweden, Denmark or Norway, so what we got was paper receipts from Sweden and Denmark, and maybe the odd PDF from Denmark, Norway or Sweden. All three currencies have two decimals. A three-decimal currency like the Kuwaiti dinar was never going to show up in that pile, and we only parse totals, never unit prices like a fuel receipt’s 20,489 kr/l. That’s the difference between code you’re afraid of and code you trust: you know exactly what would break it, and you know it isn’t in your pile.

Isn’t there a library for this?

I asked that too. The honest answer is yes and no.

Formatting numbers per locale is a solved problem: CLDR, ICU, Intl.NumberFormat. But those go one direction, number to string, given a known locale. There’s no Intl.parseNumber in the standard, and the libraries that do parse all require you to tell them the locale first.

A receipt doesn’t tell you its locale. "1,419" is one thousand four hundred and nineteen in en-US and one-point-four-one-nine in sv-SE, and the string carries no way to know which. A Swedish receipt printed by a Dutch point-of-sale system can use dots. So the library solves the deterministic half, and the part that requires knowing what kind of document this is (receipts, totals, two-decimal currencies) stays in application code, ideally as one small, documented function. That’s what toCents is.

Tests as a table of contents

The old tests were green and the code was wrong, so the fix wasn’t “more tests”. It was tests that read as a catalogue of everything the function promises. The cases live in one data file that both the test runner and a visual HTML report consume, so they can never disagree:

{ group: 'toCents · Swedish formats',
  should: 'thousands space + two decimals',
  input: '"1 419,00"', run: () => toCents('1 419,00'), expected: 141900 },
{ group: 'toCents · the assumption',
  should: 'three digits after comma = thousands',
  input: '"1,419"', run: () => toCents('1,419'), expected: 141900 },
{ group: 'toCents · bugs the old parseFloat version had',
  should: 'repeated thousands commas (used to give 123!)',
  input: '"1,234,567"', run: () => toCents('1,234,567'), expected: 123456700 },

Run the suite and the output is the specification: thirty-four named lines, grouped by what they prove. You can learn what the parser does by reading test names, without opening the implementation. That’s what I want from every test file now. Not just “does it pass” but “can a person read this and know what the code promises”.

What I actually learned

The volunteer who bought the cable ties doesn’t care about any of this. They care that the amount on their receipt is the amount that lands in their account. That’s the whole job of toCents, and it took a festival, eighty volunteers and one 0% confidence score to make me read it properly.

How I work with the agent day to day, and why reading the diff is the part I don’t skip, is in My AI-Assisted Development Workflow.


Share this post:

Previous Post
WezTerm: Open a Remote tmux Directory in Windows Explorer
Next Post
How I Rebuilt Coretours on 100% Cloudflare