How to validate extracted invoice totals: rounding, tolerances and tax
The rules I use to check that extracted invoice amounts add up: lines, subtotal, tax rounding, total tax, total and amount due, with code to copy.
On this page
An extraction gives you numbers. Before you post them anywhere, check that they agree with each other. The obvious approach is to allow a cent of difference everywhere. That’s wrong in both directions: it flags correct invoices that round differently, and it lets through errors in places where a cent is too generous. Below are the rules Temlavo uses, with code you can copy. The arithmetic turns out to be the easy part, and the section near the end covers the part that isn’t.
First rule: say when you can’t check
A check has three answers, not two: it adds up, it doesn’t, or it can’t be decided. If a number the equation needs is missing, the answer is “can’t decide”. Temlavo calls that not_evaluable. Never fill in a missing number to make a check pass, and never fail an invoice for a number it doesn’t print.
Use decimals, never floats
In floating point, 0.1 + 0.2 isn’t 0.3. Use decimal arithmetic for money and round only where the invoice rounds. The code below uses cents. For another currency, use its smallest unit; yen have none.
Line items: quantity × unit price
Check a line only when it’s actually priced that way. Tiered pricing, unit conversions and row discounts follow other formulas. First accept an exact match. Then accept the product rounded to the nearest cent, half up or half to even. Temlavo doesn’t accept rounding down, rounding up or truncation on lines.
from decimal import Decimal, ROUND_HALF_UP, ROUND_HALF_EVEN, ROUND_DOWN, ROUND_UP
CENT = Decimal("0.01")
NEAREST = (ROUND_HALF_UP, ROUND_HALF_EVEN)
ANY_DIRECTION = NEAREST + (ROUND_DOWN, ROUND_UP)
def rounds_to(value, target, modes):
return value == target or any(
value.quantize(CENT, rounding=mode) == target for mode in modes
)
def line_ok(quantity, unit_price, line_total):
return rounds_to(quantity * unit_price, line_total, NEAREST)Rows against the subtotal
Add the rows exactly, but compare the sum with an allowance. Each printed amount can differ from the value behind it by up to half a cent, so allow half a cent per row. A unit price printed as 0.42 may really be 0.4167, so a row you calculate yourself needs a little more room. Rows that already include tax can match the total instead of the subtotal, as long as no discount or charge sits between them. Only run this check when you know you have every row.
def rows_ok(row_totals, subtotal, total=None):
allowance = Decimal("0.005") * len(row_totals)
rows = sum(row_totals)
return abs(rows - subtotal) <= allowance or (
total is not None and abs(rows - total) <= allowance
)Tax: accept rounding in any direction
Tax is where strict rules flag correct invoices. Many issuers round tax down, and some take it out of a price that already includes it. So accept the taxable amount × rate rounded to the cent in any direction.
Also accept the tax-inclusive reading. Take a price of 691.34 that includes 10% tax. The tax inside it is 62.849…, which the issuer rounds down to 62.84, leaving a taxable amount of 628.50. Yet 628.50 × 10% is exactly 62.85, so a strict check fails a correct invoice by one cent.
def tax_ok(taxable, rate, tax):
if rounds_to(taxable * rate, tax, ANY_DIRECTION):
return True
# Tax taken out of a price that already includes it
price = taxable + tax
return rounds_to(price * rate / (1 + rate), tax, ANY_DIRECTION)
tax_ok(Decimal("628.50"), Decimal("0.10"), Decimal("62.84")) # TrueIf tax is rounded on each line and then added, it can drift by up to half a cent per line, and per invoice-level discount or charge. Temlavo leaves a difference that small undecided instead of failing it. A bigger difference fails, but only when you have every line. With lines missing, leave it undecided.
Tax lines against the total tax
When an invoice lists every part of its tax, such as state, county and city, the parts should add up to the total tax. Allow half a cent per part, because the total may have been rounded once from unrounded parts. Run this check only when you know the list is complete.
The total
If the subtotal excludes tax: total = subtotal − discount + shipping + other charges + tax. If it already includes tax, don’t add the tax again. On a credit note, a discount reduces the credit, whichever sign it’s printed with. Don’t set a minimum for the total. Withholding, deposits and credits legitimately bring it below the subtotal less its discount.
The amount due
The amount due is what actually gets paid, so check it exactly: amount due = total − payments − credits − withholding + earlier balance + rounding. When nothing is printed between the total and the amount due, they must be equal. Two cases get no verdict:
- A zero balance with no payment printed. The invoice was paid somewhere you can’t see.
- An instalment, deposit request or minimum payment. Only part of the total is due, and the invoice doesn’t print what was deducted.
The same rules in TypeScript
JavaScript has no built-in decimal type, so use a library such as decimal.js (npm install decimal.js). Pass amounts as strings so they never pass through floating point.
import { Decimal } from 'decimal.js';
const NEAREST = [Decimal.ROUND_HALF_UP, Decimal.ROUND_HALF_EVEN];
const ANY_DIRECTION = [...NEAREST, Decimal.ROUND_DOWN, Decimal.ROUND_UP];
const roundsTo = (value: Decimal, target: Decimal, modes: Decimal.Rounding[]) =>
value.equals(target) ||
modes.some((mode) => value.toDecimalPlaces(2, mode).equals(target));
export const lineOk = (quantity: string, unitPrice: string, lineTotal: string) =>
roundsTo(new Decimal(quantity).times(unitPrice), new Decimal(lineTotal), NEAREST);
export const rowsOk = (rowTotals: string[], subtotal: string, total?: string) => {
const allowance = new Decimal('0.005').times(rowTotals.length);
const rows = rowTotals.reduce((sum, row) => sum.plus(row), new Decimal(0));
return (
rows.minus(subtotal).abs().lessThanOrEqualTo(allowance) ||
(total !== undefined && rows.minus(total).abs().lessThanOrEqualTo(allowance))
);
};
export const taxOk = (taxable: string, rate: string, tax: string) => {
const amount = new Decimal(tax);
if (roundsTo(new Decimal(taxable).times(rate), amount, ANY_DIRECTION)) return true;
// Tax taken out of a price that already includes it
const price = new Decimal(taxable).plus(tax);
const share = price.times(rate).dividedBy(new Decimal(rate).plus(1));
return roundsTo(share, amount, ANY_DIRECTION);
};
taxOk('628.50', '0.10', '62.84'); // trueThe hard part isn’t the arithmetic
Every rule above starts with a question the numbers can’t answer:
- Is this line priced as quantity × unit price, or is it tiered, converted or discounted?
- Do you have every line, or was a page dropped?
- Does the subtotal already include tax?
- Does the invoice list every part of its tax?
- What sits between the total and the amount due: a payment, a credit, withholding, or a request for only part of the total?
Get one of these wrong and a correct invoice fails, or a wrong one passes. You can’t answer them by checking which equation fits, because then every check passes by construction. The answer has to come from the invoice itself: its layout, its labels, what it prints and what it doesn’t.
What none of this catches
These checks compare extracted values with each other. They don’t read the invoice again. A number misread the same way everywhere passes every check, and rows that are wrong in ways that cancel out still add up. Consistency is evidence, not proof.
If you’d rather not build this
Temlavo answers those questions from each invoice’s layout and labels, separately from the amounts, and runs a check only when the answer is known. Every check returns pass, fail or not_evaluable, and arithmetic checks show the calculation behind the result. When a blocking check fails, the result is needs_review, so your workflow can hold the invoice for a person instead of posting it. See every rule on Checks, or try one of your own invoices in the playground without an account.
Try the API in the playground
Compare sample invoices with their extracted fields, checks and full API responses. No account needed.
More from the blog
Extract invoices from Claude with the Temlavo MCP server
Connect Temlavo to Claude or another MCP client and ask in plain language: extract this invoice, does it need review, how many pages are left.
36 held-out real invoices: what we measured, and what we didn't
We measured Temlavo on 36 held-out 2020 US TV-station invoices from FCC filings: the method, the numbers with their uncertainty, and what they do not show.
Why I built Temlavo
Temlavo extracts invoice data and tells your code which invoices not to trust. Here is why I built it that way, and what it deliberately does not do.