Files
bin/docs/ARCHITEKTUR.md
2026-07-20 08:46:00 +02:00

13 KiB

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.

flowchart TD
  subgraph TPL["templates (Jinja2, app/templates/)"]
    T_pages["base.html, login.html, index.html<br/>transactions.html, salden.html<br/>import.html, planning.html<br/>admin.html, hilfe.html"]
  end

  subgraph ROUTERS["routers/"]
    R_gui["gui.py<br/>gui_session, index, buchungen,<br/>salden_page, import_page, planung_page"]
    R_accounts["accounts.py<br/>list_accounts, create_account,<br/>patch_account"]
    R_transactions["transactions.py<br/>list_transactions, create_transaction,<br/>patch_transaction, count_transactions"]
    R_categories["categories.py<br/>list_categories, create_category_rule"]
    R_imports["imports.py<br/>upload, scan_inbox, preview,<br/>confirm, rollback"]
    R_planning["planning.py<br/>list_recurring, list_planned,<br/>list_loans, recurring_suggestions"]
    R_scenarios["scenarios.py<br/>create_scenario, add_modifier,<br/>project_scenario"]
    R_admin["admin.py<br/>admin_page, admin_change_password,<br/>apply_category_rules"]
  end

  subgraph SERVICES["services/"]
    S_importer["importer.py<br/>process_file, process_pdf, process_csv"]
    S_balances["balances.py<br/>account_balance(at), total_balance"]
    S_categorize["categorize.py<br/>apply_rules"]
    S_suggestions["suggestions.py<br/>suggest_recurring"]
    S_projection["projection_service.py<br/>run_projection"]
    S_admin["admin.py<br/>change_password, apply_rules_retroactively"]
  end

  subgraph ENGINE["engine/ (rein, kein DB-Zugriff)"]
    E_loans["loans.py<br/>loan_schedule, annuity_payment, add_months"]
    E_recurrence["recurrence.py<br/>occurrences"]
    E_projection["projection.py<br/>project"]
    E_scenario["scenario.py<br/>build_cashflows (PlainRecurring/<br/>PlainPlanned/PlainModifier)"]
  end

  subgraph PARSERS["parsers/"]
    P_base["base.py<br/>parse_german_amount, parse_german_date"]
    P_detect["detect.py<br/>detect_bank"]
    P_validate["validate.py<br/>balance_difference, dedup_hash"]
    P_vr["vr.py — parse"]
    P_hvb["hvb.py — parse"]
    P_dkb["dkb.py — parse"]
    P_csv["csv_formats.py<br/>detect_csv_format, parse_csv"]
    P_registry["registry.py — parse_pdf"]
  end

  subgraph MODELS["models/ + alembic/"]
    M_tables["tables.py<br/>Account, Statement, Transaction,<br/>RecurringItem, PlannedItem, Loan,<br/>Scenario, ProjectionResult, ..."]
    M_views["views.py — create_views()<br/>v_balance_history, v_balance_total,<br/>v_monthly_by_category, v_projection"]
    M_alembic["alembic/versions<br/>b2b1f5a18a74_initial_schema<br/>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

  • lifespancreate_views), app/version.py (get_version).

2. Sequenzdiagramm — Import-Fluss

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:<br/>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<br/>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

flowchart TB
  subgraph HOST["Host wlfb (rootless Podman)"]
    Boot["create_pod_finance.sh<br/>Bootstrap: FB_PASSWORD erzeugen<br/>-> PBKDF2-Hash (FB_GUI_PASSWORD_HASH)<br/>-> Grafana-Sync (grafana cli<br/>admin reset-admin-password)"]
    Backup["backup_finance_pod.sh [ZIELDIR]<br/>KALT: systemctl stop -> tar czf<br/>via 'podman unshare' (subuid-Daten)<br/>-> systemctl start -> Readiness-Curl<br/>-> 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<br/>127.0.0.1:8096 -> 8000<br/>uvicorn app.main:app<br/>(entrypoint.sh: alembic upgrade head)"]
      DBC["finance-db_ctr<br/>Postgres 17.10<br/>NUR pod-intern: localhost:5432<br/>(kein Host-Port)"]
      GRA["finance-grafana_ctr<br/>127.0.0.1:8097 -> 3000<br/>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)<br/>inbox/, uploads/"]
      ENVF[".env (-v ENV_FILE:/data/.env:Z)<br/>EINZELDATEI-Mount folgt dem INODE!<br/>App schreibt IN-PLACE (open r+,<br/>flock, truncate) - NIE Temp+rename<br/>(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<br/>-> /etc/grafana/provisioning:Z,ro"]
      DASH["grafana/dashboards/finanzen.json<br/>-> /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 draftconfirmed, oder drafterror (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_cashflowsengine.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.