DEV Community

Ibnu Syifa Takbir Adha
Ibnu Syifa Takbir Adha

Posted on

ReceiptBox: a local Gemma receipt sorter for freelancers that refuses to guess

Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle Submission 💸

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle

What I Built

ReceiptBox is a small command-line tool for freelancers who dread tax season. Drop receipt and invoice text into a folder, run one command, and get an expenses.csv with date, vendor, category and total for each one. Anything it is not sure about goes into a "needs review" list instead of the spreadsheet.

Who would pay for it? Anyone who keeps a shoebox (or phone gallery) of receipts and hands them to an accountant once a year: freelancers, small shop owners, a friend running a side business. The honest pitch is not "AI reads receipts", which is common. It is "your clients' invoices never leave your laptop, and a wrong number never silently reaches your books."

Honest note: I have no first user and no revenue to report. I have not shown this to anyone, so there is no "did they pay" story. Treat it as a working prototype with tested logic, not a validated business.

Demo

ollama pull gemma3
python3 receiptbox.py receipts
Enter fullscreen mode Exit fullscreen mode

Expected output shape:

1 accepted -> expenses.csv | 1 need review
  REVIEW hosting.txt: total not found in receipt text
Enter fullscreen mode Exit fullscreen mode

That output comes from my test, where a scripted stand-in for the model reports a wrong total for one receipt. I have not run this against a real Gemma instance, so extraction accuracy on real receipts is untested, and it only reads plain .txt receipts (no OCR or PDFs yet).

Code

Stdlib-only Python, one file plus tests.

receiptbox.py:

#!/usr/bin/env python3
"""ReceiptBox: a local receipt sorter for freelancers. Nothing leaves your laptop.

A local open-weight model (Gemma via Ollama) reads each receipt's text and proposes
{vendor, date, total, currency, category}. Plain Python then validates every field,
checks the numbers against the receipt text, and sends anything doubtful to a
"needs review" pile instead of guessing. Stdlib only.
"""
import csv, json, re, sys, urllib.request
from datetime import date, datetime
from pathlib import Path

OLLAMA, MODEL = "http://localhost:11434/api/generate", "gemma3"
CATEGORIES = ["software", "travel", "meals", "equipment", "office", "other"]

def ask_model(text):
    prompt = ("Extract from this receipt. Reply ONLY as JSON: "
              '{"vendor": str, "date": "YYYY-MM-DD", "total": number, "currency": str, '
              f'"category": one of {CATEGORIES}}}\n\nRECEIPT:\n{text}')
    body = json.dumps({"model": MODEL, "prompt": prompt, "stream": False, "format": "json"}).encode()
    req = urllib.request.Request(OLLAMA, body, {"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=300) as r:
        return json.loads(json.loads(r.read())["response"])

def numbers_in(text):
    """All money-like numbers in the receipt text, as floats."""
    out = []
    for m in re.findall(r"\d[\d.,]*\d|\d", text):
        s = m
        if re.search(r"[.,]\d{2}$", s):
            s = re.sub(r"[.,](?=\d{3}\b)", "", s[:-3]) + "." + s[-2:]
        else:
            s = re.sub(r"[.,]", "", s)
        try: out.append(float(s))
        except ValueError: pass
    return out

def check(rec, text, today=None):
    """Return a list of problems; empty list means the record is accepted."""
    today = today or date.today()
    problems = []
    if not str(rec.get("vendor", "")).strip():
        problems.append("missing vendor")
    try:
        d = datetime.strptime(str(rec.get("date")), "%Y-%m-%d").date()
        if d > today or d.year < today.year - 2: problems.append("implausible date")
    except ValueError:
        problems.append("bad date")
    try:
        total = float(rec.get("total"))
        if total <= 0: problems.append("non-positive total")
        elif not any(abs(total - n) < 0.005 for n in numbers_in(text)):
            problems.append("total not found in receipt text")  # catches hallucinated amounts
    except (TypeError, ValueError):
        problems.append("bad total")
    if rec.get("category") not in CATEGORIES:
        problems.append("unknown category")
    return problems

def sort_receipts(folder, ask=ask_model, today=None):
    ok, review = [], []
    for p in sorted(Path(folder).glob("*.txt")):
        text = p.read_text()
        try:
            rec = ask(text)
        except Exception as e:
            review.append((p.name, ["model error: %s" % e], {})); continue
        probs = check(rec, text, today)
        (review if probs else ok).append((p.name, probs, rec))
    return ok, review

def main():
    folder = sys.argv[1] if len(sys.argv) > 1 else "receipts"
    ok, review = sort_receipts(folder)
    with open("expenses.csv", "w", newline="") as f:
        w = csv.writer(f); w.writerow(["file", "date", "vendor", "category", "total", "currency"])
        for name, _, r in ok: w.writerow([name, r["date"], r["vendor"], r["category"], r["total"], r.get("currency", "")])
    print(f"{len(ok)} accepted -> expenses.csv | {len(review)} need review")
    for name, probs, _ in review: print(f"  REVIEW {name}: {', '.join(probs)}")

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Example receipts used by the tests, receipts/cafe.txt:

KOPI TUKANG
2026-10-02
2x Latte      90.00
Total        90.00 THB
Enter fullscreen mode Exit fullscreen mode

and receipts/hosting.txt:

Cloudhost Ltd. Invoice
Date: 2026-09-28
Annual plan USD 1,240.50
Enter fullscreen mode Exit fullscreen mode

test_receiptbox.py (passes, using a fake model):

from datetime import date
import receiptbox as r
T = date(2026, 10, 13)
cafe = open("receipts/cafe.txt").read(); host = open("receipts/hosting.txt").read()
good = {"vendor":"Kopi Tukang","date":"2026-10-02","total":90.0,"currency":"THB","category":"meals"}
assert r.check(good, cafe, T) == []
# hallucinated amount is caught by cross-checking the receipt text
assert "total not found in receipt text" in r.check({**good, "total": 900.0}, cafe, T)
# thousands separator and decimals parse: 1,240.50 -> 1240.5
assert 1240.5 in r.numbers_in(host) and 90.0 in r.numbers_in(cafe)
assert r.check({"vendor":"Cloudhost","date":"2026-09-28","total":1240.5,"currency":"USD","category":"software"}, host, T) == []
assert "bad date" in r.check({**good, "date": "last tuesday"}, cafe, T)
assert "implausible date" in r.check({**good, "date": "2027-01-01"}, cafe, T)
assert "unknown category" in r.check({**good, "category": "yachts"}, cafe, T)
# end to end with a fake model: one good, one hallucinated -> routed to review
answers = iter([good, {"vendor":"Cloudhost","date":"2026-09-28","total":12405.0,"currency":"USD","category":"software"}])
ok, review = r.sort_receipts("receipts", ask=lambda t: next(answers), today=T)
assert len(ok) == 1 and len(review) == 1 and review[0][0] == "hosting.txt"
print("all tests passed")
Enter fullscreen mode Exit fullscreen mode

How I Built It

  • Open-weight model: Gemma through Ollama's local HTTP API, with format: "json" so the output can be parsed. It only proposes fields.
  • Verify, don't trust: every proposed total must actually appear as a number in the receipt text. A model that misreads "1,240.50" as 12405 gets caught, and the receipt lands in the review pile. Dates must parse and be plausible, and categories must come from a fixed list.
  • Fail toward a human: if the model is down or returns junk, the file goes to review rather than being skipped or guessed.
  • Zero dependencies, so it installs anywhere Python runs.

Known limits: the check confirms a number exists in the receipt, not that it is the grand total (a subtotal would pass). Plain text only. Single currency per file. Those are the first things I would fix before charging anyone.

Why Does Open Innovation Matter?

For this particular side hustle, privacy is the product. A freelancer's invoices reveal their clients, rates and cash flow, and many of those clients would not be happy to learn the documents were uploaded to a third-party AI service. Running an open-weight model locally means the tool can honestly say nothing leaves the machine, works offline on a train, and has no per-receipt API bill eating into a business that earns a few dollars per customer. Because the model is a swappable string, a small model is enough, and the validation layer is what makes small models safe to rely on.

My First User

None yet, so I am leaving this honestly empty. If you are a freelancer who would try it on your own receipts, I would genuinely like to hear what breaks.

Prize Categories

Best Use of Gemma: Gemma, served locally via Ollama, does the extraction at the core of the project.

Top comments (0)