A local-first personal finance manager that uses AI to read your bank statements, receipts, and investment reports — then gives you monthly insights to help you reach financial independence faster.
Everything runs on your own machine. Your financial data never leaves your network.
- What this app does
- Tech stack
- Project structure
- Architecture — the big picture
- Getting started
- Running the tests
- Database migrations
- API reference
- Document parsing — how it works
- How the layers talk to each other
- Key design decisions
- What has been built
- What is coming next
- Upload a PDF bank statement, investment statement, or a photo of a receipt
- PDFs are parsed locally using rules-based text extraction — free, accurate, private
- Image receipts are read by a cloud vision AI (Gemini or Claude) and returned as structured data
- All transactions are stored in a local SQLite database
- Users can correct any extraction errors directly in the app
- At the end of the month, an LLM generates a personalised financial summary with FIRE-focused tips
- Two users (e.g. you and your partner) each have their own data — no passwords, just profile selection
| Layer | Technology |
|---|---|
| Backend API | Python 3.12, FastAPI |
| Database | SQLite + SQLAlchemy ORM + Alembic migrations |
| PDF parsing | PyMuPDF — local, free, no API key needed |
| Receipt parsing | Google Gemini or Anthropic Claude via direct REST calls (httpx) |
| Insight generation | Ollama (local LLM — qwen3 or similar) |
| Package manager | uv |
| Testing | pytest + pytest-asyncio |
| Frontend (coming) | Vite + React + TypeScript |
| Deployment (coming) | Docker Compose |
fire_backend/
│
├── alembic/ # Database migration files
│ ├── env.py # Alembic config — reads FIRE_DB_PATH or default
│ └── versions/
│ └── xxxx_initial_schema.py # First migration — all 5 tables
│
├── db/ # SQLite database (git-ignored)
│ └── fire.db
│
├── src/
│ └── fire/
│ ├── config/
│ │ └── settings.py # Pydantic BaseSettings — reads from .env
│ │
│ ├── domain/ # Innermost layer — zero dependencies
│ │ ├── entities/
│ │ │ ├── user.py # Household member (no password)
│ │ │ ├── document.py # Uploaded file with status lifecycle
│ │ │ ├── transaction.py # Single financial record
│ │ │ ├── account.py # Bank or investment account
│ │ │ ├── budget_insight.py # LLM monthly analysis
│ │ │ └── storage_config.py # Path value object (files/ and db/)
│ │ └── interfaces/
│ │ ├── repositories.py # IUserRepo, IDocumentRepo, etc.
│ │ └── services.py # ILLMDocumentParser, IFileStorage, etc.
│ │
│ ├── application/ # Business logic — no frameworks
│ │ └── use_cases/
│ │ ├── ingest_document.py # Hash, dedup, store, create Document
│ │ ├── extract_transactions.py # Parse file, map to entities, persist
│ │ ├── get_monthly_summary.py # Aggregate transactions by month
│ │ └── generate_insights.py # Call LLM, persist BudgetInsight
│ │
│ ├── infrastructure/ # Concrete implementations
│ │ ├── db/
│ │ │ ├── models.py # SQLAlchemy ORM models (separate from entities)
│ │ │ └── session.py # Engine + sessionmaker factory
│ │ ├── repositories/
│ │ │ ├── user_repository.py
│ │ │ ├── document_repository.py
│ │ │ ├── transaction_repository.py
│ │ │ └── account_insight_repositories.py
│ │ ├── llm/
│ │ │ ├── pdf_text_parser.py # Local rules-based PDF parser
│ │ │ ├── gemini_document_parser.py # Gemini REST API via httpx
│ │ │ ├── claude_document_parser.py # Claude REST API via httpx
│ │ │ ├── ollama_document_parser.py # Local Ollama (experimental)
│ │ │ ├── ollama_insight_generator.py # Monthly insight via local LLM
│ │ │ └── document_parser_factory.py # Routes to correct parser
│ │ └── file_storage/
│ │ └── local_file_storage.py # Saves to root/files/DD-MM/
│ │
│ ├── api/
│ │ ├── main.py # FastAPI app — CORS + router mounting only
│ │ ├── dependencies.py # Depends() wiring for all repos and use cases
│ │ ├── routes/
│ │ │ ├── health.py # GET /health
│ │ │ ├── users.py # POST/GET /users
│ │ │ ├── documents.py # POST /documents/upload, GET /documents
│ │ │ ├── transactions.py # GET/PATCH/DELETE /transactions
│ │ │ └── insights.py # POST /insights/generate, GET /insights
│ │ └── schemas/
│ │ ├── user.py
│ │ ├── document.py
│ │ ├── transaction.py
│ │ └── insight.py
│ │
│ └── main.py # Entry point — imports api/main.py app
│
├── tests/
│ ├── fakes.py # In-memory fakes for every interface
│ ├── unit/
│ │ ├── domain/ # Entity logic — zero I/O
│ │ ├── application/ # Use case logic — fakes injected
│ │ └── infrastructure/ # Parser/settings/factory unit tests
│ └── integration/
│ ├── test_repositories.py # Real SQLite in-memory
│ ├── test_file_storage.py # Real temp directory
│ ├── test_api.py # TestClient + shared-memory SQLite
│ ├── test_live_llm.py # Real Gemini API (marked live, skipped by default)
│ └── data/ # Real fixture files (git-ignored)
│
├── scripts/
│ └── diagnose_llm.py # Dev tool — test model output on real files
│
├── .env.example # Copy to .env and fill in your keys
├── alembic.ini
└── pyproject.toml
This project follows Clean Architecture (Uncle Bob). The most important rule is the dependency rule:
Domain ← Application ← Infrastructure ← API ← Frontend
Inner layers know nothing about outer layers. The domain has no idea SQLite, Gemini, or FastAPI exist. The application layer only talks to interfaces — never to SQLAlchemy or httpx directly.
┌──────────────────────────────────────────────┐
│ Frontend (React) │
├──────────────────────────────────────────────┤
│ API Layer (FastAPI routes) │
├──────────────────────────────────────────────┤
│ Application (Use Cases) │ ← pure business logic
├──────────────────────────────────────────────┤
│ Domain (Entities + Interfaces) │ ← zero dependencies
├─────────────────┬────────────┬───────────────┤
│ Repositories │ LLM │ File Storage │ ← infrastructure
│ (SQLite) │(Gemini/PDF)│ (local disk) │
└─────────────────┴────────────┴───────────────┘
Adding a new receipt provider (e.g. OpenAI):
- Create
OpenAIDocumentParser(ILLMDocumentParser)ininfrastructure/llm/ - Add
OPENAI = "openai"toReceiptProviderenum insettings.py - Add one
elifbranch inDocumentParserFactory._build_image_parser() - Done — nothing else changes
- Python 3.12+
- uv —
pip install uv - Git
git clone <your-repo-url>
cd fire_backend
uv sync
uv pip install -e .cp .env.example .envEdit .env and fill in at minimum:
FIRE_DATA_ROOT=C:/Users/YourName/fire-data # where files and db are stored
GEMINI_API_KEY=AIza... # for reading image receiptsmkdir db
uv run alembic upgrade headuv run uvicorn fire.main:app --reload --host 0.0.0.0 --port 8000Open http://localhost:8000/docs for the interactive API documentation.
# Run everything (fast tests only — no API calls, no Ollama)
uv run pytest
# Unit tests only
uv run pytest tests/unit/
# Integration tests (real SQLite, real disk, no API keys needed)
uv run pytest tests/integration/ -m "not live"
# Live tests — requires GEMINI_API_KEY in .env and real fixture files
uv run pytest -m live -v -s
# With coverage
uv run pytest --cov=src/fireTest layout:
| Folder | What it tests | Needs |
|---|---|---|
tests/unit/domain/ |
Entity logic and invariants | Nothing |
tests/unit/application/ |
Use case logic | In-memory fakes |
tests/unit/infrastructure/ |
Parser, settings, factory logic | Nothing |
tests/integration/test_repositories.py |
SQL queries against SQLite | In-memory SQLite |
tests/integration/test_file_storage.py |
File read/write | Temp directory |
tests/integration/test_api.py |
HTTP routes end-to-end | Shared-memory SQLite |
tests/integration/test_live_llm.py |
Real Gemini + real receipts | GEMINI_API_KEY + fixture files |
153 tests, all green (8 live tests deselected by default).
# Apply all pending migrations (run after pulling new code)
uv run alembic upgrade head
# Check current version
uv run alembic current
# Generate a new migration after changing an ORM model
uv run alembic revision --autogenerate -m "describe_your_change"
uv run alembic upgrade head
# Roll back one migration
uv run alembic downgrade -1Never edit migration files by hand. Never commit db/fire.db.
Full interactive docs at http://localhost:8000/docs when the server is running.
| Method | Path | Description |
|---|---|---|
| GET | /health |
Liveness check |
| POST | /users |
Create a user profile |
| GET | /users |
List all profiles (for profile selector) |
| GET | /users/{id} |
Get a single user |
| POST | /documents/upload |
Upload PDF or image — triggers extract pipeline |
| GET | /documents?user_id= |
List documents for a user |
| GET | /documents/{id} |
Get a single document |
| GET | /transactions?user_id=&year=&month= |
List transactions for a month |
| GET | /transactions/{id} |
Get a single transaction |
| PATCH | /transactions/{id} |
Correct a transaction (amount, category, type) |
| DELETE | /transactions/{id} |
Delete a transaction |
| POST | /insights/generate?user_id=&year=&month= |
Generate monthly insight via LLM |
| GET | /insights?user_id=&year=&month= |
Get saved insight |
| GET | /insights/history?user_id= |
List insight history |
FIRE uses a two-track parsing strategy:
PDFs (bank statements, investment reports)
Text is extracted directly from the PDF using PyMuPDF. No API call, no network, completely free. A rules-based parser finds transaction rows anchored by DD.MM.YYYY date patterns, detects Soll/Haben columns for debit/credit direction, and classifies by keyword matching. Supports Sparkasse and most German bank formats.
Image receipts (photos, screenshots)
The image is base64-encoded and sent to the configured vision API. Gemini gemini-2.5-flash is the recommended default — fast, cheap, and accurate for German receipts. Claude is available as an alternative. The response is always structured JSON.
Changing provider = one line in .env:
FIRE_RECEIPT_PROVIDER=gemini # or: claude, ollama
GEMINI_API_KEY=AIza...Upload pipeline for a bank statement PDF:
1. POST /documents/upload (multipart form: file + user_id)
2. IngestDocument use case:
→ SHA-256 hash of file bytes
→ Reject if duplicate (same hash already exists)
→ Save file to data/files/DD-MM/filename.pdf
→ Create Document entity (status: PENDING)
→ Persist via IDocumentRepository
3. ExtractTransactions use case:
→ DocumentParserFactory.get_parser_for_mime("application/pdf")
returns PdfTextParser (local, no API call)
→ PdfTextParser.parse() → extracts text → regex finds transactions
→ Each row mapped to Transaction entity
→ Batch saved via ITransactionRepository
→ Document marked PROCESSED
4. Response: { document: {...}, transactions_extracted: 12 }
User corrects a salary entry misclassified as debit:
PATCH /transactions/{id}
Body: { "transaction_type": "credit", "category": "income" }
→ TransactionRepository.get_by_id()
→ Apply only the provided fields
→ TransactionRepository.save()
→ Return updated transaction
Why SQLite? Zero-config, single file, trivially backed up. Supports two users on a home LAN without any issue. Can be swapped for PostgreSQL by changing the connection string — the repository interfaces mean no use case code changes.
Why no SDK for Gemini or Claude?
Both google-generativeai and anthropic SDKs carry transitive dependencies with a history of CVEs. We call the REST APIs directly via httpx, which is already in the dependency tree and is a well-audited, single-purpose HTTP client.
Why rules-based PDF parsing instead of LLM for PDFs? PDFs with selectable text (all major German banks) give you 100% accuracy for free. LLMs are probabilistic — fine for images where there is no better option, but unnecessary and slower for structured text.
Why hand-rolled fakes instead of unittest.mock?
Fakes implement the real interface. Rename a method on the interface and the fake breaks at import time — instant feedback. Mocks break silently at runtime and are tied to implementation details.
Why no passwords? This is a home LAN app. Only devices on your network can reach it. A profile selector is sufficient. A PIN can be added in 30 minutes without touching any business logic.
| Step | What | Status |
|---|---|---|
| 1 | Domain entities + interfaces | ✅ Done |
| 2 | Application use cases | ✅ Done |
| 3 | SQLite repositories + Alembic + multi-user | ✅ Done |
| 4 | PDF rules-based parser + Gemini/Claude image parsers + local file storage | ✅ Done |
| 5 | FastAPI routes + schemas + dependency injection | ✅ Done |
| 6 | Docker Compose — local deployment | 🔜 Next |
| 7 | React + Vite frontend | 🔜 Planned |
153 tests passing. 0 failing.
services:
fire-backend:
build: .
ports: ["8000:8000"]
volumes:
- ./data:/app/data # db/ and files/ persist between restarts
env_file: .env
ollama:
image: ollama/ollama
volumes:
- ollama_models:/root/.ollama
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]Auto-starts with Docker Desktop "start on login". Every device on your LAN reaches the app at http://fire.local or http://192.168.x.x:8000.
- Profile selector — choose between your two accounts
- Upload screen — drag and drop PDF or receipt image
- Transaction list — grouped by month, editable inline
- Monthly dashboard — income vs expenses, savings rate, FIRE progress bar
- Insights panel — LLM summary and tips for the selected month