Receipt Sorter:不離開手提電腦的自由工作者報税季收據整理工具
Receipt Sorter: a freelancer's tax-season tool that never leaves the laptop
Receipt Sorter 是面向自由工作者的本機收據整理工具,可把收據及發票整理成 CSV,並按類別分類和重新命名檔案。工具透過 Ollama 在本機執行開放權重模型 Qwen2.5 3B,整個流程可在沒有網絡連線時運作。無法可靠讀取的文件會列入 needs_review.txt 供人工檢查,而非自行猜測。
This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle
What I Built
Receipt Sorter turns a messy folder of receipts and invoices into a clean CSV and a tidy, renamed folder structure. For each document it extracts the vendor, date, total, currency and a category (software, travel, meals, office, equipment, utilities, other). Files are copied into category folders and renamed like 2026-09-14_Notion_10.00.pdf. Anything it can't read confidently goes into a needs_review.txt list instead of being silently guessed.
Who would pay: freelancers, solo consultants and small studios who lose hours every tax season sorting receipts, and who often handle client documents they shouldn't upload to a random cloud service. A one-time license makes sense here because the tool has no running cost per document.
Demo
[PASTE YOUR VIDEO OR DEMO LINK HERE]
The demo runs with Wi-Fi turned off.
Code
import csv, shutil, sys
from pathlib import Path
import ollama
from pydantic import BaseModel
from pypdf import PdfReader
MODEL = "qwen2.5:3b"
CATEGORIES = "software, travel, meals, office, equipment, utilities, other"
class Receipt(BaseModel):
vendor: str
date: str # YYYY-MM-DD
amount: float
currency: str
category: str
def read_text(path: Path) -> str:
if path.suffix.lower() == ".pdf":
return "\n".join(p.extract_text() or "" for p in PdfReader(path).pages)
return path.read_text(errors="ignore")
def extract(text: str) -> Receipt:
prompt = (
"Extract the vendor, date (YYYY-MM-DD), total amount, currency code and "
f"category from this receipt. Category must be one of: {CATEGORIES}.\n\n"
+ text[:6000]
)
r = ollama.chat(
model=MODEL,
messages=[{"role": "user", "content": prompt}],
format=Receipt.model_json_schema(),
options={"temperature": 0},
)
return Receipt.model_validate_json(r.message.content)
def main(src: str, dst: str):
src, dst = Path(src), Path(dst)
dst.mkdir(exist_ok=True)
rows, failed = [], []
for f in sorted(src.iterdir()):
if f.suffix.lower() not in {".pdf", ".txt"}:
continue
rec = None
for _ in range(2): # one retry if the JSON is bad
try:
rec = extract(read_text(f))
break
except Exception:
pass
if rec is None:
failed.append(f.name)
continue
folder = dst / rec.category
folder.mkdir(exist_ok=True)
vendor = "".join(c for c in rec.vendor if c.isalnum())[:20]
new_name = f"{rec.date}{vendor}{rec.amount:.2f}{f.suffix}"
shutil.copy(f, folder / new_name)
rows.append({**rec.model_dump(), "file": new_name})
with open(dst / "receipts.csv", "w", newline="") as out:
w = csv.DictWriter(
out, fieldnames=["file", "vendor", "date", "amount", "currency", "category"]
)
w.writeheader()
w.writerows(rows)
if failed:
(dst / "needs_review.txt").write_text("\n".join(failed))
print(f"Sorted {len(rows)} files, {len(failed)} need review.")
if name == "main":
main(sys.argv[1], sys.argv[2])
How I Built It
- Model: Qwen2.5 3B, an open-weight model, running locally through Ollama.
-
Extraction: text is pulled from each PDF with
pypdf, then sent to the model with a strict JSON schema (Ollama's structured output) so the reply always has the fields I need. - Validation: the reply is parsed with Pydantic. If it fails, the tool retries once. If it fails again, the file is listed for manual review rather than guessed at.
- Output: a CSV for the accountant plus category folders with consistent file names, all in about 70 lines of Python.
- Not done yet: photographed receipts. That needs a vision model, which is the next thing I'd add.
Why Does Open Innovation Matter?
- Privacy: receipts and invoices often contain client names, project details and bank information. With a local open-weight model, none of it leaves the machine, so there is nothing to disclose and no vendor to trust.
- Works offline: the whole pipeline runs with no internet connection.
- Cost: there is no per-document API fee. That is what makes a one-time-price product viable, because a closed API would turn every receipt into a recurring cost I'd have to pass on to the customer.
- Control: the model is a one-line setting. I can swap in a bigger one for accuracy, or a smaller one for speed on older laptops, and categories are just a list in the prompt that a user can edit for their own bookkeeping.
- No lock-in: if a provider changes pricing or retires a model, the tool keeps working exactly as it did.
Thanks for reading!
來源:DEV Community · dev.to