Compare commits

...

97 Commits

Author SHA256 Message Date
8eee62ee19 fix: Datenschutz-Scrub in Tests/Kommentaren + Spec-Nachtrag Vorschlags-Algorithmus
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:59:29 +02:00
7ca64c91fe docs: Plan-Haekchen Ausbaustufe 9
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:49:38 +02:00
aff8e9b28d fix: Bestandsabgleich per Token-Match und Volatilitaets-Hinweis fuer Vorschlaege
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:46:10 +02:00
a520390e31 chore: Version 0.9.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:28:29 +02:00
220d1fc802 fix: hinweis-Betrag im Vorschlag deutsch formatiert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:28:15 +02:00
bf3f56118e feat: Vorschlaege mit Rhythmus, Start und Hinweis
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:26:48 +02:00
28d1267f0c feat: Vorschlags-Algorithmus v2 (Rhythmen, letzter Betrag, Aktiv-Check, Merge)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:20:04 +02:00
e002d8205f docs: Spec + Plan Ausbaustufe 9 (Vorschlags-Algorithmus v2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:30:31 +02:00
353ff6056c docs: Plan-Haekchen Ausbaustufe 7
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:21:11 +02:00
d6659d8b18 chore: Version 0.8.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:18:15 +02:00
4431640eb2 feat: Kategorien-Verwaltung auf der Admin-Seite
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:17:48 +02:00
b21d7b9839 feat: Eintrags-Formular mit Paar-Layout und Gesperrt-Kennzeichnung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:06:32 +02:00
f03249bff1 feat: eigene Szenarien-Seite /szenarien
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:59:03 +02:00
7b273e218c docs: Spec + Plan Ausbaustufe 7 (Szenarien-Seite, Formular-UX, Kategorien)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:50:43 +02:00
974ef5c208 docs: Plan-Haekchen Ausbaustufe 6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:54:19 +02:00
421899b922 chore: Version 0.7.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:51:17 +02:00
cb44194daf feat: Szenario-Eintraege als eine editierbare Tabelle
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:51:04 +02:00
4843f2e130 feat: PATCH-Endpunkte fuer Szenario-Modifikatoren und -Einmalzahlungen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:43:01 +02:00
1708abeb91 docs: Implementierungsplan Ausbaustufe 6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:39:37 +02:00
39c6a65b1d docs: Design-Spec Ausbaustufe 6 (Szenario-Eintraege-Tabelle)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:37:45 +02:00
2282aa3922 docs: Plan-Haekchen Task 9 (Demo-Szenario live verifiziert)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:05:26 +02:00
f1b5d442bd docs: Ledger Ausbaustufe 5 Tasks 1-8 + Plan-Haekchen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:02:29 +02:00
a192866cc4 chore: Version 0.6.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:59:58 +02:00
033c41f449 feat: Szenario-GUI mit Ende-Modifikator und Einmalzahlungen je Szenario
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:59:42 +02:00
13795fce25 feat: API fuer Szenario-Ende-Modifikator und szenario-eigene Einmalzahlungen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:52:15 +02:00
5519037688 feat: Engine+Schema fuer Szenario-Ende und szenario-eigene Einmalzahlungen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:46:52 +02:00
aa1884c31e feat: Inline-Bearbeiten fuer Fixposten, Einmalposten, Kredite und Szenario-Kopf
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:42:04 +02:00
cbcf2e0439 feat: Start/Ende fuer Fixposten in der GUI + Datumsvalidierung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:35:32 +02:00
805cb4d852 feat: Betragseingabe mit Komma (data-type=amount in json-form)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:30:58 +02:00
fe0e1b5e93 feat: deutsche Betragsanzeige (eur-Filter) und deutsche GUI-Labels
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:23:26 +02:00
bd9df8ab8b docs: Implementierungsplan Ausbaustufe 5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:13:21 +02:00
8e425840e8 docs: Design-Spec Ausbaustufe 5 (deutsche Formate, Posten-Bearbeitung, Szenario-Ende)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:45:39 +02:00
8bbabf6657 docs: Inline-Haekchen Ausbaustufe 4 vervollstaendigt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:47:54 +02:00
8ff8b39b2e docs: Ausbaustufe-4-Plan abgeschlossen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:47:42 +02:00
1ef297ab89 chore: Version 0.5.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:46:08 +02:00
89a08965db docs: Architektur (Mermaid) und CLAUDE.md fuer kuenftige Sessions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:46:00 +02:00
09b34ae13b fix: lueckenlose taegliche Saldo-Reihen fuer Grafana
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:32:36 +02:00
f5fa3e45e8 feat: Admin-Seite mit Passwortaenderung und Regel-Neuanwendung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:24:33 +02:00
74684c7e6c feat: Kalt-Backup-Skript finance_pod
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:11:53 +02:00
9316d72bb4 docs: Implementierungsplan Ausbaustufe 4 (Backup, Admin, Grafana, Doku)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:01:57 +02:00
8f6f1a6e0e docs: Ausbaustufe-3-Plan abgeschlossen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 22:10:23 +02:00
c2b5f6eaa3 chore: Version 0.4.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 22:08:44 +02:00
1b888307e9 fix: DKB-Empfaenger/Zweck-Split per CSV-Ground-Truth, Vergleichswerkzeug
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 22:08:37 +02:00
7a0f71aa8c feat: Salden-Seite mit Stichtag und Monatsuebersicht
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 21:53:23 +02:00
ea4858c4de feat: CSV-Import ueber bestehende Pipeline mit dreiwertiger Saldo-Pruefung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 21:45:53 +02:00
e2d713e960 feat: CSV-Parser fuer DKB/VR/HVB-Kontoumsatz-Exporte
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 21:34:48 +02:00
f6a53b7e56 feat: Konto-Saldo-Anker mit ankerbasierter Saldenrechnung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 21:25:04 +02:00
41d556bcde docs: Implementierungsplan Ausbaustufe 3 (CSV-Import, Saldo-Anker, Salden-Seite)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 21:12:15 +02:00
6b02fa3d85 chore: CSV-Kontoexporte in fixtures gitignoren (echte Daten)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 21:10:17 +02:00
1fc87001d6 chore: Beispielskript mit Langfuse-Keys aus Versionskontrolle ausgenommen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:42:31 +02:00
18b738738b test: UX-Regressionstests fuer Vorschlaege-Fieldset und Import-Button-Matrix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:18:03 +02:00
06392c0fec fix: tolerante GUI-Filter, sichtbare Bedienelemente, fixierte Navigation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:09:35 +02:00
e7855a7bda feat: Import-Rollback fuer bestaetigte Auszuege (v0.3.0)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:40:56 +02:00
d71c22adce feat: Buchungs-Pagination, Inbox-Scan-Button, Grafana-Direktansteuerung, UI-Feinschliff
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:18:54 +02:00
0bad69bf42 fix: Final-Review-Feinschliff (DR-Kommentar korrigiert, SameSite-Pin, Grafana-Link-Hostname)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:29:38 +02:00
543bb648b7 docs: Ausbaustufe-2-Plan abgeschlossen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:23:33 +02:00
94eb80506e feat: gemeinsames Passwort fuer GUI und Grafana, Dashboard-Embedding
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:13:31 +02:00
f57edc5fcd feat: Secrets unter ~/.local/share/finance_pod (Backup-vollstaendig)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:06:21 +02:00
bba04db663 fix: Arbeitsablauf als nummerierte Liste, HTML-Escaping in Hilfe
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:04:35 +02:00
e40442b73b feat: Hilfe-Seite mit Gebrauchsanleitung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:02:19 +02:00
d5103a0fb3 fix: API_IMAGE nutzt FINANCE_DIR-Variable statt Duplikatpfad
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:00:42 +02:00
82f278ca00 feat: Versionsanzeige (VERSION-Datei, Footer, /api/version, Image-Tag)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:57:49 +02:00
87f8bbc400 fix: json-form-Extension als Superset in base.html konsolidiert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:54:54 +02:00
36d3e4fc59 feat: Konten umbenennbar, Betrag-Spalte mit Euro im Kopf
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:50:39 +02:00
f27eda88f4 fix: Parser-Noise-Filter, Sammelstopp am Schlusssaldo, Audit-Werkzeug
Alle drei Bank-Parser gegen die realen Fixtures gehaertet:

- DKB/VR/HVB: Sammlung endet am Schlusssaldo (break), damit Summen,
  Rechnungsabschluss und Hinweisseiten nicht mehr in die letzte Buchung
  rutschen.
- Generische, rein strukturelle Noise-Filter fuer wiederkehrende
  Seitenfuss-/Kopfbloecke und Blattwechsel-Reste (keine Kontodaten hart
  codiert); Kopf-/Hinweisbloecke werden als Sequenz uebersprungen.
- VR: Rekonstruktion harter Zeilenumbrueche im Verwendungszweck unter
  konservativen Schutzbedingungen (nur purpose, nicht counterparty).
- Neue Noise- und expected-Value-Tests; expected_*.json gitignored.
- Audit-Werkzeug scripts/parser_audit.py ergaenzt (nur lokale Nutzung).

Transaktionszahlen und Saldo-Differenzen unveraendert; max. Laenge des
Verwendungszwecks je Bank deutlich unter 300 Zeichen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:46:32 +02:00
597f7ba552 docs: Implementierungsplan Ausbaustufe 2
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:30:40 +02:00
917797bb16 fix: Duplikat-Recheck beim Confirm nur statement-uebergreifend
Zwei identische Buchungen im selben Auszug teilen sich den dedup_hash. Beim
Confirm markierte die zuerst bestaetigte Buchung ihren Zwilling (autoflush +
Clash-Select) als Duplikat und uebersprang ihn, wodurch der vom Preview
gepruefte Saldo zerstoert wurde. Clash-Query um statement_id != tx.statement_id
erweitert: der Re-Check schuetzt nur noch statement-uebergreifend. Test: Auszug
mit zwei identischen Buchungen -> beide werden bestaetigt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:16:08 +02:00
cf9282c716 fix: Final-Review-Findings (GUI-Login, FK-Kaskaden, Validierung, Duplikat-Recheck, Views)
F1: GUI-Login-Hash gegen $-Korruption gehaertet. .env-Werte einfach-gequotet
    geschrieben + Sanity-Check nach Sourcing. Zusaetzlich systemd-Ebene:
    podman escapt $->$$ in Environment=, systemd 252 kollabiert das nicht ->
    sed-Post-Processing der Unit-Dateien. Live-.env: GUI-Passwort rotiert.
F2: Explizite Dependent-Deletes gegen FK-500 (delete_scenario/loan), 409 bei
    bestaetigtem Import-Delete. Test-Engine erzwingt PRAGMA foreign_keys=ON.
F3: Enum/Range-Validierung (Literal + Field ge/le) fuer Recurring, Loan,
    Modifier inkl. PATCH-Pfade.
F4: Confirm nur fuer draft-Statements (sonst 409) + Dedup-Re-Check gegen
    bestaetigte Buchungen vor dem Promoten.
F5: v_balance_history/v_balance_total addieren pro Konto den opening_balance
    des fruehesten bestaetigten Statements (approved deviation vom Plan-SQL).
F6: patch_scenario Namenskollision -> 409; create_transaction unbekanntes
    Konto -> 404 (statt 500).
F7: Generierte systemd-Unit-Dateien chmod 600 (enthalten Env-Secrets).
F8: Globaler htmx:responseError-Handler in base.html.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:11:31 +02:00
b3b40a5982 docs: Plan-Haekchen nach Umsetzung aller 17 Tasks gesetzt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:51:05 +02:00
1338bd66d5 fix: Inbox/Uploads unter gemeinsamem Bind-Mount (EXDEV bei Path.replace)
Beim E2E-Smoke-Test schlug der Import mit 'Invalid cross-device link'
fehl: rename() funktioniert nicht ueber zwei getrennte Bind-Mounts.
Inbox und Uploads liegen jetzt unter einem einzigen /data-Mount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:45:36 +02:00
557928ac3f feat: Pod-Skript finance_pod mit systemd-Service und Secrets-Bootstrap
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:37:03 +02:00
a1d8e91cbc feat: Containerfile und Entrypoint
Basis-Image docker.io/library/python:3.12.11-slim wie im Brief gepinnt
(Tag existiert auf Docker Hub, kein Patch-Bump nötig, verifiziert via
podman pull).

Zusätzlich finance/.containerignore ergänzt (.venv/, tests/, .env,
*.sqlite, inbox/, uploads/), da der Build-Kontext sonst echte
Bank-PDFs aus tests/fixtures/ sowie die ~150MB .venv/ in den
Container-Build einschleusen würde.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:26:25 +02:00
fca11f551c fix: Grafana-Datasource-UID gepinnt, Dashboard-Feinheiten
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:23:27 +02:00
43e8e8e8f9 feat: SQL-Views und Grafana-Provisionierung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:18:20 +02:00
df6615c8f7 fix: Fehler-Feedback Kreditzuordnung, robustes PDF-Fehlerhandling
Kreditzuordnung im Szenario prueft jetzt response.ok, zeigt bei Fehlschlag
eine deutsche Meldung und macht die Checkbox-Aenderung rueckgaengig statt
stillschweigend neu zu laden. process_pdf faengt zusaetzlich zu ParserError
auch unerwartete Parser-Exceptions (z.B. pdfminer bei strukturell kaputten
PDFs) ab und legt dafuer ein sauberes error-Statement an statt eines
unbehandelten 500; die Datei bleibt dabei im Posteingang.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:14:43 +02:00
afbbcbe9a5 feat: Import-Seite mit Drag-and-Drop und Planungs-Seite
Ersetzt die Platzhalter-Routen aus Task 12 durch echte GUI-Seiten: /import
mit Drop-Zone (Fetch-Upload), gepollter Importliste (Status-Badges,
aufklappbarer Vorschau samt Saldo-/Duplikat-Anzeige, Uebernehmen/Verwerfen)
und /planung mit den vier Abschnitten Fixposten (inkl. Vorschlaege),
Einmalposten, Kredite (Tilgungsplan-Aufklapper) und Szenarien
(Kredit-Zuordnung, Modifikatoren, Durchrechnen mit Ergebnisanzeige).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:05:58 +02:00
3847398055 fix: json-form-Extension fuer Standalone-Elemente, Kontoname in Buchungsliste
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:53:23 +02:00
a6009c47e3 feat: Web-GUI Basis, Uebersicht, Buchungsliste
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:45:54 +02:00
e8ea948b38 test: Vorschlagslogik suggest_recurring abgedeckt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:38:52 +02:00
b0cb4883ea feat: Planungs- und Szenario-API mit Projektion und Vorschlaegen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:33:25 +02:00
8ca563cec1 fix: Upload-Sanitizing, atomare Import-Reihenfolge, idempotentes UNBEKANNT-Konto
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:28:15 +02:00
19d870cf20 feat: Import-API mit Vorschau, Saldo-Pruefung und Duplikat-Schutz
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:21:28 +02:00
0576b396f1 fix: PATCH-Buchung ohne Feld laesst Kategorie unveraendert, 404 bei unbekannter Kategorie
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:16:52 +02:00
320abd6542 feat: CRUD-API Konten/Buchungen/Kategorien/Regeln
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:11:56 +02:00
193bbc450c fix: konstante Zeit fuer API-Key-Vergleich, Auth-Tests erweitert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:07:20 +02:00
a10f4d0b57 feat: FastAPI-App mit API-Key- und Session-Auth
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:03:24 +02:00
101ee01af1 feat: PDF-Parser VR/HVB/DKB mit Registry und Fixture-Tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:58:33 +02:00
78d94a583b fix: NBSP-Betraege und ParserError bei ungueltigem Datum
- parse_german_amount: Fix non-breaking space (U+00A0) handling in second replace call
  was: .replace(" ", "").replace(" ", "") → now: .replace(" ", "").replace("\xa0", "")
- parse_german_date: Catch ValueError from invalid calendar dates (e.g., 31.02.2026)
  and raise ParserError with "Ungültiges Datum" message instead
- Add test for non-breaking space amount parsing
- Add test for invalid date error handling with pytest.raises

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:34:39 +02:00
d91bb2e331 feat: Parser-Basis, Bank-Erkennung, Saldo-/Duplikat-Pruefung
- ParsedTransaction und ParsedStatement Datenklassen
- parse_german_amount(): German format number parsing (1.234,56 -> Decimal)
- parse_german_date(): DD.MM.YYYY date parsing with optional year
- detect_bank(): Recognize VR/HVB/DKB from text markers
- balance_difference(): Validate statement balance consistency
- dedup_hash(): SHA256-based transaction deduplication

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:30:10 +02:00
877cba4964 test: Absolute-Modifier und Stichtag-Semantik der Projektion
Ergänzte Tests für Lückenschluss:
1. test_absolute_modifier_reduces_outflow: Absolute Modifier reduziert Ausgaben (-200 - 50 = -150)
2. test_absolute_modifier_clamps_to_zero: Absolute Modifier klemmt auf 0 (nie Vorzeichenflip: -200 + 300 = 0)
3. test_cashflow_on_start_date_excluded: Cashflows am start_date werden ausgeschlossen (Teil des Startsaldos)

Alle 18 Tests grün (15 existierend + 3 neu).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:27:41 +02:00
020f41e9f2 feat: Szenario-Engine (Projektion, Modifikatoren, Zahlungsstrom)
Implementiert Task 5 mit TDD-Ansatz: projection.py mit Projection-Klasse
und project()-Funktion für Cashflow-Simulation; scenario.py mit PlainRecurring,
PlainPlanned, PlainModifier Dataclasses und build_cashflows() für Szenarien-Zahlungsströme
mit Modifier-Support (remove, percent, absolute). Alle 15 Tests grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:22:36 +02:00
d41db2e10d test: Clamping-Tests Schaltjahr und Quartalsrhythmus
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:20:19 +02:00
82800ea7d0 feat: Recurrence-Terminberechnung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:16:36 +02:00
9f50435df9 feat: Kredit-Mathematik (Annuitaet, endfaellig)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:12:25 +02:00
d37cb230c4 feat: Datenmodell (SQLAlchemy) und Alembic-Migration
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:09:02 +02:00
53ffd65104 feat: Projekt-Geruest finance/ mit Config und Test-Setup
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:04:59 +02:00
d4a98e117f docs: Implementierungsplan Finanzberatungs-Tool (Plan.md)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:19:13 +02:00
112 changed files with 15455 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
example_create_pod_langflow.sh

154
CLAUDE.md Normal file
View File

@@ -0,0 +1,154 @@
# CLAUDE.md — Repo `~/bin`
> Diese Datei richtet sich primär an künftige Claude-Coding-Sessions in
> diesem Repo, sekundär an Menschen. Architektur/Modul-Übersicht (Mermaid):
> **`docs/ARCHITEKTUR.md`**. Vor Code-Änderungen dort das Komponenten- und
> Sequenzdiagramm lesen — jeder dort genannte Name existiert real im Code.
## Projektüberblick
Finanzberatungs-Tool (Verzeichnis `finance/`): FastAPI-App (API + Web-GUI),
Postgres, Grafana-Dashboards. Importiert Kontoauszüge (PDF: VR/HVB/DKB;
CSV: VR/HVB/DKB), verwaltet Buchungen/Kategorien/Fixposten, rechnet
Kredit-Tilgungspläne und Liquiditäts-Szenarien durch. Läuft als Podman-Pod
`finance_pod` (3 Container) unter systemd `--user`, siehe
`docs/ARCHITEKTUR.md` Abschnitt 3 (Deployment). Aktuelle Version:
`finance/VERSION` (Single Source — siehe dort).
Zugehöriges Repo `/home/wlfb/fb` ist die **Beratungsumgebung** (Skills,
Gesprächsleitfäden für die Nutzung des Tools in der Finanzberatung selbst,
eigenes `CLAUDE.md`) — nicht der Code dieses Tools. Dieses Repo (`~/bin`)
enthält Quellcode, Deployment- und Betriebsskripte.
## Betriebskommandos
**Test-Suite** (Basis-Erwartung: alle grün, dreistellige Anzahl):
```bash
cd finance && .venv/bin/python -m pytest -q
```
**Deploy / Redeploy** (baut das Image neu, erstellt Pod+Container neu,
generiert systemd-Units, aktiviert den Service, wartet auf Bereitschaft):
```bash
./create_pod_finance.sh
```
Idempotent — erneutes Ausführen rotiert **keine** Secrets in einer
bestehenden `.env` (Ausnahme: `FB_GUI_PASSWORD_HASH` wird bei jedem Lauf mit
`FB_PASSWORD` synchron gehalten, siehe Passwort-Prozedur unten). Legacy-Pfad
`~/bin/finance/.env` wird beim ersten Lauf automatisch nach
`~/.local/share/finance_pod/.env` migriert.
**Backup** (kalt, kurze Downtime, Zielverzeichnis optional):
```bash
./backup_finance_pod.sh [ZIELDIR] # Default: ~/backups
```
Stoppt `pod-finance_pod.service`, sichert `~/.local/share/finance_pod`
(inkl. `.env`) via `tar czf` **im `podman unshare`-Namespace** (Postgres-Daten
gehören auf dem Host der subuid-verschobenen Container-UID und sind sonst
unlesbar), startet den Service wieder, prüft Erreichbarkeit
(`GET /login` → 200) und Archiv-Integrität (`.env`, `postgres-data`,
`grafana-data`, `data` müssen enthalten sein), setzt `chmod 600` (Archiv
enthält Secrets). Bei jedem Fehler läuft der Service danach trotzdem wieder
(EXIT-Trap).
**Passwort ändern** (GUI + Grafana teilen sich `FB_PASSWORD`):
- **Vorzugsweg (Admin-Seite):** `/admin` in der GUI aufrufen — ändert altes
Passwort gegen Grafana (`services/admin.py::change_password`), schreibt
danach die `.env` **in-place** neu. Bestehende Sitzungen bleiben gültig
(Session-Secret ändert sich nicht).
- **Handverfahren:** `FB_PASSWORD` in
`~/.local/share/finance_pod/.env` von Hand editieren (Wert
**single-quoted** lassen!) und `./create_pod_finance.sh` erneut ausführen
— das Skript leitet `FB_GUI_PASSWORD_HASH` automatisch neu ab und
synchronisiert Grafana per `grafana cli admin reset-admin-password`.
**Parser-Werkzeuge** (nur lokal, siehe DATENSCHUTZ unten):
```bash
cd finance
PYTHONPATH=. .venv/bin/python scripts/parser_audit.py tests/fixtures/dkb_beispiel.pdf
PYTHONPATH=. .venv/bin/python scripts/parser_vs_csv.py <pdf> <csv> [<csv>...]
```
`parser_audit.py` zeigt geparste Transaktionen + PDF-Rohtext einer Fixture.
`parser_vs_csv.py` vergleicht ein PDF-Parser-Ergebnis gegen CSV-Ground-Truth
(Betrag/Datum-Match, Gegenpartei/Verwendungszweck-Ähnlichkeit via
`difflib`). Beide geben echte Kontodaten auf der Konsole aus.
## WICHTIGE REGELN für künftige Sessions
**`tests/fixtures/*` = ECHTE Kontodaten — NIE committen oder zitieren.**
`finance/tests/fixtures/*.pdf`, `*.csv` und `expected_*.json` sind reale
Kontoauszüge (via `finance/.gitignore` vom Repo ausgeschlossen). In Commits,
PR-Beschreibungen, Reports, Ledger-Einträgen oder Chat-Antworten dürfen
weder Dateiinhalte noch daraus abgeleitete Beträge/Namen/IBANs auftauchen.
Ausgaben von `parser_audit.py`/`parser_vs_csv.py` sind ausschließlich zur
lokalen Fehlersuche bestimmt, niemals zum Weiterreichen.
**Decimal, nicht float.** Geldbeträge sind überall `decimal.Decimal`
(DB-Spaltentyp `Numeric(12,2)`, siehe `models/tables.py::MONEY`). Keine
`float`-Arithmetik für Beträge einführen (Rundungsfehler).
**Deutsch.** GUI-Texte, Fehlermeldungen, Templates, Commit-Messages und
Beratungsinhalte sind auf Deutsch. Datumsformat TT.MM.JJJJ.
**UX-Regel: Bedienelemente sichtbar, nicht versteckt.** Formulare/Buttons
bleiben immer sichtbar und bedienbar; Sperrzustände werden über `disabled`
ausgedrückt, nicht durch Entfernen des Elements aus dem DOM. Fehler werden
inline auf derselben Seite gemeldet (Beispiel: `routers/admin.py` gibt bei
Validierungsfehlern dasselbe Template mit `error`-Kontext zurück statt
umzuleiten oder das Formular verschwinden zu lassen).
**Fable-Testagent-Gate (Nutzer-Vorgabe).** Jeder Task in einem
Superpowers-Plan (`docs/superpowers/plans/*.md`) braucht vor dem Commit eine
Abnahme durch den Fable-Testagenten (Faktencheck/Live-Test je nach Task).
Kein Task gilt als abgeschlossen, solange dieses Gate nicht durchlaufen ist
— auch nicht bei scheinbar trivialen Doku-/Config-Änderungen.
**Superpowers-Workflow.** Pläne liegen unter `docs/superpowers/plans/`
(ein Plan pro Ausbaustufe, z.B. `2026-07-20-ausbaustufe-4.md`), gegliedert
in Tasks mit Checkbox-Tracking. Fortschritt/Entscheidungen/offene Punkte
werden fortlaufend im Ledger `.superpowers/sdd/progress.md` festgehalten
(ein Eintrag pro abgeschlossenem Task, inkl. Commit-Range und Fable-Befund).
Vor Arbeitsbeginn an einem Task: Plan-Datei UND Ledger lesen — der Ledger
enthält oft bindende Nutzerentscheidungen und Warnungen aus früheren Tasks
(z.B. Migrations-Hinweise), die nicht im Plan selbst stehen.
**Versionierung via `finance/VERSION`.** Einzige Versionsquelle (siehe
`docs/ARCHITEKTUR.md` Abschnitt 4). Speist Image-Tag
(`create_pod_finance.sh`), GUI-Footer und `GET /api/version`
(`app/version.py::get_version`). Bei einem Release: `VERSION` hochzählen,
bevor `create_pod_finance.sh` läuft — sonst baut das Skript ein Image mit
altem Tag.
**`.env` unter `~/.local/share/finance_pod/`, nicht im Repo-Checkout.**
Secrets liegen unter `$BIND_DIR/.env` (`chmod 600`), niemals unter
`finance/.env` (Legacy-Pfad, wird migriert). Alle Werte darin sind
**single-quoted** zu schreiben (`KEY='wert'`) — `create_pod_finance.sh`
sourced die Datei per `set -a; . "$ENV_FILE"; set +a`, und unquoted Werte
mit `$` (z.B. der GUI-Passwort-Hash `salt$digest`) würden dabei fälschlich
shell-expandiert. Der API-Container mountet dieselbe Datei zusätzlich als
**Einzeldatei-Bind-Mount** nach `/data/.env` — das folgt dem Host-Inode, die
App muss sie **in-place** überschreiben (`open` im Modus `r+`, `flock`,
`truncate` — siehe `services/admin.py::_rewrite_env_file`), niemals über
Temp-Datei+`rename` (neuer Inode, vom laufenden Mount nicht mehr gesehen).
Host-seitige `sed`-Edits in `create_pod_finance.sh` selbst sind unkritisch,
weil das Skript die Container ohnehin bei jedem Lauf neu erstellt.
**Disaster Recovery = `BIND_DIR`-Backup + Repo + Skript.** Für vollständige
Wiederherstellung werden **beide** gebraucht: ein Backup von
`~/.local/share/finance_pod` (Secrets + Postgres-/Grafana-Daten, via
`backup_finance_pod.sh`) UND der Repo-Checkout `~/bin` (Code für den
Image-Build, `create_pod_finance.sh`, `finance/grafana/` für die
Provisioning-Mounts). Wiederherstellung: Backup nach
`~/.local/share/finance_pod` entpacken, dann `./create_pod_finance.sh`
erneut ausführen (baut Image, erstellt Pod/Container/systemd-Units neu,
übernimmt vorhandene `.env` unverändert). Verifiziert in A2-Task 7
(Live-DR-Probe, siehe `.superpowers/sdd/progress.md`).
## Weiterführend
- Architektur (Module, Funktionen, drei Mermaid-Diagramme):
`docs/ARCHITEKTUR.md`
- Pläne: `docs/superpowers/plans/`
- Fortschritts-Ledger: `.superpowers/sdd/progress.md`
- Beratungsumgebung (Nutzung des Tools, Skills): `/home/wlfb/fb`
(eigenes `CLAUDE.md` dort)

2031
Plan.md Normal file

File diff suppressed because it is too large Load Diff

135
backup_finance_pod.sh Executable file
View File

@@ -0,0 +1,135 @@
#!/bin/bash
# Kalt-Backup-Skript für den finance_pod (Postgres-Daten, Grafana-Daten,
# Uploads/Inbox und das Secrets-.env unter ~/.local/share/finance_pod).
# Stoppt den systemd-User-Service für die Dauer der Sicherung (kurze
# Downtime), erstellt ein tar.gz, startet den Service wieder, prüft die
# Erreichbarkeit und die Archiv-Integrität, und schützt das Archiv per
# chmod 600 (es enthält Secrets aus .env).
#
# Usage: ./backup_finance_pod.sh [ZIELDIR] (Default: $HOME/backups)
set -e
# Secrets landen im Archiv (.env) -> alle vom Skript neu erzeugten
# Dateien/Verzeichnisse sollen von Anfang an nur für den Besitzer lesbar
# sein, nicht erst nach einem nachträglichen chmod.
umask 077
POD_NAME='finance_pod'
SERVICE_NAME="pod-${POD_NAME}.service"
SOURCE_PARENT="$HOME/.local/share"
BIND_DIR="$SOURCE_PARENT/$POD_NAME"
TARGET_DIR="${1:-$HOME/backups}"
HOST_LOCAL_IP='127.0.0.1'
API_HOST_PORT='8096'
CHECK_URL="http://$HOST_LOCAL_IP:$API_HOST_PORT/login"
if [ ! -d "$BIND_DIR" ]; then
echo "ERROR: $BIND_DIR existiert nicht." >&2
exit 1
fi
# Zielverzeichnis anlegen, BEVOR der Service gestoppt wird: wenn das Ziel
# unbeschreibbar ist, bricht das Skript hier ab und der Service lief nie
# an, bleibt also ungestört aktiv. Nur beim NEU-Anlegen explizit auf 700
# setzen (ein bereits vorhandenes Zielverzeichnis wird in seinen
# bestehenden Rechten nicht angetastet).
if [ ! -d "$TARGET_DIR" ]; then
mkdir -p "$TARGET_DIR"
chmod 700 "$TARGET_DIR"
else
mkdir -p "$TARGET_DIR"
fi
ARCHIVE="$TARGET_DIR/${POD_NAME}_$(date +%F_%H%M%S).tar.gz"
# Merkt sich, ob DIESES Skript den Service gestoppt hat. Der EXIT-Trap
# startet ihn bei jedem Fehler wieder - unabhängig davon, an welcher Stelle
# das Skript abbricht (set -e sorgt dafür, dass jeder Fehlschlag hierher
# springt). Zusätzlich entfernt der Trap ein evtl. bereits angelegtes,
# aber unvollständiges/fehlerhaftes Archiv - es kann Secrets (.env)
# enthalten und darf im Fehlerfall nicht liegen bleiben.
SERVICE_STOPPED=0
restart_on_error() {
rc=$?
if [ "$rc" -ne 0 ]; then
if [ -n "${ARCHIVE:-}" ] && [ -f "$ARCHIVE" ]; then
echo "Entferne unvollständiges/fehlerhaftes Archiv $ARCHIVE (kann Secrets enthalten)..." >&2
rm -f "$ARCHIVE"
fi
if [ "$SERVICE_STOPPED" -eq 1 ]; then
echo "FEHLER (Exit-Code $rc) — starte $SERVICE_NAME sicherheitshalber wieder..." >&2
systemctl --user start "$SERVICE_NAME" || true
fi
fi
exit "$rc"
}
trap restart_on_error EXIT
echo "Stoppe $SERVICE_NAME für das Kalt-Backup..."
systemctl --user stop "$SERVICE_NAME"
SERVICE_STOPPED=1
# tar läuft im rootless-Podman-User-Namespace (`podman unshare`): das
# Postgres-Datenverzeichnis gehört auf dem Host der subuid-verschobenen UID
# (Container-UID 999 -> Host-UID via /etc/subuid), für `wlfb` direkt daher
# unlesbar (drwx------). Im User-Namespace mappt UID 0 auf den aufrufenden
# Host-User zurück und darf als Namespace-root alle verschobenen Dateien
# lesen; die von ns-root neu erzeugte Archivdatei gehört auf dem Host
# dadurch bereits `wlfb` - KEIN nachträgliches chown noetig (und keins
# versuchen: `podman unshare chown "$(id -u):$(id -g)"` wertet die
# id-Substitution auf dem HOST aus, aber im Namespace ist genau diese
# host-UID/GID bereits subuid-verschoben, sodass der Aufruf die Datei auf
# eine fremde subuid-UID umbiegt und das anschliessende `chmod 600` mit
# EPERM scheitert). Zusätzlich vererbt `podman unshare` NICHT die umask
# des aufrufenden Skripts (dort dokumentiert 0022 statt der oben gesetzten
# 077) - daher `umask 077` explizit erneut innerhalb des unshare-Subshells,
# damit das Archiv von Geburt an 600 ist statt bis zum finalen chmod als
# 644 mit Secrets dazuliegen.
echo "Erstelle Archiv $ARCHIVE (im Podman-User-Namespace, wegen UID-verschobener Postgres-Daten)..."
podman unshare sh -c 'umask 077; tar czf "$1" -C "$2" "$3"' _ "$ARCHIVE" "$SOURCE_PARENT" "$POD_NAME"
echo "Starte $SERVICE_NAME wieder..."
systemctl --user start "$SERVICE_NAME"
SERVICE_STOPPED=0
echo "Warte auf Bereitschaft ($CHECK_URL)..."
READY=0
CODE=''
for attempt in $(seq 1 30); do
CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL" || true)
if [ "$CODE" = "200" ]; then
echo "API ist wieder erreichbar (200)."
READY=1
break
fi
sleep 2
done
if [ "$READY" -ne 1 ]; then
echo "ERROR: API wurde nach dem Neustart nicht rechtzeitig bereit (letzter Status: $CODE)." >&2
exit 1
fi
echo "Prüfe Archiv-Integrität (tar -tzf)..."
LISTING=$(tar -tzf "$ARCHIVE")
for required in ".env" "postgres-data" "grafana-data" "data"; do
# Regex-Sonderzeichen (nur '.' kommt in dieser Liste vor, bei ".env")
# escapen, damit z.B. "xenv" nicht faelschlich auf ".env" matcht.
escaped="${required//./\\.}"
if ! echo "$LISTING" | grep -Eq "^${POD_NAME}/${escaped}(/.*)?\$"; then
echo "ERROR: Archiv enthält kein '$required'." >&2
exit 1
fi
done
echo "Archiv-Integrität OK (.env, postgres-data, grafana-data, data vorhanden)."
# Archiv enthält Secrets (.env) -> nur der Besitzer darf lesen. (umask 077
# hat das Archiv bereits als 600 angelegt; chmod hier ist eine zusätzliche
# Absicherung, falls sich das je ändert.)
chmod 600 "$ARCHIVE"
SIZE=$(du -h "$ARCHIVE" | cut -f1)
echo "Backup fertig: $ARCHIVE ($SIZE, chmod 600)"

343
create_pod_finance.sh Executable file
View File

@@ -0,0 +1,343 @@
#!/bin/bash
# To be run by user wlfb to create pod for the Finanzberatungs-Tool
# (API + Postgres + Grafana).
set -e
# Environment variables
POD_NAME='finance_pod'
DB_CTR_NAME='finance-db_ctr'
API_CTR_NAME='finance-api_ctr'
GRAFANA_CTR_NAME='finance-grafana_ctr'
# All image tags are pinned to fixed, immutable versions (no floating tags
# such as :latest, :3 or :16). Verified against the upstream registries on
# 2026-07-17 via `podman pull` (both tags exist as-is; no substitution
# needed).
POSTGRES_IMAGE='docker.io/library/postgres:17.10'
GRAFANA_IMAGE='docker.io/grafana/grafana-oss:12.1.0'
FINANCE_DIR="$HOME/bin/finance"
API_IMAGE="localhost/finance-api:$(cat "$FINANCE_DIR/VERSION")"
HOST_LOCAL_IP='127.0.0.1'
# Expose API on 8096 -> 8000
API_HOST_PORT='8096'
API_CONTAINER_PORT='8000'
# Expose Grafana on 8097 -> 3000
GRAFANA_HOST_PORT='8097'
GRAFANA_CONTAINER_PORT='3000'
# Postgres is only reachable inside the pod (localhost:5432 from API/Grafana)
BIND_DIR="$HOME/.local/share/$POD_NAME"
POSTGRES_DATA_DIR="$BIND_DIR/postgres-data"
GRAFANA_DATA_DIR="$BIND_DIR/grafana-data"
# inbox and uploads must live under one shared bind mount: process_pdf()
# moves a file from inbox to uploads via Path.replace() (os.replace/rename),
# which fails with EXDEV ("Invalid cross-device link") across two separate
# bind mounts even when both point at the same underlying host filesystem
# (Linux permits a filesystem to be mounted at multiple points, but rename()
# does not work across different mount points - see `man 2 rename`).
DATA_DIR="$BIND_DIR/data"
INBOX_DIR="$DATA_DIR/inbox"
UPLOADS_DIR="$DATA_DIR/uploads"
USER_SYSTEMD_DIR="$HOME/.config/systemd/user"
GRAFANA_PROVISIONING_DIR="$FINANCE_DIR/grafana/provisioning"
GRAFANA_DASHBOARDS_DIR="$FINANCE_DIR/grafana/dashboards"
# --- Secrets bootstrap -------------------------------------------------------
# Secrets live under BIND_DIR (not the code checkout in FINANCE_DIR). For
# disaster recovery you need BOTH: a backup of BIND_DIR (data + secrets) AND
# the repo checkout ~/bin (this script, finance/ for the image build, Grafana
# provisioning). BIND_DIR must exist and be private before .env is written.
mkdir -p "$BIND_DIR" && chmod 700 "$BIND_DIR"
ENV_FILE="$BIND_DIR/.env"
LEGACY_ENV="$HOME/bin/finance/.env"
if [ ! -f "$ENV_FILE" ] && [ -f "$LEGACY_ENV" ]; then
mv "$LEGACY_ENV" "$ENV_FILE" && chmod 600 "$ENV_FILE"
echo "MIGRIERT: .env nach $ENV_FILE verschoben"
fi
# On first run, generate all secrets into $ENV_FILE (gitignored) and print
# the shared GUI/Grafana login password once. On subsequent runs the
# existing .env is reused, so re-running this script never rotates secrets
# under existing data - except FB_GUI_PASSWORD_HASH, which is kept in sync
# with FB_PASSWORD on every run further below. That is what makes the
# password-change procedure a one-liner: edit FB_PASSWORD in $ENV_FILE, run
# this script again - the GUI hash (below) and Grafana's admin password
# (Grafana-sync retry loop, further down near the Grafana container) both
# follow automatically, with no separate hash-regeneration step.
if [ ! -f "$ENV_FILE" ]; then
# Compute all values into variables first, then write them SINGLE-QUOTED.
# The GUI password hash has the form salt$digest; if written unquoted, the
# later `set -a; . "$ENV_FILE"` would shell-expand the "$digest" part and
# corrupt the hash, breaking GUI login. Single quotes make every value a
# literal string on sourcing.
FB_PASSWORD_VAL=$(openssl rand -base64 12)
POSTGRES_PASSWORD_VAL=$(openssl rand -hex 16)
FINANCE_READ_PASSWORD_VAL=$(openssl rand -hex 16)
FB_API_KEY_VAL=$(openssl rand -hex 32)
FB_SESSION_SECRET_VAL=$(openssl rand -hex 32)
FB_GUI_PASSWORD_HASH_VAL=$(python3 -c "import hashlib,secrets,sys;s=secrets.token_hex(16);pw=sys.argv[1];print(s+'\$'+hashlib.pbkdf2_hmac('sha256',pw.encode(),s.encode(),200000).hex())" "$FB_PASSWORD_VAL")
cat > "$ENV_FILE" <<EOF
POSTGRES_PASSWORD='$POSTGRES_PASSWORD_VAL'
FINANCE_READ_PASSWORD='$FINANCE_READ_PASSWORD_VAL'
FB_API_KEY='$FB_API_KEY_VAL'
FB_SESSION_SECRET='$FB_SESSION_SECRET_VAL'
FB_GUI_USER='admin'
FB_GUI_PASSWORD_HASH='$FB_GUI_PASSWORD_HASH_VAL'
FB_PASSWORD='$FB_PASSWORD_VAL'
EOF
chmod 600 "$ENV_FILE"
echo "NEU ERZEUGT: GUI-/Grafana-Login admin / $FB_PASSWORD_VAL (jetzt notieren!)"
elif grep -q '^FB_GUI_PASSWORD_HASH=' "$ENV_FILE" && ! grep -q '^FB_PASSWORD=' "$ENV_FILE"; then
# Migrate a pre-existing .env that predates the shared GUI/Grafana
# password (independent GUI hash + GRAFANA_ADMIN_PASSWORD) to one shared
# FB_PASSWORD. The old GUI password was only ever stored as a salted
# hash, so its plaintext cannot be recovered - a new password is
# unavoidable here. The stale hash and any GRAFANA_ADMIN_PASSWORD line
# (superseded by FB_PASSWORD) are dropped; all other keys (API key,
# session secret, DB passwords, ...) are left untouched.
FB_PASSWORD_VAL=$(openssl rand -base64 12)
FB_GUI_PASSWORD_HASH_VAL=$(python3 -c "import hashlib,secrets,sys;s=secrets.token_hex(16);pw=sys.argv[1];print(s+'\$'+hashlib.pbkdf2_hmac('sha256',pw.encode(),s.encode(),200000).hex())" "$FB_PASSWORD_VAL")
sed -i "/^FB_GUI_PASSWORD_HASH=/d; /^GRAFANA_ADMIN_PASSWORD=/d" "$ENV_FILE"
{
echo "FB_GUI_PASSWORD_HASH='$FB_GUI_PASSWORD_HASH_VAL'"
echo "FB_PASSWORD='$FB_PASSWORD_VAL'"
} >> "$ENV_FILE"
chmod 600 "$ENV_FILE"
echo "MIGRATION: gemeinsames Passwort fuer GUI+Grafana eingefuehrt (altes GUI-Passwort war nur als Hash gespeichert und nicht rekonstruierbar)."
echo "MIGRATION: GUI-/Grafana-Login admin / $FB_PASSWORD_VAL (jetzt notieren!)"
fi
set -a; . "$ENV_FILE"; set +a
# Keep FB_GUI_PASSWORD_HASH in sync with FB_PASSWORD on every run (creation
# and migration above already produce a matching pair, so this is a no-op
# for them; it only rewrites $ENV_FILE when a user has edited FB_PASSWORD by
# hand since the last run - see the password-change procedure comment
# above).
if ! python3 -c "
import hashlib, sys
pw, stored = sys.argv[1], sys.argv[2]
salt, digest = stored.split('\$', 1)
sys.exit(0 if hashlib.pbkdf2_hmac('sha256', pw.encode(), salt.encode(), 200000).hex() == digest else 1)
" "$FB_PASSWORD" "$FB_GUI_PASSWORD_HASH" 2>/dev/null; then
FB_GUI_PASSWORD_HASH=$(python3 -c "import hashlib,secrets,sys;s=secrets.token_hex(16);pw=sys.argv[1];print(s+'\$'+hashlib.pbkdf2_hmac('sha256',pw.encode(),s.encode(),200000).hex())" "$FB_PASSWORD")
sed -i "s|^FB_GUI_PASSWORD_HASH=.*|FB_GUI_PASSWORD_HASH='$FB_GUI_PASSWORD_HASH'|" "$ENV_FILE"
chmod 600 "$ENV_FILE"
echo "FB_PASSWORD-Aenderung erkannt: FB_GUI_PASSWORD_HASH in $ENV_FILE neu abgeleitet."
fi
# Sanity check: the GUI password hash must survive shell sourcing intact. An
# unquoted value containing a literal '$' gets mangled by parameter expansion,
# which would silently break GUI login. Require the canonical salt$digest form
# (32 hex chars, '$', 64 hex chars).
if ! printf '%s' "$FB_GUI_PASSWORD_HASH" | grep -Eq '^[0-9a-f]{32}\$[0-9a-f]{64}$'; then
echo "ERROR: FB_GUI_PASSWORD_HASH is malformed after sourcing $ENV_FILE." >&2
echo " All values in $ENV_FILE must be single-quoted." >&2
exit 1
fi
# Stop existing systemd-managed pod if present, to avoid conflicts on rerun
echo "Stopping systemd-managed pod 'pod-$POD_NAME.service' if it exists..."
if systemctl --user list-units --type=service --all 2>/dev/null | \
grep -q "pod-$POD_NAME.service"; then
systemctl --user stop "pod-$POD_NAME.service" || true
fi
# Prepare directories
mkdir -p "$POSTGRES_DATA_DIR" "$GRAFANA_DATA_DIR" "$INBOX_DIR" "$UPLOADS_DIR" \
"$USER_SYSTEMD_DIR"
# The Grafana image runs as a non-root user (uid 472) that belongs to group 0
# (root). Its data dir is bind-mounted from the host, where it is owned by the
# (rootless) container root. Make the dir group-writable with the setgid bit
# so the non-root Grafana process can write its sqlite db/plugins (same
# "arbitrary uid + gid 0" pattern as example_create_pod_langflow.sh's
# LANGFLOW_DATA_DIR fix).
chmod g+rwxs "$GRAFANA_DATA_DIR"
# Build the API image (Task 15 Containerfile)
podman build -t "$API_IMAGE" "$FINANCE_DIR"
# Create pod if not yet existing
if ! podman pod exists "$POD_NAME"; then
podman pod create -n "$POD_NAME" \
-p "$HOST_LOCAL_IP:$API_HOST_PORT:$API_CONTAINER_PORT" \
-p "$HOST_LOCAL_IP:$GRAFANA_HOST_PORT:$GRAFANA_CONTAINER_PORT"
echo "Pod '$POD_NAME' created (rc=$?)"
else
echo "Pod '$POD_NAME' already exists."
fi
# Remove any old containers (ignore errors if they don't exist)
podman rm -f "$DB_CTR_NAME" || true
podman rm -f "$API_CTR_NAME" || true
podman rm -f "$GRAFANA_CTR_NAME" || true
# Postgres container
podman run -d --name "$DB_CTR_NAME" --pod "$POD_NAME" \
-e POSTGRES_USER=finance \
-e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \
-e POSTGRES_DB=finance \
-v "$POSTGRES_DATA_DIR:/var/lib/postgresql/data:Z" \
"$POSTGRES_IMAGE"
echo "Container '$DB_CTR_NAME' started (rc=$?)"
# Wait for Postgres to be ready before starting API / Grafana
echo "Waiting for Postgres to be ready (pg_isready)..."
for attempt in $(seq 1 30); do
if podman exec "$DB_CTR_NAME" pg_isready -q >/dev/null 2>&1; then
echo "Postgres is ready."
break
fi
sleep 2
if [ "$attempt" -eq 30 ]; then
echo "ERROR: Postgres did not become ready in time." >&2
exit 1
fi
done
# Create the read-only role used by the Grafana datasource, idempotently.
echo "Ensuring read-only role 'finance_read' exists..."
podman exec "$DB_CTR_NAME" psql -U finance -d finance -c \
"DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='finance_read')
THEN CREATE ROLE finance_read LOGIN PASSWORD '$FINANCE_READ_PASSWORD'; END IF; END \$\$;
GRANT CONNECT ON DATABASE finance TO finance_read;
GRANT USAGE ON SCHEMA public TO finance_read;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO finance_read;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO finance_read;"
echo "Role 'finance_read' is ready."
# API container (runs alembic upgrade head on start via entrypoint.sh)
# The extra "-v $ENV_FILE:/data/.env:Z" below (Ausbaustufe 4 Task 2, Admin
# password change) is a SINGLE-FILE bind mount. Unlike a directory mount,
# this follows the host file's INODE: the app must read/write it in place
# (open r+/truncate under flock - see app/services/admin.py), NEVER via
# temp-file+rename, because a rename would swap in a new inode that the
# already-running mount no longer points at.
podman run -d --name "$API_CTR_NAME" --pod "$POD_NAME" \
-e FB_DATABASE_URL="postgresql+psycopg://finance:$POSTGRES_PASSWORD@localhost:5432/finance" \
-e FB_API_KEY \
-e FB_SESSION_SECRET \
-e FB_GUI_USER \
-e FB_GUI_PASSWORD_HASH \
-e FB_INBOX_DIR=/data/inbox \
-e FB_UPLOADS_DIR=/data/uploads \
-v "$DATA_DIR:/data:Z" \
-v "$ENV_FILE:/data/.env:Z" \
"$API_IMAGE"
echo "Container '$API_CTR_NAME' started (rc=$?)"
# Grafana container (provisioning + dashboards mounted read-only; datasource
# yaml interpolates FINANCE_READ_PASSWORD). GF_SECURITY_ALLOW_EMBEDDING
# permits the GUI's iframe to embed dashboards; anonymous access stays OFF
# (no GF_AUTH_ANONYMOUS_* vars are set) - only the iframe X-Frame-Options
# restriction is relaxed, authentication is unaffected.
podman run -d --name "$GRAFANA_CTR_NAME" --pod "$POD_NAME" \
-e GF_SECURITY_ADMIN_PASSWORD="$FB_PASSWORD" \
-e GF_SECURITY_ALLOW_EMBEDDING=true \
-e GF_SECURITY_COOKIE_SAMESITE=lax \
-e GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/finanzen.json \
-e FINANCE_READ_PASSWORD \
-v "$GRAFANA_PROVISIONING_DIR:/etc/grafana/provisioning:Z,ro" \
-v "$GRAFANA_DASHBOARDS_DIR:/var/lib/grafana/dashboards:Z,ro" \
-v "$GRAFANA_DATA_DIR:/var/lib/grafana:Z" \
"$GRAFANA_IMAGE"
echo "Container '$GRAFANA_CTR_NAME' started (rc=$?)"
# GF_SECURITY_ADMIN_PASSWORD above only takes effect on a FRESH Grafana data
# dir (initial admin bootstrap); an existing install keeps its previously
# set password. So sync Grafana's admin password to FB_PASSWORD explicitly
# on every script run via the CLI (idempotent: resetting to the same
# password is harmless). grafana-oss:12.1.0 ships the unified `grafana`
# binary, which exposes this as `grafana cli admin reset-admin-password`
# (verified in-image: `grafana cli admin reset-admin-password --help`
# succeeds); the legacy standalone `grafana-cli` binary is kept as a
# fallback in case of a differently-built image. Retry like the pg_isready
# wait above, since Grafana needs a moment after container start to
# initialize its SQLite db before the CLI can operate on it. This runs
# against the just-started container (before the stop/recreate-via-systemd
# further below); the result persists because GRAFANA_DATA_DIR is a
# bind-mounted directory shared with the systemd-managed restart.
echo "Syncing Grafana admin password with FB_PASSWORD..."
for attempt in $(seq 1 30); do
if podman exec "$GRAFANA_CTR_NAME" grafana cli admin reset-admin-password "$FB_PASSWORD" \
>/dev/null 2>&1; then
echo "Grafana admin password synced (grafana cli)."
break
fi
if podman exec "$GRAFANA_CTR_NAME" grafana-cli admin reset-admin-password "$FB_PASSWORD" \
>/dev/null 2>&1; then
echo "Grafana admin password synced (grafana-cli fallback)."
break
fi
sleep 2
if [ "$attempt" -eq 30 ]; then
echo "ERROR: could not sync Grafana admin password in time." >&2
exit 1
fi
done
# Generate systemd service files
cd "$USER_SYSTEMD_DIR"
podman generate systemd --name --new --files "$POD_NAME"
echo "Generated systemd service files (rc=$?)"
# The --new units embed the full `podman run` commands, including container
# environment secrets (POSTGRES_PASSWORD, GF_SECURITY_ADMIN_PASSWORD,
# FB_GUI_PASSWORD_HASH, ...). Post-process each generated unit file:
# 1. chmod 600 so only the owner can read the embedded secrets.
# 2. Collapse '$$' -> '$' on Environment= lines. `podman generate systemd`
# escapes every '$' as '$$' (correct for ExecStart command lines, where
# systemd performs variable expansion). But systemd does NOT un-escape
# '$$' inside Environment= *values* - it passes them through literally.
# The GUI password hash has the form salt$digest, so without this fix the
# container receives salt$$digest and GUI login fails. Restrict the
# substitution to Environment= lines so ExecStart escaping stays intact.
for svc in "pod-${POD_NAME}.service" "container-${DB_CTR_NAME}.service" \
"container-${API_CTR_NAME}.service" "container-${GRAFANA_CTR_NAME}.service"; do
[ -f "$svc" ] || continue
chmod 600 "$svc"
sed -i '/^Environment=/ s/\$\$/$/g' "$svc"
done
# Stop & remove live pod and containers
podman pod stop --ignore --time 15 "$POD_NAME"
podman pod rm -f --ignore "$POD_NAME"
if podman pod exists "$POD_NAME"; then
echo "ERROR: Pod $POD_NAME still exists." >&2
exit 1
else
echo "Stopped & removed live pod $POD_NAME and containers."
fi
# Enable systemd user services
systemctl --user daemon-reload
# pod service (creates pod + containers)
systemctl --user enable --now "pod-${POD_NAME}.service"
systemctl --user is-enabled "pod-${POD_NAME}.service"
systemctl --user is-active "pod-${POD_NAME}.service"
echo "Enabled systemd service pod-${POD_NAME}.service (rc=$?)"
echo "To view status: systemctl --user status pod-${POD_NAME}.service"
echo "To view logs: journalctl --user -u pod-${POD_NAME}.service -f"
# Wait for API and Grafana readiness
CHECK_URL_API="http://$HOST_LOCAL_IP:$API_HOST_PORT/login"
CHECK_URL_GRAFANA="http://$HOST_LOCAL_IP:$GRAFANA_HOST_PORT/api/health"
for attempt in $(seq 1 30); do
API_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL_API" || true)
GRAFANA_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL_GRAFANA" || true)
if [ "$API_CODE" = "200" ] && [ "$GRAFANA_CODE" = "200" ]; then
echo "API is reachable at $CHECK_URL_API (200)."
echo "Grafana is reachable at $CHECK_URL_GRAFANA (200)."
break
fi
sleep 2
if [ "$attempt" -eq 30 ]; then
echo "timeout error (API=$API_CODE, Grafana=$GRAFANA_CODE)." >&2
exit 1
fi
done

314
docs/ARCHITEKTUR.md Normal file
View File

@@ -0,0 +1,314 @@
# 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<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
+ `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:<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
```mermaid
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
`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`.

View File

@@ -0,0 +1,310 @@
# Ausbaustufe 2 — Implementierungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** Parser-Qualität auf Fable-Abnahmeniveau bringen, Konto-Namen und Betrag-Spalte verbessern, Version + Hilfe-Seite in die GUI, alle Anwenderdaten (inkl. Secrets) unter `~/.local/share/finance_pod`, ein gemeinsames Passwort für GUI und Grafana mit funktionierendem Dashboard-Embedding.
**Architecture:** Bestehendes System (FastAPI + Jinja2/HTMX, Postgres, Grafana, rootless Podman) wird erweitert, nicht umgebaut. Secrets wandern in das Bind-Verzeichnis des Pods, damit Backup+`create_pod_finance.sh` allein für Disaster-Recovery reichen. Grafana-Admin-Passwort wird bei jedem Skript-Lauf aus dem gemeinsamen `FB_PASSWORD` synchronisiert.
**Tech Stack:** unverändert (Python 3.12 im Container, venv 3.11 lokal, pytest, pdfplumber, Grafana OSS 12.1.0).
## Global Constraints
- Arbeitsverzeichnis `/home/wlfb/bin`, Tool-Code unter `finance/`; Phase „.env-Umzug" ändert zusätzlich Repo `/home/wlfb/fb` (dort separat committen).
- Geldbeträge `Decimal`; Nutzertexte Deutsch; vor jedem Commit `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` grün (Basis: 61 passed).
- Commit-Messages enden mit `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
- DATENSCHUTZ: `tests/fixtures/*.pdf` sind echte Kontoauszüge. Keine echten Daten (Beträge, IBANs, Namen, Verwendungszwecke, Datumsangaben aus Auszügen) in Commits, Berichten, Code-Kommentaren oder Test-Dateien. Erwartungswerte für Tests liegen NUR in gitignorten `tests/fixtures/expected_*.json`.
- Parser-Abnahme erfolgt durch den Controller (Fable) persönlich, nicht durch Subagenten-Selbsteinschätzung.
- Deployment-Änderungen (Tasks 57) erst nach den App-Tasks; Redeploy gesammelt in Task 7.
---
### Task 1: Parser-Audit-Werkzeug + DKB-Parser-Korrektur (Fable-Abnahme)
**Files:**
- Create: `finance/scripts/parser_audit.py`
- Modify: `finance/app/parsers/dkb.py` (nach Audit-Befund; `vr.py`/`hvb.py` nur falls Audit strukturgleiche Fehler zeigt)
- Modify: `finance/tests/test_bank_parsers.py` (Noise-Checks + expected-Datei-Tests)
- Modify: `finance/.gitignore` (Zeile `tests/fixtures/expected_*.json` ergänzen)
**Interfaces:**
- Produces: `parser_audit.py` CLI: `python scripts/parser_audit.py tests/fixtures/dkb_beispiel.pdf` → gibt pro Transaktion eine nummerierte Zeile `#i | booking | value | amount | counterparty | purpose` aus, danach den Rohtext (via bestehender Extraktion). Nur lokale Nutzung; Ausgabe enthält echte Daten und darf nirgends persistiert werden außer im gitignorten `.superpowers/`-Workspace.
- Produces: Testschema `tests/fixtures/expected_<bank>.json` (gitignored):
```json
{"transaction_count": 0,
"spot_checks": [{"index": 0, "booking_date": "YYYY-MM-DD",
"amount": "0.00", "counterparty_contains": "...",
"purpose_contains": "..."}]}
```
- [x] **Step 1: Audit-Skript schreiben**
`finance/scripts/parser_audit.py`:
```python
"""Parser-Audit: geparste Transaktionen + Rohtext einer Fixture anzeigen.
NUR lokal verwenden - Ausgabe enthaelt echte Kontodaten und darf nicht
in Commits, Reports oder Tickets uebernommen werden.
Aufruf: python scripts/parser_audit.py tests/fixtures/dkb_beispiel.pdf
"""
import sys
from pathlib import Path
import pdfplumber
from app.parsers.registry import parse_pdf
from app.parsers.validate import balance_difference
path = Path(sys.argv[1])
stmt = parse_pdf(path)
print(f"bank={stmt.bank} iban={stmt.iban} "
f"period={stmt.period_start}..{stmt.period_end}")
print(f"opening={stmt.opening_balance} closing={stmt.closing_balance} "
f"diff={balance_difference(stmt)} n_tx={len(stmt.transactions)}")
print("-" * 100)
for i, t in enumerate(stmt.transactions):
print(f"#{i:3d} | {t.booking_date} | {t.value_date} | {t.amount:>12} "
f"| {t.counterparty[:40]:40} | {t.purpose[:60]}")
print("=" * 100)
with pdfplumber.open(path) as pdf:
for n, page in enumerate(pdf.pages, 1):
print(f"--- Seite {n} ---")
print(page.extract_text() or "(kein Text)")
```
- [x] **Step 2: Controller-Audit (Fable, kein Subagent)** — Der Controller führt das Skript für `dkb_beispiel.pdf` aus, vergleicht JEDE geparste Transaktion feldweise mit dem Rohtext (Buchungs-/Wertstellungsdatum, Betrag, Empfänger, Verwendungszweck, keine Seitenkopf-/Übertrag-/Saldo-Reste, keine verschluckten oder zusammengeklebten Buchungen) und schreibt eine Mängelliste nach `.superpowers/sdd/parser-audit-dkb.md` (gitignorter Workspace — Rohdaten-Zitate nur dort). Danach dasselbe kompakt für `vr_beispiel.pdf` und `hvb_beispiel.pdf` (Strukturprüfung; Detailkorrektur nur bei Befund).
- [x] **Step 3: Parser fixen (Subagent, Mängelliste als Brief)** — Nur die im Audit benannten Defekte beheben; Interface `parse(path) -> ParsedStatement` und Modulstruktur unverändert. Nach jedem Fix: `pytest tests/test_bank_parsers.py -v` (Saldo-Gate bleibt Pflicht).
- [x] **Step 4: Tests härten**
In `finance/tests/test_bank_parsers.py` ergänzen (committebar, ohne echte Daten):
```python
import json
import re
NOISE = re.compile(
r"(Kontostand|Übertrag|alter Saldo|neuer Saldo|Seite \d|Blatt \d)", re.I)
@pytest.mark.parametrize("filename,bank", CASES)
def test_no_noise_in_parsed_fields(filename, bank):
path = FIXTURES / filename
if not path.exists():
pytest.skip(f"Fixture {filename} fehlt")
stmt = parse_pdf(path)
for t in stmt.transactions:
assert not NOISE.search(t.purpose), f"Noise im Verwendungszweck: #{stmt.transactions.index(t)}"
assert not NOISE.search(t.counterparty), f"Noise im Empfänger: #{stmt.transactions.index(t)}"
assert stmt.period_start <= t.booking_date <= stmt.period_end
@pytest.mark.parametrize("filename,bank", CASES)
def test_expected_values(filename, bank):
path = FIXTURES / filename
exp_path = FIXTURES / f"expected_{bank}.json"
if not path.exists() or not exp_path.exists():
pytest.skip("Fixture oder expected-Datei fehlt")
stmt = parse_pdf(path)
exp = json.loads(exp_path.read_text())
assert len(stmt.transactions) == exp["transaction_count"]
for c in exp["spot_checks"]:
t = stmt.transactions[c["index"]]
assert str(t.booking_date) == c["booking_date"]
assert str(t.amount) == c["amount"]
assert c["counterparty_contains"].lower() in t.counterparty.lower()
assert c["purpose_contains"].lower() in t.purpose.lower()
```
`expected_dkb.json` (und bei Befund `expected_vr.json`/`expected_hvb.json`) erstellt der CONTROLLER aus dem Audit (mind. 5 Spot-Checks über den Auszug verteilt, inkl. erster + letzter Buchung). Datei bleibt gitignored — `finance/.gitignore` um `tests/fixtures/expected_*.json` ergänzen.
- [x] **Step 5: Fable-Abnahme** — Controller wiederholt Step 2 auf dem gefixten Stand; Abnahme erst, wenn feldweise keine Mängel mehr bestehen und die volle Suite grün ist.
- [x] **Step 6: Commit** — `fix: DKB-Parser feldweise korrigiert, Audit-Werkzeug und gehaertete Parser-Tests` (Mängel im Commit-Body nur GENERISCH beschreiben, ohne echte Daten).
---
### Task 2: Konto-Namen + Betrag-Spalte
**Files:**
- Modify: `finance/app/routers/accounts.py` (PATCH-Route), `finance/app/routers/gui.py` (Konten in Übersicht-Kontext), `finance/app/templates/index.html` (Umbenennen-Formular), `finance/app/templates/transactions.html` (Spaltenkopf „Betrag (€)", Zellen ohne €, rechtsbündig), `finance/app/static/style.css` (`.amount { text-align: right }`, `td.account { word-break: break-all; max-width: 12rem }`)
- Test: `finance/tests/test_crud_api.py` (PATCH-Tests)
**Interfaces:**
- Produces: `PATCH /api/accounts/{id}` mit Body `{"name": str}` (nur `name`, 1100 Zeichen, führende/abschließende Leerzeichen gestrippt; leer → 422) → 200 mit AccountOut; 404 bei unbekanntem Konto.
- GUI: In der Übersicht je Konto-Zeile Inline-Formular (Textfeld vorbefüllt mit aktuellem Namen, Button „Umbenennen", `hx-patch` via bestehender json-form-Extension auf `/api/accounts/{id}`, danach Reload/Refresh der Tabelle). Buchungsliste zeigt weiterhin `account_names`-Mapping — profitiert automatisch.
- [x] **Step 1: Failing Tests** — in `finance/tests/test_crud_api.py`:
```python
def test_patch_account_name(client):
acc = client.post("/api/accounts", headers=H, json={
"bank": "DKB", "iban": "DE71", "name": "DE71", "type": "giro"}).json()
r = client.patch(f"/api/accounts/{acc['id']}", headers=H,
json={"name": "DKB Giro"})
assert r.status_code == 200 and r.json()["name"] == "DKB Giro"
assert client.patch("/api/accounts/9999", headers=H,
json={"name": "x"}).status_code == 404
assert client.patch(f"/api/accounts/{acc['id']}", headers=H,
json={"name": " "}).status_code == 422
```
- [x] **Step 2: Ausführen — FAIL** · **Step 3: Implementieren** (Route mit `AccountPatch(BaseModel)`; `field_validator` strippt und verwirft leere Namen) · **Step 4: Templates/CSS anpassen** · **Step 5: Suite grün** · **Step 6: Commit** — `feat: Konten umbenennbar, Betrag-Spalte mit Euro im Kopf`
---
### Task 3: Versionsanzeige
**Files:**
- Create: `finance/VERSION` (Inhalt: `0.2.0`), `finance/app/version.py`
- Modify: `finance/app/templates/base.html` (Footer), `finance/app/routers/gui.py` (Jinja-Global), `finance/app/main.py` (`GET /api/version`), `finance/Containerfile` (`COPY VERSION .`), `create_pod_finance.sh` (`API_IMAGE="localhost/finance-api:$(cat "$FINANCE_DIR/VERSION")"`)
- Test: `finance/tests/test_gui.py`
**Interfaces:**
- Produces: `app.version.get_version() -> str` (liest `VERSION` neben dem `app`-Paket, Fallback `"0.0.0-dev"`); `GET /api/version` (mit `require_auth`) → `{"version": "0.2.0"}`; Footer jeder GUI-Seite: `Finanzberatungs-Tool v0.2.0`.
- [x] **Step 1: Failing Test**
```python
def test_version_visible(client):
r = client.get("/api/version", headers={"Authorization": "Bearer test-key"})
assert r.status_code == 200 and r.json()["version"] == "0.2.0"
client.post("/login", data={"username": "admin", "password": "geheim"})
assert "v0.2.0" in client.get("/").text
```
- [x] **Step 2: FAIL** · **Step 3: Implementieren**
`finance/app/version.py`:
```python
from pathlib import Path
def get_version() -> str:
f = Path(__file__).resolve().parent.parent / "VERSION"
try:
return f.read_text().strip()
except OSError:
return "0.0.0-dev"
```
In `gui.py`: `templates.env.globals["app_version"] = get_version()`; in `base.html` vor `</body>`: `<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>`.
- [x] **Step 4: PASS, Suite grün** · **Step 5: Commit** — `feat: Versionsanzeige (VERSION-Datei, Footer, /api/version, Image-Tag)`
---
### Task 4: Hilfe-Seite
**Files:**
- Create: `finance/app/templates/hilfe.html`
- Modify: `finance/app/routers/gui.py` (Route `GET /hilfe` mit `gui_session`), `finance/app/templates/base.html` (Nav-Link „Hilfe"), `finance/tests/test_gui.py` (Route in beide bestehende Seiten-Tests aufnehmen)
**Interfaces:**
- Produces: `GET /hilfe` (Session-pflichtig, 302 → /login ohne Session).
- [x] **Step 1: Tests erweitern** — `/hilfe` in `test_pages_require_login` und `test_pages_render_after_login` aufnehmen; zusätzlich `assert "Gebrauchsanleitung" in r.text`.
- [x] **Step 2: FAIL** · **Step 3: Implementieren** — `hilfe.html` erbt von `base.html`; Inhalt (vollständig, Deutsch — Feinschliff der Formulierungen erlaubt, Struktur bindend):
```
# Gebrauchsanleitung
## Was dieses Tool ist
Liquiditätsplanung auf Basis echter Kontoauszüge: Ist-Stand aus importierten
Auszügen, Zukunft aus gepflegten Planposten und durchgerechneten Szenarien.
## Konzepte
- Konten: entstehen automatisch beim ersten Import (per IBAN). Über die
Übersicht umbenennbar (z. B. "DKB Giro").
- Kontoauszug-Import: PDF in die Drop-Zone auf der Import-Seite ziehen (oder
in die Inbox ~/.local/share/finance_pod/data/inbox/ legen und "Inbox
scannen"). Jeder Import wird geprüft (Anfangssaldo + Buchungen = Endsaldo)
und landet als Entwurf: erst nach Kontrolle der Vorschau "Übernehmen"
klicken. Duplikate werden erkannt und nicht doppelt übernommen.
- Kategorien & Regeln: Buchungen lassen sich kategorisieren; eine Regel
("Muster im Text → Kategorie") kategorisiert künftige Importe automatisch.
Regel direkt aus einer Buchung erzeugen: Button in der Buchungsliste.
- Wiederkehrende Posten: Miete, Gehalt, Abos - mit Rhythmus (monatlich/
quartalsweise/jährlich) und Fälligkeitstag. Der Vorschlags-Knopf erkennt
Kandidaten aus mind. 3 Monaten gleichartiger Buchungen.
- Einmalposten: einzelne künftige Zahlungen (z. B. Steuernachzahlung).
- Kredite: Annuität oder endfällig; Tilgungsplan aufklappbar. Ein Kredit
wirkt erst, wenn er einem Szenario zugeordnet ist.
- Szenarien: eine "Was-wäre-wenn"-Rechnung. Basis = alle wiederkehrenden
Posten + Einmalposten. Varianten entstehen durch Zuordnen von Krediten und
Modifikatoren (Kategorie oder Posten prozentual/absolut kürzen oder ganz
streichen). "Durchrechnen" liefert: tiefster Kontostand mit Datum, erstes
Datum unter 0 und unter der Warnschwelle.
- Grafana: Kontostand-Verläufe, Monatsausgaben nach Kategorie und der
Szenario-Vergleich als Kurven (einmal mit demselben Passwort wie hier
anmelden, dann erscheint das Dashboard auch in der Übersicht).
## Empfohlener Arbeitsablauf
1. Monatlich: neue Auszüge importieren, Vorschau prüfen, übernehmen.
2. Unkategorisierte Buchungen durchsehen; für Wiederkehrendes Regeln anlegen.
3. Planung pflegen: Vorschläge prüfen, Einmalposten eintragen.
4. Fragestellung ("Können wir uns X leisten?") als Szenario-Varianten
abbilden und durchrechnen; Entscheidung anhand Tiefpunkt und
Unterschreitungsdaten treffen, Kurven in Grafana vergleichen.
## Grundregeln
- Bestätigte Buchungen sind die Wahrheit - nie ändern, nur kategorisieren.
- Zukunft ausschließlich über Szenarien planen.
- Empfehlungen immer mit durchgerechneten Zahlen begründen.
```
- [x] **Step 4: PASS, Suite grün** · **Step 5: Commit** — `feat: Hilfe-Seite mit Gebrauchsanleitung`
---
### Task 5: Secrets-Umzug nach `$BIND_DIR/.env`
**Files:**
- Modify: `create_pod_finance.sh`
- Modify (Repo `/home/wlfb/fb`): `CLAUDE.md`, `.claude/skills/finanz-api/SKILL.md`, `.claude/skills/finanzberatung/SKILL.md`, `.claude/skills/auszug-import/SKILL.md` (überall Pfad `/home/wlfb/bin/finance/.env` → `$HOME/.local/share/finance_pod/.env`)
**Interfaces:**
- Produces: `ENV_FILE="$BIND_DIR/.env"`; `$BIND_DIR` mit `chmod 700`; Migration: existiert `$HOME/bin/finance/.env` und `$ENV_FILE` nicht → `mv` + Meldung. Disaster-Recovery-Garantie: `$BIND_DIR` aus Backup + Skript-Lauf = lauffähig.
- [x] **Step 1: Skript ändern** — Reihenfolge: `mkdir -p "$BIND_DIR" && chmod 700 "$BIND_DIR"` VOR dem `.env`-Block; dann:
```bash
ENV_FILE="$BIND_DIR/.env"
LEGACY_ENV="$HOME/bin/finance/.env"
if [ ! -f "$ENV_FILE" ] && [ -f "$LEGACY_ENV" ]; then
mv "$LEGACY_ENV" "$ENV_FILE" && chmod 600 "$ENV_FILE"
echo "MIGRIERT: .env nach $ENV_FILE verschoben"
fi
```
- [x] **Step 2: fb-Repo anpassen** — alle Pfad-Referenzen, inkl. Key-Extraktions-Muster: `grep '^FB_API_KEY=' $HOME/.local/share/finance_pod/.env | cut -d= -f2 | tr -d "'"` (Werte sind single-quoted!).
- [x] **Step 3: Verifizieren (ohne Redeploy, nur Trockenlauf)** — `bash -n create_pod_finance.sh`; Migration + Livegang erst in Task 7.
- [x] **Step 4: Commits** — `~/bin`: `feat: Secrets unter ~/.local/share/finance_pod (Backup-vollstaendig)`; `~/fb`: `docs: .env-Pfad auf finance_pod-Datenverzeichnis umgestellt`
---
### Task 6: Ein Passwort für GUI + Grafana, Embedding aktivieren
**Files:**
- Modify: `create_pod_finance.sh`, `finance/app/templates/index.html` (Hinweis am iframe)
**Interfaces:**
- Produces: `.env` führt `FB_PASSWORD='<klartext>'` (chmod 600; nötig, um Grafana synchron zu halten); `FB_GUI_PASSWORD_HASH` wird daraus abgeleitet; `GRAFANA_ADMIN_PASSWORD` entfällt. Grafana: `GF_SECURITY_ALLOW_EMBEDDING=true`, Admin-Passwort wird bei JEDEM Skript-Lauf per `grafana cli admin reset-admin-password` auf `$FB_PASSWORD` gesetzt (Invocation im Container verifizieren; `grafana-cli` als Fallback). Anonymer Zugriff bleibt AUS.
- Passwort-Wechsel-Prozedur (in Skript-Kommentar dokumentieren): `FB_PASSWORD` in der `.env` ändern, Skript ausführen — Hash und Grafana ziehen nach.
- [x] **Step 1: Bootstrap-Block umbauen** — Neuerzeugung: ein `FB_PASSWORD` generieren, Hash daraus ableiten, beide single-quoted schreiben; einmalige Terminal-Ausgabe des Passworts. Migration bestehender `.env` (hat `FB_GUI_PASSWORD_HASH`, aber kein `FB_PASSWORD`): neues `FB_PASSWORD` generieren, Hash NEU ableiten, `GRAFANA_ADMIN_PASSWORD`-Zeile entfernen, Passwort einmalig ausgeben (Rotation unvermeidbar, da Klartext aus Hash nicht rekonstruierbar).
- [x] **Step 2: Grafana-Container** — `-e GF_SECURITY_ALLOW_EMBEDDING=true`, `GF_SECURITY_ADMIN_PASSWORD="$FB_PASSWORD"`; nach Startwartezeit Sync-Kommando ausführen (idempotent, Fehler abfangen falls Grafana noch initialisiert — Retry-Schleife wie bei pg_isready).
- [x] **Step 3: iframe-Hinweis** — unter dem iframe in `index.html`: kleiner Text „Kein Diagramm sichtbar? Einmal in <a href=...>Grafana anmelden</a> (gleiches Passwort wie hier)."
- [x] **Step 4: `bash -n`, Suite grün (App unverändert bis auf Template)** · **Step 5: Commit** — `feat: gemeinsames Passwort fuer GUI und Grafana, Dashboard-Embedding`
---
### Task 7: Redeploy + Gesamt-Smoke + Disaster-Recovery-Probe
**Files:** keine neuen; führt Tasks 16 im Livesystem zusammen.
- [x] **Step 1: Image + Redeploy** — `podman build -t "localhost/finance-api:$(cat /home/wlfb/bin/finance/VERSION)" /home/wlfb/bin/finance && bash /home/wlfb/bin/create_pod_finance.sh`. Migrationsmeldungen (Env-Umzug, Passwort-Rotation) protokollieren; neues Passwort NUR als letzte Zeile der Abschlussmeldung an den Nutzer.
- [x] **Step 2: Smoke** — Service aktiv; `/login` 200; GUI-Login mit `FB_PASSWORD` → 303+Cookie; `/`, `/buchungen`, `/planung`, `/import`, `/hilfe` 200; Footer zeigt `v0.2.0`; `/api/version` 200; Grafana-Login mit demselben Passwort (`curl -u admin:$FB_PASSWORD .../api/dashboards/uid/finanzen` → 200); Embedding-Header: `curl -sI http://127.0.0.1:8097/` enthält KEIN `X-Frame-Options: deny`; Datenbestand unverändert (1 Konto, 26 bestätigte Buchungen); anonymer Zugriff verweigert (`curl -s http://127.0.0.1:8097/api/dashboards/uid/finanzen` ohne Auth → 401/403).
- [x] **Step 3: Disaster-Recovery-Probe** — `systemctl --user stop pod-finance_pod.service`; alle finance-Units disablen und aus `~/.config/systemd/user/` entfernen; `podman pod rm -f finance_pod`; NUR mit vorhandenem `$BIND_DIR` das Skript erneut ausführen → alles wieder da (Login funktioniert, Datenbestand unverändert, keine Passwort-Neuerzeugung).
- [x] **Step 4: Ledger + Abschluss** — Ergebnisse in `.superpowers/sdd/progress.md`; offene Folgeentscheidung notieren: Neuimport des DKB-Auszugs (bestehende 26 Buchungen tragen ggf. alte Parser-Textfehler) — Nutzerentscheidung, nicht automatisch ausführen.
---
## Abschluss-Checkliste
- [x] Suite grün (>= 65 Tests inkl. neuer Parser-/PATCH-/Version-Tests; expected-Tests laufen lokal, skippen ohne expected-Dateien)
- [x] Fable-Abnahme der Parser dokumentiert (`.superpowers/sdd/parser-audit-dkb.md`, ohne Commit)
- [x] Live: v0.2.0 im Footer, ein Passwort für GUI+Grafana, Embedding aktiv ohne anonymen Zugriff, `.env` unter `$BIND_DIR`, DR-Probe bestanden
- [x] Keine echten Kontodaten/Secrets in `git log -p` beider Repos
- [x] Beide Repos committet; Plan-Häkchen gesetzt

View File

@@ -0,0 +1,141 @@
# Ausbaustufe 3 — CSV-Import, Saldo-Anker, Salden-Seite, Parser-Tuning
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** CSV-Kontoumsatz-Import als primärer Import-Weg (DKB/VR/HVB-Exportformate), Konto-Saldo-Anker mit GUI-Pflege, neue Salden-Seite (Stichtag + Monatsübersicht), PDF-Parser per CSV-Ground-Truth verbessern; anschließend beaufsichtigte Vollmigration (PDF-Bestand → CSV-Basis 2025-01-01 bis heute).
**Architecture:** CSV-Dateien laufen durch die BESTEHENDE Import-Pipeline (Upload/Inbox → ParsedStatement → Statement/Draft → Vorschau → Confirm → Rollback). Neu: `parsers/csv_formats.py` erkennt die drei Bankformate am Header und liefert ParsedStatement. Saldenrechnung wird von Statement-closing auf **Konto-Anker** (Kontostand X am Datum Y, neue Account-Spalten via Alembic) umgestellt — rückwärts wie vorwärts vom Anker aus; Grafana-Views analog. Nutzerentscheidungen (2026-07-19): CSV primär; Anker GUI-pflegbar; Prüfschärfe je Format maximal (VR lückenlos, DKB gegen Datei-Kontostand-Anker, HVB ohne — gekennzeichnet); Migration führt der Controller beaufsichtigt durch.
**Tech Stack:** unverändert; Version → 0.4.0.
## Global Constraints
- Repo `/home/wlfb/bin`, Code `finance/`; Geldbeträge `Decimal`; Nutzertexte Deutsch; Suite grün vor jedem Commit (Basis: 79 passed); Commit-Trailer `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
- DATENSCHUTZ: `tests/fixtures/*.csv` und `*.pdf` sind echte Kontodaten (gitignored). Keine echten Daten in Commits/Reports/Code. Erwartungswerte nur in gitignorten `tests/fixtures/expected_*.json`-Dateien.
- **TEST-VERIFIKATION (Nutzer-Vorgabe): Nach jeder Task-Implementierung führt ein SUBAGENT MIT FABLE-MODELL die Test-/Verifikationspassage aus** (Suite + task-spezifische Checks) und berichtet unabhängig; der Implementer-Selbstbericht genügt nicht.
- UX-Regel (etabliert): Bedienelemente immer sichtbar, Nichtverfügbares disabled mit title.
- Deployment erst in Task 6.
## CSV-Formatreferenz (aus Datei-Analyse 2026-07-19)
| | DKB_*.csv | VR_*.csv | HVB_*.csv |
|---|---|---|---|
| Encoding | UTF-8 mit BOM | UTF-8 mit BOM, CRLF | **UTF-16** |
| Trenner | `;`, Werte in `"` | `;`, unquoted | `;` |
| Kopf | 4 Metazeilen: Girokonto/IBAN; Zeitraum; „Kontostand vom TT.MM.JJJJ:" + Betrag mit €; Leerzeile — dann Spaltenkopf | Spaltenkopf in Zeile 1 | Spaltenkopf in Zeile 1 |
| Spalten | Buchungsdatum;Wertstellung;Status;Zahlungspflichtige*r;Zahlungsempfänger*in;Verwendungszweck;Umsatztyp;IBAN;Betrag (€);Gläubiger-ID;Mandatsreferenz;Kundenreferenz | Bezeichnung Auftragskonto;IBAN Auftragskonto;BIC Auftragskonto;Bankname Auftragskonto;Buchungstag;Valutadatum;Name Zahlungsbeteiligter;IBAN Zahlungsbeteiligter;BIC (SWIFT-Code) Zahlungsbeteiligter;Buchungstext;Verwendungszweck;Betrag;Waehrung;Saldo nach Buchung;Bemerkung;Gekennzeichneter Umsatz;Glaeubiger ID;Mandatsreferenz | Kontonummer;Buchungsdatum;Valuta;Verwendungszweck;Betrag;Waehrung |
| Datumsformat | TT.MM.JJ (zweistellig!) | TT.MM.JJJJ | TT.MM.JJJJ |
| Sortierung | absteigend | absteigend | aufsteigend |
| Konto-Zuordnung | IBAN aus Kopfzeile | IBAN Auftragskonto | Kontonummer → Konto per `iban.endswith(kontonummer)` |
| counterparty | Betrag<0 → Zahlungsempfänger*in, sonst Zahlungspflichtige*r | Name Zahlungsbeteiligter | "" (steckt im Verwendungszweck) |
| purpose | Umsatztyp + " " + Verwendungszweck | Buchungstext + " " + Verwendungszweck | Verwendungszweck |
| value_date | Wertstellung | Valutadatum | Valuta |
| Saldo-Prüfung | Datei-Kontostand als KONTO-ANKER übernehmen (Datum aus „Kontostand vom"); keine per-Datei-Prüfung (Kontostand = Exportzeitpunkt, nicht Periodenende) | Saldo nach Buchung: chronologisch je Zeile prüfen `saldo_n = saldo_{n-1} + betrag_n` → daraus opening/closing für ParsedStatement ableiten (balance_difference==0 greift); Anker = neuester Saldo | keine (opening/closing None, preview „Saldo-Prüfung: nicht verfügbar") |
| Sonstiges | nur Zeilen mit Status "Gebucht" importieren | Abschluss-Zeilen (leerer Name) normal importieren | Betrag-Spalte ist maßgeblich (Text enthält Betrag redundant) |
Beträge deutsch formatiert (`1.234,56`, Minus führend) → `parse_german_amount` wiederverwenden (€-Zeichen/`EUR` vorher strippen).
---
### Task 1: Konto-Saldo-Anker (Modell, Migration, Saldenrechnung, GUI, Views)
**Files:**
- Modify: `finance/app/models/tables.py` (Account: `anchor_date: Mapped[date | None]`, `anchor_balance: Mapped[Decimal | None]` = Numeric(12,2))
- Create: `finance/alembic/versions/<autogen>_konto_anker.py` (autogenerate, add_column x2)
- Modify: `finance/app/services/balances.py`, `finance/app/routers/accounts.py` (PATCH um anchor-Felder), `finance/app/templates/index.html` (Anker-Anzeige+Pflegeformular je Konto), `finance/app/models/views.py` (Views ankerbasiert)
- Test: `finance/tests/test_crud_api.py`, `finance/tests/test_gui.py`
**Interfaces:**
- Produces: `balances.account_balance(session, account, at: date | None = None) -> Decimal``at` default heute. Mit Anker: `anchor_balance + Σ(tx: anchor_date < booking_date <= at) Σ(tx: at < booking_date <= anchor_date)` (nur bestätigte; eine der Summen ist leer). Ohne Anker: `Σ(tx: booking_date <= at)`. Die bisherige Statement-closing-Logik ENTFÄLLT ersatzlos (Statements bleiben als Import-Einheiten bestehen).
- `PATCH /api/accounts/{id}` zusätzlich: `anchor_date: date | None`, `anchor_balance: Decimal | None` (beide optional, model_fields_set-Semantik wie bei name; nur gemeinsam gesetzt oder gemeinsam null → sonst 422 „Anker braucht Datum und Betrag").
- GUI Übersicht: je Konto Ankeranzeige („Anker: X € am TT.MM.JJJJ" oder „kein Anker") + Inline-Formular (Datum+Betrag, json-form hx-patch).
- `views.py`: `v_balance_history`/`v_balance_total` seeden pro Konto mit `anchor_balance Σ(tx > … bis anchor_date)` statt Statement-opening — konkret: Basis je Konto = `account_balance` zum frühesten Buchungsdatum dessen Tagesumsatz; einfachste korrekte Form: CTE mit Anker-Join und kumulativer Fenstersumme relativ zum Anker. `v_projection` unverändert.
- [x] **Step 1: Failing Tests** — test_crud_api: (a) Konto mit Anker (Datum D, Betrag B) + bestätigte Buchungen vor/nach D → `account_balance(at=D)==B`, `at=D+2d` addiert spätere Buchung, `at=D2d` subtrahiert dazwischenliegende; (b) PATCH setzt/löscht Anker, 422 bei nur-einem-Feld; test_gui: Übersicht zeigt Ankertext.
- [x] **Step 2: FAIL** · **Step 3: Implementieren + Alembic-Autogenerate gegen Wegwerf-SQLite** (Muster Task 2 Ausbaustufe 1; Migration committen) · **Step 4: PASS, Suite grün**
- [x] **Step 5: FABLE-TESTAGENT** verifiziert unabhängig (Suite + Anker-Randfälle: at==anchor_date, Buchung AM Ankertag zählt nicht zur Vorwärtssumme/wohl zur Rückwärtssumme — Konvention: Anker gilt per Tagesende).
- [x] **Step 6: Commit**`feat: Konto-Saldo-Anker mit ankerbasierter Saldenrechnung`
---
### Task 2: CSV-Format-Parser
**Files:**
- Create: `finance/app/parsers/csv_formats.py`, `finance/tests/test_csv_formats.py`
- Modify: `finance/.gitignore` ist bereits um `tests/fixtures/*.csv` ergänzt (erledigt)
**Interfaces:**
- Produces: `csv_formats.detect_csv_format(head_bytes: bytes) -> str | None` („dkb_csv"|„vr_csv"|„hvb_csv"; erkennt Encoding inkl. UTF-16/BOM + Headersignatur); `csv_formats.parse_csv(path: Path) -> ParsedCsv` mit `ParsedCsv(statement: ParsedStatement, anchor: tuple[date, Decimal] | None, balance_checkable: bool)`. Transaktionen chronologisch aufsteigend im ParsedStatement, Mapping exakt gemäß Formatreferenz oben. VR: Saldo-Kette prüfen, bei Bruch `ParserError(„VR-CSV: Saldo-Kette inkonsistent bei Zeile N")`; opening/closing setzen (balance_checkable=True). DKB: anchor=(Datum aus „Kontostand vom", Betrag), opening/closing=None, nur „Gebucht". HVB: alles None/False.
- ParserError bei unbekanntem Format/kaputter Struktur.
- [x] **Step 1: Failing Tests** — pro Format parametrisiert (skip wenn Datei fehlt): Zeilenzahl >0, Transaktionen aufsteigend, Beträge Decimal, VR: balance_difference(stmt)==0 und anchor gesetzt, DKB: anchor gesetzt + alle Status Gebucht + counterparty je Vorzeichen befüllt, HVB: balance_checkable False; plus `expected_csv_<fmt>.json` (gitignored, Schema wie expected_*: transaction_count + 5 spot_checks) — Dateien erstellt der CONTROLLER nach Implementierung aus Stichproben.
- [x] **Step 2: FAIL** · **Step 3: Implementieren** (csv-Modul der stdlib; Encoding-Erkennung: BOM-Sniff + UTF-16-Heuristik über Nullbytes) · **Step 4: PASS**
- [x] **Step 5: CONTROLLER erstellt expected-Dateien** (Stichproben aus den echten CSVs, nur lokal) · **Step 6: FABLE-TESTAGENT** verifiziert (Suite + eigene Stichproben-Gegenkontrolle CSV-Zeile↔geparste Transaktion für je 3 Zeilen pro Format, feldweise).
- [x] **Step 7: Commit**`feat: CSV-Parser fuer DKB/VR/HVB-Kontoumsatz-Exporte`
---
### Task 3: CSV in die Import-Pipeline
**Files:**
- Modify: `finance/app/services/importer.py` (Dateiendungs-Weiche: .csv → parse_csv; Anker-Autofill: bei confirm... nein: beim IMPORT `ParsedCsv.anchor` am Statement zwischenspeichern → einfacher: importer setzt Anker DIREKT am Konto beim process, nur wenn `anchor_date` neuer als vorhandener Anker oder keiner existiert), `finance/app/routers/imports.py` (Upload akzeptiert .csv; 400-Text anpassen „Nur PDF- oder CSV-Dateien"), Inbox-Scan `*.csv` zusätzlich, `finance/app/templates/_preview_table.html`/`_import_list.html` (balance_ok dreiwertig: True „Saldo plausibel" / False rot / None „Saldo-Prüfung: nicht verfügbar (Format ohne Saldodaten)"), `finance/app/templates/hilfe.html` (CSV als primärer Weg, PDF als Fallback)
- Test: `finance/tests/test_import_api.py`
**Interfaces:**
- Produces: preview-Response `balance_ok: bool | None`. Statement.bank erhält Formatkennung („dkb_csv" etc.). Duplikatschutz unverändert über dedup_hash (funktioniert für künftige überlappende CSV-Exporte, da identische Texte).
- Anker-Regel: Import (bereits beim Draft-Anlegen) aktualisiert `account.anchor_*` nur, wenn CSV-Anker-Datum ≥ bestehendes Anker-Datum oder Konto ohne Anker; manuelles Überschreiben bleibt via PATCH möglich.
- [x] **Step 1: Failing Tests** — Upload einer synthetischen Mini-CSV je Format (im Test als Bytes konstruiert nach Formatreferenz — KEINE echten Daten): 201 draft, preview balance_ok-Wert je Format (VR True, HVB None), confirm übernimmt, DKB-Anker am Konto gesetzt; Upload .txt → 400; Inbox-Scan findet .csv.
- [x] **Step 2: FAIL** · **Step 3: Implementieren** · **Step 4: PASS, Suite grün**
- [x] **Step 5: FABLE-TESTAGENT** verifiziert (Suite + Upload der ECHTEN 6 Dateien gegen Test-Instanz in-memory: je 201+plausible Zeilenzahlen — Zahlen nur als Counts berichten).
- [x] **Step 6: Commit**`feat: CSV-Import ueber bestehende Pipeline mit dreiwertiger Saldo-Pruefung`
---
### Task 4: Salden-Seite
**Files:**
- Create: `finance/app/templates/salden.html`
- Modify: `finance/app/routers/gui.py` (GET /salden mit gui_session; Stichtag-Param lenient wie Filter), `finance/app/templates/base.html` (Nav „Salden" zwischen Buchungen und Planung), `finance/app/templates/hilfe.html` (Konzept-Absatz), `finance/tests/test_gui.py`
**Interfaces:**
- GET /salden[?stichtag=YYYY-MM-DD] → Abschnitt 1 „Salden am <Datum>" (Default heute): Tabelle Konto→Saldo (account_balance(at=stichtag)) + Gesamtzeile; Datumsformular (GET). Abschnitt 2 „Monatsanfangs-Salden": Zeilen = 1. jedes Monats von frühestem Buchungsmonat bis heute (absteigend), Spalten = Konten + Gesamt; Werte = account_balance(at=Monatsersten). Beträge 2 Nachkommastellen, .neg-Klasse, Konten ohne Anker mit Fußnote „ohne Anker — relative Werte".
- [x] **Step 1: Failing Tests** — /salden in beide Seiten-Tests; mit Seed (Anker + Buchungen über 3 Monate): Stichtags-Tabelle zeigt erwarteten Kontosaldo, Monatsübersicht enthält 3 Monatszeilen mit korrektem Wert für einen definierten Monatsersten.
- [x] **Step 2: FAIL** · **Step 3: Implementieren** · **Step 4: PASS** · **Step 5: FABLE-TESTAGENT** (Suite + Handrechnung eines Monatsanfangs-Werts gegen Seed-Daten) · **Step 6: Commit**`feat: Salden-Seite mit Stichtag und Monatsuebersicht`
---
### Task 5: PDF-Parser-Tuning per CSV-Ground-Truth (Fable-Audit)
**Files:**
- Create: `finance/scripts/parser_vs_csv.py` (lokales Vergleichswerkzeug)
- Modify: `finance/app/parsers/dkb.py` (ggf. vr.py/hvb.py), `finance/tests/fixtures/expected_*.json` (Controller, lokal)
**Interfaces:**
- `parser_vs_csv.py <pdf> <csv...>`: matcht PDF-Transaktionen gegen CSV-Zeilen im Überlappungszeitraum über (booking_date, amount); gibt je Match Feldvergleich counterparty/purpose aus (Ähnlichkeit + Differenzen), am Ende Metrik: N matched, counterparty-Übereinstimmung x %, purpose-Kernübereinstimmung y %. Nur lokal, echte Daten nur im gitignorten Workspace.
- [x] **Step 1: Werkzeug bauen (Subagent)** · **Step 2: CONTROLLER-Audit (Fable)**: Werkzeug auf DKB/VR/HVB-Überlappung (JuniJuli 2026) laufen lassen, konkrete strukturelle Verbesserungen identifizieren (v. a. DKB: Empfänger/Zweck-Trennung — CSV-Empfängerspalte zeigt, wo die Grenze liegt; erwartbar: Empfänger = Anfang der ersten Fortsetzungszeile bis zum ersten Schlüsselwort-Muster). Mängelliste in `.superpowers/sdd/parser-vs-csv-audit.md` (gitignored).
- [x] **Step 3: Parser-Fixes (Subagent, Mängelliste als Brief)** — Saldo-Gates und bestehende expected-Tests bleiben Pflicht; expected_*.json passt der CONTROLLER an, wo sich purposes verbessern.
- [x] **Step 4: FABLE-TESTAGENT**: Suite + Metrik-Vergleich vorher/nachher (Werkzeug erneut ausführen; Verbesserung dokumentieren, Zahlen ohne echte Daten). **Fable-Abnahme durch Controller.**
- [x] **Step 5: Commit**`fix: PDF-Parser per CSV-Ground-Truth verbessert` (generische Beschreibung). Hinweis: „möglichst gut" = Best effort; verbleibende strukturbedingte Grenzen (z. B. DKB-Fließtext) werden dokumentiert, nicht erzwungen.
---
### Task 6: Version 0.4.0, Redeploy, beaufsichtigte Vollmigration
**Files:** `finance/VERSION``0.4.0`; keine weiteren Code-Änderungen.
- [x] **Step 1:** VERSION bump + Commit `chore: Version 0.4.0` → Image-Build → `create_pod_finance.sh` (Alembic-Migration läuft im Entrypoint).
- [x] **Step 2 (CONTROLLER, beaufsichtigt):** (a) 3 PDF-Importe zurückrollen (Rollback-Feature); (b) HVB-Anker setzen: PATCH anchor_date=2026-07-03, anchor_balance=4559.51 (aus PDF-Auszug); (c) die 6 CSVs importieren (Reihenfolge egal, je: Upload → Preview prüfen [Zeilenzahl plausibel; VR balance_ok True; DKB/HVB Kennzeichnung sichtbar] → Confirm); DKB/VR-Anker kommen automatisch aus den Dateien; (d) Endkontrolle: Buchungszahl gesamt = Summe der CSV-Zeilen (minus evtl. Nicht-Gebucht), Salden-Seite: DKB-Saldo heute == Datei-Kontostand, VR-Saldo == neuester Saldo nach Buchung, HVB plausibel gegen Anker; Grafana-Kurven ab 2025; Suite grün; Smoke der Kernseiten.
- [x] **Step 3:** Ledger + Plan-Häkchen + Commit; Hilfe-/Memory-Konsistenz.
---
## Abschluss-Checkliste
- [x] Suite grün (>= 90 Tests erwartet); alle Task-Verifikationen durch FABLE-Testagenten dokumentiert
- [x] Live: v0.4.0; Datenbestand vollständig aus 6 CSVs (2025-01-01 bis heute), Anker je Konto gesetzt, Salden-Seite konsistent mit CSV-Quellwerten
- [x] Parser-Tuning-Metriken dokumentiert (vorher/nachher), Fable-Abnahme erteilt
- [x] Keine echten Kontodaten/Secrets in git log beider Repos; CSV/PDF/expected-Dateien untracked
- [x] Plan-Häkchen gesetzt, beide Repos committet

View File

@@ -0,0 +1,44 @@
# Ausbaustufe 4 — Backup, Admin-Seite mit Passwortänderung, Grafana-Lücken, Architektur-Doku
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Fable-Testagent als Gate je Task (Nutzer-Vorgabe). Checkbox-Tracking.
**Goal:** Kalt-Backup-Skript; Admin-Seite (Passwortänderung vollintegriert + Regeln-neu-anwenden); durchgehende Grafana-Kontostandslinien; Architektur-Doku (Mermaid) + CLAUDE.md für künftige Sessions. Version 0.5.0.
**Nutzerentscheidungen (2026-07-20):** Backup kalt (Pod-Stopp) nach `~/backups/` (Zielverzeichnis als Argument überschreibbar); Passwortänderung vollintegriert (.env in Container gemountet, Laufzeit-Lesen, Grafana per HTTP-API); Admin-Zusatzfunktion NUR „Regeln neu anwenden".
## Global Constraints
- Wie Ausbaustufe 3 (Decimal, Deutsch, Suite grün — Basis 132, Trailer, DATENSCHUTZ, UX-Regel sichtbar/disabled, Fable-Testagent-Gate je Task). Deployment gesammelt am Ende (Task 5).
- Backup-Datei enthält Secrets (.env) → chmod 600.
- KRITISCH (.env im Container): Bind-Mount einer EINZELDATEI folgt dem Inode — die App muss die Datei IN-PLACE beschreiben (open r+/truncate, flock), NIEMALS über Temp-Datei+rename. Host-Skript-seitige sed-Edits erzeugen neue Inodes — unkritisch, weil das Skript die Container ohnehin neu erstellt.
### Task 1: Backup-Skript
**Files:** Create `backup_finance_pod.sh` (Repo-Root, ausführbar).
**Interface:** `./backup_finance_pod.sh [ZIELDIR]` (Default `$HOME/backups`): systemctl --user stop pod-finance_pod.service → `tar czf ZIELDIR/finance_pod_$(date +%F_%H%M).tar.gz -C ~/.local/share finance_pod` → start → Readiness-Curl /login → `tar -tzf`-Integritätscheck (muss .env und postgres-data enthalten) → chmod 600 → Ausgabe Pfad+Größe. Bei jedem Fehler: Service wieder starten (trap), Exit ≠ 0.
- [x] Skript schreiben, `bash -n` · [x] FABLE-TESTAGENT: Skript LIVE ausführen (kurze Downtime ok), prüfen: Service danach active, Login 200, Archiv 600 + enthält .env/postgres-data/grafana-data/data, Restore-Probe NUR als tar-Listing (kein Zurückspielen); Fehlerpfad: Skript mit unbeschreibbarem Ziel → Service läuft trotzdem weiter · [x] Commit `feat: Kalt-Backup-Skript finance_pod`
### Task 2: Admin-Seite (Passwortänderung + Regeln-Anwenden)
**Files:** Modify `create_pod_finance.sh` (Mount `-v "$ENV_FILE:/data/.env:Z"` am API-Container; Kommentar Inode-Regel), `finance/app/config.py` (+`env_file: Path` aus `FB_ENV_FILE`, Default `/data/.env`), `finance/app/auth.py` (Hash-Lookup zur Laufzeit: existiert env_file → Werte daraus parsen (single-quoted), sonst Fallback env; kleine Helferfunktion mit mtime-Cache), Create `finance/app/services/admin.py` (`change_password(old, new)`: verify old → Grafana `PUT http://localhost:3000/api/admin/users/1/password` Basic-Auth admin:old → .env IN-PLACE (flock, FB_PASSWORD+FB_GUI_PASSWORD_HASH single-quoted ersetzen) → bei Grafana-Fehler abbrechen ohne .env-Änderung; `apply_rules_retroactively(session) -> int` auf unkategorisierte confirmed), Create `finance/app/routers/admin.py` (GUI `GET /admin` [gui_session] + `POST /admin/passwort` [gui_session, Form alt/neu/neu2, neu ≥ 8 Zeichen] + API `POST /api/category-rules/apply` [require_auth]), Templates `admin.html` + Nav „Admin", Tests.
**Semantik:** Session-Cookies bleiben nach Änderung gültig (Secret unverändert — dokumentieren). Meldungen Deutsch. In Tests env_file → tmp_path (FB_ENV_FILE), Grafana-Call gemockt (monkeypatch), In-Place-Schreiben per Inode-Vergleich getestet.
- [x] TDD · [x] FABLE-TESTAGENT (Suite; Inode-Konstanz beim Schreiben; Mock-Grafana-Reihenfolge: kein .env-Write bei Grafana-Fehler; /admin-Auth; apply-Endpoint zählt korrekt) · [x] Commit `feat: Admin-Seite mit Passwortaenderung und Regel-Neuanwendung`
### Task 3: Grafana durchgehende Linien
**Files:** Modify `finance/app/models/views.py` (v_balance_history/v_balance_total: tägliche Reihe via `generate_series(min(start), CURRENT_DATE, '1 day')` je Konto mit Carry-Forward des ankerbasierten Saldos — keine NULL-Lücken mehr), `finance/grafana/dashboards/finanzen.json` (Panels 1+2: `lineInterpolation: "stepAfter"`, `spanNulls: true`).
- [x] Implementieren (SQL Postgres-only wie bisher) · [x] FABLE-TESTAGENT: Wegwerf-DB auf finance-db_ctr (Muster A3-T1): synthetische Daten mit mehrtägigen Buchungslücken → Views liefern JEDEN Tag genau eine Zeile je Konto, Werte = Handrechnung, keine Lücken; finanzen.json parsebar + Optionen gesetzt; Suite grün · [x] Commit `fix: lueckenlose taegliche Saldo-Reihen fuer Grafana`
### Task 4: Architektur-Doku + CLAUDE.md
**Files:** Create `docs/ARCHITEKTUR.md` (Mermaid: (1) Komponenten-Diagramm Module→Funktionen: parsers [base/detect/validate/vr/hvb/dkb/csv_formats/registry], engine [loans/recurrence/projection/scenario], services [importer/balances/categorize/suggestions/projection_service/admin], routers [accounts/transactions/categories/imports/planning/scenarios/gui/admin], templates/Seiten, models+views, alembic; (2) Datenfluss-Sequenz Import CSV/PDF→Draft→Confirm→Anker/Salden; (3) Deployment-Diagramm Pod/Container/Mounts/Ports/systemd/.env; je Knoten Stichwort-Funktionsliste), Create `CLAUDE.md` (Repo-Root ~/bin: Projektüberblick, Betriebs-/Testkommandos, DATENSCHUTZ-Regeln [fixtures = echte Daten!], Konventionen [Decimal, Deutsch, Fable-Test-Gate, Superpowers-Workflow, Plan-/Ledger-Orte, Versions-Prozess VERSION-Datei, Backup/DR], Verweis auf ARCHITEKTUR.md + docs/superpowers/plans/), Update Claude-Memory.
- [x] Schreiben · [x] FABLE-TESTAGENT: Faktencheck der Doku gegen den Code (jede benannte Datei/Funktion existiert; Mermaid-Syntax valide; keine echten Daten) · [x] Commit `docs: Architektur (Mermaid) und CLAUDE.md fuer kuenftige Sessions`
### Task 5: v0.5.0 + Redeploy + Smoke
- [x] VERSION 0.5.0, Build, Redeploy · [x] Smoke: Kernseiten inkl. /admin; Passwortänderung E2E LIVE (auf Temp-Passwort ändern → GUI+Grafana-Login mit Temp OK → zurück auf Original ändern → Original-Login OK); Regeln-Anwenden-Button; Grafana-Panel liefert tägliche Punkte (View-COUNT == Kalendertage) · [x] Backup-Skript einmal final ausführen (frisches Backup nach Abschluss) · [x] Plan-Häkchen, Ledger, Memory, Commit + PUSH beider Repos
## Abschluss-Checkliste
- [x] Suite grün (>= 140 erwartet); alle Fable-Gates dokumentiert
- [x] Live v0.5.0: Passwort unverändert (nach E2E-Probe zurückrotiert), Admin-Seite funktional, Grafana-Linien durchgehend, Backup in ~/backups vorhanden (600)
- [x] ARCHITEKTUR.md + CLAUDE.md committet; Memory aktuell; gepusht

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,449 @@
# Ausbaustufe 6 Implementation Plan — Szenario-Einträge-Tabelle (v0.7.0)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** Pro Szenario eine immer sichtbare, editierbare Tabelle „Einträge" (Modifikatoren + szenario-eigene Einmalzahlungen gemeinsam) mit Inline-Bearbeiten und einem gemeinsamen Anlege-Formular; Release v0.7.0.
**Architecture:** Datenmodell unverändert — zwei neue Full-Body-PATCH-Endpunkte in `routers/scenarios.py`; Template-Umbau in `planning.html` ersetzt die zwei `<details>`-Abschnitte durch eine Tabelle; Feld-Umschaltung per `disabled` (JS `onEntryArtChange` ersetzt `onModKindChange`), Endpunkt-Wechsel des Anlege-Formulars per `hx-post`-Attribut + `htmx.process`.
**Tech Stack:** FastAPI + Pydantic v2, Jinja2, htmx + json-form-Extension, pytest.
**Spec:** `docs/superpowers/specs/2026-07-20-szenario-eintraege-design.md`
## Global Constraints
- Geldbeträge `Decimal`; JSON-API Punkt-Dezimal; HTML deutsch (`|eur`, `|de_label`, TT.MM.JJJJ).
- API-/DB-Werte englisch; neuer Formular-Artwert `einmal` ist NUR clientseitig (landet nie in der DB — an `/planned` gesendet wird er von Pydantic ignoriert).
- UX-Regel: Umschalten/Verstecken nur per `disabled`/`hidden`, nie DOM-Entfernen.
- Fable-Testagent-Gate je Task VOR dem Commit; Ledger-Eintrag je Task in `.superpowers/sdd/progress.md`.
- DATENSCHUTZ: keine echten Namen/Beträge in Commits/Tests/Doku; Live-Demo-Daten nur DB/Chat.
- Testlauf: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` — Basis 178 passed, muss grün bleiben.
- Pfade unten relativ zu `/home/wlfb/bin/finance` (Ledger/Plan unter `/home/wlfb/bin`).
---
### Task 1: PATCH-Endpunkte für Modifikatoren und Szenario-Einmalzahlungen
**Files:**
- Modify: `app/routers/scenarios.py`
- Modify: `tests/test_crud_api.py`
**Interfaces:**
- Consumes: `ModifierIn`/`ModifierOut`, `ScenarioPlannedIn`/`ScenarioPlannedOut`, `_get_scenario` (alle vorhanden in `scenarios.py`).
- Produces: `PATCH /api/scenarios/{scenario_id}/modifiers/{mod_id}` (Body `ModifierIn`, Antwort `ModifierOut`, 404 „Modifikator nicht gefunden") und `PATCH /api/scenarios/{scenario_id}/planned/{item_id}` (Body `ScenarioPlannedIn`, Antwort `ScenarioPlannedOut`, 404 „Einmalzahlung nicht gefunden"). Task 2 (GUI) sendet auf genau diese URLs.
- [x] **Step 1: Failing Tests** — in `tests/test_crud_api.py` ergänzen:
```python
def test_modifier_patch(client, db):
sc = client.post("/api/scenarios", headers=H, json={"name": "ModPatch"}).json()
m = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "percent",
"value": "10.00"}).json()
# Wert aendern
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "25.00"})
assert r.status_code == 200 and r.json()["value"] == "25.00"
# Art auf ende wechseln (mit Datum); value wird vom Validator genullt
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "ende",
"end_date": "2026-08-31"})
assert r.status_code == 200
assert r.json()["kind"] == "ende" and r.json()["end_date"] == "2026-08-31"
# ende ohne Datum -> 422
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "ende"})
assert r.status_code == 422
# zurueck auf remove: end_date wird genullt
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "remove",
"end_date": "2026-08-31"})
assert r.status_code == 200 and r.json()["end_date"] is None
# fremdes Szenario -> 404
other = client.post("/api/scenarios", headers=H, json={"name": "ModPatch2"}).json()
r = client.patch(f"/api/scenarios/{other['id']}/modifiers/{m['id']}", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "remove"})
assert r.status_code == 404
def test_scenario_planned_patch(client, db):
sc = client.post("/api/scenarios", headers=H, json={"name": "SpiPatch"}).json()
p = client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
"name": "Zufluss", "amount": "5000.00", "due": "2026-07-30"}).json()
r = client.patch(f"/api/scenarios/{sc['id']}/planned/{p['id']}", headers=H, json={
"name": "Zufluss geaendert", "amount": "6000.00", "due": "2026-08-15"})
assert r.status_code == 200
body = r.json()
assert body["name"] == "Zufluss geaendert"
assert body["amount"] == "6000.00" and body["due"] == "2026-08-15"
# fremdes Szenario -> 404
other = client.post("/api/scenarios", headers=H, json={"name": "SpiPatch2"}).json()
r = client.patch(f"/api/scenarios/{other['id']}/planned/{p['id']}", headers=H, json={
"name": "x", "amount": "1.00", "due": "2026-08-15"})
assert r.status_code == 404
```
Run: `.venv/bin/python -m pytest tests/test_crud_api.py -q` → neue Tests FAIL (405 Method Not Allowed)
- [x] **Step 2: Endpunkte implementieren**`app/routers/scenarios.py`, direkt nach `delete_modifier` bzw. nach `delete_scenario_planned`:
```python
@router.patch("/{scenario_id}/modifiers/{mod_id}", response_model=ModifierOut)
def patch_modifier(scenario_id: int, mod_id: int, data: ModifierIn,
session: Session = Depends(get_session)):
# Full-Body-Update: das Edit-Formular sendet immer alle Felder des Typs.
_get_scenario(session, scenario_id)
modifier = session.get(ScenarioModifier, mod_id)
if modifier is None or modifier.scenario_id != scenario_id:
raise HTTPException(404, "Modifikator nicht gefunden")
for key, value in data.model_dump().items():
setattr(modifier, key, value)
session.commit()
session.refresh(modifier)
return ModifierOut.model_validate(modifier)
```
```python
@router.patch("/{scenario_id}/planned/{item_id}", response_model=ScenarioPlannedOut)
def patch_scenario_planned(scenario_id: int, item_id: int, data: ScenarioPlannedIn,
session: Session = Depends(get_session)):
# Full-Body-Update, Muster patch_modifier.
_get_scenario(session, scenario_id)
item = session.get(ScenarioPlannedItem, item_id)
if item is None or item.scenario_id != scenario_id:
raise HTTPException(404, "Einmalzahlung nicht gefunden")
for key, value in data.model_dump().items():
setattr(item, key, value)
session.commit()
session.refresh(item)
return ScenarioPlannedOut.model_validate(item)
```
- [x] **Step 3: Tests grün + Suite**
Run: `.venv/bin/python -m pytest tests/test_crud_api.py -q && .venv/bin/python -m pytest -q`
Expected: alles PASS (178 + 2)
- [x] **Step 4: Fable-Testagent-Abnahme** (Faktencheck: Validator-Wirkung beim PATCH [ende↔andere Arten], 404-Pfade, keine Nebenwirkungen auf Projektion). Erst nach VERIFIED weiter.
- [x] **Step 5: Commit**
```bash
cd /home/wlfb/bin && git add finance/app/routers/scenarios.py finance/tests/test_crud_api.py
git commit -m "feat: PATCH-Endpunkte fuer Szenario-Modifikatoren und -Einmalzahlungen"
```
---
### Task 2: GUI-Umbau — eine Einträge-Tabelle je Szenario
**Files:**
- Modify: `app/templates/planning.html` (Szenario-Abschnitt: die beiden `<details>` „Modifikatoren" [Zeilen ~300-362] und „Einmalzahlungen in diesem Szenario" [~364-395] ersetzen; JS-Block: `onModKindChange` ersetzen)
- Modify: `app/routers/gui.py` (`_scenario_rows`: Modifikator-Query stabil sortieren)
- Modify: `app/templates/hilfe.html` (Szenarien-Absatz an neue Tabelle anpassen)
- Modify: `tests/test_gui.py` (neuer Test + Anpassung `test_szenario_gui_ende_und_einmalzahlungen`)
**Interfaces:**
- Consumes: PATCH-Endpunkte aus Task 1; `toggleEdit`, `onModTargetTypeChange`, Filter `|eur`/`|de_label`, Kontext `row.modifiers`/`row.planned_items`/`modifier_kinds`/`recurring_names`/`category_names` (alle vorhanden).
- Produces: Element-IDs `mod-row-{id}`/`mod-edit-{id}` und `spi-row-{id}`/`spi-edit-{id}`; JS `onEntryArtChange(select)` (ersetzt `onModKindChange` vollständig — auch in den Art-Selects); Anlege-Formular mit `data-modifiers-url`/`data-planned-url` und Eintragsart-Option `value="einmal"`.
- [x] **Step 1: Failing GUI-Test** — in `tests/test_gui.py` ergänzen (Imports `Scenario, ScenarioModifier, ScenarioPlannedItem` existieren dort seit A5):
```python
def test_szenario_eintraege_tabelle(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
sc = Scenario(name="Eintraege-GUI", description="")
db.add(sc)
db.flush()
db.add(ScenarioModifier(scenario_id=sc.id, target_type="recurring",
target_id=1, kind="ende", value=Decimal("0"),
end_date=date(2026, 8, 31)))
db.add(ScenarioModifier(scenario_id=sc.id, target_type="category",
target_id=1, kind="percent", value=Decimal("25.00")))
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Zufluss-Eintrag",
amount=Decimal("5000.00"), due=date(2026, 7, 30)))
db.commit()
r = client.get("/planung").text
assert "Einträge" in r
# alte details-Abschnitte sind ersetzt
assert "<summary>Modifikatoren</summary>" not in r
assert "Einmalzahlungen in diesem Szenario" not in r
# gemischte Zeilen in EINER Tabelle
assert "31.08.2026" in r and "25,00" in r and "Zufluss-Eintrag" in r
assert ">Einmalzahlung<" in r
# Inline-Edit je Typ
assert f'hx-patch="/api/scenarios/{sc.id}/modifiers/' in r
assert f'hx-patch="/api/scenarios/{sc.id}/planned/' in r
# Anlege-Formular: Eintragsart inkl. Einmalzahlung + Endpunkt-Wechsel-Attribute
assert 'value="einmal"' in r
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
assert "onEntryArtChange" in r and "onModKindChange" not in r
```
Run: → FAIL
- [x] **Step 2: `_scenario_rows` sortieren**`app/routers/gui.py`, Modifikator-Query um `.order_by(ScenarioModifier.id)` ergänzen:
```python
modifiers = session.execute(
select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id)
.order_by(ScenarioModifier.id)
).scalars().all()
```
- [x] **Step 3: Einträge-Tabelle** — in `planning.html` die BEIDEN `<details>`-Blöcke „Modifikatoren" und „Einmalzahlungen in diesem Szenario" komplett ersetzen durch:
```html
<h3>Einträge</h3>
<table>
<thead>
<tr><th>Was</th><th>Art</th><th>Wert/Betrag</th><th>Datum</th><th></th></tr>
</thead>
<tbody>
{% for m in row.modifiers %}
<tr id="mod-row-{{ m.id }}">
<td>
{% if m.target_type == "category" %}Kategorie: {{ category_names.get(m.target_id, m.target_id) }}
{% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %}
</td>
<td>{{ m.kind|de_label }}</td>
<td>{% if m.kind in ('percent', 'absolute') %}{{ m.value|eur }}{% else %}{% endif %}</td>
<td>{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '' }}{% else %}{% endif %}</td>
<td>
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Löschen</button>
</form>
</td>
</tr>
<tr id="mod-edit-{{ m.id }}" hidden>
<td colspan="5">
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Ziel-Typ
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
<option value="category" {% if m.target_type == 'category' %}selected{% endif %}>Kategorie</option>
<option value="recurring" {% if m.target_type == 'recurring' %}selected{% endif %}>Wiederkehrender Posten</option>
</select>
</label>
<span class="mod-target-category" {% if m.target_type != 'category' %}style="display:none"{% endif %}>
<label>Kategorie
<select name="target_id" data-type="int" {% if m.target_type != 'category' %}disabled{% endif %}>
{% for c in categories %}<option value="{{ c.id }}" {% if m.target_type == 'category' and c.id == m.target_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
</select>
</label>
</span>
<span class="mod-target-recurring" {% if m.target_type != 'recurring' %}style="display:none"{% endif %}>
<label>Posten
<select name="target_id" data-type="int" {% if m.target_type != 'recurring' %}disabled{% endif %}>
{% for r in recurring %}<option value="{{ r.id }}" {% if m.target_type == 'recurring' and r.id == m.target_id %}selected{% endif %}>{{ r.name }}</option>{% endfor %}
</select>
</label>
</span>
<label>Art
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
{% for k in modifier_kinds %}<option value="{{ k }}" {% if k == m.kind %}selected{% endif %}>{{ k|de_label }}</option>{% endfor %}
</select>
</label>
<label>Wert <input type="text" name="value" data-type="amount" value="{{ m.value|eur }}" {% if m.kind not in ('percent', 'absolute') %}disabled{% endif %}></label>
<label>Endet am <input type="date" name="end_date" value="{{ m.end_date.isoformat() if m.end_date else '' }}" {% if m.kind != 'ende' %}disabled{% endif %}></label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% endfor %}
{% for sp in row.planned_items %}
<tr id="spi-row-{{ sp.id }}">
<td>{{ sp.name }}</td>
<td>Einmalzahlung</td>
<td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td>
<td>{{ sp.due.strftime('%d.%m.%Y') }}</td>
<td>
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
hx-confirm="„{{ sp.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Löschen</button>
</form>
</td>
</tr>
<tr id="spi-edit-{{ sp.id }}" hidden>
<td colspan="5">
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ sp.name }}" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ sp.amount|eur }}" required></label>
<label>Fällig am <input type="date" name="due" value="{{ sp.due.isoformat() }}" required></label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% endfor %}
{% if not row.modifiers and not row.planned_items %}
<tr><td colspan="5">Keine Einträge.</td></tr>
{% endif %}
</tbody>
</table>
```
- [x] **Step 4: Anlege-Formular „Neuer Eintrag"** — direkt nach der Tabelle (Default-Art ist die erste Option `percent` → Wert aktiv, Datumsfelder/Einmal-Felder disabled):
```html
<form hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" hx-swap="none"
data-modifiers-url="/api/scenarios/{{ sc.id }}/modifiers"
data-planned-url="/api/scenarios/{{ sc.id }}/planned"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<strong>Neuer Eintrag:</strong>
<label>Eintragsart
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
<option value="einmal">Einmalzahlung</option>
</select>
</label>
<span class="mod-target-category">
<label>Kategorie
<select name="target_id" data-type="int">
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</label>
</span>
<span class="mod-target-recurring" style="display:none">
<label>Posten
<select name="target_id" data-type="int" disabled>
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
</select>
</label>
</span>
<label>Ziel-Typ
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
<option value="category">Kategorie</option>
<option value="recurring">Wiederkehrender Posten</option>
</select>
</label>
<label>Wert <input type="text" name="value" data-type="amount" value="0"></label>
<label>Endet am <input type="date" name="end_date" disabled></label>
<label>Name <input type="text" name="name" disabled></label>
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" disabled></label>
<label>Fällig am <input type="date" name="due" disabled></label>
<button type="submit">Hinzufügen</button>
</form>
```
WICHTIG: Der Ziel-Typ-Select muss VOR den beiden Ziel-Spans stehen wie im bisherigen Formular? Nein — Reihenfolge im bisherigen Formular war Ziel-Typ, dann Spans. Diese Reihenfolge beibehalten (Ziel-Typ zuerst, dann die Spans), damit das Formular lesbar bleibt — den obigen Block entsprechend anordnen: Eintragsart, Ziel-Typ, beide Spans, Wert, Endet am, Name, Betrag, Fällig am, Button.
- [x] **Step 5: JS** — im `<script>`-Block von `planning.html` die Funktion `onModKindChange` ERSETZEN durch:
```javascript
function onEntryArtChange(select) {
// Umschaltmatrix "Neuer Eintrag"/Modifikator-Edit: Felder nur per
// disabled schalten (UX-Regel). Fehlt ein Feld (Edit-Formulare haben
// kein name/amount/due), wird es uebersprungen.
var form = select.closest('form');
var art = select.value;
var isMod = art !== 'einmal';
function setDisabled(name, disabled) {
var el = form.querySelector('[name="' + name + '"]');
if (el) { el.disabled = disabled; }
}
setDisabled('value', !(art === 'percent' || art === 'absolute'));
setDisabled('end_date', art !== 'ende');
setDisabled('name', isMod);
setDisabled('amount', isMod);
setDisabled('due', isMod);
var typeSelect = form.querySelector('[name="target_type"]');
if (typeSelect) {
typeSelect.disabled = !isMod;
var isCategory = typeSelect.value === 'category';
var catSelect = form.querySelector('.mod-target-category select');
var recSelect = form.querySelector('.mod-target-recurring select');
if (catSelect) { catSelect.disabled = !isMod || !isCategory; }
if (recSelect) { recSelect.disabled = !isMod || isCategory; }
}
// POST-Ziel nur beim Anlege-Formular wechseln (Edit-Formulare patchen fix).
if (form.hasAttribute('data-planned-url')) {
form.setAttribute('hx-post', isMod ? form.getAttribute('data-modifiers-url')
: form.getAttribute('data-planned-url'));
htmx.process(form);
}
}
```
`onModTargetTypeChange` und `toggleEdit` bleiben unverändert.
- [x] **Step 6: Bestehenden Test anpassen**`tests/test_gui.py::test_szenario_gui_ende_und_einmalzahlungen` (aus A5 Task 7): die Assertions `"Einmalzahlungen in diesem Szenario" in r` und `f'hx-post="/api/scenarios/{sc.id}/planned"' in r` ersetzen durch:
```python
assert "Einträge" in r
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
```
Die übrigen Assertions (`value="ende"`, `name="end_date"`, `31.08.2026`, `Zufluss-GUI`, `5.000,00`, `30.07.2026`) bleiben gültig. Danach: `grep -n "Einmalzahlungen in diesem Szenario" tests/` → 0 Treffer.
- [x] **Step 7: Hilfe anpassen**`app/templates/hilfe.html`, im Szenarien-Absatz (aus A5) den Satzteil zu Modifikatoren/Einmalzahlungen ersetzen durch (generisch, keine echten Daten):
```html
Varianten wie „Best Case“ pflegst du in der Tabelle „Einträge“ des
Szenarios: Art „Ende“ lässt einen wiederkehrenden Posten (z. B. eine
auslaufende Miete) zu einem Datum enden, ohne den Posten selbst zu
ändern; „Prozent“/„Absolut“ kürzen Beträge; „Entfällt“ blendet sie ganz
aus; „Einmalzahlung“ ergänzt einen einmaligen Zu- oder Abfluss, der nur
in diesem Szenario zählt. Jeder Eintrag ist über „Bearbeiten“ direkt
änderbar.
```
- [x] **Step 8: Tests + Suite grün**
Run: `.venv/bin/python -m pytest tests/test_gui.py -q && .venv/bin/python -m pytest -q`
Expected: alles PASS
- [x] **Step 9: Fable-Testagent-Abnahme** — Live-Anteil (TestClient-Approximation): gemischte Tabelle rendert beide Typen in korrekter Reihenfolge; Edit-Roundtrips für Modifikator (percent→ende inkl. Datumspflicht) und Einmalzahlung mit deutscher Betragseingabe; Umschaltmatrix-Handtrace für alle 5 Eintragsarten (inkl. disabled-Zustand von `kind`-Fremdfeld beim jeweils anderen Endpunkt); keine doppelten IDs. Erst nach VERIFIED weiter.
- [x] **Step 10: Commit**
```bash
cd /home/wlfb/bin && git add finance/app/templates/ finance/app/routers/gui.py finance/tests/test_gui.py
git commit -m "feat: Szenario-Eintraege als eine editierbare Tabelle"
```
---
### Task 3: Release v0.7.0 — Redeploy, Live-Check, Ledger
**Files:**
- Modify: `finance/VERSION` (`0.6.0``0.7.0`)
- Modify: `.superpowers/sdd/progress.md` (Ledger), Plan-Häkchen in dieser Datei
**Interfaces:**
- Consumes: Tasks 1-2 committet, Suite grün, bestehendes Live-Szenario „Best Case" (id 4) mit 3 Einträgen.
- [x] **Step 1: Suite final**`cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` → alle grün.
- [x] **Step 2: Version + Commit**
```bash
echo "0.7.0" > /home/wlfb/bin/finance/VERSION
cd /home/wlfb/bin && git add finance/VERSION && git commit -m "chore: Version 0.7.0"
```
- [x] **Step 3: Redeploy**`cd /home/wlfb/bin && ./create_pod_finance.sh` (keine Migration nötig; Entrypoint-`alembic upgrade head` ist idempotent). Expected: Service aktiv, Readiness 200.
- [x] **Step 4: Live-Smoke**`/api/version` == 0.7.0 (Bearer-Key aus `~/.local/share/finance_pod/.env`); GUI-Login; `/planung` zeigt im „Best Case"-Kasten die Einträge-Tabelle mit 3 Zeilen (2× Ende, 1× Einmalzahlung) OHNE aufklappen zu müssen; kein „Modifikatoren"-Summary mehr.
- [x] **Step 5: Fable-Testagent-Abnahme (Release-Gate)** — Live: zusätzlich zu Step 4 ein kompletter Roundtrip in einem WEGWERF-Szenario „SMOKE-A6" (anlegen → Eintrag percent anlegen → per PATCH auf ende mit Datum ändern → Einmalzahlung anlegen → Betrag per PATCH ändern → Szenario löschen [räumt alles ab]); Bestandsdaten unverändert (confirmed-Tx-Count, „Best Case"-Einträge unangetastet). Erst nach VERIFIED weiter.
- [x] **Step 6: Ledger + Plan-Häkchen + Push**
```bash
cd /home/wlfb/bin && git add docs/superpowers/plans/2026-07-20-ausbaustufe-6.md && git commit -m "docs: Ledger/Plan Ausbaustufe 6" && git push
```
---
## Self-Review (beim Planschreiben)
- **Spec-Abdeckung:** API-PATCH → Task 1; Tabelle/Anlege-Formular/Inline-Edit/JS/Sortierung/Hilfe → Task 2; Release/Live-Check → Task 3. Vollständig.
- **Platzhalter:** keine.
- **Typ-Konsistenz:** URLs/IDs (`mod-`/`spi-`), `data-modifiers-url`/`data-planned-url`, `onEntryArtChange` konsistent zwischen Tasks 1/2; Test-Anpassung des A5-Tests explizit benannt (Step 6), damit keine Suite-Regression entsteht.

View File

@@ -0,0 +1,376 @@
# Ausbaustufe 7 Implementation Plan — Szenarien-Seite, Formular-UX, Kategorien (v0.8.0)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** Szenarien auf eigene Seite `/szenarien`, verständliches Eintrags-Formular (Paar-Layout, Gesperrt-Kennzeichnung, dynamisches Wert-Label, abgesetzter Durchrechnen-Button), Kategorien-Verwaltung auf der Admin-Seite; Release v0.8.0.
**Architecture:** Kein Datenmodell-/Migrationsbedarf. Template-Umzug der Szenarien-Sektion aus `planning.html` in neues `szenarien.html` + Route in `gui.py`; CSS-Regeln in `style.css`; ein neuer Endpunkt `PATCH /api/categories/{id}`.
**Tech Stack:** FastAPI, Jinja2, htmx + json-form, CSS, pytest.
**Spec:** `docs/superpowers/specs/2026-07-20-szenarien-seite-ux-design.md`
## Global Constraints
- GUI deutsch, TT.MM.JJJJ, `|eur`; API-Werte englisch; Geldbeträge `Decimal`.
- UX-Regel: gesperrte Felder sichtbar + `disabled` + optisch gekennzeichnet; Edit-Zeilen per `hidden`.
- Fable-Testagent-Gate je Task VOR dem Commit; Ledger-Eintrag je Task in `.superpowers/sdd/progress.md`.
- DATENSCHUTZ: keine echten Kontodaten in Commits/Tests/Doku.
- Testlauf: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` — Basis 181 passed, muss grün bleiben.
- Pfade relativ zu `/home/wlfb/bin/finance` (Ledger/Plan unter `/home/wlfb/bin`).
---
### Task 1: Szenarien-Seite `/szenarien`
**Files:**
- Create: `app/templates/szenarien.html`
- Modify: `app/templates/planning.html` (Szenarien-`<section>` Zeilen ~263-464 und szenario-spezifisches JS entfernen), `app/templates/base.html` (Nav + `toggleEdit`), `app/routers/gui.py` (neue Route, `planung_page` verschlanken), `app/templates/hilfe.html` (Ortsangaben)
- Modify: `tests/test_gui.py`
**Interfaces:**
- Consumes: bestehenden Szenario-Kontext (`_scenario_rows`, `modifier_kinds`), `gui_session`, alle bestehenden Templates/JS-Helfer.
- Produces: `GET /szenarien` (login-geschützt) mit Kontext `scenario_rows`, `loans`, `categories`, `category_names`, `recurring`, `recurring_names`, `modifier_kinds`; `toggleEdit` global in `base.html`. Task 2 baut auf `szenarien.html` auf.
- [x] **Step 1: Failing GUI-Tests** — in `tests/test_gui.py`:
```python
def test_szenarien_seite_und_nav(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(Scenario(name="Seiten-Test", description="d"))
db.commit()
r = client.get("/szenarien")
assert r.status_code == 200
assert "Seiten-Test" in r.text and "Neues Szenario anlegen" in r.text
assert 'href="/szenarien"' in r.text # Nav-Punkt
# Planung enthaelt keine Szenarien-Sektion mehr:
p = client.get("/planung").text
assert "Seiten-Test" not in p and "Neues Szenario anlegen" not in p
assert "Wiederkehrende Posten" in p and "Kredite" in p
def test_szenarien_seite_braucht_login(client):
r = client.get("/szenarien", follow_redirects=False)
assert r.status_code in (302, 303) and r.headers["location"] == "/login"
```
Run: `.venv/bin/python -m pytest tests/test_gui.py -q` → neue Tests FAIL (404)
- [x] **Step 2: Route**`app/routers/gui.py`: in `planung_page` die Zeilen `scenarios = _list_scenarios(...)`, `"scenario_rows": ...` und `"modifier_kinds": ...` entfernen (die übrigen Kontexteinträge bleiben). Neue Route direkt darunter:
```python
@router.get("/szenarien", dependencies=[Depends(gui_session)])
def szenarien_page(request: Request, session: Session = Depends(get_session)):
categories = session.execute(select(Category)).scalars().all()
recurring = _list_recurring(session=session)
loans = _list_loans(session=session)
scenarios = _list_scenarios(session=session)
return templates.TemplateResponse(request, "szenarien.html", {
"categories": categories,
"category_names": {c.id: c.name for c in categories},
"recurring": recurring,
"recurring_names": {r.id: r.name for r in recurring},
"loans": loans,
"scenario_rows": _scenario_rows(session, scenarios, loans),
"modifier_kinds": ["percent", "absolute", "remove", "ende"],
})
```
- [x] **Step 3: Template-Umzug** — Neues `app/templates/szenarien.html`:
```html
{% extends "base.html" %}
{% block title %}Szenarien Finanzberatung{% endblock %}
{% block content %}
<h1>Szenarien</h1>
<section class="planning-section">
<!-- HIER: die komplette bisherige Szenarien-<section>-INNEREI aus
planning.html unverändert einfügen (alles zwischen
'<h2>Szenarien</h2>' … einschließlich des Fieldsets
'Neues Szenario anlegen'), ohne das umschließende <section>-Tag
doppelt zu setzen. -->
</section>
<script>
// Szenario-spezifische Helfer (aus planning.html hierher umgezogen):
// onModTargetTypeChange, onEntryArtChange, toggleScenarioLoan
// unverändert einfügen.
</script>
{% endblock %}
```
Der `<h2>Szenarien</h2>` entfällt (die Seite hat die `<h1>`-Überschrift). In `planning.html`: die gesamte Szenarien-`<section>` löschen; aus dem Script-Block `onModTargetTypeChange`, `onEntryArtChange`, `toggleScenarioLoan` und `toggleEdit` entfernen — `toggleEdit` zieht in den zentralen Script-Block von `base.html` (dort nach der json-form-Extension einfügen, unverändert inkl. Kommentar); `loadLoanSchedule`/`fmtEur`/`formatIsoDate` und `loadedSchedules` bleiben in `planning.html`.
- [x] **Step 4: Nav + Hilfe**`base.html`: `<a href="/szenarien">Szenarien</a>` zwischen Planung- und Admin-Link. `hilfe.html`: Formulierungen, die die Szenarien auf der Planung-Seite verorten, auf „Szenarien-Seite" anpassen (grep nach „Szenari" in `hilfe.html` und Ortsangaben prüfen).
- [x] **Step 5: Bestehende Tests umziehen**`tests/test_gui.py`: die Szenario-GUI-Tests (`test_szenario_gui_ende_und_einmalzahlungen`, `test_szenario_eintraege_tabelle`, Szenario-Anteile von `test_planung_hat_bearbeiten_formulare`) von `client.get("/planung")` auf `client.get("/szenarien")` umstellen. In `test_planung_hat_bearbeiten_formulare` den Scenario-Teil (Scenario-Seed + `hx-patch="/api/scenarios/`-Assertion) in einen neuen Test `test_szenarien_hat_bearbeiten_formular` auslagern, der `/szenarien` lädt; der Planung-Test behält rec/pln/loan mit `>= 3` Bearbeiten-Buttons. `test_pages_require_login`/`test_pages_render_after_login`: Pfad `/szenarien` in die jeweilige Pfadliste aufnehmen.
- [x] **Step 6: Suite grün**
Run: `.venv/bin/python -m pytest -q` → PASS
- [x] **Step 7: Fable-Testagent-Abnahme** (Live-Approximation: beide Seiten rendern; keine Szenario-Reste auf /planung; `toggleEdit` genau EINMAL definiert [base.html], auf beiden Seiten funktionale Edit-Formulare; keine JS-Referenzen auf entfernte Funktionen in planning.html; Nav auf allen Seiten). Erst nach VERIFIED weiter.
- [x] **Step 8: Commit**
```bash
cd /home/wlfb/bin && git add finance/app/ finance/tests/test_gui.py
git commit -m "feat: eigene Szenarien-Seite /szenarien"
```
---
### Task 2: Formular-UX — Paar-Layout, Gesperrt-Kennzeichnung, klare Struktur
**Files:**
- Modify: `app/static/style.css`, `app/templates/szenarien.html`, `tests/test_gui.py`
**Interfaces:**
- Consumes: `szenarien.html` aus Task 1 (Neuer-Eintrag-Formular mit Feldern kind/target_type/target_id/value/end_date/name/amount/due; `onEntryArtChange`).
- Produces: CSS-Klassen `.entry-form`, `.entry-art`, `.value-label`; dynamisches Wert-Label in `onEntryArtChange`; abgesetzter Durchrechnen-Block.
- [x] **Step 1: Failing GUI-Test**
```python
def test_neuer_eintrag_formular_struktur(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(Scenario(name="UX-Test", description=""))
db.commit()
r = client.get("/szenarien").text
assert 'class="entry-form"' in r
assert 'class="entry-art"' in r # Eintragsart-Zeile
assert 'class="value-label"' in r # dynamisches Wert-Label
assert 'Für diese Eintragsart nicht relevant' in r # Tooltip an Umschaltfeldern
assert '<hr' in r # Durchrechnen abgesetzt
```
Run: → FAIL
- [x] **Step 2: CSS**`app/static/style.css` ergänzen:
```css
/* Ausbaustufe 7: Formular-Paare als Einheit, gesperrte Felder erkennbar. */
.entry-form label,
.planning-section form label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0 1.25rem 0.6rem 0;
white-space: nowrap;
}
.entry-form .entry-art {
display: block;
margin-bottom: 0.75rem;
}
.entry-form .entry-art > label { font-weight: bold; }
.entry-form .entry-art .muted { margin-left: 0.5rem; }
input:disabled, select:disabled {
background: #e3e3e3;
color: #8a8a8a;
cursor: not-allowed;
}
label:has(input:disabled), label:has(select:disabled) {
opacity: 0.55;
}
.project-block {
margin-top: 1rem;
}
```
(Falls `style.css` bereits eine kollidierende `label`-Regel für `.planning-section` hat: die neue Regel dahinter einsortieren, Spezifität prüfen.)
- [x] **Step 3: Formular-Markup** — in `szenarien.html` das Neuer-Eintrag-Formular umbauen:
- `<form … class="entry-form" …>` (Attribute inkl. `data-modifiers-url`/`data-planned-url` unverändert).
- Statt `<strong>Neuer Eintrag:</strong>`: erste Zeile
```html
<div class="entry-art">
<label>Eintragsart
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
<option value="einmal">Einmalzahlung</option>
</select>
</label>
<span class="muted">— die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut.</span>
</div>
```
- Wert-Label dynamisch: `<label><span class="value-label">Prozentsatz (%)</span> <input type="text" name="value" data-type="amount" value="0" title="Für diese Eintragsart nicht relevant"></label>` (Startzustand passend zur Default-Art `percent`).
- Die umschaltbaren Felder `end_date`, `name`, `amount`, `due` sowie die beiden Ziel-Selects bekommen `title="Für diese Eintragsart nicht relevant"`.
- Fieldset-Titel davor: `<h3>Einträge</h3>`-Tabelle bleibt; über dem Formular `<h4>Neuer Eintrag</h4>` (ersetzt das bisherige Inline-`<strong>`).
- Durchrechnen-Formular in einen abgesetzten Block:
```html
<hr>
<div class="project-block">
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Durchrechnen</button>
</form>
</div>
```
- [x] **Step 4: Dynamisches Wert-Label** — in `onEntryArtChange` (szenarien.html) nach der `setDisabled('value', …)`-Zeile ergänzen:
```javascript
var valueLabel = form.querySelector('.value-label');
if (valueLabel) {
valueLabel.textContent = art === 'percent' ? 'Prozentsatz (%)'
: art === 'absolute' ? 'Kürzung (€)' : 'Wert';
}
```
- [x] **Step 5: Suite grün**`.venv/bin/python -m pytest -q` → PASS
- [x] **Step 6: Fable-Testagent-Abnahme** — Live-Approximation + Handtrace: gerenderte Struktur (entry-art zuerst, Paare als `inline-flex`-Einheiten via CSS-Regeln vorhanden, Tooltips an allen Umschaltfeldern, `<hr>`+project-block), Wert-Label-Wechsel für alle 5 Arten, Startzustand konsistent (percent: Wert aktiv mit Label „Prozentsatz (%)"). Erst nach VERIFIED weiter.
- [x] **Step 7: Commit**
```bash
cd /home/wlfb/bin && git add finance/app/static/style.css finance/app/templates/szenarien.html finance/tests/test_gui.py
git commit -m "feat: Eintrags-Formular mit Paar-Layout und Gesperrt-Kennzeichnung"
```
---
### Task 3: Kategorien-Verwaltung auf der Admin-Seite
**Files:**
- Modify: `app/routers/categories.py` (PATCH), `app/routers/admin.py` (Kontext), `app/templates/admin.html`
- Modify: `tests/test_crud_api.py`, `tests/test_admin.py`
**Interfaces:**
- Consumes: `CategoryIn`/`CategoryOut`, `POST /api/categories` (vorhanden); `toggleEdit` (seit Task 1 global in base.html); json-form.
- Produces: `PATCH /api/categories/{category_id}` (404 „Kategorie nicht gefunden", 409 „Kategorie existiert bereits"); Admin-Abschnitt „Kategorien" mit IDs `cat-row-{id}`/`cat-edit-{id}`.
- [x] **Step 1: Failing API-Test**`tests/test_crud_api.py`:
```python
def test_category_patch(client):
a = client.post("/api/categories", headers=H, json={"name": "Kat-A"}).json()
b = client.post("/api/categories", headers=H, json={"name": "Kat-B"}).json()
# Umbenennen
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
assert r.status_code == 200 and r.json()["name"] == "Kat-A-neu"
# Umbenennen auf den EIGENEN Namen ist erlaubt (kein 409)
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
assert r.status_code == 200
# Kollision mit anderer Kategorie -> 409
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-B"})
assert r.status_code == 409
# unbekannte id -> 404
assert client.patch("/api/categories/99999", headers=H,
json={"name": "x"}).status_code == 404
```
Run: `.venv/bin/python -m pytest tests/test_crud_api.py -q` → FAIL (405)
- [x] **Step 2: PATCH-Endpunkt**`app/routers/categories.py`, nach `create_category`:
```python
@router.patch("/categories/{category_id}", response_model=CategoryOut)
def patch_category(category_id: int, data: CategoryIn,
session: Session = Depends(get_session)):
cat = session.get(Category, category_id)
if cat is None:
raise HTTPException(404, "Kategorie nicht gefunden")
clash = session.execute(
select(Category).where(Category.name == data.name)).scalar()
if clash is not None and clash.id != category_id:
raise HTTPException(409, "Kategorie existiert bereits")
cat.name = data.name
session.commit()
session.refresh(cat)
return CategoryOut.model_validate(cat)
```
- [x] **Step 3: Failing GUI-Test**`tests/test_admin.py` (Muster für Login dort übernehmen; falls die Datei GUI-Zugriffe anders aufbaut, an bestehende Fixtures anlehnen):
```python
def test_admin_zeigt_kategorien_verwaltung(client, db):
from app.models.tables import Category
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(Category(name="Admin-Kat"))
db.commit()
r = client.get("/admin").text
assert "Kategorien" in r and "Admin-Kat" in r
assert "Neue Kategorie anlegen" in r
assert 'hx-patch="/api/categories/' in r
assert 'hx-post="/api/categories"' in r
```
Run: → FAIL
- [x] **Step 4: Admin-Kontext + Template**`app/routers/admin.py`: in `admin_page` (und im Fehler-/Erfolgs-Re-Render von `admin_change_password`, damit der Abschnitt nie verschwindet — UX-Regel) `"categories": session.execute(select(Category)).scalars().all()` in den Template-Kontext aufnehmen (Imports `select`, `Category` ergänzen; `session`-Dependency, falls die Route noch keine hat). `admin.html`, neuer Abschnitt vor dem `<script>`:
```html
<section class="admin-section">
<h2>Kategorien</h2>
<p class="muted">Kategorien für Buchungen, Fixposten und Szenario-Modifikatoren.
Löschen ist bewusst nicht vorgesehen (Kategorien hängen an Buchungen und Regeln).</p>
<table>
<thead><tr><th>Name</th><th></th></tr></thead>
<tbody>
{% for c in categories %}
<tr id="cat-row-{{ c.id }}">
<td>{{ c.name }}</td>
<td><button type="button" onclick="toggleEdit('cat', {{ c.id }}, true)">Bearbeiten</button></td>
</tr>
<tr id="cat-edit-{{ c.id }}" hidden>
<td colspan="2">
<form hx-ext="json-form" hx-patch="/api/categories/{{ c.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ c.name }}" required maxlength="100"></label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('cat', {{ c.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="2">Noch keine Kategorien.</td></tr>
{% endfor %}
</tbody>
</table>
<fieldset>
<legend>Neue Kategorie anlegen</legend>
<form hx-ext="json-form" hx-post="/api/categories" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required maxlength="100"></label>
<button type="submit">Anlegen</button>
</form>
</fieldset>
</section>
```
- [x] **Step 5: Suite grün**`.venv/bin/python -m pytest -q` → PASS
- [x] **Step 6: Fable-Testagent-Abnahme** (Live-Approximation: Anlegen → erscheint in Liste UND in den Kategorie-Dropdowns von Planung/Szenarien; Umbenennen-Roundtrip; 409 im Alert-Pfad; Admin-Fehler-Re-Render zeigt Abschnitt weiterhin). Erst nach VERIFIED weiter.
- [x] **Step 7: Commit**
```bash
cd /home/wlfb/bin && git add finance/app/routers/categories.py finance/app/routers/admin.py finance/app/templates/admin.html finance/tests/
git commit -m "feat: Kategorien-Verwaltung auf der Admin-Seite"
```
---
### Task 4: Release v0.8.0 — Redeploy, Live-Check, Ledger
**Files:** `finance/VERSION` (`0.7.0``0.8.0`), `.superpowers/sdd/progress.md`, Plan-Häkchen.
- [x] **Step 1: Suite final**`.venv/bin/python -m pytest -q` → alle grün.
- [x] **Step 2: Version + Commit**`echo "0.8.0" > finance/VERSION`, Commit `chore: Version 0.8.0`.
- [x] **Step 3: Redeploy**`./create_pod_finance.sh` (keine Migration). Service aktiv, Readiness 200.
- [x] **Step 4: Live-Smoke**`/api/version` == 0.8.0; Nav zeigt „Szenarien"; `/szenarien` zeigt Best Case mit Einträgen; `/planung` ohne Szenarien; `/admin` mit Kategorien-Abschnitt.
- [x] **Step 5: Fable-Testagent-Abnahme (Release-Gate)** — Live: Wegwerf-Kategorie „SMOKE-A7" anlegen → umbenennen → in Dropdowns sichtbar (bleibt stehen, Hinweis im Bericht — Kategorien sind nicht löschbar; Namenswahl „zz-Smoke" damit sie unten einsortiert? Nein: Kategorie „SMOKE-A7" wird nach dem Test per direktem psql-DELETE entfernt, NUR wenn keine FK-Referenzen existieren — vorher COUNT-Checks auf transactions/category_rules/recurring_items/scenario_modifiers); Best-Case-Einträge unverändert; Formular-Struktur auf /szenarien (entry-art, Tooltips, hr). Erst nach VERIFIED weiter.
- [x] **Step 6: Ledger + Plan-Häkchen + Push.**
---
## Self-Review (beim Planschreiben)
- **Spec-Abdeckung:** Seite → Task 1; UX/CSS/Label/Durchrechnen → Task 2; Kategorien (PATCH + Admin-GUI) → Task 3; Release → Task 4. Vollständig.
- **Platzhalter:** Der Template-Umzug in Task 1 Step 3 ist bewusst als präziser Move beschrieben (Quelle: bestehende Szenarien-Sektion) statt als 200-Zeilen-Duplikat — die Quelle ist eindeutig benannt und unverändert zu übernehmen.
- **Typ-Konsistenz:** `toggleEdit` global (Task 1) wird von Task 3 (`cat-`-Präfix) vorausgesetzt; `entry-form`/`entry-art`/`value-label`-Klassen konsistent zwischen CSS (Task 2 Step 2) und Markup (Step 3) und Test (Step 1).

View File

@@ -0,0 +1,196 @@
# Ausbaustufe 9 Implementation Plan — Vorschlags-Algorithmus v2 (v0.9.0)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** `suggest_recurring` erkennt monatliche/vierteljährliche/jährliche Serien mit letztem Betrag, Aktiv-Check, Betrags-Clustern, Umfirmierungs-Merge und robustem Bestandsabgleich; GUI zeigt Rhythmus/Start/Hinweis; Release v0.9.0 mit Live-Gate gegen die echten Daten.
**Architecture:** Vollständiger Rewrite von `app/services/suggestions.py` (reine Session-in/dict-out-Funktion, Parameter als Modul-Konstanten); `SuggestionOut`-Erweiterung in `routers/planning.py`; Template-Anpassung der Vorschlags-Tabelle. Kein Datenmodell-/Migrationsbedarf.
**Tech Stack:** SQLAlchemy 2, Pydantic v2, Jinja2, pytest (synthetische Daten).
**Spec:** `docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md` — die dortigen Abschnitte „Algorithmus" (8 Schritte, Konstanten) und „Tests" sind bindend und Teil dieses Plans.
## Global Constraints
- Beträge `Decimal` (keine float-Arithmetik, auch nicht in Toleranzvergleichen — relative Differenzen als `Decimal`-Quotienten).
- `date.today()` nur an EINER Stelle (Parameter `today: date | None = None` der Hauptfunktion, Default heute) — Tests injizieren ein festes Datum.
- Tests ausschließlich mit synthetischen Daten (DATENSCHUTZ: keine echten Namen/Beträge aus der Live-DB in Tests/Commits).
- GUI deutsch, TT.MM.JJJJ, `|eur`, `|de_label`; API Punkt-Dezimal.
- Fable-Testagent-Gate je Task VOR Commit; Ledger-Eintrag je Task.
- Testlauf: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` — Basis 187 passed, muss grün bleiben (drei bestehende Suggestion-Tests DÜRFEN an die neue Semantik angepasst werden, siehe Task 1 Step 4).
---
### Task 1: Algorithmus-Rewrite + API-Schema
**Files:**
- Rewrite: `app/services/suggestions.py`
- Modify: `app/routers/planning.py` (`SuggestionOut`)
- Modify: `tests/test_planning_api.py`
**Interfaces:**
- Produces: `suggest_recurring(session, today: date | None = None) -> list[dict]` mit Keys `name, amount (Decimal), rhythm, due_day, start_date (date|None), category_id, hinweis (str)`; `SuggestionOut` mit denselben Feldern (`start_date: date | None = None`, `hinweis: str = ""`). Task 2 rendert genau diese Felder.
- [x] **Step 1: Failing Tests** — in `tests/test_planning_api.py` die drei bestehenden Suggestion-Tests ERSETZEN/ERWEITERN durch die Spec-Fälle (Helper zum Anlegen synthetischer Buchungen schreiben; `dedup_hash` eindeutig, `status="confirmed"`; ein Account genügt; `today=date(2026, 7, 20)` in alle Aufrufe injizieren):
```python
from datetime import date
from decimal import Decimal
def _tx(db, acc_id, d, amount, cp, cat=None):
db.add(Transaction(account_id=acc_id, booking_date=d, amount=Decimal(amount),
purpose="p", counterparty=cp, category_id=cat,
status="confirmed", dedup_hash=f"h-{cp}-{d}-{amount}"))
TODAY = date(2026, 7, 20)
def test_suggest_letzter_betrag_bei_preiserhoehung(db):
acc = _acc(db) # Helper: Account anlegen, gibt id zurueck
for d, a in [(date(2026, 3, 1), "-190.65"), (date(2026, 4, 1), "-202.94"),
(date(2026, 5, 4), "-202.94"), (date(2026, 6, 1), "-202.94"),
(date(2026, 7, 1), "-202.94")]:
_tx(db, acc, d, a, "Entis Lebensversicherung AG")
db.commit()
out = suggest_recurring(db, today=TODAY)
assert len(out) == 1
s = out[0]
assert s["amount"] == Decimal("-202.94") and s["rhythm"] == "monthly"
assert s["due_day"] == 1 and s["start_date"] is None
def test_suggest_quartal_mit_phase(db):
acc = _acc(db)
for d in [date(2025, 9, 15), date(2025, 12, 15), date(2026, 3, 16), date(2026, 6, 15)]:
_tx(db, acc, d, "-55.08", "Rundfunk ARD ZDF")
db.commit()
out = suggest_recurring(db, today=TODAY)
assert len(out) == 1
assert out[0]["rhythm"] == "quarterly"
assert out[0]["start_date"] == date(2026, 6, 15) and out[0]["due_day"] == 15
def test_suggest_jahr_mit_zwei_belegen(db):
acc = _acc(db)
for d, a in [(date(2025, 6, 16), "-409.92"), (date(2026, 6, 16), "-467.33")]:
_tx(db, acc, d, a, "Kraftfahrer-Schutz e.V.")
db.commit()
out = suggest_recurring(db, today=TODAY)
assert len(out) == 1
assert out[0]["rhythm"] == "yearly" and out[0]["amount"] == Decimal("-467.33")
assert out[0]["start_date"] == date(2026, 6, 16)
assert "409.92" in out[0]["hinweis"] # Betrag zuletzt gestiegen
def test_suggest_tote_serie_kein_vorschlag(db):
acc = _acc(db)
for m in (9, 10, 11, 12):
_tx(db, acc, date(2025, m, 1), "-35.00", "WWK Alt")
db.commit()
assert suggest_recurring(db, today=TODAY) == []
def test_suggest_umfirmierung_merge(db):
acc = _acc(db)
for m in (11, 12):
_tx(db, acc, date(2025, m, 1), "-190.65", "Heidelberger Leben")
for m in (1, 2, 3):
_tx(db, acc, date(2026, m, 2), "-190.65", "Entis Lebensversicherung")
for m in (4, 5, 6, 7):
_tx(db, acc, date(2026, m, 1), "-202.94", "Entis Lebensversicherung")
db.commit()
out = suggest_recurring(db, today=TODAY)
assert len(out) == 1
assert "Entis" in out[0]["name"] and out[0]["amount"] == Decimal("-202.94")
def test_suggest_bestandsabgleich_trotz_preisdrift(db):
acc = _acc(db)
db.add(RecurringItem(name="Entis Lebensversicherung AG", amount=Decimal("-190.65"),
rhythm="monthly", due_day=1))
for m in (4, 5, 6, 7):
_tx(db, acc, date(2026, m, 1), "-202.94", "Entis Lebensversicherung AG")
db.commit()
assert suggest_recurring(db, today=TODAY) == [] # Namens-Match schlaegt an
def test_suggest_zwei_vertraege_getrennt(db):
acc = _acc(db)
for m in (4, 5, 6, 7):
_tx(db, acc, date(2026, m, 1), "-346.23", "Heidelberger LV")
_tx(db, acc, date(2026, m, 2), "-145.21", "Heidelberger LV")
db.commit()
out = suggest_recurring(db, today=TODAY)
assert len(out) == 2
assert {s["amount"] for s in out} == {Decimal("-346.23"), Decimal("-145.21")}
```
(`_acc`-Helper analog bestehender Tests; `RecurringItem`/`Transaction`-Importe existieren.) Die drei Alt-Tests (`three_consecutive_months_with_year_wrap`, `two_months_no_suggestion`, `excludes_existing_recurring_item`) an die neue Signatur/Semantik anpassen: feste `today`-Injektion; Daten ggf. ins Fenster schieben; der Exclusion-Test bleibt inhaltlich gültig (Name-Match).
Run: `.venv/bin/python -m pytest tests/test_planning_api.py -q` → neue Tests FAIL
- [x] **Step 2: Rewrite `app/services/suggestions.py`** gemäß Spec-Abschnitt „Algorithmus" (8 Schritte, Konstanten `WINDOW_DAYS=460`, Rhythmus-Tabelle monthly 25-36/≥3, quarterly 80-105/≥3, yearly 330-400/≥2, `STEP_DAYS={"monthly":30,"quarterly":91,"yearly":365}`, `ACTIVITY_FACTOR` 7/4 als `Fraction` oder Tage-Vergleich ganzzahlig, Cluster 35 %, Merge 25 %, Bestand 10 % — alle Toleranzvergleiche als `Decimal`). Struktur: `_norm`, `_rel_diff`, `_amount_clusters` (greedy gegen letztes Mitglied, gleiches Vorzeichen), `_classify` (Median der Abstände), Merge-Pass je Konto über alle Serien, `_covered_by_existing`, Hauptfunktion `suggest_recurring(session, today=None)`. Deutsche Docstrings/Kommentare zur Begründung der Toleranzen.
- [x] **Step 3: `SuggestionOut` erweitern**`routers/planning.py`:
```python
class SuggestionOut(BaseModel):
name: str
amount: Decimal
rhythm: str
due_day: int
start_date: date | None = None
category_id: int | None = None
hinweis: str = ""
```
- [x] **Step 4: Tests + Suite grün**`.venv/bin/python -m pytest -q` → PASS (Alt-Test-Anpassungen im Report begründen).
- [x] **Step 5: Fable-Testagent-Abnahme** (Faktencheck: Toleranz-Arithmetik Decimal-rein; Aktiv-Check-Grenzen; Merge-Bedingungen; keine `date.today()`-Streuung; Alt-Test-Anpassungen berechtigt). Erst nach VERIFIED weiter.
- [x] **Step 6: Commit**`git add finance/app/services/suggestions.py finance/app/routers/planning.py finance/tests/test_planning_api.py && git commit -m "feat: Vorschlags-Algorithmus v2 (Rhythmen, letzter Betrag, Aktiv-Check, Merge)"`
---
### Task 2: GUI — Rhythmus/Start/Hinweis in der Vorschlags-Tabelle
**Files:**
- Modify: `app/templates/planning.html` (Fieldset „Vorschläge aus Buchungen")
- Modify: `tests/test_gui.py`
**Interfaces:**
- Consumes: `SuggestionOut`-Felder aus Task 1; Filter `|eur`/`|de_label`.
- [x] **Step 1: Failing GUI-Test** — in `tests/test_gui.py` (synthetische Serie seeden, `/planung` laden):
```python
def test_vorschlaege_zeigen_rhythmus_und_start(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
acc = Account(bank="dkb", iban="DE-SUG-1", name="S", type="giro")
db.add(acc)
db.flush()
for d in (date(2025, 9, 15), date(2025, 12, 15), date(2026, 3, 16), date(2026, 6, 15)):
db.add(Transaction(account_id=acc.id, booking_date=d, amount=Decimal("-55.08"),
purpose="p", counterparty="Rundfunk Synth", status="confirmed",
dedup_hash=f"sug-{d}"))
db.commit()
r = client.get("/planung").text
assert "vierteljährlich" in r # de_label des Rhythmus
assert "15.06.2026" in r # Start-Spalte TT.MM.JJJJ
assert 'name="start_date"' in r # hidden input der Uebernahme
```
WICHTIG: Der Test hängt von `date.today()` der App ab (Aktiv-Check!) — Serie so legen, dass sie um den echten Testlauf-Zeitpunkt herum aktiv ist, oder (besser) `suggest_recurring` in `planung_page` unverändert lassen und den Test mit relativen Daten um `date.today()` konstruieren (letzte Buchung ≤ 45 Tage vor heute, Quartalsschritte rückwärts). Die Variante mit relativen Daten umsetzen; die obigen Fixdaten sind als Muster zu verstehen und auf `date.today()`-relative Werte umzustellen (inkl. erwartetem Start-String via `.strftime('%d.%m.%Y')`).
- [x] **Step 2: Template** — Vorschlags-Tabelle: Kopf `Name | Betrag | Rhythmus | Fälligkeitstag | Start | (Aktion)`; Zellen `{{ s.rhythm|de_label }}`, `{{ s.start_date.strftime('%d.%m.%Y') if s.start_date else '' }}`; Betrag-Zelle ergänzt `{% if s.hinweis %}<span class="muted">{{ s.hinweis }}</span>{% endif %}`; Übernahme-Formular: hidden inputs unverändert plus `<input type="hidden" name="start_date" value="{{ s.start_date.isoformat() if s.start_date else '' }}">` (json-form macht leer → null). Hinweistext unter dem Fieldset: „Erkannt werden monatliche, vierteljährliche und jährliche Serien; Betrag = jeweils letzte Buchung."
- [x] **Step 3: Suite grün**; **Step 4: Fable-Abnahme** (Live-Approximation: Rendering + Übernahme-Roundtrip eines Quartals-Vorschlags inkl. start_date); **Step 5: Commit** `feat: Vorschlaege mit Rhythmus, Start und Hinweis`.
---
### Task 3: Release v0.9.0 + Live-Gate gegen echte Daten
- [x] **Step 1:** Suite final; `VERSION` → 0.9.0; Commit; `./create_pod_finance.sh`; `/api/version` == 0.9.0.
- [x] **Step 2: Fable-Release-Gate (LIVE, lesend):** `GET /api/recurring/suggestions` gegen die echte DB. Prüfen: (a) KEINER der bestehenden ~41 Fixposten wird erneut vorgeschlagen (Bestandsabgleich wirkt, auch bei gedrifteten Beträgen); (b) keine bekannten toten Serien (gelöschte PayPal-4,99-Serie, ausgelaufene WWK-35-Police) im Ergebnis; (c) verbleibende Vorschläge einzeln gegen die Buchungen plausibilisieren (echte aktive Serie? korrekte Werte?). Ergebnisliste NUR im Chat/Bericht, nie committen. Bei Fehlklassifikationen: Befund zurück an Task 1 (Toleranzen), Fix + Re-Gate.
- [x] **Step 3:** Ledger (generisch) + Plan-Häkchen + Push; Kandidatenliste dem Nutzer berichten.
---
## Self-Review
- Spec-Abdeckung: Algorithmus/Schema → Task 1; GUI → Task 2; Release/Live-Gate → Task 3. Testfälle der Spec vollständig in Task 1 Step 1 kodiert.
- Platzhalter: Task 1 Step 2 verweist bewusst auf den bindenden Spec-Abschnitt (8 nummerierte Schritte + Konstanten) statt den vollen Code zu duplizieren; alle Schnittstellen/Konstanten sind exakt benannt.
- Typ-Konsistenz: `suggest_recurring(session, today)`-Signatur = Testaufrufe; `SuggestionOut`-Felder = Template-Zugriffe (`s.rhythm`, `s.start_date`, `s.hinweis`).

View File

@@ -0,0 +1,157 @@
# Design — Ausbaustufe 5: Deutsche Formate, Posten-Bearbeitung, Szenario-Ende (v0.6.0)
> Status: vom Nutzer freigegeben (Chat 2026-07-20). Umsetzungsplan folgt unter
> `docs/superpowers/plans/`. Datenschutz-Regel aus `CLAUDE.md` gilt: konkrete
> Empfängernamen/Beträge des Demo-Szenarios stehen nur im Chat und in der
> Live-Datenbank, nicht in diesem Dokument, nicht in Commits, nicht im Ledger.
## Ziel
Drei Nutzeranforderungen an die Web-GUI des Finanzberatungs-Tools:
1. **Deutsche Formate** — Beträge werden deutsch angezeigt (`1.234,56 €`)
und deutsch eingegeben (Komma-Dezimaltrenner); englische GUI-Begriffe
(`monthly`, `annuity`, `percent`, …) werden übersetzt.
2. **Wiederkehrende Posten** — Felder `Start`/`Ende` in der GUI sichtbar und
pflegbar (Modell/API/Engine können das bereits); jeder Posten bekommt
einen `Bearbeiten`-Button. Inline-Bearbeitung zusätzlich für
Einmalposten, Kredite und den Szenario-Kopf (Nutzerentscheidung).
3. **Szenarien** — Modifikator-Art `Ende` (Posten endet innerhalb des
Szenarios an einem Datum, ohne den Posten selbst zu ändern) und
szenario-eigene Einmalzahlungen. Demo-Szenario „Best Case" wird nach dem
Release live angelegt und durchgerechnet.
## Nutzerentscheidungen (Chat 2026-07-20)
- Szenario-Ende als **neue Modifikator-Art `ende`** mit Datumsfeld (nicht
als Gültigkeitszeitraum aller Modifikatoren).
- Einmalzahlung im Demo-Szenario ist **szenario-spezifisch** → neues Feature
„Szenario-Einmalzahlungen".
- Formate: **Zahlen + deutsche Begriffe** (API-Werte bleiben englisch).
- Bearbeiten-Buttons für: wiederkehrende Posten, Einmalposten, Kredite,
Szenario-Kopf.
## Teil 1 — Deutsche Formate
**Anzeige.** Neuer Jinja-Filter `eur` in `app/routers/gui.py`
(`templates.env.filters["eur"]`), reine `Decimal`-Formatierung ohne
`locale`-Modul (Container-Locale unzuverlässig): Vorzeichen, Tausenderpunkt,
Komma, zwei Nachkommastellen. Ersetzt alle `'%.2f'|format(...)`-Stellen in
`index.html`, `salden.html`, `transactions.html`, `planning.html`,
`_preview_table.html`. Prozentwerte (Kredit-Zins) analog mit Komma.
Der per JavaScript nachgeladene Tilgungsplan (`planning.html`,
`loadLoanSchedule`) formatiert mit
`toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2})`.
**Eingabe.** Die json-form-Extension in `base.html` lernt
`data-type="amount"`: Whitespace/€ entfernen; enthält der Wert ein Komma →
Tausenderpunkte entfernen, Komma→Punkt; ohne Komma bleibt der Wert
unverändert (Punkt-Eingaben funktionieren weiter). Alle
Betrags-`<input>`-Felder (Fixposten, Einmalposten, Kredit, Modifikator-Wert,
Szenario-Einmalzahlung) bekommen `data-type="amount"` und deutsche
Platzhalter (`-49,99`). Versteckte Felder der Vorschlags-Übernahme bleiben
ohne `data-type="amount"` (Server liefert Punktformat). Die JSON-API bleibt
strikt Punkt-Dezimal — Tests, Skripte und Grafana sind nicht betroffen.
**Begriffe.** Zentrales Mapping als Jinja-Global `de_label` (Definition in
`gui.py`): `monthly→monatlich`, `quarterly→vierteljährlich`,
`yearly→jährlich`, `annuity→Annuität`, `bullet→endfällig`,
`percent→Prozent`, `absolute→Absolut`, `remove→Entfällt`, `ende→Ende`.
Dropdown-`<option>`-Texte und Tabellenzellen zeigen deutsche Labels;
`value`-Attribute, API-Literale und DB-Werte bleiben englisch (keine
Migration, keine API-Änderung). Unbekannte Werte fallen auf den Rohwert
zurück.
## Teil 2 — Posten-Bearbeitung + Start/Ende
`RecurringItem.start_date/end_date` existieren in Modell, API
(`RecurringIn`/`RecurringPatch`) und Engine (`recurrence.occurrences`
klammert das Fenster bereits). Es fehlt nur GUI:
- Tabelle „Wiederkehrende Posten": neue Spalten **Start** und **Ende**
(TT.MM.JJJJ, leer = „–").
- Anlege-Formular: zwei optionale `<input type="date">`-Felder.
- **Inline-Bearbeiten** je Zeile (Muster für alle vier Bereiche):
`Bearbeiten`-Button blendet eine vorbefüllte Formularzeile ein
(Anzeigezeile wird versteckt, nicht entfernt — UX-Regel aus `CLAUDE.md`
bleibt gewahrt: Bedienelemente sichtbar/disabled, Fehler via bestehendem
`htmx:responseError`-Handler), `Speichern` sendet `PATCH` über die
json-form-Extension, `Abbrechen` klappt zurück. Leeres Datumsfeld wird als
`null` gesendet (Extension tut das bereits) und löscht den Wert.
- Bereiche: `PATCH /api/recurring/{id}` (alle Felder),
`PATCH /api/planned/{id}` (Name, Betrag, Fälligkeit, Kategorie),
`PATCH /api/loans/{id}` (alle Felder),
`PATCH /api/scenarios/{id}` (Name, Beschreibung, include-Flags).
- Neue Validierung in `RecurringIn`/`RecurringPatch` (Pydantic
model_validator): `end_date ≥ start_date`, wenn beide gesetzt (beim PATCH
gegen den resultierenden Zustand geprüft), sonst 422 mit deutscher
Meldung.
## Teil 3 — Szenarien: Art `ende` + Szenario-Einmalzahlungen
Eine Alembic-Migration (`scenario_ende`) mit zwei Änderungen:
1. `scenario_modifiers.end_date``Date`, nullable.
2. Neue Tabelle `scenario_planned_items`: `id`, `scenario_id`
(FK `scenarios.id`), `name` (String 200), `amount` (`MONEY`), `due`
(`Date`).
**Modifikator-Art `ende`.**
- API: `ModifierIn.kind` um Literal `ende` erweitert; neues optionales Feld
`end_date: date | None`. Validator: `ende` erfordert `end_date`; andere
Arten ignorieren es (wird genullt). `ModifierOut` liefert `end_date` mit.
- Engine: `PlainModifier` bekommt `end_date: date | None = None`.
`build_cashflows` bestimmt vor `occurrences` je Posten das effektive Ende:
Minimum aus eigenem `end_date` und allen treffenden `ende`-Modifikatoren
(Ziel `recurring` oder `category` — bei Kategorie enden alle Posten der
Kategorie). `_modified` behandelt weiterhin nur die Betrags-Arten und
überspringt `ende`.
- GUI (`planning.html`): Art-Dropdown bekommt „Ende"; ein Datumsfeld wird
nur bei dieser Art aktiviert (disabled-Umschalter analog
`onModTargetTypeChange`), das Wert-Feld ist bei `ende` disabled.
Modifikator-Tabelle zeigt das Datum.
**Szenario-Einmalzahlungen.**
- Endpunkte in `routers/scenarios.py`:
`GET/POST /api/scenarios/{id}/planned` (201, Name/Betrag/Datum),
`DELETE /api/scenarios/{id}/planned/{item_id}` (404 bei fremder
scenario_id, Muster `delete_modifier`).
- `run_projection` lädt `scenario_planned_items` des Szenarios und hängt sie
als `PlainPlanned` an — **unabhängig** von `include_planned` (das steuert
nur globale Einmalposten; szenario-eigene Zahlungen sind bewusst diesem
Szenario zugeordnet).
- `delete_scenario` löscht zusätzlich `scenario_planned_items` (kein
DB-Cascade vorhanden, Muster der bestehenden Deletes).
- GUI: je Szenario ein `<details>` „Einmalzahlungen in diesem Szenario"
(Tabelle Name/Betrag/Datum + Löschen, Anlege-Formular).
## Teil 4 — Demo „Best Case", Release, Tests
**Demo (live, nach Redeploy):** Szenario „Best Case" anlegen; für die beiden
bestehenden Miet-Fixposten (falls nicht vorhanden: vorher anlegen bzw. aus
Vorschlägen übernehmen) je einen `ende`-Modifikator mit den im Chat
genannten Terminen setzen; szenario-eigene Einmalzahlung mit dem im Chat
genannten Betrag/Datum anlegen; Projektion durchrechnen. Anschließend
Schritt-für-Schritt-Erklärung der Eingabe im Chat. `hilfe.html` bekommt
einen kurzen Abschnitt zu Szenario-Varianten (generisch, ohne echte Daten).
**Release:** `finance/VERSION``0.6.0` vor `./create_pod_finance.sh`
(Migration läuft im Entrypoint via `alembic upgrade head`).
**Tests (TDD, Suite bleibt komplett grün):**
- `eur`-Filter: Rundung, Tausenderpunkte, negative Werte, `Decimal`-Input.
- Engine: `ende`-Modifikator (Ziel Posten/Kategorie, Zusammenspiel mit
eigenem `end_date`, Minimum-Regel), Szenario-Einmalzahlung im Fenster.
- API: Modifier-Validierung (`ende` ohne Datum → 422), CRUD
Szenario-Einmalzahlungen, Cleanup bei `delete_scenario`,
`end ≥ start`-Validierung bei Fixposten.
- GUI-Tests: deutsche Betragsformatierung in gerenderten Seiten, neue
Spalten/Formulare vorhanden, deutsche Labels statt englischer Rohwerte.
- Migration: Upgrade-Pfad in Wegwerf-DB (Muster Konto-Anker-Task).
**Außerhalb des Scopes:** Grafana-Dashboards (formatiert selbst),
PDF/CSV-Parser (parsen deutsche Formate bereits), Übersetzung von
API-Fehlertexten Dritter, Mehrsprachigkeit.

View File

@@ -0,0 +1,85 @@
# Design — Ausbaustufe 7: Szenarien-Seite, Formular-UX, Kategorien-Verwaltung (v0.8.0)
> Status: vom Nutzer freigegeben (Chat 2026-07-20, inkl. Direktdurchlauf).
> Anlass: Nutzertest von v0.7.0 — vier Befunde: (1) Eintragsart
> „Einmalzahlung" im Formular nicht auffindbar (Layout verschleiert, welches
> Dropdown was steuert), (2) irrelevantes Kategorie-Dropdown bei
> Einmalzahlung + keine GUI zum Anlegen von Kategorien, (3) Label/Feld-Paare
> zerfließen über Zeilen, „Durchrechnen" klebt am Formular, (4) Felder
> „Wert" vs. „Betrag" unverständlich, gesperrte Felder nicht als gesperrt
> erkennbar.
## Nutzerentscheidungen
- Szenarien auf **eigene Seite** `/szenarien` (Nav-Punkt zwischen Planung
und Admin); Planung behält Fixposten/Einmalposten/Kredite.
- Nicht zur Eintragsart passende Felder bleiben **sichtbar, aber gesperrt
und optisch deutlich gekennzeichnet** (grau gefüllt, gedimmt) — keine
Ausblendung.
- Kategorien-Verwaltung auf der **Admin-Seite** (anlegen + umbenennen,
bewusst kein Löschen — FK-Abhängigkeiten zu Buchungen/Regeln/Posten/
Modifikatoren wären ein eigenes Thema).
- Kein „Keine"-Eintrag im Kategorie-Dropdown des Eintrags-Formulars
(Kategorie-Modifikator braucht zwingend ein Ziel).
## 1. Szenarien-Seite
- `routers/gui.py`: neue Route `GET /szenarien` (`szenarien_page`,
`gui_session`-geschützt) mit dem Szenario-Kontext (scenario_rows, loans,
categories/category_names, recurring/recurring_names, modifier_kinds);
`planung_page` verliert scenario_rows/modifier_kinds.
- Neues Template `szenarien.html` (extends base): komplette bisherige
Szenarien-`<section>` aus `planning.html` (Kopf+Edit, Kredite zuordnen,
Einträge-Tabelle, Neuer Eintrag, Durchrechnen, Ergebnis, Neues Szenario)
plus die szenario-spezifischen JS-Helfer (`onModTargetTypeChange`,
`onEntryArtChange`, `toggleScenarioLoan`).
- `toggleEdit` (von Planung UND Szenarien gebraucht) zieht in den zentralen
Script-Block von `base.html`.
- Nav in `base.html`: „Szenarien" zwischen „Planung" und „Admin".
- `hilfe.html`: Verweise „auf der Planung-Seite" für Szenarien anpassen.
## 2. Formular-UX (style.css + Templates)
- **Paar-Layout:** Regel für Formular-Labels der Planungs-/Szenarien-
Formulare: `label` als `inline-flex`-Einheit (Label + Feld in einer
Zeile, `white-space: nowrap`, `gap`), Paare mit Außenabstand voneinander
getrennt. Gilt automatisch auch für die Bearbeiten-Formulare (gleiches
Markup).
- **Gesperrt-Kennzeichnung:** `input:disabled, select:disabled` → graue
Füllung, gedimmter Text, `cursor: not-allowed`; `label:has(:disabled)`
reduzierte Deckkraft + `title`-Tooltip im Markup der umschaltbaren Felder
(„Für diese Eintragsart nicht relevant").
- **Struktur „Neuer Eintrag":** Eintragsart als erste, eigene Zeile mit
fettem Label und Hinweistext („die passenden Felder werden aktiv");
danach die Feldpaare. Dynamisches Wert-Label in `onEntryArtChange`:
percent → „Prozentsatz (%)", absolute → „Kürzung (€)", sonst „Wert" —
Label-Text via `<span class="value-label">`.
- **„Durchrechnen"** in eigenem, per Abstand/`<hr>` abgesetztem Block.
## 3. Kategorien-Verwaltung (Admin)
- Neuer Endpunkt `PATCH /api/categories/{category_id}` in
`routers/categories.py`: Body `CategoryIn`, 404 „Kategorie nicht
gefunden", 409 „Kategorie existiert bereits" bei Namenskollision mit
anderer Kategorie, Antwort `CategoryOut`.
- `admin.html`: Abschnitt „Kategorien" — Tabelle (Name + Bearbeiten/
Inline-Umbenennen nach `toggleEdit`-Muster, IDs `cat-row-{id}`/
`cat-edit-{id}`) + Formular „Neue Kategorie anlegen" (`POST
/api/categories` via json-form). Kein Löschen.
- `routers/admin.py::admin_page` lädt die Kategorienliste in den Kontext.
## 4. Tests / Release
- GUI-Tests: `/szenarien` rendert Szenario-Inhalte, ist login-geschützt und
in der Nav; `/planung` enthält KEINE Szenarien-Sektion mehr (bestehende
Szenario-GUI-Tests auf `/szenarien` umziehen); Admin-Seite zeigt
Kategorien-Abschnitt.
- API-Tests: PATCH Kategorie (Erfolg, 404, 409-Kollision, Umbenennung auf
eigenen Namen erlaubt).
- CSS ist nicht automatisiert testbar → Fable-Gate prüft die gerenderte
Struktur (Klassen/Tooltips/Label-Spans) und macht den Live-Check.
- `VERSION` → 0.8.0, Redeploy, Live-Check (Szenarien-Seite mit Best Case,
Kategorien-Anlage+Umbenennung live mit Wegwerf-Kategorie).
**Außerhalb des Scopes:** Kategorie-Löschen/-Zusammenführen, Regel-Pflege-
GUI, Änderungen an Engine/Projektion/Datenmodell (keine Migration).

View File

@@ -0,0 +1,103 @@
# Design — Ausbaustufe 6: Szenario-Einträge-Tabelle (v0.7.0)
> Status: vom Nutzer freigegeben (Chat 2026-07-20, inkl. Freigabe zum
> Direktdurchlauf Spec → Plan → Umsetzung). Anlass: Nutzerfeedback nach
> v0.6.0 — die Szenario-Änderungen (Modifikatoren, Einmalzahlungen) waren
> auf zwei zugeklappte `<details>`-Tabellen verteilt und nicht editierbar;
> erwartet wurde EINE editierbare Tabelle aller Einträge (Zielbild: 1520
> Änderungen pro Szenario bequem pflegen).
## Ziel
Pro Szenario eine **immer sichtbare Tabelle „Einträge"**, die Modifikatoren
und szenario-eigene Einmalzahlungen gemeinsam zeigt, mit Inline-Bearbeiten
und Löschen je Zeile und einem gemeinsamen Anlege-Formular mit
Eintragsart-Umschaltung.
## Entscheidungen
- **Datenmodell unverändert.** „Eintrag" ist eine reine Darstellungssicht
auf `ScenarioModifier` und `ScenarioPlannedItem`. Keine Migration.
- **Typ bleibt beim Bearbeiten erhalten:** ein Modifikator kann Art
(percent/absolute/remove/ende), Ziel und Wert/Datum ändern; eine
Einmalzahlung bleibt Einmalzahlung (Name/Betrag/Datum änderbar).
- „Kredite zuordnen" bleibt eigener Abschnitt (Zuordnung, kein Eintrag).
- Beschreibung des Szenarios bleibt Freitext ohne Funktion.
## API
Zwei neue Endpunkte in `app/routers/scenarios.py` (Full-Body-Update — die
Edit-Formulare senden immer alle Felder des Typs):
- `PATCH /api/scenarios/{id}/modifiers/{mod_id}` — Body = `ModifierIn`
(inkl. bestehender Validierung: `ende` erfordert `end_date`, andere Arten
nullen es). 404 „Modifikator nicht gefunden" bei fremder/fehlender ID
(Muster `delete_modifier`). Antwort `ModifierOut`.
- `PATCH /api/scenarios/{id}/planned/{item_id}` — Body =
`ScenarioPlannedIn`. 404 „Einmalzahlung nicht gefunden" (Muster
`delete_scenario_planned`). Antwort `ScenarioPlannedOut`.
Sortierung für stabile Anzeige: Modifikator-Query in `_scenario_rows`
bekommt `.order_by(ScenarioModifier.id)`; Einmalzahlungen sind bereits nach
`due` sortiert.
## GUI (`app/templates/planning.html`)
Die zwei `<details>`-Abschnitte „Modifikatoren" und „Einmalzahlungen in
diesem Szenario" werden ersetzt durch eine offene Tabelle **„Einträge"**:
- Spalten: **Was** | **Art** | **Wert/Betrag** | **Datum** | Aktionen.
- Modifikator: Was = „Posten: X" / „Kategorie: Y"; Art = `|de_label`;
Wert = `value|eur` bei percent/absolute, sonst „–"; Datum =
`end_date` TT.MM.JJJJ bei ende, sonst „–".
- Einmalzahlung: Was = Name; Art = „Einmalzahlung"; Wert = `amount|eur €`
(neg-Klasse wie bisher); Datum = `due` TT.MM.JJJJ.
- Zeilen-Reihenfolge: erst Modifikatoren (nach id), dann Einmalzahlungen
(nach Fälligkeit).
- **Inline-Bearbeiten** je Zeile nach dem `toggleEdit`-Muster aus v0.6.0:
IDs `mod-row-{id}`/`mod-edit-{id}` bzw. `spi-row-{id}`/`spi-edit-{id}`.
Modifikator-Edit-Formular = Felder des Anlege-Formulars (Ziel-Typ, Ziel,
Art, Wert, Datum, vorbelegt, mit denselben Umschaltern), sendet
`hx-patch` auf den neuen Endpunkt. Einmalzahlungs-Edit: Name/Betrag/Datum.
- **Ein Anlege-Formular „Neuer Eintrag"** mit Dropdown **Eintragsart**
(Ende, Prozent, Absolut, Entfällt, Einmalzahlung; Werte
`ende|percent|absolute|remove|einmal`):
- Feld-Umschaltung ausschließlich per `disabled` (UX-Regel):
Ziel-Typ/Ziel aktiv bei den vier Modifikator-Arten; Wert aktiv bei
Prozent/Absolut; Datum „Endet am" (`end_date`) aktiv bei Ende;
Name/Betrag/„Fällig am" (`due`) aktiv bei Einmalzahlung.
- Der Eintragsart-Select trägt `name="kind"`; beim Absenden an den
Einmalzahlungs-Endpunkt wird das überzählige `kind` von Pydantic
ignoriert (Default `extra=ignore`), disabled-Felder fehlen in der
Serialisierung.
- Ziel-Endpunkt-Wechsel: JS setzt das `hx-post`-Attribut des Formulars um
(`…/modifiers``…/planned`) und ruft `htmx.process(form)` auf.
- Bestehende Helfer (`onModTargetTypeChange`, `toggleEdit`) werden
weiterverwendet; neue Funktion `onEntryArtChange(select)` ersetzt
`onModKindChange` (Umschaltmatrix oben). Scoping wie bisher über
`closest('form')`, damit Anlege- und Edit-Formulare sich nicht stören.
`hilfe.html`: der Szenarien-Absatz wird auf die neue Einträge-Tabelle
angepasst (weiterhin generisch, keine echten Daten).
## Tests (TDD, Suite bleibt grün — Basis 178)
- API: PATCH Modifikator (Wert ändern; Art auf ende wechseln mit Datum;
ende ohne Datum → 422; fremde scenario_id → 404), PATCH Einmalzahlung
(Betrag/Datum ändern; fremde scenario_id → 404).
- GUI: eine Einträge-Tabelle statt der zwei details-Abschnitte
(Summary-Texte „Modifikatoren"/„Einmalzahlungen in diesem Szenario"
verschwinden), gemischte Zeilen inkl. „Einmalzahlung"-Art, Edit-Formulare
mit `hx-patch`-URLs, Anlege-Formular mit Eintragsart-Optionen.
## Release
`finance/VERSION``0.7.0`, Redeploy via `./create_pod_finance.sh` (keine
Migration nötig), Live-Check am bestehenden Szenario „Best Case" (drei
Einträge sichtbar in einer Tabelle, ein Bearbeiten-Roundtrip live).
Fable-Testagent-Gate je Task vor Commit (CLAUDE.md). Datenschutz-Regel
unverändert: echte Namen/Beträge nur Live-DB/Chat.
**Außerhalb des Scopes:** Typwechsel Modifikator↔Einmalzahlung beim
Bearbeiten, Mehrfach-Bearbeitung/Bulk-Aktionen, Kredit-Zuordnung als
Eintrag, Änderungen an Engine/Projektion.

View File

@@ -0,0 +1,104 @@
# Design — Ausbaustufe 9: Vorschlags-Algorithmus v2 (v0.9.0)
> Status: vom Nutzer freigegeben (Chat 2026-07-20, Direktdurchlauf). Anlass:
> Der bisherige `suggest_recurring` gruppiert nach exaktem Betrag (jede
> Preiserhöhung zerreißt die Serie), erkennt nur monatliche Serien, prüft
> keine Aktivität (schlägt tote Serien vor) und nutzt Median-Werte. Ein
> manueller Vollabgleich (A8-Datenpflege, siehe Ledger) fand 16 fehlende
> Posten — der Algorithmus soll solche Serien künftig selbst finden.
## Anforderungen (Nutzer)
- Betrag = **letzte** Buchung, nicht Median (Preissteigerungen relevant).
- Erkennung **monatlich, vierteljährlich, jährlich**.
- Empfänger-Gruppierung robust (Schreibweisen, Preisänderungen,
Umfirmierungen); mehrere Verträge desselben Anbieters getrennt.
- Keine „Leichen": abgerissene Serien werden nicht vorgeschlagen.
- Kein Wiedervorschlagen bereits gepflegter Posten (auch bei zwischenzeitlich
geändertem Betrag).
## Algorithmus (`app/services/suggestions.py`, vollständiger Rewrite)
Parameter als Modul-Konstanten (Toleranzen zentral änderbar):
`WINDOW_DAYS=460` (~15 Monate), Rhythmen mit Intervallgrenzen und
Mindestbelegen: monthly 2536 Tage/≥3, quarterly 80105/≥3, yearly
330400/≥2; `ACTIVITY_FACTOR=1.75`; Betrags-Cluster-Toleranz 35 %;
Merge-Toleranz 25 %; Bestandsabgleich-Toleranz 10 %.
1. **Datenbasis:** bestätigte Buchungen der letzten `WINDOW_DAYS`, je Konto.
2. **Gruppierung:** Schlüssel = (account_id, normalisierter Empfänger)
(`casefold`, Whitespace kollabiert).
3. **Betrags-Cluster** innerhalb der Gruppe (chronologisch, greedy gegen das
jeweils letzte Cluster-Mitglied, gleiches Vorzeichen, relative Differenz
≤ 35 %) — trennt parallele Verträge, hält Preisdrift zusammen.
4. **Rhythmus je Cluster:** Median der Buchungsabstände gegen die
Intervallgrenzen; Mindestbelege je Rhythmus.
5. **Aktiv-Check:** letzte Buchung ≤ `ACTIVITY_FACTOR` × Rhythmus-Schrittweite
(30/91/365 Tage) her, sonst kein Vorschlag.
6. **Umfirmierungs-Merge** (über Gruppengrenzen, je Konto): Serie A endet,
Serie B beginnt danach (Lücke 0,41,6 Schrittweiten), gleicher Rhythmus,
Fälligkeitstag ±3, Betrag ±25 % → eine Serie; Name/Betrag der neueren.
7. **Vorschlagswerte:** Name = Empfänger-Schreibweise der neuesten Buchung;
Betrag = neueste Buchung; Fälligkeitstag = Tag der neuesten Buchung;
`start_date` = Datum der neuesten Buchung bei quarterly/yearly (Phase!),
sonst None; Kategorie = häufigste in der Serie; `hinweis` = Text
„Betrag zuletzt gestiegen (vorher X)" wenn die vorletzte Buchung
betragskleiner war, sonst leer.
8. **Bestandsabgleich:** Vorschlag entfällt, wenn ein `RecurringItem`
existiert mit (a) Namens-Substring-Match (normalisiert, in beide
Richtungen) ODER (b) gleichem Rhythmus + Fälligkeitstag ±2 + Betrag
±10 %.
## API/GUI
- `SuggestionOut` (routers/planning.py): + `start_date: date | None`,
+ `hinweis: str = ""`.
- Vorschlags-Tabelle (planning.html): Spalten Rhythmus (`|de_label`) und
Start (TT.MM.JJJJ bzw. „–"); `hinweis` als `muted`-Text hinter dem Betrag;
„Vorschlag übernehmen" überträgt `start_date` mit (hidden input).
- Hinweistext unter der Tabelle aktualisiert: monatliche/vierteljährliche/
jährliche Serien, Betrag = letzte Buchung.
## Tests (synthetische Daten, keine Fixtures)
Preiserhöhungs-Serie → letzter Betrag + hinweis; Quartals-/Jahres-Serie mit
korrektem start_date; tote Serie (letzte Buchung zu alt) → kein Vorschlag;
Umbenennungs-Merge → ein Vorschlag mit neuem Namen; Bestandsabgleich:
existierender Posten mit altem Betrag verhindert Wiedervorschlag; zwei
parallele Verträge eines Anbieters → zwei getrennte Vorschläge; bestehende
drei Suggestion-Tests an die neue Semantik anpassen.
## Release
`VERSION` → 0.9.0, Redeploy, **Live-Gate gegen echte Daten**: kein einziger
der bestehenden Fixposten darf erneut vorgeschlagen werden; keine als
beendet bekannten Serien (z.B. gelöschte PayPal-Leiche, ausgelaufene
WWK-Police) im Ergebnis; verbleibende Vorschläge werden dem Nutzer als
Kandidatenliste berichtet (nur Chat, kein Commit). Fable-Gate je Task.
**Außerhalb des Scopes:** halbjährliche Rhythmen (nicht im Datenmodell),
automatische Übernahme ohne Nutzer-Klick, Einnahmen-Prognose des
Geschäftskontos.
## Nachtrag (nach Live-Release-Gate, gleiche Session)
Das erste Live-Gate scheiterte an einem Duplikat: ein kuratierter
„variabel"-Fixposten unter Alias-Namen des Anbieters wurde vom
Bestandsabgleich (a)/(b) nicht erkannt. Daraus zwei Ergänzungen:
- **Bestandsabgleich-Regel (c) Token-Match:** Vorschlag entfällt auch, wenn
ein Fixposten mit gleichem Rhythmus, Fälligkeitstag ±2 und mindestens
einem gemeinsamen Namens-Token (≥ 5 Zeichen, normalisiert, Split an
Nicht-Alphanumerik) existiert.
- **Volatilitäts-Hinweis:** Wurde die neueste Buchung einer Empfänger-Gruppe
durch den Betrags-Cluster-Split abgetrennt UND gehört sie zu keiner
anderen qualifizierten Serie der Gruppe, erhält der Vorschlag den Zusatz
„Beträge schwanken stark letzte Buchung weicht ab" (keine
Unterdrückung; die Ausnahme verhindert False-Positives bei parallelen
Verträgen desselben Anbieters).
Bewiesene Pipeline-Eigenschaft (bindend fürs Verständnis): der
Umfirmierungs-Merge kann die Vorschlagsanzahl nie ändern (Aktiv-Check/
Fenster erledigen das allein); sein Nutzen ist Kategorie-/Historien-
Kontinuität. Nach einer Umfirmierung entsteht eine Vorschlags-Lücke, bis
der neue Name selbst die Mindestbelege erreicht.

6
finance/.containerignore Normal file
View File

@@ -0,0 +1,6 @@
.venv/
tests/
.env
*.sqlite
inbox/
uploads/

7
finance/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.env
__pycache__/
*.sqlite
tests/fixtures/*.pdf
tests/fixtures/*.csv
tests/fixtures/expected_*.json
.venv/

13
finance/Containerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM docker.io/library/python:3.12.11-slim
WORKDIR /srv/finance
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ app/
COPY alembic/ alembic/
COPY alembic.ini .
COPY VERSION .
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]

1
finance/VERSION Normal file
View File

@@ -0,0 +1 @@
0.9.0

141
finance/alembic.ini Normal file
View File

@@ -0,0 +1,141 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
finance/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

84
finance/alembic/env.py Normal file
View File

@@ -0,0 +1,84 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
from app.config import get_settings
from app.db import Base
import app.models # noqa: F401
config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,34 @@
"""konto anker
Revision ID: 1ef6a356f028
Revises: b2b1f5a18a74
Create Date: 2026-07-19 21:18:41.888568
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1ef6a356f028'
down_revision: Union[str, Sequence[str], None] = 'b2b1f5a18a74'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('accounts', sa.Column('anchor_date', sa.Date(), nullable=True))
op.add_column('accounts', sa.Column('anchor_balance', sa.Numeric(precision=12, scale=2), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('accounts', 'anchor_balance')
op.drop_column('accounts', 'anchor_date')
# ### end Alembic commands ###

View File

@@ -0,0 +1,177 @@
"""initial schema
Revision ID: b2b1f5a18a74
Revises:
Create Date: 2026-07-17 17:08:30.212347
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b2b1f5a18a74'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('accounts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('bank', sa.String(length=50), nullable=False),
sa.Column('iban', sa.String(length=34), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('type', sa.String(length=20), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('iban')
)
op.create_table('categories',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('loans',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('principal', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('annual_rate_pct', sa.Numeric(precision=5, scale=2), nullable=False),
sa.Column('term_months', sa.Integer(), nullable=False),
sa.Column('payout_date', sa.Date(), nullable=False),
sa.Column('repayment_type', sa.String(length=20), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('scenarios',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('include_recurring', sa.Boolean(), nullable=False),
sa.Column('include_planned', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('category_rules',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('pattern', sa.String(length=200), nullable=False),
sa.Column('category_id', sa.Integer(), nullable=False),
sa.Column('priority', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('planned_items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('due', sa.Date(), nullable=False),
sa.Column('category_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('projection_points',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('scenario_id', sa.Integer(), nullable=False),
sa.Column('day', sa.Date(), nullable=False),
sa.Column('balance', sa.Numeric(precision=12, scale=2), nullable=False),
sa.ForeignKeyConstraint(['scenario_id'], ['scenarios.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_projection_points_scenario_id'), 'projection_points', ['scenario_id'], unique=False)
op.create_table('projection_results',
sa.Column('scenario_id', sa.Integer(), nullable=False),
sa.Column('computed_at', sa.DateTime(), nullable=False),
sa.Column('low_point_date', sa.Date(), nullable=False),
sa.Column('low_point_balance', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('below_zero_date', sa.Date(), nullable=True),
sa.Column('below_threshold_date', sa.Date(), nullable=True),
sa.ForeignKeyConstraint(['scenario_id'], ['scenarios.id'], ),
sa.PrimaryKeyConstraint('scenario_id')
)
op.create_table('recurring_items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('rhythm', sa.String(length=20), nullable=False),
sa.Column('due_day', sa.Integer(), nullable=False),
sa.Column('start_date', sa.Date(), nullable=True),
sa.Column('end_date', sa.Date(), nullable=True),
sa.Column('category_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('scenario_loans',
sa.Column('scenario_id', sa.Integer(), nullable=False),
sa.Column('loan_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['loan_id'], ['loans.id'], ),
sa.ForeignKeyConstraint(['scenario_id'], ['scenarios.id'], ),
sa.PrimaryKeyConstraint('scenario_id', 'loan_id')
)
op.create_table('scenario_modifiers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('scenario_id', sa.Integer(), nullable=False),
sa.Column('target_type', sa.String(length=20), nullable=False),
sa.Column('target_id', sa.Integer(), nullable=False),
sa.Column('kind', sa.String(length=20), nullable=False),
sa.Column('value', sa.Numeric(precision=12, scale=2), nullable=False),
sa.ForeignKeyConstraint(['scenario_id'], ['scenarios.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('statements',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('bank', sa.String(length=50), nullable=False),
sa.Column('account_id', sa.Integer(), nullable=True),
sa.Column('period_start', sa.Date(), nullable=True),
sa.Column('period_end', sa.Date(), nullable=True),
sa.Column('opening_balance', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('closing_balance', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('error_message', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('transactions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('account_id', sa.Integer(), nullable=False),
sa.Column('statement_id', sa.Integer(), nullable=True),
sa.Column('booking_date', sa.Date(), nullable=False),
sa.Column('value_date', sa.Date(), nullable=True),
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('purpose', sa.Text(), nullable=False),
sa.Column('counterparty', sa.String(length=200), nullable=False),
sa.Column('category_id', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('dedup_hash', sa.String(length=64), nullable=False),
sa.Column('is_duplicate', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ),
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
sa.ForeignKeyConstraint(['statement_id'], ['statements.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_transactions_dedup_hash'), 'transactions', ['dedup_hash'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_transactions_dedup_hash'), table_name='transactions')
op.drop_table('transactions')
op.drop_table('statements')
op.drop_table('scenario_modifiers')
op.drop_table('scenario_loans')
op.drop_table('recurring_items')
op.drop_table('projection_results')
op.drop_index(op.f('ix_projection_points_scenario_id'), table_name='projection_points')
op.drop_table('projection_points')
op.drop_table('planned_items')
op.drop_table('category_rules')
op.drop_table('scenarios')
op.drop_table('loans')
op.drop_table('categories')
op.drop_table('accounts')
# ### end Alembic commands ###

View File

@@ -0,0 +1,37 @@
"""szenario ende + scenario_planned_items
Revision ID: c4d7e2a91b53
Revises: 1ef6a356f028
Create Date: 2026-07-20
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'c4d7e2a91b53'
down_revision: Union[str, Sequence[str], None] = '1ef6a356f028'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('scenario_modifiers', sa.Column('end_date', sa.Date(), nullable=True))
op.create_table(
'scenario_planned_items',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('scenario_id', sa.Integer(), sa.ForeignKey('scenarios.id'), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('due', sa.Date(), nullable=False),
)
op.create_index('ix_scenario_planned_items_scenario_id',
'scenario_planned_items', ['scenario_id'])
def downgrade() -> None:
op.drop_index('ix_scenario_planned_items_scenario_id', 'scenario_planned_items')
op.drop_table('scenario_planned_items')
op.drop_column('scenario_modifiers', 'end_date')

0
finance/app/__init__.py Normal file
View File

97
finance/app/auth.py Normal file
View File

@@ -0,0 +1,97 @@
import hashlib
import hmac
import re
import secrets
from pathlib import Path
from fastapi import HTTPException, Request
from itsdangerous import BadSignature, TimestampSigner
from app.config import get_settings
COOKIE = "fb_session"
MAX_AGE = 60 * 60 * 12 # 12 h
# mtime-Cache fuer den aus der (ggf. bind-gemounteten) .env gelesenen
# GUI-Passwort-Hash: {Pfad: (mtime, hash)}. Vermeidet, die Datei bei jedem
# Login/Request neu zu parsen, invalidiert sich aber automatisch, sobald
# services.admin.change_password() die Datei in-place neu schreibt (neue
# mtime).
_hash_cache: dict[str, tuple[float, str]] = {}
_ENV_LINE_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)='([^']*)'\s*$")
def _parse_env_file(env_file: Path) -> dict[str, str]:
"""Parst single-quoted KEY='value'-Zeilen wie sie create_pod_finance.sh
schreibt. Zeilen in anderer Form (Kommentare, unquoted, leer) werden
ignoriert statt einen Fehler zu werfen."""
values: dict[str, str] = {}
for line in env_file.read_text().splitlines():
m = _ENV_LINE_RE.match(line)
if m:
values[m.group(1)] = m.group(2)
return values
def current_password_hash() -> str:
"""Liest FB_GUI_PASSWORD_HASH zur Laufzeit aus der .env (Bind-Mount,
siehe KRITISCH-Hinweis Global Constraints Ausbaustufe 4: Einzeldatei-Mount
folgt dem Inode, die Datei wird von services.admin.change_password()
in-place ueberschrieben). Existiert die Datei nicht (z.B. lokale
Entwicklung ohne Pod), wird auf die Umgebungsvariable zurueckgefallen."""
settings = get_settings()
env_file = settings.env_file
if env_file.exists():
mtime = env_file.stat().st_mtime
key = str(env_file)
cached = _hash_cache.get(key)
if cached is not None and cached[0] == mtime:
return cached[1]
value = _parse_env_file(env_file).get("FB_GUI_PASSWORD_HASH")
if value is not None:
_hash_cache[key] = (mtime, value)
return value
return settings.gui_password_hash
def hash_password(pw: str, salt: str | None = None) -> str:
salt = salt or secrets.token_hex(16)
digest = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt.encode(), 200_000).hex()
return f"{salt}${digest}"
def verify_password(pw: str, stored: str) -> bool:
try:
salt, digest = stored.split("$", 1)
except ValueError:
return False
return hmac.compare_digest(hash_password(pw, salt).split("$", 1)[1], digest)
def _signer() -> TimestampSigner:
return TimestampSigner(get_settings().session_secret)
def make_session_token() -> str:
return _signer().sign(b"gui").decode()
def session_valid(token: str | None) -> bool:
if not token:
return False
try:
_signer().unsign(token, max_age=MAX_AGE)
return True
except BadSignature:
return False
def require_auth(request: Request) -> None:
settings = get_settings()
header = request.headers.get("authorization", "")
if settings.api_key and hmac.compare_digest(header, f"Bearer {settings.api_key}"):
return
if session_valid(request.cookies.get(COOKIE)):
return
raise HTTPException(status_code=401, detail="Nicht angemeldet")

41
finance/app/config.py Normal file
View File

@@ -0,0 +1,41 @@
import os
from dataclasses import dataclass
from decimal import Decimal
from functools import lru_cache
from pathlib import Path
@dataclass(frozen=True)
class Settings:
database_url: str
api_key: str
gui_user: str
gui_password_hash: str
session_secret: str
inbox_dir: Path
uploads_dir: Path
warn_threshold: Decimal
horizon_days: int
env_file: Path
grafana_url: str
@lru_cache
def get_settings() -> Settings:
e = os.environ.get
return Settings(
database_url=e("FB_DATABASE_URL", "sqlite:///./fb.sqlite"),
api_key=e("FB_API_KEY", ""),
gui_user=e("FB_GUI_USER", "admin"),
gui_password_hash=e("FB_GUI_PASSWORD_HASH", ""),
session_secret=e("FB_SESSION_SECRET", "dev-secret"),
inbox_dir=Path(e("FB_INBOX_DIR", "./inbox")),
uploads_dir=Path(e("FB_UPLOADS_DIR", "./uploads")),
warn_threshold=Decimal(e("FB_WARN_THRESHOLD", "0")),
horizon_days=int(e("FB_HORIZON_DAYS", "548")),
# Bind-gemountete .env (siehe create_pod_finance.sh): Laufzeit-Lesen
# der GUI-Passwort-Hashes fuer die Admin-Passwortaenderung (Ausbaustufe
# 4 Task 2). Default passt zum Container-Mountpunkt "/data/.env".
env_file=Path(e("FB_ENV_FILE", "/data/.env")),
grafana_url=e("FB_GRAFANA_URL", "http://localhost:3000"),
)

23
finance/app/db.py Normal file
View File

@@ -0,0 +1,23 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session
from app.config import get_settings
class Base(DeclarativeBase):
pass
_engine = None
def get_engine():
global _engine
if _engine is None:
_engine = create_engine(get_settings().database_url)
return _engine
def get_session():
with Session(get_engine()) as session:
yield session

View File

View File

@@ -0,0 +1,56 @@
import calendar
from dataclasses import dataclass
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
CENT = Decimal("0.01")
@dataclass(frozen=True)
class Installment:
due: date
payment: Decimal
interest: Decimal
principal: Decimal
remaining: Decimal
def add_months(d: date, months: int) -> date:
y, m0 = divmod(d.year * 12 + d.month - 1 + months, 12)
m = m0 + 1
return date(y, m, min(d.day, calendar.monthrange(y, m)[1]))
def annuity_payment(principal: Decimal, annual_rate_pct: Decimal, term_months: int) -> Decimal:
i = annual_rate_pct / Decimal(100) / Decimal(12)
if i == 0:
return (principal / term_months).quantize(CENT, ROUND_HALF_UP)
q = (Decimal(1) + i) ** term_months
return (principal * i * q / (q - Decimal(1))).quantize(CENT, ROUND_HALF_UP)
def loan_schedule(principal: Decimal, annual_rate_pct: Decimal, term_months: int,
payout_date: date, repayment_type: str) -> list[Installment]:
i = annual_rate_pct / Decimal(100) / Decimal(12)
plan: list[Installment] = []
remaining = principal
if repayment_type == "bullet":
for n in range(1, term_months + 1):
interest = (remaining * i).quantize(CENT, ROUND_HALF_UP)
principal_part = remaining if n == term_months else Decimal("0")
remaining = remaining - principal_part
plan.append(Installment(add_months(payout_date, n),
-(interest + principal_part), interest,
principal_part, remaining.quantize(CENT)))
return plan
rate = annuity_payment(principal, annual_rate_pct, term_months)
for n in range(1, term_months + 1):
interest = (remaining * i).quantize(CENT, ROUND_HALF_UP)
principal_part = rate - interest
if n == term_months or principal_part > remaining:
principal_part = remaining
remaining = remaining - principal_part
plan.append(Installment(add_months(payout_date, n),
-(interest + principal_part), interest,
principal_part, remaining.quantize(CENT)))
return plan

View File

@@ -0,0 +1,39 @@
from collections import defaultdict
from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal
@dataclass(frozen=True)
class Projection:
series: list[tuple[date, Decimal]]
low_point: tuple[date, Decimal]
first_below_zero: date | None
first_below_threshold: date | None
def project(start_balance: Decimal, start_date: date,
cashflows: list[tuple[date, Decimal]], horizon_days: int,
threshold: Decimal = Decimal("0")) -> Projection:
end = start_date + timedelta(days=horizon_days)
by_day: dict[date, Decimal] = defaultdict(lambda: Decimal("0"))
for d, amount in cashflows:
if start_date < d <= end:
by_day[d] += amount
series: list[tuple[date, Decimal]] = []
balance = start_balance
low = (start_date, start_balance)
below0: date | None = None
below_t: date | None = None
d = start_date + timedelta(days=1)
while d <= end:
balance += by_day.get(d, Decimal("0"))
series.append((d, balance))
if balance < low[1]:
low = (d, balance)
if below0 is None and balance < 0:
below0 = d
if below_t is None and balance < threshold:
below_t = d
d += timedelta(days=1)
return Projection(series, low, below0, below_t)

View File

@@ -0,0 +1,25 @@
import calendar
from datetime import date
STEP = {"monthly": 1, "quarterly": 3, "yearly": 12}
def _clamp(y: int, m: int, day: int) -> date:
return date(y, m, min(day, calendar.monthrange(y, m)[1]))
def occurrences(rhythm: str, due_day: int, window_start: date, window_end: date,
item_start: date | None = None, item_end: date | None = None) -> list[date]:
step = STEP[rhythm]
lo = max(window_start, item_start) if item_start else window_start
hi = min(window_end, item_end) if item_end else window_end
anchor = item_start or window_start
d = _clamp(anchor.year, anchor.month, due_day)
out: list[date] = []
while d <= hi:
if d >= lo:
out.append(d)
total = d.year * 12 + d.month - 1 + step
y, m0 = divmod(total, 12)
d = _clamp(y, m0 + 1, due_day)
return out

View File

@@ -0,0 +1,96 @@
from dataclasses import dataclass
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from app.engine.loans import Installment
from app.engine.recurrence import occurrences
CENT = Decimal("0.01")
@dataclass(frozen=True)
class PlainRecurring:
id: int
name: str
amount: Decimal
rhythm: str
due_day: int
start_date: date | None
end_date: date | None
category_id: int | None
@dataclass(frozen=True)
class PlainPlanned:
name: str
amount: Decimal
due: date
category_id: int | None
@dataclass(frozen=True)
class PlainModifier:
target_type: str
target_id: int
kind: str
value: Decimal
end_date: date | None = None
def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None,
modifiers: list[PlainModifier]) -> Decimal | None:
for m in modifiers:
hit = ((m.target_type == "category" and category_id == m.target_id)
or (m.target_type == "recurring" and recurring_id == m.target_id))
if not hit:
continue
if m.kind == "remove":
return None
if m.kind == "percent":
amount = (amount * (Decimal(100) - m.value) / Decimal(100)).quantize(CENT, ROUND_HALF_UP)
elif m.kind == "absolute":
if amount < 0:
amount = min(Decimal("0"), amount + m.value)
else:
amount = max(Decimal("0"), amount - m.value)
return amount
def _effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None:
"""Fruehestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren."""
end = r.end_date
for m in modifiers:
if m.kind != "ende" or m.end_date is None:
continue
hit = ((m.target_type == "category" and r.category_id == m.target_id)
or (m.target_type == "recurring" and r.id == m.target_id))
if hit:
end = m.end_date if end is None else min(end, m.end_date)
return end
def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned],
loan_schedules: list[list[Installment]],
loan_payouts: list[tuple[date, Decimal]],
modifiers: list[PlainModifier],
window_start: date, window_end: date) -> list[tuple[date, Decimal]]:
flows: list[tuple[date, Decimal]] = []
for r in recurring:
amount = _modified(r.amount, r.category_id, r.id, modifiers)
if amount is None:
continue
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
r.start_date, _effective_end(r, modifiers)):
flows.append((d, amount))
for p in planned:
amount = _modified(p.amount, p.category_id, None, modifiers)
if amount is not None and window_start <= p.due <= window_end:
flows.append((p.due, amount))
for d, payout in loan_payouts:
if window_start <= d <= window_end:
flows.append((d, payout))
for sched in loan_schedules:
for inst in sched:
if window_start <= inst.due <= window_end:
flows.append((inst.due, inst.payment))
return sorted(flows)

30
finance/app/formats.py Normal file
View File

@@ -0,0 +1,30 @@
"""Deutsche Anzeige-Formate fuer die GUI (Ausbaustufe 5).
Bewusst ohne locale-Modul: Container-Locales sind nicht garantiert
installiert, und die Ausgabe hinge sonst vom Host ab. Reine
Decimal-Formatierung; die JSON-API bleibt Punkt-Dezimal.
"""
from decimal import Decimal, ROUND_HALF_UP
DE_LABELS = {
"monthly": "monatlich",
"quarterly": "vierteljährlich",
"yearly": "jährlich",
"annuity": "Annuität",
"bullet": "endfällig",
"percent": "Prozent",
"absolute": "Absolut",
"remove": "Entfällt",
"ende": "Ende",
}
def eur(value) -> str:
"""Decimal("-2474.5") -> "-2.474,50" (ohne Euro-Zeichen, das setzt das Template)."""
d = Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
s = f"{d:,.2f}" # US-Trenner: "-2,474.50"
return s.replace(",", "\x00").replace(".", ",").replace("\x00", ".")
def de_label(value: str) -> str:
return DE_LABELS.get(value, value)

65
finance/app/main.py Normal file
View File

@@ -0,0 +1,65 @@
import hmac
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from app import auth
from app.auth import require_auth
from app.config import get_settings
from app.db import get_engine
from app.models.views import create_views
from app.routers import (accounts, admin, categories, gui, imports, planning,
scenarios, transactions)
from app.routers.gui import templates
from app.version import get_version
@asynccontextmanager
async def lifespan(app: FastAPI):
create_views(get_engine())
yield
app = FastAPI(title="Finanzberatungs-Tool", lifespan=lifespan)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(gui.router)
app.include_router(accounts.router)
app.include_router(transactions.router)
app.include_router(categories.router)
app.include_router(imports.router)
app.include_router(planning.router)
app.include_router(scenarios.router)
app.include_router(admin.router)
@app.get("/login")
def login_form(request: Request):
return templates.TemplateResponse(request, "login.html", {})
@app.post("/login")
def login(username: str = Form(...), password: str = Form(...)):
s = get_settings()
if not (hmac.compare_digest(username, s.gui_user)
and auth.verify_password(password, auth.current_password_hash())):
return HTMLResponse("Login fehlgeschlagen", status_code=401)
resp = RedirectResponse("/", status_code=303)
resp.set_cookie(auth.COOKIE, auth.make_session_token(), httponly=True,
max_age=auth.MAX_AGE, samesite="lax")
return resp
@app.post("/logout")
def logout():
resp = RedirectResponse("/login", status_code=303)
resp.delete_cookie(auth.COOKIE)
return resp
@app.get("/api/version", dependencies=[Depends(require_auth)])
def api_version():
return {"version": get_version()}

View File

@@ -0,0 +1 @@
from app.models import tables # noqa: F401

View File

@@ -0,0 +1,158 @@
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
MONEY = Numeric(12, 2)
class Account(Base):
__tablename__ = "accounts"
id: Mapped[int] = mapped_column(primary_key=True)
bank: Mapped[str] = mapped_column(String(50))
iban: Mapped[str] = mapped_column(String(34), unique=True)
name: Mapped[str] = mapped_column(String(100))
type: Mapped[str] = mapped_column(String(20), default="giro")
# Konto-Saldo-Anker (Ausbaustufe 3 Task 1): ersetzt die bisherige
# Statement-closing-Logik als Basis der Saldenrechnung. Beide Felder sind
# nur gemeinsam gesetzt oder gemeinsam NULL (durchgesetzt in
# app.routers.accounts.AccountPatch).
anchor_date: Mapped[date | None] = mapped_column(Date, nullable=True)
anchor_balance: Mapped[Decimal | None] = mapped_column(MONEY, nullable=True)
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=True)
class CategoryRule(Base):
__tablename__ = "category_rules"
id: Mapped[int] = mapped_column(primary_key=True)
pattern: Mapped[str] = mapped_column(String(200))
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"))
priority: Mapped[int] = mapped_column(Integer, default=100)
class Statement(Base):
__tablename__ = "statements"
id: Mapped[int] = mapped_column(primary_key=True)
filename: Mapped[str] = mapped_column(String(255))
bank: Mapped[str] = mapped_column(String(50))
account_id: Mapped[int | None] = mapped_column(ForeignKey("accounts.id"), nullable=True)
period_start: Mapped[date | None] = mapped_column(Date, nullable=True)
period_end: Mapped[date | None] = mapped_column(Date, nullable=True)
opening_balance: Mapped[Decimal | None] = mapped_column(MONEY, nullable=True)
closing_balance: Mapped[Decimal | None] = mapped_column(MONEY, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="draft")
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
class Transaction(Base):
__tablename__ = "transactions"
id: Mapped[int] = mapped_column(primary_key=True)
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"))
statement_id: Mapped[int | None] = mapped_column(ForeignKey("statements.id"), nullable=True)
booking_date: Mapped[date] = mapped_column(Date)
value_date: Mapped[date | None] = mapped_column(Date, nullable=True)
amount: Mapped[Decimal] = mapped_column(MONEY)
purpose: Mapped[str] = mapped_column(Text, default="")
counterparty: Mapped[str] = mapped_column(String(200), default="")
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="draft")
dedup_hash: Mapped[str] = mapped_column(String(64), index=True)
is_duplicate: Mapped[bool] = mapped_column(Boolean, default=False)
class RecurringItem(Base):
__tablename__ = "recurring_items"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(200))
amount: Mapped[Decimal] = mapped_column(MONEY)
rhythm: Mapped[str] = mapped_column(String(20))
due_day: Mapped[int] = mapped_column(Integer)
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
class PlannedItem(Base):
__tablename__ = "planned_items"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(200))
amount: Mapped[Decimal] = mapped_column(MONEY)
due: Mapped[date] = mapped_column(Date)
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
class Loan(Base):
__tablename__ = "loans"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(200))
principal: Mapped[Decimal] = mapped_column(MONEY)
annual_rate_pct: Mapped[Decimal] = mapped_column(Numeric(5, 2))
term_months: Mapped[int] = mapped_column(Integer)
payout_date: Mapped[date] = mapped_column(Date)
repayment_type: Mapped[str] = mapped_column(String(20), default="annuity")
class Scenario(Base):
__tablename__ = "scenarios"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(200), unique=True)
description: Mapped[str] = mapped_column(Text, default="")
include_recurring: Mapped[bool] = mapped_column(Boolean, default=True)
include_planned: Mapped[bool] = mapped_column(Boolean, default=True)
class ScenarioLoan(Base):
__tablename__ = "scenario_loans"
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), primary_key=True)
loan_id: Mapped[int] = mapped_column(ForeignKey("loans.id"), primary_key=True)
class ScenarioModifier(Base):
__tablename__ = "scenario_modifiers"
id: Mapped[int] = mapped_column(primary_key=True)
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"))
target_type: Mapped[str] = mapped_column(String(20))
target_id: Mapped[int] = mapped_column(Integer)
kind: Mapped[str] = mapped_column(String(20))
value: Mapped[Decimal] = mapped_column(MONEY, default=Decimal("0"))
# Nur fuer kind="ende" gesetzt: Datum, an dem der Ziel-Posten innerhalb
# dieses Szenarios endet (Ausbaustufe 5).
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
class ScenarioPlannedItem(Base):
"""Einmalzahlung, die NUR in einem bestimmten Szenario zaehlt -
unabhaengig von Scenario.include_planned (das steuert nur die globalen
PlannedItems). Ausbaustufe 5."""
__tablename__ = "scenario_planned_items"
id: Mapped[int] = mapped_column(primary_key=True)
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), index=True)
name: Mapped[str] = mapped_column(String(200))
amount: Mapped[Decimal] = mapped_column(MONEY)
due: Mapped[date] = mapped_column(Date)
class ProjectionPoint(Base):
__tablename__ = "projection_points"
id: Mapped[int] = mapped_column(primary_key=True)
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), index=True)
day: Mapped[date] = mapped_column(Date)
balance: Mapped[Decimal] = mapped_column(MONEY)
class ProjectionResult(Base):
__tablename__ = "projection_results"
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), primary_key=True)
computed_at: Mapped[datetime] = mapped_column(DateTime)
low_point_date: Mapped[date] = mapped_column(Date)
low_point_balance: Mapped[Decimal] = mapped_column(MONEY)
below_zero_date: Mapped[date | None] = mapped_column(Date, nullable=True)
below_threshold_date: Mapped[date | None] = mapped_column(Date, nullable=True)

132
finance/app/models/views.py Normal file
View File

@@ -0,0 +1,132 @@
from sqlalchemy import text
from sqlalchemy.engine import Engine
# Per-account offset for the Konto-Saldo-Anker (Ausbaustufe 3 Task 1): the
# same math as app.services.balances.account_balance, expressed as a single
# constant "base" per account so it can be added to a plain (anchor-agnostic)
# running sum of transaction amounts:
#
# balance(day) = anchor_balance + Sum(tx: anchor_date < booking_date <= day)
# - Sum(tx: day < booking_date <= anchor_date)
# = anchor_balance - cumsum(<=anchor_date) + cumsum(<=day)
# = base + cumsum(<=day)
#
# ... which holds for `day` both before and after anchor_date, since the
# subtracted/added transaction windows collapse into the same telescoping
# sum. Accounts without an anchor get base=0 (via COALESCE below), matching
# account_balance's "Ohne Anker: Summe ab 0" fallback.
_ACCOUNT_OFFSET = """
SELECT a.id AS account_id,
a.anchor_balance - COALESCE((
SELECT SUM(t2.amount) FROM transactions t2
WHERE t2.account_id = a.id AND t2.status = 'confirmed'
AND t2.booking_date <= a.anchor_date
), 0) AS base
FROM accounts a
WHERE a.anchor_date IS NOT NULL AND a.anchor_balance IS NOT NULL
"""
# Per-account earliest day the daily series needs to start at: the earlier of
# the account's first confirmed booking and its anchor_date (an anchor can
# predate the first booking, e.g. an anchor set before any import). Accounts
# with neither an anchor nor any confirmed booking have no meaningful start
# and are excluded (nothing to plot).
_ACCOUNT_START = """
SELECT a.id AS account_id, a.name AS account,
LEAST(
COALESCE(a.anchor_date, ft.first_date),
COALESCE(ft.first_date, a.anchor_date)
) AS start_date
FROM accounts a
LEFT JOIN (
SELECT account_id, MIN(booking_date) AS first_date
FROM transactions WHERE status = 'confirmed'
GROUP BY account_id
) ft ON ft.account_id = a.id
WHERE a.anchor_date IS NOT NULL OR ft.first_date IS NOT NULL
"""
VIEWS: dict[str, str] = {
# Daily (gap-free) balance per account: one row per calendar day from the
# account's start date (see _ACCOUNT_START) through CURRENT_DATE. The
# cumulative SUM ... OVER (... ROWS UNBOUNDED PRECEDING) carries the
# previous day's balance forward on days without any confirmed booking,
# matching services.balances.account_balance's per-Tagesende semantics
# (base + cumulated amounts up to and including that day).
"v_balance_history": f"""
WITH offsets AS ({_ACCOUNT_OFFSET}),
bounds AS ({_ACCOUNT_START}),
days AS (
SELECT b.account_id, b.account, gs.day::date AS day
FROM bounds b
CROSS JOIN LATERAL generate_series(
b.start_date, CURRENT_DATE, interval '1 day') AS gs(day)
),
daily_tx AS (
SELECT account_id, booking_date AS day, SUM(amount) AS day_amount
FROM transactions WHERE status = 'confirmed'
GROUP BY account_id, booking_date
)
SELECT d.day, d.account,
COALESCE(o.base, 0)
+ SUM(COALESCE(dt.day_amount, 0)) OVER (
PARTITION BY d.account_id ORDER BY d.day
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS balance
FROM days d
LEFT JOIN daily_tx dt ON dt.account_id = d.account_id AND dt.day = d.day
LEFT JOIN offsets o ON o.account_id = d.account_id""",
# Daily (gap-free) total balance across all accounts: same carry-forward
# logic as v_balance_history, but summed globally (equivalent to summing
# each account's own base + cumulated amounts, since a cumulative sum of
# a union of per-day amounts equals the sum of per-account cumulative
# sums). The day series spans from the earliest account start date
# through CURRENT_DATE.
"v_balance_total": f"""
WITH offsets AS ({_ACCOUNT_OFFSET}),
bounds AS ({_ACCOUNT_START}),
days AS (
SELECT gs.day::date AS day
FROM (SELECT MIN(start_date) AS start_date FROM bounds) b
CROSS JOIN LATERAL generate_series(
b.start_date, CURRENT_DATE, interval '1 day') AS gs(day)
),
daily_tx AS (
SELECT booking_date AS day, SUM(amount) AS day_amount
FROM transactions WHERE status = 'confirmed'
GROUP BY booking_date
)
SELECT d.day,
(SELECT COALESCE(SUM(base), 0) FROM offsets)
+ SUM(COALESCE(dt.day_amount, 0)) OVER (
ORDER BY d.day
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS balance
FROM days d
LEFT JOIN daily_tx dt ON dt.day = d.day""",
"v_monthly_by_category": """
SELECT date_trunc('month', t.booking_date) AS month,
COALESCE(c.name, 'unkategorisiert') AS category,
SUM(t.amount) AS total
FROM transactions t LEFT JOIN categories c ON c.id = t.category_id
WHERE t.status = 'confirmed' GROUP BY 1, 2""",
"v_projection": """
SELECT s.name AS scenario, p.day, p.balance
FROM projection_points p JOIN scenarios s ON s.id = p.scenario_id""",
}
def create_views(engine: Engine) -> None:
if engine.dialect.name != "postgresql":
return
with engine.begin() as conn:
for name, body in VIEWS.items():
# CREATE OR REPLACE VIEW fails if the output column set changed
# (name/type/order). Fall back to DROP + CREATE in that case so a
# redeploy always applies the current definition.
try:
with conn.begin_nested():
conn.execute(
text(f"CREATE OR REPLACE VIEW {name} AS {body}"))
except Exception:
conn.execute(text(f"DROP VIEW IF EXISTS {name} CASCADE"))
conn.execute(text(f"CREATE VIEW {name} AS {body}"))

View File

View File

@@ -0,0 +1,49 @@
import re
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
class ParserError(Exception):
pass
@dataclass(frozen=True)
class ParsedTransaction:
booking_date: date
value_date: date | None
amount: Decimal
purpose: str
counterparty: str
@dataclass
class ParsedStatement:
bank: str
iban: str | None
period_start: date | None
period_end: date | None
opening_balance: Decimal | None
closing_balance: Decimal | None
transactions: list[ParsedTransaction] = field(default_factory=list)
def parse_german_amount(s: str) -> Decimal:
s = s.strip().replace(" ", "").replace("\xa0", "")
neg = s.endswith("-") or s.startswith("-") or s.endswith("S")
s = s.strip("+-SH")
value = Decimal(s.replace(".", "").replace(",", "."))
return -value if neg else value
def parse_german_date(s: str, default_year: int | None = None) -> date:
m = re.fullmatch(r"(\d{2})\.(\d{2})\.(\d{4})?", s.strip())
if not m:
raise ParserError(f"Kein Datum: {s!r}")
year = int(m.group(3)) if m.group(3) else default_year
if year is None:
raise ParserError(f"Jahr fehlt: {s!r}")
try:
return date(year, int(m.group(2)), int(m.group(1)))
except ValueError:
raise ParserError(f"Ungültiges Datum: {s!r}")

View File

@@ -0,0 +1,245 @@
"""Parser für Kontoumsatz-CSV-Exporte (DKB, VR, HVB).
Siehe „CSV-Formatreferenz" in docs/superpowers/plans/2026-07-19-ausbaustufe-3.md
für die verbindliche Spalten-/Formatspezifikation je Bank.
"""
import csv
import io
import re
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from pathlib import Path
from app.parsers.base import (ParsedStatement, ParsedTransaction, ParserError,
parse_german_amount, parse_german_date)
RE_KONTOSTAND = re.compile(r"Kontostand vom (\d{2}\.\d{2}\.\d{4}):")
RE_ZEITRAUM = re.compile(r"(\d{2}\.\d{2}\.\d{4})\s*-\s*(\d{2}\.\d{2}\.\d{4})")
@dataclass
class ParsedCsv:
statement: ParsedStatement
anchor: tuple[date, Decimal] | None
balance_checkable: bool
def _detect_encoding(raw: bytes) -> str:
if raw.startswith(b"\xef\xbb\xbf"):
return "utf-8-sig"
if raw.startswith(b"\xff\xfe"):
return "utf-16-le"
if raw.startswith(b"\xfe\xff"):
return "utf-16-be"
sample = raw[:64]
if b"\x00" in sample:
even_zeros = sum(1 for i in range(0, len(sample), 2) if sample[i] == 0)
odd_zeros = sum(1 for i in range(1, len(sample), 2) if i < len(sample) and sample[i] == 0)
return "utf-16-be" if even_zeros > odd_zeros else "utf-16-le"
return "utf-8"
def _decode(raw: bytes) -> str:
enc = _detect_encoding(raw)
text = raw.decode(enc, errors="ignore")
if text.startswith(""):
text = text[1:]
return text
def _strip_currency(s: str) -> str:
return s.replace("", "").replace("EUR", "").replace("\xa0", " ").strip()
def _parse_amount(s: str) -> Decimal:
return parse_german_amount(_strip_currency(s))
def _parse_two_digit_year_date(s: str) -> date:
m = re.fullmatch(r"(\d{2})\.(\d{2})\.(\d{2})", s.strip())
if not m:
raise ParserError(f"DKB-CSV: Kein Datum im Format TT.MM.JJ: {s!r}")
day, month, yy = int(m.group(1)), int(m.group(2)), int(m.group(3))
year = 2000 + yy if yy <= 69 else 1900 + yy
try:
return date(year, month, day)
except ValueError:
raise ParserError(f"DKB-CSV: Ungueltiges Datum: {s!r}")
def detect_csv_format(head_bytes: bytes) -> str | None:
try:
text = _decode(head_bytes)
except (LookupError, UnicodeError):
return None
lines = text.splitlines()
if not lines:
return None
first = lines[0]
if first.startswith("Kontonummer;") and "Buchungsdatum" in first and "Valuta" in first \
and "Betrag" in first:
return "hvb_csv"
if first.startswith("Bezeichnung Auftragskonto;") and "IBAN Auftragskonto" in first:
return "vr_csv"
head = "\n".join(lines[:6])
if "Kontostand vom" in head and "Buchungsdatum" in head and "Zahlungsempf" in head:
return "dkb_csv"
return None
def parse_csv(path: Path) -> ParsedCsv:
raw = path.read_bytes()
fmt = detect_csv_format(raw)
if fmt is None:
raise ParserError("CSV-Format nicht erkannt")
text = _decode(raw)
if fmt == "dkb_csv":
return _parse_dkb(text)
if fmt == "vr_csv":
return _parse_vr(text)
return _parse_hvb(text)
def _rows(text: str) -> list[list[str]]:
return list(csv.reader(io.StringIO(text), delimiter=";", quotechar='"'))
def _parse_dkb(text: str) -> ParsedCsv:
rows = _rows(text)
if len(rows) < 2:
raise ParserError("DKB-CSV: Datei zu kurz")
iban = rows[0][1].strip() if len(rows[0]) > 1 else None
period_start = period_end = None
kontostand_date: date | None = None
kontostand_amount: Decimal | None = None
header_idx = None
for i, row in enumerate(rows):
if row and row[0].strip() == "Zeitraum:" and len(row) > 1:
m = RE_ZEITRAUM.search(row[1])
if m:
period_start = parse_german_date(m.group(1))
period_end = parse_german_date(m.group(2))
if row and RE_KONTOSTAND.search(row[0] or "") and len(row) > 1:
m = RE_KONTOSTAND.search(row[0])
kontostand_date = parse_german_date(m.group(1))
kontostand_amount = _parse_amount(row[1])
if row and row[0].strip() == "Buchungsdatum":
header_idx = i
break
if header_idx is None:
raise ParserError("DKB-CSV: Spaltenkopf nicht gefunden")
if kontostand_date is None or kontostand_amount is None:
raise ParserError("DKB-CSV: Kontostand-Zeile nicht gefunden")
header = [h.strip() for h in rows[header_idx]]
idx = {name: i for i, name in enumerate(header)}
required = ["Buchungsdatum", "Wertstellung", "Status", "Zahlungspflichtige*r",
"Zahlungsempfänger*in", "Verwendungszweck", "Umsatztyp", "Betrag (€)"]
for col in required:
if col not in idx:
raise ParserError(f"DKB-CSV: Spalte fehlt: {col}")
txs: list[ParsedTransaction] = []
for row in rows[header_idx + 1:]:
if not row or len(row) < len(header):
continue
if row[idx["Status"]].strip() != "Gebucht":
continue
amount = _parse_amount(row[idx["Betrag (€)"]])
counterparty = (row[idx["Zahlungsempfänger*in"]] if amount < 0
else row[idx["Zahlungspflichtige*r"]]).strip()
purpose = f"{row[idx['Umsatztyp']].strip()} {row[idx['Verwendungszweck']].strip()}".strip()
booking = _parse_two_digit_year_date(row[idx["Buchungsdatum"]])
value = _parse_two_digit_year_date(row[idx["Wertstellung"]])
txs.append(ParsedTransaction(booking_date=booking, value_date=value, amount=amount,
purpose=purpose, counterparty=counterparty))
txs.reverse() # file is sorted descending -> chronologically ascending
stmt = ParsedStatement(bank="dkb_csv", iban=iban, period_start=period_start,
period_end=period_end, opening_balance=None,
closing_balance=None, transactions=txs)
return ParsedCsv(statement=stmt, anchor=(kontostand_date, kontostand_amount),
balance_checkable=False)
def _parse_vr(text: str) -> ParsedCsv:
rows = _rows(text)
if not rows:
raise ParserError("VR-CSV: Datei leer")
header = [h.strip() for h in rows[0]]
idx = {name: i for i, name in enumerate(header)}
required = ["IBAN Auftragskonto", "Buchungstag", "Valutadatum", "Name Zahlungsbeteiligter",
"Buchungstext", "Verwendungszweck", "Betrag", "Saldo nach Buchung"]
for col in required:
if col not in idx:
raise ParserError(f"VR-CSV: Spalte fehlt: {col}")
data_rows = [(i, row) for i, row in enumerate(rows[1:], start=2) if row and len(row) >= len(header)]
if not data_rows:
raise ParserError("VR-CSV: keine Buchungszeilen gefunden")
iban = rows[1][idx["IBAN Auftragskonto"]].strip() if len(rows) > 1 else None
# File is sorted descending (newest first) -> reverse for chronological ascending.
asc = list(reversed(data_rows))
txs: list[ParsedTransaction] = []
saldi: list[Decimal] = []
prev_saldo: Decimal | None = None
for line_no, row in asc:
amount = _parse_amount(row[idx["Betrag"]])
saldo = _parse_amount(row[idx["Saldo nach Buchung"]])
if prev_saldo is not None and prev_saldo + amount != saldo:
raise ParserError(f"VR-CSV: Saldo-Kette inkonsistent bei Zeile {line_no}")
prev_saldo = saldo
booking = parse_german_date(row[idx["Buchungstag"]])
value = parse_german_date(row[idx["Valutadatum"]])
purpose = f"{row[idx['Buchungstext']].strip()} {row[idx['Verwendungszweck']].strip()}".strip()
counterparty = row[idx["Name Zahlungsbeteiligter"]].strip()
txs.append(ParsedTransaction(booking_date=booking, value_date=value, amount=amount,
purpose=purpose, counterparty=counterparty))
saldi.append(saldo)
opening_balance = saldi[0] - txs[0].amount
closing_balance = saldi[-1]
anchor = (txs[-1].booking_date, closing_balance)
stmt = ParsedStatement(bank="vr_csv", iban=iban,
period_start=txs[0].booking_date, period_end=txs[-1].booking_date,
opening_balance=opening_balance, closing_balance=closing_balance,
transactions=txs)
return ParsedCsv(statement=stmt, anchor=anchor, balance_checkable=True)
def _parse_hvb(text: str) -> ParsedCsv:
rows = _rows(text)
if not rows:
raise ParserError("HVB-CSV: Datei leer")
header = [h.strip() for h in rows[0]]
idx = {name: i for i, name in enumerate(header)}
required = ["Kontonummer", "Buchungsdatum", "Valuta", "Verwendungszweck", "Betrag"]
for col in required:
if col not in idx:
raise ParserError(f"HVB-CSV: Spalte fehlt: {col}")
data_rows = [row for row in rows[1:] if row and len(row) >= len(header)]
if not data_rows:
raise ParserError("HVB-CSV: keine Buchungszeilen gefunden")
kontonummer = data_rows[0][idx["Kontonummer"]].strip()
txs: list[ParsedTransaction] = []
for row in data_rows: # already chronologically ascending
booking = parse_german_date(row[idx["Buchungsdatum"]])
value = parse_german_date(row[idx["Valuta"]])
amount = _parse_amount(row[idx["Betrag"]])
purpose = row[idx["Verwendungszweck"]].strip()
txs.append(ParsedTransaction(booking_date=booking, value_date=value, amount=amount,
purpose=purpose, counterparty=""))
stmt = ParsedStatement(bank="hvb_csv", iban=kontonummer,
period_start=txs[0].booking_date, period_end=txs[-1].booking_date,
opening_balance=None, closing_balance=None, transactions=txs)
return ParsedCsv(statement=stmt, anchor=None, balance_checkable=False)

View File

@@ -0,0 +1,12 @@
MARKERS = [
("vr", ["Volksbank", "Raiffeisenbank", "VR-Bank", "VR Bank"]),
("hvb", ["HypoVereinsbank", "UniCredit", "Unicredit", "HYVEDEMM"]),
("dkb", ["Deutsche Kreditbank", "DKB"]),
]
def detect_bank(text: str) -> str | None:
for bank, needles in MARKERS:
if any(n in text for n in needles):
return bank
return None

184
finance/app/parsers/dkb.py Normal file
View File

@@ -0,0 +1,184 @@
"""Parser für DKB (Deutsche Kreditbank)-Kontoauszüge.
Realer Fixture-Abgleich (Task 7, Schritt 4): Der eigentliche Auszugszeitraum
steht nicht als "vom X bis Y"-Satz im Text (dieser Wortlaut kommt nur in einem
separaten quartalsweisen Zinsabschluss-Absatz mit abweichendem Zeitraum vor);
er wird daher aus den Datumsangaben der beiden Kontostand-Zeilen abgeleitet.
Buchungszeilen haben nur eine Datumsspalte, die ohne Leerzeichen direkt am
Buchungstext klebt, und der Betrag trägt ein optionales führendes Minus statt
eines nachgestellten S/H-Kürzels. Der Anfangssaldo trägt den Zusatz
", Auszug Nr. N", der Endsaldo den Zusatz "um HH:MM Uhr" beides
unterscheidet die echten Saldo-Zeilen von einer abweichend formulierten
Zwischensumme in einem quartalsweisen Rechnungsabschluss-Anhang.
"""
import re
from datetime import date
from pathlib import Path
import pdfplumber
from app.parsers.base import (ParsedStatement, ParsedTransaction, ParserError,
parse_german_amount, parse_german_date)
BANK = "dkb"
RE_IBAN = re.compile(r"\b([A-Z]{2}\d{2}(?:\s?\d{4}){4,5}(?:\s?\d{1,2})?)\b")
# "Kontostand am 01.06.2020, Auszug Nr. 1 1.234,56"
RE_OPENING = re.compile(r"Kontostand\s+am\s+(\d{2}\.\d{2}\.\d{4}),\s*Auszug\s+Nr\.\s*\d+\s+(-?[\d.,]+)", re.I)
# "Kontostand am 30.06.2020 um 12:00 Uhr -2.345,67"
RE_CLOSING = re.compile(r"Kontostand\s+am\s+(\d{2}\.\d{2}\.\d{4})\s+um\s+\d{2}:\d{2}\s*Uhr\s+(-?[\d.,]+)", re.I)
# Buchungszeile: "01.06.2020Überweisung -123,45" (Datum direkt am Text, kein Leerzeichen)
RE_TX_HEAD = re.compile(r"^(\d{2}\.\d{2}\.\d{4})(\S.*?)\s+(-?[\d.,]+)$")
# Wiederkehrender Seitenfuß (Bank-Impressum) und Folgeseiten-Kopf; rein
# strukturelle Muster (keine Kontodaten), damit dieser Block nicht als
# Verwendungszweck der jeweils letzten Buchung einer Seite eingesammelt wird.
RE_NOISE = re.compile(
r"(?:Kreditbank AG" # Impressum-Zeile 1
r"|Taubenstraße" # Impressum-Zeile 2 (Anschrift)
r"|^\d{5}\s+Berlin\b" # Impressum-Zeile 3 (PLZ Ort)
r"|Handelsregister" # Impressum-Zeile 4
r"|^Ein Unternehmen" # Impressum-Zeile 5
r"|Landesbank" # Impressum-Zeile 6
r"|^Kontoauszug\s+\d+/\d+\s+Seite" # Folgeseiten-Kopf
r"|^Girokonto\s+\d" # Folgeseiten-Kopf (Kontozeile)
r"|^Datum\s+Erläuterung" # Spaltenüberschrift
r")"
)
def _is_noise(line: str) -> bool:
return bool(RE_NOISE.search(line))
# --- Empfänger/Zweck-Split-Heuristik (D-T5-1/D-T5-2, siehe
# .superpowers/sdd/parser-vs-csv-audit.md) --------------------------------
# extras[0] enthält "<Empfänger><Leerzeichen><Beginn Verwendungszweck>" ohne
# Trenner. Die folgenden Regeln bestimmen, wo der Empfänger endet.
_LEGAL_FORM_TOKENS = {
"ag", "gmbh", "ev", "eg", "kg", "bv", "sca", "sarl", "se",
"ltd", "inc", "ek", "ohg", "gbr", "stiftung", "bank",
}
_CONNECTOR_TOKENS = {"et", "cie"}
_REF_KEYWORDS = {"rechnung", "vertrag", "kundennummer", "kassenzeichen", "rg", "nr"}
_REF_KEYWORD_PREFIXES = ("kd.-nr", "kdnr", "beitragsnr")
_RE_TOKEN_DIGIT = re.compile(r"\d")
_MAX_LEGAL_FORM_SCAN = 7
def _normalize_token(tok: str) -> str:
return tok.strip(",;").lower().replace(".", "")
def _is_legal_form(tok: str) -> bool:
return _normalize_token(tok) in _LEGAL_FORM_TOKENS
def _is_connector(tok: str) -> bool:
return _normalize_token(tok) in _CONNECTOR_TOKENS
def _is_reference_keyword(tok: str) -> bool:
norm = _normalize_token(tok)
return norm in _REF_KEYWORDS or norm.startswith(_REF_KEYWORD_PREFIXES)
def _split_counterparty(line: str) -> tuple[str, str]:
"""Trennt Empfänger von Zweck-Beginn in der ersten Fortsetzungszeile
(extras[0]) einer DKB-Buchung. Gibt (counterparty, purpose_prefix)
zurück; purpose_prefix ist der abgeschnittene Rest (kann leer sein).
Reihenfolge (D-T5-1):
1. Rechtsform-Grenze (erste ~7 Tokens; "et Cie"-Ketten bis zum letzten
Rechtsform-Token in Folge).
2. Referenz-Grenze (erstes Token mit Ziffer oder Referenz-Schlüsselwort,
frühestens ab Token 1 -> counterparty nie leer).
3. Kein Schnitt (reine Wort-Folge, semantisch nicht trennbar).
D-T5-2: "siehe Anlage"-Zeilen -> counterparty leer, ganze Zeile in den
Zweck.
"""
line = line.strip()
if not line:
return "", ""
if line.lower().startswith("siehe anlage"):
return "", line
tokens = line.split()
n = len(tokens)
for i in range(min(n, _MAX_LEGAL_FORM_SCAN)):
if not _is_legal_form(tokens[i]):
continue
last = i
j = i + 1
while j < n and (_is_connector(tokens[j]) or _is_legal_form(tokens[j])):
if _is_legal_form(tokens[j]):
last = j
j += 1
cut = last + 1
return " ".join(tokens[:cut]), " ".join(tokens[cut:])
for idx in range(1, n):
if _RE_TOKEN_DIGIT.search(tokens[idx]) or _is_reference_keyword(tokens[idx]):
return " ".join(tokens[:idx]), " ".join(tokens[idx:])
return line, ""
def parse(path: Path) -> ParsedStatement:
with pdfplumber.open(path) as pdf:
text = "\n".join((page.extract_text() or "") for page in pdf.pages)
opening = RE_OPENING.search(text)
closing = RE_CLOSING.search(text)
if not opening or not closing:
raise ParserError("DKB: Anfangs-/Endsaldo nicht gefunden")
period_start = parse_german_date(opening.group(1))
period_end = parse_german_date(closing.group(1))
iban_m = RE_IBAN.search(text)
txs: list[ParsedTransaction] = []
pending: dict | None = None
for line in text.splitlines():
line = line.strip()
if not line:
continue
# Schlusssaldo erreicht: laufende Buchung abschließen und Sammlung beenden
# (danach folgen nur noch Summen, Rechnungsabschluss und Hinweise).
if RE_CLOSING.search(line):
break
m = RE_TX_HEAD.match(line)
if m:
if pending:
txs.append(_finish(pending))
booking = parse_german_date(m.group(1))
pending = {
"booking": booking,
"value": booking,
"head": m.group(2).strip(),
"amount": parse_german_amount(m.group(3)),
"extra": [],
}
continue
if pending and not RE_OPENING.search(line) and not _is_noise(line):
pending["extra"].append(line)
if pending:
txs.append(_finish(pending))
return ParsedStatement(bank=BANK,
iban=iban_m.group(1).replace(" ", "") if iban_m else None,
period_start=period_start, period_end=period_end,
opening_balance=parse_german_amount(opening.group(2)),
closing_balance=parse_german_amount(closing.group(2)),
transactions=txs)
def _finish(p: dict) -> ParsedTransaction:
if p["extra"]:
counterparty, remainder = _split_counterparty(p["extra"][0])
purpose_parts = [p["head"]]
if remainder:
purpose_parts.append(remainder)
purpose_parts.extend(p["extra"][1:])
else:
counterparty = ""
purpose_parts = [p["head"]]
purpose = " ".join(purpose_parts).strip()
return ParsedTransaction(booking_date=p["booking"], value_date=p["value"],
amount=p["amount"], purpose=purpose,
counterparty=counterparty)

143
finance/app/parsers/hvb.py Normal file
View File

@@ -0,0 +1,143 @@
"""Parser für HypoVereinsbank (UniCredit)-Kontoauszüge.
Realer Fixture-Abgleich (Task 7, Schritt 4): Das Layout weicht strukturell vom
generischen Platzhalter ab, nicht nur in der Wortwahl:
- Eine Buchung besteht aus zwei Zeilen: Zeile 1 = Buchungsdatum + Vorgangsart +
Saldo/Umsatz-Betrag mit direkt angehängtem Vorzeichen (kein Leerzeichen vor
"+"/"-"); Zeile 2 = Wertstellungsdatum + Buchungsinformation (Text kann
leer sein, dann folgt nur ein Datum ohne weiteren Text). Fehlt Zeile 2 ganz
(z. B. Seitenumbruch mitten in der Buchung), gilt das als Parserfehler statt
eine unvollständige Buchung zu erzeugen.
- Mehrseitige Auszüge wiederholen Kopfzeilen und Zwischensummen
("Übertrag: EUR ...", "Saldo per DATUM: EUR ..."), die nicht mit dem
tatsächlichen Anfangs-/Endsaldo verwechselt werden dürfen und aus dem
Verwendungszweck herausgefiltert werden.
- Der Auszugszeitraum steht nur in der wiederholten Kopfzeile als
"<Start> - <Ende> <Auszug-Nr.> <Seite>".
"""
import re
from datetime import date
from pathlib import Path
import pdfplumber
from app.parsers.base import (ParsedStatement, ParsedTransaction, ParserError,
parse_german_amount, parse_german_date)
BANK = "hvb"
RE_IBAN = re.compile(r"\b([A-Z]{2}\d{2}(?:\s?\d{4}){4,5}(?:\s?\d{1,2})?)\b")
# Kopfzeile pro Seite: "... <Zeitraum-Start> - <Zeitraum-Ende> <Auszug-Nr> <Seite>"
RE_PERIOD = re.compile(r"(\d{2}\.\d{2}\.\d{4})\s*-\s*(\d{2}\.\d{2}\.\d{4})\s+\d+\s+\d+\s*$", re.M)
# "Ihr alter Kontostand per 01.06.2020: EUR 1.234,56+"
RE_OPENING = re.compile(r"Ihr\s+alter\s+Kontostand\s+per\s+\d{2}\.\d{2}\.\d{4}:\s*EUR\s+([\d.,]+[+-]?)", re.I)
# "Ihr neuer Kontostand: EUR 2.345,67+" (Zwischensummen heißen "Saldo per ...:" und matchen hier nicht)
RE_CLOSING = re.compile(r"Ihr\s+neuer\s+Kontostand:\s*EUR\s+([\d.,]+[+-]?)", re.I)
# Buchungszeile Teil 1: "01.06. LASTSCHRIFT 123,45-" (Buchungsdatum, Vorgang, Betrag+Vorzeichen ohne Leerzeichen)
RE_TX_HEAD = re.compile(r"^(\d{2}\.\d{2}\.)\s+(.+?)\s+([\d.,]+[+-])$")
# Buchungszeile Teil 2: "01.06. Muster GmbH" (Wertdatum, Buchungsinformation; Text darf leer sein)
RE_TX_DETAIL = re.compile(r"^(\d{2}\.\d{2}\.)\s*(.*)$")
# Zwischensummen/Übertrag zwischen Seiten, dürfen nicht in den Verwendungszweck rutschen
RE_CARRYOVER = re.compile(r"^(?:Übertrag|Saldo\s+per)\b", re.I)
# Wiederkehrende Seiten-Kopf-/Fußzeilen (Titel, Spaltenköpfe, Fußnoten), ebenfalls kein Buchungstext
RE_PAGE_NOISE = re.compile(
r"^(?:K\s?O\s?N\s?T\s?O\s?A\s?U\s?S\s?Z\s?U\s?G"
r"|Kontonummer\s+Bankleitzahl"
r"|IBAN\s+BIC"
r"|Kontoinhaber\s+Ansprechpartner"
r"|Buchung/Wert\s+Buchungsinformation"
r"|Bitte\s+R(?:ü|ue)ckseite\s+beachten"
r"|Seite\s+\d+\s+von\s+\d+)",
re.I,
)
# Weitere strukturelle Kopf-/Fußzeilen am Seitenwechsel (keine Kontodaten hart
# codiert): Formularkennung, Ansprechpartner-Zeile, zusammengesetzte IBAN+BIC-Zeile.
RE_HEADER_EXTRA = re.compile(
r"(?:Smart Banking Team" # Ansprechpartner-/Kopfzeile
r"|^\d{4}\.\d{2}/[A-Z]\d" # Formularkennung (z. B. 5202.70/C0..)
r"|^DE\d{2}\s\d{8}\s\d{10}\s[A-Z]{8}" # IBAN + BIC in einer Zeile
r")"
)
# Ganzseitiger Hinweis-/Rückseitenblock zwischen den Buchungsseiten: als
# Sequenz vom Startsatz bis zur nächsten "Seite N von M"-Zeile überspringen.
RE_HINT_START = re.compile(r"^Wir bitten Sie")
RE_SEITE = re.compile(r"^Seite\s+\d+\s+von\s+\d+")
def _is_noise(line: str) -> bool:
return bool(RE_OPENING.search(line) or RE_CLOSING.search(line)
or RE_CARRYOVER.match(line) or RE_PAGE_NOISE.match(line)
or RE_HEADER_EXTRA.search(line) or RE_PERIOD.search(line))
def parse(path: Path) -> ParsedStatement:
with pdfplumber.open(path) as pdf:
text = "\n".join((page.extract_text() or "") for page in pdf.pages)
period = RE_PERIOD.search(text)
if not period:
raise ParserError("HVB: Abrechnungszeitraum nicht gefunden")
period_start = parse_german_date(period.group(1))
period_end = parse_german_date(period.group(2))
opening = RE_OPENING.search(text)
closing = RE_CLOSING.search(text)
if not opening or not closing:
raise ParserError("HVB: Anfangs-/Endsaldo nicht gefunden")
iban_m = RE_IBAN.search(text)
txs: list[ParsedTransaction] = []
pending: dict | None = None
in_hint = False
for line in text.splitlines():
line = line.strip()
if not line:
continue
# Ganzseitigen Hinweisblock als Sequenz überspringen.
if in_hint:
if RE_SEITE.match(line):
in_hint = False
continue
if RE_HINT_START.match(line):
in_hint = True
continue
# Schlusssaldo erreicht: laufende Buchung abschließen und Sammlung beenden.
if RE_CLOSING.search(line):
break
m = RE_TX_HEAD.match(line)
if m:
if pending:
if pending["value"] is None:
raise ParserError("HVB: Buchungszeile ohne Detailzeile")
txs.append(_finish(pending))
pending = {
"booking": parse_german_date(m.group(1), period_end.year),
"value": None,
"head": m.group(2).strip(),
"amount": parse_german_amount(m.group(3)),
"extra": [],
}
continue
if pending and pending["value"] is None:
d = RE_TX_DETAIL.match(line)
if d:
pending["value"] = parse_german_date(d.group(1), period_end.year)
if d.group(2).strip():
pending["extra"].append(d.group(2).strip())
continue
if pending and not _is_noise(line):
pending["extra"].append(line)
if pending:
if pending["value"] is None:
raise ParserError("HVB: Buchungszeile ohne Detailzeile")
txs.append(_finish(pending))
return ParsedStatement(bank=BANK,
iban=iban_m.group(1).replace(" ", "") if iban_m else None,
period_start=period_start, period_end=period_end,
opening_balance=parse_german_amount(opening.group(1)),
closing_balance=parse_german_amount(closing.group(1)),
transactions=txs)
def _finish(p: dict) -> ParsedTransaction:
counterparty = p["extra"][0] if p["extra"] else ""
purpose = " ".join([p["head"], *p["extra"][1:]]).strip()
return ParsedTransaction(booking_date=p["booking"], value_date=p["value"],
amount=p["amount"], purpose=purpose,
counterparty=counterparty)

View File

@@ -0,0 +1,21 @@
from pathlib import Path
from typing import Callable
import pdfplumber
from app.parsers import dkb, hvb, vr
from app.parsers.base import ParsedStatement, ParserError
from app.parsers.detect import detect_bank
PARSERS: dict[str, Callable[[Path], ParsedStatement]] = {
"vr": vr.parse, "hvb": hvb.parse, "dkb": dkb.parse,
}
def parse_pdf(path: Path) -> ParsedStatement:
with pdfplumber.open(path) as pdf:
first = pdf.pages[0].extract_text() or ""
bank = detect_bank(first)
if bank is None:
raise ParserError("Bank nicht erkannt")
return PARSERS[bank](path)

View File

@@ -0,0 +1,15 @@
import hashlib
from datetime import date
from decimal import Decimal
from app.parsers.base import ParsedStatement
def balance_difference(p: ParsedStatement) -> Decimal:
total = sum((t.amount for t in p.transactions), Decimal("0"))
return (p.opening_balance + total - p.closing_balance).copy_abs()
def dedup_hash(account_id: int, booking_date: date, amount: Decimal, purpose: str) -> str:
raw = f"{account_id}|{booking_date.isoformat()}|{amount:.2f}|{' '.join(purpose.split())}"
return hashlib.sha256(raw.encode()).hexdigest()

139
finance/app/parsers/vr.py Normal file
View File

@@ -0,0 +1,139 @@
"""Parser für Volksbank/Raiffeisenbank-Kontoauszüge.
Realer Fixture-Abgleich (Task 7, Schritt 4): Der eigentliche Auszugszeitraum
steht nicht als "vom X bis Y"-Satz im Text (dieser Wortlaut kommt nur in einem
separaten quartalsweisen Zinsabschluss-Absatz mit abweichendem Zeitraum vor).
Anfangs-/Endsaldo tragen stattdessen je ein eigenes Datum ("alter/neuer
Kontostand vom DD.MM.YYYY ..."), aus dem der Auszugszeitraum abgeleitet wird;
RE_PERIOD bleibt als Fallback erhalten, falls ein Auszug diese Daten nicht
enthält.
"""
import re
from datetime import date
from pathlib import Path
import pdfplumber
from app.parsers.base import (ParsedStatement, ParsedTransaction, ParserError,
parse_german_amount, parse_german_date)
BANK = "vr"
RE_IBAN = re.compile(r"\b([A-Z]{2}\d{2}(?:\s?\d{4}){4,5}(?:\s?\d{1,2})?)\b")
# Fallback, falls kein Datum an den Kontostand-Zeilen hängt: "vom 01.06.2020 bis 30.06.2020"
RE_PERIOD = re.compile(r"vom\s+(\d{2}\.\d{2}\.\d{4})\s+bis\s+(\d{2}\.\d{2}\.\d{4})")
# "alter Kontostand vom 01.06.2020 1.234,56 H" (Datum optional, je nach Formatvariante)
RE_OPENING = re.compile(r"alter\s+Kontostand\s+(?:vom\s+(\d{2}\.\d{2}\.\d{4})\s+)?([\d.,]+\s*[SH-]?)", re.I)
# "neuer Kontostand vom 30.06.2020 2.345,67 H"
RE_CLOSING = re.compile(r"neuer\s+Kontostand\s+(?:vom\s+(\d{2}\.\d{2}\.\d{4})\s+)?([\d.,]+\s*[SH-]?)", re.I)
# Buchungszeile: "01.06. 01.06. Muster GmbH 1.234,56 S"
RE_TX = re.compile(r"^(\d{2}\.\d{2}\.)\s+(\d{2}\.\d{2}\.)\s+(.*?)\s+([\d.,]+\s*[SH-])$")
# Folgeseiten-Kopfblock (Bankanschrift bis Spaltenüberschrift) als Sequenz
# überspringen: fängt auch die Kontoinhaber-Kopfzeile ab, ohne gleichnamige
# Gegenparteien in Buchungen zu zerstören.
RE_HEADER_START = re.compile(r"^Beraterpark am Olgaplatz")
RE_HEADER_END = re.compile(r"^Bu-Tag\s+Wert\s+Vorgang")
# Blattwechsel-/Formular-Reste, die sonst in den Verwendungszweck rutschen.
RE_NOISE = re.compile(
r"(?:Übertrag" # "Übertrag auf/von Blatt N"
r"|^\d{3,4}$" # Formularcodes (z. B. 0007, 000)
r"|^K\d{6}" # Formularcode (K + Ziffernblock)
r"|Bitte beachten Sie die Hinweise" # Fußzeilen-Hinweis
r"|^[─—–\-]{3,}$" # Trennlinien
r")"
)
def _is_noise(line: str) -> bool:
return bool(RE_NOISE.search(line))
def _join_purpose(parts: list[str]) -> str:
"""Verwendungszweck zusammensetzen und harte Zeilenumbrüche der VR-PDF
(~54 Zeichen, teils mitten im Wort) rekonstruieren.
Ohne Leerzeichen wird nur dann verbunden, wenn die Vorzeile mindestens 53
Zeichen lang ist UND (Vorzeile endet auf Ziffer und Folgezeile beginnt mit
Ziffer) ODER (beide an der Bruchstelle Kleinbuchstaben) sonst mit
Leerzeichen. So bleibt z. B. "... 75" + "Euro" mit Leerzeichen getrennt.
"""
if not parts:
return ""
out = parts[0]
for prev, cur in zip(parts, parts[1:]):
glue = ""
if len(prev) >= 53 and prev and cur:
if (prev[-1].isdigit() and cur[0].isdigit()) or \
(prev[-1].islower() and cur[0].islower()):
glue = ""
else:
glue = " "
else:
glue = " "
out += glue + cur
return out.strip()
def parse(path: Path) -> ParsedStatement:
with pdfplumber.open(path) as pdf:
text = "\n".join((page.extract_text() or "") for page in pdf.pages)
opening = RE_OPENING.search(text)
closing = RE_CLOSING.search(text)
if not opening or not closing:
raise ParserError("VR: Anfangs-/Endsaldo nicht gefunden")
if opening.group(1) and closing.group(1):
period_start = parse_german_date(opening.group(1))
period_end = parse_german_date(closing.group(1))
else:
period = RE_PERIOD.search(text)
if not period:
raise ParserError("VR: Abrechnungszeitraum nicht gefunden")
period_start = parse_german_date(period.group(1))
period_end = parse_german_date(period.group(2))
iban_m = RE_IBAN.search(text)
txs: list[ParsedTransaction] = []
pending: dict | None = None
in_header = False
for line in text.splitlines():
line = line.strip()
if not line:
continue
# Folgeseiten-Kopfblock komplett überspringen (inkl. Kontoinhaber-Zeile).
if in_header:
if RE_HEADER_END.match(line):
in_header = False
continue
if RE_HEADER_START.match(line):
in_header = True
continue
# Schlusssaldo erreicht: laufende Buchung abschließen und Sammlung beenden.
if RE_CLOSING.search(line):
break
m = RE_TX.match(line)
if m:
if pending:
txs.append(_finish(pending))
pending = {
"booking": parse_german_date(m.group(1), period_end.year),
"value": parse_german_date(m.group(2), period_end.year),
"head": m.group(3).strip(),
"amount": parse_german_amount(m.group(4)),
"extra": [],
}
elif pending and not _is_noise(line):
pending["extra"].append(line)
if pending:
txs.append(_finish(pending))
return ParsedStatement(bank=BANK,
iban=iban_m.group(1).replace(" ", "") if iban_m else None,
period_start=period_start, period_end=period_end,
opening_balance=parse_german_amount(opening.group(2)),
closing_balance=parse_german_amount(closing.group(2)),
transactions=txs)
def _finish(p: dict) -> ParsedTransaction:
counterparty = p["extra"][0] if p["extra"] else ""
purpose = _join_purpose([p["head"], *p["extra"][1:]])
return ParsedTransaction(booking_date=p["booking"], value_date=p["value"],
amount=p["amount"], purpose=purpose,
counterparty=counterparty)

View File

View File

@@ -0,0 +1,111 @@
from datetime import date
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, field_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth import require_auth
from app.db import get_session
from app.models.tables import Account
from app.services.balances import account_balance
router = APIRouter(prefix="/api/accounts", tags=["accounts"],
dependencies=[Depends(require_auth)])
class AccountIn(BaseModel):
bank: str
iban: str
name: str
type: str = "giro"
class AccountOut(AccountIn):
model_config = ConfigDict(from_attributes=True)
id: int
balance: Decimal = Decimal("0")
anchor_date: date | None = None
anchor_balance: Decimal | None = None
class AccountPatch(BaseModel):
# Alle Felder optional; model_fields_set entscheidet, was tatsaechlich
# geaendert wird (Muster wie TransactionPatch.category_id). anchor_date
# und anchor_balance muessen gemeinsam gesetzt oder gemeinsam null sein.
name: str | None = None
anchor_date: date | None = None
anchor_balance: Decimal | None = None
@field_validator("name")
@classmethod
def name_not_blank(cls, v: str | None) -> str | None:
if v is None:
return v
v = v.strip()
if not v or len(v) > 100:
raise ValueError("Name muss 1100 Zeichen lang sein")
return v
@router.get("", response_model=list[AccountOut])
def list_accounts(session: Session = Depends(get_session)):
out = []
for acc in session.execute(select(Account)).scalars():
item = AccountOut.model_validate(acc)
item.balance = account_balance(session, acc)
out.append(item)
return out
@router.get("/{account_id}", response_model=AccountOut)
def get_account(account_id: int, session: Session = Depends(get_session)):
acc = session.get(Account, account_id)
if acc is None:
raise HTTPException(404, "Konto nicht gefunden")
item = AccountOut.model_validate(acc)
item.balance = account_balance(session, acc)
return item
@router.post("", response_model=AccountOut, status_code=201)
def create_account(data: AccountIn, session: Session = Depends(get_session)):
if session.execute(select(Account).where(Account.iban == data.iban)).scalar():
raise HTTPException(409, "IBAN existiert bereits")
acc = Account(**data.model_dump())
session.add(acc)
session.commit()
session.refresh(acc)
return AccountOut.model_validate(acc)
@router.patch("/{account_id}", response_model=AccountOut)
def patch_account(account_id: int, data: AccountPatch, session: Session = Depends(get_session)):
acc = session.get(Account, account_id)
if acc is None:
raise HTTPException(404, "Konto nicht gefunden")
# Validierung zuerst, VOR jeder Mutation - sonst haengt bei einem 422 auf
# dem Anker eine bereits geschriebene name-Aenderung im Session-State.
fields = data.model_fields_set
if "name" in fields and data.name is None:
raise HTTPException(422, "Name darf nicht leer sein")
anchor_fields = {"anchor_date", "anchor_balance"} & fields
if anchor_fields and (
anchor_fields != {"anchor_date", "anchor_balance"}
or (data.anchor_date is None) != (data.anchor_balance is None)):
raise HTTPException(422, "Anker braucht Datum und Betrag (oder beide leeren)")
if "name" in fields:
acc.name = data.name
if anchor_fields:
acc.anchor_date = data.anchor_date
acc.anchor_balance = data.anchor_balance
session.commit()
session.refresh(acc)
item = AccountOut.model_validate(acc)
item.balance = account_balance(session, acc)
return item

View File

@@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends, Form, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth import require_auth
from app.db import get_session
from app.models.tables import Category
from app.routers.gui import gui_session, templates
from app.services.admin import apply_rules_retroactively, change_password
router = APIRouter()
@router.get("/admin", dependencies=[Depends(gui_session)])
def admin_page(request: Request, session: Session = Depends(get_session)):
return templates.TemplateResponse(request, "admin.html", {
"error": None,
"success": None,
"categories": session.execute(select(Category)).scalars().all(),
})
@router.post("/admin/passwort", dependencies=[Depends(gui_session)])
def admin_change_password(
request: Request,
alt: str = Form(...),
neu: str = Form(...),
neu2: str = Form(...),
session: Session = Depends(get_session),
):
categories = session.execute(select(Category)).scalars().all()
# UX-Regel: das Formular selbst bleibt immer sichtbar/bedienbar; Fehler
# werden inline auf derselben Seite gemeldet statt still zu verwerfen.
# Der Kategorien-Abschnitt bleibt aus demselben Grund in JEDEM Render-Pfad
# (Fehler wie Erfolg) im Kontext, statt nur bei der GET-Route.
if len(neu) < 8:
return templates.TemplateResponse(request, "admin.html", {
"error": "Das neue Passwort muss mindestens 8 Zeichen lang sein.",
"success": None,
"categories": categories,
}, status_code=400)
if neu != neu2:
return templates.TemplateResponse(request, "admin.html", {
"error": "Die Wiederholung stimmt nicht mit dem neuen Passwort überein.",
"success": None,
"categories": categories,
}, status_code=400)
try:
change_password(alt, neu)
except (ValueError, RuntimeError) as exc:
return templates.TemplateResponse(request, "admin.html", {
"error": str(exc),
"success": None,
"categories": categories,
}, status_code=400)
return templates.TemplateResponse(request, "admin.html", {
"error": None,
"success": "Passwort erfolgreich geändert (gilt für GUI und Grafana). "
"Bestehende Sitzungen bleiben angemeldet.",
"categories": categories,
})
@router.post("/api/category-rules/apply", dependencies=[Depends(require_auth)])
def apply_category_rules(session: Session = Depends(get_session)):
return {"categorized": apply_rules_retroactively(session)}

View File

@@ -0,0 +1,91 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth import require_auth
from app.db import get_session
from app.models.tables import Category, CategoryRule
router = APIRouter(prefix="/api", tags=["categories"],
dependencies=[Depends(require_auth)])
class CategoryIn(BaseModel):
name: str
class CategoryOut(CategoryIn):
model_config = ConfigDict(from_attributes=True)
id: int
class CategoryRuleIn(BaseModel):
pattern: str
category_id: int
priority: int = 100
class CategoryRuleOut(CategoryRuleIn):
model_config = ConfigDict(from_attributes=True)
id: int
@router.get("/categories", response_model=list[CategoryOut])
def list_categories(session: Session = Depends(get_session)):
return [CategoryOut.model_validate(c)
for c in session.execute(select(Category)).scalars()]
@router.post("/categories", response_model=CategoryOut, status_code=201)
def create_category(data: CategoryIn, session: Session = Depends(get_session)):
if session.execute(select(Category).where(Category.name == data.name)).scalar():
raise HTTPException(409, "Kategorie existiert bereits")
cat = Category(**data.model_dump())
session.add(cat)
session.commit()
session.refresh(cat)
return CategoryOut.model_validate(cat)
@router.patch("/categories/{category_id}", response_model=CategoryOut)
def patch_category(category_id: int, data: CategoryIn,
session: Session = Depends(get_session)):
cat = session.get(Category, category_id)
if cat is None:
raise HTTPException(404, "Kategorie nicht gefunden")
clash = session.execute(
select(Category).where(Category.name == data.name)).scalar()
if clash is not None and clash.id != category_id:
raise HTTPException(409, "Kategorie existiert bereits")
cat.name = data.name
session.commit()
session.refresh(cat)
return CategoryOut.model_validate(cat)
@router.get("/category-rules", response_model=list[CategoryRuleOut])
def list_category_rules(session: Session = Depends(get_session)):
rules = session.execute(
select(CategoryRule).order_by(CategoryRule.priority)).scalars()
return [CategoryRuleOut.model_validate(r) for r in rules]
@router.post("/category-rules", response_model=CategoryRuleOut, status_code=201)
def create_category_rule(data: CategoryRuleIn, session: Session = Depends(get_session)):
if session.get(Category, data.category_id) is None:
raise HTTPException(404, "Kategorie nicht gefunden")
rule = CategoryRule(**data.model_dump())
session.add(rule)
session.commit()
session.refresh(rule)
return CategoryRuleOut.model_validate(rule)
@router.delete("/category-rules/{rule_id}", status_code=204)
def delete_category_rule(rule_id: int, session: Session = Depends(get_session)):
rule = session.get(CategoryRule, rule_id)
if rule is None:
raise HTTPException(404, "Regel nicht gefunden")
session.delete(rule)
session.commit()

321
finance/app/routers/gui.py Normal file
View File

@@ -0,0 +1,321 @@
from datetime import date, timedelta
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.templating import Jinja2Templates
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth import COOKIE, session_valid
from app.db import get_session
from app.engine.loans import add_months
from app.engine.recurrence import occurrences
from app.formats import de_label, eur
from app.models.tables import (Account, Category, PlannedItem,
ProjectionResult, RecurringItem,
ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem, Transaction)
from app.routers.imports import list_imports as _list_imports
from app.routers.imports import preview as _preview_import
from app.routers.planning import list_loans as _list_loans
from app.routers.planning import list_planned as _list_planned
from app.routers.planning import list_recurring as _list_recurring
from app.routers.planning import recurring_suggestions as _recurring_suggestions
from app.routers.scenarios import list_scenarios as _list_scenarios
from app.routers.transactions import count_transactions, list_transactions
from app.services.balances import account_balance, total_balance
from app.version import get_version
PAGE_SIZE = 50
templates = Jinja2Templates(directory="app/templates")
templates.env.globals["app_version"] = get_version()
templates.env.filters["eur"] = eur
templates.env.filters["de_label"] = de_label
router = APIRouter()
def gui_session(request: Request):
if not session_valid(request.cookies.get(COOKIE)):
raise _redirect()
def _redirect():
exc = HTTPException(302)
exc.headers = {"Location": "/login"}
return exc
def _upcoming_items(session: Session, today: date, days: int = 30, limit: int = 10) -> list[dict]:
"""Nächste Posten aus RecurringItems (Termine) und PlannedItems der nächsten `days` Tage."""
end = today + timedelta(days=days)
items: list[dict] = []
for r in session.execute(select(RecurringItem)).scalars():
for d in occurrences(r.rhythm, r.due_day, today, end, r.start_date, r.end_date):
items.append({"date": d, "name": r.name, "amount": Decimal(r.amount)})
for p in session.execute(
select(PlannedItem).where(PlannedItem.due >= today, PlannedItem.due <= end)
).scalars():
items.append({"date": p.due, "name": p.name, "amount": Decimal(p.amount)})
items.sort(key=lambda i: i["date"])
return items[:limit]
def _latest_projection(session: Session) -> ProjectionResult | None:
return session.execute(
select(ProjectionResult).order_by(ProjectionResult.computed_at.desc())
).scalars().first()
def _opt_int(value: str | None) -> int | None:
"""Wandelt einen GUI-Query-Parameter tolerant in int|None um. HTML-Formulare
senden bei nicht ausgefüllten Feldern leere Strings statt gar keinen
Parameter - das darf nie zu einem 422 fuehren, sondern degradiert zu None."""
if value is None or not value.strip():
return None
try:
return int(value)
except ValueError:
return None
def _opt_date(value: str | None) -> date | None:
"""Wie _opt_int, nur fuer date-Felder (ISO-Format aus <input type=date>)."""
if value is None or not value.strip():
return None
try:
return date.fromisoformat(value)
except ValueError:
return None
def _month_starts_desc(session: Session, today: date) -> list[date]:
"""Liefert die Monatsersten von "heute" rueckwaerts bis zum Monat der
fruehesten bestaetigten Buchung, absteigend sortiert (heute zuerst).
Ohne bestaetigte Buchungen: leere Liste."""
earliest = session.execute(
select(func.min(Transaction.booking_date)).where(Transaction.status == "confirmed")
).scalar_one_or_none()
if earliest is None:
return []
start = date(earliest.year, earliest.month, 1)
current = date(today.year, today.month, 1)
months = []
m = current
while m >= start:
months.append(m)
m = add_months(m, -1)
return months
@router.get("/", dependencies=[Depends(gui_session)])
def index(request: Request, session: Session = Depends(get_session)):
accounts = session.execute(select(Account)).scalars().all()
balances = [(a, account_balance(session, a)) for a in accounts]
return templates.TemplateResponse(request, "index.html", {
"balances": balances,
"total": total_balance(session),
"upcoming": _upcoming_items(session, date.today()),
"projection": _latest_projection(session),
})
@router.get("/buchungen", dependencies=[Depends(gui_session)])
def buchungen(
request: Request,
account_id: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
category_id: str | None = None,
q: str | None = None,
status: str = "confirmed",
page: int = 1,
session: Session = Depends(get_session),
):
# HTML-Filterformular sendet leere Strings fuer nicht ausgefuellte Felder -
# daher hier bewusst str|None statt int|None/date|None und tolerante
# Konvertierung, statt FastAPI vor dem Handler mit 422 abbrechen zu lassen.
# Die API-Routen (/api/transactions) bleiben strikt typisiert.
account_id = _opt_int(account_id)
date_from = _opt_date(date_from)
date_to = _opt_date(date_to)
category_id = _opt_int(category_id)
page = max(1, page)
total = count_transactions(
account_id=account_id,
date_from=date_from,
date_to=date_to,
category_id=category_id,
q=q,
status=status,
session=session,
)
total_pages = max(1, -(-total // PAGE_SIZE)) # ceil division
page = min(page, total_pages)
txs = list_transactions(
account_id=account_id,
date_from=date_from,
date_to=date_to,
category_id=category_id,
q=q,
status=status,
limit=PAGE_SIZE,
offset=(page - 1) * PAGE_SIZE,
session=session,
)
accounts = session.execute(select(Account)).scalars().all()
return templates.TemplateResponse(request, "transactions.html", {
"transactions": txs,
"accounts": accounts,
"account_names": {a.id: a.name for a in accounts},
"categories": session.execute(select(Category)).scalars().all(),
"filters": {
"account_id": account_id,
"date_from": date_from,
"date_to": date_to,
"category_id": category_id,
"q": q or "",
"status": status,
},
"page": page,
"total_pages": total_pages,
})
@router.get("/salden", dependencies=[Depends(gui_session)])
def salden_page(
request: Request,
stichtag: str | None = None,
session: Session = Depends(get_session),
):
# Lenient wie die Filter auf /buchungen: ein leerer oder kaputter
# stichtag-Parameter fuehrt nie zu 422, sondern faellt auf "heute" zurueck.
today = date.today()
parsed_stichtag = _opt_date(stichtag) or today
accounts = session.execute(select(Account)).scalars().all()
balances = [(a, account_balance(session, a, at=parsed_stichtag)) for a in accounts]
total = sum((b for _, b in balances), Decimal("0"))
month_rows = []
for month in _month_starts_desc(session, today):
# Saldo am Monatsanfang = Stand am ENDE DES VORTAGS (letzter Tag des
# Vormonats), NICHT account_balance(at=month) - sonst wuerden
# Buchungen AM 1. bereits in den "Anfangs"-Saldo einfliessen und den
# Ausweis verzerren. Siehe Fussnote im Template ("Stand: Ende des
# Vortags").
vortag = month - timedelta(days=1)
values = [(a, account_balance(session, a, at=vortag)) for a in accounts]
month_rows.append({
"month": month,
"values": values,
"total": sum((v for _, v in values), Decimal("0")),
})
accounts_without_anchor = [
a for a in accounts if a.anchor_date is None or a.anchor_balance is None
]
return templates.TemplateResponse(request, "salden.html", {
"stichtag": parsed_stichtag,
"balances": balances,
"total": total,
"accounts": accounts,
"months": month_rows,
"accounts_without_anchor": accounts_without_anchor,
})
@router.get("/import", dependencies=[Depends(gui_session)])
def import_page(request: Request, session: Session = Depends(get_session)):
return templates.TemplateResponse(request, "import.html", {
"statements": _list_imports(session=session),
})
@router.get("/import/list", dependencies=[Depends(gui_session)])
def import_list_fragment(request: Request, session: Session = Depends(get_session)):
return templates.TemplateResponse(request, "_import_list.html", {
"statements": _list_imports(session=session),
})
@router.get("/import/{statement_id}/preview-fragment", dependencies=[Depends(gui_session)])
def import_preview_fragment(statement_id: int, request: Request,
session: Session = Depends(get_session)):
result = _preview_import(statement_id, session=session)
return templates.TemplateResponse(request, "_preview_table.html", {
"statement": result.statement,
"transactions": result.transactions,
"balance_ok": result.balance_ok,
"duplicates": result.duplicates,
})
def _scenario_rows(session: Session, scenarios: list, loans: list) -> list[dict]:
"""Baut je Szenario den Kontext fürs Template: zugeordnete Kredite,
Modifikatoren und (falls vorhanden) das letzte Projektionsergebnis."""
rows = []
for sc in scenarios:
assigned_loan_ids = {
sl.loan_id for sl in session.execute(
select(ScenarioLoan).where(ScenarioLoan.scenario_id == sc.id)
).scalars()
}
modifiers = session.execute(
select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id)
.order_by(ScenarioModifier.id)
).scalars().all()
planned_items = session.execute(
select(ScenarioPlannedItem)
.where(ScenarioPlannedItem.scenario_id == sc.id)
.order_by(ScenarioPlannedItem.due)).scalars().all()
rows.append({
"scenario": sc,
"assigned_loan_ids": assigned_loan_ids,
"modifiers": modifiers,
"planned_items": planned_items,
"result": session.get(ProjectionResult, sc.id),
})
return rows
@router.get("/hilfe", dependencies=[Depends(gui_session)])
def hilfe_page(request: Request):
return templates.TemplateResponse(request, "hilfe.html", {})
@router.get("/planung", dependencies=[Depends(gui_session)])
def planung_page(request: Request, session: Session = Depends(get_session)):
categories = session.execute(select(Category)).scalars().all()
recurring = _list_recurring(session=session)
planned = _list_planned(session=session)
loans = _list_loans(session=session)
return templates.TemplateResponse(request, "planning.html", {
"categories": categories,
"category_names": {c.id: c.name for c in categories},
"recurring": recurring,
"recurring_names": {r.id: r.name for r in recurring},
"suggestions": _recurring_suggestions(session=session),
"planned": planned,
"loans": loans,
"rhythms": ["monthly", "quarterly", "yearly"],
"repayment_types": ["annuity", "bullet"],
})
@router.get("/szenarien", dependencies=[Depends(gui_session)])
def szenarien_page(request: Request, session: Session = Depends(get_session)):
categories = session.execute(select(Category)).scalars().all()
recurring = _list_recurring(session=session)
loans = _list_loans(session=session)
scenarios = _list_scenarios(session=session)
return templates.TemplateResponse(request, "szenarien.html", {
"categories": categories,
"category_names": {c.id: c.name for c in categories},
"recurring": recurring,
"recurring_names": {r.id: r.name for r in recurring},
"loans": loans,
"scenario_rows": _scenario_rows(session, scenarios, loans),
"modifier_kinds": ["percent", "absolute", "remove", "ende"],
})

View File

@@ -0,0 +1,195 @@
from datetime import date
from decimal import Decimal
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth import require_auth
from app.config import get_settings
from app.db import get_session
from app.models.tables import Statement, Transaction
from app.services.importer import process_file
router = APIRouter(prefix="/api/imports", tags=["imports"],
dependencies=[Depends(require_auth)])
class StatementOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
filename: str
bank: str
account_id: int | None = None
period_start: date | None = None
period_end: date | None = None
opening_balance: Decimal | None = None
closing_balance: Decimal | None = None
status: str
error_message: str | None = None
class TransactionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
account_id: int
statement_id: int | None = None
booking_date: date
value_date: date | None = None
amount: Decimal
purpose: str
counterparty: str
category_id: int | None = None
status: str
dedup_hash: str
is_duplicate: bool
class PreviewOut(BaseModel):
statement: StatementOut
transactions: list[TransactionOut]
balance_ok: bool | None
duplicates: int
@router.post("/upload", response_model=StatementOut, status_code=201)
def upload(file: UploadFile, session: Session = Depends(get_session)):
safe_name = Path(file.filename or "upload.pdf").name
if not safe_name or not safe_name.lower().endswith((".pdf", ".csv")):
raise HTTPException(400, "Nur PDF- oder CSV-Dateien")
settings = get_settings()
inbox_dir = settings.inbox_dir
inbox_dir.mkdir(parents=True, exist_ok=True)
dest = inbox_dir / safe_name
with dest.open("wb") as f:
f.write(file.file.read())
stmt = process_file(session, dest)
return StatementOut.model_validate(stmt)
@router.post("/scan-inbox", response_model=list[StatementOut])
def scan_inbox(session: Session = Depends(get_session)):
settings = get_settings()
inbox_dir = settings.inbox_dir
inbox_dir.mkdir(parents=True, exist_ok=True)
paths = sorted(inbox_dir.glob("*.pdf")) + sorted(inbox_dir.glob("*.csv"))
results = []
for path in paths:
stmt = process_file(session, path)
results.append(StatementOut.model_validate(stmt))
return results
@router.get("", response_model=list[StatementOut])
def list_imports(session: Session = Depends(get_session)):
stmts = session.execute(select(Statement).order_by(Statement.id)).scalars()
return [StatementOut.model_validate(s) for s in stmts]
@router.get("/{statement_id}/preview", response_model=PreviewOut)
def preview(statement_id: int, session: Session = Depends(get_session)):
stmt = session.get(Statement, statement_id)
if stmt is None:
raise HTTPException(404, "Import nicht gefunden")
txs = session.execute(
select(Transaction).where(Transaction.statement_id == statement_id)
.order_by(Transaction.booking_date, Transaction.id)
).scalars().all()
# Dreiwertig (Ausbaustufe 3 Task 3): manche CSV-Formate (z.B. HVB, DKB)
# liefern keine Saldodaten - None statt False, damit die Vorschau nicht
# faelschlich einen Saldo-Fehler anzeigt, wo schlicht keine Pruefung
# moeglich ist.
if stmt.opening_balance is None or stmt.closing_balance is None:
balance_ok = None
else:
balance_ok = (
stmt.opening_balance + sum((t.amount for t in txs), Decimal("0"))
- stmt.closing_balance
) == 0
duplicates = sum(1 for t in txs if t.is_duplicate)
return PreviewOut(
statement=StatementOut.model_validate(stmt),
transactions=[TransactionOut.model_validate(t) for t in txs],
balance_ok=balance_ok,
duplicates=duplicates,
)
@router.post("/{statement_id}/confirm", response_model=StatementOut)
def confirm(statement_id: int, session: Session = Depends(get_session)):
stmt = session.get(Statement, statement_id)
if stmt is None:
raise HTTPException(404, "Import nicht gefunden")
if stmt.status != "draft":
raise HTTPException(409, "Import ist nicht im Entwurfsstatus")
txs = session.execute(
select(Transaction).where(
Transaction.statement_id == statement_id,
Transaction.status == "draft",
Transaction.is_duplicate == False, # noqa: E712
)
).scalars().all()
for tx in txs:
# Re-check against currently confirmed transactions: another import
# confirmed between preview and now may have introduced the same
# booking. Mark late duplicates and skip promoting them.
clash = session.execute(
select(Transaction).where(
Transaction.dedup_hash == tx.dedup_hash,
Transaction.status == "confirmed",
Transaction.id != tx.id,
# Only guard against duplicates from OTHER statements. Two
# genuinely identical bookings within the same statement share a
# dedup_hash; the balance the preview validated depends on both
# being confirmed, so they must not knock each other out here.
Transaction.statement_id != tx.statement_id,
)
).scalar()
if clash is not None:
tx.is_duplicate = True
continue
tx.status = "confirmed"
stmt.status = "confirmed"
session.commit()
session.refresh(stmt)
return StatementOut.model_validate(stmt)
@router.post("/{statement_id}/rollback")
def rollback(statement_id: int, session: Session = Depends(get_session)):
stmt = session.get(Statement, statement_id)
if stmt is None:
raise HTTPException(404, "Import nicht gefunden")
if stmt.status != "confirmed":
raise HTTPException(409, "Nur bestätigte Importe können zurückgerollt werden")
txs = session.execute(
select(Transaction).where(Transaction.statement_id == statement_id)
).scalars().all()
deleted = len(txs)
for tx in txs:
session.delete(tx)
session.flush()
session.delete(stmt)
session.commit()
return {"deleted_transactions": deleted, "statement_id": statement_id}
@router.delete("/{statement_id}", status_code=204)
def delete_import(statement_id: int, session: Session = Depends(get_session)):
stmt = session.get(Statement, statement_id)
if stmt is None:
raise HTTPException(404, "Import nicht gefunden")
if stmt.status == "confirmed":
raise HTTPException(409, "Bestätigter Import kann nicht gelöscht werden")
drafts = session.execute(
select(Transaction).where(
Transaction.statement_id == statement_id,
Transaction.status == "draft",
)
).scalars().all()
for tx in drafts:
session.delete(tx)
session.delete(stmt)
session.commit()

View File

@@ -0,0 +1,262 @@
from datetime import date
from decimal import Decimal
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field, model_validator
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.auth import require_auth
from app.db import get_session
from app.engine.loans import loan_schedule
from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
ScenarioLoan)
from app.services.suggestions import suggest_recurring
router = APIRouter(prefix="/api", tags=["planning"],
dependencies=[Depends(require_auth)])
# ---------------------------------------------------------------- Recurring
class RecurringIn(BaseModel):
name: str
amount: Decimal
rhythm: Literal["monthly", "quarterly", "yearly"]
due_day: int = Field(ge=1, le=31)
start_date: date | None = None
end_date: date | None = None
category_id: int | None = None
@model_validator(mode="after")
def _ende_nicht_vor_start(self):
if (self.start_date is not None and self.end_date is not None
and self.end_date < self.start_date):
raise ValueError("Ende darf nicht vor Start liegen")
return self
class RecurringOut(RecurringIn):
model_config = ConfigDict(from_attributes=True)
id: int
class RecurringPatch(BaseModel):
name: str | None = None
amount: Decimal | None = None
rhythm: Literal["monthly", "quarterly", "yearly"] | None = None
due_day: int | None = Field(default=None, ge=1, le=31)
start_date: date | None = None
end_date: date | None = None
category_id: int | None = None
class SuggestionOut(BaseModel):
name: str
amount: Decimal
rhythm: str
due_day: int
start_date: date | None = None
category_id: int | None = None
hinweis: str = ""
def _check_category(session: Session, category_id: int | None) -> None:
if category_id is not None and session.get(Category, category_id) is None:
raise HTTPException(404, "Kategorie nicht gefunden")
@router.get("/recurring", response_model=list[RecurringOut])
def list_recurring(session: Session = Depends(get_session)):
return [RecurringOut.model_validate(r)
for r in session.execute(select(RecurringItem)).scalars()]
@router.post("/recurring", response_model=RecurringOut, status_code=201)
def create_recurring(data: RecurringIn, session: Session = Depends(get_session)):
_check_category(session, data.category_id)
item = RecurringItem(**data.model_dump())
session.add(item)
session.commit()
session.refresh(item)
return RecurringOut.model_validate(item)
# Muss vor /recurring/{item_id} stehen, sonst faengt die {id}-Route den Pfad ab.
@router.get("/recurring/suggestions", response_model=list[SuggestionOut])
def recurring_suggestions(session: Session = Depends(get_session)):
return suggest_recurring(session)
@router.patch("/recurring/{item_id}", response_model=RecurringOut)
def patch_recurring(item_id: int, data: RecurringPatch,
session: Session = Depends(get_session)):
item = session.get(RecurringItem, item_id)
if item is None:
raise HTTPException(404, "Fixposten nicht gefunden")
fields = data.model_dump(exclude_unset=True)
if "category_id" in fields:
_check_category(session, fields["category_id"])
for key, value in fields.items():
setattr(item, key, value)
if (item.start_date is not None and item.end_date is not None
and item.end_date < item.start_date):
session.rollback()
raise HTTPException(422, "Ende darf nicht vor Start liegen")
session.commit()
session.refresh(item)
return RecurringOut.model_validate(item)
@router.delete("/recurring/{item_id}", status_code=204)
def delete_recurring(item_id: int, session: Session = Depends(get_session)):
item = session.get(RecurringItem, item_id)
if item is None:
raise HTTPException(404, "Fixposten nicht gefunden")
session.delete(item)
session.commit()
# ------------------------------------------------------------------ Planned
class PlannedIn(BaseModel):
name: str
amount: Decimal
due: date
category_id: int | None = None
class PlannedOut(PlannedIn):
model_config = ConfigDict(from_attributes=True)
id: int
class PlannedPatch(BaseModel):
name: str | None = None
amount: Decimal | None = None
due: date | None = None
category_id: int | None = None
@router.get("/planned", response_model=list[PlannedOut])
def list_planned(session: Session = Depends(get_session)):
return [PlannedOut.model_validate(p)
for p in session.execute(select(PlannedItem)).scalars()]
@router.post("/planned", response_model=PlannedOut, status_code=201)
def create_planned(data: PlannedIn, session: Session = Depends(get_session)):
_check_category(session, data.category_id)
item = PlannedItem(**data.model_dump())
session.add(item)
session.commit()
session.refresh(item)
return PlannedOut.model_validate(item)
@router.patch("/planned/{item_id}", response_model=PlannedOut)
def patch_planned(item_id: int, data: PlannedPatch,
session: Session = Depends(get_session)):
item = session.get(PlannedItem, item_id)
if item is None:
raise HTTPException(404, "Einmalposten nicht gefunden")
fields = data.model_dump(exclude_unset=True)
if "category_id" in fields:
_check_category(session, fields["category_id"])
for key, value in fields.items():
setattr(item, key, value)
session.commit()
session.refresh(item)
return PlannedOut.model_validate(item)
@router.delete("/planned/{item_id}", status_code=204)
def delete_planned(item_id: int, session: Session = Depends(get_session)):
item = session.get(PlannedItem, item_id)
if item is None:
raise HTTPException(404, "Einmalposten nicht gefunden")
session.delete(item)
session.commit()
# --------------------------------------------------------------------- Loans
class LoanIn(BaseModel):
name: str
principal: Decimal
annual_rate_pct: Decimal
term_months: int
payout_date: date
repayment_type: Literal["annuity", "bullet"] = "annuity"
class LoanOut(LoanIn):
model_config = ConfigDict(from_attributes=True)
id: int
class LoanPatch(BaseModel):
name: str | None = None
principal: Decimal | None = None
annual_rate_pct: Decimal | None = None
term_months: int | None = None
payout_date: date | None = None
repayment_type: Literal["annuity", "bullet"] | None = None
class InstallmentOut(BaseModel):
due: date
payment: Decimal
interest: Decimal
principal: Decimal
remaining: Decimal
@router.get("/loans", response_model=list[LoanOut])
def list_loans(session: Session = Depends(get_session)):
return [LoanOut.model_validate(l)
for l in session.execute(select(Loan)).scalars()]
@router.post("/loans", response_model=LoanOut, status_code=201)
def create_loan(data: LoanIn, session: Session = Depends(get_session)):
loan = Loan(**data.model_dump())
session.add(loan)
session.commit()
session.refresh(loan)
return LoanOut.model_validate(loan)
@router.patch("/loans/{loan_id}", response_model=LoanOut)
def patch_loan(loan_id: int, data: LoanPatch, session: Session = Depends(get_session)):
loan = session.get(Loan, loan_id)
if loan is None:
raise HTTPException(404, "Kredit nicht gefunden")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(loan, key, value)
session.commit()
session.refresh(loan)
return LoanOut.model_validate(loan)
@router.delete("/loans/{loan_id}", status_code=204)
def delete_loan(loan_id: int, session: Session = Depends(get_session)):
loan = session.get(Loan, loan_id)
if loan is None:
raise HTTPException(404, "Kredit nicht gefunden")
# Remove scenario assignments first: no DB-level cascade exists, so a
# dangling ScenarioLoan row would raise a foreign-key violation (500 on
# Postgres).
session.execute(delete(ScenarioLoan).where(ScenarioLoan.loan_id == loan_id))
session.delete(loan)
session.commit()
@router.get("/loans/{loan_id}/schedule", response_model=list[InstallmentOut])
def get_loan_schedule(loan_id: int, session: Session = Depends(get_session)):
loan = session.get(Loan, loan_id)
if loan is None:
raise HTTPException(404, "Kredit nicht gefunden")
plan = loan_schedule(Decimal(loan.principal), Decimal(loan.annual_rate_pct),
loan.term_months, loan.payout_date, loan.repayment_type)
return [InstallmentOut(due=i.due, payment=i.payment, interest=i.interest,
principal=i.principal, remaining=i.remaining)
for i in plan]

View File

@@ -0,0 +1,277 @@
from datetime import date
from decimal import Decimal
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.auth import require_auth
from app.config import get_settings
from app.db import get_session
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
Scenario, ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem)
from app.services.projection_service import run_projection
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
dependencies=[Depends(require_auth)])
class ScenarioIn(BaseModel):
name: str
description: str = ""
include_recurring: bool = True
include_planned: bool = True
class ScenarioOut(ScenarioIn):
model_config = ConfigDict(from_attributes=True)
id: int
class ScenarioPatch(BaseModel):
name: str | None = None
description: str | None = None
include_recurring: bool | None = None
include_planned: bool | None = None
@field_validator("description")
@classmethod
def _leere_beschreibung(cls, v):
return "" if v is None else v
class ModifierIn(BaseModel):
target_type: Literal["category", "recurring"]
target_id: int
kind: Literal["percent", "absolute", "remove", "ende"]
value: Decimal = Decimal("0")
end_date: date | None = None
@model_validator(mode="after")
def _ende_braucht_datum(self):
if self.kind == "ende" and self.end_date is None:
raise ValueError("Art »Ende« erfordert ein Datum")
if self.kind != "ende":
self.end_date = None
return self
class ModifierOut(ModifierIn):
model_config = ConfigDict(from_attributes=True)
id: int
scenario_id: int
class SeriesPointOut(BaseModel):
day: date
balance: Decimal
class ProjectionOut(BaseModel):
low_point_date: date
low_point_balance: Decimal
below_zero_date: date | None
below_threshold_date: date | None
series: list[SeriesPointOut]
def _get_scenario(session: Session, scenario_id: int) -> Scenario:
scenario = session.get(Scenario, scenario_id)
if scenario is None:
raise HTTPException(404, "Szenario nicht gefunden")
return scenario
@router.get("", response_model=list[ScenarioOut])
def list_scenarios(session: Session = Depends(get_session)):
return [ScenarioOut.model_validate(s)
for s in session.execute(select(Scenario)).scalars()]
@router.post("", response_model=ScenarioOut, status_code=201)
def create_scenario(data: ScenarioIn, session: Session = Depends(get_session)):
if session.execute(select(Scenario).where(Scenario.name == data.name)).scalar():
raise HTTPException(409, "Szenario existiert bereits")
scenario = Scenario(**data.model_dump())
session.add(scenario)
session.commit()
session.refresh(scenario)
return ScenarioOut.model_validate(scenario)
@router.patch("/{scenario_id}", response_model=ScenarioOut)
def patch_scenario(scenario_id: int, data: ScenarioPatch,
session: Session = Depends(get_session)):
scenario = _get_scenario(session, scenario_id)
fields = data.model_dump(exclude_unset=True)
if "name" in fields and fields["name"] != scenario.name:
clash = session.execute(
select(Scenario).where(Scenario.name == fields["name"])).scalar()
if clash is not None:
raise HTTPException(409, "Szenario existiert bereits")
for key, value in fields.items():
setattr(scenario, key, value)
session.commit()
session.refresh(scenario)
return ScenarioOut.model_validate(scenario)
@router.delete("/{scenario_id}", status_code=204)
def delete_scenario(scenario_id: int, session: Session = Depends(get_session)):
scenario = _get_scenario(session, scenario_id)
# No DB-level cascades exist, so remove dependent rows explicitly to avoid
# foreign-key violations (would surface as a 500 on Postgres).
session.execute(delete(ProjectionPoint).where(
ProjectionPoint.scenario_id == scenario_id))
session.execute(delete(ProjectionResult).where(
ProjectionResult.scenario_id == scenario_id))
session.execute(delete(ScenarioLoan).where(
ScenarioLoan.scenario_id == scenario_id))
session.execute(delete(ScenarioModifier).where(
ScenarioModifier.scenario_id == scenario_id))
session.execute(delete(ScenarioPlannedItem).where(
ScenarioPlannedItem.scenario_id == scenario_id))
session.delete(scenario)
session.commit()
@router.post("/{scenario_id}/loans/{loan_id}", status_code=204)
def add_scenario_loan(scenario_id: int, loan_id: int,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
if session.get(Loan, loan_id) is None:
raise HTTPException(404, "Kredit nicht gefunden")
if session.get(ScenarioLoan, (scenario_id, loan_id)) is None:
session.add(ScenarioLoan(scenario_id=scenario_id, loan_id=loan_id))
session.commit()
@router.delete("/{scenario_id}/loans/{loan_id}", status_code=204)
def remove_scenario_loan(scenario_id: int, loan_id: int,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
link = session.get(ScenarioLoan, (scenario_id, loan_id))
if link is not None:
session.delete(link)
session.commit()
@router.post("/{scenario_id}/modifiers", response_model=ModifierOut, status_code=201)
def add_modifier(scenario_id: int, data: ModifierIn,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
modifier = ScenarioModifier(scenario_id=scenario_id, **data.model_dump())
session.add(modifier)
session.commit()
session.refresh(modifier)
return ModifierOut.model_validate(modifier)
@router.delete("/{scenario_id}/modifiers/{mod_id}", status_code=204)
def delete_modifier(scenario_id: int, mod_id: int,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
modifier = session.get(ScenarioModifier, mod_id)
if modifier is None or modifier.scenario_id != scenario_id:
raise HTTPException(404, "Modifikator nicht gefunden")
session.delete(modifier)
session.commit()
@router.patch("/{scenario_id}/modifiers/{mod_id}", response_model=ModifierOut)
def patch_modifier(scenario_id: int, mod_id: int, data: ModifierIn,
session: Session = Depends(get_session)):
# Full-Body-Update: das Edit-Formular sendet immer alle Felder des Typs.
_get_scenario(session, scenario_id)
modifier = session.get(ScenarioModifier, mod_id)
if modifier is None or modifier.scenario_id != scenario_id:
raise HTTPException(404, "Modifikator nicht gefunden")
for key, value in data.model_dump().items():
setattr(modifier, key, value)
session.commit()
session.refresh(modifier)
return ModifierOut.model_validate(modifier)
class ScenarioPlannedIn(BaseModel):
name: str
amount: Decimal
due: date
class ScenarioPlannedOut(ScenarioPlannedIn):
model_config = ConfigDict(from_attributes=True)
id: int
scenario_id: int
@router.get("/{scenario_id}/planned", response_model=list[ScenarioPlannedOut])
def list_scenario_planned(scenario_id: int, session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
return [ScenarioPlannedOut.model_validate(p) for p in session.execute(
select(ScenarioPlannedItem)
.where(ScenarioPlannedItem.scenario_id == scenario_id)
.order_by(ScenarioPlannedItem.due)).scalars()]
@router.post("/{scenario_id}/planned", response_model=ScenarioPlannedOut,
status_code=201)
def add_scenario_planned(scenario_id: int, data: ScenarioPlannedIn,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
item = ScenarioPlannedItem(scenario_id=scenario_id, **data.model_dump())
session.add(item)
session.commit()
session.refresh(item)
return ScenarioPlannedOut.model_validate(item)
@router.delete("/{scenario_id}/planned/{item_id}", status_code=204)
def delete_scenario_planned(scenario_id: int, item_id: int,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
item = session.get(ScenarioPlannedItem, item_id)
if item is None or item.scenario_id != scenario_id:
raise HTTPException(404, "Einmalzahlung nicht gefunden")
session.delete(item)
session.commit()
@router.patch("/{scenario_id}/planned/{item_id}", response_model=ScenarioPlannedOut)
def patch_scenario_planned(scenario_id: int, item_id: int, data: ScenarioPlannedIn,
session: Session = Depends(get_session)):
# Full-Body-Update, Muster patch_modifier.
_get_scenario(session, scenario_id)
item = session.get(ScenarioPlannedItem, item_id)
if item is None or item.scenario_id != scenario_id:
raise HTTPException(404, "Einmalzahlung nicht gefunden")
for key, value in data.model_dump().items():
setattr(item, key, value)
session.commit()
session.refresh(item)
return ScenarioPlannedOut.model_validate(item)
@router.post("/{scenario_id}/project", response_model=ProjectionOut)
def project_scenario(scenario_id: int, horizon_days: int | None = None,
start_date: date | None = None,
session: Session = Depends(get_session)):
scenario = _get_scenario(session, scenario_id)
settings = get_settings()
resolved_horizon = horizon_days if horizon_days is not None else settings.horizon_days
resolved_start = start_date if start_date is not None else date.today()
result = run_projection(session, scenario, resolved_horizon, resolved_start)
points = session.execute(
select(ProjectionPoint).where(ProjectionPoint.scenario_id == scenario_id)
.order_by(ProjectionPoint.day)
).scalars().all()
return ProjectionOut(
low_point_date=result.low_point_date,
low_point_balance=result.low_point_balance,
below_zero_date=result.below_zero_date,
below_threshold_date=result.below_threshold_date,
series=[SeriesPointOut(day=p.day, balance=p.balance) for p in points],
)

View File

@@ -0,0 +1,157 @@
from datetime import date
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.auth import require_auth
from app.db import get_session
from app.models.tables import Account, Category, Transaction
from app.parsers.validate import dedup_hash
from app.services.categorize import apply_rules
router = APIRouter(prefix="/api/transactions", tags=["transactions"],
dependencies=[Depends(require_auth)])
class TransactionIn(BaseModel):
account_id: int
booking_date: date
value_date: date | None = None
amount: Decimal
purpose: str = ""
counterparty: str = ""
force: bool = False
class TransactionOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
account_id: int
statement_id: int | None = None
booking_date: date
value_date: date | None = None
amount: Decimal
purpose: str
counterparty: str
category_id: int | None = None
status: str
dedup_hash: str
is_duplicate: bool
class TransactionPatch(BaseModel):
category_id: int | None = None
def _apply_transaction_filters(
stmt,
account_id: int | None = None,
date_from: date | None = None,
date_to: date | None = None,
category_id: int | None = None,
q: str | None = None,
status: str = "confirmed",
):
"""Wendet die Buchungs-Filter auf ein select()-Statement an. Wird sowohl fuer
die Liste als auch fuer die dazugehoerige Gesamtanzahl (Pagination) genutzt,
damit beide garantiert dieselben Ergebnisse zaehlen/zeigen."""
if status:
stmt = stmt.where(Transaction.status == status)
if account_id is not None:
stmt = stmt.where(Transaction.account_id == account_id)
if date_from is not None:
stmt = stmt.where(Transaction.booking_date >= date_from)
if date_to is not None:
stmt = stmt.where(Transaction.booking_date <= date_to)
if category_id is not None:
stmt = stmt.where(Transaction.category_id == category_id)
if q:
like = f"%{q}%"
stmt = stmt.where(or_(Transaction.purpose.ilike(like),
Transaction.counterparty.ilike(like)))
return stmt
@router.get("", response_model=list[TransactionOut])
def list_transactions(
account_id: int | None = None,
date_from: date | None = None,
date_to: date | None = None,
category_id: int | None = None,
q: str | None = None,
status: str = "confirmed",
limit: int = 200,
offset: int = 0,
session: Session = Depends(get_session),
):
stmt = _apply_transaction_filters(
select(Transaction), account_id=account_id, date_from=date_from,
date_to=date_to, category_id=category_id, q=q, status=status,
)
stmt = stmt.order_by(Transaction.booking_date, Transaction.id).offset(offset).limit(limit)
return [TransactionOut.model_validate(t) for t in session.execute(stmt).scalars()]
def count_transactions(
account_id: int | None = None,
date_from: date | None = None,
date_to: date | None = None,
category_id: int | None = None,
q: str | None = None,
status: str = "confirmed",
session: Session = Depends(get_session),
) -> int:
"""Gesamtanzahl der Buchungen fuer die gleichen Filter wie list_transactions
(ohne limit/offset) - Grundlage fuer die Seitenzahl in der GUI-Pagination."""
stmt = _apply_transaction_filters(
select(func.count(Transaction.id)), account_id=account_id, date_from=date_from,
date_to=date_to, category_id=category_id, q=q, status=status,
)
return session.execute(stmt).scalar_one()
@router.post("", response_model=TransactionOut, status_code=201)
def create_transaction(data: TransactionIn, session: Session = Depends(get_session)):
if session.get(Account, data.account_id) is None:
raise HTTPException(404, "Konto nicht gefunden")
h = dedup_hash(data.account_id, data.booking_date, data.amount, data.purpose)
existing = session.execute(
select(Transaction).where(Transaction.dedup_hash == h)).scalar()
if existing is not None and not data.force:
raise HTTPException(409, "Buchung existiert bereits (Duplikat)")
tx = Transaction(
account_id=data.account_id,
booking_date=data.booking_date,
value_date=data.value_date,
amount=data.amount,
purpose=data.purpose,
counterparty=data.counterparty,
status="confirmed",
dedup_hash=h,
is_duplicate=existing is not None,
)
session.add(tx)
session.commit()
session.refresh(tx)
apply_rules(session, [tx])
session.commit()
session.refresh(tx)
return TransactionOut.model_validate(tx)
@router.patch("/{tx_id}", response_model=TransactionOut)
def patch_transaction(tx_id: int, data: TransactionPatch,
session: Session = Depends(get_session)):
tx = session.get(Transaction, tx_id)
if tx is None:
raise HTTPException(404, "Buchung nicht gefunden")
if "category_id" in data.model_fields_set:
if data.category_id is not None and session.get(Category, data.category_id) is None:
raise HTTPException(404, "Kategorie nicht gefunden")
tx.category_id = data.category_id
session.commit()
session.refresh(tx)
return TransactionOut.model_validate(tx)

View File

View File

@@ -0,0 +1,116 @@
"""Admin-Funktionen: Passwortaenderung (GUI+Grafana gemeinsam) und
Regel-Neuanwendung (Ausbaustufe 4 Task 2).
KRITISCH (siehe Global Constraints des Plans): die .env wird im API-Container
als Einzeldatei-Bind-Mount eingehaengt. Ein solcher Mount folgt dem Inode -
die Datei MUSS in-place ueberschrieben werden (open r+/truncate unter flock),
NIEMALS ueber Temp-Datei+rename (das erzeugt einen neuen Inode und würde vom
Container nicht mehr gesehen).
"""
import fcntl
import json
import urllib.error
import urllib.request
from base64 import b64encode
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth import current_password_hash, hash_password, verify_password
from app.config import get_settings
from app.models.tables import Transaction
from app.services.categorize import apply_rules
def _sync_grafana_password(old: str, new: str, base_url: str) -> None:
"""PUT /api/admin/users/1/password gegen Grafana, Basic-Auth mit dem
ALTEN Passwort (admin:old) - Grafana authentifiziert den Request noch mit
dem bisherigen Passwort, aendert es aber auf `new`. Wird IMMER vor dem
Schreiben der .env aufgerufen: schlaegt Grafana fehl, bleibt die Datei
unangetastet (kein inkonsistenter Zwischenzustand GUI-Hash != Grafana)."""
url = f"{base_url.rstrip('/')}/api/admin/users/1/password"
auth_header = "Basic " + b64encode(f"admin:{old}".encode()).decode()
body = json.dumps({"password": new}).encode()
req = urllib.request.Request(
url, data=body, method="PUT",
headers={"Authorization": auth_header, "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
if resp.status >= 400:
raise RuntimeError(
f"Grafana-Passwortänderung fehlgeschlagen (HTTP {resp.status})."
)
except urllib.error.HTTPError as exc:
raise RuntimeError(
f"Grafana-Passwortänderung fehlgeschlagen (HTTP {exc.code})."
) from exc
except urllib.error.URLError as exc:
raise RuntimeError(
f"Grafana-Passwortänderung fehlgeschlagen: {exc.reason}"
) from exc
except (TimeoutError, OSError) as exc:
# Ein Hang in der Lesephase (nach erfolgreichem Verbindungsaufbau)
# kann ein rohes TimeoutError/socket.timeout werfen, das urlopen NICHT
# in ein URLError verpackt (URLError selbst ist zwar ein OSError, aber
# dieser Pfad faengt Faelle ab, die es nicht bis dorthin schaffen).
# Ohne diesen Fang wuerde die Exception bis in den Router durchschlagen
# und dort als 500 statt als deutsche 400-Fehlermeldung enden.
raise RuntimeError(
f"Grafana-Passwortänderung fehlgeschlagen: {exc}"
) from exc
def _rewrite_env_file(env_file: Path, new_password: str, new_hash: str) -> None:
"""Ersetzt FB_PASSWORD und FB_GUI_PASSWORD_HASH IN-PLACE (open r+ unter
flock, truncate) - der Inode der Datei bleibt unveraendert, siehe
Modul-Docstring."""
with open(env_file, "r+", encoding="utf-8") as f:
fcntl.flock(f, fcntl.LOCK_EX)
try:
lines = f.read().splitlines()
out = []
for line in lines:
if line.startswith("FB_PASSWORD="):
out.append(f"FB_PASSWORD='{new_password}'")
elif line.startswith("FB_GUI_PASSWORD_HASH="):
out.append(f"FB_GUI_PASSWORD_HASH='{new_hash}'")
else:
out.append(line)
f.seek(0)
f.write("\n".join(out) + "\n")
f.truncate()
finally:
fcntl.flock(f, fcntl.LOCK_UN)
def change_password(old: str, new: str) -> None:
"""Aendert das gemeinsame GUI-/Grafana-Passwort. Reihenfolge ist bindend:
1) altes Passwort gegen den aktuellen Hash verifizieren,
2) Grafana AKTUALISIEREN (bricht bei Fehler ab, ohne die .env
anzufassen),
3) erst danach die .env in-place neu schreiben.
"""
if not verify_password(old, current_password_hash()):
raise ValueError("Das alte Passwort ist falsch.")
settings = get_settings()
_sync_grafana_password(old, new, settings.grafana_url)
new_hash = hash_password(new)
_rewrite_env_file(settings.env_file, new, new_hash)
def apply_rules_retroactively(session: Session) -> int:
"""Wendet die aktuellen Kategorie-Regeln rueckwirkend auf alle
bestaetigten, noch unkategorisierten Buchungen an und committet."""
txs = session.execute(
select(Transaction).where(
Transaction.status == "confirmed",
Transaction.category_id.is_(None),
)
).scalars().all()
hits = apply_rules(session, txs)
session.commit()
return hits

View File

@@ -0,0 +1,53 @@
from datetime import date
from decimal import Decimal
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.tables import Account, Transaction
def _confirmed_sum(session: Session, account_id: int,
after: date | None, upto: date | None) -> Decimal:
"""Summe bestaetigter Buchungen fuer `account_id` mit `after < booking_date
<= upto` (Raender werden nur gesetzt, wenn nicht None)."""
q = select(func.coalesce(func.sum(Transaction.amount), 0)).where(
Transaction.account_id == account_id, Transaction.status == "confirmed")
if after is not None:
q = q.where(Transaction.booking_date > after)
if upto is not None:
q = q.where(Transaction.booking_date <= upto)
return Decimal(session.execute(q).scalar_one())
def account_balance(session: Session, account: Account, at: date | None = None) -> Decimal:
"""Kontostand am Ende des Tages `at` (Default: heute).
Konto-Saldo-Anker (Ausbaustufe 3 Task 1) ersetzt die bisherige
Statement-closing-Logik ersatzlos. Konvention: der Anker gilt PER
TAGESENDE des Ankerdatums - er enthaelt bereits alle Buchungen bis
einschliesslich diesem Tag.
Mit Anker: anchor_balance + Sum(tx: anchor_date < booking_date <= at)
- Sum(tx: at < booking_date <= anchor_date)
(jeweils nur eine der beiden Summen ist nicht-leer, je nachdem ob `at`
vor oder nach dem Anker liegt; bei at == anchor_date sind beide leer.)
Ohne Anker: Sum(tx: booking_date <= at) - bisheriges Verhalten mit Basis 0.
"""
if at is None:
at = date.today()
if account.anchor_date is not None and account.anchor_balance is not None:
anchor_date = account.anchor_date
anchor_balance = Decimal(account.anchor_balance)
if at >= anchor_date:
return anchor_balance + _confirmed_sum(session, account.id, anchor_date, at)
return anchor_balance - _confirmed_sum(session, account.id, at, anchor_date)
return _confirmed_sum(session, account.id, None, at)
def total_balance(session: Session, at: date | None = None) -> Decimal:
accounts = session.execute(select(Account)).scalars().all()
return sum((account_balance(session, a, at) for a in accounts), Decimal("0"))

View File

@@ -0,0 +1,20 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.tables import CategoryRule, Transaction
def apply_rules(session: Session, transactions: list[Transaction]) -> int:
rules = session.execute(
select(CategoryRule).order_by(CategoryRule.priority)).scalars().all()
hits = 0
for tx in transactions:
if tx.category_id is not None:
continue
haystack = f"{tx.purpose} {tx.counterparty}".lower()
for rule in rules:
if rule.pattern.lower() in haystack:
tx.category_id = rule.category_id
hits += 1
break
return hits

View File

@@ -0,0 +1,186 @@
from datetime import date
from decimal import Decimal
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.tables import Account, Statement, Transaction
from app.parsers.base import ParsedStatement, ParserError
from app.parsers.csv_formats import parse_csv
from app.parsers.registry import parse_pdf
from app.parsers.validate import balance_difference, dedup_hash
from app.services.categorize import apply_rules
def _find_or_create_account(session: Session, bank: str, iban: str | None, filename: str) -> Account:
if not iban:
iban = f"UNBEKANNT-{filename}"
if bank == "hvb_csv":
# HVB-CSV traegt nur die Kontonummer (keine IBAN). Konto-Zuordnung
# ueber "endet auf Kontonummer" gegen alle bestehenden IBANs -
# dokumentierte Einschraenkung (siehe Formatreferenz Task 2/3).
for existing in session.execute(select(Account)).scalars():
if existing.iban.endswith(iban):
return existing
acc = Account(bank=bank, iban=iban, name=iban, type="giro")
session.add(acc)
session.flush()
return acc
acc = session.execute(select(Account).where(Account.iban == iban)).scalar()
if acc is not None:
return acc
acc = Account(bank=bank, iban=iban, name=iban, type="giro")
session.add(acc)
session.flush()
return acc
def _apply_anchor_autofill(account: Account, anchor: tuple[date, Decimal] | None) -> None:
"""CSV-Anker-Regel (Ausbaustufe 3 Task 3): der Datei-Kontostand wird nur
uebernommen, wenn er neuer ist als ein evtl. vorhandener Anker (oder noch
keiner existiert). Ein neuerer, manuell per PATCH gesetzter Anker bleibt
unangetastet - so entkommt kein aelterer CSV-Export einem bewusst
gesetzten aktuellen Stand."""
if anchor is None:
return
anchor_date, anchor_balance = anchor
if account.anchor_date is None or anchor_date >= account.anchor_date:
account.anchor_date = anchor_date
account.anchor_balance = anchor_balance
def _error_statement(session: Session, filename: str, message: str) -> Statement:
stmt = Statement(filename=filename, bank="unbekannt", account_id=None,
status="error", error_message=message)
session.add(stmt)
session.commit()
session.refresh(stmt)
return stmt
def _finish_statement(session: Session, path: Path, parsed: ParsedStatement,
check_balance: bool,
anchor: tuple[date, Decimal] | None) -> Statement:
"""Gemeinsamer Abschluss fuer PDF- und CSV-Importe, sobald ein
ParsedStatement vorliegt: Konto zuordnen, Statement+Transaktionen als
Entwurf anlegen, optional den Saldo pruefen, Datei aus dem Posteingang
verschieben. `check_balance=False` (CSV-Formate ohne Saldodaten, z.B.
HVB/DKB) laesst den Entwurf OHNE Fehler stehen statt einer
Saldo-Differenz-Pruefung, die auf None-Feldern crashen wuerde."""
settings = get_settings()
filename = path.name
account = _find_or_create_account(session, parsed.bank, parsed.iban, filename)
stmt = Statement(
filename=filename,
bank=parsed.bank,
account_id=account.id,
period_start=parsed.period_start,
period_end=parsed.period_end,
opening_balance=parsed.opening_balance,
closing_balance=parsed.closing_balance,
status="draft",
)
session.add(stmt)
session.flush()
if check_balance:
diff = balance_difference(parsed)
if diff != 0:
stmt.status = "error"
stmt.error_message = f"Saldo-Differenz {diff} EUR"
session.commit()
session.refresh(stmt)
return stmt
drafts = []
for t in parsed.transactions:
h = dedup_hash(account.id, t.booking_date, t.amount, t.purpose)
is_duplicate = session.execute(
select(Transaction).where(
Transaction.dedup_hash == h,
Transaction.status == "confirmed",
)
).scalar() is not None
tx = Transaction(
account_id=account.id,
statement_id=stmt.id,
booking_date=t.booking_date,
value_date=t.value_date,
amount=t.amount,
purpose=t.purpose,
counterparty=t.counterparty,
status="draft",
dedup_hash=h,
is_duplicate=is_duplicate,
)
session.add(tx)
drafts.append(tx)
session.flush()
apply_rules(session, drafts)
stmt.status = "draft"
_apply_anchor_autofill(account, anchor)
# Invariant: a committed draft implies the source file left the inbox.
# Move the file before committing so a failed move can never leave a
# committed draft with the file still sitting in the inbox.
uploads_dir = settings.uploads_dir
uploads_dir.mkdir(parents=True, exist_ok=True)
try:
path.replace(uploads_dir / filename)
except OSError:
session.rollback()
raise
session.commit()
session.refresh(stmt)
return stmt
def process_pdf(session: Session, path: Path) -> Statement:
filename = path.name
try:
parsed = parse_pdf(path)
except ParserError as exc:
return _error_statement(session, filename, str(exc))
except Exception as exc:
# Fremdbibliotheken (pdfplumber/pdfminer) werfen bei strukturell
# kaputten PDFs eigene Exception-Typen statt ParserError (z.B.
# PdfminerException "No /Root object!"). Ohne diesen Fang wuerde ein
# kaputtes Upload zu einem unbehandelten 500 fuehren statt zu einem
# sauberen Fehler-Import wie bei "Bank nicht erkannt". Die Datei
# bleibt dabei unangetastet im Posteingang.
return _error_statement(session, filename, f"PDF nicht lesbar: {exc}")
return _finish_statement(session, path, parsed, check_balance=True, anchor=None)
def process_csv(session: Session, path: Path) -> Statement:
filename = path.name
try:
parsed_csv = parse_csv(path)
except ParserError as exc:
return _error_statement(session, filename, str(exc))
except Exception as exc:
# Analog zu process_pdf: eine strukturell kaputte/unlesbare CSV darf
# nicht als unbehandelter 500 durchschlagen.
return _error_statement(session, filename, f"CSV nicht lesbar: {exc}")
return _finish_statement(session, path, parsed_csv.statement,
check_balance=parsed_csv.balance_checkable,
anchor=parsed_csv.anchor)
def process_file(session: Session, path: Path) -> Statement:
"""Dateiendungs-Weiche fuer die Import-Pipeline (Ausbaustufe 3 Task 3):
.csv laeuft ueber den CSV-Formatparser, alles andere weiterhin ueber den
PDF-Parser."""
if path.suffix.lower() == ".csv":
return process_csv(session, path)
return process_pdf(session, path)

View File

@@ -0,0 +1,65 @@
from datetime import date, timedelta
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.engine.loans import loan_schedule
from app.engine.projection import project
from app.engine.scenario import (PlainModifier, PlainPlanned, PlainRecurring,
build_cashflows)
from app.models.tables import (Loan, PlannedItem, ProjectionPoint,
ProjectionResult, RecurringItem, Scenario,
ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem)
from app.services.balances import total_balance
def run_projection(session: Session, scenario: Scenario, horizon_days: int,
start_date: date) -> ProjectionResult:
end = start_date + timedelta(days=horizon_days)
recurring = []
if scenario.include_recurring:
recurring = [PlainRecurring(r.id, r.name, Decimal(r.amount), r.rhythm,
r.due_day, r.start_date, r.end_date, r.category_id)
for r in session.execute(select(RecurringItem)).scalars()]
planned = []
if scenario.include_planned:
planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
for p in session.execute(select(PlannedItem)).scalars()]
# Szenario-eigene Einmalzahlungen zaehlen IMMER fuer dieses Szenario -
# include_planned steuert nur die globalen PlannedItems.
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, None)
for p in session.execute(select(ScenarioPlannedItem).where(
ScenarioPlannedItem.scenario_id == scenario.id)).scalars()]
loans = session.execute(
select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id)
.where(ScenarioLoan.scenario_id == scenario.id)).scalars().all()
schedules = [loan_schedule(Decimal(l.principal), Decimal(l.annual_rate_pct),
l.term_months, l.payout_date, l.repayment_type)
for l in loans]
payouts = [(l.payout_date, Decimal(l.principal)) for l in loans]
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind,
Decimal(m.value), m.end_date)
for m in session.execute(select(ScenarioModifier).where(
ScenarioModifier.scenario_id == scenario.id)).scalars()]
flows = build_cashflows(recurring, planned, schedules, payouts, modifiers,
start_date, end)
proj = project(total_balance(session), start_date, flows, horizon_days,
threshold=get_settings().warn_threshold)
session.execute(delete(ProjectionPoint).where(
ProjectionPoint.scenario_id == scenario.id))
for d, bal in proj.series:
session.add(ProjectionPoint(scenario_id=scenario.id, day=d, balance=bal))
result = session.get(ProjectionResult, scenario.id) or ProjectionResult(
scenario_id=scenario.id, computed_at=datetime.now(timezone.utc),
low_point_date=proj.low_point[0], low_point_balance=proj.low_point[1])
result.computed_at = datetime.now(timezone.utc)
result.low_point_date, result.low_point_balance = proj.low_point
result.below_zero_date = proj.first_below_zero
result.below_threshold_date = proj.first_below_threshold
session.add(result)
session.commit()
return result

View File

@@ -0,0 +1,371 @@
"""Vorschlagsalgorithmus fuer wiederkehrende Buchungen (Ausbaustufe 9, v2).
Ersetzt die reine exakte-Betrags-Gruppierung (v1) durch: Empfaenger-Cluster
mit Toleranz (haelt Preisdrift in einer Serie zusammen, trennt aber parallele
Vertraege desselben Anbieters), Rhythmus-Erkennung ueber den Median der
Buchungsabstaende (monatlich/vierteljaehrlich/jaehrlich statt nur monatlich),
einen Aktiv-Check (keine "Leichen"-Serien) sowie einen Merge-Pass fuer
Umfirmierungen (Anbieter aendert den Namen, die Serie laeuft inhaltlich
weiter). Bindende Spec:
docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md.
Alle Betrags-Toleranzvergleiche verwenden ausschliesslich `Decimal`
(CLAUDE.md: "Decimal, nicht float" - Rundungsfehler bei Geldbetraegen sind
inakzeptabel). Tage-Vergleiche (Rhythmus, Aktiv-Check, Merge-Luecke) sind
ganzzahlige Tage-Arithmetik, niemals float/Decimal-Bruchteile von Tagen.
"""
from __future__ import annotations
import re
import statistics
from collections import Counter
from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.formats import eur
from app.models.tables import RecurringItem, Transaction
# Betrachtungsfenster (Schritt 1): ~15 Monate. Muss mindestens die zwei
# Belege einer jaehrlichen Serie (bis zu 400 Tage auseinander) plus etwas
# Puffer fuer Cluster-/Merge-Bildung abdecken.
WINDOW_DAYS = 460
# Rhythmus-Tabelle: (min_tage, max_tage, mindestbelege) je Rhythmus. Der
# Median der Buchungsabstaende einer Serie muss ins Intervall fallen, UND es
# muessen mindestens so viele Buchungen vorliegen (ein einzelner Zufallstreffer
# mit "passendem" Abstand soll nicht als Serie gelten).
RHYTHMS: dict[str, tuple[int, int, int]] = {
"monthly": (25, 36, 3),
"quarterly": (80, 105, 3),
"yearly": (330, 400, 2),
}
# Nominelle Schrittweite je Rhythmus in Tagen - Referenzwert fuer Aktiv-Check
# und Merge-Luecken-Fenster (Schritt 5/6).
STEP_DAYS: dict[str, int] = {"monthly": 30, "quarterly": 91, "yearly": 365}
# Aktiv-Check (Schritt 5): die letzte Buchung darf hoechstens das 1,75-fache
# der Rhythmus-Schrittweite zurueckliegen, sonst gilt die Serie als beendet
# ("Leiche") und wird nicht vorgeschlagen. Als Fraction 7/4 ausgedrueckt und
# ganzzahlig verglichen (delta_tage * 4 <= schrittweite * 7), damit keine
# Gleitkomma-Rundung ueber "aktiv"/"inaktiv" entscheidet.
ACTIVITY_FACTOR_NUM = 7
ACTIVITY_FACTOR_DEN = 4
# Relative Toleranzen (immer als Decimal verglichen, nie float):
CLUSTER_TOL = Decimal("0.35") # Schritt 3: Betrags-Cluster (haelt Preisdrift zusammen)
MERGE_TOL = Decimal("0.25") # Schritt 6: Umfirmierungs-Merge ueber Gruppengrenzen
BESTAND_TOL = Decimal("0.10") # Schritt 8: Bestandsabgleich gegen RecurringItem
# Merge-Luecke (Schritt 6): die Zeit zwischen dem Ende von Serie A und dem
# Beginn von Serie B muss zwischen dem 0,4- und 1,6-fachen der
# Rhythmus-Schrittweite liegen (als ganzzahlige Bruchvergleiche, aus
# demselben Grund wie beim Aktiv-Check).
MERGE_GAP_MIN_NUM, MERGE_GAP_MIN_DEN = 4, 10 # 0.4
MERGE_GAP_MAX_NUM, MERGE_GAP_MAX_DEN = 16, 10 # 1.6
MERGE_DUE_DAY_TOL = 3 # Schritt 6: Faelligkeitstag-Toleranz in Tagen
BESTAND_DUE_DAY_TOL = 2 # Schritt 8: Faelligkeitstag-Toleranz in Tagen
# Bestandsabgleich, Token-Match (Live-Gate-Fund, Nachtrag 4): kuratierte
# Fixposten tragen haeufig einen Alias-/Variabel-Namen, der weder Substring
# noch betragsaehnlich zum automatisch erkannten Vorschlag ist (Muster:
# ein Sammel-Fixposten fuer eine Kreditkartenabrechnung mit variablem Betrag
# unter einem Alias-Namen des Anbieters deckt den vom Algorithmus erkannten
# Vorschlag desselben Anbieters unter seinem regulaeren Empfaenger-Namen
# nicht ab, weil weder Substring noch Betrags-Toleranz greifen). Ein
# gemeinsames, hinreichend spezifisches Namens-Token (>=5 Zeichen, um
# generische Woerter wie "Bank" nicht faelschlich matchen zu lassen) bei
# gleichem Rhythmus und nahem Faelligkeitstag gilt als ausreichendes Indiz
# fuer denselben Fixposten.
TOKEN_MIN_LEN = 5
# Volatilitaets-Hinweis (Live-Gate A9-Fund, Nachtrag 4): wenn der
# Betrags-Cluster-Split (Schritt 3) die neueste Buchung der Empfaenger-Gruppe
# abgetrennt hat (weil sie zu stark vom Serien-Betrag abweicht), ist der
# vorgeschlagene Betrag ggf. schon wieder veraltet - keine Unterdrueckung,
# nur ein Warnhinweis fuer die Nutzerin/den Nutzer.
VOLATILITAETS_HINWEIS = "Beträge schwanken stark letzte Buchung weicht ab"
def _norm(name: str) -> str:
"""Normalisiert einen Empfaenger-Namen fuer Gruppen- und
Substring-Vergleich: Bankexporte schreiben denselben Empfaenger nicht
einheitlich (Gross-/Kleinschreibung, mehrfache Leerzeichen), das ist fuer
die Erkennung irrelevant."""
return " ".join(name.split()).casefold()
def _tokens(name: str) -> set[str]:
"""Zerlegt einen normalisierten Namen an Nicht-Alphanumerik in Tokens
(fuer den Token-Match im Bestandsabgleich, Schritt 8). Nur Tokens ab
TOKEN_MIN_LEN Zeichen zaehlen, damit kurze generische Woerter ("eG",
"AG", "Bank") keine falschen Treffer erzeugen."""
return {tok for tok in re.split(r"[^a-z0-9]+", _norm(name)) if len(tok) >= TOKEN_MIN_LEN}
def _rel_diff(a: Decimal, b: Decimal) -> Decimal:
"""Relative Differenz von Betrag a zur Referenz b (immer >= 0), als
Decimal. b=0 kommt praktisch nicht vor (eine Nullbuchung bildet keine
erkennbare Serie); fuer diesen Sonderfall gilt "keine Aehnlichkeit"."""
if b == 0:
return Decimal("Infinity") if a != 0 else Decimal("0")
return abs(a - b) / abs(b)
@dataclass
class _Series:
"""Eine erkannte Serie: chronologisch sortierte Buchungen eines
Betrags-Clusters mit zugeordnetem Rhythmus.
`volatile` markiert, dass der Cluster-Split (Schritt 3) innerhalb der
Empfaenger-Gruppe eine NEUERE, betragsmaessig abweichende Buchung
abgetrennt hat - der hier vorgeschlagene Betrag koennte also schon
wieder veraltet sein (siehe VOLATILITAETS_HINWEIS)."""
items: list[Transaction]
rhythm: str
volatile: bool = False
@property
def first(self) -> Transaction:
return self.items[0]
@property
def last(self) -> Transaction:
return self.items[-1]
def _amount_clusters(items: list[Transaction]) -> list[list[Transaction]]:
"""Schritt 3: teilt chronologisch sortierte Buchungen einer
Empfaenger-Gruppe in Betrags-Cluster. Eine Buchung haengt sich an das
Cluster, dessen zuletzt aufgenommenes Mitglied gleiches Vorzeichen und
eine relative Differenz <= CLUSTER_TOL hat (greedy, erstes passendes
Cluster gewinnt) - das haelt eine langsam driftende Serie (Preiserhoehung)
zusammen, trennt aber parallele Vertraege mit deutlich anderem Betrag."""
clusters: list[list[Transaction]] = []
for t in items:
for cluster in clusters:
last = cluster[-1]
same_sign = (t.amount > 0) == (last.amount > 0)
if same_sign and _rel_diff(Decimal(t.amount), Decimal(last.amount)) <= CLUSTER_TOL:
cluster.append(t)
break
else:
clusters.append([t])
return clusters
def _classify(dates: list[date]) -> str | None:
"""Schritt 4: bestimmt den Rhythmus einer Serie ueber den Median der
Buchungsabstaende (robust gegen einzelne Ausreisser, z.B.
Wochenend-/Feiertagsverschiebung einer einzelnen Buchung)."""
if len(dates) < 2:
return None
gaps = [(b - a).days for a, b in zip(dates, dates[1:])]
median_gap = statistics.median(gaps)
for rhythm, (lo, hi, min_belege) in RHYTHMS.items():
if len(dates) >= min_belege and lo <= median_gap <= hi:
return rhythm
return None
def _merge_gap_ok(gap_days: int, step: int) -> bool:
"""Schritt 6: Luecke zwischen Serienende und -beginn im Fenster
[0,4; 1,6] * Schrittweite (ganzzahliger Bruchvergleich, keine Rundung)."""
return (gap_days * MERGE_GAP_MIN_DEN >= MERGE_GAP_MIN_NUM * step
and gap_days * MERGE_GAP_MAX_DEN <= MERGE_GAP_MAX_NUM * step)
def _mergeable(a: _Series, b: _Series) -> bool:
"""Prueft die Umfirmierungs-Merge-Bedingungen aus Schritt 6 fuer ein
Paar (A endet, B beginnt danach): gleicher Rhythmus, plausible Luecke,
Faelligkeitstag nah beieinander (Transitionspunkte: letzte Buchung von A
gegen erste Buchung von B), Betrag nicht sprunghaft veraendert."""
if a.rhythm != b.rhythm:
return False
if a.last.booking_date >= b.first.booking_date:
return False
step = STEP_DAYS[a.rhythm]
gap = (b.first.booking_date - a.last.booking_date).days
if not _merge_gap_ok(gap, step):
return False
if abs(a.last.booking_date.day - b.first.booking_date.day) > MERGE_DUE_DAY_TOL:
return False
same_sign = (a.last.amount > 0) == (b.first.amount > 0)
if not same_sign:
return False
return _rel_diff(Decimal(b.first.amount), Decimal(a.last.amount)) <= MERGE_TOL
def _try_merge(series_list: list[_Series]) -> list[_Series]:
"""Schritt 6: fasst Serien desselben Kontos ueber Gruppengrenzen
(unterschiedlicher normalisierter Empfaenger-Name, z.B. nach einer
Umfirmierung) zusammen, solange `_mergeable` zutrifft. Laeuft iterativ
bis zum Fixpunkt, damit eine bereits gemergte Serie mit einer weiteren,
noch juengeren Serie erneut zusammengefasst werden kann (z.B. zwei
Umbenennungen hintereinander)."""
series_list = list(series_list)
changed = True
while changed:
changed = False
for i, a in enumerate(series_list):
for j, b in enumerate(series_list):
if i == j or not _mergeable(a, b):
continue
merged = _Series(
items=sorted(a.items + b.items, key=lambda t: t.booking_date),
rhythm=a.rhythm,
volatile=a.volatile or b.volatile,
)
series_list = [s for k, s in enumerate(series_list) if k not in (i, j)]
series_list.append(merged)
changed = True
break
if changed:
break
return series_list
def _covered_by_existing(cand_name: str, cand_amount: Decimal, rhythm: str, due_day: int,
existing: list[RecurringItem]) -> bool:
"""Schritt 8 (Bestandsabgleich): ein Vorschlag entfaellt, wenn er bereits
als Fixposten gepflegt ist - ueber einen von drei Wegen:
(a) Namens-Substring-Match (normalisiert, in beide Richtungen: sowohl
Kurz- als auch Langschreibweisen kommen in der Praxis in beiden
Datenquellen vor);
(b) Rhythmus + Faelligkeitstag + Betrag innerhalb enger Toleranz (falls
der Fixposten unter einem ganz anderen Namen gepflegt wurde);
(c) Token-Match: gleicher Rhythmus, Faelligkeitstag-Differenz <= 2 UND
mindestens ein gemeinsames Namens-Token (>=5 Zeichen) - faengt
kuratierte Alias-/Variabel-Fixposten, deren Name UND Betrag stark
vom automatisch erkannten Vorschlag abweichen (Live-Gate-Fund: ein
Sammel-Fixposten unter Alias-Namen des Anbieters deckt den
automatisch erkannten Vorschlag desselben Anbieters unter seinem
regulaeren Empfaenger-Namen ab, obwohl weder (a) noch (b) greifen)."""
cand_norm = _norm(cand_name)
cand_tokens = _tokens(cand_name)
for item in existing:
item_norm = _norm(item.name)
if cand_norm in item_norm or item_norm in cand_norm:
return True
if (item.rhythm == rhythm
and abs(item.due_day - due_day) <= BESTAND_DUE_DAY_TOL
and _rel_diff(cand_amount, Decimal(item.amount)) <= BESTAND_TOL):
return True
if (item.rhythm == rhythm
and abs(item.due_day - due_day) <= BESTAND_DUE_DAY_TOL
and cand_tokens & _tokens(item.name)):
return True
return False
def suggest_recurring(session: Session, today: date | None = None) -> list[dict]:
"""Ermittelt Vorschlaege fuer wiederkehrende Posten aus bestaetigten
Buchungen der letzten WINDOW_DAYS Tage. `today` ist ausschliesslich zu
Testzwecken injizierbar (deterministischer Aktiv-Check) - im
Produktivbetrieb liefert der Default `date.today()`. Reihenfolge der
Schritte gemaess Spec, mit einer bewussten Umstellung gegenueber der
Nummerierung dort: der Aktiv-Check (Schritt 5) laeuft NACH dem
Umfirmierungs-Merge (Schritt 6) auf der ggf. gemergten Serie - sonst
wuerde eine per Umfirmierung fortgesetzte Serie an ihrem alten,
laengst inaktiven Teil scheitern, bevor der Merge sie retten kann."""
if today is None:
today = date.today()
cutoff = today - timedelta(days=WINDOW_DAYS)
# Schritt 1: Datenbasis.
txs = session.execute(
select(Transaction)
.where(Transaction.status == "confirmed", Transaction.booking_date >= cutoff)
).scalars().all()
# Schritt 2: Gruppierung je (Konto, normalisierter Empfaenger).
groups: dict[tuple[int, str], list[Transaction]] = {}
for t in txs:
groups.setdefault((t.account_id, _norm(t.counterparty)), []).append(t)
# Schritt 3+4: je Gruppe Betrags-Cluster bilden und Rhythmus klassifizieren.
series_by_account: dict[int, list[_Series]] = {}
for (account_id, _name_norm), items in groups.items():
items_sorted = sorted(items, key=lambda t: t.booking_date)
clusters = _amount_clusters(items_sorted)
classified = [(cluster, _classify([t.booking_date for t in cluster]))
for cluster in clusters]
# Fuer den Volatilitaets-Check zaehlt eine neuere Buchung nur dann als
# "abgetrennt", wenn sie NICHT bereits zu einem ANDEREN qualifizierten
# (klassifizierten) Cluster derselben Gruppe gehoert - sonst waeren
# zwei parallele, stabile Vertraege (jeder fuer sich eine gueltige
# eigene Serie) faelschlich als "volatil" markiert, nur weil der
# jeweils andere Vertrag zufaellig spaeter im Monat faellig ist
# (Nachtrag 3b, Fable-Gate-Korrektur nach dem ersten Live-Gate-Fund).
qualified_items = {t for cluster, rhythm in classified if rhythm is not None
for t in cluster}
for cluster, rhythm in classified:
if rhythm is None:
continue
# Volatilitaets-Hinweis: hat der Cluster-Split innerhalb DIESER
# Empfaenger-Gruppe (gleiches Konto, gleiches Vorzeichen) eine
# NEUERE Buchung in einen UNQUALIFIZIERTEN Cluster abgetrennt
# (z.B. eine einzelne Ausreisser-Buchung, die allein keine Serie
# bildet), ist der hier vorgeschlagene (letzte) Betrag ggf. schon
# veraltet.
cluster_sign = cluster[-1].amount > 0
volatile = any(
(t.amount > 0) == cluster_sign
and t.booking_date > cluster[-1].booking_date
and t not in qualified_items
for t in items_sorted
)
series_by_account.setdefault(account_id, []).append(
_Series(items=cluster, rhythm=rhythm, volatile=volatile))
existing = list(session.execute(select(RecurringItem)).scalars())
suggestions: list[dict] = []
for series_list in series_by_account.values():
# Schritt 6: Umfirmierungs-Merge ueber Gruppengrenzen, je Konto.
for s in _try_merge(series_list):
# Schritt 5: Aktiv-Check auf der (ggf. gemergten) finalen Serie.
step = STEP_DAYS[s.rhythm]
delta_tage = (today - s.last.booking_date).days
if delta_tage * ACTIVITY_FACTOR_DEN > step * ACTIVITY_FACTOR_NUM:
continue
# Schritt 7: Vorschlagswerte aus der neuesten Buchung.
last = s.last
name = last.counterparty
amount = Decimal(last.amount)
due_day = last.booking_date.day
start_date = last.booking_date if s.rhythm in ("quarterly", "yearly") else None
cat_counts = Counter(t.category_id for t in s.items if t.category_id is not None)
category_id = cat_counts.most_common(1)[0][0] if cat_counts else None
hinweis = ""
if len(s.items) >= 2:
previous = Decimal(s.items[-2].amount)
# "Gestiegen" bezieht sich auf den Betragswert (Ausgaben sind
# negativ: gestiegen heisst betragsmaessig groesser, also
# abs(neu) > abs(alt)), nicht auf das Vorzeichen.
if abs(amount) > abs(previous):
hinweis = f"Betrag zuletzt gestiegen (vorher {eur(abs(previous))} €)"
if s.volatile:
hinweis = f"{hinweis} {VOLATILITAETS_HINWEIS}".strip()
# Schritt 8: Bestandsabgleich.
if _covered_by_existing(name, amount, s.rhythm, due_day, existing):
continue
suggestions.append({
"name": name,
"amount": amount,
"rhythm": s.rhythm,
"due_day": due_day,
"start_date": start_date,
"category_id": category_id,
"hinweis": hinweis,
})
return suggestions

1
finance/app/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,212 @@
* { box-sizing: border-box; }
body {
font-family: system-ui, sans-serif;
max-width: 960px;
margin: 0 auto;
padding: 1rem;
color: #222;
background: #fff;
}
nav {
display: flex;
gap: 1rem;
align-items: center;
border-bottom: 1px solid #ccc;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
position: sticky;
top: 0;
z-index: 10;
background: #fff;
}
nav a {
text-decoration: none;
color: #06c;
}
nav form {
margin-left: auto;
}
table {
border-collapse: collapse;
width: 100%;
margin-bottom: 1.5rem;
}
th, td {
border: 1px solid #ddd;
padding: 0.4rem 0.6rem;
text-align: left;
}
th {
background: #f2f2f2;
}
tr:nth-child(even) {
background: #fafafa;
}
.neg {
color: #b00;
}
.amount {
text-align: right;
}
td.account {
word-break: break-all;
max-width: 12rem;
}
.warning {
background: #fff3cd;
border: 1px solid #e0c26b;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: 4px;
}
.success {
background: #d4edda;
border: 1px solid #7bc088;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: 4px;
}
.total-balance {
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 1rem;
}
fieldset {
margin-bottom: 1.5rem;
}
form.filter-form {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: end;
margin-bottom: 1rem;
}
form.filter-form label {
display: flex;
flex-direction: column;
font-size: 0.85rem;
}
iframe.grafana {
width: 100%;
height: 480px;
border: 1px solid #ccc;
}
button {
cursor: pointer;
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.badge {
display: inline-block;
padding: 0.15rem 0.6rem;
border-radius: 999px;
font-size: 0.85rem;
}
.badge-warning {
background: #fff3cd;
color: #7a5c00;
}
.badge-error {
background: #fdecea;
color: #b00020;
}
.badge-ok {
background: #e6f4ea;
color: #1e7e34;
}
#dropzone {
border: 2px dashed #999;
border-radius: 8px;
padding: 2rem;
text-align: center;
cursor: pointer;
margin-bottom: 0.5rem;
color: #555;
}
#dropzone.dragover {
background: #f0f6ff;
border-color: #06c;
}
.inline-form {
display: inline-block;
margin-right: 0.5rem;
}
.muted {
color: #888;
}
section.planning-section {
margin-bottom: 2rem;
}
section.planning-section fieldset {
margin-top: 0.75rem;
}
.version {
margin-top: 2rem;
color: #888;
text-align: center;
font-size: 0.75rem;
}
/* Ausbaustufe 7: Formular-Paare als Einheit, gesperrte Felder erkennbar. */
.entry-form label,
.planning-section form label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0 1.25rem 0.6rem 0;
white-space: nowrap;
}
.entry-form select,
.planning-section form select {
max-width: 14rem;
}
.entry-form .entry-art {
display: block;
margin-bottom: 0.75rem;
}
.entry-form .entry-art > label { font-weight: bold; }
.entry-form .entry-art .muted { margin-left: 0.5rem; }
input:disabled, select:disabled {
background: #e3e3e3;
color: #8a8a8a;
cursor: not-allowed;
}
label:has(input:disabled), label:has(select:disabled) {
opacity: 0.55;
}
.project-block {
margin-top: 1rem;
}

View File

@@ -0,0 +1,67 @@
<table>
<thead>
<tr><th>Datei</th><th>Bank</th><th>Zeitraum</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{% for s in statements %}
<tr>
<td>{{ s.filename }}</td>
<td>{{ s.bank }}</td>
<td>
{% if s.period_start and s.period_end %}
{{ s.period_start.strftime('%d.%m.%Y') }} {{ s.period_end.strftime('%d.%m.%Y') }}
{% endif %}
</td>
<td>
{% if s.status == "draft" %}
<span class="badge badge-warning">wartet auf Bestätigung</span>
{% elif s.status == "error" %}
<span class="badge badge-error">Fehler: {{ s.error_message }}</span>
{% elif s.status == "confirmed" %}
<span class="badge badge-ok">übernommen</span>
{% else %}
<span class="badge">{{ s.status }}</span>
{% endif %}
</td>
<td>
{% if s.status == "draft" %}
<details hx-get="/import/{{ s.id }}/preview-fragment"
hx-trigger="toggle once" hx-target="find .preview-slot" hx-swap="innerHTML">
<summary>Vorschau</summary>
<div class="preview-slot">Lädt…</div>
</details>
<form class="inline-form" hx-post="/api/imports/{{ s.id }}/confirm" hx-swap="none"
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
<button type="submit">Übernehmen</button>
</form>
<form class="inline-form" hx-delete="/api/imports/{{ s.id }}" hx-swap="none"
hx-confirm="Import „{{ s.filename }}“ wirklich verwerfen?"
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
<button type="submit">Verwerfen</button>
</form>
<button type="button" class="inline-form" disabled title="Nur für bestätigte Importe">Import zurückrollen</button>
{% elif s.status == "confirmed" %}
<span class="muted">Bestätigt — Buchungen übernommen</span>
<button type="button" class="inline-form" disabled title="Nur für Entwürfe">Übernehmen</button>
<button type="button" class="inline-form" disabled title="Nur für Entwürfe">Verwerfen</button>
<form class="inline-form" hx-post="/api/imports/{{ s.id }}/rollback" hx-swap="none"
hx-confirm="Diesen Import und ALLE zugehörigen Buchungen unwiderruflich löschen? Salden und Projektionen ändern sich."
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
<button type="submit">Import zurückrollen</button>
</form>
{% else %}
<button type="button" class="inline-form" disabled title="Nur für Entwürfe">Übernehmen</button>
<form class="inline-form" hx-delete="/api/imports/{{ s.id }}" hx-swap="none"
hx-confirm="Import „{{ s.filename }}“ wirklich verwerfen?"
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
<button type="submit">Verwerfen</button>
</form>
<button type="button" class="inline-form" disabled title="Nur für bestätigte Importe">Import zurückrollen</button>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="5">Noch keine Imports vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -0,0 +1,31 @@
<table>
<thead>
<tr><th>Datum</th><th>Verwendungszweck</th><th>Empfänger/Zahler</th><th>Betrag</th><th></th></tr>
</thead>
<tbody>
{% for tx in transactions %}
<tr>
<td>{{ tx.booking_date.strftime('%d.%m.%Y') }}</td>
<td>{{ tx.purpose }}</td>
<td>{{ tx.counterparty }}</td>
<td class="{{ 'neg' if tx.amount < 0 else '' }}">{{ tx.amount|eur }} €</td>
<td>{% if tx.is_duplicate %}<span class="badge badge-warning">Duplikat</span>{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="5">Keine Buchungen in diesem Import.</td></tr>
{% endfor %}
</tbody>
</table>
<p>
Saldo-Status:
{% if balance_ok is none %}
<span class="badge">Saldo-Prüfung: nicht verfügbar (Format ohne Saldodaten)</span>
{% elif balance_ok %}
<span class="badge badge-ok">Saldo plausibel</span>
{% else %}
<span class="badge badge-error">Saldo-Differenz bitte prüfen</span>
{% endif %}
· Eröffnungssaldo: {{ statement.opening_balance|eur if statement.opening_balance is not none else '' }} €
· Endsaldo: {{ statement.closing_balance|eur if statement.closing_balance is not none else '' }} €
· Duplikate: {{ duplicates }}
</p>

View File

@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Admin Finanzberatung{% endblock %}
{% block content %}
<h1>Admin</h1>
<section class="admin-section">
<h2>Passwort ändern</h2>
<p class="muted">Gilt gemeinsam für GUI- und Grafana-Login. Bestehende Sitzungen bleiben nach der Änderung gültig.</p>
{% if error %}<div class="warning">{{ error }}</div>{% endif %}
{% if success %}<div class="success">{{ success }}</div>{% endif %}
<form method="post" action="/admin/passwort">
<label>Altes Passwort
<input type="password" name="alt" required>
</label>
<label>Neues Passwort (mind. 8 Zeichen)
<input type="password" name="neu" minlength="8" required>
</label>
<label>Neues Passwort wiederholen
<input type="password" name="neu2" minlength="8" required>
</label>
<button type="submit">Passwort ändern</button>
</form>
</section>
<section class="admin-section">
<h2>Regeln neu anwenden</h2>
<p class="muted">Wendet die aktuellen Kategorie-Regeln rückwirkend auf noch nicht kategorisierte, bestätigte Buchungen an.</p>
<button type="button" hx-post="/api/category-rules/apply" hx-swap="none"
hx-on::after-request="handleApplyRulesResult(event)">
Regeln neu anwenden
</button>
<p id="apply-rules-result"></p>
</section>
<section class="admin-section">
<h2>Kategorien</h2>
<p class="muted">Kategorien für Buchungen, Fixposten und Szenario-Modifikatoren.
Löschen ist bewusst nicht vorgesehen (Kategorien hängen an Buchungen und Regeln).</p>
<table>
<thead><tr><th>Name</th><th></th></tr></thead>
<tbody>
{% for c in categories %}
<tr id="cat-row-{{ c.id }}">
<td>{{ c.name }}</td>
<td><button type="button" onclick="toggleEdit('cat', {{ c.id }}, true)">Bearbeiten</button></td>
</tr>
<tr id="cat-edit-{{ c.id }}" hidden>
<td colspan="2">
<form hx-ext="json-form" hx-patch="/api/categories/{{ c.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ c.name }}" required maxlength="100"></label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('cat', {{ c.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="2">Noch keine Kategorien.</td></tr>
{% endfor %}
</tbody>
</table>
<fieldset>
<legend>Neue Kategorie anlegen</legend>
<form hx-ext="json-form" hx-post="/api/categories" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required maxlength="100"></label>
<button type="submit">Anlegen</button>
</form>
</fieldset>
</section>
<script>
function handleApplyRulesResult(event) {
var result = document.getElementById('apply-rules-result');
if (event.detail.successful) {
var data = JSON.parse(event.detail.xhr.responseText);
result.textContent = data.categorized + ' Buchung(en) neu kategorisiert.';
} else {
result.textContent = 'Fehler beim Anwenden der Regeln.';
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,87 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Finanzberatung{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js"></script>
</head>
<body>
<nav>
<a href="/">Übersicht</a>
<a href="/import">Import</a>
<a href="/buchungen">Buchungen</a>
<a href="/salden">Salden</a>
<a href="/planung">Planung</a>
<a href="/szenarien">Szenarien</a>
<a href="/admin">Admin</a>
<a href="/hilfe">Hilfe</a>
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a>
<form method="post" action="/logout">
<button type="submit">Logout</button>
</form>
</nav>
<main>
{% block content %}{% endblock %}
</main>
<script>
document.body.addEventListener('htmx:responseError', function (e) {
alert('Fehler: ' + (e.detail.xhr.responseText || e.detail.xhr.status));
});
// Minimale JSON-Kodierung für htmx-Formulare/Selects, die gegen die JSON-APIs
// (POST/PATCH /api/...) senden. Felder mit data-type="int" werden als Zahl
// kodiert, leere Werte als null, alles andere bleibt String.
htmx.defineExtension('json-form', {
onEvent: function (name, evt) {
if (name === 'htmx:configRequest') {
evt.detail.headers['Content-Type'] = 'application/json';
}
},
encodeParameters: function (xhr, parameters, elt) {
var out = {};
for (var k in parameters) {
var v = parameters[k];
var field = elt.matches && elt.matches('[name="' + k + '"]')
? elt
: elt.querySelector('[name="' + k + '"]');
var kind = field && field.dataset ? field.dataset.type : null;
if (v === '') {
out[k] = null;
} else if (kind === 'int') {
out[k] = parseInt(v, 10);
} else if (kind === 'amount') {
// Deutsche Betragseingabe: "1.234,56" -> "1234.56". Ohne Komma
// bleibt der Wert unveraendert (Punkt-Eingaben funktionieren weiter).
var s = String(v).replace(/[\s€]/g, '');
if (s.indexOf(',') !== -1) {
s = s.replace(/\./g, '').replace(',', '.');
}
out[k] = s;
} else {
out[k] = v;
}
}
// Checkboxen mit data-type="bool" werden unabhaengig vom parameters-Objekt
// ausgelesen, weil unmarkierte Checkboxen beim Serialisieren komplett
// fehlen wuerden (kein Feld => kein "false" moeglich).
if (elt.querySelectorAll) {
elt.querySelectorAll('[data-type="bool"]').forEach(function (field) {
out[field.name] = field.checked;
});
}
xhr.overrideMimeType('text/json');
return JSON.stringify(out);
}
});
function toggleEdit(prefix, id, editing) {
// UX-Regel: Zeilen werden versteckt (hidden), nie aus dem DOM entfernt.
document.getElementById(prefix + '-row-' + id).hidden = editing;
document.getElementById(prefix + '-edit-' + id).hidden = !editing;
}
</script>
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
</body>
</html>

View File

@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}Hilfe Finanzberatung{% endblock %}
{% block content %}
<h1>Gebrauchsanleitung</h1>
<h2>Was dieses Tool ist</h2>
<p>
Liquiditätsplanung auf Basis echter Kontoauszüge: Ist-Stand aus importierten
Auszügen, Zukunft aus gepflegten Planposten und durchgerechneten Szenarien.
</p>
<h2>Konzepte</h2>
<ul>
<li>
<strong>Konten:</strong> entstehen automatisch beim ersten Import (per IBAN).
Über die Übersicht umbenennbar (z. B. "DKB Giro").
</li>
<li>
<strong>Kontoauszug-Import:</strong> primärer Weg ist der
Kontoumsatz-CSV-Export der Bank (DKB, VR-Bank, HypoVereinsbank) in die
Drop-Zone auf der Import-Seite ziehen (oder in die Inbox
<code>~/.local/share/finance_pod/data/inbox/</code> legen und "Inbox
scannen"). PDF-Kontoauszüge funktionieren weiterhin als Fallback, falls
kein CSV-Export vorliegt. Jeder Import landet als Entwurf: erst nach
Kontrolle der Vorschau "Übernehmen" klicken. Der Saldo-Status in der
Vorschau ist dreiwertig "Saldo plausibel" (Anfangssaldo + Buchungen =
Endsaldo bzw. lückenlose Saldo-Kette), eine rote Saldo-Differenz, oder
"Saldo-Prüfung nicht verfügbar", wenn das Format (z. B. HVB-CSV) keine
Saldodaten liefert dort lohnt ein Blick in die Zeilenzahl und
Stichproben vor dem Übernehmen. CSV-Importe mit Datei-Kontostand (DKB)
oder lückenloser Saldo-Kette (VR) aktualisieren beim Import automatisch
den Konto-Saldo-Anker, sofern er dadurch nicht älter würde als ein bereits
vorhandener. Duplikate werden erkannt und nicht doppelt übernommen.
Bestätigte Importe lassen sich als Ganzes zurückrollen (Auszug + alle
seine Buchungen), z. B. um einen Auszug mit einem verbesserten Parser neu
zu importieren; einzelne Buchungen bleiben unlöschbar.
</li>
<li>
<strong>Salden-Seite:</strong> zeigt den Saldo je Konto zu einem
wählbaren Stichtag (Default: heute) sowie eine Monatsanfangs-Übersicht
vom frühesten Buchungsmonat bis heute, absteigend. Die
Monatsanfangs-Werte gelten "Stand: Ende des Vortags" (letzter Tag des
Vormonats), damit Buchungen am 1. eines Monats den Ausweis des
Monatsanfangs nicht verfälschen. Konten ohne Anker sind mit "ohne Anker —
relative Werte" gekennzeichnet.
</li>
<li>
<strong>Kategorien &amp; Regeln:</strong> Buchungen lassen sich kategorisieren;
eine Regel ("Muster im Text → Kategorie") kategorisiert künftige Importe
automatisch. Regel direkt aus einer Buchung erzeugen: Button in der
Buchungsliste.
</li>
<li>
<strong>Wiederkehrende Posten:</strong> Miete, Gehalt, Abos mit Rhythmus
(monatlich/quartalsweise/jährlich) und Fälligkeitstag. Der
Vorschlags-Knopf erkennt Kandidaten aus mind. 3 Monaten gleichartiger
Buchungen.
</li>
<li>
<strong>Einmalposten:</strong> einzelne künftige Zahlungen (z. B.
Steuernachzahlung).
</li>
<li>
<strong>Kredite:</strong> Annuität oder endfällig; Tilgungsplan
aufklappbar. Ein Kredit wirkt erst, wenn er einem Szenario zugeordnet ist.
</li>
<li>
<strong>Szenarien:</strong> eine "Was-wäre-wenn"-Rechnung. Basis = alle
wiederkehrenden Posten + Einmalposten. Varianten entstehen durch Zuordnen
von Krediten und Modifikatoren (Kategorie oder Posten prozentual/absolut
kürzen oder ganz streichen). "Durchrechnen" liefert: tiefster Kontostand
mit Datum, erstes Datum unter 0 und unter der Warnschwelle.
Varianten wie „Best Case“ pflegst du in der Tabelle „Einträge“ des
Szenarios: Art „Ende“ lässt einen wiederkehrenden Posten (z. B. eine
auslaufende Miete) zu einem Datum enden, ohne den Posten selbst zu
ändern; „Prozent“/„Absolut“ kürzen Beträge; „Entfällt“ blendet sie ganz
aus; „Einmalzahlung“ ergänzt einen einmaligen Zu- oder Abfluss, der nur
in diesem Szenario zählt. Jeder Eintrag ist über „Bearbeiten“ direkt
änderbar.
</li>
<li>
<strong>Grafana:</strong> Kontostand-Verläufe, Monatsausgaben nach
Kategorie und der Szenario-Vergleich als Kurven (einmal mit demselben
Passwort wie hier anmelden, dann erscheint das Dashboard auch in der
Übersicht).
</li>
</ul>
<h2>Empfohlener Arbeitsablauf</h2>
<ol>
<li>Monatlich: neue Auszüge importieren, Vorschau prüfen, übernehmen.</li>
<li>
Unkategorisierte Buchungen durchsehen; für Wiederkehrendes Regeln
anlegen.
</li>
<li>Planung pflegen: Vorschläge prüfen, Einmalposten eintragen.</li>
<li>
Fragestellung ("Können wir uns X leisten?") als Szenario-Varianten
abbilden und durchrechnen; Entscheidung anhand Tiefpunkt und
Unterschreitungsdaten treffen, Kurven in Grafana vergleichen.
</li>
</ol>
<h2>Grundregeln</h2>
<ul>
<li>Bestätigte Buchungen sind die Wahrheit nie ändern, nur kategorisieren.</li>
<li>Zukunft ausschließlich über Szenarien planen.</li>
<li>Empfehlungen immer mit durchgerechneten Zahlen begründen.</li>
</ul>
{% endblock %}

View File

@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}Import Finanzberatung{% endblock %}
{% block content %}
<h1>Import</h1>
<div id="dropzone">
Kontoumsatz-CSV oder Kontoauszug-PDF hier ablegen oder klicken zum Hochladen
<input type="file" id="file-input" accept=".csv,.pdf,application/pdf,text/csv" style="display:none">
</div>
<button type="button" hx-post="/api/imports/scan-inbox" hx-swap="none"
hx-on::after-request="if(event.detail.successful){htmx.trigger(document.getElementById('imports'), 'refresh')}">
Inbox scannen
</button>
<p class="muted">Durchsucht <code>~/.local/share/finance_pod/data/inbox/</code> nach neuen CSV- oder PDF-Kontoauszügen.</p>
<p id="upload-message"></p>
<h2>Importe</h2>
<div id="imports" hx-get="/import/list" hx-trigger="every 5s, refresh" hx-swap="innerHTML">
{% include "_import_list.html" %}
</div>
<script>
(function () {
var dropzone = document.getElementById('dropzone');
var fileInput = document.getElementById('file-input');
var message = document.getElementById('upload-message');
function refreshImports() {
htmx.trigger(document.getElementById('imports'), 'refresh');
}
function uploadFile(file) {
if (!file) return;
var fd = new FormData();
fd.append('file', file);
message.textContent = 'Lade hoch: ' + file.name + ' …';
fetch('/api/imports/upload', { method: 'POST', body: fd })
.then(function (resp) {
return resp.json().then(function (data) { return { ok: resp.ok, data: data }; });
})
.then(function (result) {
if (result.ok) {
message.textContent = 'Hochgeladen: ' + file.name + ' Status: ' + result.data.status;
} else {
message.textContent = 'Fehler beim Hochladen: ' + (result.data.detail || 'unbekannter Fehler');
}
refreshImports();
})
.catch(function (err) {
message.textContent = 'Fehler beim Hochladen: ' + err;
});
}
dropzone.addEventListener('click', function () { fileInput.click(); });
fileInput.addEventListener('change', function () {
uploadFile(fileInput.files[0]);
fileInput.value = '';
});
dropzone.addEventListener('dragover', function (e) {
e.preventDefault();
dropzone.classList.add('dragover');
});
dropzone.addEventListener('dragleave', function () {
dropzone.classList.remove('dragover');
});
dropzone.addEventListener('drop', function (e) {
e.preventDefault();
dropzone.classList.remove('dragover');
var file = e.dataTransfer.files && e.dataTransfer.files[0];
uploadFile(file);
});
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,84 @@
{% extends "base.html" %}
{% block title %}Übersicht Finanzberatung{% endblock %}
{% block content %}
<h1>Übersicht</h1>
{% if projection and projection.below_threshold_date %}
<div class="warning">
Achtung: Prognostizierter Saldo unterschreitet den Schwellenwert am
<strong>{{ projection.below_threshold_date.strftime('%d.%m.%Y') }}</strong>.
</div>
{% endif %}
<div class="total-balance {{ 'neg' if total < 0 else '' }}">
Gesamtsaldo: {{ total|eur }} €
</div>
<table>
<thead>
<tr>
<th>Konto</th><th>Bank</th><th>Saldo</th><th>Anker</th>
<th>Umbenennen</th><th>Anker pflegen</th>
</tr>
</thead>
<tbody>
{% for account, balance in balances %}
<tr>
<td class="account">{{ account.name }}</td>
<td>{{ account.bank }}</td>
<td class="{{ 'neg' if balance < 0 else '' }}">{{ balance|eur }} €</td>
<td>
{% if account.anchor_date is not none and account.anchor_balance is not none %}
Anker: {{ account.anchor_balance|eur }} € am {{ account.anchor_date.strftime('%d.%m.%Y') }}
{% else %}
kein Anker
{% endif %}
</td>
<td>
<form class="inline-form" hx-ext="json-form" hx-patch="/api/accounts/{{ account.id }}"
hx-swap="none" hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<input type="text" name="name" value="{{ account.name }}" required maxlength="100">
<button type="submit">Umbenennen</button>
</form>
</td>
<td>
<form class="inline-form" hx-ext="json-form" hx-patch="/api/accounts/{{ account.id }}"
hx-swap="none" hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<input type="date" name="anchor_date"
value="{{ account.anchor_date.isoformat() if account.anchor_date is not none else '' }}" required>
<input type="text" name="anchor_balance" data-type="amount" placeholder="500,00"
value="{{ account.anchor_balance|eur if account.anchor_balance is not none else '' }}" required>
<button type="submit">Anker setzen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="6">Keine Konten angelegt.</td></tr>
{% endfor %}
</tbody>
</table>
<h2>Nächste anstehende Posten (30 Tage)</h2>
<table>
<thead>
<tr><th>Datum</th><th>Bezeichnung</th><th>Betrag</th></tr>
</thead>
<tbody>
{% for item in upcoming %}
<tr>
<td>{{ item.date.strftime('%d.%m.%Y') }}</td>
<td>{{ item.name }}</td>
<td class="{{ 'neg' if item.amount < 0 else '' }}">{{ item.amount|eur }} €</td>
</tr>
{% else %}
<tr><td colspan="3">Keine anstehenden Posten.</td></tr>
{% endfor %}
</tbody>
</table>
<h2>Grafana-Dashboard</h2>
<iframe class="grafana" src="http://{{ request.url.hostname or '127.0.0.1' }}:8097/d/finanzen/finanzen?orgId=1&kiosk"></iframe>
<p class="hint">Kein Diagramm sichtbar? Einmal in
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank">Grafana anmelden</a>
(gleiches Passwort wie hier).</p>
{% endblock %}

View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anmelden Finanzberatung</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<h1>Finanzberatung</h1>
<form method="post" action="/login">
<label>Benutzer
<input name="username" placeholder="Benutzer">
</label>
<label>Passwort
<input name="password" type="password" placeholder="Passwort">
</label>
<button type="submit">Anmelden</button>
</form>
</body>
</html>

View File

@@ -0,0 +1,303 @@
{% extends "base.html" %}
{% block title %}Planung Finanzberatung{% endblock %}
{% block content %}
<h1>Planung</h1>
<section class="planning-section">
<h2>Wiederkehrende Posten</h2>
<table>
<thead>
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th>Start</th><th>Ende</th><th>Kategorie</th><th></th></tr>
</thead>
<tbody>
{% for r in recurring %}
<tr id="rec-row-{{ r.id }}">
<td>{{ r.name }}</td>
<td class="{{ 'neg' if r.amount < 0 else '' }}">{{ r.amount|eur }} €</td>
<td>{{ r.rhythm|de_label }}</td>
<td>{{ r.due_day }}</td>
<td>{{ r.start_date.strftime('%d.%m.%Y') if r.start_date else '' }}</td>
<td>{{ r.end_date.strftime('%d.%m.%Y') if r.end_date else '' }}</td>
<td>{{ category_names.get(r.category_id, '') }}</td>
<td>
<button type="button" onclick="toggleEdit('rec', {{ r.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/recurring/{{ r.id }}" hx-swap="none"
hx-confirm="„{{ r.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Löschen</button>
</form>
</td>
</tr>
<tr id="rec-edit-{{ r.id }}" hidden>
<td colspan="8">
<form hx-ext="json-form" hx-patch="/api/recurring/{{ r.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ r.name }}" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ r.amount|eur }}" required></label>
<label>Rhythmus
<select name="rhythm" data-type="str">
{% for rh in rhythms %}<option value="{{ rh }}" {% if rh == r.rhythm %}selected{% endif %}>{{ rh|de_label }}</option>{% endfor %}
</select>
</label>
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" value="{{ r.due_day }}" required></label>
<label>Start <input type="date" name="start_date" value="{{ r.start_date.isoformat() if r.start_date else '' }}"></label>
<label>Ende <input type="date" name="end_date" value="{{ r.end_date.isoformat() if r.end_date else '' }}"></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value=""></option>
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == r.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
</select>
</label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('rec', {{ r.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="8">Noch keine wiederkehrenden Posten.</td></tr>
{% endfor %}
</tbody>
</table>
<fieldset>
<legend>Neuen wiederkehrenden Posten anlegen</legend>
<form hx-ext="json-form" hx-post="/api/recurring" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="-49,99" required></label>
<label>Rhythmus
<select name="rhythm" data-type="str">
{% for rh in rhythms %}<option value="{{ rh }}">{{ rh|de_label }}</option>{% endfor %}
</select>
</label>
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" required></label>
<label>Start (optional) <input type="date" name="start_date"></label>
<label>Ende (optional) <input type="date" name="end_date"></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value=""></option>
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</label>
<button type="submit">Anlegen</button>
</form>
</fieldset>
<fieldset>
<legend>Vorschläge aus Buchungen</legend>
{% if suggestions %}
<table>
<thead>
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th>Start</th><th></th></tr>
</thead>
<tbody>
{% for s in suggestions %}
<tr>
<td>{{ s.name }}</td>
<td class="{{ 'neg' if s.amount < 0 else '' }}">
{{ s.amount|eur }} €
{% if s.hinweis %}<span class="muted">{{ s.hinweis }}</span>{% endif %}
</td>
<td>{{ s.rhythm|de_label }}</td>
<td>{{ s.due_day }}</td>
<td>{{ s.start_date.strftime('%d.%m.%Y') if s.start_date else '' }}</td>
<td>
<form class="inline-form" hx-ext="json-form" hx-post="/api/recurring" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<input type="hidden" name="name" value="{{ s.name }}">
<input type="hidden" name="amount" value="{{ s.amount }}">
<input type="hidden" name="rhythm" value="{{ s.rhythm }}">
<input type="hidden" name="due_day" data-type="int" value="{{ s.due_day }}">
<input type="hidden" name="category_id" data-type="int" value="{{ s.category_id if s.category_id is not none else '' }}">
<input type="hidden" name="start_date" value="{{ s.start_date.isoformat() if s.start_date else '' }}">
<button type="submit">Vorschlag übernehmen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">Keine Vorschläge.</p>
{% endif %}
<p class="muted">Erkannt werden monatliche, vierteljährliche und jährliche Serien; Betrag = jeweils letzte Buchung.</p>
</fieldset>
</section>
<section class="planning-section">
<h2>Einmalposten</h2>
<table>
<thead>
<tr><th>Name</th><th>Betrag</th><th>Fällig am</th><th>Kategorie</th><th></th></tr>
</thead>
<tbody>
{% for p in planned %}
<tr id="pln-row-{{ p.id }}">
<td>{{ p.name }}</td>
<td class="{{ 'neg' if p.amount < 0 else '' }}">{{ p.amount|eur }} €</td>
<td>{{ p.due.strftime('%d.%m.%Y') }}</td>
<td>{{ category_names.get(p.category_id, '') }}</td>
<td>
<button type="button" onclick="toggleEdit('pln', {{ p.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/planned/{{ p.id }}" hx-swap="none"
hx-confirm="„{{ p.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Löschen</button>
</form>
</td>
</tr>
<tr id="pln-edit-{{ p.id }}" hidden>
<td colspan="5">
<form hx-ext="json-form" hx-patch="/api/planned/{{ p.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ p.name }}" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ p.amount|eur }}" required></label>
<label>Fällig am <input type="date" name="due" value="{{ p.due.isoformat() }}" required></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value=""></option>
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == p.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
</select>
</label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('pln', {{ p.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5">Noch keine Einmalposten.</td></tr>
{% endfor %}
</tbody>
</table>
<fieldset>
<legend>Neuen Einmalposten anlegen</legend>
<form hx-ext="json-form" hx-post="/api/planned" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="-2000,00" required></label>
<label>Fällig am <input type="date" name="due" required></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value=""></option>
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</label>
<button type="submit">Anlegen</button>
</form>
</fieldset>
</section>
<section class="planning-section">
<h2>Kredite</h2>
<table>
<thead>
<tr><th>Name</th><th>Summe</th><th>Zins p.a.</th><th>Laufzeit</th><th>Auszahlung</th><th>Tilgungsart</th><th></th></tr>
</thead>
<tbody>
{% for l in loans %}
<tr id="loan-row-{{ l.id }}">
<td>{{ l.name }}</td>
<td>{{ l.principal|eur }} €</td>
<td>{{ l.annual_rate_pct|eur }} %</td>
<td>{{ l.term_months }} Monate</td>
<td>{{ l.payout_date.strftime('%d.%m.%Y') }}</td>
<td>{{ l.repayment_type|de_label }}</td>
<td>
<button type="button" onclick="toggleEdit('loan', {{ l.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/loans/{{ l.id }}" hx-swap="none"
hx-confirm="„{{ l.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Löschen</button>
</form>
</td>
</tr>
<!-- Hinweis: eine Kredit-Aenderung veraendert den Tilgungsplan; danach
muessen betroffene Szenarien neu durchgerechnet werden. -->
<tr id="loan-edit-{{ l.id }}" hidden>
<td colspan="7">
<form hx-ext="json-form" hx-patch="/api/loans/{{ l.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ l.name }}" required></label>
<label>Darlehenssumme <input type="text" name="principal" data-type="amount" value="{{ l.principal|eur }}" required></label>
<label>Zins p.a. (%) <input type="text" name="annual_rate_pct" data-type="amount" value="{{ l.annual_rate_pct|eur }}" required></label>
<label>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" value="{{ l.term_months }}" required></label>
<label>Auszahlungsdatum <input type="date" name="payout_date" value="{{ l.payout_date.isoformat() }}" required></label>
<label>Tilgungsart
<select name="repayment_type" data-type="str">
{% for rt in repayment_types %}<option value="{{ rt }}" {% if rt == l.repayment_type %}selected{% endif %}>{{ rt|de_label }}</option>{% endfor %}
</select>
</label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('loan', {{ l.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
<tr>
<td colspan="7">
<details ontoggle="if(this.open){loadLoanSchedule({{ l.id }})}">
<summary>Tilgungsplan</summary>
<div id="schedule-{{ l.id }}">Wird beim Öffnen geladen…</div>
</details>
</td>
</tr>
{% else %}
<tr><td colspan="7">Noch keine Kredite.</td></tr>
{% endfor %}
</tbody>
</table>
<fieldset>
<legend>Neuen Kredit anlegen</legend>
<form hx-ext="json-form" hx-post="/api/loans" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required></label>
<label>Darlehenssumme <input type="text" name="principal" data-type="amount" placeholder="10000,00" required></label>
<label>Zins p.a. (%) <input type="text" name="annual_rate_pct" data-type="amount" placeholder="4,5" required></label>
<label>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" required></label>
<label>Auszahlungsdatum <input type="date" name="payout_date" required></label>
<label>Tilgungsart
<select name="repayment_type" data-type="str">
{% for rt in repayment_types %}<option value="{{ rt }}">{{ rt|de_label }}</option>{% endfor %}
</select>
</label>
<button type="submit">Anlegen</button>
</form>
</fieldset>
</section>
<script>
// json-form-Extension ist zentral in base.html registriert (eine
// Definition fuer alle Templates, inkl. data-type="bool"-Handling).
// toggleEdit ist ebenfalls zentral in base.html definiert (wird auf
// mehreren Seiten fuer Inline-Edit-Formulare gebraucht).
var loadedSchedules = {};
function formatIsoDate(iso) {
var parts = iso.split('-');
return parts[2] + '.' + parts[1] + '.' + parts[0];
}
function fmtEur(n) {
return Number(n).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
function loadLoanSchedule(loanId) {
if (loadedSchedules[loanId]) return;
loadedSchedules[loanId] = true;
fetch('/api/loans/' + loanId + '/schedule')
.then(function (r) { return r.json(); })
.then(function (rows) {
var html = '<table><thead><tr><th>Fällig</th><th>Rate</th><th>Zins</th>'
+ '<th>Tilgung</th><th>Restschuld</th></tr></thead><tbody>';
rows.forEach(function (r) {
html += '<tr><td>' + formatIsoDate(r.due) + '</td>'
+ '<td>' + fmtEur(r.payment) + ' €</td>'
+ '<td>' + fmtEur(r.interest) + ' €</td>'
+ '<td>' + fmtEur(r.principal) + ' €</td>'
+ '<td>' + fmtEur(r.remaining) + ' €</td></tr>';
});
html += '</tbody></table>';
document.getElementById('schedule-' + loanId).innerHTML = html;
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}Salden Finanzberatung{% endblock %}
{% block content %}
<h1>Salden</h1>
<h2>Salden am {{ stichtag.strftime('%d.%m.%Y') }}</h2>
<form method="get" action="/salden" class="inline-form">
<input type="date" name="stichtag" value="{{ stichtag.isoformat() }}">
<button type="submit">Anzeigen</button>
</form>
<table>
<thead>
<tr><th>Konto</th><th>Saldo</th></tr>
</thead>
<tbody>
{% for account, balance in balances %}
<tr>
<td class="account">{{ account.name }}</td>
<td class="amount {{ 'neg' if balance < 0 else '' }}">{{ balance|eur }} €</td>
</tr>
{% else %}
<tr><td colspan="2">Keine Konten angelegt.</td></tr>
{% endfor %}
<tr class="total-row">
<td><strong>Gesamt</strong></td>
<td class="amount {{ 'neg' if total < 0 else '' }}"><strong>{{ total|eur }} €</strong></td>
</tr>
</tbody>
</table>
<h2>Monatsanfangs-Übersicht</h2>
<p class="hint">
Stand: Ende des Vortags (letzter Tag des Vormonats) — Buchungen am 1. eines
Monats zählen bewusst noch nicht zum "Anfangs"-Saldo, sonst würde der
Monatsanfang durch Buchungen des laufenden Monats verzerrt.
</p>
<table>
<thead>
<tr>
<th>Monat</th>
{% for account in accounts %}<th>{{ account.name }}</th>{% endfor %}
<th>Gesamt</th>
</tr>
</thead>
<tbody>
{% for row in months %}
<tr data-month="{{ row['month'].isoformat() }}">
<td>{{ row['month'].strftime('%m.%Y') }}</td>
{% for account, value in row['values'] %}
<td class="amount {{ 'neg' if value < 0 else '' }}">{{ value|eur }} €</td>
{% endfor %}
<td class="amount {{ 'neg' if row['total'] < 0 else '' }}">{{ row['total']|eur }} €</td>
</tr>
{% else %}
<tr><td colspan="{{ accounts|length + 2 }}">Keine bestätigten Buchungen vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
{% if accounts_without_anchor %}
<p class="hint">
Fußnote „ohne Anker — relative Werte" (Basis 0 statt Datei-Kontostand) gilt für:
{{ accounts_without_anchor|map(attribute='name')|join(', ') }}.
</p>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,298 @@
{% extends "base.html" %}
{% block title %}Szenarien Finanzberatung{% endblock %}
{% block content %}
<h1>Szenarien</h1>
<section class="planning-section">
{% for row in scenario_rows %}
{% set sc = row.scenario %}
<fieldset>
<legend>{{ sc.name }}</legend>
<p id="sc-row-{{ sc.id }}">
{{ sc.description }}
· Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }}
· Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }}
<button type="button" onclick="toggleEdit('sc', {{ sc.id }}, true)">Bearbeiten</button>
</p>
<div id="sc-edit-{{ sc.id }}" hidden>
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ sc.name }}" required></label>
<label>Beschreibung <input type="text" name="description" value="{{ sc.description }}"></label>
<label><input type="checkbox" name="include_recurring" data-type="bool" {% if sc.include_recurring %}checked{% endif %}> Wiederkehrende Posten einschließen</label>
<label><input type="checkbox" name="include_planned" data-type="bool" {% if sc.include_planned %}checked{% endif %}> Einmalposten einschließen</label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('sc', {{ sc.id }}, false)">Abbrechen</button>
</form>
</div>
<details>
<summary>Kredite zuordnen</summary>
{% for l in loans %}
<label class="inline-form">
<input type="checkbox" {% if l.id in row.assigned_loan_ids %}checked{% endif %}
onchange="toggleScenarioLoan({{ sc.id }}, {{ l.id }}, this.checked, this)">
{{ l.name }}
</label>
{% else %}
<p>Keine Kredite vorhanden.</p>
{% endfor %}
</details>
<h3>Einträge</h3>
<table>
<thead>
<tr><th>Was</th><th>Art</th><th>Wert/Betrag</th><th>Datum</th><th></th></tr>
</thead>
<tbody>
{% for m in row.modifiers %}
<tr id="mod-row-{{ m.id }}">
<td>
{% if m.target_type == "category" %}Kategorie: {{ category_names.get(m.target_id, m.target_id) }}
{% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %}
</td>
<td>{{ m.kind|de_label }}</td>
<td>{% if m.kind in ('percent', 'absolute') %}{{ m.value|eur }}{% else %}{% endif %}</td>
<td>{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '' }}{% else %}{% endif %}</td>
<td>
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Löschen</button>
</form>
</td>
</tr>
<tr id="mod-edit-{{ m.id }}" hidden>
<td colspan="5">
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Ziel-Typ
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
<option value="category" {% if m.target_type == 'category' %}selected{% endif %}>Kategorie</option>
<option value="recurring" {% if m.target_type == 'recurring' %}selected{% endif %}>Wiederkehrender Posten</option>
</select>
</label>
<span class="mod-target-category" {% if m.target_type != 'category' %}style="display:none"{% endif %}>
<label>Kategorie
<select name="target_id" data-type="int" {% if m.target_type != 'category' %}disabled{% endif %}>
{% for c in categories %}<option value="{{ c.id }}" {% if m.target_type == 'category' and c.id == m.target_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
</select>
</label>
</span>
<span class="mod-target-recurring" {% if m.target_type != 'recurring' %}style="display:none"{% endif %}>
<label>Posten
<select name="target_id" data-type="int" {% if m.target_type != 'recurring' %}disabled{% endif %}>
{% for r in recurring %}<option value="{{ r.id }}" {% if m.target_type == 'recurring' and r.id == m.target_id %}selected{% endif %}>{{ r.name }}</option>{% endfor %}
</select>
</label>
</span>
<label>Art
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
{% for k in modifier_kinds %}<option value="{{ k }}" {% if k == m.kind %}selected{% endif %}>{{ k|de_label }}</option>{% endfor %}
</select>
</label>
<label>Wert <input type="text" name="value" data-type="amount" value="{{ m.value|eur }}" {% if m.kind not in ('percent', 'absolute') %}disabled{% endif %}></label>
<label>Endet am <input type="date" name="end_date" value="{{ m.end_date.isoformat() if m.end_date else '' }}" {% if m.kind != 'ende' %}disabled{% endif %}></label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% endfor %}
{% for sp in row.planned_items %}
<tr id="spi-row-{{ sp.id }}">
<td>{{ sp.name }}</td>
<td>Einmalzahlung</td>
<td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td>
<td>{{ sp.due.strftime('%d.%m.%Y') }}</td>
<td>
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
hx-confirm="„{{ sp.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Löschen</button>
</form>
</td>
</tr>
<tr id="spi-edit-{{ sp.id }}" hidden>
<td colspan="5">
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ sp.name }}" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ sp.amount|eur }}" required></label>
<label>Fällig am <input type="date" name="due" value="{{ sp.due.isoformat() }}" required></label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% endfor %}
{% if not row.modifiers and not row.planned_items %}
<tr><td colspan="5">Keine Einträge.</td></tr>
{% endif %}
</tbody>
</table>
<h4>Neuer Eintrag</h4>
<form class="entry-form" hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" hx-swap="none"
data-modifiers-url="/api/scenarios/{{ sc.id }}/modifiers"
data-planned-url="/api/scenarios/{{ sc.id }}/planned"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<div class="entry-art">
<label>Eintragsart
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
<option value="einmal">Einmalzahlung</option>
</select>
</label>
<span class="muted">— die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut.</span>
</div>
<label>Ziel-Typ
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
<option value="category">Kategorie</option>
<option value="recurring">Wiederkehrender Posten</option>
</select>
</label>
<span class="mod-target-category">
<label>Kategorie
<select name="target_id" data-type="int">
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</label>
</span>
<span class="mod-target-recurring" style="display:none">
<label>Posten
<select name="target_id" data-type="int" disabled title="Für diese Eintragsart nicht relevant">
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
</select>
</label>
</span>
<label><span class="value-label">Prozentsatz (%)</span> <input type="text" name="value" data-type="amount" value="0"></label>
<label>Endet am <input type="date" name="end_date" disabled title="Für diese Eintragsart nicht relevant"></label>
<label>Name <input type="text" name="name" disabled title="Für diese Eintragsart nicht relevant"></label>
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" disabled title="Für diese Eintragsart nicht relevant"></label>
<label>Fällig am <input type="date" name="due" disabled title="Für diese Eintragsart nicht relevant"></label>
<button type="submit">Hinzufügen</button>
</form>
<hr>
<div class="project-block">
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Durchrechnen</button>
</form>
</div>
{% if row.result %}
<p>
Tiefpunkt: {{ row.result.low_point_balance|eur }} € am {{ row.result.low_point_date.strftime('%d.%m.%Y') }}<br>
{% if row.result.below_zero_date %}Unterschreitet 0 € ab {{ row.result.below_zero_date.strftime('%d.%m.%Y') }}<br>{% endif %}
{% if row.result.below_threshold_date %}Unterschreitet Warnschwelle ab {{ row.result.below_threshold_date.strftime('%d.%m.%Y') }}<br>{% endif %}
Kurven in <a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a> ansehen.
</p>
{% else %}
<p>Noch nicht durchgerechnet.</p>
{% endif %}
</fieldset>
{% else %}
<p>Noch keine Szenarien.</p>
{% endfor %}
<fieldset>
<legend>Neues Szenario anlegen</legend>
<form hx-ext="json-form" hx-post="/api/scenarios" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required></label>
<label>Beschreibung <input type="text" name="description"></label>
<label><input type="checkbox" name="include_recurring" data-type="bool" checked> Wiederkehrende Posten einschließen</label>
<label><input type="checkbox" name="include_planned" data-type="bool" checked> Einmalposten einschließen</label>
<button type="submit">Szenario anlegen</button>
</form>
</fieldset>
</section>
<script>
function onModTargetTypeChange(select) {
var form = select.closest('form');
var catSpan = form.querySelector('.mod-target-category');
var recSpan = form.querySelector('.mod-target-recurring');
var catSelect = catSpan.querySelector('select');
var recSelect = recSpan.querySelector('select');
var isCategory = select.value === 'category';
catSpan.style.display = isCategory ? '' : 'none';
recSpan.style.display = isCategory ? 'none' : '';
catSelect.disabled = !isCategory;
if (catSelect.disabled) { catSelect.title = 'Für diese Eintragsart nicht relevant'; }
else { catSelect.removeAttribute('title'); }
recSelect.disabled = isCategory;
if (recSelect.disabled) { recSelect.title = 'Für diese Eintragsart nicht relevant'; }
else { recSelect.removeAttribute('title'); }
}
function onEntryArtChange(select) {
// Umschaltmatrix "Neuer Eintrag"/Modifikator-Edit: Felder nur per
// disabled schalten (UX-Regel). Fehlt ein Feld (Edit-Formulare haben
// kein name/amount/due), wird es uebersprungen.
var form = select.closest('form');
var art = select.value;
var isMod = art !== 'einmal';
function setDisabled(name, disabled) {
var el = form.querySelector('[name="' + name + '"]');
if (el) {
el.disabled = disabled;
if (disabled) { el.title = 'Für diese Eintragsart nicht relevant'; }
else { el.removeAttribute('title'); }
}
}
setDisabled('value', !(art === 'percent' || art === 'absolute'));
var valueLabel = form.querySelector('.value-label');
if (valueLabel) {
valueLabel.textContent = art === 'percent' ? 'Prozentsatz (%)'
: art === 'absolute' ? 'Kürzung (€)' : 'Wert';
}
setDisabled('end_date', art !== 'ende');
setDisabled('name', isMod);
setDisabled('amount', isMod);
setDisabled('due', isMod);
var typeSelect = form.querySelector('[name="target_type"]');
if (typeSelect) {
typeSelect.disabled = !isMod;
if (!isMod) { typeSelect.title = 'Für diese Eintragsart nicht relevant'; }
else { typeSelect.removeAttribute('title'); }
var isCategory = typeSelect.value === 'category';
var catSelect = form.querySelector('.mod-target-category select');
var recSelect = form.querySelector('.mod-target-recurring select');
if (catSelect) {
catSelect.disabled = !isMod || !isCategory;
if (catSelect.disabled) { catSelect.title = 'Für diese Eintragsart nicht relevant'; }
else { catSelect.removeAttribute('title'); }
}
if (recSelect) {
recSelect.disabled = !isMod || isCategory;
if (recSelect.disabled) { recSelect.title = 'Für diese Eintragsart nicht relevant'; }
else { recSelect.removeAttribute('title'); }
}
}
// POST-Ziel nur beim Anlege-Formular wechseln (Edit-Formulare patchen fix).
if (form.hasAttribute('data-planned-url')) {
form.setAttribute('hx-post', isMod ? form.getAttribute('data-modifiers-url')
: form.getAttribute('data-planned-url'));
htmx.process(form);
}
}
function toggleScenarioLoan(scenarioId, loanId, checked, checkbox) {
fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' })
.then(function (resp) {
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
window.location.reload();
})
.catch(function (err) {
checkbox.checked = !checked;
alert('Zuordnung fehlgeschlagen: ' + err.message);
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,134 @@
{% extends "base.html" %}
{% block title %}Buchungen Finanzberatung{% endblock %}
{% block content %}
<h1>Buchungen</h1>
<form class="filter-form" method="get" action="/buchungen">
<label>Konto
<select name="account_id">
<option value="">alle</option>
{% for a in accounts %}
<option value="{{ a.id }}" {% if filters.account_id == a.id %}selected{% endif %}>{{ a.name }}</option>
{% endfor %}
</select>
</label>
<label>Von
<input type="date" name="date_from" value="{{ filters.date_from.isoformat() if filters.date_from else '' }}">
</label>
<label>Bis
<input type="date" name="date_to" value="{{ filters.date_to.isoformat() if filters.date_to else '' }}">
</label>
<label>Kategorie
<select name="category_id">
<option value="">alle</option>
{% for c in categories %}
<option value="{{ c.id }}" {% if filters.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</label>
<label>Suche
<input type="text" name="q" value="{{ filters.q }}" placeholder="Verwendungszweck / Empfänger">
</label>
<label>Status
<select name="status">
{% for s in ("confirmed", "draft") %}
<option value="{{ s }}" {% if filters.status == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Filtern</button>
</form>
<table>
<thead>
<tr>
<th>Datum</th><th>Konto</th><th>Verwendungszweck</th><th>Empfänger/Zahler</th>
<th>Betrag (€)</th><th>Kategorie</th><th></th>
</tr>
</thead>
<tbody>
{% for tx in transactions %}
<tr>
<td>{{ tx.booking_date.strftime('%d.%m.%Y') }}</td>
<td class="account">{{ account_names.get(tx.account_id, tx.account_id) }}</td>
<td>{{ tx.purpose }}</td>
<td>{{ tx.counterparty }}</td>
<td class="amount {{ 'neg' if tx.amount < 0 else '' }}">{{ tx.amount|eur }}</td>
<td>
<select name="category_id" data-type="int" hx-ext="json-form"
hx-patch="/api/transactions/{{ tx.id }}" hx-trigger="change" hx-swap="none">
<option value=""></option>
{% for c in categories %}
<option value="{{ c.id }}" {% if tx.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</td>
<td>
<details>
<summary>Regel erzeugen</summary>
<form hx-ext="json-form" hx-post="/api/category-rules" hx-swap="none"
hx-on::after-request="if(event.detail.successful){this.closest('details').open=false}">
<input type="hidden" name="pattern" value="{{ tx.counterparty }}">
<label>Kategorie
<select name="category_id" data-type="int" required>
{% for c in categories %}
<option value="{{ c.id }}" {% if tx.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Regel speichern</button>
</form>
</details>
</td>
</tr>
{% else %}
<tr><td colspan="7">Keine Buchungen gefunden.</td></tr>
{% endfor %}
</tbody>
</table>
<p class="pager">
{% set pager_params = {
'account_id': filters.account_id,
'date_from': filters.date_from.isoformat() if filters.date_from else None,
'date_to': filters.date_to.isoformat() if filters.date_to else None,
'category_id': filters.category_id,
'q': filters.q,
'status': filters.status,
} %}
{% if page > 1 %}
<a href="/buchungen?{% for k, v in pager_params.items() %}{% if v %}{{ k }}={{ v|urlencode }}&{% endif %}{% endfor %}page={{ page - 1 }}">Zurück</a>
{% endif %}
Seite {{ page }} von {{ total_pages }}
{% if page < total_pages %}
<a href="/buchungen?{% for k, v in pager_params.items() %}{% if v %}{{ k }}={{ v|urlencode }}&{% endif %}{% endfor %}page={{ page + 1 }}">Weiter</a>
{% endif %}
</p>
<fieldset>
<legend>Buchung manuell erfassen</legend>
<form hx-ext="json-form" hx-post="/api/transactions" hx-swap="none"
hx-on::after-request="if(event.detail.successful){this.reset(); window.location.reload()}">
<label>Konto
<select name="account_id" data-type="int" required>
{% for a in accounts %}
<option value="{{ a.id }}">{{ a.name }}</option>
{% endfor %}
</select>
</label>
<label>Buchungsdatum
<input type="date" name="booking_date" required>
</label>
<label>Betrag
<input type="text" name="amount" placeholder="-12.50" required>
</label>
<label>Verwendungszweck
<input type="text" name="purpose">
</label>
<label>Empfänger/Zahler
<input type="text" name="counterparty">
</label>
<button type="submit">Buchung speichern</button>
</form>
</fieldset>
{% endblock %}

9
finance/app/version.py Normal file
View File

@@ -0,0 +1,9 @@
from pathlib import Path
def get_version() -> str:
f = Path(__file__).resolve().parent.parent / "VERSION"
try:
return f.read_text().strip()
except OSError:
return "0.0.0-dev"

4
finance/entrypoint.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
set -e
alembic upgrade head
exec uvicorn app.main:app --host 0.0.0.0 --port 8000

View File

@@ -0,0 +1,173 @@
{
"uid": "finanzen",
"title": "Finanzen",
"schemaVersion": 39,
"version": 1,
"editable": true,
"timezone": "browser",
"tags": ["finanzen"],
"time": {
"from": "now-1y",
"to": "now"
},
"refresh": "",
"panels": [
{
"id": 1,
"type": "timeseries",
"title": "Kontostand-Verlauf",
"datasource": {
"type": "postgres",
"uid": "FinanzDB"
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineWidth": 1,
"fillOpacity": 0,
"lineInterpolation": "stepAfter",
"spanNulls": true
},
"unit": "currencyEUR"
},
"overrides": []
},
"options": {
"legend": { "displayMode": "list", "placement": "bottom" },
"tooltip": { "mode": "multi" }
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "postgres",
"uid": "FinanzDB"
},
"format": "time_series",
"rawSql": "SELECT day AS time, account AS metric, balance AS value FROM v_balance_history ORDER BY day",
"rawQuery": true
}
]
},
{
"id": 2,
"type": "timeseries",
"title": "Gesamt-Saldo",
"datasource": {
"type": "postgres",
"uid": "FinanzDB"
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineWidth": 2,
"fillOpacity": 10,
"lineInterpolation": "stepAfter",
"spanNulls": true
},
"unit": "currencyEUR"
},
"overrides": []
},
"options": {
"legend": { "displayMode": "list", "placement": "bottom" },
"tooltip": { "mode": "single" }
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "postgres",
"uid": "FinanzDB"
},
"format": "time_series",
"rawSql": "SELECT day AS time, balance AS value FROM v_balance_total ORDER BY day",
"rawQuery": true
}
]
},
{
"id": 3,
"type": "barchart",
"title": "Monat nach Kategorie",
"datasource": {
"type": "postgres",
"uid": "FinanzDB"
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"fieldConfig": {
"defaults": {
"unit": "currencyEUR"
},
"overrides": []
},
"options": {
"legend": { "displayMode": "list", "placement": "bottom" },
"stacking": "normal",
"xTickLabelRotation": 0
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "postgres",
"uid": "FinanzDB"
},
"format": "time_series",
"rawSql": "SELECT month AS time, category AS metric, total AS value FROM v_monthly_by_category ORDER BY month",
"rawQuery": true
}
]
},
{
"id": 4,
"type": "timeseries",
"title": "Szenario-Vergleich",
"datasource": {
"type": "postgres",
"uid": "FinanzDB"
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"lineWidth": 1,
"fillOpacity": 0,
"thresholdsStyle": { "mode": "line" }
},
"unit": "currencyEUR",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "orange", "value": 0 },
{ "color": "green", "value": 500 }
]
}
},
"overrides": []
},
"options": {
"legend": { "displayMode": "list", "placement": "bottom" },
"tooltip": { "mode": "multi" }
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "postgres",
"uid": "FinanzDB"
},
"format": "time_series",
"rawSql": "SELECT day AS time, scenario AS metric, balance AS value FROM v_projection ORDER BY day",
"rawQuery": true
}
]
}
]
}

View File

@@ -0,0 +1,6 @@
apiVersion: 1
providers:
- name: finanzen
type: file
options:
path: /var/lib/grafana/dashboards

View File

@@ -0,0 +1,13 @@
apiVersion: 1
datasources:
- name: FinanzDB
type: postgres
uid: FinanzDB
access: proxy
url: localhost:5432
user: finance_read
jsonData:
database: finance
sslmode: disable
secureJsonData:
password: ${FINANCE_READ_PASSWORD}

2
finance/pytest.ini Normal file
View File

@@ -0,0 +1,2 @@
[pytest]
testpaths = tests

View File

@@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.4.1
httpx==0.28.1

9
finance/requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi==0.116.1
uvicorn==0.35.0
sqlalchemy==2.0.41
alembic==1.16.2
psycopg[binary]==3.2.9
jinja2==3.1.6
python-multipart==0.0.20
itsdangerous==2.2.0
pdfplumber==0.11.7

View File

@@ -0,0 +1,9 @@
"""PDF-Text anzeigen, um Parser-Regexe abzugleichen: python scripts/dump_pdf_text.py <pdf>"""
import sys
import pdfplumber
with pdfplumber.open(sys.argv[1]) as pdf:
for i, page in enumerate(pdf.pages, 1):
print(f"--- Seite {i} ---")
print(page.extract_text() or "(kein Text)")

View File

@@ -0,0 +1,29 @@
"""Parser-Audit: geparste Transaktionen + Rohtext einer Fixture anzeigen.
NUR lokal verwenden - Ausgabe enthaelt echte Kontodaten und darf nicht
in Commits, Reports oder Tickets uebernommen werden.
Aufruf: python scripts/parser_audit.py tests/fixtures/dkb_beispiel.pdf
"""
import sys
from pathlib import Path
import pdfplumber
from app.parsers.registry import parse_pdf
from app.parsers.validate import balance_difference
path = Path(sys.argv[1])
stmt = parse_pdf(path)
print(f"bank={stmt.bank} iban={stmt.iban} "
f"period={stmt.period_start}..{stmt.period_end}")
print(f"opening={stmt.opening_balance} closing={stmt.closing_balance} "
f"diff={balance_difference(stmt)} n_tx={len(stmt.transactions)}")
print("-" * 100)
for i, t in enumerate(stmt.transactions):
print(f"#{i:3d} | {t.booking_date} | {t.value_date} | {t.amount:>12} "
f"| {t.counterparty[:40]:40} | {t.purpose[:60]}")
print("=" * 100)
with pdfplumber.open(path) as pdf:
for n, page in enumerate(pdf.pages, 1):
print(f"--- Seite {n} ---")
print(page.extract_text() or "(kein Text)")

View File

@@ -0,0 +1,166 @@
"""Vergleichswerkzeug: PDF-Parser-Ergebnis gegen CSV-Ground-Truth (Task 5, Ausbaustufe 3).
NUR LOKAL VERWENDEN - die Ausgabe enthaelt echte Kontodaten (Betraege,
Gegenparteien, Verwendungszwecke) und darf nicht in Commits, Reports oder
Tickets uebernommen werden.
Matcht die aus einem Kontoauszugs-PDF geparsten Transaktionen gegen die
Zeilen einer oder mehrerer Kontoumsatz-CSV-Dateien (auf den vom PDF
abgedeckten Zeitraum eingeschraenkt) anhand von (booking_date, amount) und
vergleicht je Match Gegenpartei (counterparty) und Verwendungszweck
(purpose) mittels difflib.SequenceMatcher.
Aufruf:
PYTHONPATH=. .venv/bin/python scripts/parser_vs_csv.py <pdf> <csv> [<csv>...]
"""
import statistics
import sys
from collections import defaultdict
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from difflib import SequenceMatcher
from pathlib import Path
from app.parsers.base import ParsedTransaction
from app.parsers.csv_formats import parse_csv
from app.parsers.registry import parse_pdf
TRUNC = 60
@dataclass
class CsvRow:
booking_date: date
amount: Decimal
counterparty: str
purpose: str
def _norm(s: str) -> str:
return " ".join((s or "").casefold().split())
def _sim(a: str, b: str) -> float:
return SequenceMatcher(None, _norm(a), _norm(b)).ratio()
def _trunc(s: str) -> str:
s = s or ""
return s if len(s) <= TRUNC else s[: TRUNC - 1] + ""
def load_csv_rows(paths: list[Path], period_start: date | None,
period_end: date | None) -> list[CsvRow]:
rows: list[CsvRow] = []
for p in paths:
parsed = parse_csv(p)
for t in parsed.statement.transactions:
if period_start is not None and t.booking_date < period_start:
continue
if period_end is not None and t.booking_date > period_end:
continue
rows.append(CsvRow(booking_date=t.booking_date, amount=t.amount,
counterparty=t.counterparty, purpose=t.purpose))
return rows
def match(pdf_txs: list[ParsedTransaction], csv_rows: list[CsvRow]):
"""Greedy 1:1 match auf (booking_date, amount); Reihenfolge = PDF-Reihenfolge.
Returns (matches, unmatched_pdf, unmatched_csv, ambiguous_keys).
matches: list[tuple[ParsedTransaction, CsvRow]]
"""
buckets: dict[tuple[date, Decimal], list[CsvRow]] = defaultdict(list)
for r in csv_rows:
buckets[(r.booking_date, r.amount)].append(r)
ambiguous_keys = {k for k, v in buckets.items() if len(v) > 1}
matches: list[tuple[ParsedTransaction, CsvRow]] = []
unmatched_pdf: list[ParsedTransaction] = []
for t in pdf_txs:
key = (t.booking_date, t.amount)
bucket = buckets.get(key)
if bucket:
row = bucket.pop(0)
matches.append((t, row))
else:
unmatched_pdf.append(t)
unmatched_csv = [r for bucket in buckets.values() for r in bucket]
return matches, unmatched_pdf, unmatched_csv, ambiguous_keys
def purpose_sim(pdf_purpose: str, csv_counterparty: str, csv_purpose: str) -> float:
direct = _sim(pdf_purpose, csv_purpose)
combined = _sim(pdf_purpose, f"{csv_counterparty} {csv_purpose}")
return max(direct, combined)
def main(argv: list[str]) -> int:
if len(argv) < 3:
print(f"Usage: {argv[0]} <pdf> <csv> [<csv>...]", file=sys.stderr)
return 2
pdf_path = Path(argv[1])
csv_paths = [Path(p) for p in argv[2:]]
stmt = parse_pdf(pdf_path)
csv_rows = load_csv_rows(csv_paths, stmt.period_start, stmt.period_end)
print(f"PDF: {pdf_path.name} bank={stmt.bank} "
f"period={stmt.period_start}..{stmt.period_end} n_pdf={len(stmt.transactions)}")
print(f"CSV: {', '.join(p.name for p in csv_paths)} "
f"n_csv(in Periode)={len(csv_rows)}")
print("-" * 100)
matches, unmatched_pdf, unmatched_csv, ambiguous_keys = match(
stmt.transactions, csv_rows)
cp_sims: list[float] = []
p_sims: list[float] = []
for i, (t, r) in enumerate(matches, start=1):
cp_s = _sim(t.counterparty, r.counterparty)
p_s = purpose_sim(t.purpose, r.counterparty, r.purpose)
cp_sims.append(cp_s)
p_sims.append(p_s)
print(f"#{i} | {t.booking_date} | {t.amount:>10} "
f"| PDF-cp={_trunc(t.counterparty)} | CSV-cp={_trunc(r.counterparty)} "
f"| cp-sim={cp_s:.2f} "
f"| PDF-p={_trunc(t.purpose)} | CSV-p={_trunc(r.purpose)} "
f"| p-sim={p_s:.2f}")
print("-" * 100)
if unmatched_pdf:
print(f"Unmatched PDF-Transaktionen ({len(unmatched_pdf)}):")
for t in unmatched_pdf:
print(f" {t.booking_date} | {t.amount:>10}")
if unmatched_csv:
print(f"Unmatched CSV-Zeilen ({len(unmatched_csv)}):")
for r in unmatched_csv:
print(f" {r.booking_date} | {r.amount:>10}")
print("=" * 100)
print("Summary")
print(f" n_pdf = {len(stmt.transactions)}")
print(f" n_csv(in Periode) = {len(csv_rows)}")
print(f" n_matched = {len(matches)}")
print(f" ambiguities = {len(ambiguous_keys)} "
f"(Buchungs-Keys mit >1 CSV-Zeile gleichen Datums/Betrags)")
if cp_sims:
print(f" cp-sim mean/median = {statistics.mean(cp_sims):.3f} / "
f"{statistics.median(cp_sims):.3f}")
print(f" p-sim mean/median = {statistics.mean(p_sims):.3f} / "
f"{statistics.median(p_sims):.3f}")
low_cp = sum(1 for s in cp_sims if s < 0.5)
print(f" cp-sim < 0.5 = {low_cp} von {len(matches)} Matches")
else:
print(" (keine Matches)")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))

View File

93
finance/tests/conftest.py Normal file
View File

@@ -0,0 +1,93 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.db import Base, get_session
import app.models # noqa: F401 ensures all ORM tables are registered on Base.metadata
@pytest.fixture
def db():
engine = create_engine(
"sqlite://", poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
# SQLite does not enforce foreign keys unless told to per-connection. Turn
# enforcement on so the tests catch FK violations (missing cascades) the
# same way Postgres would in production.
@event.listens_for(engine, "connect")
def _fk_pragma(dbapi_conn, _):
cur = dbapi_conn.cursor()
cur.execute("PRAGMA foreign_keys=ON")
cur.close()
Base.metadata.create_all(engine)
with Session(engine) as session:
yield session
@pytest.fixture
def client(db, monkeypatch):
monkeypatch.setenv("FB_API_KEY", "test-key")
from app.config import get_settings
get_settings.cache_clear()
from app.auth import hash_password
monkeypatch.setenv("FB_GUI_PASSWORD_HASH", hash_password("geheim"))
get_settings.cache_clear()
from app.main import app
def _override_get_session():
yield db
app.dependency_overrides[get_session] = _override_get_session
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
get_settings.cache_clear()
@pytest.fixture
def env_file(tmp_path):
"""Erzeugt eine .env-Datei im create_pod_finance.sh-Format (single-quoted
KEY='value') mit einem initialen Passwort+Hash. Liefert (Pfad, Passwort)
fuer Tests der Admin-Passwortaenderung (Ausbaustufe 4 Task 2), die die
Laufzeit-.env-Lesung/-Schreibung ueber FB_ENV_FILE gegen eine tmp_path
statt der echten Container-.env pruefen."""
from app.auth import hash_password
path = tmp_path / ".env"
password = "geheim123"
path.write_text(
f"FB_PASSWORD='{password}'\n"
f"FB_GUI_PASSWORD_HASH='{hash_password(password)}'\n"
)
return path, password
@pytest.fixture
def client_with_env_file(db, env_file, monkeypatch):
"""Wie `client`, aber der GUI-Passwort-Hash kommt NICHT aus der
FB_GUI_PASSWORD_HASH-Umgebungsvariable, sondern wird zur Laufzeit aus der
ueber FB_ENV_FILE referenzierten Datei gelesen (der Pfad, den
change_password() in-place ueberschreibt)."""
path, _password = env_file
monkeypatch.setenv("FB_API_KEY", "test-key")
monkeypatch.delenv("FB_GUI_PASSWORD_HASH", raising=False)
monkeypatch.setenv("FB_ENV_FILE", str(path))
from app.config import get_settings
get_settings.cache_clear()
from app.auth import _hash_cache
_hash_cache.clear()
from app.main import app
def _override_get_session():
yield db
app.dependency_overrides[get_session] = _override_get_session
with TestClient(app) as c:
yield c, env_file[1]
app.dependency_overrides.clear()
get_settings.cache_clear()
_hash_cache.clear()

9
finance/tests/fixtures/README.md vendored Normal file
View File

@@ -0,0 +1,9 @@
# Beispiel-Auszüge (nicht committen!)
Hier pro Bank mindestens einen echten PDF-Kontoauszug ablegen:
- `vr_beispiel.pdf` (Volksbank/Raiffeisenbank)
- `hvb_beispiel.pdf` (HypoVereinsbank)
- `dkb_beispiel.pdf` (DKB)
Beträge dürfen verfremdet sein, das Layout muss echt sein.
PDFs sind via .gitignore vom Repo ausgeschlossen.

315
finance/tests/test_admin.py Normal file
View File

@@ -0,0 +1,315 @@
import os
from datetime import date
from decimal import Decimal
import pytest
import app.services.admin as admin_service
from app.services.admin import apply_rules_retroactively, change_password
from app.models.tables import Account, Category, CategoryRule, Transaction
# --- change_password(): reine Service-Tests (kein HTTP), gegen die
# FB_ENV_FILE-Datei aus client_with_env_file -----------------------------
def test_change_password_wrong_old_raises_without_touching_grafana_or_env(
client_with_env_file, env_file, monkeypatch
):
_client, password = client_with_env_file
path, _ = env_file
before = path.read_text()
calls = []
monkeypatch.setattr(admin_service, "_sync_grafana_password",
lambda *a, **kw: calls.append(a))
with pytest.raises(ValueError):
change_password("falsches-passwort", "neuesPasswort123")
assert calls == [] # Grafana darf bei falschem alten Passwort nie aufgerufen werden
assert path.read_text() == before
def test_change_password_calls_grafana_before_writing_env(
client_with_env_file, env_file, monkeypatch
):
_client, password = client_with_env_file
path, _ = env_file
order = []
def fake_sync(old, new, base_url):
order.append("grafana")
assert path.read_text() == before # .env noch unveraendert an diesem Punkt
before = path.read_text()
monkeypatch.setattr(admin_service, "_sync_grafana_password", fake_sync)
change_password(password, "neuesPasswort123")
order.append("env-written")
assert order == ["grafana", "env-written"]
assert "FB_PASSWORD='neuesPasswort123'" in path.read_text()
def test_change_password_grafana_failure_leaves_env_unchanged(
client_with_env_file, env_file, monkeypatch
):
_client, password = client_with_env_file
path, _ = env_file
before = path.read_text()
def fail(*a, **kw):
raise RuntimeError("Grafana-Passwortänderung fehlgeschlagen: boom")
monkeypatch.setattr(admin_service, "_sync_grafana_password", fail)
with pytest.raises(RuntimeError):
change_password(password, "neuesPasswort123")
assert path.read_text() == before
def test_change_password_success_rewrites_env_in_place_preserving_inode(
client_with_env_file, env_file, monkeypatch
):
_client, password = client_with_env_file
path, _ = env_file
monkeypatch.setattr(admin_service, "_sync_grafana_password",
lambda *a, **kw: None)
inode_before = os.stat(path).st_ino
change_password(password, "neuesPasswort123")
inode_after = os.stat(path).st_ino
assert inode_before == inode_after # KRITISCH: kein rename, gleicher Inode
content = path.read_text()
assert "FB_PASSWORD='neuesPasswort123'" in content
assert "FB_GUI_PASSWORD_HASH='" in content
assert f"FB_GUI_PASSWORD_HASH='{password}'" not in content
def test_change_password_new_password_logs_in_old_does_not(
client_with_env_file, monkeypatch
):
client, password = client_with_env_file
monkeypatch.setattr(admin_service, "_sync_grafana_password",
lambda *a, **kw: None)
change_password(password, "neuesPasswort123")
r = client.post("/login", data={"username": "admin", "password": "neuesPasswort123"},
follow_redirects=False)
assert r.status_code == 303
client.cookies.clear()
r2 = client.post("/login", data={"username": "admin", "password": password},
follow_redirects=False)
assert r2.status_code == 401
# --- /admin GUI-Route: Auth-Gate + Formular-Fehlerpfade -------------------
def test_admin_page_requires_gui_session(client_with_env_file):
client, _password = client_with_env_file
r = client.get("/admin", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/login"
def test_admin_page_reachable_after_login(client_with_env_file):
client, password = client_with_env_file
client.post("/login", data={"username": "admin", "password": password})
r = client.get("/admin")
assert r.status_code == 200
assert "Passwort ändern" in r.text
assert "Regeln neu anwenden" in r.text
def test_admin_passwort_wrong_old_returns_400_with_message(
client_with_env_file, monkeypatch
):
client, password = client_with_env_file
monkeypatch.setattr(admin_service, "_sync_grafana_password",
lambda *a, **kw: None)
client.post("/login", data={"username": "admin", "password": password})
r = client.post("/admin/passwort", data={
"alt": "falsch", "neu": "neuesPasswort123", "neu2": "neuesPasswort123",
})
assert r.status_code == 400
assert "falsch" in r.text.lower()
def test_admin_passwort_too_short_returns_400(client_with_env_file):
client, password = client_with_env_file
client.post("/login", data={"username": "admin", "password": password})
r = client.post("/admin/passwort", data={
"alt": password, "neu": "kurz1", "neu2": "kurz1",
})
assert r.status_code == 400
assert "8 Zeichen" in r.text
def test_admin_passwort_mismatch_returns_400(client_with_env_file):
client, password = client_with_env_file
client.post("/login", data={"username": "admin", "password": password})
r = client.post("/admin/passwort", data={
"alt": password, "neu": "neuesPasswort123", "neu2": "andersPasswort123",
})
assert r.status_code == 400
assert "stimmt nicht" in r.text.lower() or "wiederholung" in r.text.lower()
def test_admin_passwort_grafana_failure_returns_400_env_unchanged(
client_with_env_file, env_file, monkeypatch
):
client, password = client_with_env_file
path, _ = env_file
before = path.read_text()
def fail(*a, **kw):
raise RuntimeError("Grafana-Passwortänderung fehlgeschlagen: boom")
monkeypatch.setattr(admin_service, "_sync_grafana_password", fail)
client.post("/login", data={"username": "admin", "password": password})
r = client.post("/admin/passwort", data={
"alt": password, "neu": "neuesPasswort123", "neu2": "neuesPasswort123",
})
assert r.status_code == 400
assert "grafana" in r.text.lower()
assert path.read_text() == before
def test_admin_passwort_grafana_read_timeout_returns_400_env_unchanged(
client_with_env_file, env_file, monkeypatch
):
"""F1 (Fable-Reject): ein Hang in der Grafana-Lesephase wirft ein rohes
TimeoutError statt eines urllib.error.URLError. Ohne expliziten Fang in
_sync_grafana_password schlaegt das bis in den Router durch (500 statt
deutscher 400-Fehlermeldung) - siehe app/services/admin.py."""
client, password = client_with_env_file
path, _ = env_file
before = path.read_text()
def hang(request, timeout=None):
raise TimeoutError("timed out")
monkeypatch.setattr("app.services.admin.urllib.request.urlopen", hang)
client.post("/login", data={"username": "admin", "password": password})
r = client.post("/admin/passwort", data={
"alt": password, "neu": "neuesPasswort123", "neu2": "neuesPasswort123",
})
assert r.status_code == 400
assert "grafana" in r.text.lower()
assert path.read_text() == before
def test_admin_passwort_success_returns_200_with_confirmation(
client_with_env_file, monkeypatch
):
client, password = client_with_env_file
calls = []
monkeypatch.setattr(admin_service, "_sync_grafana_password",
lambda *a, **kw: calls.append(a))
client.post("/login", data={"username": "admin", "password": password})
r = client.post("/admin/passwort", data={
"alt": password, "neu": "neuesPasswort123", "neu2": "neuesPasswort123",
})
assert r.status_code == 200
assert "erfolgreich" in r.text.lower()
assert calls # Grafana wurde tatsaechlich aufgerufen
# Session-Cookie bleibt nach der Aenderung gueltig (Secret unveraendert)
r2 = client.get("/admin")
assert r2.status_code == 200
# --- Kategorien-Verwaltung --------------------------------------------------
def test_admin_zeigt_kategorien_verwaltung(client_with_env_file, db):
from app.models.tables import Category
client, password = client_with_env_file
client.post("/login", data={"username": "admin", "password": password})
db.add(Category(name="Admin-Kat"))
db.commit()
r = client.get("/admin").text
assert "Kategorien" in r and "Admin-Kat" in r
assert "Neue Kategorie anlegen" in r
assert 'hx-patch="/api/categories/' in r
assert 'hx-post="/api/categories"' in r
# --- Regeln neu anwenden ---------------------------------------------------
def test_apply_rules_retroactively_categorizes_uncategorized_confirmed_tx(db):
account = Account(bank="Test", iban="DE02120300000000202099", name="Giro")
db.add(account)
db.flush()
category = Category(name="Lebensmittel")
db.add(category)
db.flush()
db.add(CategoryRule(pattern="rewe", category_id=category.id, priority=100))
tx_confirmed_uncategorized = Transaction(
account_id=account.id, booking_date=date(2026, 1, 5),
amount=Decimal("-10.00"), purpose="REWE Markt", counterparty="",
status="confirmed", dedup_hash="h1",
)
tx_draft = Transaction(
account_id=account.id, booking_date=date(2026, 1, 6),
amount=Decimal("-5.00"), purpose="REWE Markt", counterparty="",
status="draft", dedup_hash="h2",
)
tx_already_categorized = Transaction(
account_id=account.id, booking_date=date(2026, 1, 7),
amount=Decimal("-5.00"), purpose="REWE Markt", counterparty="",
status="confirmed", category_id=category.id, dedup_hash="h3",
)
db.add_all([tx_confirmed_uncategorized, tx_draft, tx_already_categorized])
db.commit()
hits = apply_rules_retroactively(db)
assert hits == 1
db.refresh(tx_confirmed_uncategorized)
db.refresh(tx_draft)
assert tx_confirmed_uncategorized.category_id == category.id
assert tx_draft.category_id is None # Entwuerfe bleiben unangetastet
def test_apply_category_rules_endpoint_requires_auth(client):
assert client.post("/api/category-rules/apply").status_code == 401
def test_apply_category_rules_endpoint_categorizes_and_returns_count(client, db):
account = Account(bank="Test", iban="DE02120300000000202098", name="Giro")
db.add(account)
db.flush()
category = Category(name="Lebensmittel")
db.add(category)
db.flush()
db.add(CategoryRule(pattern="rewe", category_id=category.id, priority=100))
tx = Transaction(
account_id=account.id, booking_date=date(2026, 1, 5),
amount=Decimal("-10.00"), purpose="REWE Markt", counterparty="",
status="confirmed", dedup_hash="h1",
)
db.add(tx)
db.commit()
r = client.post("/api/category-rules/apply",
headers={"Authorization": "Bearer test-key"})
assert r.status_code == 200
assert r.json() == {"categorized": 1}
db.refresh(tx)
assert tx.category_id == category.id

View File

@@ -0,0 +1,62 @@
def test_api_requires_key(client):
assert client.get("/api/accounts").status_code == 401
r = client.get("/api/accounts", headers={"Authorization": "Bearer test-key"})
assert r.status_code == 200
def test_login_flow(client):
r = client.post("/login", data={"username": "admin", "password": "falsch"},
follow_redirects=False)
assert r.status_code == 401
r = client.post("/login", data={"username": "admin", "password": "geheim"},
follow_redirects=False)
assert r.status_code == 303
assert client.get("/api/accounts").status_code == 200 # Cookie reicht
def test_logout_flow(client):
r = client.post("/login", data={"username": "admin", "password": "geheim"},
follow_redirects=False)
assert r.status_code == 303
assert client.get("/api/accounts").status_code == 200
r = client.post("/logout", follow_redirects=False)
assert r.status_code == 303
assert client.get("/api/accounts").status_code == 401
def test_tampered_session_cookie_rejected(client):
client.cookies.set("fb_session", "gui.invalid-signature")
assert client.get("/api/accounts").status_code == 401
def test_current_password_hash_falls_back_to_env_when_no_env_file(client, monkeypatch):
# `client`-Fixture setzt FB_GUI_PASSWORD_HASH direkt und keine FB_ENV_FILE,
# der Default-Pfad /data/.env existiert im Testlauf nicht -> Fallback.
from app.auth import current_password_hash
from app.config import get_settings
assert current_password_hash() == get_settings().gui_password_hash
def test_current_password_hash_reads_from_env_file_when_present(
client_with_env_file,
):
from app.auth import current_password_hash, verify_password
_client, password = client_with_env_file
assert verify_password(password, current_password_hash())
def test_current_password_hash_cache_invalidates_on_file_change(
client_with_env_file, env_file,
):
from app.auth import current_password_hash, hash_password, verify_password
path, password = env_file
first = current_password_hash()
assert verify_password(password, first)
new_hash = hash_password("andereswort999")
path.write_text(f"FB_PASSWORD='andereswort999'\nFB_GUI_PASSWORD_HASH='{new_hash}'\n")
second = current_password_hash()
assert second == new_hash
assert verify_password("andereswort999", second)

View File

@@ -0,0 +1,120 @@
import json
import re
from decimal import Decimal
from pathlib import Path
import pytest
from app.parsers.dkb import _split_counterparty
from app.parsers.registry import parse_pdf
from app.parsers.validate import balance_difference
FIXTURES = Path(__file__).parent / "fixtures"
CASES = [("vr_beispiel.pdf", "vr"), ("hvb_beispiel.pdf", "hvb"),
("dkb_beispiel.pdf", "dkb")]
NOISE = re.compile(
r"(Kontostand|Übertrag|alter Saldo|neuer Saldo|Seite \d|Blatt \d)", re.I)
@pytest.mark.parametrize("filename,bank", CASES)
def test_parse_fixture(filename, bank):
path = FIXTURES / filename
if not path.exists():
pytest.skip(f"Fixture {filename} fehlt")
stmt = parse_pdf(path)
assert stmt.bank == bank
assert stmt.opening_balance is not None
assert stmt.closing_balance is not None
assert len(stmt.transactions) > 0
assert balance_difference(stmt) == Decimal("0")
@pytest.mark.parametrize("filename,bank", CASES)
def test_no_noise_in_parsed_fields(filename, bank):
path = FIXTURES / filename
if not path.exists():
pytest.skip(f"Fixture {filename} fehlt")
stmt = parse_pdf(path)
for t in stmt.transactions:
assert not NOISE.search(t.purpose), f"Noise im Verwendungszweck: #{stmt.transactions.index(t)}"
assert not NOISE.search(t.counterparty), f"Noise im Empfänger: #{stmt.transactions.index(t)}"
assert stmt.period_start <= t.booking_date <= stmt.period_end
@pytest.mark.parametrize("filename,bank", CASES)
def test_expected_values(filename, bank):
path = FIXTURES / filename
exp_path = FIXTURES / f"expected_{bank}.json"
if not path.exists() or not exp_path.exists():
pytest.skip("Fixture oder expected-Datei fehlt")
stmt = parse_pdf(path)
exp = json.loads(exp_path.read_text())
assert len(stmt.transactions) == exp["transaction_count"]
for c in exp["spot_checks"]:
t = stmt.transactions[c["index"]]
assert str(t.booking_date) == c["booking_date"]
assert str(t.amount) == c["amount"]
assert c["counterparty_contains"].lower() in t.counterparty.lower()
assert c["purpose_contains"].lower() in t.purpose.lower()
# --- DKB Empfänger/Zweck-Split-Heuristik (D-T5-1/D-T5-2) -------------------
# Ausschließlich synthetische Zeilen (fiktive Namen/Nummern), keine echten
# Kontodaten.
def test_split_counterparty_legal_form_single():
cp, rest = _split_counterparty(
"Musterfirma Beispiel GmbH Rechnung Nr. F000123 vom 01.01.20")
assert cp == "Musterfirma Beispiel GmbH"
assert rest == "Rechnung Nr. F000123 vom 01.01.20"
def test_split_counterparty_legal_form_et_cie_chain():
cp, rest = _split_counterparty(
"Fiktiv Global S.a.r.l. et Cie S.C.A Onlinedienst Referenz 999")
assert cp == "Fiktiv Global S.a.r.l. et Cie S.C.A"
assert rest == "Onlinedienst Referenz 999"
def test_split_counterparty_reference_boundary_digit_token():
cp, rest = _split_counterparty(
"Beispiel Handelsgesellschaft Auftrag 84210 Sonderzahlung")
assert cp == "Beispiel Handelsgesellschaft Auftrag"
assert rest == "84210 Sonderzahlung"
def test_split_counterparty_reference_boundary_keyword_token():
cp, rest = _split_counterparty(
"Servicepartner Nord Vertrag ABC123 Laufzeit")
assert cp == "Servicepartner Nord"
assert rest == "Vertrag ABC123 Laufzeit"
def test_split_counterparty_no_split_pure_words():
cp, rest = _split_counterparty("MUSTER PERSON Nebenkosten")
assert cp == "MUSTER PERSON Nebenkosten"
assert rest == ""
def test_split_counterparty_siehe_anlage():
cp, rest = _split_counterparty("siehe Anlage Nr. 7")
assert cp == ""
assert rest == "siehe Anlage Nr. 7"
def test_split_counterparty_never_empty_guard():
# Erstes Token enthält eine Ziffer, darf aber nie selbst die
# Referenz-Grenze bilden (Regel greift frühestens ab Token 1) - da kein
# weiteres Token eine Ziffer/Schlüsselwort liefert, bleibt die ganze
# Zeile als counterparty erhalten (nie leer).
cp, rest = _split_counterparty("42 Testfirma Ausgleich")
assert cp == "42 Testfirma Ausgleich"
assert rest == ""
assert cp != ""
# Einzelnes Token (auch mit Ziffer): kein Schnitt möglich, bleibt
# counterparty (nie leer, nie None).
cp2, rest2 = _split_counterparty("12345")
assert cp2 == "12345"
assert rest2 == ""

View File

@@ -0,0 +1,37 @@
from pathlib import Path
from app.config import get_settings
def test_defaults():
s = get_settings()
assert s.horizon_days == 548
assert s.gui_user == "admin"
def test_env_file_defaults_to_container_mount_path(monkeypatch):
monkeypatch.delenv("FB_ENV_FILE", raising=False)
get_settings.cache_clear()
try:
assert get_settings().env_file == Path("/data/.env")
finally:
get_settings.cache_clear()
def test_env_file_overridable_via_env_var(monkeypatch, tmp_path):
custom = tmp_path / "custom.env"
monkeypatch.setenv("FB_ENV_FILE", str(custom))
get_settings.cache_clear()
try:
assert get_settings().env_file == custom
finally:
get_settings.cache_clear()
def test_grafana_url_default(monkeypatch):
monkeypatch.delenv("FB_GRAFANA_URL", raising=False)
get_settings.cache_clear()
try:
assert get_settings().grafana_url == "http://localhost:3000"
finally:
get_settings.cache_clear()

View File

@@ -0,0 +1,296 @@
from datetime import date, timedelta
from decimal import Decimal
from app.models.tables import Account, Transaction
from app.services.balances import account_balance
H = {"Authorization": "Bearer test-key"}
def test_account_crud_and_balance(client, db):
# Portiert von Statement-closing-Logik auf Konto-Saldo-Anker (Ausbaustufe 3
# Task 1): frueher lieferte die closing_balance des juengsten bestaetigten
# Statements die Basis, jetzt uebernimmt der Anker (Datum+Betrag am Konto)
# dieselbe Rolle. `at` wird explizit gesetzt statt auf date.today() zu
# vertrauen, damit der Test unabhaengig vom Ausfuehrungsdatum ist.
r = client.post("/api/accounts", headers=H, json={
"bank": "DKB", "iban": "DE02120300000000202051",
"name": "Giro", "type": "giro"})
assert r.status_code == 201
acc_id = r.json()["id"]
acc = db.get(Account, acc_id)
acc.anchor_date = date(2026, 6, 30)
acc.anchor_balance = Decimal("100.00")
db.add(Transaction(account_id=acc_id, booking_date=date(2026, 7, 2),
amount=Decimal("-30.00"), purpose="Bar", status="confirmed",
dedup_hash="x1"))
db.commit()
acc = db.get(Account, acc_id)
assert account_balance(db, acc, at=date(2026, 7, 15)) == Decimal("70.00")
def test_account_balance_anchor_forward_and_backward(client, db):
# Spec-Interface (Plan Ausbaustufe 3 Task 1): anchor_balance +
# Sum(tx: anchor_date < booking_date <= at) - Sum(tx: at < booking_date
# <= anchor_date). Buchungen VOR und NACH dem Anker duerfen sich nicht
# gegenseitig beeinflussen.
acc = Account(bank="dkb", iban="DE-ANCHOR-1", name="Anker-Konto", type="giro")
db.add(acc)
db.flush()
anchor_date = date(2026, 6, 30)
acc.anchor_date = anchor_date
acc.anchor_balance = Decimal("500.00")
db.add(Transaction(account_id=acc.id, booking_date=date(2026, 6, 29),
amount=Decimal("-20.00"), purpose="vor Anker",
status="confirmed", dedup_hash="x2"))
db.add(Transaction(account_id=acc.id, booking_date=date(2026, 7, 1),
amount=Decimal("50.00"), purpose="nach Anker",
status="confirmed", dedup_hash="x3"))
db.commit()
acc = db.get(Account, acc.id)
assert account_balance(db, acc, at=anchor_date) == Decimal("500.00")
assert account_balance(db, acc, at=anchor_date + timedelta(days=2)) == Decimal("550.00")
assert account_balance(db, acc, at=anchor_date - timedelta(days=2)) == Decimal("520.00")
def test_account_balance_anchor_day_transaction_convention(client, db):
# Konvention (Plan Step 5): der Anker gilt PER TAGESENDE des Ankerdatums.
# Eine Buchung AM Ankertag ist im Anker bereits enthalten -> zaehlt NICHT
# zur Vorwaertssumme, wohl aber zur Rueckwaertssumme.
acc = Account(bank="dkb", iban="DE-ANCHOR-2", name="Anker-Konto 2", type="giro")
db.add(acc)
db.flush()
anchor_date = date(2026, 6, 30)
acc.anchor_date = anchor_date
acc.anchor_balance = Decimal("200.00")
db.add(Transaction(account_id=acc.id, booking_date=anchor_date,
amount=Decimal("15.00"), purpose="am Ankertag",
status="confirmed", dedup_hash="x4"))
db.commit()
acc = db.get(Account, acc.id)
assert account_balance(db, acc, at=anchor_date + timedelta(days=1)) == Decimal("200.00")
assert account_balance(db, acc, at=anchor_date - timedelta(days=1)) == Decimal("185.00")
def test_account_without_anchor_uses_zero_base(client, db):
# Ohne Anker: Summe aller bestaetigten Buchungen bis `at` (Basis 0) -
# bisheriges Verhalten bleibt fuer Konten ohne Anker erhalten.
acc = Account(bank="dkb", iban="DE-NOANCHOR", name="Kein Anker", type="giro")
db.add(acc)
db.flush()
db.add(Transaction(account_id=acc.id, booking_date=date(2026, 7, 1),
amount=Decimal("42.00"), purpose="ohne Anker",
status="confirmed", dedup_hash="x5"))
db.commit()
acc = db.get(Account, acc.id)
assert account_balance(db, acc, at=date(2026, 7, 15)) == Decimal("42.00")
def test_manual_transaction_dedup(client):
acc = client.post("/api/accounts", headers=H, json={
"bank": "DKB", "iban": "DE99", "name": "G", "type": "giro"}).json()
payload = {"account_id": acc["id"], "booking_date": "2026-07-01",
"amount": "-10.00", "purpose": "Kaffee", "counterparty": ""}
assert client.post("/api/transactions", headers=H, json=payload).status_code == 201
assert client.post("/api/transactions", headers=H, json=payload).status_code == 409
payload["force"] = True
assert client.post("/api/transactions", headers=H, json=payload).status_code == 201
def test_rules_categorize(client):
cat = client.post("/api/categories", headers=H, json={"name": "Energie"}).json()
client.post("/api/category-rules", headers=H,
json={"pattern": "stadtwerke", "category_id": cat["id"]})
acc = client.post("/api/accounts", headers=H, json={
"bank": "DKB", "iban": "DE98", "name": "G2", "type": "giro"}).json()
client.post("/api/transactions", headers=H, json={
"account_id": acc["id"], "booking_date": "2026-07-03",
"amount": "-80.00", "purpose": "STADTWERKE Abschlag", "counterparty": ""})
txs = client.get("/api/transactions", headers=H,
params={"account_id": acc["id"]}).json()
assert txs[0]["category_id"] == cat["id"]
def test_category_patch(client):
a = client.post("/api/categories", headers=H, json={"name": "Kat-A"}).json()
b = client.post("/api/categories", headers=H, json={"name": "Kat-B"}).json()
# Umbenennen
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
assert r.status_code == 200 and r.json()["name"] == "Kat-A-neu"
# Umbenennen auf den EIGENEN Namen ist erlaubt (kein 409)
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
assert r.status_code == 200
# Kollision mit anderer Kategorie -> 409
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-B"})
assert r.status_code == 409
# unbekannte id -> 404
assert client.patch("/api/categories/99999", headers=H,
json={"name": "x"}).status_code == 404
def test_patch_account_name(client):
acc = client.post("/api/accounts", headers=H, json={
"bank": "DKB", "iban": "DE71", "name": "DE71", "type": "giro"}).json()
r = client.patch(f"/api/accounts/{acc['id']}", headers=H,
json={"name": "DKB Giro"})
assert r.status_code == 200 and r.json()["name"] == "DKB Giro"
assert client.patch("/api/accounts/9999", headers=H,
json={"name": "x"}).status_code == 404
assert client.patch(f"/api/accounts/{acc['id']}", headers=H,
json={"name": " "}).status_code == 422
def test_patch_account_anchor_set_and_clear(client):
acc = client.post("/api/accounts", headers=H, json={
"bank": "DKB", "iban": "DE72", "name": "Anker-Test", "type": "giro"}).json()
assert acc["anchor_date"] is None
assert acc["anchor_balance"] is None
r = client.patch(f"/api/accounts/{acc['id']}", headers=H,
json={"anchor_date": "2026-06-30", "anchor_balance": "500.00"})
assert r.status_code == 200
body = r.json()
assert body["anchor_date"] == "2026-06-30"
assert Decimal(body["anchor_balance"]) == Decimal("500.00")
r = client.patch(f"/api/accounts/{acc['id']}", headers=H,
json={"anchor_date": None, "anchor_balance": None})
assert r.status_code == 200
body = r.json()
assert body["anchor_date"] is None
assert body["anchor_balance"] is None
def test_patch_account_anchor_requires_both_fields(client):
acc = client.post("/api/accounts", headers=H, json={
"bank": "DKB", "iban": "DE73", "name": "Anker-422", "type": "giro"}).json()
assert client.patch(f"/api/accounts/{acc['id']}", headers=H,
json={"anchor_date": "2026-06-30"}).status_code == 422
assert client.patch(f"/api/accounts/{acc['id']}", headers=H,
json={"anchor_balance": "500.00"}).status_code == 422
assert client.patch(f"/api/accounts/{acc['id']}", headers=H,
json={"anchor_date": "2026-06-30",
"anchor_balance": None}).status_code == 422
def test_patch_transaction_category(client):
cat = client.post("/api/categories", headers=H, json={"name": "Freizeit"}).json()
acc = client.post("/api/accounts", headers=H, json={
"bank": "DKB", "iban": "DE77", "name": "G3", "type": "giro"}).json()
tx = client.post("/api/transactions", headers=H, json={
"account_id": acc["id"], "booking_date": "2026-07-04",
"amount": "-15.00", "purpose": "Kino", "counterparty": ""}).json()
tx_id = tx["id"]
assert tx["category_id"] is None
r = client.patch(f"/api/transactions/{tx_id}", headers=H,
json={"category_id": cat["id"]})
assert r.status_code == 200
assert r.json()["category_id"] == cat["id"]
r = client.patch(f"/api/transactions/{tx_id}", headers=H, json={})
assert r.status_code == 200
assert r.json()["category_id"] == cat["id"]
r = client.patch(f"/api/transactions/{tx_id}", headers=H,
json={"category_id": None})
assert r.status_code == 200
assert r.json()["category_id"] is None
r = client.patch(f"/api/transactions/{tx_id}", headers=H,
json={"category_id": 9999})
assert r.status_code == 404
def test_scenario_patch_beschreibung_leeren(client):
sc = client.post("/api/scenarios", headers=H, json={"name": "DescNull", "description": "x"}).json()
r = client.patch(f"/api/scenarios/{sc['id']}", headers=H, json={"description": None})
assert r.status_code == 200 and r.json()["description"] == ""
def test_modifier_ende_validierung(client, db):
sc = client.post("/api/scenarios", headers=H, json={"name": "EndeVal"}).json()
# ende ohne Datum -> 422
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "ende"})
assert r.status_code == 422
# ende mit Datum -> 201, end_date in der Antwort
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "ende",
"end_date": "2026-08-31"})
assert r.status_code == 201
assert r.json()["end_date"] == "2026-08-31"
# andere Arten nullen end_date
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "remove",
"end_date": "2026-08-31"})
assert r.status_code == 201 and r.json()["end_date"] is None
def test_scenario_planned_crud_und_cleanup(client, db):
sc = client.post("/api/scenarios", headers=H, json={"name": "SPI-CRUD"}).json()
r = client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
"name": "Sonderzahlung", "amount": "5000.00", "due": "2026-07-30"})
assert r.status_code == 201
item = r.json()
assert item["scenario_id"] == sc["id"] and item["amount"] == "5000.00"
assert client.get(f"/api/scenarios/{sc['id']}/planned", headers=H).json()[0]["name"] == "Sonderzahlung"
# DELETE mit fremder scenario_id -> 404
other = client.post("/api/scenarios", headers=H, json={"name": "SPI-Other"}).json()
assert client.delete(f"/api/scenarios/{other['id']}/planned/{item['id']}",
headers=H).status_code == 404
# Szenario loeschen raeumt Einmalzahlungen mit ab (kein FK-Fehler):
assert client.delete(f"/api/scenarios/{sc['id']}", headers=H).status_code == 204
from app.models.tables import ScenarioPlannedItem
assert db.query(ScenarioPlannedItem).count() == 0
def test_modifier_patch(client, db):
sc = client.post("/api/scenarios", headers=H, json={"name": "ModPatch"}).json()
m = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "percent",
"value": "10.00"}).json()
# Wert aendern
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "25.00"})
assert r.status_code == 200 and r.json()["value"] == "25.00"
# Art auf ende wechseln (mit Datum); value wird vom Validator genullt
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "ende",
"end_date": "2026-08-31"})
assert r.status_code == 200
assert r.json()["kind"] == "ende" and r.json()["end_date"] == "2026-08-31"
# ende ohne Datum -> 422
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "ende"})
assert r.status_code == 422
# zurueck auf remove: end_date wird genullt
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "remove",
"end_date": "2026-08-31"})
assert r.status_code == 200 and r.json()["end_date"] is None
# fremdes Szenario -> 404
other = client.post("/api/scenarios", headers=H, json={"name": "ModPatch2"}).json()
r = client.patch(f"/api/scenarios/{other['id']}/modifiers/{m['id']}", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "remove"})
assert r.status_code == 404
def test_scenario_planned_patch(client, db):
sc = client.post("/api/scenarios", headers=H, json={"name": "SpiPatch"}).json()
p = client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
"name": "Zufluss", "amount": "5000.00", "due": "2026-07-30"}).json()
r = client.patch(f"/api/scenarios/{sc['id']}/planned/{p['id']}", headers=H, json={
"name": "Zufluss geaendert", "amount": "6000.00", "due": "2026-08-15"})
assert r.status_code == 200
body = r.json()
assert body["name"] == "Zufluss geaendert"
assert body["amount"] == "6000.00" and body["due"] == "2026-08-15"
# fremdes Szenario -> 404
other = client.post("/api/scenarios", headers=H, json={"name": "SpiPatch2"}).json()
r = client.patch(f"/api/scenarios/{other['id']}/planned/{p['id']}", headers=H, json={
"name": "x", "amount": "1.00", "due": "2026-08-15"})
assert r.status_code == 404

Some files were not shown because too many files have changed in this diff Show More