DEV Community

Geminate Solutions
Geminate Solutions

Posted on

AI Workflow Automation in Node.js: Invoice to PO Matching with Claude

If you want AI workflow automation in Node.js that your finance team will trust, split the job in two. Claude reads the invoice, and plain code decides whether it matches the purchase order. Only the invoices that fail a check ever reach a person.

You probably know how this works today. Someone opens a PDF, finds the PO number and switches to the ERP. They compare quantities line by line and then type everything in again. Most invoices are fine, but the team still checks every one.

This walkthrough builds the intake pipeline in three parts: extraction, matching and an exception queue.

Why shouldn't the model approve the invoice?

Approval is a rule, and a rule should give the same answer every time. A language model is very good at reading a messy layout and working out that "Units" means quantity. It is the wrong place to decide whether a small price difference is acceptable.

Keep one sentence in mind for the whole build: the model reads, the code decides.

There is a less obvious reason too. Models tend to make numbers agree. Say an invoice's line items don't add up to the printed total. Ask a model for "the total" and it may return a number that fits the lines instead of the one on the page. That gap is exactly what you wanted to catch. So the model only copies the printed values, and your code does every sum.

What does it cost to get this wrong?

When the model decides, mistakes go unnoticed. An overbilled invoice gets approved and the record behind it looks clean. Nobody notices until a supplier reconciliation or an audit, and by then you are unwinding payments, not just correcting invoices.

The opposite mistake costs time. If the pipeline flags everything as "needs review", your team does the old job and also has to read the system's notes. People stop using it within weeks.

The goal is narrow: catch every real mismatch and let clean invoices through without anyone touching them.

Do you actually need to build this?

Maybe not. If you handle a few dozen invoices a month from a handful of suppliers, the OCR in your accounting tool plus a quick manual check is enough. Build your own when volume or the number of suppliers makes the manual check the bottleneck, or when your purchase orders live in a system an off-the-shelf tool can't reach.

Step 1: How do you get structured fields out of an invoice PDF?

Send the PDF to Claude and force a tool call. The answer comes back as JSON that follows your schema, not free text.

import Anthropic from '@anthropic-ai/sdk'
import fs from 'node:fs/promises'

const client = new Anthropic()

const invoiceTool = {
  name: 'record_invoice',
  description: 'Record fields exactly as printed. Never calculate, round or correct values.',
  input_schema: {
    type: 'object',
    properties: {
      invoice_number: { type: 'string' },
      po_number: { type: ['string', 'null'] },
      supplier_name: { type: 'string' },
      currency: { type: 'string' },
      printed_total: { type: 'string' },
      lines: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            sku: { type: ['string', 'null'] },
            description: { type: 'string' },
            quantity: { type: 'string' },
            unit_price: { type: 'string' }
          },
          required: ['description', 'quantity', 'unit_price']
        }
      },
      unreadable_fields: { type: 'array', items: { type: 'string' } }
    },
    required: ['invoice_number', 'supplier_name', 'currency', 'printed_total', 'lines', 'unreadable_fields']
  }
}

export async function extractInvoice(path) {
  const pdf = await fs.readFile(path)
  const res = await client.messages.create({
    model: 'claude-sonnet-5-5',
    max_tokens: 2000,
    tools: [invoiceTool],
    tool_choice: { type: 'tool', name: 'record_invoice' },
    messages: [{
      role: 'user',
      content: [
        { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: pdf.toString('base64') } },
        { type: 'text', text: 'Extract this invoice. If a field is missing or unclear, list it in unreadable_fields instead of guessing.' }
      ]
    }]
  })
  return res.content.find(b => b.type === 'tool_use').input
}
Enter fullscreen mode Exit fullscreen mode

Two choices here are worth copying. Amounts come back as strings, so your own code parses them and can fail loudly when something looks wrong. And unreadable_fields lets the model say "I can't read this" instead of making up a value.

Step 2: How do you match invoice fields to the purchase order?

Load the PO from your own system and convert money to whole minor units (cents, paise) before comparing. The model plays no part in this step.

const normalise = s => String(s).toLowerCase().replace(/[^a-z0-9]/g, '')

const toMinor = s => {
  const clean = String(s).replace(/[^0-9.]/g, '')
  const [whole, frac = '', extra] = clean.split('.')
  if (!whole || extra !== undefined || frac.length > 2) {
    throw new Error('Unparseable amount: ' + s)
  }
  return Number(whole) * 100 + Number(frac.padEnd(2, '0'))
}

export function matchInvoice(inv, po, rules) {
  const issues = []
  if (inv.unreadable_fields.length) issues.push({ code: 'UNREADABLE', fields: inv.unreadable_fields })
  if (!po) return [...issues, { code: 'PO_NOT_FOUND', po: inv.po_number }]
  if (inv.currency !== po.currency) issues.push({ code: 'CURRENCY' })
  if (normalise(inv.supplier_name) !== normalise(po.supplier_name)) issues.push({ code: 'SUPPLIER' })

  let computed = 0
  for (const line of inv.lines) {
    const poLine = po.lines.find(l => l.sku === line.sku)
    if (!poLine) {
      issues.push({ code: 'LINE_NOT_ON_PO', line })
      continue
    }
    const qty = Number(line.quantity)
    if (!Number.isFinite(qty)) throw new Error('Unparseable quantity: ' + line.quantity)
    const price = toMinor(line.unit_price)
    computed += qty * price
    if (qty > poLine.remaining_qty) issues.push({ code: 'QTY_OVER_PO', sku: line.sku })
    if (Math.abs(price - poLine.unit_price_minor) > rules.priceToleranceMinor) {
      issues.push({ code: 'PRICE', sku: line.sku, invoiced: price, agreed: poLine.unit_price_minor })
    }
  }
  if (Math.abs(computed - toMinor(inv.printed_total)) > rules.roundingMinor) {
    issues.push({ code: 'TOTAL_DOES_NOT_ADD_UP' })
  }
  return issues
}
Enter fullscreen mode Exit fullscreen mode

If your invoices have tax or freight lines, extract them as separate fields and add them to computed before the final check. An amount written in European format, like "1.250,00", fails the parser and throws an error. That is what you want, because a loud error is better than a number that is quietly wrong.

Here is why each check is there:

  • The PO exists. An invoice with no PO raises a policy question that a person should answer. It is not a data entry task.
  • The supplier matches. This stops you paying the right amount to the wrong party. In production, match on a supplier ID through an alias table, not on raw names.
  • Quantity is checked against what is still open, not what was ordered. Comparing to remaining_qty catches a second invoice for the same delivery.
  • The unit price is within tolerance. Finance sets the tolerance. Keep it in config where an accountant can see it, never inside a prompt.
  • The lines add up to the printed total. This catches the supplier's own arithmetic errors and any extraction that changed a number to make it fit.
  • Unreadable fields go to a person. Someone looks at the document instead of the system guessing.

Once this works, add the goods received note as a third document. The same pattern then gives you a full three-way match.

Step 3: How do mismatches reach a person?

Clean invoices go straight to posting. Everything else goes onto a review queue, with the reasons attached.

import { Queue } from 'bullmq'

const review = new Queue('invoice-review', { connection: { host: 'localhost', port: 6379 } })

export async function processInvoice(path, rules) {
  const inv = await extractInvoice(path)
  const po = await db.purchaseOrders.findByNumber(inv.po_number)
  let issues
  try {
    issues = matchInvoice(inv, po, rules)
  } catch (err) {
    issues = [{ code: 'PARSE_ERROR', message: err.message }]
  }
  if (issues.length === 0) return db.invoices.markReadyToPost(inv, po)
  await review.add('mismatch', { path, inv, po_number: inv.po_number, issues })
}
Enter fullscreen mode Exit fullscreen mode

The reviewer's screen matters more than most teams expect. Show the PDF, the extracted fields and the PO line side by side, and explain each issue code in plain words. Log every correction the reviewer makes. That log becomes your test set when you change the prompt or the model.

One problem is worth watching from the first week. Count exceptions by code and by supplier. If TOTAL_DOES_NOT_ADD_UP suddenly jumps for one supplier, their invoice layout has probably changed, and you will see it before anyone pays a wrong bill.

Where else does this pattern work?

The same split works for purchase requests, delivery notes, claims and onboarding forms. A model turns messy input into fields, code applies the rules and people handle the exceptions. If you are adding this to an existing backend, the guide to Node.js AI integration covers retries, timeouts and logging around the model call.

For more workflows built this way, read AI automation examples for business. If you would like someone to look over your own intake flow, Geminate Solutions offers a free review of your plan and replies within 24 hours.

Top comments (0)