# LedgerLocal: a private invoice reader for Indian freelancers, running on Gemma 4

> Source: <https://dev.to/tanmayhutt/ledgerlocal-a-private-invoice-reader-for-indian-freelancers-running-on-gemma-4-3b4j>
> Published: 2026-10-17 08:48:24+00:00

*This is a submission for the [Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle](https://dev.to/challenges/hacktoberfest-week2-2026-10-12)*

In India, freelancers and small businesses who want to claim GST input tax credit have to type every bill into a spreadsheet: vendor name, GSTIN, tax and total, one bill at a time. Those bills arrive as WhatsApp photos, PDFs in email, and pages torn from handwritten bill books. I wanted a tool that does the typing for me, without uploading private bills to someone else's server.

**LedgerLocal** is a small web app that runs on your own laptop. You drop in photos, screenshots or PDFs of invoices, Google's open-weight **Gemma 4** model reads them, and you get a clean table you can export as CSV. Nothing is uploaded anywhere.

The part I care most about is that it does not blindly trust the model. After Gemma returns the fields, plain Python checks them:

Rows that fail a check are marked **Needs a look** with the reason, so you review three rows instead of thirty. You can fix any cell inline before exporting.

**Who would pay for it:** freelancers and small agencies who file GST, and the accountants who process their receipts every month. The code is free and open source under MIT, and anyone technical can run it today. The side hustle is for everyone else: a one-click Mac app that bundles the model and needs no Terminal, sold once for about Rs. 999. Because there is no per-invoice API cost, a one-time price works instead of yet another subscription.

The repo includes two sample invoices. The cowork invoice has two deliberate mistakes: the total is Rs. 100 too high and the GSTIN check digit is wrong. Here is what happens when I drop both onto the page:

The hosting invoice passes every check. The cowork invoice is flagged, and the reasons are spelled out:

Each read took between 5 and 6 seconds on my Mac, with the model running locally.

A private invoice reader for Indian freelancers and small businesses. Drop in receipt photos, screenshots or PDF invoices. Your Mac reads the text, Google's open-weight **Gemma 4** model running locally in llama.cpp pulls out the fields, and LedgerLocal checks the results before you trust them:

You review the flagged rows, fix any cell inline, and export a CSV for your accountant or…

No pip install. The server uses only the Python standard library, the page is plain HTML, CSS and JavaScript, and the model runs in llama.cpp:

```
brew install llama.cpp poppler
LEDGERLOCAL_GGUF=/path/to/gemma-4-E2B.gguf python3 -m ledgerlocal
```

The pipeline has four small steps, all on the laptop:

`pdftotext`. Photos and scanned PDFs go through Apple's on-device Vision OCR, using a 20-line Swift helper that the app compiles once.`llama-server`` Rs. 1,23,456.50`, a dozen date formats, and the tax arithmetic are all plain Python with unit tests.
The prompt also tells the model that the invoice text is a document to read, not instructions to follow, so a malicious "ignore previous instructions" line on a receipt does nothing.

**What surprised me:** on my first run, Gemma 4 E2B read a hosting invoice perfectly except for one thing. The invoice charged 18% IGST, and the model put that amount in the CGST field, because CGST came first in my schema. My own tax check caught it immediately ("CGST and SGST should be equal"), which was exactly the point of having checks. Instead of fighting the prompt, I changed the design: the model now copies each tax line exactly as printed ("IGST @ 18%", 540.00), and Python decides whether it is CGST, SGST or IGST from the label. A 2B model is very good at copying and less good at categorising, so I let each side do what it is good at.

**The bigger lesson came from a real bill.** I tried a handwritten bill book from a clothing shop: crossed-out lines, scribbles at the top, no printed shop name, no GSTIN, no date. The OCR could only read parts of it. Gemma did not say "I don't know". It filled every field anyway, with a made-up GSTIN, a 2023 date and tax amounts that are nowhere on the bill. My maths checks could not catch that, because invented numbers can still add up.

So I added the check I now think matters most: the model only ever sees the bill's text, so every value it returns must be findable in that text. Amounts are matched as numbers (so `3,540.00` matches `3540`), codes are matched ignoring spaces and punctuation, and dates are matched in several formats. Anything not found is removed and listed. On that handwritten bill, LedgerLocal now says "Could not read this bill reliably. Enter it by hand" instead of producing a confident, wrong row. On the printed invoices, nothing changed. A tool for tax records should rather say "I can't read this" than guess.

I also tried a WhatsApp photo of a shop bill. The first version broke on it: the model kept repeating the same tax line until it ran out of room, and the answer came back unreadable. I capped the number of tax lines and made the code check each one: it must have a tax word like GST or VAT, and both the word and the amount must be on the bill. Now that bill loads, but the handwritten numbers are hard enough that the text reader saw the total 9,222 as 92,221. LedgerLocal marks the row "Needs a look" instead of trusting it, which is the behaviour I want.

**Then I measured it properly.** I collected 49 public documents that come with answer keys: real invoices from the invoice2data project (including Flipkart and OYO GST invoices), 12 photographed shop receipts from the ICDAR SROIE dataset, and 22 hard cases from an invoice benchmark: phone photos, coffee stains, fax-quality scans, handwritten bill books, tax-inclusive totals.

The first run was humbling: **14 of 49** fully correct. But every wrong answer was flagged, so nothing slipped through silently. The failures had boring, fixable causes:

`for example "CGST @ 9%"`, so on European VAT invoices the model wrote "CGST @ 9%". The "must be on the bill" check removed it every time. My example was the bug.`R 12 843,69` and `56,02` (space thousands, decimal comma) were read as the wrong numbers.`28/03/2018 10:36:13`, `2026/03/14`, `7. Mai 2014` and US-style `03/20/2023` were not parsed.
After fixing those: **29 of 49 fully correct, 20 flagged for review, 0 wrong-and-unflagged.** Along the way the second round produced 2 silent errors, where the model picked the *customer* as the vendor (a Dutch "Factuuradres" block and a German window-envelope layout). I added one more check: if the vendor name only appears under "Bill to", "Ship to", "M/s", "Factuuradres" and similar labels, the row is flagged. Back to zero.

|  | First run | Final | 
|---|---|---|
| Fully correct | 14 / 49 | 29 / 49 | 
| Wrong and not flagged | 0 | 0 | 
| Dates right | 32 / 49 | 42 / 49 | 
| Totals right | 34 / 47 | 41 / 47 | 
| Tax right | 6 / 22 | 18 / 22 | 

The evaluation script and the list of documents are in the repo, so anyone can rerun it.

Invoices carry client names, tax IDs, bank details and amounts. With a closed API, I would be sending all of that to someone else's servers and paying for every page. An open-weight model changes the product:

It also runs fast enough to be practical: about 5 to 10 seconds per invoice on my Mac.
