DEV Community

Robert Breen
Robert Breen

Posted on Originally published at learn.ynteractive.com

n8n: Turn Invoice PDFs Into JSON With AI, Then Two-Way Match Them Against the PO in a Code Node

In most small offices, paying a vendor invoice goes like this. The PDF arrives by email. Someone types the vendor, invoice number and total into a spreadsheet. Then, if they're careful, they pull up the purchase order and check that the invoice matches what was ordered.

That last check is the one that gets skipped on a busy week. It's also the one that catches a price that crept up, a line nobody ordered, or an invoice with no PO behind it at all. Accounts payable calls it a two-way match: compare the invoice with its purchase order before you pay. If the vendor, items, quantities, prices and total agree, within a small tolerance, the invoice can go for approval.

The typing half of this is a solved problem in n8n. My free lesson Invoices from Your Inbox to Google Sheets, with Approval builds it. This post adds the matching half in one Code node.

Who this is for, and what changes

If you're the bookkeeper or office manager at a 5 to 50 person firm, the cost is easy to see: minutes of typing per invoice, plus the occasional overpayment nobody noticed. What changes is that a person only looks at the invoices that don't match, instead of re-checking every one.

Two-way matching fits services and low-risk purchases: software, consulting, cleaning, a monthly retainer. For physical goods, many businesses add the receiving record and run a three-way match, because a two-way match can't tell you whether 40 cartons actually arrived.

The reading half, from the lesson

The lesson's example is Cobalt Street Architects, a made-up firm, and a test invoice from Tidewater Office Supply: invoice TW-20418, dated September 29, 2026, due October 29, total $1,284.50.

  1. Gmail Trigger catches invoice emails. To get the PDF, turn Simplify off and add the Download Attachments option. The first attachment arrives as binary data called attachment_0.
  2. Extract from File, operation Extract From PDF, turns that PDF into plain text.
  3. Information Extractor with an OpenAI Chat Model pulls fields out of the text into a fixed shape.

One honest limit: Extract from File needs a PDF with real text in it. A scanned invoice that is only an image has no text to extract, so you'd add an OCR step before the extractor. Check what your vendors actually send.

The extractor: instructions and JSON example

In the Information Extractor, set Schema Type to Generate From JSON Example. The lesson's example has five fields. For matching, I add the PO number and the line items:

{
  "vendor": "Tidewater Office Supply",
  "invoice_number": "TW-20418",
  "po_number": "PO-2026-118",
  "invoice_date": "2026-09-29",
  "due_date": "2026-10-29",
  "total": 1284.5,
  "line_items": [
    { "item": "Copy paper, 10-ream case", "qty": 12, "unit_price": 52.5 },
    { "item": "Toner cartridge, black", "qty": 5, "unit_price": 130.9 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Two details from the lesson that make the match work later. Numbers are plain numbers (250.75, not "$250.75"), so code can compare them. Dates are year first, so they sort correctly.

The instructions in Text matter more than the model. The lesson's rule is the right one for matching: copy what is printed, never calculate or guess, and leave a missing field empty. An extractor that "helpfully" fixes a total is exactly what a match is meant to catch.

Extract these fields from the invoice text below: vendor, invoice_number,
po_number, invoice_date, due_date, total, and line_items (item, qty, unit_price).
Copy what is printed. Never calculate, round or guess.
If a field is not on the invoice, leave it empty.
Dates as YYYY-MM-DD. Numbers without currency symbols or commas.

{{ $json.text }}
Enter fullscreen mode Exit fullscreen mode

The extractor puts its fields under output, so later nodes read $json.output.vendor, $json.output.total and so on.

The PO side: one row per PO line

Keep your purchase orders in a Google Sheet with one row per line: po_number, vendor, item, qty, unit_price. After the extractor, add Google Sheets Get row(s) in sheet and rename the node Get PO rows. Filter it on po_number equal to {{ $json.output.po_number }} if you have many POs, or read the whole sheet if you don't.

The match: one Code node

Add a Code node after Get PO rows, mode Run Once for All Items:

// Two-way match: invoice (from the Information Extractor) vs PO lines (from Google Sheets)
const TOTAL_TOLERANCE = 5.00;    // dollars: rounding, small shipping
const PRICE_TOLERANCE = 0.01;    // per unit

const num = v => Number(String(v ?? '').replace(/[$,\s]/g, '')) || 0;
const norm = s => String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ');

const inv = $('Information Extractor').first().json.output;
const poLines = $('Get PO rows').all()
  .map(i => i.json)
  .filter(r => norm(r.po_number) === norm(inv.po_number));

const reasons = [];

if (!inv.po_number) {
  reasons.push('Invoice has no PO number');
} else if (poLines.length === 0) {
  reasons.push(`PO ${inv.po_number} not found`);
} else {
  if (norm(poLines[0].vendor) !== norm(inv.vendor)) {
    reasons.push(`Vendor differs: PO "${poLines[0].vendor}", invoice "${inv.vendor}"`);
  }

  const poByItem = new Map(poLines.map(r => [norm(r.item), r]));
  for (const line of inv.line_items || []) {
    const po = poByItem.get(norm(line.item));
    if (!po) {
      reasons.push(`Not on the PO: ${line.item}`);
      continue;
    }
    if (num(line.qty) > num(po.qty)) {
      reasons.push(`${line.item}: billed ${line.qty}, ordered ${po.qty}`);
    }
    if (Math.abs(num(line.unit_price) - num(po.unit_price)) > PRICE_TOLERANCE) {
      reasons.push(`${line.item}: price ${line.unit_price}, PO price ${po.unit_price}`);
    }
  }

  const poTotal = poLines.reduce((sum, r) => sum + num(r.qty) * num(r.unit_price), 0);
  if (Math.abs(poTotal - num(inv.total)) > TOTAL_TOLERANCE) {
    reasons.push(`Total ${num(inv.total).toFixed(2)} vs PO ${poTotal.toFixed(2)}`);
  }
}

return [{
  json: {
    ...inv,
    match_status: reasons.length === 0 ? 'matched' : 'needs_review',
    match_reasons: reasons.join('; '),
  },
}];
Enter fullscreen mode Exit fullscreen mode

A few choices in there are deliberate:

  • Billing less than ordered passes; billing more fails. A partial invoice is normal. An invoice for 14 cases when you ordered 12 is not.
  • Line totals are recomputed from the PO, never from the invoice. The invoice total is what's being checked, so it can't be the reference.
  • No PO number is a fail, not a skip. The glossary page's tip is to send those back to the vendor or the requester.
  • The tolerance is a policy number. Which purchases get which check, and how much drift is fine, belongs in writing where accounts payable can follow it. Put your number in the constant and tell people what it is.

Route on the result

Add an If node: {{ $json.match_status }}, String, is equal to, matched.

  • true: append the row to your invoice log. If you also keep the lesson's rule that anything over $1,000 needs the owner, add a second If on {{ $json.total }}, Number, is greater than, 1000.
  • false: Gmail Send message and wait for response to whoever approves exceptions, with {{ $json.match_reasons }} in the message. The lesson notes this node defaults to Approve Only; add Approval Options and choose Approve and Disapprove if you want a No button.

The workflow pauses until someone answers, and test runs wait just like live runs.

Keep a person in the loop

AI can misread a blurry PDF or an odd layout. That's why the instructions forbid guessing and why mismatches go to a person instead of being "fixed" by code. Spot-check the first week of rows against the PDFs. This is bookkeeping help, not accounting or tax advice, and paying stays a separate, human step.

If you're building this on n8n Cloud, the lesson lists plans from about €20 a month with a free trial, or the free self-hosted Community Edition. Try n8n here (affiliate link: I may earn a commission, at no cost to you).

Try it free

The two-way match glossary page explains the check, how it differs from a three-way match and from invoice approval, and links the hands-on invoice lesson.

Practice it step by step, free: https://learn.ynteractive.com/content/glossary/two-way-match

Top comments (0)