When you extract data from invoices, store one record per invoice and one per line, in integer cents, and keep every number exactly as printed. Then let the invoice check itself: quantity × unit price must equal each line's amount, the lines must add up to the subtotal, the subtotal less discount plus tax, shipping and other charges must equal the total, and the total less payments must equal the amount due. Those four equations catch most bad reads, and they tell you where the error is.
I'm Damin and I run ZeraBooks, whose Convert product reads invoices from PDFs and runs those same four checks before anything is downloaded. This is a standalone sketch in TypeScript, not our code. It runs as-is on Node 24, which strips the types itself.
Why keep the printed numbers instead of computing them?
Because the point is to compare. If your extractor reads quantity and unit price and then calculates the amount, a misread unit price produces a perfectly consistent, perfectly wrong line. Store what the page says in every field, including the amount, subtotal and total, and the redundancy on the invoice becomes your error detector. Invoices are full of numbers that are supposed to agree. Use them.
Two smaller rules come from the same idea. If a field isn't printed, store null or zero and say so; never fill it in. And if no vendor name is printed, store that fact rather than guessing one from the logo.
The schema and the checks
// All money in integer cents. Quantity can be fractional (3.25 hours).
type Line = {
description: string
quantity: number
unitPriceCents: number
amountCents: number // as printed
}
type Invoice = {
number: string
vendor: string | null // null when no vendor is printed; never guessed
invoiceDate: string // MM/DD/YYYY as printed
lines: Line[]
subtotalCents: number
discountCents: number
taxCents: number
taxRatePct: number | null // printed rate, if any
shippingCents: number
otherChargesCents: number // fees and surcharges
totalCents: number
paymentsCents: number // deposits already received
amountDueCents: number
}
type Finding = { check: string; expected: number; printed: number; where: string }
function checkInvoice(inv: Invoice): Finding[] {
const out: Finding[] = []
const at = (where: string) => `${inv.number}${where}`
// 1. quantity x unit price = amount, line by line
inv.lines.forEach((l, i) => {
const expected = Math.round(l.quantity * l.unitPriceCents)
if (expected !== l.amountCents)
out.push({ check: 'qty x price', expected, printed: l.amountCents, where: at(` line ${i + 1} (${l.description})`) })
})
// 2. lines add up to the subtotal
const lineSum = inv.lines.reduce((s, l) => s + l.amountCents, 0)
if (lineSum !== inv.subtotalCents)
out.push({ check: 'lines = subtotal', expected: lineSum, printed: inv.subtotalCents, where: at('') })
// 3. subtotal - discount + tax + shipping + other = total
const total = inv.subtotalCents - inv.discountCents + inv.taxCents + inv.shippingCents + inv.otherChargesCents
if (total !== inv.totalCents)
out.push({ check: 'subtotal to total', expected: total, printed: inv.totalCents, where: at('') })
// 4. total - payments = amount due
const due = inv.totalCents - inv.paymentsCents
if (due !== inv.amountDueCents)
out.push({ check: 'total to amount due', expected: due, printed: inv.amountDueCents, where: at('') })
// 5. optional: the printed tax rate applied to the taxable amount
if (inv.taxRatePct !== null) {
const tax = Math.round(((inv.subtotalCents - inv.discountCents) * inv.taxRatePct) / 100)
if (tax !== inv.taxCents)
out.push({ check: 'tax rate', expected: tax, printed: inv.taxCents, where: at('') })
}
return out
}
const money = (c: number) => (c / 100).toFixed(2)
// Meridian Signworks (fictional), invoice 2207, as the extractor read it
const read: Invoice = {
number: '2207',
vendor: 'Meridian Signworks',
invoiceDate: '08/14/2026',
lines: [
{ description: 'Vinyl banner 3x8', quantity: 2, unitPriceCents: 14500, amountCents: 29000 },
{ description: 'Install labor, hours', quantity: 3.25, unitPriceCents: 8000, amountCents: 26000 },
{ description: 'Yard signs', quantity: 50, unitPriceCents: 470, amountCents: 20500 }, // 4.10 misread as 4.70
],
subtotalCents: 75500,
discountCents: 5500,
taxCents: 4200,
taxRatePct: 6,
shippingCents: 0,
otherChargesCents: 0,
totalCents: 74200,
paymentsCents: 30000,
amountDueCents: 44200,
}
for (const f of checkInvoice(read))
console.log(`${f.where}: ${f.check} expected ${money(f.expected)}, printed ${money(f.printed)}`)
// The same invoice with the install line missed entirely
const short = { ...read, lines: [read.lines[0], { ...read.lines[2], unitPriceCents: 410 }] }
for (const f of checkInvoice(short))
console.log(`${f.where}: ${f.check} expected ${money(f.expected)}, printed ${money(f.printed)}`)
A few notes on the types.
Cents everywhere. Money is an integer. The only multiplication involving a fraction is quantity × unit price, and that's rounded once, to the cent, right there. Floating-point sums of dollars drift; integer sums of cents don't.
Fractional quantities are normal. Labor in hours, materials by weight. 3.25 × 8000 is exactly 26000, but 0.333 hours at 9,500 cents won't be, which is why the rounding is explicit.
Other charges get a field. Fees and surcharges count toward the total but aren't lines. If your schema has nowhere to put them, check 3 fails on honest invoices.
Tax rate is a separate, optional check. Plenty of invoices print a tax amount without a rate, and some tax only part of the subtotal. The sketch applies the printed rate to subtotal less discount, which is right for this invoice and not for every invoice, so treat a failure there as a question rather than an error.
A misread, caught
Meridian Signworks is a fictional sign shop. Invoice 2207: two vinyl banners at 145.00, 3.25 hours of install at 80.00, and 50 yard signs at 4.10. Subtotal 755.00, a 55.00 discount, 6% tax on the remaining 700.00 for 42.00, total 742.00, a 300.00 deposit already paid, 442.00 due.
Suppose the extractor reads the yard sign unit price as 4.70 instead of 4.10, a common confusion on a soft scan. Everything else is read correctly. The script runs that case first:
$ node invoice.ts
2207 line 3 (Yard signs): qty x price expected 235.00, printed 205.00
One finding, on the exact line. 50 × 4.70 = 235.00, but the page printed 205.00 for that line, so either the quantity or the price was misread, and the printed amount is the one to trust because it agrees with the subtotal. Checks 2 to 4 all pass, which is useful too: the error is local to that line and the totals hold.
The second case drops the install line entirely, as if it sat at the top of page two and never got read:
2207: lines = subtotal expected 495.00, printed 755.00
The lines add to 495.00 against a printed subtotal of 755.00. The gap is 260.00, exactly the missing line, so you know what to look for: one line of 260.00, or a few that add up to it.
How do findings map to fixes?
| Check that fails | Most likely cause |
|---|---|
| qty x price on one line | Misread quantity or unit price on that line |
| lines = subtotal | A line missed, doubled or split across a page |
| subtotal to total | Tax, shipping or a fee read wrongly or not at all |
| total to amount due | A deposit or payment not read |
| tax rate | Tax on only part of the invoice, or a misread rate |
When everything passes, the invoice is at least internally consistent. It can still be the wrong invoice, or a duplicate of one you already have, so a duplicate check on vendor plus invoice number belongs one level up, across the batch.
How do the rows go out?
Two tables, joined on invoice number. One row per invoice with the header and totals, which is what you reconcile against a supplier's statement and the bank. One row per line, which is what you analyse: spend by vendor, unit prices over time, materials against labor. A one-row-per-invoice export throws away the data that made the checks possible.
Disclosure: I'm Damin and I run ZeraBooks, so weigh this part accordingly. ZeraBooks Convert reads invoice PDFs and downloads this shape as its All details format: an Invoices sheet (invoice number, vendor, customer, dates, terms, PO number, subtotal, discount, tax, shipping, total, amount due and the source file) and a Line Items sheet (invoice number, vendor, description, quantity, unit price, amount). It runs checks 1 to 4 on every invoice and marks anything that doesn't add up; it doesn't check the tax rate. It's part of the ZeraBooks subscription, 1,000 pages a month for $79 a month billed annually, with a 7-day trial.
A worked two-invoice example with both sheets is in our guide to invoice PDF to Excel. The other documents Convert reads are on the bank statement converter page.
Store what's printed and let the invoice argue with itself. It's the cheapest validator you'll ever write.











