Rechnungsabgleich mit Claude und Node.js: Nur Code entscheidet über die Buchung
Ein Workflow teilt die Aufgabe auf: Claude liest die Rechnung aus, aber klassischer Code prüft, ob sie zur Bestellung passt. Nur Rechnungen, die eine Prüfung nicht bestehen, landen beim Menschen.

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.
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.
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.
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.
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.
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.
Once this works, add the goods received note as a third document. The same pattern then gives you a full three-way match.
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.
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.