Skip to content

Writing a Cross-Field Validation Rule That Compares Two Extracted Amounts

10 min read · updated August 11, 2026

The model read three numbers off an invoice independently: a subtotal, a tax amount and a total. Arithmetic has to hold between them, which means you have a free correctness check on all three at once. Most implementations of that check reject a large fraction of perfectly good invoices, because the identity they test is not the identity the document is using.

The identity you are really testing

The rule everybody writes first is subtotal + tax === total. On real commercial invoices this is wrong more often than it is right, because a total is normally the end of a longer chain: line items sum to a net amount, a document-level discount comes off, freight and handling go on, tax is applied to some subset of those, and only then do you have a total. A vendor that prints a “subtotal” line may mean the pre-discount net, the post-discount net, or the taxable base, and those are three different numbers.

So the first job is to decide which identity you are asserting, and there are usually two worth having:

  • The footing identity. The extracted line items sum to the extracted net. This is the stronger of the two, because it has as many degrees of freedom as there are lines — a single misread digit anywhere in the table breaks it, and a coincidental pass is very unlikely.
  • The settlement identity. net − discount + freight + tax === total, with any term allowed to be absent. This one is weak on a two-line invoice and strong on nothing, but it is the identity that governs the number you are about to pay, so it earns its place.

Write both, report them separately, and never collapse them into a single boolean. When only the footing identity fails you know the error is inside the table; when only the settlement identity fails you know it is in one of the four summary fields. That distinction is what makes the failure actionable in a review queue that points at a location rather than at a document.

Compare integers, never floats

Parse every amount into an integer count of the currency’s minor unit and do all the arithmetic there. Binary floating point cannot represent 0.1, so a sum of a dozen cent-denominated line items accumulates a residue that has nothing to do with the document, and your tolerance ends up absorbing your own arithmetic instead of the vendor’s rounding.

The number of minor units per major unit is not always two. ISO 4217, published by ISO, assigns a minor-unit exponent per currency: JPY and KRW have none, and BHD, KWD, OMR and TND have three, so a Kuwaiti invoice’s smallest unit is a thousandth of a dinar. A validator that assumes two decimal places will multiply a JPY amount by 100 and then complain that nothing balances. Check the currency code before you scale, against ISO’s own 4217 listing.

Parsing to minor units is also where you catch the two classic numeric-extraction failures, so do it in one place and make it strict. A European decimal comma read as a thousands separator turns 1.234,56 into either 1234.56 or 1.23456 depending on which convention the parser assumed — both are plausible numbers, and only one is four orders of magnitude wrong. A credit line printed as (1,250.00) is negative in accounting notation and positive to anything that strips punctuation. Neither is a model failure; both arrive as clean strings and become wrong during your own normalisation.

Deriving the tolerance

Now the interesting part. Two amounts that should be equal will differ by small amounts on a large minority of documents, and the size of the allowed difference is derivable rather than a matter of taste.

Rounding happens where the vendor’s billing system chose to put it. If tax is computed and rounded per line, each of n taxable lines contributes an error of at most half a minor unit relative to the unrounded value, so the sum of the rounded line taxes differs from the tax on the rounded subtotal by at most n / 2 minor units in the worst case. Ten lines therefore justify a five-cent tolerance and one line justifies one cent. If tax is computed once on the subtotal, the bound collapses to a single minor unit and anything larger is a real discrepancy.

You usually do not know which method the vendor used, so take the worst case that the document supports: count the extracted line items, and set the tolerance from that count. This is the whole argument for extracting the table even when you only care about the total — the line count is what lets the tolerance be tight on a two-line invoice instead of uniformly slack on everything.

Make the tolerance absolute, in minor units, and never a percentage. A 0.1% tolerance sounds conservative and permits a €2,400 error on a €2.4m invoice, which is precisely the invoice you built the check for. If you must scale with size, scale with the line count, because that is what the rounding actually scales with.

Tax-inclusive pricing changes the equation

On a VAT-inclusive invoice — common in retail and across much of the EU consumer trade — the line prices already contain the tax. The document may still print a “VAT” figure, but it is extracted from the total rather than added to it. The identity is now total === subtotal, with the tax satisfying tax = round(total × r / (1 + r)) for rate r.

Apply the additive rule to that document and every single invoice fails by exactly the tax amount, which is a spectacular false-positive rate and an easy one to misread as an extraction problem. The detection is cheap: if subtotal + tax exceeds total by approximately the tax itself, you are looking at inclusive pricing, not a misread digit. Some documents say so in words — “prices include VAT”, “TTC”, “incl. BTW” — and that phrase is worth extracting as a field of its own precisely so the validator can branch on it instead of guessing.

Multiple tax rates on one document break the single-rate check the same way. A grocery invoice with a zero-rated and a standard-rated block has a tax total that no single rate reproduces. If you cannot extract the per-rate breakdown, do not assert a rate-derived identity at all — assert only the additive one, and record that the stronger check was unavailable rather than recording that it passed.

Wrong extraction or wrong document?

A cross-field rule tells you the numbers disagree. It does not tell you whose fault that is, and vendors do ship invoices that genuinely do not foot — a manual credit typed into the total, a line added after the subtotal was computed, an off-by-one-cent VAT calculation in a bespoke billing script. These are real and they are not rare.

The two cases need different work, so the rule should produce a verdict rather than a failure. If the per-field confidence on all three amounts is high and consistent, and the source crops show clean, unambiguous glyphs, the arithmetic error is in the document and the item belongs in an accounts-payable exception queue where somebody contacts the vendor. If one of the three amounts is the weak link, it belongs in an extraction review queue where somebody looks at a crop and retypes a digit. Sending document-arithmetic problems to extraction reviewers wastes the reviewer and, worse, teaches them that the queue is full of things they cannot fix.

There is one failure a cross-field rule structurally cannot catch: coherent extraction of the wrong thing. If the model read the “previous balance” block instead of the current invoice block, all three numbers are internally consistent and all three are wrong. Arithmetic identities check consistency, never provenance; provenance is what storing the source location for each field is for.

Building the rule

  1. Extract the currency code first and look up its minor-unit exponent. Every amount in the document is normalised through one function that takes the raw string and the exponent and returns an integer, and that function throws rather than guessing when it sees an ambiguous separator pattern.
  2. Extract line items even if the consumer only wants the total. You need the count for the tolerance and the sum for the footing identity.
  3. Detect the pricing basis. Branch to the inclusive identity when the document declares inclusive pricing or when the additive check misses by approximately the tax.
  4. Evaluate both identities, keeping the signed residual rather than a boolean. The residual is diagnostic: a residual equal to one line item is a dropped row, and a residual that is a factor of ten is a decimal-place error.
  5. Route on the residual and on the field confidences together, using the verdict split above.
import { Decimal } from "./money"; // integer minor units

type Amounts = {
  currency: string;        // ISO 4217 alpha code
  lineTotals: number[];    // minor units
  net: number;
  discount: number;        // 0 when absent
  freight: number;
  tax: number;
  total: number;
  taxInclusive: boolean;
};

type Check = {
  name: "footing" | "settlement";
  residual: number;        // minor units, signed
  tolerance: number;
  ok: boolean;
};

export function checkAmounts(a: Amounts): Check[] {
  const lines = a.lineTotals.length;

  // Per-line tax rounding admits up to half a minor unit per taxable line.
  const settlementTolerance = Math.max(1, Math.ceil(lines / 2));

  // Line totals are themselves rounded, so footing gets the same allowance.
  const footingTolerance = Math.max(1, Math.ceil(lines / 2));

  const summed = a.lineTotals.reduce((x, y) => x + y, 0);
  const footing = summed - a.net;

  const settlement = a.taxInclusive
    ? a.net - a.discount + a.freight - a.total
    : a.net - a.discount + a.freight + a.tax - a.total;

  return [
    {
      name: "footing",
      residual: footing,
      tolerance: footingTolerance,
      ok: Math.abs(footing) <= footingTolerance,
    },
    {
      name: "settlement",
      residual: settlement,
      tolerance: settlementTolerance,
      ok: Math.abs(settlement) <= settlementTolerance,
    },
  ];
}

Two properties of that function matter more than its contents. It never mutates the extracted record — a validator that “fixes” a total to make the arithmetic work has destroyed the evidence that anything was wrong. And it returns residuals, so the same output feeds both the routing decision and the weekly report on which vendors’ documents fail most often, which is usually where the next real improvement is hiding.