BlogExtraction7 min read

1.234,56 is not 1.234

Half the world swaps the comma and the full stop. An extractor that assumes one convention silently divides some invoices by a thousand.

An invoice from a German supplier reads 1.234,56. That is one thousand two hundred and thirty-four euros and fifty-six cents.

Parsed with a US convention, the comma is a thousands separator and the full stop is a decimal point, so 1.234,56 becomes 1.234 — or throws, or becomes 1234.56 by accident, depending on which library and which fallback. One of those outcomes is a payable off by a factor of a thousand.

Every other extraction bug produces a number that is visibly odd. This one produces a number that looks like an ordinary invoice for a smaller amount.

The two conventions

Roughly half the world's economies use each:

StyleThousandsDecimalWrittenUsed in
Anglo,.1,234.56US, UK, Ireland, Australia, most of Asia
Continental.,1.234,56Germany, France, Spain, Italy, Brazil, most of Latin America
Spacethin space,1 234,56France (official), Nordics, Poland, Russia
Indian, (2-2-3).12,34,567.89India, Bangladesh, Pakistan

The last row is the one people forget entirely. Indian grouping puts separators every two digits after the first three, so 1,23,456 is 123,456. A regex that assumes three-digit groups rejects it or mangles it.

When you can tell, and when you cannot

Some strings are unambiguous. Most of the dangerous ones are not.

Resolvable. 1,234.56 has both separators, and their order settles it — the last one is always the decimal. 1.234,56 likewise. Any string containing both characters is safe.

Resolvable by digit count. 1,2345 cannot be Anglo thousands, because thousands groups are exactly three digits. 1.234 could be either: 1234 continental, or 1.234 Anglo.

Genuinely ambiguous. 1.234 and 1,234 on their own. Both are real numbers under both conventions, three orders of magnitude apart. Nothing in the string resolves it.

That last category is where the money is lost, and it is common — round-thousand amounts are everywhere in invoicing.

Parse defensively, then use the document

type Amount =
  | { kind: "resolved";  minor: bigint; currency: string; basis: string }
  | { kind: "ambiguous"; candidates: [bigint, bigint]; raw: string }

/**
 * Money is stored in minor units (cents) as an integer. Never a float —
 * 0.1 + 0.2 !== 0.3 and an accounts ledger is the worst place to discover
 * binary floating point.
 */
function parseAmount(raw: string, ctx: DocContext): Amount {
  const s = raw.replace(/\s| | /g, "")  // normal, nbsp, narrow nbsp
  const lastComma = s.lastIndexOf(",")
  const lastDot   = s.lastIndexOf(".")

  // Both present: the rightmost separator is the decimal point.
  if (lastComma !== -1 && lastDot !== -1) {
    const decimalSep = lastComma > lastDot ? "," : "."
    return resolved(toMinor(s, decimalSep), ctx, "both-separators")
  }

  const sep = lastComma !== -1 ? "," : lastDot !== -1 ? "." : null
  if (sep === null) return resolved(BigInt(s) * 100n, ctx, "integer")

  const tail = s.slice(s.lastIndexOf(sep) + 1)

  // A group of exactly 3 digits after the only separator is the ambiguous
  // case: it is a valid thousands group AND a valid (if unusual) fraction.
  if (tail.length === 3) {
    return {
      kind: "ambiguous",
      candidates: [toMinor(s, sep), BigInt(s.replace(sep, "")) * 100n],
      raw,
    }
  }

  // 2 digits after a separator is a decimal fraction in every convention.
  // 1, or 4+, cannot be a thousands group either.
  return resolved(toMinor(s, sep), ctx, `tail-${tail.length}`)
}

The ambiguous branch is not a failure. It is a question, and the document usually answers it — which is why the parser takes a context.

What resolves the ambiguous case

In order of strength:

Another amount on the same document. Invoices contain several numbers. If any one of them is unambiguous — a line item at 1.234,56, a tax line at 19,00 — the convention is fixed for the entire document. Issuers do not switch conventions mid-page. Resolve the whole document in two passes: settle what you can, then apply the settled convention to the rest.

The arithmetic. This is the strongest signal available and it is free. Line items should sum to a subtotal, and subtotal plus tax should equal the total. Try both interpretations and keep the one where the sum works:

// Whichever reading balances is the correct reading. A wrong convention is
// off by 1000x on some fields and not others, so it never balances by chance.
const balanced = candidates.filter((c) => sumsCorrectly(doc, c))
if (balanced.length === 1) return balanced[0]

The currency and the locale. EUR with a German address is continental. This is a good prior and a bad rule on its own — plenty of German companies invoice international customers using Anglo formatting, and accounting software often formats to the recipient's locale rather than the issuer's.

The supplier record. Once determined, store it. Every subsequent document from that supplier starts resolved.

Two adjacent traps

The currency symbol is not the currency. $ is USD, CAD, AUD, NZD, HKD, SGD, MXN and about a dozen more. kr is four different Nordic currencies. Never infer ISO 4217 from a glyph alone — use the issuer's country, an explicit code elsewhere on the document, or refuse. A CAD invoice booked as USD is a 30% error that arithmetic will never catch, because the numbers are internally consistent.

Negatives and credits. (1.234,56), -1.234,56, 1.234,56 CR and 1.234,56- (trailing minus, common in SAP exports) all mean the same thing. Handle the parenthesis and trailing forms explicitly or a credit note posts as a charge — sign errors and magnitude errors have the same root cause here, which is trusting a numeric string to mean one thing.

The general rule is the one from a schema that fails loudly: a bare number is not a type, it is a hope. Money carries an integer minor unit and an explicit currency, and a parser that cannot produce both says so instead of guessing.


More in when extraction accuracy collapses, 03/04/2026 is two different dates, and how we build extraction pipelines.

Something here

the audit is the cheapest way to find out for certain.