Back to all projects

2026 · Solo developer

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.

  • python
  • fastapi
  • sqlmodel
  • sqlite
  • react
  • tanstack-query
  • ollama
  • llm
Personal Expenses Tracker cover image

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.

From statement upload to categorized dashboard, with zero cloud calls.

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.

Dashboard for 2026: income, expenses, net and savings-rate cards, a monthly income vs expenses bar chart, and a donut of expenses by category
LayerTechnology
BackendFastAPI, SQLModel, Alembic (Python 3.12, uv)
DatabaseSQLite, plus automatic backups before every import
Extractionpdfplumber, pandas, pydantic schemas
LLMAny OpenAI-compatible local server: Ollama, LM Studio, llama.cpp
FrontendReact 19, Vite, TanStack Query, Recharts, Radix UI
API contractOpenAPI → openapi-typescript → openapi-fetch
QualityRuff, 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 format set to the JSON schema, temperature: 0, and “thinking” turned off. Any other server gets the portable OpenAI-compatible json_schema response 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" and 25/03/2026 into the right types instead of rejecting the whole chunk.
LLM settings: presets for Ollama, LM Studio or a custom server, base URL, optional API key and model picker

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.

Category settings: a sortable list of categories, each with a kind, group, color, LLM hint and exclude toggle

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.

Import review for a synthetic statement: 24 transactions extracted, low-confidence rows highlighted, each with an editable category and a confidence score

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-api generates 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/Avere columns and 1.234,56 amounts.
  • 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.1 only, takes a database backup before every import (keeping the last 20), and keeps all personal data under a gitignored data/ 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 EU and friends into one counterparty so that rules and dashboards stop splitting them.