# Architektur — Finanzberatungs-Tool (`finance/`) > Diese Doku richtet sich **primär an künftige Claude-Coding-Sessions** in > diesem Repo, sekundär an Menschen. Sie beschreibt den Stand nach > Ausbaustufe 4 (v0.5.0). Jeder hier genannte Modul-/Funktionsname existiert > real im Code unter `finance/app/` — bei Unsicherheit den Pfad öffnen statt > zu raten. Betriebs-/Konventionsregeln stehen in `/home/wlfb/bin/CLAUDE.md`. ## 1. Komponenten-Diagramm Kernachse: `routers/* → services/* → engine/* + parsers/* → models/tables.py → db.py`. `routers/gui.py` ist ein Sonderfall — es importiert Handler-Funktionen direkt aus anderen Routern (`imports.py`, `planning.py`, `scenarios.py`, `transactions.py`), um serverseitige Logik nicht zu duplizieren. `routers/admin.py` importiert `gui_session` und `templates` direkt aus `routers/gui.py`. ```mermaid flowchart TD subgraph TPL["templates (Jinja2, app/templates/)"] T_pages["base.html, login.html, index.html
transactions.html, salden.html
import.html, planning.html
admin.html, hilfe.html"] end subgraph ROUTERS["routers/"] R_gui["gui.py
gui_session, index, buchungen,
salden_page, import_page, planung_page"] R_accounts["accounts.py
list_accounts, create_account,
patch_account"] R_transactions["transactions.py
list_transactions, create_transaction,
patch_transaction, count_transactions"] R_categories["categories.py
list_categories, create_category_rule"] R_imports["imports.py
upload, scan_inbox, preview,
confirm, rollback"] R_planning["planning.py
list_recurring, list_planned,
list_loans, recurring_suggestions"] R_scenarios["scenarios.py
create_scenario, add_modifier,
project_scenario"] R_admin["admin.py
admin_page, admin_change_password,
apply_category_rules"] end subgraph SERVICES["services/"] S_importer["importer.py
process_file, process_pdf, process_csv"] S_balances["balances.py
account_balance(at), total_balance"] S_categorize["categorize.py
apply_rules"] S_suggestions["suggestions.py
suggest_recurring"] S_projection["projection_service.py
run_projection"] S_admin["admin.py
change_password, apply_rules_retroactively"] end subgraph ENGINE["engine/ (rein, kein DB-Zugriff)"] E_loans["loans.py
loan_schedule, annuity_payment, add_months"] E_recurrence["recurrence.py
occurrences"] E_projection["projection.py
project"] E_scenario["scenario.py
build_cashflows (PlainRecurring/
PlainPlanned/PlainModifier)"] end subgraph PARSERS["parsers/"] P_base["base.py
parse_german_amount, parse_german_date"] P_detect["detect.py
detect_bank"] P_validate["validate.py
balance_difference, dedup_hash"] P_vr["vr.py — parse"] P_hvb["hvb.py — parse"] P_dkb["dkb.py — parse"] P_csv["csv_formats.py
detect_csv_format, parse_csv"] P_registry["registry.py — parse_pdf"] end subgraph MODELS["models/ + alembic/"] M_tables["tables.py
Account, Statement, Transaction,
RecurringItem, PlannedItem, Loan,
Scenario, ProjectionResult, ..."] M_views["views.py — create_views()
v_balance_history, v_balance_total,
v_monthly_by_category, v_projection"] M_alembic["alembic/versions
b2b1f5a18a74_initial_schema
1ef6a356f028_konto_anker"] end R_gui --> T_pages R_admin --> T_pages R_gui --> R_imports R_gui --> R_planning R_gui --> R_scenarios R_gui --> R_transactions R_gui --> E_loans R_gui --> E_recurrence R_gui --> S_balances R_admin --> R_gui R_accounts --> S_balances R_transactions --> P_validate R_transactions --> S_categorize R_imports --> S_importer R_planning --> E_loans R_planning --> S_suggestions R_scenarios --> S_projection R_admin --> S_admin ROUTERS -.-> M_tables S_importer --> P_csv S_importer --> P_registry S_importer --> P_validate S_importer --> S_categorize S_projection --> E_loans S_projection --> E_projection S_projection --> E_scenario S_projection --> S_balances S_admin --> S_categorize E_scenario --> E_loans E_scenario --> E_recurrence P_registry --> P_vr P_registry --> P_hvb P_registry --> P_dkb P_registry --> P_detect P_vr --> P_base P_hvb --> P_base P_dkb --> P_base P_csv --> P_base P_validate --> P_base SERVICES -.-> M_tables M_alembic -.->|erzeugt Schema fuer| M_tables M_tables -.->|Basis fuer| M_views ``` Nicht im Diagramm (Querschnitt, siehe Prosa unten): `app/auth.py` (`require_auth`, `current_password_hash`, `session_valid` — an fast allen Routern als FastAPI-Dependency), `app/config.py` (`get_settings`), `app/db.py` (`get_session`, `get_engine`), `app/main.py` (bindet alle Router + `lifespan` → `create_views`), `app/version.py` (`get_version`). ## 2. Sequenzdiagramm — Import-Fluss ```mermaid sequenceDiagram participant B as Browser/GUI participant RI as routers/imports.py participant IM as services/importer.py participant PA as parsers (registry.py / csv_formats.py) participant VA as parsers/validate.py participant CA as services/categorize.py participant DB as Postgres Note over B,RI: Upload ODER Inbox-Scan alt Datei-Upload B->>RI: POST /api/imports/upload (PDF/CSV) else Inbox-Scan B->>RI: POST /api/imports/scan-inbox Note right of RI: iteriert *.pdf und *.csv in FB_INBOX_DIR end RI->>IM: process_file(session, path) alt Endung .csv IM->>PA: parse_csv(path) [csv_formats.detect_csv_format] else Endung .pdf (Default) IM->>PA: parse_pdf(path) [registry.detect_bank -> vr/hvb/dkb.parse] end PA-->>IM: ParsedStatement / ParsedCsv IM->>IM: _find_or_create_account(bank, iban) IM->>DB: INSERT Statement(status="draft") opt Saldo pruefbar (PDF immer; CSV nur VR: balance_checkable=True) IM->>VA: balance_difference(parsed) Note right of IM: Differenz != 0 -> Statement.status="error", Abbruch end loop je Buchungszeile IM->>VA: dedup_hash(account_id, booking_date, amount, purpose) IM->>DB: INSERT Transaction(status="draft", is_duplicate=?) end IM->>CA: apply_rules(session, drafts) IM->>IM: _apply_anchor_autofill(account, anchor) Note right of IM: nur wenn CSV-Anker-Datum >= Account.anchor_date (oder kein Anker) IM->>DB: Datei inbox -> uploads verschieben, COMMIT IM-->>RI: Statement (draft) RI-->>B: 201 StatementOut B->>RI: GET /api/imports/{id}/preview RI->>DB: SELECT Transactions WHERE statement_id=id RI->>RI: balance_ok = true/false/None (dreiwertig:
None bei fehlenden Salden, z.B. HVB/DKB-CSV) RI-->>B: PreviewOut(balance_ok, duplicates) alt Bestaetigen B->>RI: POST /api/imports/{id}/confirm loop je Draft-Transaktion (nicht is_duplicate) RI->>DB: Dedup-Recheck: dedup_hash bereits confirmed
in ANDEREM Statement? alt Kollision gefunden RI->>DB: tx.is_duplicate = true (bleibt draft) else keine Kollision RI->>DB: tx.status = "confirmed" end end RI->>DB: Statement.status = "confirmed" RI-->>B: 200 StatementOut else Verwerfen (Rollback) B->>RI: POST /api/imports/{id}/rollback Note right of RI: nur wenn Statement.status == "confirmed" RI->>DB: DELETE Transactions + Statement RI-->>B: 200 {deleted_transactions, statement_id} end ``` ## 3. Deployment-Diagramm ```mermaid flowchart TB subgraph HOST["Host wlfb (rootless Podman)"] Boot["create_pod_finance.sh
Bootstrap: FB_PASSWORD erzeugen
-> PBKDF2-Hash (FB_GUI_PASSWORD_HASH)
-> Grafana-Sync (grafana cli
admin reset-admin-password)"] Backup["backup_finance_pod.sh [ZIELDIR]
KALT: systemctl stop -> tar czf
via 'podman unshare' (subuid-Daten)
-> systemctl start -> Readiness-Curl
-> tar -tzf Integritaet -> chmod 600"] subgraph SYSTEMD["systemd --user (Unit-Dateien in ~/.config/systemd/user, je chmod 600)"] U1["pod-finance_pod.service"] U2["container-finance-db_ctr.service"] U3["container-finance-api_ctr.service"] U4["container-finance-grafana_ctr.service"] end subgraph POD["Pod finance_pod (podman pod)"] API["finance-api_ctr
127.0.0.1:8096 -> 8000
uvicorn app.main:app
(entrypoint.sh: alembic upgrade head)"] DBC["finance-db_ctr
Postgres 17.10
NUR pod-intern: localhost:5432
(kein Host-Port)"] GRA["finance-grafana_ctr
127.0.0.1:8097 -> 3000
grafana-oss 12.1.0"] end subgraph BINDDIR["Bind-Mounts ~/.local/share/finance_pod (BIND_DIR, chmod 700)"] DATA["data/ (-v DATA_DIR:/data:Z)
inbox/, uploads/"] ENVF[".env (-v ENV_FILE:/data/.env:Z)
EINZELDATEI-Mount folgt dem INODE!
App schreibt IN-PLACE (open r+,
flock, truncate) - NIE Temp+rename
(services/admin.py _rewrite_env_file)"] PGD["postgres-data/"] GRD["grafana-data/ (setgid, gid 0)"] end subgraph REPO["Repo-Checkout ~/bin/finance/grafana (read-only Mounts)"] PROV["grafana/provisioning
-> /etc/grafana/provisioning:Z,ro"] DASH["grafana/dashboards/finanzen.json
-> /var/lib/grafana/dashboards:Z,ro"] end end Boot -->|erzeugt/aktualisiert| ENVF Boot -->|podman generate systemd --new| SYSTEMD Boot -->|podman run| API Boot -->|podman run| DBC Boot -->|podman run| GRA Boot -.->|exec grafana cli admin reset-admin-password| GRA SYSTEMD -->|verwaltet Start/Stop| POD API -->|liest/schreibt in-place| ENVF API -->|Daten| DATA API -.->|SQL localhost:5432| DBC DBC --> PGD GRA --> GRD GRA --> PROV GRA --> DASH GRA -.->|SQL read-only Rolle finance_read| DBC API -.->|HTTP PUT .../password, siehe change_password| GRA Backup -->|stoppt/startet| U1 Backup -->|tar czf BIND_DIR| BINDDIR ``` ## 4. Zentrale Konzepte (kompakt) **Saldo-Anker (per Tagesende).** `Account.anchor_date` / `Account.anchor_balance` (Migration `1ef6a356f028_konto_anker`) ersetzen die frühere Statement-closing-Logik als Basis der Saldenrechnung. `services/balances.py::account_balance(session, account, at)` addiert bzw. subtrahiert bestätigte Buchungen zwischen Anker und `at`; der Anker gilt **per Tagesende** des Ankerdatums (enthält bereits alle Buchungen bis einschließlich diesem Tag). CSV-Importe füllen einen fehlenden oder veralteten Anker automatisch nach (`_apply_anchor_autofill` in `services/importer.py`) — ein manuell per PATCH gesetzter, neuerer Anker bleibt dabei unangetastet. Die Postgres-Views `v_balance_history` / `v_balance_total` (`models/views.py`) bilden dieselbe Anker-Semantik als lückenlose Tagesreihe (Carry-Forward via `generate_series` + Fenster-`SUM`) für Grafana ab. **`dedup_hash`.** `parsers/validate.py::dedup_hash(account_id, booking_date, amount, purpose)` erzeugt einen deterministischen Hash je Buchung. Er wird zweimal genutzt: (1) beim Import markiert `services/importer.py` Entwürfe als `is_duplicate`, wenn der Hash bereits unter einer *bestätigten* Transaktion existiert; (2) beim Bestätigen (`routers/imports.py::confirm`) erfolgt ein **Dedup-Recheck** gegen zwischenzeitlich bestätigte Buchungen *anderer* Statements — zwei identische Buchungen *innerhalb* desselben Statements teilen sich zwar denselben Hash, dürfen sich aber nicht gegenseitig ausschließen, da die in der Preview geprüfte Bilanz von beiden abhängt. **Statement-Lebenszyklus.** `Statement.status` (`models/tables.py`) durchläuft `draft` → `confirmed`, oder `draft` → `error` (Bank/Format nicht erkannt, Saldo-Differenz, kaputte Datei). Transaktionen haben denselben Status-Wortschatz (`draft`/`confirmed`) pro Zeile. Bestätigte Statements sind nur noch über `rollback` (löscht Statement + Transaktionen komplett) zu entfernen, nicht mehr über `DELETE /api/imports/{id}` (nur für Entwürfe und Fehl-Importe). **Szenario-Engine ist rein.** `app/engine/*` (`loans.py`, `recurrence.py`, `projection.py`, `scenario.py`) hat **keinen** Datenbank- oder Request-Zugriff — nur `scenario.py` importiert innerhalb der Engine von `loans.py`/`recurrence.py`, sonst keine Abhängigkeiten auf `app.*`. Ein-/ Ausgaben sind einfache Dataclasses (`Installment`, `PlainRecurring`, `PlainPlanned`, `PlainModifier`, `Projection`). `services/projection_service.py` ist die einzige Brücke: es lädt ORM-Objekte (`Loan`, `RecurringItem`, `PlannedItem`, `Scenario`, `ScenarioModifier`), baut daraus die Plain-Objekte und ruft `engine.scenario.build_cashflows` → `engine.projection.project`. **`VERSION`-Datei als Single Source.** `finance/VERSION` (ein Einzeiler, z.B. `0.4.0`) ist die einzige Versionsquelle: `app/version.py::get_version()` liest sie (Fallback `"0.0.0-dev"`), gespeist in den Jinja2-Footer (`templates.env.globals["app_version"]` in `routers/gui.py`) und `GET /api/version` (`main.py`). `Containerfile` kopiert `VERSION` ins Image; `create_pod_finance.sh` liest dieselbe Datei für den Image-Tag (`API_IMAGE="localhost/finance-api:$(cat "$FINANCE_DIR/VERSION")"`). Ein Release ist somit: `VERSION` hochzählen → Image bauen → Redeploy. **DATENSCHUTZ: Fixtures enthalten echte Kontodaten.** `finance/tests/fixtures/` enthält reale PDF-/CSV-Kontoauszüge (`*.pdf`, `*.csv`, `expected_*.json`) — siehe `finance/tests/fixtures/README.md` und `finance/.gitignore` (alle drei Muster ausgeschlossen). `scripts/parser_audit.py` und `scripts/parser_vs_csv.py` geben Klartext-Kontodaten auf der Konsole aus und sind ausdrücklich mit "NUR lokal verwenden" markiert — ihre Ausgabe darf nicht in Commits, Reports, Tickets oder Chat-Antworten landen. Details und verbindliche Regeln: siehe `CLAUDE.md`.