Skip to content
Blog

How an invoice is scored against an ERP record

What the matching engine actually does between “PROCESSED” and “Fully matched” — with its real output.

Reviewers trust a match score only when they know what it is made of. This is the whole decision, from the four lookup keys to the 0.55 threshold, with the engine’s own JSON for each case. Nothing below is simplified for the article: the inputs are the ones we ran, and the outputs are what came back.

By Deepak Rao Gaikwad, LedgerSync · 23 October 2026

Step 1 — the same four keys on both sides

Every ERP row is stamped on import with four normalised keys, and every extracted invoice gets the same four. They are deliberately boring:

Match keys and how they are normalised
KeyNormalisation
normInvoiceNoUpper-case, keep only A–Z and 0–9. INV-1001 → INV1001; 2/984712/3349120 → 29847123349120.
normSupplierThe supplier code lower-cased if there is one, otherwise the name lower-cased with whitespace collapsed.
normTotalAmountCommas removed, parsed, rounded to 2 decimals.
invoiceDayThe date as a UTC YYYY-MM-DD day.

The candidate lookup is an OR over those keys — same invoice number; same supplier and total; same supplier; same total on the same day — limited to the newest 500 rows of the catalog. If it finds nothing, the engine samples 50 rows, re-infers which columns mean what, re-stamps the keys and tries once more. That retry is what makes an export with headers like VendName, Invoice No. and GrossAmt work without anyone mapping columns by hand; the repository keeps a “golden” test for exactly that export shape.

Step 2 — seven fields, each worth 1 or 0

Once candidates exist, each is scored on up to seven header fields: invoice number, supplier, total, invoice date, currency, subtotal and tax. Every field scores exactly 1 or 0. The composite is the plain average of the fields present on both sides — a field your export doesn’t carry is skipped, not penalised. There are no weights to tune.

A clean case first. Note that the ERP total has decimals and the extracted total does not:

evaluateAgainstErp({ invoiceNumber: 'INV-1001', supplierName: 'Acme Supplies Ltd', totalAmount: '1100' }, erp { INV-1001 / Acme Supplies Ltd / 1100.00 })
{
  "composite": 1,
  "allOk": true,
  "fieldScores": { "invoiceNumber": 1, "supplier": 1, "totalAmount": 1 },
  "discrepancies": []
}

Amounts are compared after rounding both sides to 2 decimals, which is why 1100 and 1100.00 agree. The order of checks inside a field comparison matters: pure-digit invoice tokens are compared as integers; then a 1–4 letter series prefix is tolerated when the shared part is at least 6 characters; then amounts; then dates by UTC day; then 3-letter currency codes; and only then a plain case-insensitive string match. That prefix rule handles the common case where the ERP stores a series and the invoice prints only the number:

extracted invoiceNumber '90214872' vs ERP 'VT-90214872' (Vanguard Tailors AG, 1689.80 on both sides)
{
  "composite": 1,
  "allOk": true,
  "fieldScores": { "invoiceNumber": 1, "supplier": 1, "totalAmount": 1 },
  "discrepancies": []
}

Now a genuine disagreement. The total differs and the ERP date is in a different format:

extracted { INV-1001, Acme, 100.00, 2026-04-17, EUR } vs ERP { INV-1001, Acme, 200.00, 17/04/2026, eur }
{
  "composite": 0.6,
  "allOk": false,
  "fieldScores": { "invoiceNumber": 1, "supplier": 1, "totalAmount": 0, "invoiceDate": 0, "currency": 1 },
  "discrepancies": [
    { "field": "totalAmount", "extracted": "100.00",     "erp": "200.00" },
    { "field": "invoiceDate", "extracted": "2026-04-17", "erp": "17/04/2026" }
  ]
}

Two things to notice. The currency matched despite EUR vs eur. The date did not: 17/04/2026 is ambiguous (day-first or month-first), so the engine does not guess, and the discrepancy shows both raw values so the reviewer can see at a glance that it is a format problem in the export rather than a different date. The fix is on the ERP side (export ISO dates), after which Match again re-scores without re-reading the PDF.

Subtotals get one extra allowance: when the gap between the two equals the printed discount, it counts as agreement.

extracted { MV-882104, subtotal 420, discount_amount 25.2, total 394.80 } vs ERP { subtotal 394.80, total 394.80 }
{
  "composite": 1,
  "allOk": true,
  "fieldScores": { "invoiceNumber": 1, "supplier": 1, "totalAmount": 1, "subtotal": 1 },
  "discrepancies": []
}

Step 3 — line items, when both sides have them

Lines are paired by item code (exact, case-insensitive). If either line has no code, they are paired by description similarity — 1 minus the edit distance over the longer length — of at least 0.75. For each pair, quantity, unit price and line total must agree within 0.5% or 0.02. A line missing on either side scores 0, and the line score is the share of lines that passed, which is then blended into the composite as one extra field.

matchLineItems(): two lines; the ERP row has quantity 12 instead of 10 on the second
{
  "score": 0.667,
  "discrepancies": [
    { "field": "lineItem.quantity",   "extracted": 10,  "erp": 12,  "lineItemDiscrepancyType": "QUANTITY_MISMATCH",
      "itemCode": "HM1607815", "itemDescription": "Horizon Slim Suit Pants Navy" },
    { "field": "lineItem.totalPrice", "extracted": 260, "erp": 312, "lineItemDiscrepancyType": "TOTAL_MISMATCH",
      "itemCode": "HM1607815", "itemDescription": "Horizon Slim Suit Pants Navy" }
  ],
  "compared": true
}

The discrepancy carries the item code and description, so the reviewer reads “10 vs 12 pairs of Horizon Slim Suit Pants” rather than “line 2 differs”.

Step 4 — does the invoice add up on its own?

Independently of the ERP row, the extracted figures are self-checked: line totals (minus discount) against the subtotal, subtotal × tax rate against the tax, subtotal + tax against the total, each within 0.5% or 0.02. An Austrian invoice at 20% VAT with a net of 290 and a gross of 348 produces no findings. The same invoice with the total mis-read as 358 produces one:

validateInvoiceArithmetic({ lineItems: [290], subtotal: 290, taxRate: 20, taxAmount: 58, totalAmount: 358 })
[{ "field": "arithmetic.totalAmount", "extracted": 358, "erp": 348 }]

Any arithmetic finding downgrades a would-be full match to a partial one. A 0% reverse-charge or intra-Community invoice is recognised from the printed statement and does not trigger the tax check.

Step 5 — the decision

  1. Not matched when there is no candidate, or the best composite is below 0.55.
  2. Fully matched when every field that could be compared scored 1 (allOk) and the arithmetic check passed.
  3. Partially matched otherwise — a candidate exists, and the result lists exactly what disagrees.

The stored result keeps the composite score, the per-field 0/1 scores, and the discrepancy list with the extracted value next to the ERP value. When the extraction has an invoice number and a supplier but no candidate matched on any key, the engine leaves the document Not matched instead of scoring the newest 400 rows — a confident “nothing found” beats a plausible-looking wrong pair.

What this means for a reviewer

  • A partial with only a date discrepancy and a format-looking ERP value is an export problem — fix the export, Match again.
  • A partial with a total discrepancy and clean arithmetic is a genuine difference between the supplier and your books.
  • A partial with an arithmetic finding and a clean ERP comparison usually means the PDF was mis-read — Refresh from file.

Ready to see it on your own invoices?