AI tools need an off-ramp
Notes from building an invoice reader for an accounts team: the model is the easy part, deciding when to hand back to a person is the work.
2 min read
- ai
- clients
- typescript
An accounts team I work with receives around 400 supplier invoices a month as PDFs and photos. Someone types each one into their accounting system: supplier, invoice number, date, line items, tax, total. It takes about four minutes per invoice when everything is clear, and much longer when it isn't.
They asked if AI could do it. It can do most of it. The interesting part of the project was the rest.
Reading is not the hard part
Getting a model to pull fields out of an invoice is straightforward now. You give it the document and a schema, and it gives you structured data back. On a clean invoice from a regular supplier it gets everything right.
The trouble is that "most invoices" isn't good enough for accounting. One wrong total that slips through costs more trust than a hundred correct ones earn. So the question became: how does the tool know when it shouldn't be trusted?
Checks that don't need a model
I ended up with a small set of plain checks that run after extraction. None of them are clever. They catch most of the mistakes that matter.
type Extracted = {
supplier: string;
invoiceNumber: string;
lines: { amount: number }[];
subtotal: number;
tax: number;
total: number;
};
type Review = { ok: true } | { ok: false; reasons: string[] };
export function review(inv: Extracted, knownSuppliers: Set<string>): Review {
const reasons: string[] = [];
const lineSum = inv.lines.reduce((sum, l) => sum + l.amount, 0);
if (Math.abs(lineSum - inv.subtotal) > 0.01) {
reasons.push(`Lines add up to ${lineSum.toFixed(2)}, subtotal says ${inv.subtotal}`);
}
if (Math.abs(inv.subtotal + inv.tax - inv.total) > 0.01) {
reasons.push("Subtotal plus tax doesn't match the total");
}
if (!knownSuppliers.has(inv.supplier)) {
reasons.push(`New supplier: ${inv.supplier}`);
}
return reasons.length ? { ok: false, reasons } : { ok: true };
}If any check fails, the invoice goes to a person with the reasons listed at the top. If everything passes, it goes into a "ready to post" queue that someone still approves in batches.
The off-ramp is the product
What made the team actually use it wasn't the extraction accuracy. It was that every invoice had an obvious next step:
- Ready to post: checks passed, approve in bulk.
- Needs a look: one or more checks failed, reasons shown, original document side by side.
- Couldn't read: the model wasn't confident, so it's back to typing, but already sorted to the right supplier.
Nobody had to wonder whether to trust a number. The tool told them, and told them why.
After two months, around 80% of invoices go through the first queue. The team spends their time on the 20% that actually needed a human, which is the part of the job they're good at anyway.
What I'd tell anyone building something similar
Spend less time tuning the prompt and more time designing what happens when it's wrong. Write the checks in normal code where you can. Show your reasons. And keep a person in the loop for anything that moves money, at least until the numbers say otherwise.