Personal Expenses Tracker
A local-only expenses tracker. Drop in bank statements as PDF, CSV or XLSX, and a local LLM extracts, deduplicates and classifies every transaction into your own categories.
Every bank exports statements differently. One gives you a PDF with wrapped lines, another a CSV with Italian number formatting, a third an XLSX with merged header cells. I wanted one place that reads all of them and tells me where my money goes. I also didn’t want to send a single statement to a third-party API. So the whole pipeline runs against a local LLM, and all the data stays on my machine.
What it is
A single-user web app that runs on localhost. You upload a statement, a background job extracts the transactions, drops duplicates, classifies them, and hands you a review screen. A dashboard then breaks spending down by category, month and year, and an Advice page asks the LLM for a few practical tips, grounded only in your own numbers.
| Layer | Technology |
|---|---|
| Backend | FastAPI, SQLModel, Alembic (Python 3.12, uv) |
| Database | SQLite, plus automatic backups before every import |
| Extraction | pdfplumber, pandas, pydantic schemas |
| LLM | Any OpenAI-compatible local server: Ollama, LM Studio, llama.cpp |
| Frontend | React 19, Vite, TanStack Query, Recharts, Radix UI |
| API contract | OpenAPI → openapi-typescript → openapi-fetch |
| Quality | Ruff, pytest, ESLint, GitHub Actions CI |
The import pipeline
One import is one background job. It moves through explicit states
(extracting → classifying → review → done) and reports progress to the UI as
it goes:
statement file
→ extract_text PDF / CSV / XLSX → normalized text chunks (header repeated per chunk)
→ llm_extract chunk → pydantic ExtractionResult (transactions + balances)
→ reconcile opening balance + Σ amounts vs. closing balance
→ dedupe stable hash per transaction, unique index rejects repeats
→ classify user rules first, then batched LLM classification
→ review low-confidence rows flagged for a human
Each step works around a specific way this goes wrong in practice.
Chunking keeps the table header. Statements are split into chunks of about 60 lines, and every chunk after the first gets the table header prepended. Without it, the model has to guess which column is the debit and which is the credit, and on Italian statements it guesses wrong.
Reconciliation is a free correctness check. When a statement prints an
opening and a closing balance, the pipeline checks that
opening + Σ(amounts) == closing and stores the difference. If the model
dropped or invented a row, the difference is non-zero, and the review screen
shows it. The check is plain arithmetic.
Structured output from a small model
Every LLM call goes through one function, chat_json, which takes a pydantic
schema and returns a validated instance. That function is the only entry point
to the model:
def chat_json[T: BaseModel](system: str, user: str, schema: type[T]) -> T:
...
for _attempt in range(2):
try:
content = call_model(messages, schema) # native Ollama or OpenAI-compatible
content = _strip_think(content)
return schema.model_validate_json(content)
except ValidationError as exc:
messages.append({"role": "assistant", "content": content})
messages.append({
"role": "user",
"content": f"Your previous response was invalid: {exc}. Reply with valid JSON only.",
})
raise LLMError(...)
Several decisions sit behind that function:
- Constrained decoding where available. When the server is Ollama, the call
goes to its native API with
formatset to the JSON schema,temperature: 0, and “thinking” turned off. Any other server gets the portable OpenAI-compatiblejson_schemaresponse format. - One retry, with the error in the prompt. If validation fails, the pydantic error is sent back to the model as the next turn. One retry fixes most failures, and a single retry puts a bound on latency.
- Tolerant validators. Even when the prompt explicitly asks for ISO dates
and
.decimals, small models sometimes echo the source format. Field validators convert"1.234,56"and25/03/2026into the right types instead of rejecting the whole chunk.
Classification: rules first, LLM second
Paying for an LLM call to classify your monthly rent payment is wasted work.
User-defined rules run first. They are matched longest-first against a
whitespace-normalized description, because PDF extraction wraps
MAIN STREET 7 across two lines. Only the transactions no rule matched go to
the model, in batches.
The classification schema is built at runtime from the user’s own categories, so the model can’t return a category that doesn’t exist:
def _build_category_schema(category_names: list[str]) -> type[BaseModel]:
category_literal = Literal[*category_names]
result_model = create_model(
"DynamicClassificationResult",
id=(int, ...),
category=(category_literal, ...),
confidence=(float, ...),
reason=(str, ...),
)
return create_model("DynamicClassificationBatch", results=(list[result_model], ...))
The categories themselves are plain data the user edits. Each one has a kind, an optional group, a color and a free-text hint that is passed to the model.
The prompt includes the 30 most recent transactions the user has confirmed, as few-shot examples. Every correction the user makes therefore improves the next import, without any fine-tuning. On top of that, a sanity check halves the confidence when the model puts an outgoing payment into an income category. Anything below 0.7 is flagged for review.
Deduplication across overlapping statements
Statements overlap. A monthly PDF and a quarterly CSV export cover the same
days, and so do two downloads of the same month. Each transaction gets a
SHA-256 hash of (account, date, amount, normalized description, occurrence index), and a unique index on that hash rejects duplicates:
seen: dict[tuple[date, int, str], int] = {}
for txn_date, amount_cents, desc in items:
normalized_desc = normalize(desc) # lowercase, strip long digit tokens
key = (txn_date, amount_cents, normalized_desc)
occurrence_idx = seen.get(key, 0)
seen[key] = occurrence_idx + 1
hashes.append(dedupe_hash(account_id, txn_date, amount_cents, normalized_desc, occurrence_idx))
The occurrence index is what makes this work. Two identical coffees on the same day get different hashes. Re-importing an overlapping statement produces the same hashes in the same order, so the duplicates are rejected. Long digit tokens, such as transaction reference numbers that change between export formats, are removed before hashing.
The rest of the product
- End-to-end types.
make gen-apigenerates the frontend’s TypeScript types from FastAPI’s OpenAPI schema. There is one TanStack Query hook per endpoint, and nobody edits the generated types by hand. - English and Italian. The UI strings, the extraction prompts and the
default categories all exist in both languages. The Italian prompt spells out
the rules for
Dare/Averecolumns and1.234,56amounts. - Demo dataset. A synthetic dataset lets anyone browse the dashboard without a local LLM or any real statements.
- Safe by default. The app binds to
127.0.0.1only, takes a database backup before every import (keeping the last 20), and keeps all personal data under a gitignoreddata/directory.
All screenshots use the synthetic demo dataset.
What I’d do next
- An extraction eval set. A handful of anonymized statements with hand-checked transactions, so that a model or prompt change is measured by precision and recall rather than by feel.
- Confidence calibration. Right now “0.7” is a threshold I picked. The confirmed and corrected transactions already in the database are the data I need to check whether the model’s confidence means anything.
- Merchant normalization. Group
AMZN MKTP,AMAZON EUand friends into one counterparty so that rules and dashboards stop splitting them.