This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle
What I Built
gstsort reads a folder of GST invoices, PDFs or phone photos, and tells you which ones you can claim input tax credit on and which ones you can't yet.
Every Indian business registered for GST claims back the tax it paid on purchases, and the claim only holds if the invoice is right: the supplier's GSTIN is valid, it's billed to your GSTIN, the tax type matches the states involved, and the numbers add up. Freelancers and small agencies collect a pile of these every month and check them by hand, or pay their CA to, and a CA office does the same for dozens of clients. Those are the people who'd pay for this: the freelancer gets a clean sheet in a few minutes instead of an evening, and the CA office gets a first pass over every client's folder before anyone opens a PDF.
Demo
I ran it on ten sample invoices with made-up suppliers. Six of them have a problem planted in them, and it found all six:
All ten took about two minutes on a laptop with 16 GB of RAM, 12 to 16 seconds an invoice, with nothing going over the network.
One catch wasn't planted. On the Inkwell invoice the model read the GSTIN 27ABZPI9054E1Z4 as 27ABZP19054E1Z4, an I turned into a 1. A wrong GSTIN in your purchase register won't reconcile with GSTR-2B, and that's credit you don't get. A GSTIN has a check digit, and the PAN inside it has letters and digits in fixed slots, so gstsort put an I back in the slot that has to be a letter, recomputed the check digit, saw it match, and fixed it. When a fix doesn't make the check digit agree, like the Lensbox invoice where the GSTIN printed on the invoice itself is wrong, it leaves it alone and flags it for a human.
Code
gstsort
Point it at a folder of GST invoices (PDFs or phone photos) and it gives you a CSV of everything on them, plus a list of the ones you shouldn't claim input tax credit on yet. A local open-weight vision model reads the invoices, so they never leave your machine.
$ gstsort -gstin 27AKXPS4821M1Z7 samples/
...
06-typo-gstin.pdf (Lensbox Camera Rentals LB-26-0391)
- supplier GSTIN "27AAKFL4410C1ZQ" fails the checksum, recheck it on the invoice
07-total-mismatch.pdf (INKWELL STATIONERS IW-2207)
- taxable 1580.00 + tax 284.40 + cess 0.00 doesn't add up to total 1964.40 (off by -100.00)
~ read supplier GSTIN as 27ABZP19054E1Z4, fixed to 27ABZPI9054E1Z4 (checksum confirms)
...
10 invoices, 4 clean. Input tax credit ready to claim: ₹3437.82. Held back for review: ₹2406.56
Wrote invoices.csv
What it checks
The model only reads. Everything after that is plain code:
- GSTIN format and the mod-36 check digit. A misread look-alike (an…
How I Built It
The model is Qwen2.5-VL-7B-Instruct, an open-weight vision model, quantized to 4 bits (Q4_K_M GGUF) and served by llama.cpp's llama-server on localhost. The CLI is about 400 lines of Go.
For each invoice:
- PDFs get rendered to PNG with pdftoppm, and phone photos get scaled down so the long side is 1400 px. A 4000 px photo blows past the context window of a 7B vision model.
- The image goes to llama-server's OpenAI-compatible endpoint with a JSON schema in
response_format. llama.cpp turns that schema into a grammar, so the model can only produce valid JSON with the fields I asked for: GSTINs, invoice number, date, taxable value, CGST, SGST, IGST, cess, total and the HSN/SAC codes. - Everything after that is plain Go. The model reads the invoice and never decides whether a credit is claimable.
The checks:
- GSTIN format and the mod-36 check digit, with the look-alike repair above
- billed to your GSTIN, and no buyer GSTIN means it's a B2C bill with no credit
- same state means CGST + SGST, different states means IGST
- CGST equals SGST, the effective rate is a real GST slab, and taxable + tax + cess equals the total
- restaurant services (SAC 99633x), which are usually blocked credit under section 17(5)
- the same supplier and invoice number showing up twice
The output is a CSV with every field, the credit you can claim on each invoice, and the reason for anything held back. Place-of-supply edge cases like a hotel stay in another state still need a human, and the README says so.
I tried letting the model pick an expense category too. It labeled almost everything "travel", including a broadband bill. So that field went, and the food check reads the SAC code instead, which is printed on the invoice and doesn't need guessing.
Why Does Open Innovation Matter?
Invoices are other people's financial data. A CA office holds them for dozens of clients and has every reason not to upload them to someone else's API. With an open-weight model on llama.cpp nothing leaves the machine, it works with the internet off, and there's no per-page API bill, so running a whole year of invoices through it is fine.
Running the inference server myself also means I control the decoding. The JSON schema is enforced as a grammar while the model generates, so I never parse free text and hope. When a better open vision model comes out, I point llama-server at it and nothing else changes.



Top comments (1)
The checksum-confirmed repair loop is the right shape, and the Lensbox case - fix doesn't check out, so leave it and flag - is the honest half of it. The fixture I'd add covers the dangerous middle: a repair that passes the checksum but lands on a different real GSTIN. With one mod-36 check character, a misread slot "corrected" the wrong way has roughly a 1-in-36 shot at validating, so checksum-confirmed is evidence, not proof. The second signal is already in your data: the GSTIN embeds the supplier's PAN, and you have the supplier name from the invoice plus, over time, a vendor master. Pin a fixture where the model's misread repairs into a checksum-valid GSTIN whose PAN belongs to a different known entity, and assert it goes to the human queue, not the CSV. The other gap in the demo eval: six planted problems found is recall. Add a control set of clean invoices the model read correctly and assert they come out byte-identical - that's what catches an over-eager repair pass "fixing" GSTINs that were never broken.