An invoice extractor that's right most of the time is worse than useless if you can't tell which times. A wrong total doesn't throw an error. It just lands in your sheet looking exactly like a right one.
So I fed my n8n invoice extractor 14 real-format documents, 3 times each, and checked every field by hand against the truth. Then I ran the same model output through two versions of a validation node to see which wrong answers each one would have stopped.
Setup: n8n 1.123.84 self-hosted, Qwen 1.8B (Q4_K_M) in Ollama on CPU via its OpenAI-compatible /v1 endpoint, temperature 0.2, response_format: json_object. One workflow: Webhook, prompt, LLM, then Validate v1 and Validate v2 reading the same LLM output, so the comparison is apples to apples. 14 documents x 3 rounds = 42 runs.
The documents: a GST tax invoice, a cafe receipt, a freelancer invoice in USD, an AWS statement, a hand-typed electrician's bill, an order-shipped email, a food delivery order summary with a promo code, an office-supplies GST invoice, a SaaS receipt, a freelancer invoice with an advance already paid, an electricity bill, a hotel folio, a German hosting invoice (1.234,55 €), and a ride receipt.
The scoreboard
| What | Result |
|---|---|
| Invalid JSON | 0/42 |
| Runs with at least one wrong field | 23/42 |
| Wrong total | 9/42 |
| Wrong invoice date | 3/42 |
| Made-up due date | 2/42 |
| Wrong invoice number | 9/42 |
| Wrong GSTIN | 6/42 |
JSON mode did its job again: every response parsed. Every failure was in the content.
The 9 wrong totals
| Document | Wrong total | Right total | What happened |
|---|---|---|---|
| AWS statement | 62.93 (3/3 runs) | 54.61 | Added the $8.32 tax line on top of a total that already included it |
| German hosting invoice | 1431.66 (2/3) | 1234.55 | Same thing: added the 19% USt again |
| Electricity bill | 1,387.00 (1/3) | 1,284.00 | Same thing: added the Rs 103 tax again |
| Freelancer invoice with advance | 20,000 (3/3) | 30,000 | Took "Balance due" instead of the invoice total |
Six of the nine are the same bug: the model adds tax to a total that already includes tax. Every one of those wrong numbers is "total + tax" to the cent. You'd never spot it scanning a spreadsheet, because the number looks completely reasonable.
The freelancer one is arguably a definition problem, not a model problem: do you want what the invoice was for, or what you still owe? Decide that before you build, and put it in the schema. I wanted the invoice total.
The other failures
-
02/11/2026became February 11, 3 out of 3 times. The receipt is from a Bangalore cafe, so it's 2 November. A 1.8B model defaults to US date order, and a validator that only checks "is this a valid YYYY-MM-DD?" will happily pass it. -
Invented due dates. The freelancer invoice says "Payment within 15 days". Twice the model turned that into a
due_date(once correctly calculated, once just made up). Neither date appears in the document. -
The promo code became a tax ID. The food delivery summary has
Discount applied (TRYNEW). The model putTRYNEWingstin3/3 times. On the advance-paid invoice it put"Advance received (8 Oct) -10,000"ingstin. - Right number, wrong field. The electricity bill has an RR number and a bill number. The model picked the RR number as the invoice number 2/3 times.
Validate v1: catches most bad runs, and blocks half the good ones
v1 was the guard from my last round of tests. It checks that the total and IDs appear somewhere in the document, that dates look like YYYY-MM-DD, and that subtotal + tax = total.
| v1 | |
|---|---|
| Bad runs caught | 19/23 |
| Bad runs missed | 4 (all 3 February dates, plus a GSTIN with a prefix) |
| Good runs wrongly flagged | 10/19 |
That last row is the problem. A validator that sends half of your correct invoices to manual review just gets turned off after a week. The false alarms came from two rules that sound sensible and aren't:
-
subtotal + tax = totalbreaks on anything with a discount, a delivery fee, CGST + SGST split across two lines, or a hotel tariff. Real invoices aren't that tidy. -
"due date must appear in the document" compared
2026-10-28to28 Oct 2026as text, so a correct date failed.
Validate v2: check against the document, not against arithmetic
v2 changes the question from "does this look valid?" to "does the document say this?". Three ideas:
1. Read every date in the document with code, then require the model's dates to be one of them. You tell it how your vendors write numeric dates.
const DATE_ORDER = 'DMY'; // 'MDY' if your vendors are in the US
const dates = new Set();
for (const x of doc.matchAll(/\b(\d{1,2})[\/.\-](\d{1,2})[\/.\-](20\d\d)\b/g)) {
const [a, b] = [+x[1], +x[2]];
dates.add(DATE_ORDER === 'DMY' ? iso(x[3], b, a) : iso(x[3], a, b));
}
// ...plus patterns for "14 Oct 2026", "Oct 3, 2026" and 2026-10-14
for (const k of ['invoice_date', 'due_date'])
if (out[k] && !dates.has(String(out[k])))
reasons.push(`${k} ${out[k]} is not a date in the document`);
That one rule caught all 3 February dates and both invented due dates, because a date the document never wrote can't pass.
2. Find the document's own total with code and compare. Take the first amount after the last "total / amount payable / amount paid" keyword, skipping subtotal and balance lines. Parse amounts properly, including 1.234,55.
const KW = /(grand total|order total|total due|amount payable|amount paid|net amount payable|gesamtbetrag|\btotal\b|charged)/i;
const totalLine = doc.split('\n')
.filter(l => KW.test(l) && !/(sub ?total|item total|balance)/i.test(l)).pop();
let docTotal = null;
if (totalLine) {
const m = totalLine.slice(totalLine.search(KW)).match(/\d[\d.,]*\d|\d/);
docTotal = m ? amt(m[0]) : null;
}
if (docTotal !== null && Math.abs(t - docTotal) > 0.01)
reasons.push(`total ${t} != document total ${docTotal}`);
3. Only fall back to subtotal + tax = total when the document has no total line at all.
| v1 | v2 | |
|---|---|---|
| Wrong totals caught | 9/9 | 9/9 |
| Wrong/invented dates caught | 2/5 | 5/5 |
| Bad runs caught overall | 19/23 | 14/23 |
| Good runs wrongly flagged | 10/19 | 0/19 |
v2 caught fewer bad runs overall, but every miss was an ID field (invoice number or GSTIN), and it never blocked a correct invoice. Before running it on the model, I also fed it the 14 correct answers: 0 flags.
v2.1: one more rule from the misses
Most of v2's misses were the promo code and junk text in gstin. "Appears in the document" can't catch that, because TRYNEW is in the document. A format check can:
if (out.gstin && !/^\d{2}[A-Z]{5}\d{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/.test(String(out.gstin)))
reasons.push('gstin is not a valid GSTIN format');
I replayed the saved 42 outputs through v2.1 (no new model calls): 18/23 bad runs caught, still 0/19 false alarms.
The 5 it still misses are the honest limit of this approach: the electricity bill's RR number picked as the invoice number (2/3; it's a real number from the document, just the wrong one), and the ride receipt's trip ID used as an invoice number (3/3, which you could argue is fine). Code can tell you a value exists in the document. It can't tell you it's the right value when two plausible ones exist. For that you need a per-vendor rule or a human.
What I'd do with any n8n extraction workflow
- Never trust a total the model computed. Find the document's total line in code and compare. It's about 10 lines of JavaScript and it caught 9/9.
-
Set your date order explicitly. If your vendors write
02/11/2026, a small model will guess wrong every single time. - Check values against what the document says, not against arithmetic. Arithmetic rules break on discounts, fees and split taxes, and false alarms kill validators.
- Add format checks for IDs. "Present in the document" isn't enough when the document contains promo codes.
- Measure false alarms, not just catches. v1 looked great on catches and would have been switched off in a week.
- Define ambiguous fields up front. "Total" vs "balance due" is your call, not the model's.
Latency on a shared CPU box: 8.9s average, 6.6s median, one 71s outlier. The validation nodes add nothing measurable.
The free workflows
This invoice extractor isn't in the free repo yet. The validation code above is the important part, and you can paste it into a Code node after any LLM node. The free workflows in arkintof/n8n-ai-agent-workflows right now are the lead qualifier and the review reply bot, and the same idea applies to both: let the model propose, let code decide.
What's the worst wrong value an extraction workflow of yours let through? Did anyone notice before it reached the accounts?
Written with AI assistance; workflow built and tested by me.
Top comments (0)