Reconciling an Invoice Total Against Its Own Line Items
10 min read · updated August 11, 2026
An invoice states its own answer twice: once as a list of lines and once as a total. That redundancy is the most valuable property the document has, because it lets you check an extraction without a label, a benchmark or a human.
The chain the invoice must satisfy
EN 16931, implemented by Peppol BIS Billing 3.0, defines the document totals as a chain of business terms rather than a single sum. Written as arithmetic:
BT-106 sum of invoice line net amounts = Σ BT-131 over all lines
BT-109 invoice total amount without VAT = BT-106 - BT-107 + BT-108
BT-110 invoice total VAT amount = Σ VAT category amounts
BT-112 invoice total amount with VAT = BT-109 + BT-110
BT-115 amount due for payment = BT-112 - BT-113 + BT-114
where BT-107 = sum of document-level allowances
BT-108 = sum of document-level charges
BT-113 = paid amount
BT-114 = rounding amountAdopt this even for scanned PDFs that will never be UBL. It gives every extracted amount a place, and it makes the two most common extraction errors structurally impossible to hide: a document-level allowance double-counted at line level breaks the BT-106 equality, and a freight charge miscoded as goods breaks it in the opposite direction.
BT-114 deserves a note. A rounding amount exists so that an invoice can be rounded to a convenient payable figure — to the nearest five cents in some jurisdictions, or to whole units. It is a real field, and treating an unexplained small difference as a rounding amount rather than an error is legitimate only when the invoice actually states one. If it does not, an unexplained difference is an error.
Do the arithmetic in minor units
Reconciliation in floating point does not work, and it fails in a way that looks like a data problem. In IEEE-754 binary floating point, 0.1 + 0.2 is not 0.3; accumulate forty line totals and the residue is reliably non-zero. Teams then widen the tolerance to hide it, and the widened tolerance swallows real one-cent errors.
So parse to integers immediately. The number of decimal places comes from the currency’s ISO 4217 minor unit — two for most, none for the Japanese yen, three for several Gulf currencies — and not from how the document happens to print the number. The details of that, and of the decimal-comma problem, are in extracting multi-currency line items.
function toMinor(printed: string, minorUnits: number): number {
// "1.234,56" or "1,234.56" -> 123456 (minorUnits = 2)
const cleaned = printed.replace(/[^\d.,-]/g, "");
const lastDot = cleaned.lastIndexOf(".");
const lastComma = cleaned.lastIndexOf(",");
const sepAt = Math.max(lastDot, lastComma);
const looksDecimal =
sepAt !== -1 && cleaned.length - sepAt - 1 === minorUnits;
const digits = looksDecimal
? cleaned.slice(0, sepAt).replace(/[^\d-]/g, "") +
cleaned.slice(sepAt + 1)
: cleaned.replace(/[^\d-]/g, "") + "0".repeat(minorUnits);
return Number(digits);
}Note that the separator is decided positionally, by whether the trailing group has exactly the currency’s minor-unit length. That handles 1.234,56, 1,234.56 and 1 234,56 without a locale setting, and it is why the currency has to be resolved before amounts are parsed rather than after.
A rounding budget, not an epsilon
Once everything is an integer, exact equality is nearly right — but not quite, because the invoice itself rounds. Peppol’s own guidance is that document-level amounts and line net amounts are rounded to two decimals, and that results computed from already-rounded amounts are not rounded again. Every rounding is a potential half-minor-unit of disagreement with your recomputation.
So derive the tolerance from the structure rather than picking a number. For the line-sum check, the budget is one minor unit per rounded line: the printed line net was rounded, and so was your quantity-times-price recomputation. For the VAT check, the budget depends on whether the supplier rounds VAT per line or per rate — per line, the budget is one minor unit per line in that rate group; per rate, it is one for the whole group. Both practices exist, and a supplier that switches between them will show up as a stable, small, one-directional drift.
const lineSumTolerance = lines.length; // minor units const vatTolerance = perLineRounding ? linesInRate : 1; const totalTolerance = 1; // BT-112 from two rounded inputs
A discrepancy inside the budget is rounding. A discrepancy of exactly one line’s net amount is a classification error. A discrepancy equal to a rate times a base is a tax error. A discrepancy of a factor of ten or a hundred is a decimal-separator or minor-unit error. The size of the difference is itself diagnostic, and reporting it beats reporting a boolean.
A worked failure
Take an extraction that produced these figures, all in minor units of EUR:
lines: 1) 174000 2) 42000 3) 8500 stated goods subtotal 216000 stated document allowance 15000 stated net total 209500 stated VAT (21%) 43995 stated total due 253495 Σ lines = 224500 Σ lines - goods subtotal = 8500 ← exactly line 3 BT-109 check: 224500 - 15000 = 209500 ✓ matches stated net total VAT check: 209500 × 0.21 = 43995.0 ✓ within 1 minor unit BT-112 check: 209500 + 43995 = 253495 ✓ matches stated total due
Every headline check passes, and the extraction is still wrong: line 3 is a carriage charge that was classified as goods. Only the intermediate subtotal catches it, and the difference is exactly the amount of the misclassified line, which names the culprit outright. This is why the reconciliation should include every printed subtotal rather than only the fields the schema requires — the argument is worked in full in separating freight and handling from goods.
The mirror-image case is an allowance recorded at both line and document level, where the line sum comes out low by exactly the allowance amount. Same diagnostic, opposite sign, and the fix is in extracting line-item discounts.
The check, step by step
- Resolve the currency for every amount first, then convert each printed amount to integer minor units using that currency’s minor-unit count. Keep the printed string alongside.
- Recompute each line: quantity times unit price, minus line allowances, plus line charges. Compare to the printed line net with a tolerance of one minor unit. Flag lines individually — you want to know which one.
- Sum line nets and compare against every printed subtotal, not just the net total. Record the difference as a signed integer.
- Apply document allowances and charges to reach BT-109, and compare against the stated net total.
- For each VAT rate group, check base times rate against the stated tax amount within the rounding budget, then sum the groups to BT-110. Remember that BT-110 may legitimately be in a different currency from BT-109 under Article 230 of the EU VAT Directive.
- Check BT-112 and, if a paid amount or rounding amount is stated, BT-115. An invoice with a paid amount is a partially settled document and the payable figure is not the total.
- Emit the failure with its arithmetic, not a boolean. “Line sum exceeds goods subtotal by 8500 EUR minor units, equal to line 3” is a review item somebody resolves in seconds, especially if the queue highlights the source region; “reconciliation failed” is a ticket.
Because these assertions need no ground truth, they run on every document in production forever, which makes them a far better regression signal than a fixed evaluation set. A rise in the reconciliation failure rate is the earliest warning you will get that a supplier changed a template or that a model update changed behaviour — the same argument as catching a silent model update.