Skip to main content
To automate accounts payable, you need four things from every invoice: who sent it, its number, the amounts, and the due date. You also need to know when those numbers can’t be trusted. This recipe sends the invoice text to ScaleDown Extract with a one-line description of each field you want, then checks that the subtotal plus tax equals the total. Invoices that pass post automatically. The rest go to a person for review, tagged with what’s wrong, such as a missing tax line or totals that don’t add up. Goal: Write an extractInvoice function that returns the fields Extract found, and a triage function that decides whether each invoice can post. Outcome: Consistent invoices post with every field filled in. An invoice with a missing field or totals that don’t add up goes to review with the reason attached. Running the same invoice twice gives the same fields.
Prerequisites:
  • Your existing TypeScript app runs on Node.js 20+ or Bun as an ES module, with @types/node or @types/bun installed, and has the invoice text as a string.
  • You’ve configured OPENROUTER_API_KEY and installed zod 4.

1. Describe the fields

Give each field a name and a one-line description of what to look for. Extract uses the description to find the field and labels what it finds with the name, so pick names that work as keys, like invoice_number. The wording matters. “With currency” on the amounts makes Extract include the currency code or symbol, which step 3 needs to compare currencies and to read three-decimal currencies like the Kuwaiti dinar correctly.

2. Call Extract

Send the invoice text as the user message. Field descriptions go in provider.options (the part of an OpenRouter request that carries provider-specific settings), under scaledown.entities. Extract replies with a JSON string in message.content. Inside is an entities array with one entry per field it found: the field name as type, the matching invoice text as text, the start and end character positions of that text in your input, and a confidence. The code parses the string and validates it with Zod before using it.
The invoice below produces the captured output in this step. Use it as your first test input, then replace it with invoices from your own queue.
Captured output. The entities array Extract returned for this invoice, reordered to match INVOICE_ENTITIES. start and end index into INVOICE, so INVOICE.slice(8, 30) is the vendor. The response also carries fields this recipe doesn’t use, such as ocr_text and a context snippet for each entity.
A field Extract can’t find is left out of entities. Read confidence as “a match was found”, not as the chance the value is right, and don’t route invoices on it.
Each extractInvoice call is one paid request billed to your OpenRouter account at the scaledown/extract rate. In our runs that came to $0.012 to $0.015 per 1,000 invoices.

3. Check the invoice before you post it

triage takes the first value Extract found for each field, checks that none are missing, and checks that subtotal plus tax equals the total in one currency. parseMoney reads each amount as whole cents (or fils, for three-decimal currencies like the Kuwaiti dinar) and refuses anything it can’t read unambiguously, such as a negative amount. Those invoices go to review.
An invoice with an empty problems list can post. Anything else goes to review, with problems naming what failed. Dates come back exactly as printed (2026-10-30, October 15, 2026, 31.10.2026, and 20 Oct 2026 in our runs), so convert due_date with your date library before it reaches the ledger.

Worked example

The six made-up invoices below cover the layouts an accounts-payable queue sees: the table from step 2, a prose paragraph, a German-English mix with 12.400,00 EUR amounts, a one-line statement with fields separated by pipes, an invoice whose printed total is $10 too high, and a freight invoice with no tax line. Save steps 1 to 3 and this block as one file and run it with bun run invoices.ts. Step 2’s own call runs first and prints the Northwind entities array; the loop then prints one line per invoice.
Captured output. One run of the loop above, one line per invoice:

Check your work

  • Request. The request body carries provider.options.scaledown.entities with one description per field.
  • Response. message.content parses as JSON with an entities array. Each entry has type, text, confidence, start, and end. Each text equals invoice.slice(start, end).
  • Clean invoice. Every field in INVOICE_FIELDS is present and triage returns post: true.
  • One cent off. Change the total by one cent, on a small invoice and on a large one. Both return totals_mismatch.
  • No cents. $1,000 / $80 / $1,080 posts, with the comma read as a thousands separator.
  • Three-decimal currency. KWD 1.250 / KWD 0.100 / KWD 1.350 posts, and so does KWD 1.250 / KWD 0.25 / KWD 1.500 (1,250 + 250 = 1,500 fils). A total of KWD 1.351 returns totals_mismatch, and KWD 3,000 anywhere returns unparsed_amount.
  • Mixed currencies. Print the tax as EUR 20.00 on a USD invoice. problems contains currency_mismatch.
  • Negative amount. Make the subtotal -$100.00 or ($100.00). problems contains unparsed_amount.
  • Missing field. Remove the tax line. problems contains missing_vat and post is false.
  • Stable. Run one invoice five times. fields is identical every time.

Variations

Change INVOICE_ENTITIES and the same code pulls fields out of other documents: renewal terms, notice periods, and liability caps from contracts, guidance and segment revenue from filings, or error codes and product versions from support tickets. Whenever two extracted values must agree, keep a check like step 3.

Next steps