Compare commits

...

57 Commits

Author SHA256 Message Date
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
92 changed files with 9790 additions and 0 deletions

1
.gitignore vendored Normal file
View File

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

2031
Plan.md Normal file

File diff suppressed because it is too large Load Diff

336
create_pod_finance.sh Executable file
View File

@@ -0,0 +1,336 @@
#!/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)
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" \
"$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

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

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.4.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 ###

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

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

@@ -0,0 +1,53 @@
import hashlib
import hmac
import secrets
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
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")

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

@@ -0,0 +1,34 @@
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
@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")),
)

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,82 @@
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
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 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, r.end_date):
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)

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

@@ -0,0 +1,64 @@
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, 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.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, s.gui_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,143 @@
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"))
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)

View File

@@ -0,0 +1,71 @@
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
"""
VIEWS: dict[str, str] = {
"v_balance_history": f"""
SELECT t.booking_date AS day, a.name AS account,
COALESCE(b.base, 0)
+ SUM(SUM(t.amount)) OVER (PARTITION BY a.id
ORDER BY t.booking_date) AS balance
FROM transactions t JOIN accounts a ON a.id = t.account_id
LEFT JOIN ({_ACCOUNT_OFFSET}) b ON b.account_id = a.id
WHERE t.status = 'confirmed'
GROUP BY a.id, a.name, t.booking_date, b.base""",
"v_balance_total": f"""
SELECT booking_date AS day,
(SELECT COALESCE(SUM(base), 0)
FROM ({_ACCOUNT_OFFSET}) s)
+ SUM(SUM(amount)) OVER (ORDER BY booking_date) AS balance
FROM transactions WHERE status = 'confirmed' GROUP BY booking_date""",
"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,75 @@
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.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()

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

@@ -0,0 +1,297 @@
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.models.tables import (Account, Category, PlannedItem,
ProjectionResult, RecurringItem,
ScenarioLoan, ScenarioModifier, 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()
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)
).scalars().all()
rows.append({
"scenario": sc,
"assigned_loan_ids": assigned_loan_ids,
"modifiers": modifiers,
"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)
scenarios = _list_scenarios(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,
"scenario_rows": _scenario_rows(session, scenarios, loans),
"rhythms": ["monthly", "quarterly", "yearly"],
"repayment_types": ["annuity", "bullet"],
"modifier_kinds": ["percent", "absolute", "remove"],
})

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,249 @@
from datetime import date
from decimal import Decimal
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
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
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
category_id: int | None = None
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)
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,186 @@
from datetime import date
from decimal import Decimal
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
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)
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
class ModifierIn(BaseModel):
target_type: Literal["category", "recurring"]
target_id: int
kind: Literal["percent", "absolute", "remove"]
value: Decimal = Decimal("0")
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.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.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,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,58 @@
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)
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()]
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))
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,52 @@
import statistics
from collections import Counter, defaultdict
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.tables import RecurringItem, Transaction
def _max_consecutive_months(months: list[tuple[int, int]]) -> int:
if not months:
return 0
best = current = 1
for prev, cur in zip(months, months[1:]):
prev_idx = prev[0] * 12 + prev[1]
cur_idx = cur[0] * 12 + cur[1]
current = current + 1 if cur_idx == prev_idx + 1 else 1
best = max(best, current)
return best
def suggest_recurring(session: Session) -> list[dict]:
txs = session.execute(
select(Transaction).where(Transaction.status == "confirmed")
).scalars().all()
groups: dict[tuple, list[Transaction]] = defaultdict(list)
for t in txs:
groups[(t.account_id, t.counterparty, t.amount)].append(t)
existing = {(r.name, Decimal(r.amount))
for r in session.execute(select(RecurringItem)).scalars()}
suggestions: list[dict] = []
for (_account_id, counterparty, amount), items in groups.items():
months = sorted({(t.booking_date.year, t.booking_date.month) for t in items})
if _max_consecutive_months(months) < 3:
continue
name = counterparty
if (name, Decimal(amount)) in existing:
continue
due_day = int(statistics.median(sorted(t.booking_date.day for t in items)))
cat_counts = Counter(t.category_id for t in items if t.category_id is not None)
category_id = cat_counts.most_common(1)[0][0] if cat_counts else None
suggestions.append({
"name": name,
"amount": Decimal(amount),
"rhythm": "monthly",
"due_day": due_day,
"category_id": category_id,
})
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,173 @@
* { 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;
}
.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;
}

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 '' }}">{{ '%.2f'|format(tx.amount) }} €</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: {{ '%.2f'|format(statement.opening_balance) if statement.opening_balance is not none else '' }} €
· Endsaldo: {{ '%.2f'|format(statement.closing_balance) if statement.closing_balance is not none else '' }} €
· Duplikate: {{ duplicates }}
</p>

View File

@@ -0,0 +1,71 @@
<!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="/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 {
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);
}
});
</script>
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
</body>
</html>

View File

@@ -0,0 +1,103 @@
{% 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.
</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: {{ '%.2f'|format(total) }} €
</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 '' }}">{{ '%.2f'|format(balance) }} €</td>
<td>
{% if account.anchor_date is not none and account.anchor_balance is not none %}
Anker: {{ '%.2f'|format(account.anchor_balance) }} € 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" placeholder="500.00"
value="{{ account.anchor_balance 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 '' }}">{{ '%.2f'|format(item.amount) }} €</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,366 @@
{% 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>Kategorie</th><th></th></tr>
</thead>
<tbody>
{% for r in recurring %}
<tr>
<td>{{ r.name }}</td>
<td class="{{ 'neg' if r.amount < 0 else '' }}">{{ '%.2f'|format(r.amount) }} €</td>
<td>{{ r.rhythm }}</td>
<td>{{ r.due_day }}</td>
<td>{{ category_names.get(r.category_id, '') }}</td>
<td>
<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>
{% else %}
<tr><td colspan="6">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" placeholder="-49.99" required></label>
<label>Rhythmus
<select name="rhythm" data-type="str">
{% for rh in rhythms %}<option value="{{ rh }}">{{ rh }}</option>{% endfor %}
</select>
</label>
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" 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>
<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></th></tr>
</thead>
<tbody>
{% for s in suggestions %}
<tr>
<td>{{ s.name }}</td>
<td class="{{ 'neg' if s.amount < 0 else '' }}">{{ '%.2f'|format(s.amount) }} €</td>
<td>{{ s.rhythm }}</td>
<td>{{ s.due_day }}</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 '' }}">
<button type="submit">Vorschlag übernehmen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">Keine Vorschläge — erkannt werden Serien aus mindestens 3 Monaten gleichartiger Buchungen.</p>
{% endif %}
</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>
<td>{{ p.name }}</td>
<td class="{{ 'neg' if p.amount < 0 else '' }}">{{ '%.2f'|format(p.amount) }} €</td>
<td>{{ p.due.strftime('%d.%m.%Y') }}</td>
<td>{{ category_names.get(p.category_id, '') }}</td>
<td>
<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>
{% 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" 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>
<td>{{ l.name }}</td>
<td>{{ '%.2f'|format(l.principal) }} €</td>
<td>{{ '%.2f'|format(l.annual_rate_pct) }} %</td>
<td>{{ l.term_months }} Monate</td>
<td>{{ l.payout_date.strftime('%d.%m.%Y') }}</td>
<td>{{ l.repayment_type }}</td>
<td>
<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>
<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" placeholder="10000.00" required></label>
<label>Zins p.a. (%) <input type="text" name="annual_rate_pct" 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 }}</option>{% endfor %}
</select>
</label>
<button type="submit">Anlegen</button>
</form>
</fieldset>
</section>
<section class="planning-section">
<h2>Szenarien</h2>
{% for row in scenario_rows %}
{% set sc = row.scenario %}
<fieldset>
<legend>{{ sc.name }}</legend>
<p>
{{ sc.description }}
· Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }}
· Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }}
</p>
<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>
<details>
<summary>Modifikatoren</summary>
<table>
<thead>
<tr><th>Ziel</th><th>Art</th><th>Wert</th><th></th></tr>
</thead>
<tbody>
{% for m in row.modifiers %}
<tr>
<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 }}</td>
<td>{{ '%.2f'|format(m.value) }}</td>
<td>
<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>
{% else %}
<tr><td colspan="4">Keine Modifikatoren.</td></tr>
{% endfor %}
</tbody>
</table>
<form hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" 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">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>
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
</select>
</label>
</span>
<label>Art
<select name="kind" data-type="str">
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k }}</option>{% endfor %}
</select>
</label>
<label>Wert <input type="text" name="value" value="0"></label>
<button type="submit">Modifikator hinzufügen</button>
</form>
</details>
<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>
{% if row.result %}
<p>
Tiefpunkt: {{ '%.2f'|format(row.result.low_point_balance) }} € 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>
// json-form-Extension ist zentral in base.html registriert (eine
// Definition fuer alle Templates, inkl. data-type="bool"-Handling).
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;
recSelect.disabled = isCategory;
}
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);
});
}
var loadedSchedules = {};
function formatIsoDate(iso) {
var parts = iso.split('-');
return parts[2] + '.' + parts[1] + '.' + parts[0];
}
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>' + Number(r.payment).toFixed(2) + ' €</td>'
+ '<td>' + Number(r.interest).toFixed(2) + ' €</td>'
+ '<td>' + Number(r.principal).toFixed(2) + ' €</td>'
+ '<td>' + Number(r.remaining).toFixed(2) + ' €</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 '' }}">{{ '%.2f'|format(balance) }} €</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>{{ '%.2f'|format(total) }} €</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 '' }}">{{ '%.2f'|format(value) }} €</td>
{% endfor %}
<td class="amount {{ 'neg' if row['total'] < 0 else '' }}">{{ '%.2f'|format(row['total']) }} €</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,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 '' }}">{{ '%.2f'|format(tx.amount) }}</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,169 @@
{
"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
},
"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
},
"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

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

@@ -0,0 +1,49 @@
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()

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.

View File

@@ -0,0 +1,30 @@
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

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,7 @@
from app.config import get_settings
def test_defaults():
s = get_settings()
assert s.horizon_days == 548
assert s.gui_user == "admin"

View File

@@ -0,0 +1,188 @@
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_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

View File

@@ -0,0 +1,255 @@
import json
from datetime import date
from decimal import Decimal
from pathlib import Path
import pytest
from app.parsers.base import ParserError
from app.parsers.csv_formats import detect_csv_format, parse_csv
from app.parsers.validate import balance_difference
FIXTURES = Path(__file__).parent / "fixtures"
FIXTURE_CASES = [
("DKB_2025.csv", "dkb_csv"),
("DKB_2026.csv", "dkb_csv"),
("VR_2025.csv", "vr_csv"),
("VR_2026.csv", "vr_csv"),
("HVB_2025.csv", "hvb_csv"),
("HVB_2026.csv", "hvb_csv"),
]
def _read_bytes(filename: str) -> bytes:
return (FIXTURES / filename).read_bytes()
def _gebucht_count(raw: bytes) -> int:
"""Independently count 'Gebucht' rows without keeping/printing any row content."""
# DKB is UTF-8 (with BOM); Status column is ASCII, safe to count on raw text.
text = raw.decode("utf-8-sig")
return text.count(';"Gebucht";')
@pytest.mark.parametrize("filename,fmt", FIXTURE_CASES)
def test_fixture_rowcount_and_ascending(filename, fmt):
path = FIXTURES / filename
if not path.exists():
pytest.skip(f"Fixture {filename} fehlt")
parsed = parse_csv(path)
stmt = parsed.statement
assert stmt.bank == fmt
assert len(stmt.transactions) > 0
dates = [t.booking_date for t in stmt.transactions]
assert dates == sorted(dates), "Transaktionen muessen chronologisch aufsteigend sein"
for t in stmt.transactions:
assert isinstance(t.amount, Decimal)
@pytest.mark.parametrize("filename,fmt", [c for c in FIXTURE_CASES if c[1] == "vr_csv"])
def test_vr_fixture_balance_chain_and_anchor(filename, fmt):
path = FIXTURES / filename
if not path.exists():
pytest.skip(f"Fixture {filename} fehlt")
parsed = parse_csv(path)
assert parsed.balance_checkable is True
assert balance_difference(parsed.statement) == Decimal("0")
assert parsed.anchor is not None
anchor_date, anchor_balance = parsed.anchor
assert isinstance(anchor_date, date)
assert isinstance(anchor_balance, Decimal)
@pytest.mark.parametrize("filename,fmt", [c for c in FIXTURE_CASES if c[1] == "dkb_csv"])
def test_dkb_fixture_anchor_and_gebucht_filter(filename, fmt):
path = FIXTURES / filename
if not path.exists():
pytest.skip(f"Fixture {filename} fehlt")
raw = _read_bytes(filename)
parsed = parse_csv(path)
assert parsed.anchor is not None
assert parsed.balance_checkable is False
assert parsed.statement.opening_balance is None
assert parsed.statement.closing_balance is None
# Filtering: parsed transaction count must equal independently-counted
# "Gebucht" data rows in the raw file (counts only, no content asserted).
assert len(parsed.statement.transactions) == _gebucht_count(raw)
for t in parsed.statement.transactions:
assert t.counterparty != ""
@pytest.mark.parametrize("filename,fmt", [c for c in FIXTURE_CASES if c[1] == "hvb_csv"])
def test_hvb_fixture_no_balance_check(filename, fmt):
path = FIXTURES / filename
if not path.exists():
pytest.skip(f"Fixture {filename} fehlt")
parsed = parse_csv(path)
assert parsed.balance_checkable is False
assert parsed.anchor is None
assert parsed.statement.opening_balance is None
assert parsed.statement.closing_balance is None
for t in parsed.statement.transactions:
assert t.counterparty == ""
# ---------------------------------------------------------------------------
# Synthetic tests: minimal, fictitious CSVs constructed as bytes in-test.
# ---------------------------------------------------------------------------
def _dkb_row(booking, value, status, payer, payee, purpose, umsatztyp, iban, amount):
return (
f'"{booking}";"{value}";"{status}";"{payer}";"{payee}";'
f'"{purpose}";"{umsatztyp}";"{iban}";"{amount}";"";"";""\r\n'
)
def _build_dkb_csv() -> bytes:
header = (
'"Girokonto";"DE00123456780000000042"\r\n'
'"Zeitraum:";"01.01.2025 - 31.01.2025"\r\n'
'"Kontostand vom 31.01.2025:";"1.000,00\xa0"\r\n'
'""\r\n'
'"Buchungsdatum";"Wertstellung";"Status";"Zahlungspflichtige*r";'
'"Zahlungsempfänger*in";"Verwendungszweck";"Umsatztyp";"IBAN";"Betrag (€)";'
'"Gläubiger-ID";"Mandatsreferenz";"Kundenreferenz"\r\n'
)
rows = (
_dkb_row("15.01.25", "15.01.25", "Gebucht", "Max Muster", "Erika Beispiel",
"Testzweck A", "Ausgang", "DE00TESTIBAN1", "-10,00")
+ _dkb_row("10.01.25", "10.01.25", "Vorgemerkt", "Max Muster", "Erika Beispiel",
"Testzweck B (nicht gebucht)", "Ausgang", "DE00TESTIBAN1", "-99,00")
+ _dkb_row("05.01.25", "05.01.25", "Gebucht", "Erika Beispiel", "Max Muster",
"Testzweck C", "Eingang", "DE00TESTIBAN1", "50,00")
)
return (header + rows).encode("utf-8-sig")
def _build_vr_csv(saldi_consistent: bool = True) -> bytes:
header = (
"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\r\n"
)
# File order is descending (newest first), matches real format.
saldo_newest = "120,00" if saldi_consistent else "999,00"
rows = (
"Testkonto;DE00TESTVR000000000001;GENODETEST;Test-Bank;15.01.2025;15.01.2025;"
"Beispiel Gegenpartei;DE00GEGEN0001;GENODETEST;Buchung;Testzweck A;20,00;EUR;"
f"{saldo_newest};;;;\r\n"
"Testkonto;DE00TESTVR000000000001;GENODETEST;Test-Bank;05.01.2025;05.01.2025;"
"Beispiel Gegenpartei 2;DE00GEGEN0002;GENODETEST;Buchung;Testzweck B;100,00;EUR;"
"100,00;;;;\r\n"
)
return (header + rows).encode("utf-8-sig")
def _build_hvb_csv() -> bytes:
header = "Kontonummer;Buchungsdatum;Valuta;Verwendungszweck;Betrag;Waehrung\r\n"
rows = (
"123456789;05.01.2025;05.01.2025;Testzweck mit Umlaut ä ö ü ß;-30,00;EUR\r\n"
"123456789;15.01.2025;15.01.2025;Testzweck zwei;40,00;EUR\r\n"
)
return (header + rows).encode("utf-16")
def test_detect_dkb_csv():
assert detect_csv_format(_build_dkb_csv()) == "dkb_csv"
def test_detect_vr_csv():
assert detect_csv_format(_build_vr_csv()) == "vr_csv"
def test_detect_hvb_csv():
assert detect_csv_format(_build_hvb_csv()) == "hvb_csv"
def test_detect_garbage_returns_none():
assert detect_csv_format(b"this,is,not;a\nrecognised\tformat garbage 1234") is None
def test_dkb_synthetic_skips_non_gebucht_rows(tmp_path):
p = tmp_path / "dkb_synth.csv"
p.write_bytes(_build_dkb_csv())
parsed = parse_csv(p)
assert len(parsed.statement.transactions) == 2
for t in parsed.statement.transactions:
assert t.purpose != "" and "nicht gebucht" not in t.purpose
def test_dkb_synthetic_two_digit_year_parsed(tmp_path):
p = tmp_path / "dkb_synth.csv"
p.write_bytes(_build_dkb_csv())
parsed = parse_csv(p)
dates = [t.booking_date for t in parsed.statement.transactions]
assert date(2025, 1, 5) in dates
assert date(2025, 1, 15) in dates
def test_dkb_synthetic_anchor(tmp_path):
p = tmp_path / "dkb_synth.csv"
p.write_bytes(_build_dkb_csv())
parsed = parse_csv(p)
assert parsed.anchor == (date(2025, 1, 31), Decimal("1000.00"))
assert parsed.balance_checkable is False
def test_vr_synthetic_balance_chain_ok(tmp_path):
p = tmp_path / "vr_synth.csv"
p.write_bytes(_build_vr_csv(saldi_consistent=True))
parsed = parse_csv(p)
assert parsed.balance_checkable is True
assert balance_difference(parsed.statement) == Decimal("0")
assert parsed.anchor == (date(2025, 1, 15), Decimal("120.00"))
def test_vr_synthetic_balance_chain_break_raises(tmp_path):
p = tmp_path / "vr_synth_broken.csv"
p.write_bytes(_build_vr_csv(saldi_consistent=False))
with pytest.raises(ParserError):
parse_csv(p)
def test_hvb_synthetic_utf16_decode(tmp_path):
p = tmp_path / "hvb_synth.csv"
p.write_bytes(_build_hvb_csv())
parsed = parse_csv(p)
stmt = parsed.statement
assert len(stmt.transactions) == 2
assert stmt.transactions[0].booking_date == date(2025, 1, 5)
assert stmt.transactions[1].booking_date == date(2025, 1, 15)
assert "ä" in stmt.transactions[0].purpose
assert all(t.counterparty == "" for t in stmt.transactions)
assert parsed.balance_checkable is False
assert parsed.anchor is None
def test_parse_csv_unknown_format_raises(tmp_path):
p = tmp_path / "garbage.csv"
p.write_bytes(b"not,a;known\nformat\n")
with pytest.raises(ParserError):
parse_csv(p)
CSV_FILES = ["DKB_2025", "DKB_2026", "VR_2025", "VR_2026", "HVB_2025", "HVB_2026"]
@pytest.mark.parametrize("name", CSV_FILES)
def test_csv_expected_values(name):
path = FIXTURES / f"{name}.csv"
exp_path = FIXTURES / f"expected_csv_{name}.json"
if not path.exists() or not exp_path.exists():
pytest.skip("Fixture oder expected-Datei fehlt")
result = parse_csv(path)
exp = json.loads(exp_path.read_text())
txs = result.statement.transactions
assert len(txs) == exp["transaction_count"]
for c in exp["spot_checks"]:
t = txs[c["index"]]
assert str(t.booking_date) == c["booking_date"]
assert str(t.amount) == c["amount"]
assert c["counterparty_contains"] in t.counterparty
assert c["purpose_contains"] in t.purpose

View File

@@ -0,0 +1,93 @@
"""Tests fuer die Final-Review-Findings F2, F3, F6."""
from datetime import date
from decimal import Decimal
from app.models.tables import (ProjectionPoint, ProjectionResult, Scenario,
Statement, Transaction)
H = {"Authorization": "Bearer test-key"}
def _account(client, iban="DE01"):
return client.post("/api/accounts", headers=H, json={
"bank": "DKB", "iban": iban, "name": "G", "type": "giro"}).json()
# ----------------------------------------------------------------- F2 cascades
def test_delete_projected_scenario_removes_dependent_rows(client, db):
sc = client.post("/api/scenarios", headers=H, json={"name": "Basis"}).json()
sid = sc["id"]
r = client.post(f"/api/scenarios/{sid}/project", headers=H,
params={"horizon_days": 10, "start_date": "2026-07-15"})
assert r.status_code == 200
assert db.query(ProjectionPoint).filter_by(scenario_id=sid).count() > 0
assert db.get(ProjectionResult, sid) is not None
assert client.delete(f"/api/scenarios/{sid}", headers=H).status_code == 204
db.expire_all()
assert db.get(Scenario, sid) is None
assert db.query(ProjectionPoint).filter_by(scenario_id=sid).count() == 0
assert db.get(ProjectionResult, sid) is None
def test_delete_loan_assigned_to_scenario(client):
loan = client.post("/api/loans", headers=H, json={
"name": "K1", "principal": "5000.00", "annual_rate_pct": "6.0",
"term_months": 48, "payout_date": "2026-07-20"}).json()
sc = client.post("/api/scenarios", headers=H, json={"name": "Kredit"}).json()
assert client.post(f"/api/scenarios/{sc['id']}/loans/{loan['id']}",
headers=H).status_code == 204
assert client.delete(f"/api/loans/{loan['id']}", headers=H).status_code == 204
def test_delete_confirmed_import_returns_409(client, db):
acc = _account(client)
db.add(Statement(filename="s.pdf", bank="dkb", account_id=acc["id"],
status="confirmed"))
db.commit()
sid = db.query(Statement).one().id
assert client.delete(f"/api/imports/{sid}", headers=H).status_code == 409
# --------------------------------------------------------------- F3 validation
def test_recurring_invalid_rhythm_422(client):
r = client.post("/api/recurring", headers=H, json={
"name": "X", "amount": "-1.00", "rhythm": "weekly", "due_day": 1})
assert r.status_code == 422
def test_recurring_due_day_zero_422(client):
r = client.post("/api/recurring", headers=H, json={
"name": "X", "amount": "-1.00", "rhythm": "monthly", "due_day": 0})
assert r.status_code == 422
def test_loan_invalid_repayment_type_422(client):
r = client.post("/api/loans", headers=H, json={
"name": "K", "principal": "1000.00", "annual_rate_pct": "3.0",
"term_months": 12, "payout_date": "2026-08-01",
"repayment_type": "balloon"})
assert r.status_code == 422
def test_modifier_invalid_kind_422(client):
sc = client.post("/api/scenarios", headers=H, json={"name": "S"}).json()
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "category", "target_id": 1, "kind": "double",
"value": "10"})
assert r.status_code == 422
# ------------------------------------------------------- F6 409/404 statt 500
def test_patch_scenario_rename_clash_409(client):
client.post("/api/scenarios", headers=H, json={"name": "A"})
b = client.post("/api/scenarios", headers=H, json={"name": "B"}).json()
r = client.patch(f"/api/scenarios/{b['id']}", headers=H, json={"name": "A"})
assert r.status_code == 409
def test_create_transaction_unknown_account_404(client):
r = client.post("/api/transactions", headers=H, json={
"account_id": 9999, "booking_date": "2026-07-01",
"amount": "-10.00", "purpose": "X", "counterparty": ""})
assert r.status_code == 404

270
finance/tests/test_gui.py Normal file
View File

@@ -0,0 +1,270 @@
from datetime import date, timedelta
from decimal import Decimal
from app.models.tables import Account
H = {"Authorization": "Bearer test-key"}
def test_pages_require_login(client):
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/hilfe"):
r = client.get(path, follow_redirects=False)
assert r.status_code in (302, 303), path
assert r.headers["location"] == "/login"
def test_pages_render_after_login(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/hilfe"):
r = client.get(path)
assert r.status_code == 200
assert "Finanzberatung" in r.text
assert "Gebrauchsanleitung" in client.get("/hilfe").text
def test_index_shows_account_anchor(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
with_anchor = Account(bank="dkb", iban="DE-GUI-1", name="Mit Anker", type="giro")
without_anchor = Account(bank="vr", iban="DE-GUI-2", name="Ohne Anker", type="giro")
db.add_all([with_anchor, without_anchor])
db.flush()
with_anchor.anchor_date = date(2026, 6, 30)
with_anchor.anchor_balance = Decimal("321.00")
db.commit()
r = client.get("/")
assert r.status_code == 200
assert "Anker: 321.00" in r.text
assert "30.06.2026" in r.text
assert "kein Anker" in r.text
# Inline-Pflegeformular fuer den Anker muss je Konto vorhanden sein.
assert 'name="anchor_date"' in r.text
assert 'name="anchor_balance"' in r.text
def test_import_page_has_dropzone_and_list(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/import")
assert r.status_code == 200
assert 'id="dropzone"' in r.text
assert 'id="imports"' in r.text
def test_import_list_fragment_requires_login(client):
r = client.get("/import/list", follow_redirects=False)
assert r.status_code in (302, 303)
def test_import_list_fragment_renders_after_login(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/import/list")
assert r.status_code == 200
assert "Noch keine Imports" in r.text
def test_preview_fragment_shows_balance_ok_none_for_format_without_saldo(
client, tmp_path, monkeypatch):
# UX-Regel: Saldo-Status ist IMMER sichtbar, auch wenn das Format (hier
# HVB-CSV) keine Saldodaten liefert - dreiwertig statt faelschlich "rot".
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
from tests.test_import_api import _hvb_csv_bytes
sid = client.post("/api/imports/upload", headers=H,
files={"file": ("HVB_gui.csv", _hvb_csv_bytes(), "text/csv")}
).json()["id"]
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get(f"/import/{sid}/preview-fragment")
assert r.status_code == 200
assert "Saldo-Prüfung: nicht verfügbar (Format ohne Saldodaten)" in r.text
def test_preview_fragment_shows_balance_ok_true_for_vr_csv(client, tmp_path, monkeypatch):
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
from tests.test_import_api import _vr_csv_bytes
sid = client.post("/api/imports/upload", headers=H,
files={"file": ("VR_gui.csv", _vr_csv_bytes(), "text/csv")}
).json()["id"]
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get(f"/import/{sid}/preview-fragment")
assert r.status_code == 200
assert "Saldo plausibel" in r.text
def test_version_visible(client):
from pathlib import Path
version = (Path(__file__).resolve().parent.parent / "VERSION").read_text().strip()
r = client.get("/api/version", headers={"Authorization": "Bearer test-key"})
assert r.status_code == 200 and r.json()["version"] == version
client.post("/login", data={"username": "admin", "password": "geheim"})
assert f"v{version}" in client.get("/").text
def test_buchungen_pagination(client, db):
from app.models.tables import Account, Transaction
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
db.add(acc)
db.flush()
base = date(2026, 1, 1)
for i in range(1, 61):
purpose = "MARKER51" if i == 51 else f"TX{i}"
db.add(Transaction(
account_id=acc.id, booking_date=base + timedelta(days=i),
amount=Decimal("10.00"), purpose=purpose, counterparty="X",
status="confirmed", dedup_hash=f"hash-{i}",
))
db.commit()
client.post("/login", data={"username": "admin", "password": "geheim"})
r1 = client.get("/buchungen")
assert r1.status_code == 200
assert "Seite 1 von 2" in r1.text
assert "Weiter" in r1.text
assert "MARKER51" not in r1.text
r2 = client.get("/buchungen?page=2")
assert r2.status_code == 200
assert "Seite 2 von 2" in r2.text
assert "Zurück" in r2.text
assert "MARKER51" in r2.text
def test_buchungen_empty_filter_fields_no_422(client):
# HTML-Formulare senden bei nicht ausgefüllten Feldern leere Strings, nicht
# "kein Parameter" — die GUI-Route muss das tolerant behandeln statt mit
# einem rohen 422 zu scheitern.
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get(
"/buchungen"
"?account_id=2&date_from=&date_to=&category_id=&q=&status=confirmed"
)
assert r.status_code == 200
def test_buchungen_filled_filters_scope_to_account(client, db):
from app.models.tables import Account, Transaction
acc_a = Account(bank="dkb", iban="DE01", name="Konto A", type="giro")
acc_b = Account(bank="dkb", iban="DE02", name="Konto B", type="giro")
db.add_all([acc_a, acc_b])
db.flush()
db.add(Transaction(
account_id=acc_a.id, booking_date=date(2026, 6, 15),
amount=Decimal("10.00"), purpose="MARKER-A", counterparty="X",
status="confirmed", dedup_hash="hash-a",
))
db.add(Transaction(
account_id=acc_b.id, booking_date=date(2026, 6, 15),
amount=Decimal("20.00"), purpose="MARKER-B", counterparty="Y",
status="confirmed", dedup_hash="hash-b",
))
db.commit()
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get(
f"/buchungen?account_id={acc_a.id}&date_from=2026-06-01&date_to=2026-06-30"
)
assert r.status_code == 200
assert "MARKER-A" in r.text
assert "MARKER-B" not in r.text
def test_planning_page_has_all_sections(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/planung")
assert r.status_code == 200
for heading in ("Wiederkehrende Posten", "Einmalposten", "Kredite", "Szenarien"):
assert heading in r.text
def test_planning_page_shows_empty_suggestions_hint(client):
# UX-Regel: das "Vorschläge aus Buchungen"-Fieldset ist immer sichtbar,
# auch ohne Daten - statt komplett zu verschwinden zeigt es einen Hinweis.
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/planung")
assert r.status_code == 200
assert "Vorschläge aus Buchungen" in r.text
assert "Keine Vorschläge" in r.text
def test_salden_page_stichtag_and_month_overview(client, db):
from app.engine.loans import add_months
from app.models.tables import Transaction
# Drei Monate relativ zu "heute" verwenden statt fester Kalenderdaten,
# damit der Test unabhaengig vom Ausfuehrungsdatum immer genau 3
# Monatsanfangs-Zeilen (frühester Buchungsmonat bis heute) erzeugt.
today = date.today()
m0 = date(today.year, today.month, 1)
m1 = add_months(m0, -1)
m2 = add_months(m0, -2)
acc = Account(bank="dkb", iban="DE-SALDEN-1", name="Salden-Konto", type="giro")
db.add(acc)
db.flush()
acc.anchor_date = m2
acc.anchor_balance = Decimal("1000.00")
db.flush()
db.add_all([
Transaction(account_id=acc.id, booking_date=m2 + timedelta(days=5),
amount=Decimal("111.11"), purpose="SALDEN-TEST-1",
counterparty="X", status="confirmed", dedup_hash="salden-h1"),
Transaction(account_id=acc.id, booking_date=m1 + timedelta(days=3),
amount=Decimal("22.22"), purpose="SALDEN-TEST-2",
counterparty="X", status="confirmed", dedup_hash="salden-h2"),
Transaction(account_id=acc.id, booking_date=m0 + timedelta(days=2),
amount=Decimal("-33.33"), purpose="SALDEN-TEST-3",
counterparty="X", status="confirmed", dedup_hash="salden-h3"),
])
db.commit()
client.post("/login", data={"username": "admin", "password": "geheim"})
# Stichtag zwischen Buchung 2 und Buchung 3: Saldo = Anker + Buchung1 + Buchung2.
stichtag = m1 + timedelta(days=10)
expected_stichtag = Decimal("1000.00") + Decimal("111.11") + Decimal("22.22")
r = client.get(f"/salden?stichtag={stichtag.isoformat()}")
assert r.status_code == 200
assert f"{expected_stichtag:.2f}" in r.text
# Monatsuebersicht: genau 3 Monatszeilen (m2, m1, m0).
assert r.text.count('data-month="') == 3
for m in (m0, m1, m2):
assert f'data-month="{m.isoformat()}"' in r.text
# Monatsanfangs-Saldo fuer m1 = Stand Ende Vortag von m1 (letzter Tag von
# m2), d.h. NACH Buchung 1 (in m2), aber VOR Buchung 2 (die faellt erst
# in m1 selbst und darf den Monatsanfang nicht verfaelschen).
expected_m1_start = Decimal("1000.00") + Decimal("111.11")
assert f"{expected_m1_start:.2f}" in r.text
def test_salden_stichtag_garbage_falls_back_to_today(client):
# Lenient wie die Filter auf /buchungen: ein kaputter stichtag-Parameter
# darf nie zu 422/500 fuehren, sondern degradiert auf "heute".
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/salden?stichtag=garbage")
assert r.status_code == 200
assert date.today().strftime('%d.%m.%Y') in r.text
def test_salden_shows_ohne_anker_footnote(client, db):
# UX-Regel: Konten ohne Anker sind immer sichtbar als relative Werte
# gekennzeichnet, nicht stillschweigend wie geankerte Konten dargestellt.
acc = Account(bank="vr", iban="DE-SALDEN-2", name="Ohne-Anker-Konto", type="giro")
db.add(acc)
db.commit()
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/salden")
assert r.status_code == 200
assert "ohne Anker" in r.text and "relative Werte" in r.text

View File

@@ -0,0 +1,480 @@
import csv
import io
import re
from datetime import date
from decimal import Decimal
from pathlib import Path
import pytest
from app.parsers.base import ParsedStatement, ParsedTransaction
H = {"Authorization": "Bearer test-key"}
FAKE = ParsedStatement(
bank="dkb", iban="DE02120300000000202051",
period_start=date(2026, 6, 1), period_end=date(2026, 6, 30),
opening_balance=Decimal("100.00"), closing_balance=Decimal("40.00"),
transactions=[ParsedTransaction(date(2026, 6, 3), date(2026, 6, 3),
Decimal("-60.00"), "Miete Juni", "Vermieter")])
@pytest.fixture
def fake_parse(monkeypatch):
monkeypatch.setattr("app.services.importer.parse_pdf", lambda p: FAKE)
# Ein Auszug mit ZWEI identischen Buchungen (gleicher dedup_hash). Saldo bleibt
# konsistent (100 - 60 - 60 = -20). Beide muessen bestaetigt werden koennen.
FAKE_TWINS = ParsedStatement(
bank="dkb", iban="DE02120300000000202051",
period_start=date(2026, 6, 1), period_end=date(2026, 6, 30),
opening_balance=Decimal("100.00"), closing_balance=Decimal("-20.00"),
transactions=[
ParsedTransaction(date(2026, 6, 3), date(2026, 6, 3),
Decimal("-60.00"), "Miete Juni", "Vermieter"),
ParsedTransaction(date(2026, 6, 3), date(2026, 6, 3),
Decimal("-60.00"), "Miete Juni", "Vermieter"),
])
@pytest.fixture
def fake_parse_twins(monkeypatch):
monkeypatch.setattr("app.services.importer.parse_pdf", lambda p: FAKE_TWINS)
def _upload(client, name="auszug.pdf"):
return client.post("/api/imports/upload", headers=H,
files={"file": (name, b"%PDF-fake", "application/pdf")})
def test_upload_preview_confirm(client, fake_parse, tmp_path, monkeypatch):
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
r = _upload(client)
assert r.status_code == 201
sid = r.json()["id"]
prev = client.get(f"/api/imports/{sid}/preview", headers=H).json()
assert prev["balance_ok"] is True
assert len(prev["transactions"]) == 1
assert client.post(f"/api/imports/{sid}/confirm", headers=H).status_code == 200
txs = client.get("/api/transactions", headers=H).json()
assert len(txs) == 1 and txs[0]["status"] == "confirmed"
# Zweiter Import desselben Auszugs: alles Duplikate
sid2 = _upload(client, "auszug2.pdf").json()["id"]
prev2 = client.get(f"/api/imports/{sid2}/preview", headers=H).json()
assert prev2["duplicates"] == 1
client.post(f"/api/imports/{sid2}/confirm", headers=H)
assert len(client.get("/api/transactions", headers=H).json()) == 1
def test_confirm_rechecks_duplicates_across_two_drafts(client, fake_parse, tmp_path, monkeypatch):
# Beide Auszuege werden importiert, bevor einer bestaetigt ist -> zum
# Importzeitpunkt ist keine Buchung ein Duplikat (dedup prueft nur gegen
# bestaetigte). Erst beim zweiten Confirm faellt auf, dass die Buchung nun
# bereits bestaetigt existiert. Der Re-Check muss sie ueberspringen, sonst
# wird dieselbe Buchung doppelt bestaetigt.
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
sid1 = _upload(client, "a1.pdf").json()["id"]
sid2 = _upload(client, "a2.pdf").json()["id"]
assert client.post(f"/api/imports/{sid1}/confirm", headers=H).status_code == 200
assert client.post(f"/api/imports/{sid2}/confirm", headers=H).status_code == 200
# Zweites Confirm darf die Buchung nicht erneut bestaetigen.
assert len(client.get("/api/transactions", headers=H).json()) == 1
# Zweites Confirm eines bereits bestaetigten Imports -> 409.
assert client.post(f"/api/imports/{sid1}/confirm", headers=H).status_code == 409
def test_confirm_keeps_identical_twins_within_same_statement(client, fake_parse_twins, tmp_path, monkeypatch):
# Zwei identische Buchungen im SELBEN Auszug teilen sich den dedup_hash. Der
# Confirm-Re-Check darf nur statement-uebergreifende Duplikate abfangen,
# sonst wuerde die erste bestaetigte Buchung ihre Zwillingsbuchung als
# Duplikat markieren und den vom Preview geprueften Saldo zerstoeren.
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
sid = _upload(client).json()["id"]
prev = client.get(f"/api/imports/{sid}/preview", headers=H).json()
assert prev["balance_ok"] is True
assert prev["duplicates"] == 0
assert client.post(f"/api/imports/{sid}/confirm", headers=H).status_code == 200
txs = client.get("/api/transactions", headers=H).json()
assert len(txs) == 2
assert all(t["status"] == "confirmed" for t in txs)
def test_upload_sanitizes_path_traversal_filename(client, fake_parse, tmp_path, monkeypatch):
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
r = client.post("/api/imports/upload", headers=H,
files={"file": ("../evil.pdf", b"%PDF-fake", "application/pdf")})
assert r.status_code == 201
# Der Dateiname darf keine Pfad-Anteile aus dem Client uebernehmen: nur
# der Basisname landet im Uploads-Verzeichnis, nichts entkommt nach oben.
assert not (tmp_path / "evil.pdf").exists()
assert (tmp_path / "uploads" / "evil.pdf").exists()
assert not (tmp_path / "inbox" / "evil.pdf").exists()
def test_upload_rejects_non_pdf(client, tmp_path, monkeypatch):
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
r = client.post("/api/imports/upload", headers=H,
files={"file": ("x.txt", b"not a pdf", "text/plain")})
assert r.status_code == 400
def test_delete_error_import_returns_204(client, db):
# Ein fehlgeschlagener Import (status="error") muss verworfen werden
# koennen - nur bestaetigte Importe sind vor dem Loeschen geschuetzt.
from app.models.tables import Statement
db.add(Statement(filename="kaputt.pdf", bank="dkb", status="error",
error_message="PDF nicht lesbar"))
db.commit()
sid = db.query(Statement).one().id
assert client.delete(f"/api/imports/{sid}", headers=H).status_code == 204
def test_rollback_confirmed_import_deletes_transactions_and_statement(client, fake_parse, tmp_path, monkeypatch):
# Ein bestaetigter Import laesst sich als Ganzes zurueckrollen: alle seine
# Buchungen UND der Auszug selbst verschwinden, das Konto bleibt bestehen
# (Name/IBAN fuer einen Re-Import mit verbessertem Parser).
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
sid = _upload(client).json()["id"]
assert client.post(f"/api/imports/{sid}/confirm", headers=H).status_code == 200
account_id = client.get("/api/accounts", headers=H).json()[0]["id"]
r = client.post(f"/api/imports/{sid}/rollback", headers=H)
assert r.status_code == 200
body = r.json()
assert body == {"deleted_transactions": 1, "statement_id": sid}
txs = client.get("/api/transactions", headers=H).json()
assert len(txs) == 0
imports = client.get("/api/imports", headers=H).json()
assert all(s["id"] != sid for s in imports)
accounts = client.get("/api/accounts", headers=H).json()
assert any(a["id"] == account_id for a in accounts)
def test_rollback_then_reimport_restores_transactions(client, fake_parse, tmp_path, monkeypatch):
# Der eigentliche Zweck des Rollbacks: denselben Auszug erneut importieren
# koennen (z.B. nach einem verbesserten Parser), ohne dass alte Buchungen
# als Duplikate im Weg stehen.
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
sid = _upload(client).json()["id"]
client.post(f"/api/imports/{sid}/confirm", headers=H)
client.post(f"/api/imports/{sid}/rollback", headers=H)
sid2 = _upload(client, "auszug_reimport.pdf").json()["id"]
prev = client.get(f"/api/imports/{sid2}/preview", headers=H).json()
assert prev["balance_ok"] is True
assert prev["duplicates"] == 0
assert client.post(f"/api/imports/{sid2}/confirm", headers=H).status_code == 200
txs = client.get("/api/transactions", headers=H).json()
assert len(txs) == 1 and txs[0]["status"] == "confirmed"
def test_rollback_draft_import_returns_409(client, fake_parse, tmp_path, monkeypatch):
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
sid = _upload(client).json()["id"]
r = client.post(f"/api/imports/{sid}/rollback", headers=H)
assert r.status_code == 409
def test_rollback_unknown_import_returns_404(client):
r = client.post("/api/imports/999999/rollback", headers=H)
assert r.status_code == 404
def _row_for(html: str, filename: str) -> str:
"""Isoliert den <tr>-Block eines bestimmten Import-Statements anhand seines
Dateinamens, damit Button-Zustaende eindeutig dem richtigen Statement
zugeordnet werden (statt nur global auf Disabled-Buttons zu zaehlen)."""
for row in html.split("<tr>"):
if filename in row:
return row
raise AssertionError(f"keine Zeile fuer {filename!r} gefunden")
def _has_active_button(row: str, label: str) -> bool:
for tag in re.finditer(r"<button[^>]*>" + re.escape(label) + r"</button>", row):
if "disabled" not in tag.group(0):
return True
return False
def _has_disabled_button(row: str, label: str) -> bool:
return bool(re.search(r"<button[^>]*disabled[^>]*>" + re.escape(label) + r"</button>", row))
def test_import_list_button_matrix(client, tmp_path, monkeypatch):
# UX-Regel: Bedienelemente sind immer sichtbar, ggf. ausgegraut (disabled),
# statt je nach Status komplett zu fehlen. Drei Statements mit den drei
# moeglichen Status erzeugen, dann pro Zeile die Button-Matrix pruefen.
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
# parse_pdf direkt (nicht ueber die fake_parse-Fixture) patchen/zuruecksetzen,
# damit der dritte Upload weiter unten wieder den echten Parser durchlaeuft
# und an kaputten Bytes wie in test_upload_corrupt_pdf_produces_error_statement_not_500
# scheitert - monkeypatch.undo() wuerde hier auch die setenv-Aenderungen
# oben zuruecknehmen.
import app.services.importer as importer_mod
original_parse_pdf = importer_mod.parse_pdf
importer_mod.parse_pdf = lambda p: FAKE
try:
sid_draft = _upload(client, "entwurf.pdf").json()["id"]
sid_confirmed = _upload(client, "bestaetigt.pdf").json()["id"]
assert client.post(f"/api/imports/{sid_confirmed}/confirm", headers=H).status_code == 200
finally:
importer_mod.parse_pdf = original_parse_pdf
r_err = client.post("/api/imports/upload", headers=H,
files={"file": ("fehler.pdf", b"not a pdf", "application/pdf")})
assert r_err.status_code == 201
assert r_err.json()["status"] == "error"
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/import/list")
assert r.status_code == 200
draft_row = _row_for(r.text, "entwurf.pdf")
assert _has_active_button(draft_row, "Übernehmen")
assert _has_active_button(draft_row, "Verwerfen")
assert _has_disabled_button(draft_row, "Import zurückrollen")
assert not _has_active_button(draft_row, "Import zurückrollen")
confirmed_row = _row_for(r.text, "bestaetigt.pdf")
assert _has_disabled_button(confirmed_row, "Übernehmen")
assert _has_disabled_button(confirmed_row, "Verwerfen")
assert _has_active_button(confirmed_row, "Import zurückrollen")
assert not _has_disabled_button(confirmed_row, "Import zurückrollen")
error_row = _row_for(r.text, "fehler.pdf")
assert _has_active_button(error_row, "Verwerfen")
assert _has_disabled_button(error_row, "Übernehmen")
assert _has_disabled_button(error_row, "Import zurückrollen")
def test_upload_corrupt_pdf_produces_error_statement_not_500(client, tmp_path, monkeypatch):
# Datei besteht die Endungspruefung (.pdf), ist aber strukturell kein
# gueltiges PDF -> pdfplumber wirft eine eigene Exception (keine
# ParserError). Das darf nicht als unbehandelter 500 durchschlagen,
# sondern muss wie "Bank nicht erkannt" als sauberer Fehler-Import
# sichtbar werden.
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
r = client.post("/api/imports/upload", headers=H,
files={"file": ("kaputt.pdf", b"not a pdf", "application/pdf")})
assert r.status_code == 201
body = r.json()
assert body["status"] == "error"
assert "PDF nicht lesbar" in body["error_message"]
# Datei bleibt im Posteingang liegen, wird nicht ins Uploads-Verzeichnis verschoben.
assert (tmp_path / "inbox" / "kaputt.pdf").exists()
assert not (tmp_path / "uploads" / "kaputt.pdf").exists()
# ---------------------------------------------------------------------------
# CSV-Import (Ausbaustufe 3, Task 3): rein SYNTHETISCHE Mini-CSVs, nach der
# in docs/superpowers/plans/2026-07-19-ausbaustufe-3.md dokumentierten
# Formatreferenz konstruiert. KEINE echten Kontodaten.
# ---------------------------------------------------------------------------
def _dkb_csv_bytes(kontostand_date="30.06.2026", kontostand_amount="1.000,00 €") -> bytes:
buf = io.StringIO()
w = csv.writer(buf, delimiter=";", quoting=csv.QUOTE_ALL)
w.writerow(["Girokonto", "DE12500105170001234567"])
w.writerow(["Zeitraum:", "01.06.2026 - 30.06.2026"])
w.writerow([f"Kontostand vom {kontostand_date}:", kontostand_amount])
w.writerow([])
w.writerow(["Buchungsdatum", "Wertstellung", "Status", "Zahlungspflichtige*r",
"Zahlungsempfänger*in", "Verwendungszweck", "Umsatztyp", "IBAN",
"Betrag (€)", "Gläubiger-ID", "Mandatsreferenz", "Kundenreferenz"])
# Datei ist absteigend sortiert (neueste Buchung zuerst).
w.writerow(["15.06.26", "15.06.26", "Gebucht", "Max Mustermann", "Beispiel GmbH",
"Testzweck Miete", "Lastschrift", "DE12500105170001234567",
"-50,00", "", "", ""])
w.writerow(["10.06.26", "10.06.26", "Storniert", "Max Mustermann", "Nicht Gebucht GmbH",
"sollte ignoriert werden", "Lastschrift", "DE12500105170001234567",
"-999,00", "", "", ""])
w.writerow(["01.06.26", "01.06.26", "Gebucht", "Arbeitgeber XY", "Max Mustermann",
"Testzweck Gehalt", "Gutschrift", "DE12500105170001234567",
"1500,00", "", "", ""])
return ("" + buf.getvalue()).encode("utf-8")
def _vr_csv_bytes() -> bytes:
buf = io.StringIO()
w = csv.writer(buf, delimiter=";")
w.writerow(["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"])
# Datei ist absteigend sortiert (neueste Buchung zuerst).
w.writerow(["Girokonto", "DE02600501010000123456", "SOLADEST600", "VR Bank Test",
"15.06.2026", "15.06.2026", "Test Empfaenger", "DE00000000000000000000",
"", "Ueberweisung", "Testzweck 2", "100,00", "EUR", "1.100,00", "", "", "", ""])
w.writerow(["Girokonto", "DE02600501010000123456", "SOLADEST600", "VR Bank Test",
"01.06.2026", "01.06.2026", "Test Arbeitgeber", "DE00000000000000000000",
"", "Gutschrift", "Testzweck 1", "1000,00", "EUR", "1.000,00", "", "", "", ""])
text = ("" + buf.getvalue()).replace("\n", "\r\n")
return text.encode("utf-8")
def _hvb_csv_bytes(kontonummer="1234567") -> bytes:
buf = io.StringIO()
w = csv.writer(buf, delimiter=";")
w.writerow(["Kontonummer", "Buchungsdatum", "Valuta", "Verwendungszweck", "Betrag",
"Waehrung"])
# Datei ist aufsteigend sortiert (aelteste Buchung zuerst).
w.writerow([kontonummer, "01.06.2026", "01.06.2026", "Testzweck A", "500,00", "EUR"])
w.writerow([kontonummer, "15.06.2026", "15.06.2026", "Testzweck B", "-20,00", "EUR"])
return buf.getvalue().encode("utf-16")
def _upload_csv(client, name: str, content: bytes):
return client.post("/api/imports/upload", headers=H,
files={"file": (name, content, "text/csv")})
def test_upload_csv_vr_balance_ok_true_and_confirm(client, tmp_path, monkeypatch):
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
r = _upload_csv(client, "VR_test.csv", _vr_csv_bytes())
assert r.status_code == 201
body = r.json()
assert body["status"] == "draft"
assert body["bank"] == "vr_csv"
sid = body["id"]
prev = client.get(f"/api/imports/{sid}/preview", headers=H).json()
assert prev["balance_ok"] is True
assert len(prev["transactions"]) == 2
assert client.post(f"/api/imports/{sid}/confirm", headers=H).status_code == 200
accounts = client.get("/api/accounts", headers=H).json()
acc = next(a for a in accounts if a["iban"] == "DE02600501010000123456")
assert acc["anchor_balance"] == "1100.00"
assert acc["anchor_date"] == "2026-06-15"
def test_upload_csv_hvb_balance_ok_none(client, tmp_path, monkeypatch):
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
r = _upload_csv(client, "HVB_test.csv", _hvb_csv_bytes())
assert r.status_code == 201
body = r.json()
assert body["status"] == "draft"
assert body["bank"] == "hvb_csv"
sid = body["id"]
prev = client.get(f"/api/imports/{sid}/preview", headers=H).json()
assert prev["balance_ok"] is None
assert len(prev["transactions"]) == 2
assert client.post(f"/api/imports/{sid}/confirm", headers=H).status_code == 200
# HVB traegt die Kontonummer als iban im Konto (dokumentierte Einschraenkung).
accounts = client.get("/api/accounts", headers=H).json()
acc = next(a for a in accounts if a["iban"] == "1234567")
assert acc["anchor_date"] is None # HVB liefert keinen Anker
def test_upload_csv_dkb_balance_ok_none_and_sets_account_anchor(client, tmp_path, monkeypatch):
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
r = _upload_csv(client, "DKB_test.csv", _dkb_csv_bytes())
assert r.status_code == 201
body = r.json()
assert body["status"] == "draft"
assert body["bank"] == "dkb_csv"
sid = body["id"]
prev = client.get(f"/api/imports/{sid}/preview", headers=H).json()
assert prev["balance_ok"] is None
# Storno-Zeile ("Storniert") darf nicht importiert werden.
assert len(prev["transactions"]) == 2
assert client.post(f"/api/imports/{sid}/confirm", headers=H).status_code == 200
accounts = client.get("/api/accounts", headers=H).json()
acc = next(a for a in accounts if a["iban"] == "DE12500105170001234567")
assert acc["anchor_date"] == "2026-06-30"
assert acc["anchor_balance"] == "1000.00"
def test_upload_csv_dkb_does_not_downgrade_newer_manual_anchor(client, db, tmp_path, monkeypatch):
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
from app.models.tables import Account
# Konto existiert bereits mit einem manuellen Anker NACH dem CSV-Kontostand-Datum
# (30.06.2026) - dieser neuere, manuell gesetzte Anker darf durch den CSV-Import
# NICHT ueberschrieben/zurueckdatiert werden.
acc = Account(bank="dkb_csv", iban="DE12500105170001234567", name="DKB Giro",
type="giro", anchor_date=date(2026, 7, 10), anchor_balance=Decimal("42.00"))
db.add(acc)
db.commit()
r = _upload_csv(client, "DKB_test.csv", _dkb_csv_bytes())
assert r.status_code == 201
sid = r.json()["id"]
assert client.post(f"/api/imports/{sid}/confirm", headers=H).status_code == 200
accounts = client.get("/api/accounts", headers=H).json()
acc_after = next(a for a in accounts if a["iban"] == "DE12500105170001234567")
assert acc_after["anchor_date"] == "2026-07-10"
assert acc_after["anchor_balance"] == "42.00"
def test_upload_rejects_non_pdf_non_csv(client, tmp_path, monkeypatch):
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
r = client.post("/api/imports/upload", headers=H,
files={"file": ("x.txt", b"not a csv or pdf", "text/plain")})
assert r.status_code == 400
assert "PDF" in r.text and "CSV" in r.text
def test_scan_inbox_picks_up_csv(client, tmp_path, monkeypatch):
inbox = tmp_path / "inbox"
monkeypatch.setenv("FB_INBOX_DIR", str(inbox))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
inbox.mkdir(parents=True)
(inbox / "VR_scan.csv").write_bytes(_vr_csv_bytes())
r = client.post("/api/imports/scan-inbox", headers=H)
assert r.status_code == 200
results = r.json()
assert len(results) == 1
assert results[0]["bank"] == "vr_csv"
assert results[0]["status"] == "draft"

View File

@@ -0,0 +1,29 @@
from datetime import date
from decimal import Decimal
from app.engine.loans import add_months, annuity_payment, loan_schedule
def test_add_months_clamps_month_end():
assert add_months(date(2026, 1, 31), 1) == date(2026, 2, 28)
def test_annuity_payment():
# 10.000 EUR, 6 % p.a., 48 Monate -> 234,85 EUR Monatsrate
assert annuity_payment(Decimal("10000"), Decimal("6"), 48) == Decimal("234.85")
def test_annuity_schedule_amortizes_fully():
plan = loan_schedule(Decimal("10000"), Decimal("6"), 48, date(2026, 8, 1), "annuity")
assert len(plan) == 48
assert plan[-1].remaining == Decimal("0.00")
assert plan[0].due == date(2026, 9, 1)
assert plan[0].interest == Decimal("50.00") # 10000 * 0,5 %
assert plan[0].payment < 0
def test_bullet_loan():
plan = loan_schedule(Decimal("10000"), Decimal("6"), 12, date(2026, 8, 1), "bullet")
assert all(i.principal == 0 for i in plan[:-1])
assert plan[-1].principal == Decimal("10000")
assert plan[-1].remaining == Decimal("0.00")

View File

@@ -0,0 +1,24 @@
from datetime import date
from decimal import Decimal
from app.models.tables import Account, Statement, Transaction
def test_account_transaction_roundtrip(db):
acc = Account(bank="DKB", iban="DE02120300000000202051", name="Giro", type="giro")
db.add(acc)
db.flush()
st = Statement(filename="a.pdf", bank="DKB", account_id=acc.id,
period_start=date(2026, 6, 1), period_end=date(2026, 6, 30),
opening_balance=Decimal("100.00"), closing_balance=Decimal("50.00"),
status="draft")
db.add(st)
db.flush()
tx = Transaction(account_id=acc.id, statement_id=st.id,
booking_date=date(2026, 6, 3), value_date=date(2026, 6, 3),
amount=Decimal("-50.00"), purpose="Miete Juni",
counterparty="Vermieter GmbH", status="draft",
dedup_hash="abc", is_duplicate=False)
db.add(tx)
db.commit()
assert db.query(Transaction).one().amount == Decimal("-50.00")

View File

@@ -0,0 +1,56 @@
from datetime import date
from decimal import Decimal
import pytest
from app.parsers.base import (ParsedStatement, ParsedTransaction, ParserError,
parse_german_amount, parse_german_date)
from app.parsers.detect import detect_bank
from app.parsers.validate import balance_difference, dedup_hash
def test_parse_german_amount():
assert parse_german_amount("1.234,56") == Decimal("1234.56")
assert parse_german_amount("1.234,56-") == Decimal("-1234.56")
assert parse_german_amount("12,00 S") == Decimal("-12.00")
assert parse_german_amount("12,00 H") == Decimal("12.00")
assert parse_german_amount("1\xa0234,56") == Decimal("1234.56")
def test_parse_german_date():
assert parse_german_date("03.06.2026") == date(2026, 6, 3)
assert parse_german_date("03.06.", default_year=2026) == date(2026, 6, 3)
def test_parse_german_date_invalid():
with pytest.raises(ParserError):
parse_german_date("31.02.2026")
def test_detect_bank():
assert detect_bank("... Volksbank Musterstadt eG ...") == "vr"
assert detect_bank("... Raiffeisenbank ...") == "vr"
assert detect_bank("... HypoVereinsbank ...") == "hvb"
assert detect_bank("... UniCredit Bank ...") == "hvb"
assert detect_bank("... Deutsche Kreditbank AG ...") == "dkb"
assert detect_bank("... DKB ...") == "dkb"
assert detect_bank("irgendwas") is None
def _stmt(txs, opening, closing):
return ParsedStatement(bank="vr", iban=None, period_start=None,
period_end=None, opening_balance=opening,
closing_balance=closing, transactions=txs)
def test_balance_difference():
txs = [ParsedTransaction(date(2026, 6, 1), None, Decimal("-50"), "Miete", "V"),
ParsedTransaction(date(2026, 6, 2), None, Decimal("20"), "Gutschrift", "K")]
assert balance_difference(_stmt(txs, Decimal("100"), Decimal("70"))) == Decimal("0")
assert balance_difference(_stmt(txs, Decimal("100"), Decimal("75"))) == Decimal("5")
def test_dedup_hash_normalizes_whitespace():
a = dedup_hash(1, date(2026, 6, 1), Decimal("-50.00"), "Miete Juni")
b = dedup_hash(1, date(2026, 6, 1), Decimal("-50.00"), "Miete Juni")
assert a == b and len(a) == 64

View File

@@ -0,0 +1,102 @@
from datetime import date
from decimal import Decimal
from app.models.tables import Account, Category, RecurringItem, Transaction
from app.services.suggestions import suggest_recurring
H = {"Authorization": "Bearer test-key"}
def _seed_balance(db, amount="1000.00"):
# Portiert von Statement-closing-Logik auf Konto-Saldo-Anker (Ausbaustufe 3
# Task 1): der Anker am Konto uebernimmt die Rolle der frueheren
# Statement.closing_balance als Saldo-Basis.
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
db.add(acc)
db.flush()
acc.anchor_date = date(2026, 6, 30)
acc.anchor_balance = Decimal(amount)
db.commit()
def test_scenario_projection(client, db):
_seed_balance(db)
client.post("/api/recurring", headers=H, json={
"name": "Miete", "amount": "-600.00", "rhythm": "monthly", "due_day": 1})
sc = client.post("/api/scenarios", headers=H, json={"name": "Basis"}).json()
r = client.post(f"/api/scenarios/{sc['id']}/project", headers=H,
params={"horizon_days": 92, "start_date": "2026-07-15"})
assert r.status_code == 200
body = r.json()
# 3 Mietzahlungen (01.08., 01.09., 01.10.): 400 -> -200 -> -800
assert Decimal(body["low_point_balance"]) == Decimal("-800.00")
assert body["below_zero_date"] == "2026-09-01"
assert len(body["series"]) == 92
def test_loan_in_scenario_keeps_balance_positive(client, db):
_seed_balance(db)
client.post("/api/recurring", headers=H, json={
"name": "Miete", "amount": "-600.00", "rhythm": "monthly", "due_day": 1})
loan = client.post("/api/loans", headers=H, json={
"name": "K1", "principal": "5000.00", "annual_rate_pct": "6.0",
"term_months": 48, "payout_date": "2026-07-20",
"repayment_type": "annuity"}).json()
sc = client.post("/api/scenarios", headers=H, json={"name": "Kredit"}).json()
client.post(f"/api/scenarios/{sc['id']}/loans/{loan['id']}", headers=H)
body = client.post(f"/api/scenarios/{sc['id']}/project", headers=H,
params={"horizon_days": 92, "start_date": "2026-07-15"}).json()
assert Decimal(body["low_point_balance"]) > Decimal("0")
def _tx(acc, d, amount, counterparty, dedup, category_id=None):
return Transaction(account_id=acc.id, booking_date=d, amount=Decimal(amount),
purpose="", counterparty=counterparty, status="confirmed",
dedup_hash=dedup, category_id=category_id)
def test_suggest_recurring_three_consecutive_months_with_year_wrap(db):
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
db.add(acc)
db.flush()
cat = Category(name="Miete")
db.add(cat)
db.flush()
# Dez 2025 -> Jan 2026 -> Feb 2026: 3 aufeinanderfolgende Monate ueber den
# Jahreswechsel hinweg (prueft die Monats-Linearisierung y*12+m).
db.add(_tx(acc, date(2025, 12, 1), "-600.00", "Vermieter", "h1", cat.id))
db.add(_tx(acc, date(2026, 1, 15), "-600.00", "Vermieter", "h2", cat.id))
db.add(_tx(acc, date(2026, 2, 28), "-600.00", "Vermieter", "h3", cat.id))
db.commit()
out = suggest_recurring(db)
# Median der Tage [1, 15, 28] = 15; Betrag unveraendert uebernommen.
assert out == [{"name": "Vermieter", "amount": Decimal("-600.00"),
"rhythm": "monthly", "due_day": 15, "category_id": cat.id}]
def test_suggest_recurring_two_months_no_suggestion(db):
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
db.add(acc)
db.flush()
db.add(_tx(acc, date(2026, 3, 10), "-50.00", "Zweimonatig", "h1"))
db.add(_tx(acc, date(2026, 4, 10), "-50.00", "Zweimonatig", "h2"))
db.commit()
assert suggest_recurring(db) == []
def test_suggest_recurring_excludes_existing_recurring_item(db):
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
db.add(acc)
db.flush()
db.add(_tx(acc, date(2026, 1, 5), "-30.00", "Streaming", "h1"))
db.add(_tx(acc, date(2026, 2, 5), "-30.00", "Streaming", "h2"))
db.add(_tx(acc, date(2026, 3, 5), "-30.00", "Streaming", "h3"))
db.add(RecurringItem(name="Streaming", amount=Decimal("-30.00"),
rhythm="monthly", due_day=5))
db.commit()
# Gleicher Name + Betrag wie ein bereits vorhandenes RecurringItem -> ausgelassen.
assert suggest_recurring(db) == []

View File

@@ -0,0 +1,30 @@
from datetime import date
from decimal import Decimal
from app.engine.projection import project
def test_low_point_and_threshold():
flows = [(date(2026, 8, 1), Decimal("-150")), (date(2026, 8, 10), Decimal("200"))]
p = project(Decimal("100"), date(2026, 7, 31), flows, horizon_days=15,
threshold=Decimal("60"))
assert p.low_point == (date(2026, 8, 1), Decimal("-50"))
assert p.first_below_zero == date(2026, 8, 1)
assert p.first_below_threshold == date(2026, 8, 1)
assert p.series[-1][1] == Decimal("150")
def test_no_crossing():
p = project(Decimal("100"), date(2026, 7, 31), [], horizon_days=5)
assert p.first_below_zero is None
assert p.low_point[1] == Decimal("100")
def test_cashflow_on_start_date_excluded():
"""Cashflows dated exactly on start_date are excluded (part of starting balance)"""
p = project(Decimal("100"), date(2026, 8, 1), [(date(2026, 8, 1), Decimal("-999"))],
horizon_days=5)
# All balances remain 100 because the -999 flow on start_date is ignored
assert all(bal == Decimal("100") for _, bal in p.series)
assert p.first_below_zero is None
assert p.low_point[1] == Decimal("100")

View File

@@ -0,0 +1,29 @@
from datetime import date
from app.engine.recurrence import occurrences
def test_monthly_clamps_short_months():
got = occurrences("monthly", 31, date(2026, 1, 1), date(2026, 3, 31))
assert got == [date(2026, 1, 31), date(2026, 2, 28), date(2026, 3, 31)]
def test_quarterly_respects_item_bounds():
got = occurrences("quarterly", 15, date(2026, 1, 1), date(2026, 12, 31),
item_start=date(2026, 4, 1), item_end=date(2026, 10, 31))
assert got == [date(2026, 4, 15), date(2026, 7, 15), date(2026, 10, 15)]
def test_yearly():
got = occurrences("yearly", 1, date(2026, 1, 1), date(2028, 12, 31))
assert got == [date(2026, 1, 1), date(2027, 1, 1), date(2028, 1, 1)]
def test_monthly_leap_year_clamping():
got = occurrences("monthly", 31, date(2028, 1, 1), date(2028, 3, 31))
assert got == [date(2028, 1, 31), date(2028, 2, 29), date(2028, 3, 31)]
def test_quarterly_clamping():
got = occurrences("quarterly", 31, date(2026, 1, 1), date(2026, 12, 31))
assert got == [date(2026, 1, 31), date(2026, 4, 30), date(2026, 7, 31), date(2026, 10, 31)]

View File

@@ -0,0 +1,59 @@
from datetime import date
from decimal import Decimal
from app.engine.loans import loan_schedule
from app.engine.scenario import (PlainModifier, PlainPlanned, PlainRecurring,
build_cashflows)
W = (date(2026, 8, 1), date(2026, 10, 31))
def test_recurring_with_percent_cut():
rec = [PlainRecurring(id=1, name="Marketing", amount=Decimal("-1000"),
rhythm="monthly", due_day=5, start_date=None,
end_date=None, category_id=7)]
mods = [PlainModifier(target_type="category", target_id=7,
kind="percent", value=Decimal("50"))]
flows = build_cashflows(rec, [], [], [], mods, *W)
assert flows == [(date(2026, 8, 5), Decimal("-500.00")),
(date(2026, 9, 5), Decimal("-500.00")),
(date(2026, 10, 5), Decimal("-500.00"))]
def test_remove_modifier_and_planned_and_loan():
rec = [PlainRecurring(id=2, name="Abo", amount=Decimal("-50"),
rhythm="monthly", due_day=1, start_date=None,
end_date=None, category_id=None)]
mods = [PlainModifier(target_type="recurring", target_id=2,
kind="remove", value=Decimal("0"))]
planned = [PlainPlanned(name="Steuer", amount=Decimal("-2000"),
due=date(2026, 9, 15), category_id=None)]
sched = loan_schedule(Decimal("10000"), Decimal("6"), 48, date(2026, 8, 1), "annuity")
flows = build_cashflows(rec, planned, [sched],
[(date(2026, 8, 1), Decimal("10000"))], mods, *W)
days = [d for d, _ in flows]
assert date(2026, 8, 1) in days # Kredit-Auszahlung
assert date(2026, 9, 15) in days # Einmalposten
assert all(a != Decimal("-50") for _, a in flows) # Abo entfernt
def test_absolute_modifier_reduces_outflow():
"""Test absolute modifier for outflows: -200 - 50 = -150"""
rec = [PlainRecurring(id=1, name="Marketing", amount=Decimal("-200"),
rhythm="monthly", due_day=10, start_date=None,
end_date=None, category_id=3)]
mods = [PlainModifier(target_type="category", target_id=3,
kind="absolute", value=Decimal("50"))]
flows = build_cashflows(rec, [], [], [], mods, date(2026, 8, 1), date(2026, 8, 31))
assert flows == [(date(2026, 8, 10), Decimal("-150.00"))]
def test_absolute_modifier_clamps_to_zero():
"""Test absolute modifier clamping: -200 + 300 = 0 (never flips sign)"""
rec = [PlainRecurring(id=1, name="Marketing", amount=Decimal("-200"),
rhythm="monthly", due_day=10, start_date=None,
end_date=None, category_id=3)]
mods = [PlainModifier(target_type="category", target_id=3,
kind="absolute", value=Decimal("300"))]
flows = build_cashflows(rec, [], [], [], mods, date(2026, 8, 1), date(2026, 8, 31))
assert flows == [(date(2026, 8, 10), Decimal("0.00"))]