A few weeks ago, while sharing an early version of FINpulse—a personal and family finance PWA I’ve been building—someone on Reddit left a comment that stuck with me:
"I run an enterprise system processing tens of thousands of receipts a month. AI extraction looks great when you test it on 50 receipts. When you hit thousands, you realize LLMs estimate—they don't interpret. If a receipt is faded and it looks logical for a line item to be 100 RON, the model will just write 100 RON and report 100% confidence."
He was completely right.
When you use a vision LLM (whether it’s Gemini Flash, GPT-4o, or Claude) to extract structured JSON from a crumpled thermal grocery receipt, the model is optimizing for plausibility, not accounting accuracy.
If a thermal printer skipped a few pixels on a 7 so it looks a bit like a 1, a standard OCR engine might return a low character confidence score. A vision LLM, however, will happily "fix" the rest of the line item—or worse, invent a price that makes the receipt look tidy—while returning "confidence": 0.99.
In a toy project, a 2-dollar hallucination doesn't matter. In a personal finance and 50/50 couple bill-splitting app, a single invented number ruins the user's trust immediately.
Here is how we redesigned the extraction pipeline in FINpulse to stop trusting single-pass LLM outputs and force deterministic verification before anything touches the database.
1. Never Ask an LLM for a "Confidence Score"
Our first mistake was asking the model to rate its own confidence (0.0 to 1.0) in the JSON schema.
LLMs are notoriously terrible at calibrating their own uncertainty on visual tasks. Unless the image is completely black, the model will almost always output 0.95 or 1.0, even when it just guessed three blurry prices.
Instead of asking for subjective confidence, we changed the prompt schema to ask for observable visual telemetry:
-
isBlurry: boolean -
isCutOff: boolean (is the top merchant header or bottom total missing?) -
textClarity:"high" | "medium" | "low"
If a receipt is physically cut off at the bottom, the model is much more honest about answering isCutOff: true than lowering an arbitrary confidence float.
2. The Deterministic Math Gate
A receipt is not freeform prose; it’s a strict mathematical table. Every receipt must obey two basic invariants:
-
Line-Item Invariant: For every product row,
quantity × unitPricemust equallineTotal(within a±0.02rounding tolerance for weighted items like fruits or vegetables). -
Receipt Sum Invariant: The sum of all extracted
lineTotalvalues, minus any explicitdiscountlines, must equal the printedtotalAmountat the bottom of the receipt.
So we built a pure TypeScript deterministic validator (lib/ai/receipt-validator.ts) that runs immediately after the primary vision model returns its JSON:
const sumOfItems = items.reduce((acc, item) => acc + item.lineTotal, 0);
const netCalculatedTotal = Number((sumOfItems - totalDiscounts).toFixed(2));
const headerTotal = Number(extracted.totalAmount.toFixed(2));
const totalDiff = Math.abs(netCalculatedTotal - headerTotal);
const isMathValid = totalDiff <= 0.05;
We also check line-level multiplication and date sanity (e.g., rejecting receipts where a smudge caused 2026 to be read as 2020 or a future date).
If isMathValid is true, all line multiplications check out, and textClarity is high, we don't need to waste latency or tokens—the extraction is mathematically proven to be consistent.
3. Dual-Model Consensus (Only When the Math Fails)
What happens when totalDiff > 0.05?
Maybe the receipt has 15 grocery items, and the sum of the items is 142.50, but the printed total at the bottom is 152.50. One of the items (or the total itself) was misread by 10 units.
Calling the same model a second time rarely helps; it usually makes the exact same visual assumption. Instead, our pipeline triggers a Consensus Fallback using a second, independent vision architecture (crossing Google Gemini with OpenAI's gpt-4o-mini vision).
Once we have outputs from both Model A and Model B:
- We run the Deterministic Math Gate on Model B's output. If Model B's line items add up cleanly to the printed total (
totalDiff <= 0.02), Model B wins automatically. - If both models struggle (for instance, a receipt was torn or folded right across a price), our comparator diffs the two item lists line by line and pinpoints the exact row where the two models disagreed.
4. Honest UX: Flagging Discrepancies Instead of Hiding Them
The biggest takeaway from that Reddit thread was simple: users don't expect a scanner to read an unreadable receipt, but they hate when software silently lies to them.
If the deterministic math gate still finds a gap after consensus, we don't silently insert a fake "adjustment" row or pretend everything is fine.
Instead, FINpulse opens a Reconciliation View right next to the receipt photo:
- Lines that passed mathematical validation are marked green.
- The specific line item (or total) where the math didn't add up is highlighted in amber, showing both the sum of items and the printed receipt total.
- The user can tap the highlighted number, type the correction in two seconds, and save.
Once verified, the receipt items can automatically cross off products from the user's active shopping list or be split 50/50 inside a shared family workspace.
Wrapping Up
Combining LLMs with deterministic rules completely changed how reliable our document pipeline feels in production. LLMs are fantastic at layout-agnostic parsing—figuring out that "LAPTE 3.5% 2 BUC X 6.50" is a dairy product with quantity: 2 and unitPrice: 6.50 across hundreds of different store formats. But arithmetic and verification belong in deterministic code, not in neural weights.
If you're curious to test how the hybrid receipt scanner and PDF/CSV bank statement importer work in practice, you can try the free tier of FINpulse (no bank login credentials required) or check out the architecture details on our overview page.
How are you handling validation and hallucinations when extracting structured financial data with LLMs? I'd love to hear what patterns worked for your stack in the comments.
Top comments (0)