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:
| Style | Thousands | Decimal | Written | Used in |
|---|---|---|---|---|
| Anglo | , | . | 1,234.56 | US, UK, Ireland, Australia, most of Asia |
| Continental | . | , | 1.234,56 | Germany, France, Spain, Italy, Brazil, most of Latin America |
| Space | thin space | , | 1 234,56 | France (official), Nordics, Poland, Russia |
| Indian | , (2-2-3) | . | 12,34,567.89 | India, 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.