A personal finance dashboard for Portuguese bank statements and Degiro brokerage data. PDFs from Crédito Agrícola and Caixa Geral de Depósitos go in, every transaction comes out classified, and the dashboards show income, expenses, cashflow, net worth and investments split by Business / Personal / House scope.
Production-grade Django + DRF + PostgreSQL underneath, fully dockerised, with tests, CI and automated database backups.
- Imports Crédito Agrícola and CGD statement PDFs via pdfplumber, plus
the Degiro
Account.csvcash-account export. - Classifies every transaction with priority-ordered, date-aware rules
(
CategoryRule), with anIgnoreRuletable for noise the importer should drop entirely (e.g. the bank-side mirror of broker deposits). - Dashboards at
/overview,/income,/expenses,/cashflow,/net-worth,/investments,/accounts,/transactions— Apple-Health bento style with a dark theme and an All / Business / Personal scope tab on every page. - Edit anything inline from
/transactions: change date / description / amount / balance / category, and optionally apply the same category to every similar uncategorised row in one go. - Bulk-categorise from
/transactions: tick row checkboxes (selection persists across pagination), pick a category in the floating bar, hit Apply. - Browser-driven workflow on
/upload,/seed,/investmentsso the whole monthly refresh can be done without docker exec or SSH:- Drop bank PDFs and the Degiro CSV in the upload zone.
- Set the broker portfolio's current value on the Investments page so Unrealised gain matches Degiro's Total L/P.
- Manage classification rules + ignore patterns from the Seed page via
a structured GUI (Add / Edit / Delete per row) or an inline JSON
editor; import / export the whole config as
seed_rules.json.
- Reset everything from the Upload page's Danger zone (typed confirmation) so a re-import always starts clean.
| Component | Version | Notes |
|---|---|---|
| Django | 5.2 LTS | anchor — Dependabot is pinned to the LTS line |
| Python | 3.14 | python:3.14-slim |
| PostgreSQL | 18 | postgres:18 |
| DRF | 3.17.1 | REST layer |
| psycopg | 3.3.4 | psycopg[binary] — bundles libpq |
| WhiteNoise | 6.12.0 | compressed, cache-busted static |
| gunicorn | 26.0.0 | prod + dev server |
| Tailwind CSS | 3.4 | compiled in the Docker build stage |
| Chart.js | 4.4.7 | dashboards (loaded from a CDN) |
| pdfplumber | latest | PDF statement parsing |
| django-environ | 0.13.0 | env-driven settings |
| ruff | 0.15.14 | lint + format (make lint) |
- Get the files locally:
- Bank PDFs (e.g.
Extrato global (n).pdf,Extracto_Simples(n).pdf). - Degiro
Account.csv— Activity → Conta → set the date range → Export.
- Bank PDFs (e.g.
- Open
/upload/in the dashboard and drag every file in. Categories, classification rules andIgnoreRulepatterns are kept across imports; the importer dedupes by a stable SHA-256 of the natural key, so re-uploading the same file is a no-op. - Open
/transactions/?uncategorised=1and clean the long tail. Edit one row → tick "Also apply to similar uncategorised" → bulk-categorise the rest. - Open
/investments/, click Set on the Current value tile and enter the Degiro portfolio total (Saldo da conta). The Investments page shows Net invested vs Current value vs Unrealised gain; Net worth picks up the broker value automatically. - (Occasional) Open
/seed/to manage classification rules: add / edit / delete from the structured tables, paste a JSON edit into the advanced editor, or round-trip the whole config asseed_rules.json.
| Page | Endpoint | What it shows |
|---|---|---|
/overview/ |
/api/dashboard/overview/ |
Headline net-worth tiles, YTD income, recent transactions, top spending categories. |
/income/ |
/api/dashboard/income/ |
Monthly and quarterly income totals per scope. |
/expenses/ |
/api/dashboard/expenses/ |
Monthly expenses, by-category aggregate, monthly-by-category breakdown. |
/cashflow/ |
/api/dashboard/cashflow/ |
Income, expenses and net per month, per scope. |
/net-worth/ |
/api/dashboard/net-worth/ |
Business / Personal / House / Savings / Investments tiles; mortgage informational. |
/investments/ |
/api/dashboard/investments/ |
Cost basis (cumulative Investment txns) vs Current value vs Unrealised gain; per-deposit flow. |
/accounts/ |
/api/dashboard/accounts/ |
One row per non-brokerage account with its latest balance. |
/transactions/ |
/api/transactions/ |
Paginated, filterable list. Each row has an inline edit modal; checkbox multi-select drives bulk categorise. |
/upload/ |
POST /api/upload/ |
Multi-file picker: dispatches .pdf → bank importer, .csv → Degiro importer. |
/seed/ |
GET / POST /api/seed/ |
Inspect, download and replace the whole seed config. Per-entity CRUD lives on the routes below. |
| — | /api/categories/ |
List + create / <id>/ retrieve / update / delete. Backs the Seed page's Categories table. |
| — | /api/category-rules/ |
List + create / <id>/ retrieve / update / delete. Backs the Rules table. |
| — | /api/ignore-rules/ |
List + create / <id>/ retrieve / update / delete. Backs the Ignore patterns table. |
| — | POST /api/portfolio-snapshots/ |
Upsert a manual portfolio snapshot (account, as_of, market_value). |
| — | POST /api/transactions/categorise-matching/ |
Bulk-apply a category to every uncategorised description match. |
| — | POST /api/transactions/categorise-bulk/ |
Apply a category to an explicit list of transaction ids (drives the multi-select bar). |
| — | POST /api/reset/ |
Typed-confirmation wipe of accounts, transactions, statements and snapshots. |
- Account — bank or brokerage.
scope(personal / business),role(house / personal / business) andkind(current / savings / term / credit / brokerage). Brokerage accounts are hidden from the Accounts list and the role-based net-worth buckets; they contribute via manual portfolio snapshots instead. - Category — labelled bucket with a
kind(income / expense / transfer / investment / tax / other) for the dashboards. - CategoryRule —
match_textsubstring + optionalsign/scope/effective_from. Priority-ordered; first match wins. - IgnoreRule —
match_textsubstring. Matching rows are dropped at import time so they never enter the ledger. - Transaction — signed amount, balance, dedupe key = SHA-256 of (account_id, date, description, amount, balance) so re-imports are idempotent.
- StatementImport + BalanceSnapshot — per-statement provenance and the headline figures CGD prints (savings, mortgage, investments).
- PortfolioSnapshot — manual broker market value, one per (account, as_of) date. Drives the Investments page's Current value and the broker contribution to Net worth.
Gitignored. The /seed/ page lets you download a snapshot, edit it locally
and re-upload it; the seed_finance management command does the same job
from the CLI. Shape:
{
"categories": [
{"name": "Salary", "kind": "income"}
],
"rules": [
{"match_text": "ACME PAYROLL", "sign": "credit", "scope": "personal",
"category": "Salary", "priority": 10, "effective_from": "2026-01-01"}
],
"ignore": [
{"match_text": "DEGIRO", "note": "Tracked via the Degiro CSV instead"}
]
}sign is any / credit / debit, scope is "" (any) / personal /
business, effective_from is optional (only matches rows on or after the
date). seed_rules.example.json lives in the repo with placeholder data.
- dev — local development on the
devbranch.DJANGO_DEBUG=True. - main — production (homelab via Portainer).
DJANGO_DEBUG=False.
The same image runs in both; behaviour is driven entirely by environment
variables. There is a single docker-compose.yml; gunicorn serves in dev
and prod (no runserver, no source bind-mount) so it deploys cleanly under
Portainer. Rebuild to pick up code changes.
financial-audit/
├── config/ # Django project: settings, urls, wsgi, asgi
├── finance/ # The app
│ ├── api.py # All DRF views (dashboards + edit + upload + seed + reset)
│ ├── models.py # Account / Category / *Rule / Transaction / *Snapshot
│ ├── parsers/ # CA + CGD PDF parsers, Degiro CSV parser
│ ├── services.py # import_statement, import_degiro_csv, apply_seed, classify_*
│ ├── serializers.py
│ ├── urls.py / page_urls.py
│ ├── views.py # Server-rendered page shells
│ ├── templates/finance/ # Tailwind + Chart.js dashboards
│ ├── static/finance/ # Compiled tailwind.css + dashboard.js + favicon
│ ├── management/commands/ # seed_finance, import_statement, import_degiro_csv, …
│ ├── migrations/
│ └── tests/
├── tailwind/input.css # Tailwind source compiled in the Docker build
├── seed_rules.example.json # Placeholder template (private file is gitignored)
├── docker-compose.yml # single file, all environments
├── Dockerfile # multi-stage: Tailwind build + Python runtime
├── entrypoint.sh
├── Makefile
├── conftest.py / pytest.ini / ruff.toml
└── .github/workflows/ci.yml
-
Create your env files from the template:
cp .env.example .env.dev cp .env.example .env.prod
.env.devdefaults are fine whileDJANGO_DEBUG=True. -
Build and start the stack:
make up
-
Apply migrations and create an admin user on first run:
make migrate make superuser
-
Confirm it is healthy:
curl localhost:8000/health/ # -> {"status": "ok"} -
Common tasks (run
make helpfor the full list):make logs # follow web logs make shell # bash into the web container make test # run pytest with coverage against a real postgres make lint # ruff check make down # stop the stack
All targets load
.env.devby default; passENV=prodto use.env.prod.
You can rehearse the production deploy locally: in Portainer add a stack
using the Repository method pointed at this repo, supplying the values
from a per-env file (e.g. .env.prod). Portainer builds the image from the
repo (build: in compose) and recreates the services.
Deploy the stack from main using Portainer's Repository method, which
builds the image from the repo on the homelab. Supply the environment from
.env.prod (gitignored), with at minimum:
DJANGO_DEBUG=False- a real
DJANGO_SECRET_KEYandPOSTGRES_PASSWORD DJANGO_ALLOWED_HOSTS=your-domain.example.comDJANGO_CSRF_TRUSTED_ORIGINS=https://your-domain.example.comCOMPOSE_PROFILES=backupPOSTGRES_HOST_PORT=127.0.0.1:5432
After the first deploy:
docker exec -it <web_container> python manage.py migrate
docker exec -it <web_container> python manage.py createsuperuserFor a private repo, Portainer git deploys need a GitHub PAT (fine-grained,
Contents: Read-only).
CI is a lean GitHub Actions quality gate; deploy is pull-based via Portainer GitOps.
- Develop on
dev; push. CI runs (ruff check+pytestagainst a realpostgres:18service). - Open a PR
dev→main; CI must be green to merge. - Merging to
main→ Portainer (watchingmain) re-pulls, rebuilds, and redeploys prod.
Enable Portainer Automatic updates on the stack (polling or webhook);
both ship in Portainer CE. No image registry or deploy secret is involved —
Portainer builds the image from the repo. Protect main so only CI-passing
PRs land.
Releases are SemVer tags cut from main; they are version markers and do
not trigger deploys (merging to main does). Current line: v0.3.0.
make release VERSION=0.3.0This validates the version, checks the tree is clean and on main, then
tags and pushes the version tag.
The backup service (behind the backup profile) takes periodic
pg_dump --clean --if-exists snapshots, gzips them to the backup_data
volume, keeps a …-latest.sql.gz symlink, and prunes dumps older than
BACKUP_KEEP_DAYS.
# Run the backup service
COMPOSE_PROFILES=backup docker compose --env-file .env.dev up -d backup
# On-demand backup
make backup
# Restore the latest backup (DESTRUCTIVE)
make restoreThe backup container's PG* environment variables (PGHOST, PGDATABASE,
PGUSER, PGPASSWORD) drive pg_dump and psql directly.