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.

Code example
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.

Code example
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.

Code example
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"))  # True

If 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.

Code example
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'); // true

The 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.

  • validation checks
  • invoice extraction

Try the API in the playground

Compare sample invoices with their extracted fields, checks and full API responses. No account needed.

Open the playground