Compare commits
97 Commits
309b1039c5
...
main
| Author | SHA256 | Date | |
|---|---|---|---|
| 8eee62ee19 | |||
| 7ca64c91fe | |||
| aff8e9b28d | |||
| a520390e31 | |||
| 220d1fc802 | |||
| bf3f56118e | |||
| 28d1267f0c | |||
| e002d8205f | |||
| 353ff6056c | |||
| d6659d8b18 | |||
| 4431640eb2 | |||
| b21d7b9839 | |||
| f03249bff1 | |||
| 7b273e218c | |||
| 974ef5c208 | |||
| 421899b922 | |||
| cb44194daf | |||
| 4843f2e130 | |||
| 1708abeb91 | |||
| 39c6a65b1d | |||
| 2282aa3922 | |||
| f1b5d442bd | |||
| a192866cc4 | |||
| 033c41f449 | |||
| 13795fce25 | |||
| 5519037688 | |||
| aa1884c31e | |||
| cbcf2e0439 | |||
| 805cb4d852 | |||
| fe0e1b5e93 | |||
| bd9df8ab8b | |||
| 8e425840e8 | |||
| 8bbabf6657 | |||
| 8ff8b39b2e | |||
| 1ef297ab89 | |||
| 89a08965db | |||
| 09b34ae13b | |||
| f5fa3e45e8 | |||
| 74684c7e6c | |||
| 9316d72bb4 | |||
| 8f6f1a6e0e | |||
| c2b5f6eaa3 | |||
| 1b888307e9 | |||
| 7a0f71aa8c | |||
| ea4858c4de | |||
| e2d713e960 | |||
| f6a53b7e56 | |||
| 41d556bcde | |||
| 6b02fa3d85 | |||
| 1fc87001d6 | |||
| 18b738738b | |||
| 06392c0fec | |||
| e7855a7bda | |||
| d71c22adce | |||
| 0bad69bf42 | |||
| 543bb648b7 | |||
| 94eb80506e | |||
| f57edc5fcd | |||
| bba04db663 | |||
| e40442b73b | |||
| d5103a0fb3 | |||
| 82f278ca00 | |||
| 87f8bbc400 | |||
| 36d3e4fc59 | |||
| f27eda88f4 | |||
| 597f7ba552 | |||
| 917797bb16 | |||
| cf9282c716 | |||
| b3b40a5982 | |||
| 1338bd66d5 | |||
| 557928ac3f | |||
| a1d8e91cbc | |||
| fca11f551c | |||
| 43e8e8e8f9 | |||
| df6615c8f7 | |||
| afbbcbe9a5 | |||
| 3847398055 | |||
| a6009c47e3 | |||
| e8ea948b38 | |||
| b0cb4883ea | |||
| 8ca563cec1 | |||
| 19d870cf20 | |||
| 0576b396f1 | |||
| 320abd6542 | |||
| 193bbc450c | |||
| a10f4d0b57 | |||
| 101ee01af1 | |||
| 78d94a583b | |||
| d91bb2e331 | |||
| 877cba4964 | |||
| 020f41e9f2 | |||
| d41db2e10d | |||
| 82800ea7d0 | |||
| 9f50435df9 | |||
| d37cb230c4 | |||
| 53ffd65104 | |||
| d4a98e117f |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
example_create_pod_langflow.sh
|
||||
154
CLAUDE.md
Normal file
154
CLAUDE.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# CLAUDE.md — Repo `~/bin`
|
||||
|
||||
> Diese Datei richtet sich primär an künftige Claude-Coding-Sessions in
|
||||
> diesem Repo, sekundär an Menschen. Architektur/Modul-Übersicht (Mermaid):
|
||||
> **`docs/ARCHITEKTUR.md`**. Vor Code-Änderungen dort das Komponenten- und
|
||||
> Sequenzdiagramm lesen — jeder dort genannte Name existiert real im Code.
|
||||
|
||||
## Projektüberblick
|
||||
|
||||
Finanzberatungs-Tool (Verzeichnis `finance/`): FastAPI-App (API + Web-GUI),
|
||||
Postgres, Grafana-Dashboards. Importiert Kontoauszüge (PDF: VR/HVB/DKB;
|
||||
CSV: VR/HVB/DKB), verwaltet Buchungen/Kategorien/Fixposten, rechnet
|
||||
Kredit-Tilgungspläne und Liquiditäts-Szenarien durch. Läuft als Podman-Pod
|
||||
`finance_pod` (3 Container) unter systemd `--user`, siehe
|
||||
`docs/ARCHITEKTUR.md` Abschnitt 3 (Deployment). Aktuelle Version:
|
||||
`finance/VERSION` (Single Source — siehe dort).
|
||||
|
||||
Zugehöriges Repo `/home/wlfb/fb` ist die **Beratungsumgebung** (Skills,
|
||||
Gesprächsleitfäden für die Nutzung des Tools in der Finanzberatung selbst,
|
||||
eigenes `CLAUDE.md`) — nicht der Code dieses Tools. Dieses Repo (`~/bin`)
|
||||
enthält Quellcode, Deployment- und Betriebsskripte.
|
||||
|
||||
## Betriebskommandos
|
||||
|
||||
**Test-Suite** (Basis-Erwartung: alle grün, dreistellige Anzahl):
|
||||
```bash
|
||||
cd finance && .venv/bin/python -m pytest -q
|
||||
```
|
||||
|
||||
**Deploy / Redeploy** (baut das Image neu, erstellt Pod+Container neu,
|
||||
generiert systemd-Units, aktiviert den Service, wartet auf Bereitschaft):
|
||||
```bash
|
||||
./create_pod_finance.sh
|
||||
```
|
||||
Idempotent — erneutes Ausführen rotiert **keine** Secrets in einer
|
||||
bestehenden `.env` (Ausnahme: `FB_GUI_PASSWORD_HASH` wird bei jedem Lauf mit
|
||||
`FB_PASSWORD` synchron gehalten, siehe Passwort-Prozedur unten). Legacy-Pfad
|
||||
`~/bin/finance/.env` wird beim ersten Lauf automatisch nach
|
||||
`~/.local/share/finance_pod/.env` migriert.
|
||||
|
||||
**Backup** (kalt, kurze Downtime, Zielverzeichnis optional):
|
||||
```bash
|
||||
./backup_finance_pod.sh [ZIELDIR] # Default: ~/backups
|
||||
```
|
||||
Stoppt `pod-finance_pod.service`, sichert `~/.local/share/finance_pod`
|
||||
(inkl. `.env`) via `tar czf` **im `podman unshare`-Namespace** (Postgres-Daten
|
||||
gehören auf dem Host der subuid-verschobenen Container-UID und sind sonst
|
||||
unlesbar), startet den Service wieder, prüft Erreichbarkeit
|
||||
(`GET /login` → 200) und Archiv-Integrität (`.env`, `postgres-data`,
|
||||
`grafana-data`, `data` müssen enthalten sein), setzt `chmod 600` (Archiv
|
||||
enthält Secrets). Bei jedem Fehler läuft der Service danach trotzdem wieder
|
||||
(EXIT-Trap).
|
||||
|
||||
**Passwort ändern** (GUI + Grafana teilen sich `FB_PASSWORD`):
|
||||
- **Vorzugsweg (Admin-Seite):** `/admin` in der GUI aufrufen — ändert altes
|
||||
Passwort gegen Grafana (`services/admin.py::change_password`), schreibt
|
||||
danach die `.env` **in-place** neu. Bestehende Sitzungen bleiben gültig
|
||||
(Session-Secret ändert sich nicht).
|
||||
- **Handverfahren:** `FB_PASSWORD` in
|
||||
`~/.local/share/finance_pod/.env` von Hand editieren (Wert
|
||||
**single-quoted** lassen!) und `./create_pod_finance.sh` erneut ausführen
|
||||
— das Skript leitet `FB_GUI_PASSWORD_HASH` automatisch neu ab und
|
||||
synchronisiert Grafana per `grafana cli admin reset-admin-password`.
|
||||
|
||||
**Parser-Werkzeuge** (nur lokal, siehe DATENSCHUTZ unten):
|
||||
```bash
|
||||
cd finance
|
||||
PYTHONPATH=. .venv/bin/python scripts/parser_audit.py tests/fixtures/dkb_beispiel.pdf
|
||||
PYTHONPATH=. .venv/bin/python scripts/parser_vs_csv.py <pdf> <csv> [<csv>...]
|
||||
```
|
||||
`parser_audit.py` zeigt geparste Transaktionen + PDF-Rohtext einer Fixture.
|
||||
`parser_vs_csv.py` vergleicht ein PDF-Parser-Ergebnis gegen CSV-Ground-Truth
|
||||
(Betrag/Datum-Match, Gegenpartei/Verwendungszweck-Ähnlichkeit via
|
||||
`difflib`). Beide geben echte Kontodaten auf der Konsole aus.
|
||||
|
||||
## WICHTIGE REGELN für künftige Sessions
|
||||
|
||||
**`tests/fixtures/*` = ECHTE Kontodaten — NIE committen oder zitieren.**
|
||||
`finance/tests/fixtures/*.pdf`, `*.csv` und `expected_*.json` sind reale
|
||||
Kontoauszüge (via `finance/.gitignore` vom Repo ausgeschlossen). In Commits,
|
||||
PR-Beschreibungen, Reports, Ledger-Einträgen oder Chat-Antworten dürfen
|
||||
weder Dateiinhalte noch daraus abgeleitete Beträge/Namen/IBANs auftauchen.
|
||||
Ausgaben von `parser_audit.py`/`parser_vs_csv.py` sind ausschließlich zur
|
||||
lokalen Fehlersuche bestimmt, niemals zum Weiterreichen.
|
||||
|
||||
**Decimal, nicht float.** Geldbeträge sind überall `decimal.Decimal`
|
||||
(DB-Spaltentyp `Numeric(12,2)`, siehe `models/tables.py::MONEY`). Keine
|
||||
`float`-Arithmetik für Beträge einführen (Rundungsfehler).
|
||||
|
||||
**Deutsch.** GUI-Texte, Fehlermeldungen, Templates, Commit-Messages und
|
||||
Beratungsinhalte sind auf Deutsch. Datumsformat TT.MM.JJJJ.
|
||||
|
||||
**UX-Regel: Bedienelemente sichtbar, nicht versteckt.** Formulare/Buttons
|
||||
bleiben immer sichtbar und bedienbar; Sperrzustände werden über `disabled`
|
||||
ausgedrückt, nicht durch Entfernen des Elements aus dem DOM. Fehler werden
|
||||
inline auf derselben Seite gemeldet (Beispiel: `routers/admin.py` gibt bei
|
||||
Validierungsfehlern dasselbe Template mit `error`-Kontext zurück statt
|
||||
umzuleiten oder das Formular verschwinden zu lassen).
|
||||
|
||||
**Fable-Testagent-Gate (Nutzer-Vorgabe).** Jeder Task in einem
|
||||
Superpowers-Plan (`docs/superpowers/plans/*.md`) braucht vor dem Commit eine
|
||||
Abnahme durch den Fable-Testagenten (Faktencheck/Live-Test je nach Task).
|
||||
Kein Task gilt als abgeschlossen, solange dieses Gate nicht durchlaufen ist
|
||||
— auch nicht bei scheinbar trivialen Doku-/Config-Änderungen.
|
||||
|
||||
**Superpowers-Workflow.** Pläne liegen unter `docs/superpowers/plans/`
|
||||
(ein Plan pro Ausbaustufe, z.B. `2026-07-20-ausbaustufe-4.md`), gegliedert
|
||||
in Tasks mit Checkbox-Tracking. Fortschritt/Entscheidungen/offene Punkte
|
||||
werden fortlaufend im Ledger `.superpowers/sdd/progress.md` festgehalten
|
||||
(ein Eintrag pro abgeschlossenem Task, inkl. Commit-Range und Fable-Befund).
|
||||
Vor Arbeitsbeginn an einem Task: Plan-Datei UND Ledger lesen — der Ledger
|
||||
enthält oft bindende Nutzerentscheidungen und Warnungen aus früheren Tasks
|
||||
(z.B. Migrations-Hinweise), die nicht im Plan selbst stehen.
|
||||
|
||||
**Versionierung via `finance/VERSION`.** Einzige Versionsquelle (siehe
|
||||
`docs/ARCHITEKTUR.md` Abschnitt 4). Speist Image-Tag
|
||||
(`create_pod_finance.sh`), GUI-Footer und `GET /api/version`
|
||||
(`app/version.py::get_version`). Bei einem Release: `VERSION` hochzählen,
|
||||
bevor `create_pod_finance.sh` läuft — sonst baut das Skript ein Image mit
|
||||
altem Tag.
|
||||
|
||||
**`.env` unter `~/.local/share/finance_pod/`, nicht im Repo-Checkout.**
|
||||
Secrets liegen unter `$BIND_DIR/.env` (`chmod 600`), niemals unter
|
||||
`finance/.env` (Legacy-Pfad, wird migriert). Alle Werte darin sind
|
||||
**single-quoted** zu schreiben (`KEY='wert'`) — `create_pod_finance.sh`
|
||||
sourced die Datei per `set -a; . "$ENV_FILE"; set +a`, und unquoted Werte
|
||||
mit `$` (z.B. der GUI-Passwort-Hash `salt$digest`) würden dabei fälschlich
|
||||
shell-expandiert. Der API-Container mountet dieselbe Datei zusätzlich als
|
||||
**Einzeldatei-Bind-Mount** nach `/data/.env` — das folgt dem Host-Inode, die
|
||||
App muss sie **in-place** überschreiben (`open` im Modus `r+`, `flock`,
|
||||
`truncate` — siehe `services/admin.py::_rewrite_env_file`), niemals über
|
||||
Temp-Datei+`rename` (neuer Inode, vom laufenden Mount nicht mehr gesehen).
|
||||
Host-seitige `sed`-Edits in `create_pod_finance.sh` selbst sind unkritisch,
|
||||
weil das Skript die Container ohnehin bei jedem Lauf neu erstellt.
|
||||
|
||||
**Disaster Recovery = `BIND_DIR`-Backup + Repo + Skript.** Für vollständige
|
||||
Wiederherstellung werden **beide** gebraucht: ein Backup von
|
||||
`~/.local/share/finance_pod` (Secrets + Postgres-/Grafana-Daten, via
|
||||
`backup_finance_pod.sh`) UND der Repo-Checkout `~/bin` (Code für den
|
||||
Image-Build, `create_pod_finance.sh`, `finance/grafana/` für die
|
||||
Provisioning-Mounts). Wiederherstellung: Backup nach
|
||||
`~/.local/share/finance_pod` entpacken, dann `./create_pod_finance.sh`
|
||||
erneut ausführen (baut Image, erstellt Pod/Container/systemd-Units neu,
|
||||
übernimmt vorhandene `.env` unverändert). Verifiziert in A2-Task 7
|
||||
(Live-DR-Probe, siehe `.superpowers/sdd/progress.md`).
|
||||
|
||||
## Weiterführend
|
||||
|
||||
- Architektur (Module, Funktionen, drei Mermaid-Diagramme):
|
||||
`docs/ARCHITEKTUR.md`
|
||||
- Pläne: `docs/superpowers/plans/`
|
||||
- Fortschritts-Ledger: `.superpowers/sdd/progress.md`
|
||||
- Beratungsumgebung (Nutzung des Tools, Skills): `/home/wlfb/fb`
|
||||
(eigenes `CLAUDE.md` dort)
|
||||
135
backup_finance_pod.sh
Executable file
135
backup_finance_pod.sh
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Kalt-Backup-Skript für den finance_pod (Postgres-Daten, Grafana-Daten,
|
||||
# Uploads/Inbox und das Secrets-.env unter ~/.local/share/finance_pod).
|
||||
# Stoppt den systemd-User-Service für die Dauer der Sicherung (kurze
|
||||
# Downtime), erstellt ein tar.gz, startet den Service wieder, prüft die
|
||||
# Erreichbarkeit und die Archiv-Integrität, und schützt das Archiv per
|
||||
# chmod 600 (es enthält Secrets aus .env).
|
||||
#
|
||||
# Usage: ./backup_finance_pod.sh [ZIELDIR] (Default: $HOME/backups)
|
||||
|
||||
set -e
|
||||
|
||||
# Secrets landen im Archiv (.env) -> alle vom Skript neu erzeugten
|
||||
# Dateien/Verzeichnisse sollen von Anfang an nur für den Besitzer lesbar
|
||||
# sein, nicht erst nach einem nachträglichen chmod.
|
||||
umask 077
|
||||
|
||||
POD_NAME='finance_pod'
|
||||
SERVICE_NAME="pod-${POD_NAME}.service"
|
||||
SOURCE_PARENT="$HOME/.local/share"
|
||||
BIND_DIR="$SOURCE_PARENT/$POD_NAME"
|
||||
TARGET_DIR="${1:-$HOME/backups}"
|
||||
|
||||
HOST_LOCAL_IP='127.0.0.1'
|
||||
API_HOST_PORT='8096'
|
||||
CHECK_URL="http://$HOST_LOCAL_IP:$API_HOST_PORT/login"
|
||||
|
||||
if [ ! -d "$BIND_DIR" ]; then
|
||||
echo "ERROR: $BIND_DIR existiert nicht." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Zielverzeichnis anlegen, BEVOR der Service gestoppt wird: wenn das Ziel
|
||||
# unbeschreibbar ist, bricht das Skript hier ab und der Service lief nie
|
||||
# an, bleibt also ungestört aktiv. Nur beim NEU-Anlegen explizit auf 700
|
||||
# setzen (ein bereits vorhandenes Zielverzeichnis wird in seinen
|
||||
# bestehenden Rechten nicht angetastet).
|
||||
if [ ! -d "$TARGET_DIR" ]; then
|
||||
mkdir -p "$TARGET_DIR"
|
||||
chmod 700 "$TARGET_DIR"
|
||||
else
|
||||
mkdir -p "$TARGET_DIR"
|
||||
fi
|
||||
|
||||
ARCHIVE="$TARGET_DIR/${POD_NAME}_$(date +%F_%H%M%S).tar.gz"
|
||||
|
||||
# Merkt sich, ob DIESES Skript den Service gestoppt hat. Der EXIT-Trap
|
||||
# startet ihn bei jedem Fehler wieder - unabhängig davon, an welcher Stelle
|
||||
# das Skript abbricht (set -e sorgt dafür, dass jeder Fehlschlag hierher
|
||||
# springt). Zusätzlich entfernt der Trap ein evtl. bereits angelegtes,
|
||||
# aber unvollständiges/fehlerhaftes Archiv - es kann Secrets (.env)
|
||||
# enthalten und darf im Fehlerfall nicht liegen bleiben.
|
||||
SERVICE_STOPPED=0
|
||||
|
||||
restart_on_error() {
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
if [ -n "${ARCHIVE:-}" ] && [ -f "$ARCHIVE" ]; then
|
||||
echo "Entferne unvollständiges/fehlerhaftes Archiv $ARCHIVE (kann Secrets enthalten)..." >&2
|
||||
rm -f "$ARCHIVE"
|
||||
fi
|
||||
if [ "$SERVICE_STOPPED" -eq 1 ]; then
|
||||
echo "FEHLER (Exit-Code $rc) — starte $SERVICE_NAME sicherheitshalber wieder..." >&2
|
||||
systemctl --user start "$SERVICE_NAME" || true
|
||||
fi
|
||||
fi
|
||||
exit "$rc"
|
||||
}
|
||||
trap restart_on_error EXIT
|
||||
|
||||
echo "Stoppe $SERVICE_NAME für das Kalt-Backup..."
|
||||
systemctl --user stop "$SERVICE_NAME"
|
||||
SERVICE_STOPPED=1
|
||||
|
||||
# tar läuft im rootless-Podman-User-Namespace (`podman unshare`): das
|
||||
# Postgres-Datenverzeichnis gehört auf dem Host der subuid-verschobenen UID
|
||||
# (Container-UID 999 -> Host-UID via /etc/subuid), für `wlfb` direkt daher
|
||||
# unlesbar (drwx------). Im User-Namespace mappt UID 0 auf den aufrufenden
|
||||
# Host-User zurück und darf als Namespace-root alle verschobenen Dateien
|
||||
# lesen; die von ns-root neu erzeugte Archivdatei gehört auf dem Host
|
||||
# dadurch bereits `wlfb` - KEIN nachträgliches chown noetig (und keins
|
||||
# versuchen: `podman unshare chown "$(id -u):$(id -g)"` wertet die
|
||||
# id-Substitution auf dem HOST aus, aber im Namespace ist genau diese
|
||||
# host-UID/GID bereits subuid-verschoben, sodass der Aufruf die Datei auf
|
||||
# eine fremde subuid-UID umbiegt und das anschliessende `chmod 600` mit
|
||||
# EPERM scheitert). Zusätzlich vererbt `podman unshare` NICHT die umask
|
||||
# des aufrufenden Skripts (dort dokumentiert 0022 statt der oben gesetzten
|
||||
# 077) - daher `umask 077` explizit erneut innerhalb des unshare-Subshells,
|
||||
# damit das Archiv von Geburt an 600 ist statt bis zum finalen chmod als
|
||||
# 644 mit Secrets dazuliegen.
|
||||
echo "Erstelle Archiv $ARCHIVE (im Podman-User-Namespace, wegen UID-verschobener Postgres-Daten)..."
|
||||
podman unshare sh -c 'umask 077; tar czf "$1" -C "$2" "$3"' _ "$ARCHIVE" "$SOURCE_PARENT" "$POD_NAME"
|
||||
|
||||
echo "Starte $SERVICE_NAME wieder..."
|
||||
systemctl --user start "$SERVICE_NAME"
|
||||
SERVICE_STOPPED=0
|
||||
|
||||
echo "Warte auf Bereitschaft ($CHECK_URL)..."
|
||||
READY=0
|
||||
CODE=''
|
||||
for attempt in $(seq 1 30); do
|
||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL" || true)
|
||||
if [ "$CODE" = "200" ]; then
|
||||
echo "API ist wieder erreichbar (200)."
|
||||
READY=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$READY" -ne 1 ]; then
|
||||
echo "ERROR: API wurde nach dem Neustart nicht rechtzeitig bereit (letzter Status: $CODE)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Prüfe Archiv-Integrität (tar -tzf)..."
|
||||
LISTING=$(tar -tzf "$ARCHIVE")
|
||||
for required in ".env" "postgres-data" "grafana-data" "data"; do
|
||||
# Regex-Sonderzeichen (nur '.' kommt in dieser Liste vor, bei ".env")
|
||||
# escapen, damit z.B. "xenv" nicht faelschlich auf ".env" matcht.
|
||||
escaped="${required//./\\.}"
|
||||
if ! echo "$LISTING" | grep -Eq "^${POD_NAME}/${escaped}(/.*)?\$"; then
|
||||
echo "ERROR: Archiv enthält kein '$required'." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "Archiv-Integrität OK (.env, postgres-data, grafana-data, data vorhanden)."
|
||||
|
||||
# Archiv enthält Secrets (.env) -> nur der Besitzer darf lesen. (umask 077
|
||||
# hat das Archiv bereits als 600 angelegt; chmod hier ist eine zusätzliche
|
||||
# Absicherung, falls sich das je ändert.)
|
||||
chmod 600 "$ARCHIVE"
|
||||
|
||||
SIZE=$(du -h "$ARCHIVE" | cut -f1)
|
||||
echo "Backup fertig: $ARCHIVE ($SIZE, chmod 600)"
|
||||
343
create_pod_finance.sh
Executable file
343
create_pod_finance.sh
Executable file
@@ -0,0 +1,343 @@
|
||||
#!/bin/bash
|
||||
|
||||
# To be run by user wlfb to create pod for the Finanzberatungs-Tool
|
||||
# (API + Postgres + Grafana).
|
||||
|
||||
set -e
|
||||
|
||||
# Environment variables
|
||||
POD_NAME='finance_pod'
|
||||
DB_CTR_NAME='finance-db_ctr'
|
||||
API_CTR_NAME='finance-api_ctr'
|
||||
GRAFANA_CTR_NAME='finance-grafana_ctr'
|
||||
|
||||
# All image tags are pinned to fixed, immutable versions (no floating tags
|
||||
# such as :latest, :3 or :16). Verified against the upstream registries on
|
||||
# 2026-07-17 via `podman pull` (both tags exist as-is; no substitution
|
||||
# needed).
|
||||
POSTGRES_IMAGE='docker.io/library/postgres:17.10'
|
||||
GRAFANA_IMAGE='docker.io/grafana/grafana-oss:12.1.0'
|
||||
FINANCE_DIR="$HOME/bin/finance"
|
||||
API_IMAGE="localhost/finance-api:$(cat "$FINANCE_DIR/VERSION")"
|
||||
|
||||
HOST_LOCAL_IP='127.0.0.1'
|
||||
# Expose API on 8096 -> 8000
|
||||
API_HOST_PORT='8096'
|
||||
API_CONTAINER_PORT='8000'
|
||||
# Expose Grafana on 8097 -> 3000
|
||||
GRAFANA_HOST_PORT='8097'
|
||||
GRAFANA_CONTAINER_PORT='3000'
|
||||
# Postgres is only reachable inside the pod (localhost:5432 from API/Grafana)
|
||||
|
||||
BIND_DIR="$HOME/.local/share/$POD_NAME"
|
||||
POSTGRES_DATA_DIR="$BIND_DIR/postgres-data"
|
||||
GRAFANA_DATA_DIR="$BIND_DIR/grafana-data"
|
||||
# inbox and uploads must live under one shared bind mount: process_pdf()
|
||||
# moves a file from inbox to uploads via Path.replace() (os.replace/rename),
|
||||
# which fails with EXDEV ("Invalid cross-device link") across two separate
|
||||
# bind mounts even when both point at the same underlying host filesystem
|
||||
# (Linux permits a filesystem to be mounted at multiple points, but rename()
|
||||
# does not work across different mount points - see `man 2 rename`).
|
||||
DATA_DIR="$BIND_DIR/data"
|
||||
INBOX_DIR="$DATA_DIR/inbox"
|
||||
UPLOADS_DIR="$DATA_DIR/uploads"
|
||||
USER_SYSTEMD_DIR="$HOME/.config/systemd/user"
|
||||
|
||||
GRAFANA_PROVISIONING_DIR="$FINANCE_DIR/grafana/provisioning"
|
||||
GRAFANA_DASHBOARDS_DIR="$FINANCE_DIR/grafana/dashboards"
|
||||
|
||||
# --- Secrets bootstrap -------------------------------------------------------
|
||||
# Secrets live under BIND_DIR (not the code checkout in FINANCE_DIR). For
|
||||
# disaster recovery you need BOTH: a backup of BIND_DIR (data + secrets) AND
|
||||
# the repo checkout ~/bin (this script, finance/ for the image build, Grafana
|
||||
# provisioning). BIND_DIR must exist and be private before .env is written.
|
||||
mkdir -p "$BIND_DIR" && chmod 700 "$BIND_DIR"
|
||||
|
||||
ENV_FILE="$BIND_DIR/.env"
|
||||
LEGACY_ENV="$HOME/bin/finance/.env"
|
||||
if [ ! -f "$ENV_FILE" ] && [ -f "$LEGACY_ENV" ]; then
|
||||
mv "$LEGACY_ENV" "$ENV_FILE" && chmod 600 "$ENV_FILE"
|
||||
echo "MIGRIERT: .env nach $ENV_FILE verschoben"
|
||||
fi
|
||||
|
||||
# On first run, generate all secrets into $ENV_FILE (gitignored) and print
|
||||
# the shared GUI/Grafana login password once. On subsequent runs the
|
||||
# existing .env is reused, so re-running this script never rotates secrets
|
||||
# under existing data - except FB_GUI_PASSWORD_HASH, which is kept in sync
|
||||
# with FB_PASSWORD on every run further below. That is what makes the
|
||||
# password-change procedure a one-liner: edit FB_PASSWORD in $ENV_FILE, run
|
||||
# this script again - the GUI hash (below) and Grafana's admin password
|
||||
# (Grafana-sync retry loop, further down near the Grafana container) both
|
||||
# follow automatically, with no separate hash-regeneration step.
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
# Compute all values into variables first, then write them SINGLE-QUOTED.
|
||||
# The GUI password hash has the form salt$digest; if written unquoted, the
|
||||
# later `set -a; . "$ENV_FILE"` would shell-expand the "$digest" part and
|
||||
# corrupt the hash, breaking GUI login. Single quotes make every value a
|
||||
# literal string on sourcing.
|
||||
FB_PASSWORD_VAL=$(openssl rand -base64 12)
|
||||
POSTGRES_PASSWORD_VAL=$(openssl rand -hex 16)
|
||||
FINANCE_READ_PASSWORD_VAL=$(openssl rand -hex 16)
|
||||
FB_API_KEY_VAL=$(openssl rand -hex 32)
|
||||
FB_SESSION_SECRET_VAL=$(openssl rand -hex 32)
|
||||
FB_GUI_PASSWORD_HASH_VAL=$(python3 -c "import hashlib,secrets,sys;s=secrets.token_hex(16);pw=sys.argv[1];print(s+'\$'+hashlib.pbkdf2_hmac('sha256',pw.encode(),s.encode(),200000).hex())" "$FB_PASSWORD_VAL")
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
POSTGRES_PASSWORD='$POSTGRES_PASSWORD_VAL'
|
||||
FINANCE_READ_PASSWORD='$FINANCE_READ_PASSWORD_VAL'
|
||||
FB_API_KEY='$FB_API_KEY_VAL'
|
||||
FB_SESSION_SECRET='$FB_SESSION_SECRET_VAL'
|
||||
FB_GUI_USER='admin'
|
||||
FB_GUI_PASSWORD_HASH='$FB_GUI_PASSWORD_HASH_VAL'
|
||||
FB_PASSWORD='$FB_PASSWORD_VAL'
|
||||
EOF
|
||||
chmod 600 "$ENV_FILE"
|
||||
echo "NEU ERZEUGT: GUI-/Grafana-Login admin / $FB_PASSWORD_VAL (jetzt notieren!)"
|
||||
elif grep -q '^FB_GUI_PASSWORD_HASH=' "$ENV_FILE" && ! grep -q '^FB_PASSWORD=' "$ENV_FILE"; then
|
||||
# Migrate a pre-existing .env that predates the shared GUI/Grafana
|
||||
# password (independent GUI hash + GRAFANA_ADMIN_PASSWORD) to one shared
|
||||
# FB_PASSWORD. The old GUI password was only ever stored as a salted
|
||||
# hash, so its plaintext cannot be recovered - a new password is
|
||||
# unavoidable here. The stale hash and any GRAFANA_ADMIN_PASSWORD line
|
||||
# (superseded by FB_PASSWORD) are dropped; all other keys (API key,
|
||||
# session secret, DB passwords, ...) are left untouched.
|
||||
FB_PASSWORD_VAL=$(openssl rand -base64 12)
|
||||
FB_GUI_PASSWORD_HASH_VAL=$(python3 -c "import hashlib,secrets,sys;s=secrets.token_hex(16);pw=sys.argv[1];print(s+'\$'+hashlib.pbkdf2_hmac('sha256',pw.encode(),s.encode(),200000).hex())" "$FB_PASSWORD_VAL")
|
||||
sed -i "/^FB_GUI_PASSWORD_HASH=/d; /^GRAFANA_ADMIN_PASSWORD=/d" "$ENV_FILE"
|
||||
{
|
||||
echo "FB_GUI_PASSWORD_HASH='$FB_GUI_PASSWORD_HASH_VAL'"
|
||||
echo "FB_PASSWORD='$FB_PASSWORD_VAL'"
|
||||
} >> "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
echo "MIGRATION: gemeinsames Passwort fuer GUI+Grafana eingefuehrt (altes GUI-Passwort war nur als Hash gespeichert und nicht rekonstruierbar)."
|
||||
echo "MIGRATION: GUI-/Grafana-Login admin / $FB_PASSWORD_VAL (jetzt notieren!)"
|
||||
fi
|
||||
set -a; . "$ENV_FILE"; set +a
|
||||
|
||||
# Keep FB_GUI_PASSWORD_HASH in sync with FB_PASSWORD on every run (creation
|
||||
# and migration above already produce a matching pair, so this is a no-op
|
||||
# for them; it only rewrites $ENV_FILE when a user has edited FB_PASSWORD by
|
||||
# hand since the last run - see the password-change procedure comment
|
||||
# above).
|
||||
if ! python3 -c "
|
||||
import hashlib, sys
|
||||
pw, stored = sys.argv[1], sys.argv[2]
|
||||
salt, digest = stored.split('\$', 1)
|
||||
sys.exit(0 if hashlib.pbkdf2_hmac('sha256', pw.encode(), salt.encode(), 200000).hex() == digest else 1)
|
||||
" "$FB_PASSWORD" "$FB_GUI_PASSWORD_HASH" 2>/dev/null; then
|
||||
FB_GUI_PASSWORD_HASH=$(python3 -c "import hashlib,secrets,sys;s=secrets.token_hex(16);pw=sys.argv[1];print(s+'\$'+hashlib.pbkdf2_hmac('sha256',pw.encode(),s.encode(),200000).hex())" "$FB_PASSWORD")
|
||||
sed -i "s|^FB_GUI_PASSWORD_HASH=.*|FB_GUI_PASSWORD_HASH='$FB_GUI_PASSWORD_HASH'|" "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
echo "FB_PASSWORD-Aenderung erkannt: FB_GUI_PASSWORD_HASH in $ENV_FILE neu abgeleitet."
|
||||
fi
|
||||
|
||||
# Sanity check: the GUI password hash must survive shell sourcing intact. An
|
||||
# unquoted value containing a literal '$' gets mangled by parameter expansion,
|
||||
# which would silently break GUI login. Require the canonical salt$digest form
|
||||
# (32 hex chars, '$', 64 hex chars).
|
||||
if ! printf '%s' "$FB_GUI_PASSWORD_HASH" | grep -Eq '^[0-9a-f]{32}\$[0-9a-f]{64}$'; then
|
||||
echo "ERROR: FB_GUI_PASSWORD_HASH is malformed after sourcing $ENV_FILE." >&2
|
||||
echo " All values in $ENV_FILE must be single-quoted." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop existing systemd-managed pod if present, to avoid conflicts on rerun
|
||||
echo "Stopping systemd-managed pod 'pod-$POD_NAME.service' if it exists..."
|
||||
if systemctl --user list-units --type=service --all 2>/dev/null | \
|
||||
grep -q "pod-$POD_NAME.service"; then
|
||||
systemctl --user stop "pod-$POD_NAME.service" || true
|
||||
fi
|
||||
|
||||
# Prepare directories
|
||||
mkdir -p "$POSTGRES_DATA_DIR" "$GRAFANA_DATA_DIR" "$INBOX_DIR" "$UPLOADS_DIR" \
|
||||
"$USER_SYSTEMD_DIR"
|
||||
|
||||
# The Grafana image runs as a non-root user (uid 472) that belongs to group 0
|
||||
# (root). Its data dir is bind-mounted from the host, where it is owned by the
|
||||
# (rootless) container root. Make the dir group-writable with the setgid bit
|
||||
# so the non-root Grafana process can write its sqlite db/plugins (same
|
||||
# "arbitrary uid + gid 0" pattern as example_create_pod_langflow.sh's
|
||||
# LANGFLOW_DATA_DIR fix).
|
||||
chmod g+rwxs "$GRAFANA_DATA_DIR"
|
||||
|
||||
# Build the API image (Task 15 Containerfile)
|
||||
podman build -t "$API_IMAGE" "$FINANCE_DIR"
|
||||
|
||||
# Create pod if not yet existing
|
||||
if ! podman pod exists "$POD_NAME"; then
|
||||
podman pod create -n "$POD_NAME" \
|
||||
-p "$HOST_LOCAL_IP:$API_HOST_PORT:$API_CONTAINER_PORT" \
|
||||
-p "$HOST_LOCAL_IP:$GRAFANA_HOST_PORT:$GRAFANA_CONTAINER_PORT"
|
||||
echo "Pod '$POD_NAME' created (rc=$?)"
|
||||
else
|
||||
echo "Pod '$POD_NAME' already exists."
|
||||
fi
|
||||
|
||||
# Remove any old containers (ignore errors if they don't exist)
|
||||
podman rm -f "$DB_CTR_NAME" || true
|
||||
podman rm -f "$API_CTR_NAME" || true
|
||||
podman rm -f "$GRAFANA_CTR_NAME" || true
|
||||
|
||||
# Postgres container
|
||||
podman run -d --name "$DB_CTR_NAME" --pod "$POD_NAME" \
|
||||
-e POSTGRES_USER=finance \
|
||||
-e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \
|
||||
-e POSTGRES_DB=finance \
|
||||
-v "$POSTGRES_DATA_DIR:/var/lib/postgresql/data:Z" \
|
||||
"$POSTGRES_IMAGE"
|
||||
echo "Container '$DB_CTR_NAME' started (rc=$?)"
|
||||
|
||||
# Wait for Postgres to be ready before starting API / Grafana
|
||||
echo "Waiting for Postgres to be ready (pg_isready)..."
|
||||
for attempt in $(seq 1 30); do
|
||||
if podman exec "$DB_CTR_NAME" pg_isready -q >/dev/null 2>&1; then
|
||||
echo "Postgres is ready."
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
if [ "$attempt" -eq 30 ]; then
|
||||
echo "ERROR: Postgres did not become ready in time." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Create the read-only role used by the Grafana datasource, idempotently.
|
||||
echo "Ensuring read-only role 'finance_read' exists..."
|
||||
podman exec "$DB_CTR_NAME" psql -U finance -d finance -c \
|
||||
"DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='finance_read')
|
||||
THEN CREATE ROLE finance_read LOGIN PASSWORD '$FINANCE_READ_PASSWORD'; END IF; END \$\$;
|
||||
GRANT CONNECT ON DATABASE finance TO finance_read;
|
||||
GRANT USAGE ON SCHEMA public TO finance_read;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA public TO finance_read;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO finance_read;"
|
||||
echo "Role 'finance_read' is ready."
|
||||
|
||||
# API container (runs alembic upgrade head on start via entrypoint.sh)
|
||||
# The extra "-v $ENV_FILE:/data/.env:Z" below (Ausbaustufe 4 Task 2, Admin
|
||||
# password change) is a SINGLE-FILE bind mount. Unlike a directory mount,
|
||||
# this follows the host file's INODE: the app must read/write it in place
|
||||
# (open r+/truncate under flock - see app/services/admin.py), NEVER via
|
||||
# temp-file+rename, because a rename would swap in a new inode that the
|
||||
# already-running mount no longer points at.
|
||||
podman run -d --name "$API_CTR_NAME" --pod "$POD_NAME" \
|
||||
-e FB_DATABASE_URL="postgresql+psycopg://finance:$POSTGRES_PASSWORD@localhost:5432/finance" \
|
||||
-e FB_API_KEY \
|
||||
-e FB_SESSION_SECRET \
|
||||
-e FB_GUI_USER \
|
||||
-e FB_GUI_PASSWORD_HASH \
|
||||
-e FB_INBOX_DIR=/data/inbox \
|
||||
-e FB_UPLOADS_DIR=/data/uploads \
|
||||
-v "$DATA_DIR:/data:Z" \
|
||||
-v "$ENV_FILE:/data/.env:Z" \
|
||||
"$API_IMAGE"
|
||||
echo "Container '$API_CTR_NAME' started (rc=$?)"
|
||||
|
||||
# Grafana container (provisioning + dashboards mounted read-only; datasource
|
||||
# yaml interpolates FINANCE_READ_PASSWORD). GF_SECURITY_ALLOW_EMBEDDING
|
||||
# permits the GUI's iframe to embed dashboards; anonymous access stays OFF
|
||||
# (no GF_AUTH_ANONYMOUS_* vars are set) - only the iframe X-Frame-Options
|
||||
# restriction is relaxed, authentication is unaffected.
|
||||
podman run -d --name "$GRAFANA_CTR_NAME" --pod "$POD_NAME" \
|
||||
-e GF_SECURITY_ADMIN_PASSWORD="$FB_PASSWORD" \
|
||||
-e GF_SECURITY_ALLOW_EMBEDDING=true \
|
||||
-e GF_SECURITY_COOKIE_SAMESITE=lax \
|
||||
-e GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/finanzen.json \
|
||||
-e FINANCE_READ_PASSWORD \
|
||||
-v "$GRAFANA_PROVISIONING_DIR:/etc/grafana/provisioning:Z,ro" \
|
||||
-v "$GRAFANA_DASHBOARDS_DIR:/var/lib/grafana/dashboards:Z,ro" \
|
||||
-v "$GRAFANA_DATA_DIR:/var/lib/grafana:Z" \
|
||||
"$GRAFANA_IMAGE"
|
||||
echo "Container '$GRAFANA_CTR_NAME' started (rc=$?)"
|
||||
|
||||
# GF_SECURITY_ADMIN_PASSWORD above only takes effect on a FRESH Grafana data
|
||||
# dir (initial admin bootstrap); an existing install keeps its previously
|
||||
# set password. So sync Grafana's admin password to FB_PASSWORD explicitly
|
||||
# on every script run via the CLI (idempotent: resetting to the same
|
||||
# password is harmless). grafana-oss:12.1.0 ships the unified `grafana`
|
||||
# binary, which exposes this as `grafana cli admin reset-admin-password`
|
||||
# (verified in-image: `grafana cli admin reset-admin-password --help`
|
||||
# succeeds); the legacy standalone `grafana-cli` binary is kept as a
|
||||
# fallback in case of a differently-built image. Retry like the pg_isready
|
||||
# wait above, since Grafana needs a moment after container start to
|
||||
# initialize its SQLite db before the CLI can operate on it. This runs
|
||||
# against the just-started container (before the stop/recreate-via-systemd
|
||||
# further below); the result persists because GRAFANA_DATA_DIR is a
|
||||
# bind-mounted directory shared with the systemd-managed restart.
|
||||
echo "Syncing Grafana admin password with FB_PASSWORD..."
|
||||
for attempt in $(seq 1 30); do
|
||||
if podman exec "$GRAFANA_CTR_NAME" grafana cli admin reset-admin-password "$FB_PASSWORD" \
|
||||
>/dev/null 2>&1; then
|
||||
echo "Grafana admin password synced (grafana cli)."
|
||||
break
|
||||
fi
|
||||
if podman exec "$GRAFANA_CTR_NAME" grafana-cli admin reset-admin-password "$FB_PASSWORD" \
|
||||
>/dev/null 2>&1; then
|
||||
echo "Grafana admin password synced (grafana-cli fallback)."
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
if [ "$attempt" -eq 30 ]; then
|
||||
echo "ERROR: could not sync Grafana admin password in time." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Generate systemd service files
|
||||
cd "$USER_SYSTEMD_DIR"
|
||||
podman generate systemd --name --new --files "$POD_NAME"
|
||||
echo "Generated systemd service files (rc=$?)"
|
||||
|
||||
# The --new units embed the full `podman run` commands, including container
|
||||
# environment secrets (POSTGRES_PASSWORD, GF_SECURITY_ADMIN_PASSWORD,
|
||||
# FB_GUI_PASSWORD_HASH, ...). Post-process each generated unit file:
|
||||
# 1. chmod 600 so only the owner can read the embedded secrets.
|
||||
# 2. Collapse '$$' -> '$' on Environment= lines. `podman generate systemd`
|
||||
# escapes every '$' as '$$' (correct for ExecStart command lines, where
|
||||
# systemd performs variable expansion). But systemd does NOT un-escape
|
||||
# '$$' inside Environment= *values* - it passes them through literally.
|
||||
# The GUI password hash has the form salt$digest, so without this fix the
|
||||
# container receives salt$$digest and GUI login fails. Restrict the
|
||||
# substitution to Environment= lines so ExecStart escaping stays intact.
|
||||
for svc in "pod-${POD_NAME}.service" "container-${DB_CTR_NAME}.service" \
|
||||
"container-${API_CTR_NAME}.service" "container-${GRAFANA_CTR_NAME}.service"; do
|
||||
[ -f "$svc" ] || continue
|
||||
chmod 600 "$svc"
|
||||
sed -i '/^Environment=/ s/\$\$/$/g' "$svc"
|
||||
done
|
||||
|
||||
# Stop & remove live pod and containers
|
||||
podman pod stop --ignore --time 15 "$POD_NAME"
|
||||
podman pod rm -f --ignore "$POD_NAME"
|
||||
if podman pod exists "$POD_NAME"; then
|
||||
echo "ERROR: Pod $POD_NAME still exists." >&2
|
||||
exit 1
|
||||
else
|
||||
echo "Stopped & removed live pod $POD_NAME and containers."
|
||||
fi
|
||||
|
||||
# Enable systemd user services
|
||||
systemctl --user daemon-reload
|
||||
# pod service (creates pod + containers)
|
||||
systemctl --user enable --now "pod-${POD_NAME}.service"
|
||||
systemctl --user is-enabled "pod-${POD_NAME}.service"
|
||||
systemctl --user is-active "pod-${POD_NAME}.service"
|
||||
echo "Enabled systemd service pod-${POD_NAME}.service (rc=$?)"
|
||||
echo "To view status: systemctl --user status pod-${POD_NAME}.service"
|
||||
echo "To view logs: journalctl --user -u pod-${POD_NAME}.service -f"
|
||||
|
||||
# Wait for API and Grafana readiness
|
||||
CHECK_URL_API="http://$HOST_LOCAL_IP:$API_HOST_PORT/login"
|
||||
CHECK_URL_GRAFANA="http://$HOST_LOCAL_IP:$GRAFANA_HOST_PORT/api/health"
|
||||
for attempt in $(seq 1 30); do
|
||||
API_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL_API" || true)
|
||||
GRAFANA_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL_GRAFANA" || true)
|
||||
if [ "$API_CODE" = "200" ] && [ "$GRAFANA_CODE" = "200" ]; then
|
||||
echo "API is reachable at $CHECK_URL_API (200)."
|
||||
echo "Grafana is reachable at $CHECK_URL_GRAFANA (200)."
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
if [ "$attempt" -eq 30 ]; then
|
||||
echo "timeout error (API=$API_CODE, Grafana=$GRAFANA_CODE)." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
314
docs/ARCHITEKTUR.md
Normal file
314
docs/ARCHITEKTUR.md
Normal file
@@ -0,0 +1,314 @@
|
||||
# Architektur — Finanzberatungs-Tool (`finance/`)
|
||||
|
||||
> Diese Doku richtet sich **primär an künftige Claude-Coding-Sessions** in
|
||||
> diesem Repo, sekundär an Menschen. Sie beschreibt den Stand nach
|
||||
> Ausbaustufe 4 (v0.5.0). Jeder hier genannte Modul-/Funktionsname existiert
|
||||
> real im Code unter `finance/app/` — bei Unsicherheit den Pfad öffnen statt
|
||||
> zu raten. Betriebs-/Konventionsregeln stehen in `/home/wlfb/bin/CLAUDE.md`.
|
||||
|
||||
## 1. Komponenten-Diagramm
|
||||
|
||||
Kernachse: `routers/* → services/* → engine/* + parsers/* → models/tables.py
|
||||
→ db.py`. `routers/gui.py` ist ein Sonderfall — es importiert
|
||||
Handler-Funktionen direkt aus anderen Routern (`imports.py`, `planning.py`,
|
||||
`scenarios.py`, `transactions.py`), um serverseitige Logik nicht zu
|
||||
duplizieren. `routers/admin.py` importiert `gui_session` und `templates`
|
||||
direkt aus `routers/gui.py`.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph TPL["templates (Jinja2, app/templates/)"]
|
||||
T_pages["base.html, login.html, index.html<br/>transactions.html, salden.html<br/>import.html, planning.html<br/>admin.html, hilfe.html"]
|
||||
end
|
||||
|
||||
subgraph ROUTERS["routers/"]
|
||||
R_gui["gui.py<br/>gui_session, index, buchungen,<br/>salden_page, import_page, planung_page"]
|
||||
R_accounts["accounts.py<br/>list_accounts, create_account,<br/>patch_account"]
|
||||
R_transactions["transactions.py<br/>list_transactions, create_transaction,<br/>patch_transaction, count_transactions"]
|
||||
R_categories["categories.py<br/>list_categories, create_category_rule"]
|
||||
R_imports["imports.py<br/>upload, scan_inbox, preview,<br/>confirm, rollback"]
|
||||
R_planning["planning.py<br/>list_recurring, list_planned,<br/>list_loans, recurring_suggestions"]
|
||||
R_scenarios["scenarios.py<br/>create_scenario, add_modifier,<br/>project_scenario"]
|
||||
R_admin["admin.py<br/>admin_page, admin_change_password,<br/>apply_category_rules"]
|
||||
end
|
||||
|
||||
subgraph SERVICES["services/"]
|
||||
S_importer["importer.py<br/>process_file, process_pdf, process_csv"]
|
||||
S_balances["balances.py<br/>account_balance(at), total_balance"]
|
||||
S_categorize["categorize.py<br/>apply_rules"]
|
||||
S_suggestions["suggestions.py<br/>suggest_recurring"]
|
||||
S_projection["projection_service.py<br/>run_projection"]
|
||||
S_admin["admin.py<br/>change_password, apply_rules_retroactively"]
|
||||
end
|
||||
|
||||
subgraph ENGINE["engine/ (rein, kein DB-Zugriff)"]
|
||||
E_loans["loans.py<br/>loan_schedule, annuity_payment, add_months"]
|
||||
E_recurrence["recurrence.py<br/>occurrences"]
|
||||
E_projection["projection.py<br/>project"]
|
||||
E_scenario["scenario.py<br/>build_cashflows (PlainRecurring/<br/>PlainPlanned/PlainModifier)"]
|
||||
end
|
||||
|
||||
subgraph PARSERS["parsers/"]
|
||||
P_base["base.py<br/>parse_german_amount, parse_german_date"]
|
||||
P_detect["detect.py<br/>detect_bank"]
|
||||
P_validate["validate.py<br/>balance_difference, dedup_hash"]
|
||||
P_vr["vr.py — parse"]
|
||||
P_hvb["hvb.py — parse"]
|
||||
P_dkb["dkb.py — parse"]
|
||||
P_csv["csv_formats.py<br/>detect_csv_format, parse_csv"]
|
||||
P_registry["registry.py — parse_pdf"]
|
||||
end
|
||||
|
||||
subgraph MODELS["models/ + alembic/"]
|
||||
M_tables["tables.py<br/>Account, Statement, Transaction,<br/>RecurringItem, PlannedItem, Loan,<br/>Scenario, ProjectionResult, ..."]
|
||||
M_views["views.py — create_views()<br/>v_balance_history, v_balance_total,<br/>v_monthly_by_category, v_projection"]
|
||||
M_alembic["alembic/versions<br/>b2b1f5a18a74_initial_schema<br/>1ef6a356f028_konto_anker"]
|
||||
end
|
||||
|
||||
R_gui --> T_pages
|
||||
R_admin --> T_pages
|
||||
|
||||
R_gui --> R_imports
|
||||
R_gui --> R_planning
|
||||
R_gui --> R_scenarios
|
||||
R_gui --> R_transactions
|
||||
R_gui --> E_loans
|
||||
R_gui --> E_recurrence
|
||||
R_gui --> S_balances
|
||||
R_admin --> R_gui
|
||||
|
||||
R_accounts --> S_balances
|
||||
R_transactions --> P_validate
|
||||
R_transactions --> S_categorize
|
||||
R_imports --> S_importer
|
||||
R_planning --> E_loans
|
||||
R_planning --> S_suggestions
|
||||
R_scenarios --> S_projection
|
||||
R_admin --> S_admin
|
||||
|
||||
ROUTERS -.-> M_tables
|
||||
|
||||
S_importer --> P_csv
|
||||
S_importer --> P_registry
|
||||
S_importer --> P_validate
|
||||
S_importer --> S_categorize
|
||||
S_projection --> E_loans
|
||||
S_projection --> E_projection
|
||||
S_projection --> E_scenario
|
||||
S_projection --> S_balances
|
||||
S_admin --> S_categorize
|
||||
|
||||
E_scenario --> E_loans
|
||||
E_scenario --> E_recurrence
|
||||
|
||||
P_registry --> P_vr
|
||||
P_registry --> P_hvb
|
||||
P_registry --> P_dkb
|
||||
P_registry --> P_detect
|
||||
P_vr --> P_base
|
||||
P_hvb --> P_base
|
||||
P_dkb --> P_base
|
||||
P_csv --> P_base
|
||||
P_validate --> P_base
|
||||
|
||||
SERVICES -.-> M_tables
|
||||
M_alembic -.->|erzeugt Schema fuer| M_tables
|
||||
M_tables -.->|Basis fuer| M_views
|
||||
```
|
||||
|
||||
Nicht im Diagramm (Querschnitt, siehe Prosa unten): `app/auth.py`
|
||||
(`require_auth`, `current_password_hash`, `session_valid` — an fast allen
|
||||
Routern als FastAPI-Dependency), `app/config.py` (`get_settings`),
|
||||
`app/db.py` (`get_session`, `get_engine`), `app/main.py` (bindet alle Router
|
||||
+ `lifespan` → `create_views`), `app/version.py` (`get_version`).
|
||||
|
||||
## 2. Sequenzdiagramm — Import-Fluss
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser/GUI
|
||||
participant RI as routers/imports.py
|
||||
participant IM as services/importer.py
|
||||
participant PA as parsers (registry.py / csv_formats.py)
|
||||
participant VA as parsers/validate.py
|
||||
participant CA as services/categorize.py
|
||||
participant DB as Postgres
|
||||
|
||||
Note over B,RI: Upload ODER Inbox-Scan
|
||||
alt Datei-Upload
|
||||
B->>RI: POST /api/imports/upload (PDF/CSV)
|
||||
else Inbox-Scan
|
||||
B->>RI: POST /api/imports/scan-inbox
|
||||
Note right of RI: iteriert *.pdf und *.csv in FB_INBOX_DIR
|
||||
end
|
||||
|
||||
RI->>IM: process_file(session, path)
|
||||
alt Endung .csv
|
||||
IM->>PA: parse_csv(path) [csv_formats.detect_csv_format]
|
||||
else Endung .pdf (Default)
|
||||
IM->>PA: parse_pdf(path) [registry.detect_bank -> vr/hvb/dkb.parse]
|
||||
end
|
||||
PA-->>IM: ParsedStatement / ParsedCsv
|
||||
|
||||
IM->>IM: _find_or_create_account(bank, iban)
|
||||
IM->>DB: INSERT Statement(status="draft")
|
||||
|
||||
opt Saldo pruefbar (PDF immer; CSV nur VR: balance_checkable=True)
|
||||
IM->>VA: balance_difference(parsed)
|
||||
Note right of IM: Differenz != 0 -> Statement.status="error", Abbruch
|
||||
end
|
||||
|
||||
loop je Buchungszeile
|
||||
IM->>VA: dedup_hash(account_id, booking_date, amount, purpose)
|
||||
IM->>DB: INSERT Transaction(status="draft", is_duplicate=?)
|
||||
end
|
||||
IM->>CA: apply_rules(session, drafts)
|
||||
IM->>IM: _apply_anchor_autofill(account, anchor)
|
||||
Note right of IM: nur wenn CSV-Anker-Datum >= Account.anchor_date (oder kein Anker)
|
||||
IM->>DB: Datei inbox -> uploads verschieben, COMMIT
|
||||
IM-->>RI: Statement (draft)
|
||||
RI-->>B: 201 StatementOut
|
||||
|
||||
B->>RI: GET /api/imports/{id}/preview
|
||||
RI->>DB: SELECT Transactions WHERE statement_id=id
|
||||
RI->>RI: balance_ok = true/false/None (dreiwertig:<br/>None bei fehlenden Salden, z.B. HVB/DKB-CSV)
|
||||
RI-->>B: PreviewOut(balance_ok, duplicates)
|
||||
|
||||
alt Bestaetigen
|
||||
B->>RI: POST /api/imports/{id}/confirm
|
||||
loop je Draft-Transaktion (nicht is_duplicate)
|
||||
RI->>DB: Dedup-Recheck: dedup_hash bereits confirmed<br/>in ANDEREM Statement?
|
||||
alt Kollision gefunden
|
||||
RI->>DB: tx.is_duplicate = true (bleibt draft)
|
||||
else keine Kollision
|
||||
RI->>DB: tx.status = "confirmed"
|
||||
end
|
||||
end
|
||||
RI->>DB: Statement.status = "confirmed"
|
||||
RI-->>B: 200 StatementOut
|
||||
else Verwerfen (Rollback)
|
||||
B->>RI: POST /api/imports/{id}/rollback
|
||||
Note right of RI: nur wenn Statement.status == "confirmed"
|
||||
RI->>DB: DELETE Transactions + Statement
|
||||
RI-->>B: 200 {deleted_transactions, statement_id}
|
||||
end
|
||||
```
|
||||
|
||||
## 3. Deployment-Diagramm
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph HOST["Host wlfb (rootless Podman)"]
|
||||
Boot["create_pod_finance.sh<br/>Bootstrap: FB_PASSWORD erzeugen<br/>-> PBKDF2-Hash (FB_GUI_PASSWORD_HASH)<br/>-> Grafana-Sync (grafana cli<br/>admin reset-admin-password)"]
|
||||
Backup["backup_finance_pod.sh [ZIELDIR]<br/>KALT: systemctl stop -> tar czf<br/>via 'podman unshare' (subuid-Daten)<br/>-> systemctl start -> Readiness-Curl<br/>-> tar -tzf Integritaet -> chmod 600"]
|
||||
|
||||
subgraph SYSTEMD["systemd --user (Unit-Dateien in ~/.config/systemd/user, je chmod 600)"]
|
||||
U1["pod-finance_pod.service"]
|
||||
U2["container-finance-db_ctr.service"]
|
||||
U3["container-finance-api_ctr.service"]
|
||||
U4["container-finance-grafana_ctr.service"]
|
||||
end
|
||||
|
||||
subgraph POD["Pod finance_pod (podman pod)"]
|
||||
API["finance-api_ctr<br/>127.0.0.1:8096 -> 8000<br/>uvicorn app.main:app<br/>(entrypoint.sh: alembic upgrade head)"]
|
||||
DBC["finance-db_ctr<br/>Postgres 17.10<br/>NUR pod-intern: localhost:5432<br/>(kein Host-Port)"]
|
||||
GRA["finance-grafana_ctr<br/>127.0.0.1:8097 -> 3000<br/>grafana-oss 12.1.0"]
|
||||
end
|
||||
|
||||
subgraph BINDDIR["Bind-Mounts ~/.local/share/finance_pod (BIND_DIR, chmod 700)"]
|
||||
DATA["data/ (-v DATA_DIR:/data:Z)<br/>inbox/, uploads/"]
|
||||
ENVF[".env (-v ENV_FILE:/data/.env:Z)<br/>EINZELDATEI-Mount folgt dem INODE!<br/>App schreibt IN-PLACE (open r+,<br/>flock, truncate) - NIE Temp+rename<br/>(services/admin.py _rewrite_env_file)"]
|
||||
PGD["postgres-data/"]
|
||||
GRD["grafana-data/ (setgid, gid 0)"]
|
||||
end
|
||||
|
||||
subgraph REPO["Repo-Checkout ~/bin/finance/grafana (read-only Mounts)"]
|
||||
PROV["grafana/provisioning<br/>-> /etc/grafana/provisioning:Z,ro"]
|
||||
DASH["grafana/dashboards/finanzen.json<br/>-> /var/lib/grafana/dashboards:Z,ro"]
|
||||
end
|
||||
end
|
||||
|
||||
Boot -->|erzeugt/aktualisiert| ENVF
|
||||
Boot -->|podman generate systemd --new| SYSTEMD
|
||||
Boot -->|podman run| API
|
||||
Boot -->|podman run| DBC
|
||||
Boot -->|podman run| GRA
|
||||
Boot -.->|exec grafana cli admin reset-admin-password| GRA
|
||||
|
||||
SYSTEMD -->|verwaltet Start/Stop| POD
|
||||
|
||||
API -->|liest/schreibt in-place| ENVF
|
||||
API -->|Daten| DATA
|
||||
API -.->|SQL localhost:5432| DBC
|
||||
DBC --> PGD
|
||||
GRA --> GRD
|
||||
GRA --> PROV
|
||||
GRA --> DASH
|
||||
GRA -.->|SQL read-only Rolle finance_read| DBC
|
||||
API -.->|HTTP PUT .../password, siehe change_password| GRA
|
||||
|
||||
Backup -->|stoppt/startet| U1
|
||||
Backup -->|tar czf BIND_DIR| BINDDIR
|
||||
```
|
||||
|
||||
## 4. Zentrale Konzepte (kompakt)
|
||||
|
||||
**Saldo-Anker (per Tagesende).** `Account.anchor_date` /
|
||||
`Account.anchor_balance` (Migration `1ef6a356f028_konto_anker`) ersetzen die
|
||||
frühere Statement-closing-Logik als Basis der Saldenrechnung.
|
||||
`services/balances.py::account_balance(session, account, at)` addiert bzw.
|
||||
subtrahiert bestätigte Buchungen zwischen Anker und `at`; der Anker gilt
|
||||
**per Tagesende** des Ankerdatums (enthält bereits alle Buchungen bis
|
||||
einschließlich diesem Tag). CSV-Importe füllen einen fehlenden oder
|
||||
veralteten Anker automatisch nach (`_apply_anchor_autofill` in
|
||||
`services/importer.py`) — ein manuell per PATCH gesetzter, neuerer Anker
|
||||
bleibt dabei unangetastet. Die Postgres-Views `v_balance_history` /
|
||||
`v_balance_total` (`models/views.py`) bilden dieselbe Anker-Semantik als
|
||||
lückenlose Tagesreihe (Carry-Forward via `generate_series` + Fenster-`SUM`)
|
||||
für Grafana ab.
|
||||
|
||||
**`dedup_hash`.** `parsers/validate.py::dedup_hash(account_id, booking_date,
|
||||
amount, purpose)` erzeugt einen deterministischen Hash je Buchung. Er wird
|
||||
zweimal genutzt: (1) beim Import markiert `services/importer.py` Entwürfe
|
||||
als `is_duplicate`, wenn der Hash bereits unter einer *bestätigten*
|
||||
Transaktion existiert; (2) beim Bestätigen (`routers/imports.py::confirm`)
|
||||
erfolgt ein **Dedup-Recheck** gegen zwischenzeitlich bestätigte Buchungen
|
||||
*anderer* Statements — zwei identische Buchungen *innerhalb* desselben
|
||||
Statements teilen sich zwar denselben Hash, dürfen sich aber nicht
|
||||
gegenseitig ausschließen, da die in der Preview geprüfte Bilanz von beiden
|
||||
abhängt.
|
||||
|
||||
**Statement-Lebenszyklus.** `Statement.status` (`models/tables.py`) durchläuft
|
||||
`draft` → `confirmed`, oder `draft` → `error` (Bank/Format nicht erkannt,
|
||||
Saldo-Differenz, kaputte Datei). Transaktionen haben denselben
|
||||
Status-Wortschatz (`draft`/`confirmed`) pro Zeile. Bestätigte Statements sind
|
||||
nur noch über `rollback` (löscht Statement + Transaktionen komplett) zu
|
||||
entfernen, nicht mehr über `DELETE /api/imports/{id}` (nur für Entwürfe und Fehl-Importe).
|
||||
|
||||
**Szenario-Engine ist rein.** `app/engine/*` (`loans.py`, `recurrence.py`,
|
||||
`projection.py`, `scenario.py`) hat **keinen** Datenbank- oder
|
||||
Request-Zugriff — nur `scenario.py` importiert innerhalb der Engine von
|
||||
`loans.py`/`recurrence.py`, sonst keine Abhängigkeiten auf `app.*`. Ein-/
|
||||
Ausgaben sind einfache Dataclasses (`Installment`, `PlainRecurring`,
|
||||
`PlainPlanned`, `PlainModifier`, `Projection`). `services/projection_service.py`
|
||||
ist die einzige Brücke: es lädt ORM-Objekte (`Loan`, `RecurringItem`,
|
||||
`PlannedItem`, `Scenario`, `ScenarioModifier`), baut daraus die Plain-Objekte
|
||||
und ruft `engine.scenario.build_cashflows` → `engine.projection.project`.
|
||||
|
||||
**`VERSION`-Datei als Single Source.** `finance/VERSION` (ein Einzeiler, z.B.
|
||||
`0.4.0`) ist die einzige Versionsquelle: `app/version.py::get_version()`
|
||||
liest sie (Fallback `"0.0.0-dev"`), gespeist in den Jinja2-Footer
|
||||
(`templates.env.globals["app_version"]` in `routers/gui.py`) und
|
||||
`GET /api/version` (`main.py`). `Containerfile` kopiert `VERSION` ins Image;
|
||||
`create_pod_finance.sh` liest dieselbe Datei für den Image-Tag
|
||||
(`API_IMAGE="localhost/finance-api:$(cat "$FINANCE_DIR/VERSION")"`). Ein
|
||||
Release ist somit: `VERSION` hochzählen → Image bauen → Redeploy.
|
||||
|
||||
**DATENSCHUTZ: Fixtures enthalten echte Kontodaten.** `finance/tests/fixtures/`
|
||||
enthält reale PDF-/CSV-Kontoauszüge (`*.pdf`, `*.csv`, `expected_*.json`) —
|
||||
siehe `finance/tests/fixtures/README.md` und `finance/.gitignore` (alle drei
|
||||
Muster ausgeschlossen). `scripts/parser_audit.py` und
|
||||
`scripts/parser_vs_csv.py` geben Klartext-Kontodaten auf der Konsole aus und
|
||||
sind ausdrücklich mit "NUR lokal verwenden" markiert — ihre Ausgabe darf
|
||||
nicht in Commits, Reports, Tickets oder Chat-Antworten landen. Details und
|
||||
verbindliche Regeln: siehe `CLAUDE.md`.
|
||||
310
docs/superpowers/plans/2026-07-19-ausbaustufe-2.md
Normal file
310
docs/superpowers/plans/2026-07-19-ausbaustufe-2.md
Normal 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 5–7) 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`, 1–100 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 1–6 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
|
||||
141
docs/superpowers/plans/2026-07-19-ausbaustufe-3.md
Normal file
141
docs/superpowers/plans/2026-07-19-ausbaustufe-3.md
Normal 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=D−2d` 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 (Juni–Juli 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
|
||||
44
docs/superpowers/plans/2026-07-20-ausbaustufe-4.md
Normal file
44
docs/superpowers/plans/2026-07-20-ausbaustufe-4.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Ausbaustufe 4 — Backup, Admin-Seite mit Passwortänderung, Grafana-Lücken, Architektur-Doku
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Fable-Testagent als Gate je Task (Nutzer-Vorgabe). Checkbox-Tracking.
|
||||
|
||||
**Goal:** Kalt-Backup-Skript; Admin-Seite (Passwortänderung vollintegriert + Regeln-neu-anwenden); durchgehende Grafana-Kontostandslinien; Architektur-Doku (Mermaid) + CLAUDE.md für künftige Sessions. Version 0.5.0.
|
||||
|
||||
**Nutzerentscheidungen (2026-07-20):** Backup kalt (Pod-Stopp) nach `~/backups/` (Zielverzeichnis als Argument überschreibbar); Passwortänderung vollintegriert (.env in Container gemountet, Laufzeit-Lesen, Grafana per HTTP-API); Admin-Zusatzfunktion NUR „Regeln neu anwenden".
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Wie Ausbaustufe 3 (Decimal, Deutsch, Suite grün — Basis 132, Trailer, DATENSCHUTZ, UX-Regel sichtbar/disabled, Fable-Testagent-Gate je Task). Deployment gesammelt am Ende (Task 5).
|
||||
- Backup-Datei enthält Secrets (.env) → chmod 600.
|
||||
- KRITISCH (.env im Container): Bind-Mount einer EINZELDATEI folgt dem Inode — die App muss die Datei IN-PLACE beschreiben (open r+/truncate, flock), NIEMALS über Temp-Datei+rename. Host-Skript-seitige sed-Edits erzeugen neue Inodes — unkritisch, weil das Skript die Container ohnehin neu erstellt.
|
||||
|
||||
### Task 1: Backup-Skript
|
||||
|
||||
**Files:** Create `backup_finance_pod.sh` (Repo-Root, ausführbar).
|
||||
**Interface:** `./backup_finance_pod.sh [ZIELDIR]` (Default `$HOME/backups`): systemctl --user stop pod-finance_pod.service → `tar czf ZIELDIR/finance_pod_$(date +%F_%H%M).tar.gz -C ~/.local/share finance_pod` → start → Readiness-Curl /login → `tar -tzf`-Integritätscheck (muss .env und postgres-data enthalten) → chmod 600 → Ausgabe Pfad+Größe. Bei jedem Fehler: Service wieder starten (trap), Exit ≠ 0.
|
||||
- [x] Skript schreiben, `bash -n` · [x] FABLE-TESTAGENT: Skript LIVE ausführen (kurze Downtime ok), prüfen: Service danach active, Login 200, Archiv 600 + enthält .env/postgres-data/grafana-data/data, Restore-Probe NUR als tar-Listing (kein Zurückspielen); Fehlerpfad: Skript mit unbeschreibbarem Ziel → Service läuft trotzdem weiter · [x] Commit `feat: Kalt-Backup-Skript finance_pod`
|
||||
|
||||
### Task 2: Admin-Seite (Passwortänderung + Regeln-Anwenden)
|
||||
|
||||
**Files:** Modify `create_pod_finance.sh` (Mount `-v "$ENV_FILE:/data/.env:Z"` am API-Container; Kommentar Inode-Regel), `finance/app/config.py` (+`env_file: Path` aus `FB_ENV_FILE`, Default `/data/.env`), `finance/app/auth.py` (Hash-Lookup zur Laufzeit: existiert env_file → Werte daraus parsen (single-quoted), sonst Fallback env; kleine Helferfunktion mit mtime-Cache), Create `finance/app/services/admin.py` (`change_password(old, new)`: verify old → Grafana `PUT http://localhost:3000/api/admin/users/1/password` Basic-Auth admin:old → .env IN-PLACE (flock, FB_PASSWORD+FB_GUI_PASSWORD_HASH single-quoted ersetzen) → bei Grafana-Fehler abbrechen ohne .env-Änderung; `apply_rules_retroactively(session) -> int` auf unkategorisierte confirmed), Create `finance/app/routers/admin.py` (GUI `GET /admin` [gui_session] + `POST /admin/passwort` [gui_session, Form alt/neu/neu2, neu ≥ 8 Zeichen] + API `POST /api/category-rules/apply` [require_auth]), Templates `admin.html` + Nav „Admin", Tests.
|
||||
**Semantik:** Session-Cookies bleiben nach Änderung gültig (Secret unverändert — dokumentieren). Meldungen Deutsch. In Tests env_file → tmp_path (FB_ENV_FILE), Grafana-Call gemockt (monkeypatch), In-Place-Schreiben per Inode-Vergleich getestet.
|
||||
- [x] TDD · [x] FABLE-TESTAGENT (Suite; Inode-Konstanz beim Schreiben; Mock-Grafana-Reihenfolge: kein .env-Write bei Grafana-Fehler; /admin-Auth; apply-Endpoint zählt korrekt) · [x] Commit `feat: Admin-Seite mit Passwortaenderung und Regel-Neuanwendung`
|
||||
|
||||
### Task 3: Grafana durchgehende Linien
|
||||
|
||||
**Files:** Modify `finance/app/models/views.py` (v_balance_history/v_balance_total: tägliche Reihe via `generate_series(min(start), CURRENT_DATE, '1 day')` je Konto mit Carry-Forward des ankerbasierten Saldos — keine NULL-Lücken mehr), `finance/grafana/dashboards/finanzen.json` (Panels 1+2: `lineInterpolation: "stepAfter"`, `spanNulls: true`).
|
||||
- [x] Implementieren (SQL Postgres-only wie bisher) · [x] FABLE-TESTAGENT: Wegwerf-DB auf finance-db_ctr (Muster A3-T1): synthetische Daten mit mehrtägigen Buchungslücken → Views liefern JEDEN Tag genau eine Zeile je Konto, Werte = Handrechnung, keine Lücken; finanzen.json parsebar + Optionen gesetzt; Suite grün · [x] Commit `fix: lueckenlose taegliche Saldo-Reihen fuer Grafana`
|
||||
|
||||
### Task 4: Architektur-Doku + CLAUDE.md
|
||||
|
||||
**Files:** Create `docs/ARCHITEKTUR.md` (Mermaid: (1) Komponenten-Diagramm Module→Funktionen: parsers [base/detect/validate/vr/hvb/dkb/csv_formats/registry], engine [loans/recurrence/projection/scenario], services [importer/balances/categorize/suggestions/projection_service/admin], routers [accounts/transactions/categories/imports/planning/scenarios/gui/admin], templates/Seiten, models+views, alembic; (2) Datenfluss-Sequenz Import CSV/PDF→Draft→Confirm→Anker/Salden; (3) Deployment-Diagramm Pod/Container/Mounts/Ports/systemd/.env; je Knoten Stichwort-Funktionsliste), Create `CLAUDE.md` (Repo-Root ~/bin: Projektüberblick, Betriebs-/Testkommandos, DATENSCHUTZ-Regeln [fixtures = echte Daten!], Konventionen [Decimal, Deutsch, Fable-Test-Gate, Superpowers-Workflow, Plan-/Ledger-Orte, Versions-Prozess VERSION-Datei, Backup/DR], Verweis auf ARCHITEKTUR.md + docs/superpowers/plans/), Update Claude-Memory.
|
||||
- [x] Schreiben · [x] FABLE-TESTAGENT: Faktencheck der Doku gegen den Code (jede benannte Datei/Funktion existiert; Mermaid-Syntax valide; keine echten Daten) · [x] Commit `docs: Architektur (Mermaid) und CLAUDE.md fuer kuenftige Sessions`
|
||||
|
||||
### Task 5: v0.5.0 + Redeploy + Smoke
|
||||
|
||||
- [x] VERSION 0.5.0, Build, Redeploy · [x] Smoke: Kernseiten inkl. /admin; Passwortänderung E2E LIVE (auf Temp-Passwort ändern → GUI+Grafana-Login mit Temp OK → zurück auf Original ändern → Original-Login OK); Regeln-Anwenden-Button; Grafana-Panel liefert tägliche Punkte (View-COUNT == Kalendertage) · [x] Backup-Skript einmal final ausführen (frisches Backup nach Abschluss) · [x] Plan-Häkchen, Ledger, Memory, Commit + PUSH beider Repos
|
||||
|
||||
## Abschluss-Checkliste
|
||||
- [x] Suite grün (>= 140 erwartet); alle Fable-Gates dokumentiert
|
||||
- [x] Live v0.5.0: Passwort unverändert (nach E2E-Probe zurückrotiert), Admin-Seite funktional, Grafana-Linien durchgehend, Backup in ~/backups vorhanden (600)
|
||||
- [x] ARCHITEKTUR.md + CLAUDE.md committet; Memory aktuell; gepusht
|
||||
1211
docs/superpowers/plans/2026-07-20-ausbaustufe-5.md
Normal file
1211
docs/superpowers/plans/2026-07-20-ausbaustufe-5.md
Normal file
File diff suppressed because it is too large
Load Diff
449
docs/superpowers/plans/2026-07-20-ausbaustufe-6.md
Normal file
449
docs/superpowers/plans/2026-07-20-ausbaustufe-6.md
Normal file
@@ -0,0 +1,449 @@
|
||||
# Ausbaustufe 6 Implementation Plan — Szenario-Einträge-Tabelle (v0.7.0)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||
|
||||
**Goal:** Pro Szenario eine immer sichtbare, editierbare Tabelle „Einträge" (Modifikatoren + szenario-eigene Einmalzahlungen gemeinsam) mit Inline-Bearbeiten und einem gemeinsamen Anlege-Formular; Release v0.7.0.
|
||||
|
||||
**Architecture:** Datenmodell unverändert — zwei neue Full-Body-PATCH-Endpunkte in `routers/scenarios.py`; Template-Umbau in `planning.html` ersetzt die zwei `<details>`-Abschnitte durch eine Tabelle; Feld-Umschaltung per `disabled` (JS `onEntryArtChange` ersetzt `onModKindChange`), Endpunkt-Wechsel des Anlege-Formulars per `hx-post`-Attribut + `htmx.process`.
|
||||
|
||||
**Tech Stack:** FastAPI + Pydantic v2, Jinja2, htmx + json-form-Extension, pytest.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-20-szenario-eintraege-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Geldbeträge `Decimal`; JSON-API Punkt-Dezimal; HTML deutsch (`|eur`, `|de_label`, TT.MM.JJJJ).
|
||||
- API-/DB-Werte englisch; neuer Formular-Artwert `einmal` ist NUR clientseitig (landet nie in der DB — an `/planned` gesendet wird er von Pydantic ignoriert).
|
||||
- UX-Regel: Umschalten/Verstecken nur per `disabled`/`hidden`, nie DOM-Entfernen.
|
||||
- Fable-Testagent-Gate je Task VOR dem Commit; Ledger-Eintrag je Task in `.superpowers/sdd/progress.md`.
|
||||
- DATENSCHUTZ: keine echten Namen/Beträge in Commits/Tests/Doku; Live-Demo-Daten nur DB/Chat.
|
||||
- Testlauf: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` — Basis 178 passed, muss grün bleiben.
|
||||
- Pfade unten relativ zu `/home/wlfb/bin/finance` (Ledger/Plan unter `/home/wlfb/bin`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: PATCH-Endpunkte für Modifikatoren und Szenario-Einmalzahlungen
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/routers/scenarios.py`
|
||||
- Modify: `tests/test_crud_api.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ModifierIn`/`ModifierOut`, `ScenarioPlannedIn`/`ScenarioPlannedOut`, `_get_scenario` (alle vorhanden in `scenarios.py`).
|
||||
- Produces: `PATCH /api/scenarios/{scenario_id}/modifiers/{mod_id}` (Body `ModifierIn`, Antwort `ModifierOut`, 404 „Modifikator nicht gefunden") und `PATCH /api/scenarios/{scenario_id}/planned/{item_id}` (Body `ScenarioPlannedIn`, Antwort `ScenarioPlannedOut`, 404 „Einmalzahlung nicht gefunden"). Task 2 (GUI) sendet auf genau diese URLs.
|
||||
|
||||
- [x] **Step 1: Failing Tests** — in `tests/test_crud_api.py` ergänzen:
|
||||
|
||||
```python
|
||||
def test_modifier_patch(client, db):
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "ModPatch"}).json()
|
||||
m = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "percent",
|
||||
"value": "10.00"}).json()
|
||||
# Wert aendern
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "25.00"})
|
||||
assert r.status_code == 200 and r.json()["value"] == "25.00"
|
||||
# Art auf ende wechseln (mit Datum); value wird vom Validator genullt
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "ende",
|
||||
"end_date": "2026-08-31"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["kind"] == "ende" and r.json()["end_date"] == "2026-08-31"
|
||||
# ende ohne Datum -> 422
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "ende"})
|
||||
assert r.status_code == 422
|
||||
# zurueck auf remove: end_date wird genullt
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "remove",
|
||||
"end_date": "2026-08-31"})
|
||||
assert r.status_code == 200 and r.json()["end_date"] is None
|
||||
# fremdes Szenario -> 404
|
||||
other = client.post("/api/scenarios", headers=H, json={"name": "ModPatch2"}).json()
|
||||
r = client.patch(f"/api/scenarios/{other['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "remove"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_scenario_planned_patch(client, db):
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "SpiPatch"}).json()
|
||||
p = client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
|
||||
"name": "Zufluss", "amount": "5000.00", "due": "2026-07-30"}).json()
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/planned/{p['id']}", headers=H, json={
|
||||
"name": "Zufluss geaendert", "amount": "6000.00", "due": "2026-08-15"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["name"] == "Zufluss geaendert"
|
||||
assert body["amount"] == "6000.00" and body["due"] == "2026-08-15"
|
||||
# fremdes Szenario -> 404
|
||||
other = client.post("/api/scenarios", headers=H, json={"name": "SpiPatch2"}).json()
|
||||
r = client.patch(f"/api/scenarios/{other['id']}/planned/{p['id']}", headers=H, json={
|
||||
"name": "x", "amount": "1.00", "due": "2026-08-15"})
|
||||
assert r.status_code == 404
|
||||
```
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_crud_api.py -q` → neue Tests FAIL (405 Method Not Allowed)
|
||||
|
||||
- [x] **Step 2: Endpunkte implementieren** — `app/routers/scenarios.py`, direkt nach `delete_modifier` bzw. nach `delete_scenario_planned`:
|
||||
|
||||
```python
|
||||
@router.patch("/{scenario_id}/modifiers/{mod_id}", response_model=ModifierOut)
|
||||
def patch_modifier(scenario_id: int, mod_id: int, data: ModifierIn,
|
||||
session: Session = Depends(get_session)):
|
||||
# Full-Body-Update: das Edit-Formular sendet immer alle Felder des Typs.
|
||||
_get_scenario(session, scenario_id)
|
||||
modifier = session.get(ScenarioModifier, mod_id)
|
||||
if modifier is None or modifier.scenario_id != scenario_id:
|
||||
raise HTTPException(404, "Modifikator nicht gefunden")
|
||||
for key, value in data.model_dump().items():
|
||||
setattr(modifier, key, value)
|
||||
session.commit()
|
||||
session.refresh(modifier)
|
||||
return ModifierOut.model_validate(modifier)
|
||||
```
|
||||
|
||||
```python
|
||||
@router.patch("/{scenario_id}/planned/{item_id}", response_model=ScenarioPlannedOut)
|
||||
def patch_scenario_planned(scenario_id: int, item_id: int, data: ScenarioPlannedIn,
|
||||
session: Session = Depends(get_session)):
|
||||
# Full-Body-Update, Muster patch_modifier.
|
||||
_get_scenario(session, scenario_id)
|
||||
item = session.get(ScenarioPlannedItem, item_id)
|
||||
if item is None or item.scenario_id != scenario_id:
|
||||
raise HTTPException(404, "Einmalzahlung nicht gefunden")
|
||||
for key, value in data.model_dump().items():
|
||||
setattr(item, key, value)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return ScenarioPlannedOut.model_validate(item)
|
||||
```
|
||||
|
||||
- [x] **Step 3: Tests grün + Suite**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_crud_api.py -q && .venv/bin/python -m pytest -q`
|
||||
Expected: alles PASS (178 + 2)
|
||||
|
||||
- [x] **Step 4: Fable-Testagent-Abnahme** (Faktencheck: Validator-Wirkung beim PATCH [ende↔andere Arten], 404-Pfade, keine Nebenwirkungen auf Projektion). Erst nach VERIFIED weiter.
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/wlfb/bin && git add finance/app/routers/scenarios.py finance/tests/test_crud_api.py
|
||||
git commit -m "feat: PATCH-Endpunkte fuer Szenario-Modifikatoren und -Einmalzahlungen"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: GUI-Umbau — eine Einträge-Tabelle je Szenario
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/templates/planning.html` (Szenario-Abschnitt: die beiden `<details>` „Modifikatoren" [Zeilen ~300-362] und „Einmalzahlungen in diesem Szenario" [~364-395] ersetzen; JS-Block: `onModKindChange` ersetzen)
|
||||
- Modify: `app/routers/gui.py` (`_scenario_rows`: Modifikator-Query stabil sortieren)
|
||||
- Modify: `app/templates/hilfe.html` (Szenarien-Absatz an neue Tabelle anpassen)
|
||||
- Modify: `tests/test_gui.py` (neuer Test + Anpassung `test_szenario_gui_ende_und_einmalzahlungen`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: PATCH-Endpunkte aus Task 1; `toggleEdit`, `onModTargetTypeChange`, Filter `|eur`/`|de_label`, Kontext `row.modifiers`/`row.planned_items`/`modifier_kinds`/`recurring_names`/`category_names` (alle vorhanden).
|
||||
- Produces: Element-IDs `mod-row-{id}`/`mod-edit-{id}` und `spi-row-{id}`/`spi-edit-{id}`; JS `onEntryArtChange(select)` (ersetzt `onModKindChange` vollständig — auch in den Art-Selects); Anlege-Formular mit `data-modifiers-url`/`data-planned-url` und Eintragsart-Option `value="einmal"`.
|
||||
|
||||
- [x] **Step 1: Failing GUI-Test** — in `tests/test_gui.py` ergänzen (Imports `Scenario, ScenarioModifier, ScenarioPlannedItem` existieren dort seit A5):
|
||||
|
||||
```python
|
||||
def test_szenario_eintraege_tabelle(client, db):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
sc = Scenario(name="Eintraege-GUI", description="")
|
||||
db.add(sc)
|
||||
db.flush()
|
||||
db.add(ScenarioModifier(scenario_id=sc.id, target_type="recurring",
|
||||
target_id=1, kind="ende", value=Decimal("0"),
|
||||
end_date=date(2026, 8, 31)))
|
||||
db.add(ScenarioModifier(scenario_id=sc.id, target_type="category",
|
||||
target_id=1, kind="percent", value=Decimal("25.00")))
|
||||
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Zufluss-Eintrag",
|
||||
amount=Decimal("5000.00"), due=date(2026, 7, 30)))
|
||||
db.commit()
|
||||
r = client.get("/planung").text
|
||||
assert "Einträge" in r
|
||||
# alte details-Abschnitte sind ersetzt
|
||||
assert "<summary>Modifikatoren</summary>" not in r
|
||||
assert "Einmalzahlungen in diesem Szenario" not in r
|
||||
# gemischte Zeilen in EINER Tabelle
|
||||
assert "31.08.2026" in r and "25,00" in r and "Zufluss-Eintrag" in r
|
||||
assert ">Einmalzahlung<" in r
|
||||
# Inline-Edit je Typ
|
||||
assert f'hx-patch="/api/scenarios/{sc.id}/modifiers/' in r
|
||||
assert f'hx-patch="/api/scenarios/{sc.id}/planned/' in r
|
||||
# Anlege-Formular: Eintragsart inkl. Einmalzahlung + Endpunkt-Wechsel-Attribute
|
||||
assert 'value="einmal"' in r
|
||||
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
|
||||
assert "onEntryArtChange" in r and "onModKindChange" not in r
|
||||
```
|
||||
|
||||
Run: → FAIL
|
||||
|
||||
- [x] **Step 2: `_scenario_rows` sortieren** — `app/routers/gui.py`, Modifikator-Query um `.order_by(ScenarioModifier.id)` ergänzen:
|
||||
|
||||
```python
|
||||
modifiers = session.execute(
|
||||
select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id)
|
||||
.order_by(ScenarioModifier.id)
|
||||
).scalars().all()
|
||||
```
|
||||
|
||||
- [x] **Step 3: Einträge-Tabelle** — in `planning.html` die BEIDEN `<details>`-Blöcke „Modifikatoren" und „Einmalzahlungen in diesem Szenario" komplett ersetzen durch:
|
||||
|
||||
```html
|
||||
<h3>Einträge</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Was</th><th>Art</th><th>Wert/Betrag</th><th>Datum</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in row.modifiers %}
|
||||
<tr id="mod-row-{{ m.id }}">
|
||||
<td>
|
||||
{% if m.target_type == "category" %}Kategorie: {{ category_names.get(m.target_id, m.target_id) }}
|
||||
{% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %}
|
||||
</td>
|
||||
<td>{{ m.kind|de_label }}</td>
|
||||
<td>{% if m.kind in ('percent', 'absolute') %}{{ m.value|eur }}{% else %}–{% endif %}</td>
|
||||
<td>{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}{% else %}–{% endif %}</td>
|
||||
<td>
|
||||
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, true)">Bearbeiten</button>
|
||||
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<button type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="mod-edit-{{ m.id }}" hidden>
|
||||
<td colspan="5">
|
||||
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Ziel-Typ
|
||||
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
|
||||
<option value="category" {% if m.target_type == 'category' %}selected{% endif %}>Kategorie</option>
|
||||
<option value="recurring" {% if m.target_type == 'recurring' %}selected{% endif %}>Wiederkehrender Posten</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="mod-target-category" {% if m.target_type != 'category' %}style="display:none"{% endif %}>
|
||||
<label>Kategorie
|
||||
<select name="target_id" data-type="int" {% if m.target_type != 'category' %}disabled{% endif %}>
|
||||
{% for c in categories %}<option value="{{ c.id }}" {% if m.target_type == 'category' and c.id == m.target_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</span>
|
||||
<span class="mod-target-recurring" {% if m.target_type != 'recurring' %}style="display:none"{% endif %}>
|
||||
<label>Posten
|
||||
<select name="target_id" data-type="int" {% if m.target_type != 'recurring' %}disabled{% endif %}>
|
||||
{% for r in recurring %}<option value="{{ r.id }}" {% if m.target_type == 'recurring' and r.id == m.target_id %}selected{% endif %}>{{ r.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</span>
|
||||
<label>Art
|
||||
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
|
||||
{% for k in modifier_kinds %}<option value="{{ k }}" {% if k == m.kind %}selected{% endif %}>{{ k|de_label }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Wert <input type="text" name="value" data-type="amount" value="{{ m.value|eur }}" {% if m.kind not in ('percent', 'absolute') %}disabled{% endif %}></label>
|
||||
<label>Endet am <input type="date" name="end_date" value="{{ m.end_date.isoformat() if m.end_date else '' }}" {% if m.kind != 'ende' %}disabled{% endif %}></label>
|
||||
<button type="submit">Speichern</button>
|
||||
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, false)">Abbrechen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for sp in row.planned_items %}
|
||||
<tr id="spi-row-{{ sp.id }}">
|
||||
<td>{{ sp.name }}</td>
|
||||
<td>Einmalzahlung</td>
|
||||
<td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td>
|
||||
<td>{{ sp.due.strftime('%d.%m.%Y') }}</td>
|
||||
<td>
|
||||
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, true)">Bearbeiten</button>
|
||||
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
|
||||
hx-confirm="„{{ sp.name }}“ wirklich löschen?"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<button type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="spi-edit-{{ sp.id }}" hidden>
|
||||
<td colspan="5">
|
||||
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" value="{{ sp.name }}" required></label>
|
||||
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ sp.amount|eur }}" required></label>
|
||||
<label>Fällig am <input type="date" name="due" value="{{ sp.due.isoformat() }}" required></label>
|
||||
<button type="submit">Speichern</button>
|
||||
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, false)">Abbrechen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not row.modifiers and not row.planned_items %}
|
||||
<tr><td colspan="5">Keine Einträge.</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
```
|
||||
|
||||
- [x] **Step 4: Anlege-Formular „Neuer Eintrag"** — direkt nach der Tabelle (Default-Art ist die erste Option `percent` → Wert aktiv, Datumsfelder/Einmal-Felder disabled):
|
||||
|
||||
```html
|
||||
<form hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" hx-swap="none"
|
||||
data-modifiers-url="/api/scenarios/{{ sc.id }}/modifiers"
|
||||
data-planned-url="/api/scenarios/{{ sc.id }}/planned"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<strong>Neuer Eintrag:</strong>
|
||||
<label>Eintragsart
|
||||
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
|
||||
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
|
||||
<option value="einmal">Einmalzahlung</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="mod-target-category">
|
||||
<label>Kategorie
|
||||
<select name="target_id" data-type="int">
|
||||
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</span>
|
||||
<span class="mod-target-recurring" style="display:none">
|
||||
<label>Posten
|
||||
<select name="target_id" data-type="int" disabled>
|
||||
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</span>
|
||||
<label>Ziel-Typ
|
||||
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
|
||||
<option value="category">Kategorie</option>
|
||||
<option value="recurring">Wiederkehrender Posten</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Wert <input type="text" name="value" data-type="amount" value="0"></label>
|
||||
<label>Endet am <input type="date" name="end_date" disabled></label>
|
||||
<label>Name <input type="text" name="name" disabled></label>
|
||||
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" disabled></label>
|
||||
<label>Fällig am <input type="date" name="due" disabled></label>
|
||||
<button type="submit">Hinzufügen</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
WICHTIG: Der Ziel-Typ-Select muss VOR den beiden Ziel-Spans stehen wie im bisherigen Formular? Nein — Reihenfolge im bisherigen Formular war Ziel-Typ, dann Spans. Diese Reihenfolge beibehalten (Ziel-Typ zuerst, dann die Spans), damit das Formular lesbar bleibt — den obigen Block entsprechend anordnen: Eintragsart, Ziel-Typ, beide Spans, Wert, Endet am, Name, Betrag, Fällig am, Button.
|
||||
|
||||
- [x] **Step 5: JS** — im `<script>`-Block von `planning.html` die Funktion `onModKindChange` ERSETZEN durch:
|
||||
|
||||
```javascript
|
||||
function onEntryArtChange(select) {
|
||||
// Umschaltmatrix "Neuer Eintrag"/Modifikator-Edit: Felder nur per
|
||||
// disabled schalten (UX-Regel). Fehlt ein Feld (Edit-Formulare haben
|
||||
// kein name/amount/due), wird es uebersprungen.
|
||||
var form = select.closest('form');
|
||||
var art = select.value;
|
||||
var isMod = art !== 'einmal';
|
||||
function setDisabled(name, disabled) {
|
||||
var el = form.querySelector('[name="' + name + '"]');
|
||||
if (el) { el.disabled = disabled; }
|
||||
}
|
||||
setDisabled('value', !(art === 'percent' || art === 'absolute'));
|
||||
setDisabled('end_date', art !== 'ende');
|
||||
setDisabled('name', isMod);
|
||||
setDisabled('amount', isMod);
|
||||
setDisabled('due', isMod);
|
||||
var typeSelect = form.querySelector('[name="target_type"]');
|
||||
if (typeSelect) {
|
||||
typeSelect.disabled = !isMod;
|
||||
var isCategory = typeSelect.value === 'category';
|
||||
var catSelect = form.querySelector('.mod-target-category select');
|
||||
var recSelect = form.querySelector('.mod-target-recurring select');
|
||||
if (catSelect) { catSelect.disabled = !isMod || !isCategory; }
|
||||
if (recSelect) { recSelect.disabled = !isMod || isCategory; }
|
||||
}
|
||||
// POST-Ziel nur beim Anlege-Formular wechseln (Edit-Formulare patchen fix).
|
||||
if (form.hasAttribute('data-planned-url')) {
|
||||
form.setAttribute('hx-post', isMod ? form.getAttribute('data-modifiers-url')
|
||||
: form.getAttribute('data-planned-url'));
|
||||
htmx.process(form);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`onModTargetTypeChange` und `toggleEdit` bleiben unverändert.
|
||||
|
||||
- [x] **Step 6: Bestehenden Test anpassen** — `tests/test_gui.py::test_szenario_gui_ende_und_einmalzahlungen` (aus A5 Task 7): die Assertions `"Einmalzahlungen in diesem Szenario" in r` und `f'hx-post="/api/scenarios/{sc.id}/planned"' in r` ersetzen durch:
|
||||
|
||||
```python
|
||||
assert "Einträge" in r
|
||||
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
|
||||
```
|
||||
|
||||
Die übrigen Assertions (`value="ende"`, `name="end_date"`, `31.08.2026`, `Zufluss-GUI`, `5.000,00`, `30.07.2026`) bleiben gültig. Danach: `grep -n "Einmalzahlungen in diesem Szenario" tests/` → 0 Treffer.
|
||||
|
||||
- [x] **Step 7: Hilfe anpassen** — `app/templates/hilfe.html`, im Szenarien-Absatz (aus A5) den Satzteil zu Modifikatoren/Einmalzahlungen ersetzen durch (generisch, keine echten Daten):
|
||||
|
||||
```html
|
||||
Varianten wie „Best Case“ pflegst du in der Tabelle „Einträge“ des
|
||||
Szenarios: Art „Ende“ lässt einen wiederkehrenden Posten (z. B. eine
|
||||
auslaufende Miete) zu einem Datum enden, ohne den Posten selbst zu
|
||||
ändern; „Prozent“/„Absolut“ kürzen Beträge; „Entfällt“ blendet sie ganz
|
||||
aus; „Einmalzahlung“ ergänzt einen einmaligen Zu- oder Abfluss, der nur
|
||||
in diesem Szenario zählt. Jeder Eintrag ist über „Bearbeiten“ direkt
|
||||
änderbar.
|
||||
```
|
||||
|
||||
- [x] **Step 8: Tests + Suite grün**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_gui.py -q && .venv/bin/python -m pytest -q`
|
||||
Expected: alles PASS
|
||||
|
||||
- [x] **Step 9: Fable-Testagent-Abnahme** — Live-Anteil (TestClient-Approximation): gemischte Tabelle rendert beide Typen in korrekter Reihenfolge; Edit-Roundtrips für Modifikator (percent→ende inkl. Datumspflicht) und Einmalzahlung mit deutscher Betragseingabe; Umschaltmatrix-Handtrace für alle 5 Eintragsarten (inkl. disabled-Zustand von `kind`-Fremdfeld beim jeweils anderen Endpunkt); keine doppelten IDs. Erst nach VERIFIED weiter.
|
||||
|
||||
- [x] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/wlfb/bin && git add finance/app/templates/ finance/app/routers/gui.py finance/tests/test_gui.py
|
||||
git commit -m "feat: Szenario-Eintraege als eine editierbare Tabelle"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Release v0.7.0 — Redeploy, Live-Check, Ledger
|
||||
|
||||
**Files:**
|
||||
- Modify: `finance/VERSION` (`0.6.0` → `0.7.0`)
|
||||
- Modify: `.superpowers/sdd/progress.md` (Ledger), Plan-Häkchen in dieser Datei
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 1-2 committet, Suite grün, bestehendes Live-Szenario „Best Case" (id 4) mit 3 Einträgen.
|
||||
|
||||
- [x] **Step 1: Suite final** — `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` → alle grün.
|
||||
|
||||
- [x] **Step 2: Version + Commit**
|
||||
|
||||
```bash
|
||||
echo "0.7.0" > /home/wlfb/bin/finance/VERSION
|
||||
cd /home/wlfb/bin && git add finance/VERSION && git commit -m "chore: Version 0.7.0"
|
||||
```
|
||||
|
||||
- [x] **Step 3: Redeploy** — `cd /home/wlfb/bin && ./create_pod_finance.sh` (keine Migration nötig; Entrypoint-`alembic upgrade head` ist idempotent). Expected: Service aktiv, Readiness 200.
|
||||
|
||||
- [x] **Step 4: Live-Smoke** — `/api/version` == 0.7.0 (Bearer-Key aus `~/.local/share/finance_pod/.env`); GUI-Login; `/planung` zeigt im „Best Case"-Kasten die Einträge-Tabelle mit 3 Zeilen (2× Ende, 1× Einmalzahlung) OHNE aufklappen zu müssen; kein „Modifikatoren"-Summary mehr.
|
||||
|
||||
- [x] **Step 5: Fable-Testagent-Abnahme (Release-Gate)** — Live: zusätzlich zu Step 4 ein kompletter Roundtrip in einem WEGWERF-Szenario „SMOKE-A6" (anlegen → Eintrag percent anlegen → per PATCH auf ende mit Datum ändern → Einmalzahlung anlegen → Betrag per PATCH ändern → Szenario löschen [räumt alles ab]); Bestandsdaten unverändert (confirmed-Tx-Count, „Best Case"-Einträge unangetastet). Erst nach VERIFIED weiter.
|
||||
|
||||
- [x] **Step 6: Ledger + Plan-Häkchen + Push**
|
||||
|
||||
```bash
|
||||
cd /home/wlfb/bin && git add docs/superpowers/plans/2026-07-20-ausbaustufe-6.md && git commit -m "docs: Ledger/Plan Ausbaustufe 6" && git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (beim Planschreiben)
|
||||
|
||||
- **Spec-Abdeckung:** API-PATCH → Task 1; Tabelle/Anlege-Formular/Inline-Edit/JS/Sortierung/Hilfe → Task 2; Release/Live-Check → Task 3. Vollständig.
|
||||
- **Platzhalter:** keine.
|
||||
- **Typ-Konsistenz:** URLs/IDs (`mod-`/`spi-`), `data-modifiers-url`/`data-planned-url`, `onEntryArtChange` konsistent zwischen Tasks 1/2; Test-Anpassung des A5-Tests explizit benannt (Step 6), damit keine Suite-Regression entsteht.
|
||||
376
docs/superpowers/plans/2026-07-20-ausbaustufe-7.md
Normal file
376
docs/superpowers/plans/2026-07-20-ausbaustufe-7.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# Ausbaustufe 7 Implementation Plan — Szenarien-Seite, Formular-UX, Kategorien (v0.8.0)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||
|
||||
**Goal:** Szenarien auf eigene Seite `/szenarien`, verständliches Eintrags-Formular (Paar-Layout, Gesperrt-Kennzeichnung, dynamisches Wert-Label, abgesetzter Durchrechnen-Button), Kategorien-Verwaltung auf der Admin-Seite; Release v0.8.0.
|
||||
|
||||
**Architecture:** Kein Datenmodell-/Migrationsbedarf. Template-Umzug der Szenarien-Sektion aus `planning.html` in neues `szenarien.html` + Route in `gui.py`; CSS-Regeln in `style.css`; ein neuer Endpunkt `PATCH /api/categories/{id}`.
|
||||
|
||||
**Tech Stack:** FastAPI, Jinja2, htmx + json-form, CSS, pytest.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-20-szenarien-seite-ux-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- GUI deutsch, TT.MM.JJJJ, `|eur`; API-Werte englisch; Geldbeträge `Decimal`.
|
||||
- UX-Regel: gesperrte Felder sichtbar + `disabled` + optisch gekennzeichnet; Edit-Zeilen per `hidden`.
|
||||
- Fable-Testagent-Gate je Task VOR dem Commit; Ledger-Eintrag je Task in `.superpowers/sdd/progress.md`.
|
||||
- DATENSCHUTZ: keine echten Kontodaten in Commits/Tests/Doku.
|
||||
- Testlauf: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` — Basis 181 passed, muss grün bleiben.
|
||||
- Pfade relativ zu `/home/wlfb/bin/finance` (Ledger/Plan unter `/home/wlfb/bin`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Szenarien-Seite `/szenarien`
|
||||
|
||||
**Files:**
|
||||
- Create: `app/templates/szenarien.html`
|
||||
- Modify: `app/templates/planning.html` (Szenarien-`<section>` Zeilen ~263-464 und szenario-spezifisches JS entfernen), `app/templates/base.html` (Nav + `toggleEdit`), `app/routers/gui.py` (neue Route, `planung_page` verschlanken), `app/templates/hilfe.html` (Ortsangaben)
|
||||
- Modify: `tests/test_gui.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: bestehenden Szenario-Kontext (`_scenario_rows`, `modifier_kinds`), `gui_session`, alle bestehenden Templates/JS-Helfer.
|
||||
- Produces: `GET /szenarien` (login-geschützt) mit Kontext `scenario_rows`, `loans`, `categories`, `category_names`, `recurring`, `recurring_names`, `modifier_kinds`; `toggleEdit` global in `base.html`. Task 2 baut auf `szenarien.html` auf.
|
||||
|
||||
- [x] **Step 1: Failing GUI-Tests** — in `tests/test_gui.py`:
|
||||
|
||||
```python
|
||||
def test_szenarien_seite_und_nav(client, db):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
db.add(Scenario(name="Seiten-Test", description="d"))
|
||||
db.commit()
|
||||
r = client.get("/szenarien")
|
||||
assert r.status_code == 200
|
||||
assert "Seiten-Test" in r.text and "Neues Szenario anlegen" in r.text
|
||||
assert 'href="/szenarien"' in r.text # Nav-Punkt
|
||||
# Planung enthaelt keine Szenarien-Sektion mehr:
|
||||
p = client.get("/planung").text
|
||||
assert "Seiten-Test" not in p and "Neues Szenario anlegen" not in p
|
||||
assert "Wiederkehrende Posten" in p and "Kredite" in p
|
||||
|
||||
|
||||
def test_szenarien_seite_braucht_login(client):
|
||||
r = client.get("/szenarien", follow_redirects=False)
|
||||
assert r.status_code in (302, 303) and r.headers["location"] == "/login"
|
||||
```
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_gui.py -q` → neue Tests FAIL (404)
|
||||
|
||||
- [x] **Step 2: Route** — `app/routers/gui.py`: in `planung_page` die Zeilen `scenarios = _list_scenarios(...)`, `"scenario_rows": ...` und `"modifier_kinds": ...` entfernen (die übrigen Kontexteinträge bleiben). Neue Route direkt darunter:
|
||||
|
||||
```python
|
||||
@router.get("/szenarien", dependencies=[Depends(gui_session)])
|
||||
def szenarien_page(request: Request, session: Session = Depends(get_session)):
|
||||
categories = session.execute(select(Category)).scalars().all()
|
||||
recurring = _list_recurring(session=session)
|
||||
loans = _list_loans(session=session)
|
||||
scenarios = _list_scenarios(session=session)
|
||||
return templates.TemplateResponse(request, "szenarien.html", {
|
||||
"categories": categories,
|
||||
"category_names": {c.id: c.name for c in categories},
|
||||
"recurring": recurring,
|
||||
"recurring_names": {r.id: r.name for r in recurring},
|
||||
"loans": loans,
|
||||
"scenario_rows": _scenario_rows(session, scenarios, loans),
|
||||
"modifier_kinds": ["percent", "absolute", "remove", "ende"],
|
||||
})
|
||||
```
|
||||
|
||||
- [x] **Step 3: Template-Umzug** — Neues `app/templates/szenarien.html`:
|
||||
|
||||
```html
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Szenarien – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Szenarien</h1>
|
||||
|
||||
<section class="planning-section">
|
||||
<!-- HIER: die komplette bisherige Szenarien-<section>-INNEREI aus
|
||||
planning.html unverändert einfügen (alles zwischen
|
||||
'<h2>Szenarien</h2>' … einschließlich des Fieldsets
|
||||
'Neues Szenario anlegen'), ohne das umschließende <section>-Tag
|
||||
doppelt zu setzen. -->
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// Szenario-spezifische Helfer (aus planning.html hierher umgezogen):
|
||||
// onModTargetTypeChange, onEntryArtChange, toggleScenarioLoan
|
||||
// unverändert einfügen.
|
||||
</script>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
Der `<h2>Szenarien</h2>` entfällt (die Seite hat die `<h1>`-Überschrift). In `planning.html`: die gesamte Szenarien-`<section>` löschen; aus dem Script-Block `onModTargetTypeChange`, `onEntryArtChange`, `toggleScenarioLoan` und `toggleEdit` entfernen — `toggleEdit` zieht in den zentralen Script-Block von `base.html` (dort nach der json-form-Extension einfügen, unverändert inkl. Kommentar); `loadLoanSchedule`/`fmtEur`/`formatIsoDate` und `loadedSchedules` bleiben in `planning.html`.
|
||||
|
||||
- [x] **Step 4: Nav + Hilfe** — `base.html`: `<a href="/szenarien">Szenarien</a>` zwischen Planung- und Admin-Link. `hilfe.html`: Formulierungen, die die Szenarien auf der Planung-Seite verorten, auf „Szenarien-Seite" anpassen (grep nach „Szenari" in `hilfe.html` und Ortsangaben prüfen).
|
||||
|
||||
- [x] **Step 5: Bestehende Tests umziehen** — `tests/test_gui.py`: die Szenario-GUI-Tests (`test_szenario_gui_ende_und_einmalzahlungen`, `test_szenario_eintraege_tabelle`, Szenario-Anteile von `test_planung_hat_bearbeiten_formulare`) von `client.get("/planung")` auf `client.get("/szenarien")` umstellen. In `test_planung_hat_bearbeiten_formulare` den Scenario-Teil (Scenario-Seed + `hx-patch="/api/scenarios/`-Assertion) in einen neuen Test `test_szenarien_hat_bearbeiten_formular` auslagern, der `/szenarien` lädt; der Planung-Test behält rec/pln/loan mit `>= 3` Bearbeiten-Buttons. `test_pages_require_login`/`test_pages_render_after_login`: Pfad `/szenarien` in die jeweilige Pfadliste aufnehmen.
|
||||
|
||||
- [x] **Step 6: Suite grün**
|
||||
|
||||
Run: `.venv/bin/python -m pytest -q` → PASS
|
||||
|
||||
- [x] **Step 7: Fable-Testagent-Abnahme** (Live-Approximation: beide Seiten rendern; keine Szenario-Reste auf /planung; `toggleEdit` genau EINMAL definiert [base.html], auf beiden Seiten funktionale Edit-Formulare; keine JS-Referenzen auf entfernte Funktionen in planning.html; Nav auf allen Seiten). Erst nach VERIFIED weiter.
|
||||
|
||||
- [x] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/wlfb/bin && git add finance/app/ finance/tests/test_gui.py
|
||||
git commit -m "feat: eigene Szenarien-Seite /szenarien"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Formular-UX — Paar-Layout, Gesperrt-Kennzeichnung, klare Struktur
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/static/style.css`, `app/templates/szenarien.html`, `tests/test_gui.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `szenarien.html` aus Task 1 (Neuer-Eintrag-Formular mit Feldern kind/target_type/target_id/value/end_date/name/amount/due; `onEntryArtChange`).
|
||||
- Produces: CSS-Klassen `.entry-form`, `.entry-art`, `.value-label`; dynamisches Wert-Label in `onEntryArtChange`; abgesetzter Durchrechnen-Block.
|
||||
|
||||
- [x] **Step 1: Failing GUI-Test**
|
||||
|
||||
```python
|
||||
def test_neuer_eintrag_formular_struktur(client, db):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
db.add(Scenario(name="UX-Test", description=""))
|
||||
db.commit()
|
||||
r = client.get("/szenarien").text
|
||||
assert 'class="entry-form"' in r
|
||||
assert 'class="entry-art"' in r # Eintragsart-Zeile
|
||||
assert 'class="value-label"' in r # dynamisches Wert-Label
|
||||
assert 'Für diese Eintragsart nicht relevant' in r # Tooltip an Umschaltfeldern
|
||||
assert '<hr' in r # Durchrechnen abgesetzt
|
||||
```
|
||||
|
||||
Run: → FAIL
|
||||
|
||||
- [x] **Step 2: CSS** — `app/static/style.css` ergänzen:
|
||||
|
||||
```css
|
||||
/* Ausbaustufe 7: Formular-Paare als Einheit, gesperrte Felder erkennbar. */
|
||||
.entry-form label,
|
||||
.planning-section form label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0 1.25rem 0.6rem 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.entry-form .entry-art {
|
||||
display: block;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.entry-form .entry-art > label { font-weight: bold; }
|
||||
.entry-form .entry-art .muted { margin-left: 0.5rem; }
|
||||
input:disabled, select:disabled {
|
||||
background: #e3e3e3;
|
||||
color: #8a8a8a;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
label:has(input:disabled), label:has(select:disabled) {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.project-block {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
```
|
||||
|
||||
(Falls `style.css` bereits eine kollidierende `label`-Regel für `.planning-section` hat: die neue Regel dahinter einsortieren, Spezifität prüfen.)
|
||||
|
||||
- [x] **Step 3: Formular-Markup** — in `szenarien.html` das Neuer-Eintrag-Formular umbauen:
|
||||
- `<form … class="entry-form" …>` (Attribute inkl. `data-modifiers-url`/`data-planned-url` unverändert).
|
||||
- Statt `<strong>Neuer Eintrag:</strong>`: erste Zeile
|
||||
|
||||
```html
|
||||
<div class="entry-art">
|
||||
<label>Eintragsart
|
||||
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
|
||||
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
|
||||
<option value="einmal">Einmalzahlung</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="muted">— die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut.</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
- Wert-Label dynamisch: `<label><span class="value-label">Prozentsatz (%)</span> <input type="text" name="value" data-type="amount" value="0" title="Für diese Eintragsart nicht relevant"></label>` (Startzustand passend zur Default-Art `percent`).
|
||||
- Die umschaltbaren Felder `end_date`, `name`, `amount`, `due` sowie die beiden Ziel-Selects bekommen `title="Für diese Eintragsart nicht relevant"`.
|
||||
- Fieldset-Titel davor: `<h3>Einträge</h3>`-Tabelle bleibt; über dem Formular `<h4>Neuer Eintrag</h4>` (ersetzt das bisherige Inline-`<strong>`).
|
||||
- Durchrechnen-Formular in einen abgesetzten Block:
|
||||
|
||||
```html
|
||||
<hr>
|
||||
<div class="project-block">
|
||||
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<button type="submit">Durchrechnen</button>
|
||||
</form>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [x] **Step 4: Dynamisches Wert-Label** — in `onEntryArtChange` (szenarien.html) nach der `setDisabled('value', …)`-Zeile ergänzen:
|
||||
|
||||
```javascript
|
||||
var valueLabel = form.querySelector('.value-label');
|
||||
if (valueLabel) {
|
||||
valueLabel.textContent = art === 'percent' ? 'Prozentsatz (%)'
|
||||
: art === 'absolute' ? 'Kürzung (€)' : 'Wert';
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 5: Suite grün** — `.venv/bin/python -m pytest -q` → PASS
|
||||
|
||||
- [x] **Step 6: Fable-Testagent-Abnahme** — Live-Approximation + Handtrace: gerenderte Struktur (entry-art zuerst, Paare als `inline-flex`-Einheiten via CSS-Regeln vorhanden, Tooltips an allen Umschaltfeldern, `<hr>`+project-block), Wert-Label-Wechsel für alle 5 Arten, Startzustand konsistent (percent: Wert aktiv mit Label „Prozentsatz (%)"). Erst nach VERIFIED weiter.
|
||||
|
||||
- [x] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/wlfb/bin && git add finance/app/static/style.css finance/app/templates/szenarien.html finance/tests/test_gui.py
|
||||
git commit -m "feat: Eintrags-Formular mit Paar-Layout und Gesperrt-Kennzeichnung"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Kategorien-Verwaltung auf der Admin-Seite
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/routers/categories.py` (PATCH), `app/routers/admin.py` (Kontext), `app/templates/admin.html`
|
||||
- Modify: `tests/test_crud_api.py`, `tests/test_admin.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `CategoryIn`/`CategoryOut`, `POST /api/categories` (vorhanden); `toggleEdit` (seit Task 1 global in base.html); json-form.
|
||||
- Produces: `PATCH /api/categories/{category_id}` (404 „Kategorie nicht gefunden", 409 „Kategorie existiert bereits"); Admin-Abschnitt „Kategorien" mit IDs `cat-row-{id}`/`cat-edit-{id}`.
|
||||
|
||||
- [x] **Step 1: Failing API-Test** — `tests/test_crud_api.py`:
|
||||
|
||||
```python
|
||||
def test_category_patch(client):
|
||||
a = client.post("/api/categories", headers=H, json={"name": "Kat-A"}).json()
|
||||
b = client.post("/api/categories", headers=H, json={"name": "Kat-B"}).json()
|
||||
# Umbenennen
|
||||
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
|
||||
assert r.status_code == 200 and r.json()["name"] == "Kat-A-neu"
|
||||
# Umbenennen auf den EIGENEN Namen ist erlaubt (kein 409)
|
||||
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
|
||||
assert r.status_code == 200
|
||||
# Kollision mit anderer Kategorie -> 409
|
||||
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-B"})
|
||||
assert r.status_code == 409
|
||||
# unbekannte id -> 404
|
||||
assert client.patch("/api/categories/99999", headers=H,
|
||||
json={"name": "x"}).status_code == 404
|
||||
```
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_crud_api.py -q` → FAIL (405)
|
||||
|
||||
- [x] **Step 2: PATCH-Endpunkt** — `app/routers/categories.py`, nach `create_category`:
|
||||
|
||||
```python
|
||||
@router.patch("/categories/{category_id}", response_model=CategoryOut)
|
||||
def patch_category(category_id: int, data: CategoryIn,
|
||||
session: Session = Depends(get_session)):
|
||||
cat = session.get(Category, category_id)
|
||||
if cat is None:
|
||||
raise HTTPException(404, "Kategorie nicht gefunden")
|
||||
clash = session.execute(
|
||||
select(Category).where(Category.name == data.name)).scalar()
|
||||
if clash is not None and clash.id != category_id:
|
||||
raise HTTPException(409, "Kategorie existiert bereits")
|
||||
cat.name = data.name
|
||||
session.commit()
|
||||
session.refresh(cat)
|
||||
return CategoryOut.model_validate(cat)
|
||||
```
|
||||
|
||||
- [x] **Step 3: Failing GUI-Test** — `tests/test_admin.py` (Muster für Login dort übernehmen; falls die Datei GUI-Zugriffe anders aufbaut, an bestehende Fixtures anlehnen):
|
||||
|
||||
```python
|
||||
def test_admin_zeigt_kategorien_verwaltung(client, db):
|
||||
from app.models.tables import Category
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
db.add(Category(name="Admin-Kat"))
|
||||
db.commit()
|
||||
r = client.get("/admin").text
|
||||
assert "Kategorien" in r and "Admin-Kat" in r
|
||||
assert "Neue Kategorie anlegen" in r
|
||||
assert 'hx-patch="/api/categories/' in r
|
||||
assert 'hx-post="/api/categories"' in r
|
||||
```
|
||||
|
||||
Run: → FAIL
|
||||
|
||||
- [x] **Step 4: Admin-Kontext + Template** — `app/routers/admin.py`: in `admin_page` (und im Fehler-/Erfolgs-Re-Render von `admin_change_password`, damit der Abschnitt nie verschwindet — UX-Regel) `"categories": session.execute(select(Category)).scalars().all()` in den Template-Kontext aufnehmen (Imports `select`, `Category` ergänzen; `session`-Dependency, falls die Route noch keine hat). `admin.html`, neuer Abschnitt vor dem `<script>`:
|
||||
|
||||
```html
|
||||
<section class="admin-section">
|
||||
<h2>Kategorien</h2>
|
||||
<p class="muted">Kategorien für Buchungen, Fixposten und Szenario-Modifikatoren.
|
||||
Löschen ist bewusst nicht vorgesehen (Kategorien hängen an Buchungen und Regeln).</p>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in categories %}
|
||||
<tr id="cat-row-{{ c.id }}">
|
||||
<td>{{ c.name }}</td>
|
||||
<td><button type="button" onclick="toggleEdit('cat', {{ c.id }}, true)">Bearbeiten</button></td>
|
||||
</tr>
|
||||
<tr id="cat-edit-{{ c.id }}" hidden>
|
||||
<td colspan="2">
|
||||
<form hx-ext="json-form" hx-patch="/api/categories/{{ c.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" value="{{ c.name }}" required maxlength="100"></label>
|
||||
<button type="submit">Speichern</button>
|
||||
<button type="button" onclick="toggleEdit('cat', {{ c.id }}, false)">Abbrechen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="2">Noch keine Kategorien.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<fieldset>
|
||||
<legend>Neue Kategorie anlegen</legend>
|
||||
<form hx-ext="json-form" hx-post="/api/categories" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" required maxlength="100"></label>
|
||||
<button type="submit">Anlegen</button>
|
||||
</form>
|
||||
</fieldset>
|
||||
</section>
|
||||
```
|
||||
|
||||
- [x] **Step 5: Suite grün** — `.venv/bin/python -m pytest -q` → PASS
|
||||
|
||||
- [x] **Step 6: Fable-Testagent-Abnahme** (Live-Approximation: Anlegen → erscheint in Liste UND in den Kategorie-Dropdowns von Planung/Szenarien; Umbenennen-Roundtrip; 409 im Alert-Pfad; Admin-Fehler-Re-Render zeigt Abschnitt weiterhin). Erst nach VERIFIED weiter.
|
||||
|
||||
- [x] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/wlfb/bin && git add finance/app/routers/categories.py finance/app/routers/admin.py finance/app/templates/admin.html finance/tests/
|
||||
git commit -m "feat: Kategorien-Verwaltung auf der Admin-Seite"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Release v0.8.0 — Redeploy, Live-Check, Ledger
|
||||
|
||||
**Files:** `finance/VERSION` (`0.7.0` → `0.8.0`), `.superpowers/sdd/progress.md`, Plan-Häkchen.
|
||||
|
||||
- [x] **Step 1: Suite final** — `.venv/bin/python -m pytest -q` → alle grün.
|
||||
- [x] **Step 2: Version + Commit** — `echo "0.8.0" > finance/VERSION`, Commit `chore: Version 0.8.0`.
|
||||
- [x] **Step 3: Redeploy** — `./create_pod_finance.sh` (keine Migration). Service aktiv, Readiness 200.
|
||||
- [x] **Step 4: Live-Smoke** — `/api/version` == 0.8.0; Nav zeigt „Szenarien"; `/szenarien` zeigt Best Case mit Einträgen; `/planung` ohne Szenarien; `/admin` mit Kategorien-Abschnitt.
|
||||
- [x] **Step 5: Fable-Testagent-Abnahme (Release-Gate)** — Live: Wegwerf-Kategorie „SMOKE-A7" anlegen → umbenennen → in Dropdowns sichtbar (bleibt stehen, Hinweis im Bericht — Kategorien sind nicht löschbar; Namenswahl „zz-Smoke" damit sie unten einsortiert? Nein: Kategorie „SMOKE-A7" wird nach dem Test per direktem psql-DELETE entfernt, NUR wenn keine FK-Referenzen existieren — vorher COUNT-Checks auf transactions/category_rules/recurring_items/scenario_modifiers); Best-Case-Einträge unverändert; Formular-Struktur auf /szenarien (entry-art, Tooltips, hr). Erst nach VERIFIED weiter.
|
||||
- [x] **Step 6: Ledger + Plan-Häkchen + Push.**
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (beim Planschreiben)
|
||||
|
||||
- **Spec-Abdeckung:** Seite → Task 1; UX/CSS/Label/Durchrechnen → Task 2; Kategorien (PATCH + Admin-GUI) → Task 3; Release → Task 4. Vollständig.
|
||||
- **Platzhalter:** Der Template-Umzug in Task 1 Step 3 ist bewusst als präziser Move beschrieben (Quelle: bestehende Szenarien-Sektion) statt als 200-Zeilen-Duplikat — die Quelle ist eindeutig benannt und unverändert zu übernehmen.
|
||||
- **Typ-Konsistenz:** `toggleEdit` global (Task 1) wird von Task 3 (`cat-`-Präfix) vorausgesetzt; `entry-form`/`entry-art`/`value-label`-Klassen konsistent zwischen CSS (Task 2 Step 2) und Markup (Step 3) und Test (Step 1).
|
||||
196
docs/superpowers/plans/2026-07-20-ausbaustufe-9.md
Normal file
196
docs/superpowers/plans/2026-07-20-ausbaustufe-9.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Ausbaustufe 9 Implementation Plan — Vorschlags-Algorithmus v2 (v0.9.0)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||
|
||||
**Goal:** `suggest_recurring` erkennt monatliche/vierteljährliche/jährliche Serien mit letztem Betrag, Aktiv-Check, Betrags-Clustern, Umfirmierungs-Merge und robustem Bestandsabgleich; GUI zeigt Rhythmus/Start/Hinweis; Release v0.9.0 mit Live-Gate gegen die echten Daten.
|
||||
|
||||
**Architecture:** Vollständiger Rewrite von `app/services/suggestions.py` (reine Session-in/dict-out-Funktion, Parameter als Modul-Konstanten); `SuggestionOut`-Erweiterung in `routers/planning.py`; Template-Anpassung der Vorschlags-Tabelle. Kein Datenmodell-/Migrationsbedarf.
|
||||
|
||||
**Tech Stack:** SQLAlchemy 2, Pydantic v2, Jinja2, pytest (synthetische Daten).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md` — die dortigen Abschnitte „Algorithmus" (8 Schritte, Konstanten) und „Tests" sind bindend und Teil dieses Plans.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Beträge `Decimal` (keine float-Arithmetik, auch nicht in Toleranzvergleichen — relative Differenzen als `Decimal`-Quotienten).
|
||||
- `date.today()` nur an EINER Stelle (Parameter `today: date | None = None` der Hauptfunktion, Default heute) — Tests injizieren ein festes Datum.
|
||||
- Tests ausschließlich mit synthetischen Daten (DATENSCHUTZ: keine echten Namen/Beträge aus der Live-DB in Tests/Commits).
|
||||
- GUI deutsch, TT.MM.JJJJ, `|eur`, `|de_label`; API Punkt-Dezimal.
|
||||
- Fable-Testagent-Gate je Task VOR Commit; Ledger-Eintrag je Task.
|
||||
- Testlauf: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` — Basis 187 passed, muss grün bleiben (drei bestehende Suggestion-Tests DÜRFEN an die neue Semantik angepasst werden, siehe Task 1 Step 4).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Algorithmus-Rewrite + API-Schema
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `app/services/suggestions.py`
|
||||
- Modify: `app/routers/planning.py` (`SuggestionOut`)
|
||||
- Modify: `tests/test_planning_api.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `suggest_recurring(session, today: date | None = None) -> list[dict]` mit Keys `name, amount (Decimal), rhythm, due_day, start_date (date|None), category_id, hinweis (str)`; `SuggestionOut` mit denselben Feldern (`start_date: date | None = None`, `hinweis: str = ""`). Task 2 rendert genau diese Felder.
|
||||
|
||||
- [x] **Step 1: Failing Tests** — in `tests/test_planning_api.py` die drei bestehenden Suggestion-Tests ERSETZEN/ERWEITERN durch die Spec-Fälle (Helper zum Anlegen synthetischer Buchungen schreiben; `dedup_hash` eindeutig, `status="confirmed"`; ein Account genügt; `today=date(2026, 7, 20)` in alle Aufrufe injizieren):
|
||||
|
||||
```python
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
def _tx(db, acc_id, d, amount, cp, cat=None):
|
||||
db.add(Transaction(account_id=acc_id, booking_date=d, amount=Decimal(amount),
|
||||
purpose="p", counterparty=cp, category_id=cat,
|
||||
status="confirmed", dedup_hash=f"h-{cp}-{d}-{amount}"))
|
||||
|
||||
TODAY = date(2026, 7, 20)
|
||||
|
||||
def test_suggest_letzter_betrag_bei_preiserhoehung(db):
|
||||
acc = _acc(db) # Helper: Account anlegen, gibt id zurueck
|
||||
for d, a in [(date(2026, 3, 1), "-190.65"), (date(2026, 4, 1), "-202.94"),
|
||||
(date(2026, 5, 4), "-202.94"), (date(2026, 6, 1), "-202.94"),
|
||||
(date(2026, 7, 1), "-202.94")]:
|
||||
_tx(db, acc, d, a, "Entis Lebensversicherung AG")
|
||||
db.commit()
|
||||
out = suggest_recurring(db, today=TODAY)
|
||||
assert len(out) == 1
|
||||
s = out[0]
|
||||
assert s["amount"] == Decimal("-202.94") and s["rhythm"] == "monthly"
|
||||
assert s["due_day"] == 1 and s["start_date"] is None
|
||||
|
||||
def test_suggest_quartal_mit_phase(db):
|
||||
acc = _acc(db)
|
||||
for d in [date(2025, 9, 15), date(2025, 12, 15), date(2026, 3, 16), date(2026, 6, 15)]:
|
||||
_tx(db, acc, d, "-55.08", "Rundfunk ARD ZDF")
|
||||
db.commit()
|
||||
out = suggest_recurring(db, today=TODAY)
|
||||
assert len(out) == 1
|
||||
assert out[0]["rhythm"] == "quarterly"
|
||||
assert out[0]["start_date"] == date(2026, 6, 15) and out[0]["due_day"] == 15
|
||||
|
||||
def test_suggest_jahr_mit_zwei_belegen(db):
|
||||
acc = _acc(db)
|
||||
for d, a in [(date(2025, 6, 16), "-409.92"), (date(2026, 6, 16), "-467.33")]:
|
||||
_tx(db, acc, d, a, "Kraftfahrer-Schutz e.V.")
|
||||
db.commit()
|
||||
out = suggest_recurring(db, today=TODAY)
|
||||
assert len(out) == 1
|
||||
assert out[0]["rhythm"] == "yearly" and out[0]["amount"] == Decimal("-467.33")
|
||||
assert out[0]["start_date"] == date(2026, 6, 16)
|
||||
assert "409.92" in out[0]["hinweis"] # Betrag zuletzt gestiegen
|
||||
|
||||
def test_suggest_tote_serie_kein_vorschlag(db):
|
||||
acc = _acc(db)
|
||||
for m in (9, 10, 11, 12):
|
||||
_tx(db, acc, date(2025, m, 1), "-35.00", "WWK Alt")
|
||||
db.commit()
|
||||
assert suggest_recurring(db, today=TODAY) == []
|
||||
|
||||
def test_suggest_umfirmierung_merge(db):
|
||||
acc = _acc(db)
|
||||
for m in (11, 12):
|
||||
_tx(db, acc, date(2025, m, 1), "-190.65", "Heidelberger Leben")
|
||||
for m in (1, 2, 3):
|
||||
_tx(db, acc, date(2026, m, 2), "-190.65", "Entis Lebensversicherung")
|
||||
for m in (4, 5, 6, 7):
|
||||
_tx(db, acc, date(2026, m, 1), "-202.94", "Entis Lebensversicherung")
|
||||
db.commit()
|
||||
out = suggest_recurring(db, today=TODAY)
|
||||
assert len(out) == 1
|
||||
assert "Entis" in out[0]["name"] and out[0]["amount"] == Decimal("-202.94")
|
||||
|
||||
def test_suggest_bestandsabgleich_trotz_preisdrift(db):
|
||||
acc = _acc(db)
|
||||
db.add(RecurringItem(name="Entis Lebensversicherung AG", amount=Decimal("-190.65"),
|
||||
rhythm="monthly", due_day=1))
|
||||
for m in (4, 5, 6, 7):
|
||||
_tx(db, acc, date(2026, m, 1), "-202.94", "Entis Lebensversicherung AG")
|
||||
db.commit()
|
||||
assert suggest_recurring(db, today=TODAY) == [] # Namens-Match schlaegt an
|
||||
|
||||
def test_suggest_zwei_vertraege_getrennt(db):
|
||||
acc = _acc(db)
|
||||
for m in (4, 5, 6, 7):
|
||||
_tx(db, acc, date(2026, m, 1), "-346.23", "Heidelberger LV")
|
||||
_tx(db, acc, date(2026, m, 2), "-145.21", "Heidelberger LV")
|
||||
db.commit()
|
||||
out = suggest_recurring(db, today=TODAY)
|
||||
assert len(out) == 2
|
||||
assert {s["amount"] for s in out} == {Decimal("-346.23"), Decimal("-145.21")}
|
||||
```
|
||||
|
||||
(`_acc`-Helper analog bestehender Tests; `RecurringItem`/`Transaction`-Importe existieren.) Die drei Alt-Tests (`three_consecutive_months_with_year_wrap`, `two_months_no_suggestion`, `excludes_existing_recurring_item`) an die neue Signatur/Semantik anpassen: feste `today`-Injektion; Daten ggf. ins Fenster schieben; der Exclusion-Test bleibt inhaltlich gültig (Name-Match).
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_planning_api.py -q` → neue Tests FAIL
|
||||
|
||||
- [x] **Step 2: Rewrite `app/services/suggestions.py`** gemäß Spec-Abschnitt „Algorithmus" (8 Schritte, Konstanten `WINDOW_DAYS=460`, Rhythmus-Tabelle monthly 25-36/≥3, quarterly 80-105/≥3, yearly 330-400/≥2, `STEP_DAYS={"monthly":30,"quarterly":91,"yearly":365}`, `ACTIVITY_FACTOR` 7/4 als `Fraction` oder Tage-Vergleich ganzzahlig, Cluster 35 %, Merge 25 %, Bestand 10 % — alle Toleranzvergleiche als `Decimal`). Struktur: `_norm`, `_rel_diff`, `_amount_clusters` (greedy gegen letztes Mitglied, gleiches Vorzeichen), `_classify` (Median der Abstände), Merge-Pass je Konto über alle Serien, `_covered_by_existing`, Hauptfunktion `suggest_recurring(session, today=None)`. Deutsche Docstrings/Kommentare zur Begründung der Toleranzen.
|
||||
|
||||
- [x] **Step 3: `SuggestionOut` erweitern** — `routers/planning.py`:
|
||||
|
||||
```python
|
||||
class SuggestionOut(BaseModel):
|
||||
name: str
|
||||
amount: Decimal
|
||||
rhythm: str
|
||||
due_day: int
|
||||
start_date: date | None = None
|
||||
category_id: int | None = None
|
||||
hinweis: str = ""
|
||||
```
|
||||
|
||||
- [x] **Step 4: Tests + Suite grün** — `.venv/bin/python -m pytest -q` → PASS (Alt-Test-Anpassungen im Report begründen).
|
||||
|
||||
- [x] **Step 5: Fable-Testagent-Abnahme** (Faktencheck: Toleranz-Arithmetik Decimal-rein; Aktiv-Check-Grenzen; Merge-Bedingungen; keine `date.today()`-Streuung; Alt-Test-Anpassungen berechtigt). Erst nach VERIFIED weiter.
|
||||
|
||||
- [x] **Step 6: Commit** — `git add finance/app/services/suggestions.py finance/app/routers/planning.py finance/tests/test_planning_api.py && git commit -m "feat: Vorschlags-Algorithmus v2 (Rhythmen, letzter Betrag, Aktiv-Check, Merge)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: GUI — Rhythmus/Start/Hinweis in der Vorschlags-Tabelle
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/templates/planning.html` (Fieldset „Vorschläge aus Buchungen")
|
||||
- Modify: `tests/test_gui.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `SuggestionOut`-Felder aus Task 1; Filter `|eur`/`|de_label`.
|
||||
|
||||
- [x] **Step 1: Failing GUI-Test** — in `tests/test_gui.py` (synthetische Serie seeden, `/planung` laden):
|
||||
|
||||
```python
|
||||
def test_vorschlaege_zeigen_rhythmus_und_start(client, db):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
acc = Account(bank="dkb", iban="DE-SUG-1", name="S", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
for d in (date(2025, 9, 15), date(2025, 12, 15), date(2026, 3, 16), date(2026, 6, 15)):
|
||||
db.add(Transaction(account_id=acc.id, booking_date=d, amount=Decimal("-55.08"),
|
||||
purpose="p", counterparty="Rundfunk Synth", status="confirmed",
|
||||
dedup_hash=f"sug-{d}"))
|
||||
db.commit()
|
||||
r = client.get("/planung").text
|
||||
assert "vierteljährlich" in r # de_label des Rhythmus
|
||||
assert "15.06.2026" in r # Start-Spalte TT.MM.JJJJ
|
||||
assert 'name="start_date"' in r # hidden input der Uebernahme
|
||||
```
|
||||
|
||||
WICHTIG: Der Test hängt von `date.today()` der App ab (Aktiv-Check!) — Serie so legen, dass sie um den echten Testlauf-Zeitpunkt herum aktiv ist, oder (besser) `suggest_recurring` in `planung_page` unverändert lassen und den Test mit relativen Daten um `date.today()` konstruieren (letzte Buchung ≤ 45 Tage vor heute, Quartalsschritte rückwärts). Die Variante mit relativen Daten umsetzen; die obigen Fixdaten sind als Muster zu verstehen und auf `date.today()`-relative Werte umzustellen (inkl. erwartetem Start-String via `.strftime('%d.%m.%Y')`).
|
||||
|
||||
- [x] **Step 2: Template** — Vorschlags-Tabelle: Kopf `Name | Betrag | Rhythmus | Fälligkeitstag | Start | (Aktion)`; Zellen `{{ s.rhythm|de_label }}`, `{{ s.start_date.strftime('%d.%m.%Y') if s.start_date else '–' }}`; Betrag-Zelle ergänzt `{% if s.hinweis %}<span class="muted">{{ s.hinweis }}</span>{% endif %}`; Übernahme-Formular: hidden inputs unverändert plus `<input type="hidden" name="start_date" value="{{ s.start_date.isoformat() if s.start_date else '' }}">` (json-form macht leer → null). Hinweistext unter dem Fieldset: „Erkannt werden monatliche, vierteljährliche und jährliche Serien; Betrag = jeweils letzte Buchung."
|
||||
|
||||
- [x] **Step 3: Suite grün**; **Step 4: Fable-Abnahme** (Live-Approximation: Rendering + Übernahme-Roundtrip eines Quartals-Vorschlags inkl. start_date); **Step 5: Commit** `feat: Vorschlaege mit Rhythmus, Start und Hinweis`.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Release v0.9.0 + Live-Gate gegen echte Daten
|
||||
|
||||
- [x] **Step 1:** Suite final; `VERSION` → 0.9.0; Commit; `./create_pod_finance.sh`; `/api/version` == 0.9.0.
|
||||
- [x] **Step 2: Fable-Release-Gate (LIVE, lesend):** `GET /api/recurring/suggestions` gegen die echte DB. Prüfen: (a) KEINER der bestehenden ~41 Fixposten wird erneut vorgeschlagen (Bestandsabgleich wirkt, auch bei gedrifteten Beträgen); (b) keine bekannten toten Serien (gelöschte PayPal-−4,99-Serie, ausgelaufene WWK-−35-Police) im Ergebnis; (c) verbleibende Vorschläge einzeln gegen die Buchungen plausibilisieren (echte aktive Serie? korrekte Werte?). Ergebnisliste NUR im Chat/Bericht, nie committen. Bei Fehlklassifikationen: Befund zurück an Task 1 (Toleranzen), Fix + Re-Gate.
|
||||
- [x] **Step 3:** Ledger (generisch) + Plan-Häkchen + Push; Kandidatenliste dem Nutzer berichten.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec-Abdeckung: Algorithmus/Schema → Task 1; GUI → Task 2; Release/Live-Gate → Task 3. Testfälle der Spec vollständig in Task 1 Step 1 kodiert.
|
||||
- Platzhalter: Task 1 Step 2 verweist bewusst auf den bindenden Spec-Abschnitt (8 nummerierte Schritte + Konstanten) statt den vollen Code zu duplizieren; alle Schnittstellen/Konstanten sind exakt benannt.
|
||||
- Typ-Konsistenz: `suggest_recurring(session, today)`-Signatur = Testaufrufe; `SuggestionOut`-Felder = Template-Zugriffe (`s.rhythm`, `s.start_date`, `s.hinweis`).
|
||||
157
docs/superpowers/specs/2026-07-20-ausbaustufe-5-design.md
Normal file
157
docs/superpowers/specs/2026-07-20-ausbaustufe-5-design.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Design — Ausbaustufe 5: Deutsche Formate, Posten-Bearbeitung, Szenario-Ende (v0.6.0)
|
||||
|
||||
> Status: vom Nutzer freigegeben (Chat 2026-07-20). Umsetzungsplan folgt unter
|
||||
> `docs/superpowers/plans/`. Datenschutz-Regel aus `CLAUDE.md` gilt: konkrete
|
||||
> Empfängernamen/Beträge des Demo-Szenarios stehen nur im Chat und in der
|
||||
> Live-Datenbank, nicht in diesem Dokument, nicht in Commits, nicht im Ledger.
|
||||
|
||||
## Ziel
|
||||
|
||||
Drei Nutzeranforderungen an die Web-GUI des Finanzberatungs-Tools:
|
||||
|
||||
1. **Deutsche Formate** — Beträge werden deutsch angezeigt (`1.234,56 €`)
|
||||
und deutsch eingegeben (Komma-Dezimaltrenner); englische GUI-Begriffe
|
||||
(`monthly`, `annuity`, `percent`, …) werden übersetzt.
|
||||
2. **Wiederkehrende Posten** — Felder `Start`/`Ende` in der GUI sichtbar und
|
||||
pflegbar (Modell/API/Engine können das bereits); jeder Posten bekommt
|
||||
einen `Bearbeiten`-Button. Inline-Bearbeitung zusätzlich für
|
||||
Einmalposten, Kredite und den Szenario-Kopf (Nutzerentscheidung).
|
||||
3. **Szenarien** — Modifikator-Art `Ende` (Posten endet innerhalb des
|
||||
Szenarios an einem Datum, ohne den Posten selbst zu ändern) und
|
||||
szenario-eigene Einmalzahlungen. Demo-Szenario „Best Case" wird nach dem
|
||||
Release live angelegt und durchgerechnet.
|
||||
|
||||
## Nutzerentscheidungen (Chat 2026-07-20)
|
||||
|
||||
- Szenario-Ende als **neue Modifikator-Art `ende`** mit Datumsfeld (nicht
|
||||
als Gültigkeitszeitraum aller Modifikatoren).
|
||||
- Einmalzahlung im Demo-Szenario ist **szenario-spezifisch** → neues Feature
|
||||
„Szenario-Einmalzahlungen".
|
||||
- Formate: **Zahlen + deutsche Begriffe** (API-Werte bleiben englisch).
|
||||
- Bearbeiten-Buttons für: wiederkehrende Posten, Einmalposten, Kredite,
|
||||
Szenario-Kopf.
|
||||
|
||||
## Teil 1 — Deutsche Formate
|
||||
|
||||
**Anzeige.** Neuer Jinja-Filter `eur` in `app/routers/gui.py`
|
||||
(`templates.env.filters["eur"]`), reine `Decimal`-Formatierung ohne
|
||||
`locale`-Modul (Container-Locale unzuverlässig): Vorzeichen, Tausenderpunkt,
|
||||
Komma, zwei Nachkommastellen. Ersetzt alle `'%.2f'|format(...)`-Stellen in
|
||||
`index.html`, `salden.html`, `transactions.html`, `planning.html`,
|
||||
`_preview_table.html`. Prozentwerte (Kredit-Zins) analog mit Komma.
|
||||
Der per JavaScript nachgeladene Tilgungsplan (`planning.html`,
|
||||
`loadLoanSchedule`) formatiert mit
|
||||
`toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2})`.
|
||||
|
||||
**Eingabe.** Die json-form-Extension in `base.html` lernt
|
||||
`data-type="amount"`: Whitespace/€ entfernen; enthält der Wert ein Komma →
|
||||
Tausenderpunkte entfernen, Komma→Punkt; ohne Komma bleibt der Wert
|
||||
unverändert (Punkt-Eingaben funktionieren weiter). Alle
|
||||
Betrags-`<input>`-Felder (Fixposten, Einmalposten, Kredit, Modifikator-Wert,
|
||||
Szenario-Einmalzahlung) bekommen `data-type="amount"` und deutsche
|
||||
Platzhalter (`-49,99`). Versteckte Felder der Vorschlags-Übernahme bleiben
|
||||
ohne `data-type="amount"` (Server liefert Punktformat). Die JSON-API bleibt
|
||||
strikt Punkt-Dezimal — Tests, Skripte und Grafana sind nicht betroffen.
|
||||
|
||||
**Begriffe.** Zentrales Mapping als Jinja-Global `de_label` (Definition in
|
||||
`gui.py`): `monthly→monatlich`, `quarterly→vierteljährlich`,
|
||||
`yearly→jährlich`, `annuity→Annuität`, `bullet→endfällig`,
|
||||
`percent→Prozent`, `absolute→Absolut`, `remove→Entfällt`, `ende→Ende`.
|
||||
Dropdown-`<option>`-Texte und Tabellenzellen zeigen deutsche Labels;
|
||||
`value`-Attribute, API-Literale und DB-Werte bleiben englisch (keine
|
||||
Migration, keine API-Änderung). Unbekannte Werte fallen auf den Rohwert
|
||||
zurück.
|
||||
|
||||
## Teil 2 — Posten-Bearbeitung + Start/Ende
|
||||
|
||||
`RecurringItem.start_date/end_date` existieren in Modell, API
|
||||
(`RecurringIn`/`RecurringPatch`) und Engine (`recurrence.occurrences`
|
||||
klammert das Fenster bereits). Es fehlt nur GUI:
|
||||
|
||||
- Tabelle „Wiederkehrende Posten": neue Spalten **Start** und **Ende**
|
||||
(TT.MM.JJJJ, leer = „–").
|
||||
- Anlege-Formular: zwei optionale `<input type="date">`-Felder.
|
||||
- **Inline-Bearbeiten** je Zeile (Muster für alle vier Bereiche):
|
||||
`Bearbeiten`-Button blendet eine vorbefüllte Formularzeile ein
|
||||
(Anzeigezeile wird versteckt, nicht entfernt — UX-Regel aus `CLAUDE.md`
|
||||
bleibt gewahrt: Bedienelemente sichtbar/disabled, Fehler via bestehendem
|
||||
`htmx:responseError`-Handler), `Speichern` sendet `PATCH` über die
|
||||
json-form-Extension, `Abbrechen` klappt zurück. Leeres Datumsfeld wird als
|
||||
`null` gesendet (Extension tut das bereits) und löscht den Wert.
|
||||
- Bereiche: `PATCH /api/recurring/{id}` (alle Felder),
|
||||
`PATCH /api/planned/{id}` (Name, Betrag, Fälligkeit, Kategorie),
|
||||
`PATCH /api/loans/{id}` (alle Felder),
|
||||
`PATCH /api/scenarios/{id}` (Name, Beschreibung, include-Flags).
|
||||
- Neue Validierung in `RecurringIn`/`RecurringPatch` (Pydantic
|
||||
model_validator): `end_date ≥ start_date`, wenn beide gesetzt (beim PATCH
|
||||
gegen den resultierenden Zustand geprüft), sonst 422 mit deutscher
|
||||
Meldung.
|
||||
|
||||
## Teil 3 — Szenarien: Art `ende` + Szenario-Einmalzahlungen
|
||||
|
||||
Eine Alembic-Migration (`scenario_ende`) mit zwei Änderungen:
|
||||
|
||||
1. `scenario_modifiers.end_date` — `Date`, nullable.
|
||||
2. Neue Tabelle `scenario_planned_items`: `id`, `scenario_id`
|
||||
(FK `scenarios.id`), `name` (String 200), `amount` (`MONEY`), `due`
|
||||
(`Date`).
|
||||
|
||||
**Modifikator-Art `ende`.**
|
||||
|
||||
- API: `ModifierIn.kind` um Literal `ende` erweitert; neues optionales Feld
|
||||
`end_date: date | None`. Validator: `ende` erfordert `end_date`; andere
|
||||
Arten ignorieren es (wird genullt). `ModifierOut` liefert `end_date` mit.
|
||||
- Engine: `PlainModifier` bekommt `end_date: date | None = None`.
|
||||
`build_cashflows` bestimmt vor `occurrences` je Posten das effektive Ende:
|
||||
Minimum aus eigenem `end_date` und allen treffenden `ende`-Modifikatoren
|
||||
(Ziel `recurring` oder `category` — bei Kategorie enden alle Posten der
|
||||
Kategorie). `_modified` behandelt weiterhin nur die Betrags-Arten und
|
||||
überspringt `ende`.
|
||||
- GUI (`planning.html`): Art-Dropdown bekommt „Ende"; ein Datumsfeld wird
|
||||
nur bei dieser Art aktiviert (disabled-Umschalter analog
|
||||
`onModTargetTypeChange`), das Wert-Feld ist bei `ende` disabled.
|
||||
Modifikator-Tabelle zeigt das Datum.
|
||||
|
||||
**Szenario-Einmalzahlungen.**
|
||||
|
||||
- Endpunkte in `routers/scenarios.py`:
|
||||
`GET/POST /api/scenarios/{id}/planned` (201, Name/Betrag/Datum),
|
||||
`DELETE /api/scenarios/{id}/planned/{item_id}` (404 bei fremder
|
||||
scenario_id, Muster `delete_modifier`).
|
||||
- `run_projection` lädt `scenario_planned_items` des Szenarios und hängt sie
|
||||
als `PlainPlanned` an — **unabhängig** von `include_planned` (das steuert
|
||||
nur globale Einmalposten; szenario-eigene Zahlungen sind bewusst diesem
|
||||
Szenario zugeordnet).
|
||||
- `delete_scenario` löscht zusätzlich `scenario_planned_items` (kein
|
||||
DB-Cascade vorhanden, Muster der bestehenden Deletes).
|
||||
- GUI: je Szenario ein `<details>` „Einmalzahlungen in diesem Szenario"
|
||||
(Tabelle Name/Betrag/Datum + Löschen, Anlege-Formular).
|
||||
|
||||
## Teil 4 — Demo „Best Case", Release, Tests
|
||||
|
||||
**Demo (live, nach Redeploy):** Szenario „Best Case" anlegen; für die beiden
|
||||
bestehenden Miet-Fixposten (falls nicht vorhanden: vorher anlegen bzw. aus
|
||||
Vorschlägen übernehmen) je einen `ende`-Modifikator mit den im Chat
|
||||
genannten Terminen setzen; szenario-eigene Einmalzahlung mit dem im Chat
|
||||
genannten Betrag/Datum anlegen; Projektion durchrechnen. Anschließend
|
||||
Schritt-für-Schritt-Erklärung der Eingabe im Chat. `hilfe.html` bekommt
|
||||
einen kurzen Abschnitt zu Szenario-Varianten (generisch, ohne echte Daten).
|
||||
|
||||
**Release:** `finance/VERSION` → `0.6.0` vor `./create_pod_finance.sh`
|
||||
(Migration läuft im Entrypoint via `alembic upgrade head`).
|
||||
|
||||
**Tests (TDD, Suite bleibt komplett grün):**
|
||||
|
||||
- `eur`-Filter: Rundung, Tausenderpunkte, negative Werte, `Decimal`-Input.
|
||||
- Engine: `ende`-Modifikator (Ziel Posten/Kategorie, Zusammenspiel mit
|
||||
eigenem `end_date`, Minimum-Regel), Szenario-Einmalzahlung im Fenster.
|
||||
- API: Modifier-Validierung (`ende` ohne Datum → 422), CRUD
|
||||
Szenario-Einmalzahlungen, Cleanup bei `delete_scenario`,
|
||||
`end ≥ start`-Validierung bei Fixposten.
|
||||
- GUI-Tests: deutsche Betragsformatierung in gerenderten Seiten, neue
|
||||
Spalten/Formulare vorhanden, deutsche Labels statt englischer Rohwerte.
|
||||
- Migration: Upgrade-Pfad in Wegwerf-DB (Muster Konto-Anker-Task).
|
||||
|
||||
**Außerhalb des Scopes:** Grafana-Dashboards (formatiert selbst),
|
||||
PDF/CSV-Parser (parsen deutsche Formate bereits), Übersetzung von
|
||||
API-Fehlertexten Dritter, Mehrsprachigkeit.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Design — Ausbaustufe 7: Szenarien-Seite, Formular-UX, Kategorien-Verwaltung (v0.8.0)
|
||||
|
||||
> Status: vom Nutzer freigegeben (Chat 2026-07-20, inkl. Direktdurchlauf).
|
||||
> Anlass: Nutzertest von v0.7.0 — vier Befunde: (1) Eintragsart
|
||||
> „Einmalzahlung" im Formular nicht auffindbar (Layout verschleiert, welches
|
||||
> Dropdown was steuert), (2) irrelevantes Kategorie-Dropdown bei
|
||||
> Einmalzahlung + keine GUI zum Anlegen von Kategorien, (3) Label/Feld-Paare
|
||||
> zerfließen über Zeilen, „Durchrechnen" klebt am Formular, (4) Felder
|
||||
> „Wert" vs. „Betrag" unverständlich, gesperrte Felder nicht als gesperrt
|
||||
> erkennbar.
|
||||
|
||||
## Nutzerentscheidungen
|
||||
|
||||
- Szenarien auf **eigene Seite** `/szenarien` (Nav-Punkt zwischen Planung
|
||||
und Admin); Planung behält Fixposten/Einmalposten/Kredite.
|
||||
- Nicht zur Eintragsart passende Felder bleiben **sichtbar, aber gesperrt
|
||||
und optisch deutlich gekennzeichnet** (grau gefüllt, gedimmt) — keine
|
||||
Ausblendung.
|
||||
- Kategorien-Verwaltung auf der **Admin-Seite** (anlegen + umbenennen,
|
||||
bewusst kein Löschen — FK-Abhängigkeiten zu Buchungen/Regeln/Posten/
|
||||
Modifikatoren wären ein eigenes Thema).
|
||||
- Kein „Keine"-Eintrag im Kategorie-Dropdown des Eintrags-Formulars
|
||||
(Kategorie-Modifikator braucht zwingend ein Ziel).
|
||||
|
||||
## 1. Szenarien-Seite
|
||||
|
||||
- `routers/gui.py`: neue Route `GET /szenarien` (`szenarien_page`,
|
||||
`gui_session`-geschützt) mit dem Szenario-Kontext (scenario_rows, loans,
|
||||
categories/category_names, recurring/recurring_names, modifier_kinds);
|
||||
`planung_page` verliert scenario_rows/modifier_kinds.
|
||||
- Neues Template `szenarien.html` (extends base): komplette bisherige
|
||||
Szenarien-`<section>` aus `planning.html` (Kopf+Edit, Kredite zuordnen,
|
||||
Einträge-Tabelle, Neuer Eintrag, Durchrechnen, Ergebnis, Neues Szenario)
|
||||
plus die szenario-spezifischen JS-Helfer (`onModTargetTypeChange`,
|
||||
`onEntryArtChange`, `toggleScenarioLoan`).
|
||||
- `toggleEdit` (von Planung UND Szenarien gebraucht) zieht in den zentralen
|
||||
Script-Block von `base.html`.
|
||||
- Nav in `base.html`: „Szenarien" zwischen „Planung" und „Admin".
|
||||
- `hilfe.html`: Verweise „auf der Planung-Seite" für Szenarien anpassen.
|
||||
|
||||
## 2. Formular-UX (style.css + Templates)
|
||||
|
||||
- **Paar-Layout:** Regel für Formular-Labels der Planungs-/Szenarien-
|
||||
Formulare: `label` als `inline-flex`-Einheit (Label + Feld in einer
|
||||
Zeile, `white-space: nowrap`, `gap`), Paare mit Außenabstand voneinander
|
||||
getrennt. Gilt automatisch auch für die Bearbeiten-Formulare (gleiches
|
||||
Markup).
|
||||
- **Gesperrt-Kennzeichnung:** `input:disabled, select:disabled` → graue
|
||||
Füllung, gedimmter Text, `cursor: not-allowed`; `label:has(:disabled)` →
|
||||
reduzierte Deckkraft + `title`-Tooltip im Markup der umschaltbaren Felder
|
||||
(„Für diese Eintragsart nicht relevant").
|
||||
- **Struktur „Neuer Eintrag":** Eintragsart als erste, eigene Zeile mit
|
||||
fettem Label und Hinweistext („die passenden Felder werden aktiv");
|
||||
danach die Feldpaare. Dynamisches Wert-Label in `onEntryArtChange`:
|
||||
percent → „Prozentsatz (%)", absolute → „Kürzung (€)", sonst „Wert" —
|
||||
Label-Text via `<span class="value-label">`.
|
||||
- **„Durchrechnen"** in eigenem, per Abstand/`<hr>` abgesetztem Block.
|
||||
|
||||
## 3. Kategorien-Verwaltung (Admin)
|
||||
|
||||
- Neuer Endpunkt `PATCH /api/categories/{category_id}` in
|
||||
`routers/categories.py`: Body `CategoryIn`, 404 „Kategorie nicht
|
||||
gefunden", 409 „Kategorie existiert bereits" bei Namenskollision mit
|
||||
anderer Kategorie, Antwort `CategoryOut`.
|
||||
- `admin.html`: Abschnitt „Kategorien" — Tabelle (Name + Bearbeiten/
|
||||
Inline-Umbenennen nach `toggleEdit`-Muster, IDs `cat-row-{id}`/
|
||||
`cat-edit-{id}`) + Formular „Neue Kategorie anlegen" (`POST
|
||||
/api/categories` via json-form). Kein Löschen.
|
||||
- `routers/admin.py::admin_page` lädt die Kategorienliste in den Kontext.
|
||||
|
||||
## 4. Tests / Release
|
||||
|
||||
- GUI-Tests: `/szenarien` rendert Szenario-Inhalte, ist login-geschützt und
|
||||
in der Nav; `/planung` enthält KEINE Szenarien-Sektion mehr (bestehende
|
||||
Szenario-GUI-Tests auf `/szenarien` umziehen); Admin-Seite zeigt
|
||||
Kategorien-Abschnitt.
|
||||
- API-Tests: PATCH Kategorie (Erfolg, 404, 409-Kollision, Umbenennung auf
|
||||
eigenen Namen erlaubt).
|
||||
- CSS ist nicht automatisiert testbar → Fable-Gate prüft die gerenderte
|
||||
Struktur (Klassen/Tooltips/Label-Spans) und macht den Live-Check.
|
||||
- `VERSION` → 0.8.0, Redeploy, Live-Check (Szenarien-Seite mit Best Case,
|
||||
Kategorien-Anlage+Umbenennung live mit Wegwerf-Kategorie).
|
||||
|
||||
**Außerhalb des Scopes:** Kategorie-Löschen/-Zusammenführen, Regel-Pflege-
|
||||
GUI, Änderungen an Engine/Projektion/Datenmodell (keine Migration).
|
||||
103
docs/superpowers/specs/2026-07-20-szenario-eintraege-design.md
Normal file
103
docs/superpowers/specs/2026-07-20-szenario-eintraege-design.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Design — Ausbaustufe 6: Szenario-Einträge-Tabelle (v0.7.0)
|
||||
|
||||
> Status: vom Nutzer freigegeben (Chat 2026-07-20, inkl. Freigabe zum
|
||||
> Direktdurchlauf Spec → Plan → Umsetzung). Anlass: Nutzerfeedback nach
|
||||
> v0.6.0 — die Szenario-Änderungen (Modifikatoren, Einmalzahlungen) waren
|
||||
> auf zwei zugeklappte `<details>`-Tabellen verteilt und nicht editierbar;
|
||||
> erwartet wurde EINE editierbare Tabelle aller Einträge (Zielbild: 15–20
|
||||
> Änderungen pro Szenario bequem pflegen).
|
||||
|
||||
## Ziel
|
||||
|
||||
Pro Szenario eine **immer sichtbare Tabelle „Einträge"**, die Modifikatoren
|
||||
und szenario-eigene Einmalzahlungen gemeinsam zeigt, mit Inline-Bearbeiten
|
||||
und Löschen je Zeile und einem gemeinsamen Anlege-Formular mit
|
||||
Eintragsart-Umschaltung.
|
||||
|
||||
## Entscheidungen
|
||||
|
||||
- **Datenmodell unverändert.** „Eintrag" ist eine reine Darstellungssicht
|
||||
auf `ScenarioModifier` und `ScenarioPlannedItem`. Keine Migration.
|
||||
- **Typ bleibt beim Bearbeiten erhalten:** ein Modifikator kann Art
|
||||
(percent/absolute/remove/ende), Ziel und Wert/Datum ändern; eine
|
||||
Einmalzahlung bleibt Einmalzahlung (Name/Betrag/Datum änderbar).
|
||||
- „Kredite zuordnen" bleibt eigener Abschnitt (Zuordnung, kein Eintrag).
|
||||
- Beschreibung des Szenarios bleibt Freitext ohne Funktion.
|
||||
|
||||
## API
|
||||
|
||||
Zwei neue Endpunkte in `app/routers/scenarios.py` (Full-Body-Update — die
|
||||
Edit-Formulare senden immer alle Felder des Typs):
|
||||
|
||||
- `PATCH /api/scenarios/{id}/modifiers/{mod_id}` — Body = `ModifierIn`
|
||||
(inkl. bestehender Validierung: `ende` erfordert `end_date`, andere Arten
|
||||
nullen es). 404 „Modifikator nicht gefunden" bei fremder/fehlender ID
|
||||
(Muster `delete_modifier`). Antwort `ModifierOut`.
|
||||
- `PATCH /api/scenarios/{id}/planned/{item_id}` — Body =
|
||||
`ScenarioPlannedIn`. 404 „Einmalzahlung nicht gefunden" (Muster
|
||||
`delete_scenario_planned`). Antwort `ScenarioPlannedOut`.
|
||||
|
||||
Sortierung für stabile Anzeige: Modifikator-Query in `_scenario_rows`
|
||||
bekommt `.order_by(ScenarioModifier.id)`; Einmalzahlungen sind bereits nach
|
||||
`due` sortiert.
|
||||
|
||||
## GUI (`app/templates/planning.html`)
|
||||
|
||||
Die zwei `<details>`-Abschnitte „Modifikatoren" und „Einmalzahlungen in
|
||||
diesem Szenario" werden ersetzt durch eine offene Tabelle **„Einträge"**:
|
||||
|
||||
- Spalten: **Was** | **Art** | **Wert/Betrag** | **Datum** | Aktionen.
|
||||
- Modifikator: Was = „Posten: X" / „Kategorie: Y"; Art = `|de_label`;
|
||||
Wert = `value|eur` bei percent/absolute, sonst „–"; Datum =
|
||||
`end_date` TT.MM.JJJJ bei ende, sonst „–".
|
||||
- Einmalzahlung: Was = Name; Art = „Einmalzahlung"; Wert = `amount|eur €`
|
||||
(neg-Klasse wie bisher); Datum = `due` TT.MM.JJJJ.
|
||||
- Zeilen-Reihenfolge: erst Modifikatoren (nach id), dann Einmalzahlungen
|
||||
(nach Fälligkeit).
|
||||
- **Inline-Bearbeiten** je Zeile nach dem `toggleEdit`-Muster aus v0.6.0:
|
||||
IDs `mod-row-{id}`/`mod-edit-{id}` bzw. `spi-row-{id}`/`spi-edit-{id}`.
|
||||
Modifikator-Edit-Formular = Felder des Anlege-Formulars (Ziel-Typ, Ziel,
|
||||
Art, Wert, Datum, vorbelegt, mit denselben Umschaltern), sendet
|
||||
`hx-patch` auf den neuen Endpunkt. Einmalzahlungs-Edit: Name/Betrag/Datum.
|
||||
- **Ein Anlege-Formular „Neuer Eintrag"** mit Dropdown **Eintragsart**
|
||||
(Ende, Prozent, Absolut, Entfällt, Einmalzahlung; Werte
|
||||
`ende|percent|absolute|remove|einmal`):
|
||||
- Feld-Umschaltung ausschließlich per `disabled` (UX-Regel):
|
||||
Ziel-Typ/Ziel aktiv bei den vier Modifikator-Arten; Wert aktiv bei
|
||||
Prozent/Absolut; Datum „Endet am" (`end_date`) aktiv bei Ende;
|
||||
Name/Betrag/„Fällig am" (`due`) aktiv bei Einmalzahlung.
|
||||
- Der Eintragsart-Select trägt `name="kind"`; beim Absenden an den
|
||||
Einmalzahlungs-Endpunkt wird das überzählige `kind` von Pydantic
|
||||
ignoriert (Default `extra=ignore`), disabled-Felder fehlen in der
|
||||
Serialisierung.
|
||||
- Ziel-Endpunkt-Wechsel: JS setzt das `hx-post`-Attribut des Formulars um
|
||||
(`…/modifiers` ↔ `…/planned`) und ruft `htmx.process(form)` auf.
|
||||
- Bestehende Helfer (`onModTargetTypeChange`, `toggleEdit`) werden
|
||||
weiterverwendet; neue Funktion `onEntryArtChange(select)` ersetzt
|
||||
`onModKindChange` (Umschaltmatrix oben). Scoping wie bisher über
|
||||
`closest('form')`, damit Anlege- und Edit-Formulare sich nicht stören.
|
||||
|
||||
`hilfe.html`: der Szenarien-Absatz wird auf die neue Einträge-Tabelle
|
||||
angepasst (weiterhin generisch, keine echten Daten).
|
||||
|
||||
## Tests (TDD, Suite bleibt grün — Basis 178)
|
||||
|
||||
- API: PATCH Modifikator (Wert ändern; Art auf ende wechseln mit Datum;
|
||||
ende ohne Datum → 422; fremde scenario_id → 404), PATCH Einmalzahlung
|
||||
(Betrag/Datum ändern; fremde scenario_id → 404).
|
||||
- GUI: eine Einträge-Tabelle statt der zwei details-Abschnitte
|
||||
(Summary-Texte „Modifikatoren"/„Einmalzahlungen in diesem Szenario"
|
||||
verschwinden), gemischte Zeilen inkl. „Einmalzahlung"-Art, Edit-Formulare
|
||||
mit `hx-patch`-URLs, Anlege-Formular mit Eintragsart-Optionen.
|
||||
|
||||
## Release
|
||||
|
||||
`finance/VERSION` → `0.7.0`, Redeploy via `./create_pod_finance.sh` (keine
|
||||
Migration nötig), Live-Check am bestehenden Szenario „Best Case" (drei
|
||||
Einträge sichtbar in einer Tabelle, ein Bearbeiten-Roundtrip live).
|
||||
Fable-Testagent-Gate je Task vor Commit (CLAUDE.md). Datenschutz-Regel
|
||||
unverändert: echte Namen/Beträge nur Live-DB/Chat.
|
||||
|
||||
**Außerhalb des Scopes:** Typwechsel Modifikator↔Einmalzahlung beim
|
||||
Bearbeiten, Mehrfach-Bearbeitung/Bulk-Aktionen, Kredit-Zuordnung als
|
||||
Eintrag, Änderungen an Engine/Projektion.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Design — Ausbaustufe 9: Vorschlags-Algorithmus v2 (v0.9.0)
|
||||
|
||||
> Status: vom Nutzer freigegeben (Chat 2026-07-20, Direktdurchlauf). Anlass:
|
||||
> Der bisherige `suggest_recurring` gruppiert nach exaktem Betrag (jede
|
||||
> Preiserhöhung zerreißt die Serie), erkennt nur monatliche Serien, prüft
|
||||
> keine Aktivität (schlägt tote Serien vor) und nutzt Median-Werte. Ein
|
||||
> manueller Vollabgleich (A8-Datenpflege, siehe Ledger) fand 16 fehlende
|
||||
> Posten — der Algorithmus soll solche Serien künftig selbst finden.
|
||||
|
||||
## Anforderungen (Nutzer)
|
||||
|
||||
- Betrag = **letzte** Buchung, nicht Median (Preissteigerungen relevant).
|
||||
- Erkennung **monatlich, vierteljährlich, jährlich**.
|
||||
- Empfänger-Gruppierung robust (Schreibweisen, Preisänderungen,
|
||||
Umfirmierungen); mehrere Verträge desselben Anbieters getrennt.
|
||||
- Keine „Leichen": abgerissene Serien werden nicht vorgeschlagen.
|
||||
- Kein Wiedervorschlagen bereits gepflegter Posten (auch bei zwischenzeitlich
|
||||
geändertem Betrag).
|
||||
|
||||
## Algorithmus (`app/services/suggestions.py`, vollständiger Rewrite)
|
||||
|
||||
Parameter als Modul-Konstanten (Toleranzen zentral änderbar):
|
||||
`WINDOW_DAYS=460` (~15 Monate), Rhythmen mit Intervallgrenzen und
|
||||
Mindestbelegen: monthly 25–36 Tage/≥3, quarterly 80–105/≥3, yearly
|
||||
330–400/≥2; `ACTIVITY_FACTOR=1.75`; Betrags-Cluster-Toleranz 35 %;
|
||||
Merge-Toleranz 25 %; Bestandsabgleich-Toleranz 10 %.
|
||||
|
||||
1. **Datenbasis:** bestätigte Buchungen der letzten `WINDOW_DAYS`, je Konto.
|
||||
2. **Gruppierung:** Schlüssel = (account_id, normalisierter Empfänger)
|
||||
(`casefold`, Whitespace kollabiert).
|
||||
3. **Betrags-Cluster** innerhalb der Gruppe (chronologisch, greedy gegen das
|
||||
jeweils letzte Cluster-Mitglied, gleiches Vorzeichen, relative Differenz
|
||||
≤ 35 %) — trennt parallele Verträge, hält Preisdrift zusammen.
|
||||
4. **Rhythmus je Cluster:** Median der Buchungsabstände gegen die
|
||||
Intervallgrenzen; Mindestbelege je Rhythmus.
|
||||
5. **Aktiv-Check:** letzte Buchung ≤ `ACTIVITY_FACTOR` × Rhythmus-Schrittweite
|
||||
(30/91/365 Tage) her, sonst kein Vorschlag.
|
||||
6. **Umfirmierungs-Merge** (über Gruppengrenzen, je Konto): Serie A endet,
|
||||
Serie B beginnt danach (Lücke 0,4–1,6 Schrittweiten), gleicher Rhythmus,
|
||||
Fälligkeitstag ±3, Betrag ±25 % → eine Serie; Name/Betrag der neueren.
|
||||
7. **Vorschlagswerte:** Name = Empfänger-Schreibweise der neuesten Buchung;
|
||||
Betrag = neueste Buchung; Fälligkeitstag = Tag der neuesten Buchung;
|
||||
`start_date` = Datum der neuesten Buchung bei quarterly/yearly (Phase!),
|
||||
sonst None; Kategorie = häufigste in der Serie; `hinweis` = Text
|
||||
„Betrag zuletzt gestiegen (vorher X)" wenn die vorletzte Buchung
|
||||
betragskleiner war, sonst leer.
|
||||
8. **Bestandsabgleich:** Vorschlag entfällt, wenn ein `RecurringItem`
|
||||
existiert mit (a) Namens-Substring-Match (normalisiert, in beide
|
||||
Richtungen) ODER (b) gleichem Rhythmus + Fälligkeitstag ±2 + Betrag
|
||||
±10 %.
|
||||
|
||||
## API/GUI
|
||||
|
||||
- `SuggestionOut` (routers/planning.py): + `start_date: date | None`,
|
||||
+ `hinweis: str = ""`.
|
||||
- Vorschlags-Tabelle (planning.html): Spalten Rhythmus (`|de_label`) und
|
||||
Start (TT.MM.JJJJ bzw. „–"); `hinweis` als `muted`-Text hinter dem Betrag;
|
||||
„Vorschlag übernehmen" überträgt `start_date` mit (hidden input).
|
||||
- Hinweistext unter der Tabelle aktualisiert: monatliche/vierteljährliche/
|
||||
jährliche Serien, Betrag = letzte Buchung.
|
||||
|
||||
## Tests (synthetische Daten, keine Fixtures)
|
||||
|
||||
Preiserhöhungs-Serie → letzter Betrag + hinweis; Quartals-/Jahres-Serie mit
|
||||
korrektem start_date; tote Serie (letzte Buchung zu alt) → kein Vorschlag;
|
||||
Umbenennungs-Merge → ein Vorschlag mit neuem Namen; Bestandsabgleich:
|
||||
existierender Posten mit altem Betrag verhindert Wiedervorschlag; zwei
|
||||
parallele Verträge eines Anbieters → zwei getrennte Vorschläge; bestehende
|
||||
drei Suggestion-Tests an die neue Semantik anpassen.
|
||||
|
||||
## Release
|
||||
|
||||
`VERSION` → 0.9.0, Redeploy, **Live-Gate gegen echte Daten**: kein einziger
|
||||
der bestehenden Fixposten darf erneut vorgeschlagen werden; keine als
|
||||
beendet bekannten Serien (z.B. gelöschte PayPal-Leiche, ausgelaufene
|
||||
WWK-Police) im Ergebnis; verbleibende Vorschläge werden dem Nutzer als
|
||||
Kandidatenliste berichtet (nur Chat, kein Commit). Fable-Gate je Task.
|
||||
|
||||
**Außerhalb des Scopes:** halbjährliche Rhythmen (nicht im Datenmodell),
|
||||
automatische Übernahme ohne Nutzer-Klick, Einnahmen-Prognose des
|
||||
Geschäftskontos.
|
||||
|
||||
## Nachtrag (nach Live-Release-Gate, gleiche Session)
|
||||
|
||||
Das erste Live-Gate scheiterte an einem Duplikat: ein kuratierter
|
||||
„variabel"-Fixposten unter Alias-Namen des Anbieters wurde vom
|
||||
Bestandsabgleich (a)/(b) nicht erkannt. Daraus zwei Ergänzungen:
|
||||
|
||||
- **Bestandsabgleich-Regel (c) Token-Match:** Vorschlag entfällt auch, wenn
|
||||
ein Fixposten mit gleichem Rhythmus, Fälligkeitstag ±2 und mindestens
|
||||
einem gemeinsamen Namens-Token (≥ 5 Zeichen, normalisiert, Split an
|
||||
Nicht-Alphanumerik) existiert.
|
||||
- **Volatilitäts-Hinweis:** Wurde die neueste Buchung einer Empfänger-Gruppe
|
||||
durch den Betrags-Cluster-Split abgetrennt UND gehört sie zu keiner
|
||||
anderen qualifizierten Serie der Gruppe, erhält der Vorschlag den Zusatz
|
||||
„Beträge schwanken stark – letzte Buchung weicht ab" (keine
|
||||
Unterdrückung; die Ausnahme verhindert False-Positives bei parallelen
|
||||
Verträgen desselben Anbieters).
|
||||
|
||||
Bewiesene Pipeline-Eigenschaft (bindend fürs Verständnis): der
|
||||
Umfirmierungs-Merge kann die Vorschlagsanzahl nie ändern (Aktiv-Check/
|
||||
Fenster erledigen das allein); sein Nutzen ist Kategorie-/Historien-
|
||||
Kontinuität. Nach einer Umfirmierung entsteht eine Vorschlags-Lücke, bis
|
||||
der neue Name selbst die Mindestbelege erreicht.
|
||||
6
finance/.containerignore
Normal file
6
finance/.containerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
tests/
|
||||
.env
|
||||
*.sqlite
|
||||
inbox/
|
||||
uploads/
|
||||
7
finance/.gitignore
vendored
Normal file
7
finance/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.sqlite
|
||||
tests/fixtures/*.pdf
|
||||
tests/fixtures/*.csv
|
||||
tests/fixtures/expected_*.json
|
||||
.venv/
|
||||
13
finance/Containerfile
Normal file
13
finance/Containerfile
Normal 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
1
finance/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
0.9.0
|
||||
141
finance/alembic.ini
Normal file
141
finance/alembic.ini
Normal 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
1
finance/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
84
finance/alembic/env.py
Normal file
84
finance/alembic/env.py
Normal 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()
|
||||
28
finance/alembic/script.py.mako
Normal file
28
finance/alembic/script.py.mako
Normal 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"}
|
||||
34
finance/alembic/versions/1ef6a356f028_konto_anker.py
Normal file
34
finance/alembic/versions/1ef6a356f028_konto_anker.py
Normal 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 ###
|
||||
177
finance/alembic/versions/b2b1f5a18a74_initial_schema.py
Normal file
177
finance/alembic/versions/b2b1f5a18a74_initial_schema.py
Normal 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 ###
|
||||
37
finance/alembic/versions/c4d7e2a91b53_szenario_ende.py
Normal file
37
finance/alembic/versions/c4d7e2a91b53_szenario_ende.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""szenario ende + scenario_planned_items
|
||||
|
||||
Revision ID: c4d7e2a91b53
|
||||
Revises: 1ef6a356f028
|
||||
Create Date: 2026-07-20
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'c4d7e2a91b53'
|
||||
down_revision: Union[str, Sequence[str], None] = '1ef6a356f028'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('scenario_modifiers', sa.Column('end_date', sa.Date(), nullable=True))
|
||||
op.create_table(
|
||||
'scenario_planned_items',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('scenario_id', sa.Integer(), sa.ForeignKey('scenarios.id'), nullable=False),
|
||||
sa.Column('name', sa.String(length=200), nullable=False),
|
||||
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
sa.Column('due', sa.Date(), nullable=False),
|
||||
)
|
||||
op.create_index('ix_scenario_planned_items_scenario_id',
|
||||
'scenario_planned_items', ['scenario_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_scenario_planned_items_scenario_id', 'scenario_planned_items')
|
||||
op.drop_table('scenario_planned_items')
|
||||
op.drop_column('scenario_modifiers', 'end_date')
|
||||
0
finance/app/__init__.py
Normal file
0
finance/app/__init__.py
Normal file
97
finance/app/auth.py
Normal file
97
finance/app/auth.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from itsdangerous import BadSignature, TimestampSigner
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
COOKIE = "fb_session"
|
||||
MAX_AGE = 60 * 60 * 12 # 12 h
|
||||
|
||||
# mtime-Cache fuer den aus der (ggf. bind-gemounteten) .env gelesenen
|
||||
# GUI-Passwort-Hash: {Pfad: (mtime, hash)}. Vermeidet, die Datei bei jedem
|
||||
# Login/Request neu zu parsen, invalidiert sich aber automatisch, sobald
|
||||
# services.admin.change_password() die Datei in-place neu schreibt (neue
|
||||
# mtime).
|
||||
_hash_cache: dict[str, tuple[float, str]] = {}
|
||||
|
||||
_ENV_LINE_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)='([^']*)'\s*$")
|
||||
|
||||
|
||||
def _parse_env_file(env_file: Path) -> dict[str, str]:
|
||||
"""Parst single-quoted KEY='value'-Zeilen wie sie create_pod_finance.sh
|
||||
schreibt. Zeilen in anderer Form (Kommentare, unquoted, leer) werden
|
||||
ignoriert statt einen Fehler zu werfen."""
|
||||
values: dict[str, str] = {}
|
||||
for line in env_file.read_text().splitlines():
|
||||
m = _ENV_LINE_RE.match(line)
|
||||
if m:
|
||||
values[m.group(1)] = m.group(2)
|
||||
return values
|
||||
|
||||
|
||||
def current_password_hash() -> str:
|
||||
"""Liest FB_GUI_PASSWORD_HASH zur Laufzeit aus der .env (Bind-Mount,
|
||||
siehe KRITISCH-Hinweis Global Constraints Ausbaustufe 4: Einzeldatei-Mount
|
||||
folgt dem Inode, die Datei wird von services.admin.change_password()
|
||||
in-place ueberschrieben). Existiert die Datei nicht (z.B. lokale
|
||||
Entwicklung ohne Pod), wird auf die Umgebungsvariable zurueckgefallen."""
|
||||
settings = get_settings()
|
||||
env_file = settings.env_file
|
||||
if env_file.exists():
|
||||
mtime = env_file.stat().st_mtime
|
||||
key = str(env_file)
|
||||
cached = _hash_cache.get(key)
|
||||
if cached is not None and cached[0] == mtime:
|
||||
return cached[1]
|
||||
value = _parse_env_file(env_file).get("FB_GUI_PASSWORD_HASH")
|
||||
if value is not None:
|
||||
_hash_cache[key] = (mtime, value)
|
||||
return value
|
||||
return settings.gui_password_hash
|
||||
|
||||
|
||||
def hash_password(pw: str, salt: str | None = None) -> str:
|
||||
salt = salt or secrets.token_hex(16)
|
||||
digest = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt.encode(), 200_000).hex()
|
||||
return f"{salt}${digest}"
|
||||
|
||||
|
||||
def verify_password(pw: str, stored: str) -> bool:
|
||||
try:
|
||||
salt, digest = stored.split("$", 1)
|
||||
except ValueError:
|
||||
return False
|
||||
return hmac.compare_digest(hash_password(pw, salt).split("$", 1)[1], digest)
|
||||
|
||||
|
||||
def _signer() -> TimestampSigner:
|
||||
return TimestampSigner(get_settings().session_secret)
|
||||
|
||||
|
||||
def make_session_token() -> str:
|
||||
return _signer().sign(b"gui").decode()
|
||||
|
||||
|
||||
def session_valid(token: str | None) -> bool:
|
||||
if not token:
|
||||
return False
|
||||
try:
|
||||
_signer().unsign(token, max_age=MAX_AGE)
|
||||
return True
|
||||
except BadSignature:
|
||||
return False
|
||||
|
||||
|
||||
def require_auth(request: Request) -> None:
|
||||
settings = get_settings()
|
||||
header = request.headers.get("authorization", "")
|
||||
if settings.api_key and hmac.compare_digest(header, f"Bearer {settings.api_key}"):
|
||||
return
|
||||
if session_valid(request.cookies.get(COOKIE)):
|
||||
return
|
||||
raise HTTPException(status_code=401, detail="Nicht angemeldet")
|
||||
41
finance/app/config.py
Normal file
41
finance/app/config.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
database_url: str
|
||||
api_key: str
|
||||
gui_user: str
|
||||
gui_password_hash: str
|
||||
session_secret: str
|
||||
inbox_dir: Path
|
||||
uploads_dir: Path
|
||||
warn_threshold: Decimal
|
||||
horizon_days: int
|
||||
env_file: Path
|
||||
grafana_url: str
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
e = os.environ.get
|
||||
return Settings(
|
||||
database_url=e("FB_DATABASE_URL", "sqlite:///./fb.sqlite"),
|
||||
api_key=e("FB_API_KEY", ""),
|
||||
gui_user=e("FB_GUI_USER", "admin"),
|
||||
gui_password_hash=e("FB_GUI_PASSWORD_HASH", ""),
|
||||
session_secret=e("FB_SESSION_SECRET", "dev-secret"),
|
||||
inbox_dir=Path(e("FB_INBOX_DIR", "./inbox")),
|
||||
uploads_dir=Path(e("FB_UPLOADS_DIR", "./uploads")),
|
||||
warn_threshold=Decimal(e("FB_WARN_THRESHOLD", "0")),
|
||||
horizon_days=int(e("FB_HORIZON_DAYS", "548")),
|
||||
# Bind-gemountete .env (siehe create_pod_finance.sh): Laufzeit-Lesen
|
||||
# der GUI-Passwort-Hashes fuer die Admin-Passwortaenderung (Ausbaustufe
|
||||
# 4 Task 2). Default passt zum Container-Mountpunkt "/data/.env".
|
||||
env_file=Path(e("FB_ENV_FILE", "/data/.env")),
|
||||
grafana_url=e("FB_GRAFANA_URL", "http://localhost:3000"),
|
||||
)
|
||||
23
finance/app/db.py
Normal file
23
finance/app/db.py
Normal 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
|
||||
0
finance/app/engine/__init__.py
Normal file
0
finance/app/engine/__init__.py
Normal file
56
finance/app/engine/loans.py
Normal file
56
finance/app/engine/loans.py
Normal 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
|
||||
39
finance/app/engine/projection.py
Normal file
39
finance/app/engine/projection.py
Normal 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)
|
||||
25
finance/app/engine/recurrence.py
Normal file
25
finance/app/engine/recurrence.py
Normal 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
|
||||
96
finance/app/engine/scenario.py
Normal file
96
finance/app/engine/scenario.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from app.engine.loans import Installment
|
||||
from app.engine.recurrence import occurrences
|
||||
|
||||
CENT = Decimal("0.01")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlainRecurring:
|
||||
id: int
|
||||
name: str
|
||||
amount: Decimal
|
||||
rhythm: str
|
||||
due_day: int
|
||||
start_date: date | None
|
||||
end_date: date | None
|
||||
category_id: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlainPlanned:
|
||||
name: str
|
||||
amount: Decimal
|
||||
due: date
|
||||
category_id: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlainModifier:
|
||||
target_type: str
|
||||
target_id: int
|
||||
kind: str
|
||||
value: Decimal
|
||||
end_date: date | None = None
|
||||
|
||||
|
||||
def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None,
|
||||
modifiers: list[PlainModifier]) -> Decimal | None:
|
||||
for m in modifiers:
|
||||
hit = ((m.target_type == "category" and category_id == m.target_id)
|
||||
or (m.target_type == "recurring" and recurring_id == m.target_id))
|
||||
if not hit:
|
||||
continue
|
||||
if m.kind == "remove":
|
||||
return None
|
||||
if m.kind == "percent":
|
||||
amount = (amount * (Decimal(100) - m.value) / Decimal(100)).quantize(CENT, ROUND_HALF_UP)
|
||||
elif m.kind == "absolute":
|
||||
if amount < 0:
|
||||
amount = min(Decimal("0"), amount + m.value)
|
||||
else:
|
||||
amount = max(Decimal("0"), amount - m.value)
|
||||
return amount
|
||||
|
||||
|
||||
def _effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None:
|
||||
"""Fruehestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren."""
|
||||
end = r.end_date
|
||||
for m in modifiers:
|
||||
if m.kind != "ende" or m.end_date is None:
|
||||
continue
|
||||
hit = ((m.target_type == "category" and r.category_id == m.target_id)
|
||||
or (m.target_type == "recurring" and r.id == m.target_id))
|
||||
if hit:
|
||||
end = m.end_date if end is None else min(end, m.end_date)
|
||||
return end
|
||||
|
||||
|
||||
def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned],
|
||||
loan_schedules: list[list[Installment]],
|
||||
loan_payouts: list[tuple[date, Decimal]],
|
||||
modifiers: list[PlainModifier],
|
||||
window_start: date, window_end: date) -> list[tuple[date, Decimal]]:
|
||||
flows: list[tuple[date, Decimal]] = []
|
||||
for r in recurring:
|
||||
amount = _modified(r.amount, r.category_id, r.id, modifiers)
|
||||
if amount is None:
|
||||
continue
|
||||
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
|
||||
r.start_date, _effective_end(r, modifiers)):
|
||||
flows.append((d, amount))
|
||||
for p in planned:
|
||||
amount = _modified(p.amount, p.category_id, None, modifiers)
|
||||
if amount is not None and window_start <= p.due <= window_end:
|
||||
flows.append((p.due, amount))
|
||||
for d, payout in loan_payouts:
|
||||
if window_start <= d <= window_end:
|
||||
flows.append((d, payout))
|
||||
for sched in loan_schedules:
|
||||
for inst in sched:
|
||||
if window_start <= inst.due <= window_end:
|
||||
flows.append((inst.due, inst.payment))
|
||||
return sorted(flows)
|
||||
30
finance/app/formats.py
Normal file
30
finance/app/formats.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Deutsche Anzeige-Formate fuer die GUI (Ausbaustufe 5).
|
||||
|
||||
Bewusst ohne locale-Modul: Container-Locales sind nicht garantiert
|
||||
installiert, und die Ausgabe hinge sonst vom Host ab. Reine
|
||||
Decimal-Formatierung; die JSON-API bleibt Punkt-Dezimal.
|
||||
"""
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
DE_LABELS = {
|
||||
"monthly": "monatlich",
|
||||
"quarterly": "vierteljährlich",
|
||||
"yearly": "jährlich",
|
||||
"annuity": "Annuität",
|
||||
"bullet": "endfällig",
|
||||
"percent": "Prozent",
|
||||
"absolute": "Absolut",
|
||||
"remove": "Entfällt",
|
||||
"ende": "Ende",
|
||||
}
|
||||
|
||||
|
||||
def eur(value) -> str:
|
||||
"""Decimal("-2474.5") -> "-2.474,50" (ohne Euro-Zeichen, das setzt das Template)."""
|
||||
d = Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
s = f"{d:,.2f}" # US-Trenner: "-2,474.50"
|
||||
return s.replace(",", "\x00").replace(".", ",").replace("\x00", ".")
|
||||
|
||||
|
||||
def de_label(value: str) -> str:
|
||||
return DE_LABELS.get(value, value)
|
||||
65
finance/app/main.py
Normal file
65
finance/app/main.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import hmac
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app import auth
|
||||
from app.auth import require_auth
|
||||
from app.config import get_settings
|
||||
from app.db import get_engine
|
||||
from app.models.views import create_views
|
||||
from app.routers import (accounts, admin, categories, gui, imports, planning,
|
||||
scenarios, transactions)
|
||||
from app.routers.gui import templates
|
||||
from app.version import get_version
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
create_views(get_engine())
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Finanzberatungs-Tool", lifespan=lifespan)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
app.include_router(gui.router)
|
||||
app.include_router(accounts.router)
|
||||
app.include_router(transactions.router)
|
||||
app.include_router(categories.router)
|
||||
app.include_router(imports.router)
|
||||
app.include_router(planning.router)
|
||||
app.include_router(scenarios.router)
|
||||
app.include_router(admin.router)
|
||||
|
||||
|
||||
@app.get("/login")
|
||||
def login_form(request: Request):
|
||||
return templates.TemplateResponse(request, "login.html", {})
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
def login(username: str = Form(...), password: str = Form(...)):
|
||||
s = get_settings()
|
||||
if not (hmac.compare_digest(username, s.gui_user)
|
||||
and auth.verify_password(password, auth.current_password_hash())):
|
||||
return HTMLResponse("Login fehlgeschlagen", status_code=401)
|
||||
resp = RedirectResponse("/", status_code=303)
|
||||
resp.set_cookie(auth.COOKIE, auth.make_session_token(), httponly=True,
|
||||
max_age=auth.MAX_AGE, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
def logout():
|
||||
resp = RedirectResponse("/login", status_code=303)
|
||||
resp.delete_cookie(auth.COOKIE)
|
||||
return resp
|
||||
|
||||
|
||||
@app.get("/api/version", dependencies=[Depends(require_auth)])
|
||||
def api_version():
|
||||
return {"version": get_version()}
|
||||
1
finance/app/models/__init__.py
Normal file
1
finance/app/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from app.models import tables # noqa: F401
|
||||
158
finance/app/models/tables.py
Normal file
158
finance/app/models/tables.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
MONEY = Numeric(12, 2)
|
||||
|
||||
|
||||
class Account(Base):
|
||||
__tablename__ = "accounts"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
bank: Mapped[str] = mapped_column(String(50))
|
||||
iban: Mapped[str] = mapped_column(String(34), unique=True)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
type: Mapped[str] = mapped_column(String(20), default="giro")
|
||||
# Konto-Saldo-Anker (Ausbaustufe 3 Task 1): ersetzt die bisherige
|
||||
# Statement-closing-Logik als Basis der Saldenrechnung. Beide Felder sind
|
||||
# nur gemeinsam gesetzt oder gemeinsam NULL (durchgesetzt in
|
||||
# app.routers.accounts.AccountPatch).
|
||||
anchor_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
anchor_balance: Mapped[Decimal | None] = mapped_column(MONEY, nullable=True)
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True)
|
||||
|
||||
|
||||
class CategoryRule(Base):
|
||||
__tablename__ = "category_rules"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
pattern: Mapped[str] = mapped_column(String(200))
|
||||
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"))
|
||||
priority: Mapped[int] = mapped_column(Integer, default=100)
|
||||
|
||||
|
||||
class Statement(Base):
|
||||
__tablename__ = "statements"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
bank: Mapped[str] = mapped_column(String(50))
|
||||
account_id: Mapped[int | None] = mapped_column(ForeignKey("accounts.id"), nullable=True)
|
||||
period_start: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
period_end: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
opening_balance: Mapped[Decimal | None] = mapped_column(MONEY, nullable=True)
|
||||
closing_balance: Mapped[Decimal | None] = mapped_column(MONEY, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="draft")
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class Transaction(Base):
|
||||
__tablename__ = "transactions"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"))
|
||||
statement_id: Mapped[int | None] = mapped_column(ForeignKey("statements.id"), nullable=True)
|
||||
booking_date: Mapped[date] = mapped_column(Date)
|
||||
value_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
amount: Mapped[Decimal] = mapped_column(MONEY)
|
||||
purpose: Mapped[str] = mapped_column(Text, default="")
|
||||
counterparty: Mapped[str] = mapped_column(String(200), default="")
|
||||
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="draft")
|
||||
dedup_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||
is_duplicate: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class RecurringItem(Base):
|
||||
__tablename__ = "recurring_items"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
amount: Mapped[Decimal] = mapped_column(MONEY)
|
||||
rhythm: Mapped[str] = mapped_column(String(20))
|
||||
due_day: Mapped[int] = mapped_column(Integer)
|
||||
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||||
|
||||
|
||||
class PlannedItem(Base):
|
||||
__tablename__ = "planned_items"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
amount: Mapped[Decimal] = mapped_column(MONEY)
|
||||
due: Mapped[date] = mapped_column(Date)
|
||||
category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||||
|
||||
|
||||
class Loan(Base):
|
||||
__tablename__ = "loans"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
principal: Mapped[Decimal] = mapped_column(MONEY)
|
||||
annual_rate_pct: Mapped[Decimal] = mapped_column(Numeric(5, 2))
|
||||
term_months: Mapped[int] = mapped_column(Integer)
|
||||
payout_date: Mapped[date] = mapped_column(Date)
|
||||
repayment_type: Mapped[str] = mapped_column(String(20), default="annuity")
|
||||
|
||||
|
||||
class Scenario(Base):
|
||||
__tablename__ = "scenarios"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(200), unique=True)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
include_recurring: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
include_planned: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class ScenarioLoan(Base):
|
||||
__tablename__ = "scenario_loans"
|
||||
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), primary_key=True)
|
||||
loan_id: Mapped[int] = mapped_column(ForeignKey("loans.id"), primary_key=True)
|
||||
|
||||
|
||||
class ScenarioModifier(Base):
|
||||
__tablename__ = "scenario_modifiers"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"))
|
||||
target_type: Mapped[str] = mapped_column(String(20))
|
||||
target_id: Mapped[int] = mapped_column(Integer)
|
||||
kind: Mapped[str] = mapped_column(String(20))
|
||||
value: Mapped[Decimal] = mapped_column(MONEY, default=Decimal("0"))
|
||||
# Nur fuer kind="ende" gesetzt: Datum, an dem der Ziel-Posten innerhalb
|
||||
# dieses Szenarios endet (Ausbaustufe 5).
|
||||
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
|
||||
class ScenarioPlannedItem(Base):
|
||||
"""Einmalzahlung, die NUR in einem bestimmten Szenario zaehlt -
|
||||
unabhaengig von Scenario.include_planned (das steuert nur die globalen
|
||||
PlannedItems). Ausbaustufe 5."""
|
||||
__tablename__ = "scenario_planned_items"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
amount: Mapped[Decimal] = mapped_column(MONEY)
|
||||
due: Mapped[date] = mapped_column(Date)
|
||||
|
||||
|
||||
class ProjectionPoint(Base):
|
||||
__tablename__ = "projection_points"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), index=True)
|
||||
day: Mapped[date] = mapped_column(Date)
|
||||
balance: Mapped[Decimal] = mapped_column(MONEY)
|
||||
|
||||
|
||||
class ProjectionResult(Base):
|
||||
__tablename__ = "projection_results"
|
||||
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), primary_key=True)
|
||||
computed_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
low_point_date: Mapped[date] = mapped_column(Date)
|
||||
low_point_balance: Mapped[Decimal] = mapped_column(MONEY)
|
||||
below_zero_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
below_threshold_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
132
finance/app/models/views.py
Normal file
132
finance/app/models/views.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
# Per-account offset for the Konto-Saldo-Anker (Ausbaustufe 3 Task 1): the
|
||||
# same math as app.services.balances.account_balance, expressed as a single
|
||||
# constant "base" per account so it can be added to a plain (anchor-agnostic)
|
||||
# running sum of transaction amounts:
|
||||
#
|
||||
# balance(day) = anchor_balance + Sum(tx: anchor_date < booking_date <= day)
|
||||
# - Sum(tx: day < booking_date <= anchor_date)
|
||||
# = anchor_balance - cumsum(<=anchor_date) + cumsum(<=day)
|
||||
# = base + cumsum(<=day)
|
||||
#
|
||||
# ... which holds for `day` both before and after anchor_date, since the
|
||||
# subtracted/added transaction windows collapse into the same telescoping
|
||||
# sum. Accounts without an anchor get base=0 (via COALESCE below), matching
|
||||
# account_balance's "Ohne Anker: Summe ab 0" fallback.
|
||||
_ACCOUNT_OFFSET = """
|
||||
SELECT a.id AS account_id,
|
||||
a.anchor_balance - COALESCE((
|
||||
SELECT SUM(t2.amount) FROM transactions t2
|
||||
WHERE t2.account_id = a.id AND t2.status = 'confirmed'
|
||||
AND t2.booking_date <= a.anchor_date
|
||||
), 0) AS base
|
||||
FROM accounts a
|
||||
WHERE a.anchor_date IS NOT NULL AND a.anchor_balance IS NOT NULL
|
||||
"""
|
||||
|
||||
|
||||
# Per-account earliest day the daily series needs to start at: the earlier of
|
||||
# the account's first confirmed booking and its anchor_date (an anchor can
|
||||
# predate the first booking, e.g. an anchor set before any import). Accounts
|
||||
# with neither an anchor nor any confirmed booking have no meaningful start
|
||||
# and are excluded (nothing to plot).
|
||||
_ACCOUNT_START = """
|
||||
SELECT a.id AS account_id, a.name AS account,
|
||||
LEAST(
|
||||
COALESCE(a.anchor_date, ft.first_date),
|
||||
COALESCE(ft.first_date, a.anchor_date)
|
||||
) AS start_date
|
||||
FROM accounts a
|
||||
LEFT JOIN (
|
||||
SELECT account_id, MIN(booking_date) AS first_date
|
||||
FROM transactions WHERE status = 'confirmed'
|
||||
GROUP BY account_id
|
||||
) ft ON ft.account_id = a.id
|
||||
WHERE a.anchor_date IS NOT NULL OR ft.first_date IS NOT NULL
|
||||
"""
|
||||
|
||||
VIEWS: dict[str, str] = {
|
||||
# Daily (gap-free) balance per account: one row per calendar day from the
|
||||
# account's start date (see _ACCOUNT_START) through CURRENT_DATE. The
|
||||
# cumulative SUM ... OVER (... ROWS UNBOUNDED PRECEDING) carries the
|
||||
# previous day's balance forward on days without any confirmed booking,
|
||||
# matching services.balances.account_balance's per-Tagesende semantics
|
||||
# (base + cumulated amounts up to and including that day).
|
||||
"v_balance_history": f"""
|
||||
WITH offsets AS ({_ACCOUNT_OFFSET}),
|
||||
bounds AS ({_ACCOUNT_START}),
|
||||
days AS (
|
||||
SELECT b.account_id, b.account, gs.day::date AS day
|
||||
FROM bounds b
|
||||
CROSS JOIN LATERAL generate_series(
|
||||
b.start_date, CURRENT_DATE, interval '1 day') AS gs(day)
|
||||
),
|
||||
daily_tx AS (
|
||||
SELECT account_id, booking_date AS day, SUM(amount) AS day_amount
|
||||
FROM transactions WHERE status = 'confirmed'
|
||||
GROUP BY account_id, booking_date
|
||||
)
|
||||
SELECT d.day, d.account,
|
||||
COALESCE(o.base, 0)
|
||||
+ SUM(COALESCE(dt.day_amount, 0)) OVER (
|
||||
PARTITION BY d.account_id ORDER BY d.day
|
||||
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS balance
|
||||
FROM days d
|
||||
LEFT JOIN daily_tx dt ON dt.account_id = d.account_id AND dt.day = d.day
|
||||
LEFT JOIN offsets o ON o.account_id = d.account_id""",
|
||||
# Daily (gap-free) total balance across all accounts: same carry-forward
|
||||
# logic as v_balance_history, but summed globally (equivalent to summing
|
||||
# each account's own base + cumulated amounts, since a cumulative sum of
|
||||
# a union of per-day amounts equals the sum of per-account cumulative
|
||||
# sums). The day series spans from the earliest account start date
|
||||
# through CURRENT_DATE.
|
||||
"v_balance_total": f"""
|
||||
WITH offsets AS ({_ACCOUNT_OFFSET}),
|
||||
bounds AS ({_ACCOUNT_START}),
|
||||
days AS (
|
||||
SELECT gs.day::date AS day
|
||||
FROM (SELECT MIN(start_date) AS start_date FROM bounds) b
|
||||
CROSS JOIN LATERAL generate_series(
|
||||
b.start_date, CURRENT_DATE, interval '1 day') AS gs(day)
|
||||
),
|
||||
daily_tx AS (
|
||||
SELECT booking_date AS day, SUM(amount) AS day_amount
|
||||
FROM transactions WHERE status = 'confirmed'
|
||||
GROUP BY booking_date
|
||||
)
|
||||
SELECT d.day,
|
||||
(SELECT COALESCE(SUM(base), 0) FROM offsets)
|
||||
+ SUM(COALESCE(dt.day_amount, 0)) OVER (
|
||||
ORDER BY d.day
|
||||
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS balance
|
||||
FROM days d
|
||||
LEFT JOIN daily_tx dt ON dt.day = d.day""",
|
||||
"v_monthly_by_category": """
|
||||
SELECT date_trunc('month', t.booking_date) AS month,
|
||||
COALESCE(c.name, 'unkategorisiert') AS category,
|
||||
SUM(t.amount) AS total
|
||||
FROM transactions t LEFT JOIN categories c ON c.id = t.category_id
|
||||
WHERE t.status = 'confirmed' GROUP BY 1, 2""",
|
||||
"v_projection": """
|
||||
SELECT s.name AS scenario, p.day, p.balance
|
||||
FROM projection_points p JOIN scenarios s ON s.id = p.scenario_id""",
|
||||
}
|
||||
|
||||
|
||||
def create_views(engine: Engine) -> None:
|
||||
if engine.dialect.name != "postgresql":
|
||||
return
|
||||
with engine.begin() as conn:
|
||||
for name, body in VIEWS.items():
|
||||
# CREATE OR REPLACE VIEW fails if the output column set changed
|
||||
# (name/type/order). Fall back to DROP + CREATE in that case so a
|
||||
# redeploy always applies the current definition.
|
||||
try:
|
||||
with conn.begin_nested():
|
||||
conn.execute(
|
||||
text(f"CREATE OR REPLACE VIEW {name} AS {body}"))
|
||||
except Exception:
|
||||
conn.execute(text(f"DROP VIEW IF EXISTS {name} CASCADE"))
|
||||
conn.execute(text(f"CREATE VIEW {name} AS {body}"))
|
||||
0
finance/app/parsers/__init__.py
Normal file
0
finance/app/parsers/__init__.py
Normal file
49
finance/app/parsers/base.py
Normal file
49
finance/app/parsers/base.py
Normal 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}")
|
||||
245
finance/app/parsers/csv_formats.py
Normal file
245
finance/app/parsers/csv_formats.py
Normal 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)
|
||||
12
finance/app/parsers/detect.py
Normal file
12
finance/app/parsers/detect.py
Normal 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
184
finance/app/parsers/dkb.py
Normal 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
143
finance/app/parsers/hvb.py
Normal 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)
|
||||
21
finance/app/parsers/registry.py
Normal file
21
finance/app/parsers/registry.py
Normal 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)
|
||||
15
finance/app/parsers/validate.py
Normal file
15
finance/app/parsers/validate.py
Normal 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
139
finance/app/parsers/vr.py
Normal 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)
|
||||
0
finance/app/routers/__init__.py
Normal file
0
finance/app/routers/__init__.py
Normal file
111
finance/app/routers/accounts.py
Normal file
111
finance/app/routers/accounts.py
Normal 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 1–100 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
|
||||
66
finance/app/routers/admin.py
Normal file
66
finance/app/routers/admin.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_auth
|
||||
from app.db import get_session
|
||||
from app.models.tables import Category
|
||||
from app.routers.gui import gui_session, templates
|
||||
from app.services.admin import apply_rules_retroactively, change_password
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/admin", dependencies=[Depends(gui_session)])
|
||||
def admin_page(request: Request, session: Session = Depends(get_session)):
|
||||
return templates.TemplateResponse(request, "admin.html", {
|
||||
"error": None,
|
||||
"success": None,
|
||||
"categories": session.execute(select(Category)).scalars().all(),
|
||||
})
|
||||
|
||||
|
||||
@router.post("/admin/passwort", dependencies=[Depends(gui_session)])
|
||||
def admin_change_password(
|
||||
request: Request,
|
||||
alt: str = Form(...),
|
||||
neu: str = Form(...),
|
||||
neu2: str = Form(...),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
categories = session.execute(select(Category)).scalars().all()
|
||||
# UX-Regel: das Formular selbst bleibt immer sichtbar/bedienbar; Fehler
|
||||
# werden inline auf derselben Seite gemeldet statt still zu verwerfen.
|
||||
# Der Kategorien-Abschnitt bleibt aus demselben Grund in JEDEM Render-Pfad
|
||||
# (Fehler wie Erfolg) im Kontext, statt nur bei der GET-Route.
|
||||
if len(neu) < 8:
|
||||
return templates.TemplateResponse(request, "admin.html", {
|
||||
"error": "Das neue Passwort muss mindestens 8 Zeichen lang sein.",
|
||||
"success": None,
|
||||
"categories": categories,
|
||||
}, status_code=400)
|
||||
if neu != neu2:
|
||||
return templates.TemplateResponse(request, "admin.html", {
|
||||
"error": "Die Wiederholung stimmt nicht mit dem neuen Passwort überein.",
|
||||
"success": None,
|
||||
"categories": categories,
|
||||
}, status_code=400)
|
||||
try:
|
||||
change_password(alt, neu)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
return templates.TemplateResponse(request, "admin.html", {
|
||||
"error": str(exc),
|
||||
"success": None,
|
||||
"categories": categories,
|
||||
}, status_code=400)
|
||||
return templates.TemplateResponse(request, "admin.html", {
|
||||
"error": None,
|
||||
"success": "Passwort erfolgreich geändert (gilt für GUI und Grafana). "
|
||||
"Bestehende Sitzungen bleiben angemeldet.",
|
||||
"categories": categories,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/api/category-rules/apply", dependencies=[Depends(require_auth)])
|
||||
def apply_category_rules(session: Session = Depends(get_session)):
|
||||
return {"categorized": apply_rules_retroactively(session)}
|
||||
91
finance/app/routers/categories.py
Normal file
91
finance/app/routers/categories.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_auth
|
||||
from app.db import get_session
|
||||
from app.models.tables import Category, CategoryRule
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["categories"],
|
||||
dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
class CategoryIn(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class CategoryOut(CategoryIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
|
||||
|
||||
class CategoryRuleIn(BaseModel):
|
||||
pattern: str
|
||||
category_id: int
|
||||
priority: int = 100
|
||||
|
||||
|
||||
class CategoryRuleOut(CategoryRuleIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
|
||||
|
||||
@router.get("/categories", response_model=list[CategoryOut])
|
||||
def list_categories(session: Session = Depends(get_session)):
|
||||
return [CategoryOut.model_validate(c)
|
||||
for c in session.execute(select(Category)).scalars()]
|
||||
|
||||
|
||||
@router.post("/categories", response_model=CategoryOut, status_code=201)
|
||||
def create_category(data: CategoryIn, session: Session = Depends(get_session)):
|
||||
if session.execute(select(Category).where(Category.name == data.name)).scalar():
|
||||
raise HTTPException(409, "Kategorie existiert bereits")
|
||||
cat = Category(**data.model_dump())
|
||||
session.add(cat)
|
||||
session.commit()
|
||||
session.refresh(cat)
|
||||
return CategoryOut.model_validate(cat)
|
||||
|
||||
|
||||
@router.patch("/categories/{category_id}", response_model=CategoryOut)
|
||||
def patch_category(category_id: int, data: CategoryIn,
|
||||
session: Session = Depends(get_session)):
|
||||
cat = session.get(Category, category_id)
|
||||
if cat is None:
|
||||
raise HTTPException(404, "Kategorie nicht gefunden")
|
||||
clash = session.execute(
|
||||
select(Category).where(Category.name == data.name)).scalar()
|
||||
if clash is not None and clash.id != category_id:
|
||||
raise HTTPException(409, "Kategorie existiert bereits")
|
||||
cat.name = data.name
|
||||
session.commit()
|
||||
session.refresh(cat)
|
||||
return CategoryOut.model_validate(cat)
|
||||
|
||||
|
||||
@router.get("/category-rules", response_model=list[CategoryRuleOut])
|
||||
def list_category_rules(session: Session = Depends(get_session)):
|
||||
rules = session.execute(
|
||||
select(CategoryRule).order_by(CategoryRule.priority)).scalars()
|
||||
return [CategoryRuleOut.model_validate(r) for r in rules]
|
||||
|
||||
|
||||
@router.post("/category-rules", response_model=CategoryRuleOut, status_code=201)
|
||||
def create_category_rule(data: CategoryRuleIn, session: Session = Depends(get_session)):
|
||||
if session.get(Category, data.category_id) is None:
|
||||
raise HTTPException(404, "Kategorie nicht gefunden")
|
||||
rule = CategoryRule(**data.model_dump())
|
||||
session.add(rule)
|
||||
session.commit()
|
||||
session.refresh(rule)
|
||||
return CategoryRuleOut.model_validate(rule)
|
||||
|
||||
|
||||
@router.delete("/category-rules/{rule_id}", status_code=204)
|
||||
def delete_category_rule(rule_id: int, session: Session = Depends(get_session)):
|
||||
rule = session.get(CategoryRule, rule_id)
|
||||
if rule is None:
|
||||
raise HTTPException(404, "Regel nicht gefunden")
|
||||
session.delete(rule)
|
||||
session.commit()
|
||||
321
finance/app/routers/gui.py
Normal file
321
finance/app/routers/gui.py
Normal file
@@ -0,0 +1,321 @@
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import COOKIE, session_valid
|
||||
from app.db import get_session
|
||||
from app.engine.loans import add_months
|
||||
from app.engine.recurrence import occurrences
|
||||
from app.formats import de_label, eur
|
||||
from app.models.tables import (Account, Category, PlannedItem,
|
||||
ProjectionResult, RecurringItem,
|
||||
ScenarioLoan, ScenarioModifier,
|
||||
ScenarioPlannedItem, Transaction)
|
||||
from app.routers.imports import list_imports as _list_imports
|
||||
from app.routers.imports import preview as _preview_import
|
||||
from app.routers.planning import list_loans as _list_loans
|
||||
from app.routers.planning import list_planned as _list_planned
|
||||
from app.routers.planning import list_recurring as _list_recurring
|
||||
from app.routers.planning import recurring_suggestions as _recurring_suggestions
|
||||
from app.routers.scenarios import list_scenarios as _list_scenarios
|
||||
from app.routers.transactions import count_transactions, list_transactions
|
||||
from app.services.balances import account_balance, total_balance
|
||||
from app.version import get_version
|
||||
|
||||
PAGE_SIZE = 50
|
||||
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
templates.env.globals["app_version"] = get_version()
|
||||
templates.env.filters["eur"] = eur
|
||||
templates.env.filters["de_label"] = de_label
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def gui_session(request: Request):
|
||||
if not session_valid(request.cookies.get(COOKIE)):
|
||||
raise _redirect()
|
||||
|
||||
|
||||
def _redirect():
|
||||
exc = HTTPException(302)
|
||||
exc.headers = {"Location": "/login"}
|
||||
return exc
|
||||
|
||||
|
||||
def _upcoming_items(session: Session, today: date, days: int = 30, limit: int = 10) -> list[dict]:
|
||||
"""Nächste Posten aus RecurringItems (Termine) und PlannedItems der nächsten `days` Tage."""
|
||||
end = today + timedelta(days=days)
|
||||
items: list[dict] = []
|
||||
for r in session.execute(select(RecurringItem)).scalars():
|
||||
for d in occurrences(r.rhythm, r.due_day, today, end, r.start_date, r.end_date):
|
||||
items.append({"date": d, "name": r.name, "amount": Decimal(r.amount)})
|
||||
for p in session.execute(
|
||||
select(PlannedItem).where(PlannedItem.due >= today, PlannedItem.due <= end)
|
||||
).scalars():
|
||||
items.append({"date": p.due, "name": p.name, "amount": Decimal(p.amount)})
|
||||
items.sort(key=lambda i: i["date"])
|
||||
return items[:limit]
|
||||
|
||||
|
||||
def _latest_projection(session: Session) -> ProjectionResult | None:
|
||||
return session.execute(
|
||||
select(ProjectionResult).order_by(ProjectionResult.computed_at.desc())
|
||||
).scalars().first()
|
||||
|
||||
|
||||
def _opt_int(value: str | None) -> int | None:
|
||||
"""Wandelt einen GUI-Query-Parameter tolerant in int|None um. HTML-Formulare
|
||||
senden bei nicht ausgefüllten Feldern leere Strings statt gar keinen
|
||||
Parameter - das darf nie zu einem 422 fuehren, sondern degradiert zu None."""
|
||||
if value is None or not value.strip():
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _opt_date(value: str | None) -> date | None:
|
||||
"""Wie _opt_int, nur fuer date-Felder (ISO-Format aus <input type=date>)."""
|
||||
if value is None or not value.strip():
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _month_starts_desc(session: Session, today: date) -> list[date]:
|
||||
"""Liefert die Monatsersten von "heute" rueckwaerts bis zum Monat der
|
||||
fruehesten bestaetigten Buchung, absteigend sortiert (heute zuerst).
|
||||
Ohne bestaetigte Buchungen: leere Liste."""
|
||||
earliest = session.execute(
|
||||
select(func.min(Transaction.booking_date)).where(Transaction.status == "confirmed")
|
||||
).scalar_one_or_none()
|
||||
if earliest is None:
|
||||
return []
|
||||
start = date(earliest.year, earliest.month, 1)
|
||||
current = date(today.year, today.month, 1)
|
||||
months = []
|
||||
m = current
|
||||
while m >= start:
|
||||
months.append(m)
|
||||
m = add_months(m, -1)
|
||||
return months
|
||||
|
||||
|
||||
@router.get("/", dependencies=[Depends(gui_session)])
|
||||
def index(request: Request, session: Session = Depends(get_session)):
|
||||
accounts = session.execute(select(Account)).scalars().all()
|
||||
balances = [(a, account_balance(session, a)) for a in accounts]
|
||||
return templates.TemplateResponse(request, "index.html", {
|
||||
"balances": balances,
|
||||
"total": total_balance(session),
|
||||
"upcoming": _upcoming_items(session, date.today()),
|
||||
"projection": _latest_projection(session),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/buchungen", dependencies=[Depends(gui_session)])
|
||||
def buchungen(
|
||||
request: Request,
|
||||
account_id: str | None = None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
category_id: str | None = None,
|
||||
q: str | None = None,
|
||||
status: str = "confirmed",
|
||||
page: int = 1,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
# HTML-Filterformular sendet leere Strings fuer nicht ausgefuellte Felder -
|
||||
# daher hier bewusst str|None statt int|None/date|None und tolerante
|
||||
# Konvertierung, statt FastAPI vor dem Handler mit 422 abbrechen zu lassen.
|
||||
# Die API-Routen (/api/transactions) bleiben strikt typisiert.
|
||||
account_id = _opt_int(account_id)
|
||||
date_from = _opt_date(date_from)
|
||||
date_to = _opt_date(date_to)
|
||||
category_id = _opt_int(category_id)
|
||||
page = max(1, page)
|
||||
total = count_transactions(
|
||||
account_id=account_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
category_id=category_id,
|
||||
q=q,
|
||||
status=status,
|
||||
session=session,
|
||||
)
|
||||
total_pages = max(1, -(-total // PAGE_SIZE)) # ceil division
|
||||
page = min(page, total_pages)
|
||||
txs = list_transactions(
|
||||
account_id=account_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
category_id=category_id,
|
||||
q=q,
|
||||
status=status,
|
||||
limit=PAGE_SIZE,
|
||||
offset=(page - 1) * PAGE_SIZE,
|
||||
session=session,
|
||||
)
|
||||
accounts = session.execute(select(Account)).scalars().all()
|
||||
return templates.TemplateResponse(request, "transactions.html", {
|
||||
"transactions": txs,
|
||||
"accounts": accounts,
|
||||
"account_names": {a.id: a.name for a in accounts},
|
||||
"categories": session.execute(select(Category)).scalars().all(),
|
||||
"filters": {
|
||||
"account_id": account_id,
|
||||
"date_from": date_from,
|
||||
"date_to": date_to,
|
||||
"category_id": category_id,
|
||||
"q": q or "",
|
||||
"status": status,
|
||||
},
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/salden", dependencies=[Depends(gui_session)])
|
||||
def salden_page(
|
||||
request: Request,
|
||||
stichtag: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
# Lenient wie die Filter auf /buchungen: ein leerer oder kaputter
|
||||
# stichtag-Parameter fuehrt nie zu 422, sondern faellt auf "heute" zurueck.
|
||||
today = date.today()
|
||||
parsed_stichtag = _opt_date(stichtag) or today
|
||||
|
||||
accounts = session.execute(select(Account)).scalars().all()
|
||||
balances = [(a, account_balance(session, a, at=parsed_stichtag)) for a in accounts]
|
||||
total = sum((b for _, b in balances), Decimal("0"))
|
||||
|
||||
month_rows = []
|
||||
for month in _month_starts_desc(session, today):
|
||||
# Saldo am Monatsanfang = Stand am ENDE DES VORTAGS (letzter Tag des
|
||||
# Vormonats), NICHT account_balance(at=month) - sonst wuerden
|
||||
# Buchungen AM 1. bereits in den "Anfangs"-Saldo einfliessen und den
|
||||
# Ausweis verzerren. Siehe Fussnote im Template ("Stand: Ende des
|
||||
# Vortags").
|
||||
vortag = month - timedelta(days=1)
|
||||
values = [(a, account_balance(session, a, at=vortag)) for a in accounts]
|
||||
month_rows.append({
|
||||
"month": month,
|
||||
"values": values,
|
||||
"total": sum((v for _, v in values), Decimal("0")),
|
||||
})
|
||||
|
||||
accounts_without_anchor = [
|
||||
a for a in accounts if a.anchor_date is None or a.anchor_balance is None
|
||||
]
|
||||
|
||||
return templates.TemplateResponse(request, "salden.html", {
|
||||
"stichtag": parsed_stichtag,
|
||||
"balances": balances,
|
||||
"total": total,
|
||||
"accounts": accounts,
|
||||
"months": month_rows,
|
||||
"accounts_without_anchor": accounts_without_anchor,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/import", dependencies=[Depends(gui_session)])
|
||||
def import_page(request: Request, session: Session = Depends(get_session)):
|
||||
return templates.TemplateResponse(request, "import.html", {
|
||||
"statements": _list_imports(session=session),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/import/list", dependencies=[Depends(gui_session)])
|
||||
def import_list_fragment(request: Request, session: Session = Depends(get_session)):
|
||||
return templates.TemplateResponse(request, "_import_list.html", {
|
||||
"statements": _list_imports(session=session),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/import/{statement_id}/preview-fragment", dependencies=[Depends(gui_session)])
|
||||
def import_preview_fragment(statement_id: int, request: Request,
|
||||
session: Session = Depends(get_session)):
|
||||
result = _preview_import(statement_id, session=session)
|
||||
return templates.TemplateResponse(request, "_preview_table.html", {
|
||||
"statement": result.statement,
|
||||
"transactions": result.transactions,
|
||||
"balance_ok": result.balance_ok,
|
||||
"duplicates": result.duplicates,
|
||||
})
|
||||
|
||||
|
||||
def _scenario_rows(session: Session, scenarios: list, loans: list) -> list[dict]:
|
||||
"""Baut je Szenario den Kontext fürs Template: zugeordnete Kredite,
|
||||
Modifikatoren und (falls vorhanden) das letzte Projektionsergebnis."""
|
||||
rows = []
|
||||
for sc in scenarios:
|
||||
assigned_loan_ids = {
|
||||
sl.loan_id for sl in session.execute(
|
||||
select(ScenarioLoan).where(ScenarioLoan.scenario_id == sc.id)
|
||||
).scalars()
|
||||
}
|
||||
modifiers = session.execute(
|
||||
select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id)
|
||||
.order_by(ScenarioModifier.id)
|
||||
).scalars().all()
|
||||
planned_items = session.execute(
|
||||
select(ScenarioPlannedItem)
|
||||
.where(ScenarioPlannedItem.scenario_id == sc.id)
|
||||
.order_by(ScenarioPlannedItem.due)).scalars().all()
|
||||
rows.append({
|
||||
"scenario": sc,
|
||||
"assigned_loan_ids": assigned_loan_ids,
|
||||
"modifiers": modifiers,
|
||||
"planned_items": planned_items,
|
||||
"result": session.get(ProjectionResult, sc.id),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/hilfe", dependencies=[Depends(gui_session)])
|
||||
def hilfe_page(request: Request):
|
||||
return templates.TemplateResponse(request, "hilfe.html", {})
|
||||
|
||||
|
||||
@router.get("/planung", dependencies=[Depends(gui_session)])
|
||||
def planung_page(request: Request, session: Session = Depends(get_session)):
|
||||
categories = session.execute(select(Category)).scalars().all()
|
||||
recurring = _list_recurring(session=session)
|
||||
planned = _list_planned(session=session)
|
||||
loans = _list_loans(session=session)
|
||||
return templates.TemplateResponse(request, "planning.html", {
|
||||
"categories": categories,
|
||||
"category_names": {c.id: c.name for c in categories},
|
||||
"recurring": recurring,
|
||||
"recurring_names": {r.id: r.name for r in recurring},
|
||||
"suggestions": _recurring_suggestions(session=session),
|
||||
"planned": planned,
|
||||
"loans": loans,
|
||||
"rhythms": ["monthly", "quarterly", "yearly"],
|
||||
"repayment_types": ["annuity", "bullet"],
|
||||
})
|
||||
|
||||
|
||||
@router.get("/szenarien", dependencies=[Depends(gui_session)])
|
||||
def szenarien_page(request: Request, session: Session = Depends(get_session)):
|
||||
categories = session.execute(select(Category)).scalars().all()
|
||||
recurring = _list_recurring(session=session)
|
||||
loans = _list_loans(session=session)
|
||||
scenarios = _list_scenarios(session=session)
|
||||
return templates.TemplateResponse(request, "szenarien.html", {
|
||||
"categories": categories,
|
||||
"category_names": {c.id: c.name for c in categories},
|
||||
"recurring": recurring,
|
||||
"recurring_names": {r.id: r.name for r in recurring},
|
||||
"loans": loans,
|
||||
"scenario_rows": _scenario_rows(session, scenarios, loans),
|
||||
"modifier_kinds": ["percent", "absolute", "remove", "ende"],
|
||||
})
|
||||
195
finance/app/routers/imports.py
Normal file
195
finance/app/routers/imports.py
Normal 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()
|
||||
262
finance/app/routers/planning.py
Normal file
262
finance/app/routers/planning.py
Normal file
@@ -0,0 +1,262 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_auth
|
||||
from app.db import get_session
|
||||
from app.engine.loans import loan_schedule
|
||||
from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
|
||||
ScenarioLoan)
|
||||
from app.services.suggestions import suggest_recurring
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["planning"],
|
||||
dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Recurring
|
||||
class RecurringIn(BaseModel):
|
||||
name: str
|
||||
amount: Decimal
|
||||
rhythm: Literal["monthly", "quarterly", "yearly"]
|
||||
due_day: int = Field(ge=1, le=31)
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
category_id: int | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _ende_nicht_vor_start(self):
|
||||
if (self.start_date is not None and self.end_date is not None
|
||||
and self.end_date < self.start_date):
|
||||
raise ValueError("Ende darf nicht vor Start liegen")
|
||||
return self
|
||||
|
||||
|
||||
class RecurringOut(RecurringIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
|
||||
|
||||
class RecurringPatch(BaseModel):
|
||||
name: str | None = None
|
||||
amount: Decimal | None = None
|
||||
rhythm: Literal["monthly", "quarterly", "yearly"] | None = None
|
||||
due_day: int | None = Field(default=None, ge=1, le=31)
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
category_id: int | None = None
|
||||
|
||||
|
||||
class SuggestionOut(BaseModel):
|
||||
name: str
|
||||
amount: Decimal
|
||||
rhythm: str
|
||||
due_day: int
|
||||
start_date: date | None = None
|
||||
category_id: int | None = None
|
||||
hinweis: str = ""
|
||||
|
||||
|
||||
def _check_category(session: Session, category_id: int | None) -> None:
|
||||
if category_id is not None and session.get(Category, category_id) is None:
|
||||
raise HTTPException(404, "Kategorie nicht gefunden")
|
||||
|
||||
|
||||
@router.get("/recurring", response_model=list[RecurringOut])
|
||||
def list_recurring(session: Session = Depends(get_session)):
|
||||
return [RecurringOut.model_validate(r)
|
||||
for r in session.execute(select(RecurringItem)).scalars()]
|
||||
|
||||
|
||||
@router.post("/recurring", response_model=RecurringOut, status_code=201)
|
||||
def create_recurring(data: RecurringIn, session: Session = Depends(get_session)):
|
||||
_check_category(session, data.category_id)
|
||||
item = RecurringItem(**data.model_dump())
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return RecurringOut.model_validate(item)
|
||||
|
||||
|
||||
# Muss vor /recurring/{item_id} stehen, sonst faengt die {id}-Route den Pfad ab.
|
||||
@router.get("/recurring/suggestions", response_model=list[SuggestionOut])
|
||||
def recurring_suggestions(session: Session = Depends(get_session)):
|
||||
return suggest_recurring(session)
|
||||
|
||||
|
||||
@router.patch("/recurring/{item_id}", response_model=RecurringOut)
|
||||
def patch_recurring(item_id: int, data: RecurringPatch,
|
||||
session: Session = Depends(get_session)):
|
||||
item = session.get(RecurringItem, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(404, "Fixposten nicht gefunden")
|
||||
fields = data.model_dump(exclude_unset=True)
|
||||
if "category_id" in fields:
|
||||
_check_category(session, fields["category_id"])
|
||||
for key, value in fields.items():
|
||||
setattr(item, key, value)
|
||||
if (item.start_date is not None and item.end_date is not None
|
||||
and item.end_date < item.start_date):
|
||||
session.rollback()
|
||||
raise HTTPException(422, "Ende darf nicht vor Start liegen")
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return RecurringOut.model_validate(item)
|
||||
|
||||
|
||||
@router.delete("/recurring/{item_id}", status_code=204)
|
||||
def delete_recurring(item_id: int, session: Session = Depends(get_session)):
|
||||
item = session.get(RecurringItem, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(404, "Fixposten nicht gefunden")
|
||||
session.delete(item)
|
||||
session.commit()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Planned
|
||||
class PlannedIn(BaseModel):
|
||||
name: str
|
||||
amount: Decimal
|
||||
due: date
|
||||
category_id: int | None = None
|
||||
|
||||
|
||||
class PlannedOut(PlannedIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
|
||||
|
||||
class PlannedPatch(BaseModel):
|
||||
name: str | None = None
|
||||
amount: Decimal | None = None
|
||||
due: date | None = None
|
||||
category_id: int | None = None
|
||||
|
||||
|
||||
@router.get("/planned", response_model=list[PlannedOut])
|
||||
def list_planned(session: Session = Depends(get_session)):
|
||||
return [PlannedOut.model_validate(p)
|
||||
for p in session.execute(select(PlannedItem)).scalars()]
|
||||
|
||||
|
||||
@router.post("/planned", response_model=PlannedOut, status_code=201)
|
||||
def create_planned(data: PlannedIn, session: Session = Depends(get_session)):
|
||||
_check_category(session, data.category_id)
|
||||
item = PlannedItem(**data.model_dump())
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return PlannedOut.model_validate(item)
|
||||
|
||||
|
||||
@router.patch("/planned/{item_id}", response_model=PlannedOut)
|
||||
def patch_planned(item_id: int, data: PlannedPatch,
|
||||
session: Session = Depends(get_session)):
|
||||
item = session.get(PlannedItem, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(404, "Einmalposten nicht gefunden")
|
||||
fields = data.model_dump(exclude_unset=True)
|
||||
if "category_id" in fields:
|
||||
_check_category(session, fields["category_id"])
|
||||
for key, value in fields.items():
|
||||
setattr(item, key, value)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return PlannedOut.model_validate(item)
|
||||
|
||||
|
||||
@router.delete("/planned/{item_id}", status_code=204)
|
||||
def delete_planned(item_id: int, session: Session = Depends(get_session)):
|
||||
item = session.get(PlannedItem, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(404, "Einmalposten nicht gefunden")
|
||||
session.delete(item)
|
||||
session.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- Loans
|
||||
class LoanIn(BaseModel):
|
||||
name: str
|
||||
principal: Decimal
|
||||
annual_rate_pct: Decimal
|
||||
term_months: int
|
||||
payout_date: date
|
||||
repayment_type: Literal["annuity", "bullet"] = "annuity"
|
||||
|
||||
|
||||
class LoanOut(LoanIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
|
||||
|
||||
class LoanPatch(BaseModel):
|
||||
name: str | None = None
|
||||
principal: Decimal | None = None
|
||||
annual_rate_pct: Decimal | None = None
|
||||
term_months: int | None = None
|
||||
payout_date: date | None = None
|
||||
repayment_type: Literal["annuity", "bullet"] | None = None
|
||||
|
||||
|
||||
class InstallmentOut(BaseModel):
|
||||
due: date
|
||||
payment: Decimal
|
||||
interest: Decimal
|
||||
principal: Decimal
|
||||
remaining: Decimal
|
||||
|
||||
|
||||
@router.get("/loans", response_model=list[LoanOut])
|
||||
def list_loans(session: Session = Depends(get_session)):
|
||||
return [LoanOut.model_validate(l)
|
||||
for l in session.execute(select(Loan)).scalars()]
|
||||
|
||||
|
||||
@router.post("/loans", response_model=LoanOut, status_code=201)
|
||||
def create_loan(data: LoanIn, session: Session = Depends(get_session)):
|
||||
loan = Loan(**data.model_dump())
|
||||
session.add(loan)
|
||||
session.commit()
|
||||
session.refresh(loan)
|
||||
return LoanOut.model_validate(loan)
|
||||
|
||||
|
||||
@router.patch("/loans/{loan_id}", response_model=LoanOut)
|
||||
def patch_loan(loan_id: int, data: LoanPatch, session: Session = Depends(get_session)):
|
||||
loan = session.get(Loan, loan_id)
|
||||
if loan is None:
|
||||
raise HTTPException(404, "Kredit nicht gefunden")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(loan, key, value)
|
||||
session.commit()
|
||||
session.refresh(loan)
|
||||
return LoanOut.model_validate(loan)
|
||||
|
||||
|
||||
@router.delete("/loans/{loan_id}", status_code=204)
|
||||
def delete_loan(loan_id: int, session: Session = Depends(get_session)):
|
||||
loan = session.get(Loan, loan_id)
|
||||
if loan is None:
|
||||
raise HTTPException(404, "Kredit nicht gefunden")
|
||||
# Remove scenario assignments first: no DB-level cascade exists, so a
|
||||
# dangling ScenarioLoan row would raise a foreign-key violation (500 on
|
||||
# Postgres).
|
||||
session.execute(delete(ScenarioLoan).where(ScenarioLoan.loan_id == loan_id))
|
||||
session.delete(loan)
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.get("/loans/{loan_id}/schedule", response_model=list[InstallmentOut])
|
||||
def get_loan_schedule(loan_id: int, session: Session = Depends(get_session)):
|
||||
loan = session.get(Loan, loan_id)
|
||||
if loan is None:
|
||||
raise HTTPException(404, "Kredit nicht gefunden")
|
||||
plan = loan_schedule(Decimal(loan.principal), Decimal(loan.annual_rate_pct),
|
||||
loan.term_months, loan.payout_date, loan.repayment_type)
|
||||
return [InstallmentOut(due=i.due, payment=i.payment, interest=i.interest,
|
||||
principal=i.principal, remaining=i.remaining)
|
||||
for i in plan]
|
||||
277
finance/app/routers/scenarios.py
Normal file
277
finance/app/routers/scenarios.py
Normal file
@@ -0,0 +1,277 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_auth
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
|
||||
Scenario, ScenarioLoan, ScenarioModifier,
|
||||
ScenarioPlannedItem)
|
||||
from app.services.projection_service import run_projection
|
||||
|
||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
|
||||
dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
class ScenarioIn(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
include_recurring: bool = True
|
||||
include_planned: bool = True
|
||||
|
||||
|
||||
class ScenarioOut(ScenarioIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
|
||||
|
||||
class ScenarioPatch(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
include_recurring: bool | None = None
|
||||
include_planned: bool | None = None
|
||||
|
||||
@field_validator("description")
|
||||
@classmethod
|
||||
def _leere_beschreibung(cls, v):
|
||||
return "" if v is None else v
|
||||
|
||||
|
||||
class ModifierIn(BaseModel):
|
||||
target_type: Literal["category", "recurring"]
|
||||
target_id: int
|
||||
kind: Literal["percent", "absolute", "remove", "ende"]
|
||||
value: Decimal = Decimal("0")
|
||||
end_date: date | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _ende_braucht_datum(self):
|
||||
if self.kind == "ende" and self.end_date is None:
|
||||
raise ValueError("Art »Ende« erfordert ein Datum")
|
||||
if self.kind != "ende":
|
||||
self.end_date = None
|
||||
return self
|
||||
|
||||
|
||||
class ModifierOut(ModifierIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
scenario_id: int
|
||||
|
||||
|
||||
class SeriesPointOut(BaseModel):
|
||||
day: date
|
||||
balance: Decimal
|
||||
|
||||
|
||||
class ProjectionOut(BaseModel):
|
||||
low_point_date: date
|
||||
low_point_balance: Decimal
|
||||
below_zero_date: date | None
|
||||
below_threshold_date: date | None
|
||||
series: list[SeriesPointOut]
|
||||
|
||||
|
||||
def _get_scenario(session: Session, scenario_id: int) -> Scenario:
|
||||
scenario = session.get(Scenario, scenario_id)
|
||||
if scenario is None:
|
||||
raise HTTPException(404, "Szenario nicht gefunden")
|
||||
return scenario
|
||||
|
||||
|
||||
@router.get("", response_model=list[ScenarioOut])
|
||||
def list_scenarios(session: Session = Depends(get_session)):
|
||||
return [ScenarioOut.model_validate(s)
|
||||
for s in session.execute(select(Scenario)).scalars()]
|
||||
|
||||
|
||||
@router.post("", response_model=ScenarioOut, status_code=201)
|
||||
def create_scenario(data: ScenarioIn, session: Session = Depends(get_session)):
|
||||
if session.execute(select(Scenario).where(Scenario.name == data.name)).scalar():
|
||||
raise HTTPException(409, "Szenario existiert bereits")
|
||||
scenario = Scenario(**data.model_dump())
|
||||
session.add(scenario)
|
||||
session.commit()
|
||||
session.refresh(scenario)
|
||||
return ScenarioOut.model_validate(scenario)
|
||||
|
||||
|
||||
@router.patch("/{scenario_id}", response_model=ScenarioOut)
|
||||
def patch_scenario(scenario_id: int, data: ScenarioPatch,
|
||||
session: Session = Depends(get_session)):
|
||||
scenario = _get_scenario(session, scenario_id)
|
||||
fields = data.model_dump(exclude_unset=True)
|
||||
if "name" in fields and fields["name"] != scenario.name:
|
||||
clash = session.execute(
|
||||
select(Scenario).where(Scenario.name == fields["name"])).scalar()
|
||||
if clash is not None:
|
||||
raise HTTPException(409, "Szenario existiert bereits")
|
||||
for key, value in fields.items():
|
||||
setattr(scenario, key, value)
|
||||
session.commit()
|
||||
session.refresh(scenario)
|
||||
return ScenarioOut.model_validate(scenario)
|
||||
|
||||
|
||||
@router.delete("/{scenario_id}", status_code=204)
|
||||
def delete_scenario(scenario_id: int, session: Session = Depends(get_session)):
|
||||
scenario = _get_scenario(session, scenario_id)
|
||||
# No DB-level cascades exist, so remove dependent rows explicitly to avoid
|
||||
# foreign-key violations (would surface as a 500 on Postgres).
|
||||
session.execute(delete(ProjectionPoint).where(
|
||||
ProjectionPoint.scenario_id == scenario_id))
|
||||
session.execute(delete(ProjectionResult).where(
|
||||
ProjectionResult.scenario_id == scenario_id))
|
||||
session.execute(delete(ScenarioLoan).where(
|
||||
ScenarioLoan.scenario_id == scenario_id))
|
||||
session.execute(delete(ScenarioModifier).where(
|
||||
ScenarioModifier.scenario_id == scenario_id))
|
||||
session.execute(delete(ScenarioPlannedItem).where(
|
||||
ScenarioPlannedItem.scenario_id == scenario_id))
|
||||
session.delete(scenario)
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.post("/{scenario_id}/loans/{loan_id}", status_code=204)
|
||||
def add_scenario_loan(scenario_id: int, loan_id: int,
|
||||
session: Session = Depends(get_session)):
|
||||
_get_scenario(session, scenario_id)
|
||||
if session.get(Loan, loan_id) is None:
|
||||
raise HTTPException(404, "Kredit nicht gefunden")
|
||||
if session.get(ScenarioLoan, (scenario_id, loan_id)) is None:
|
||||
session.add(ScenarioLoan(scenario_id=scenario_id, loan_id=loan_id))
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.delete("/{scenario_id}/loans/{loan_id}", status_code=204)
|
||||
def remove_scenario_loan(scenario_id: int, loan_id: int,
|
||||
session: Session = Depends(get_session)):
|
||||
_get_scenario(session, scenario_id)
|
||||
link = session.get(ScenarioLoan, (scenario_id, loan_id))
|
||||
if link is not None:
|
||||
session.delete(link)
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.post("/{scenario_id}/modifiers", response_model=ModifierOut, status_code=201)
|
||||
def add_modifier(scenario_id: int, data: ModifierIn,
|
||||
session: Session = Depends(get_session)):
|
||||
_get_scenario(session, scenario_id)
|
||||
modifier = ScenarioModifier(scenario_id=scenario_id, **data.model_dump())
|
||||
session.add(modifier)
|
||||
session.commit()
|
||||
session.refresh(modifier)
|
||||
return ModifierOut.model_validate(modifier)
|
||||
|
||||
|
||||
@router.delete("/{scenario_id}/modifiers/{mod_id}", status_code=204)
|
||||
def delete_modifier(scenario_id: int, mod_id: int,
|
||||
session: Session = Depends(get_session)):
|
||||
_get_scenario(session, scenario_id)
|
||||
modifier = session.get(ScenarioModifier, mod_id)
|
||||
if modifier is None or modifier.scenario_id != scenario_id:
|
||||
raise HTTPException(404, "Modifikator nicht gefunden")
|
||||
session.delete(modifier)
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.patch("/{scenario_id}/modifiers/{mod_id}", response_model=ModifierOut)
|
||||
def patch_modifier(scenario_id: int, mod_id: int, data: ModifierIn,
|
||||
session: Session = Depends(get_session)):
|
||||
# Full-Body-Update: das Edit-Formular sendet immer alle Felder des Typs.
|
||||
_get_scenario(session, scenario_id)
|
||||
modifier = session.get(ScenarioModifier, mod_id)
|
||||
if modifier is None or modifier.scenario_id != scenario_id:
|
||||
raise HTTPException(404, "Modifikator nicht gefunden")
|
||||
for key, value in data.model_dump().items():
|
||||
setattr(modifier, key, value)
|
||||
session.commit()
|
||||
session.refresh(modifier)
|
||||
return ModifierOut.model_validate(modifier)
|
||||
|
||||
|
||||
class ScenarioPlannedIn(BaseModel):
|
||||
name: str
|
||||
amount: Decimal
|
||||
due: date
|
||||
|
||||
|
||||
class ScenarioPlannedOut(ScenarioPlannedIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
scenario_id: int
|
||||
|
||||
|
||||
@router.get("/{scenario_id}/planned", response_model=list[ScenarioPlannedOut])
|
||||
def list_scenario_planned(scenario_id: int, session: Session = Depends(get_session)):
|
||||
_get_scenario(session, scenario_id)
|
||||
return [ScenarioPlannedOut.model_validate(p) for p in session.execute(
|
||||
select(ScenarioPlannedItem)
|
||||
.where(ScenarioPlannedItem.scenario_id == scenario_id)
|
||||
.order_by(ScenarioPlannedItem.due)).scalars()]
|
||||
|
||||
|
||||
@router.post("/{scenario_id}/planned", response_model=ScenarioPlannedOut,
|
||||
status_code=201)
|
||||
def add_scenario_planned(scenario_id: int, data: ScenarioPlannedIn,
|
||||
session: Session = Depends(get_session)):
|
||||
_get_scenario(session, scenario_id)
|
||||
item = ScenarioPlannedItem(scenario_id=scenario_id, **data.model_dump())
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return ScenarioPlannedOut.model_validate(item)
|
||||
|
||||
|
||||
@router.delete("/{scenario_id}/planned/{item_id}", status_code=204)
|
||||
def delete_scenario_planned(scenario_id: int, item_id: int,
|
||||
session: Session = Depends(get_session)):
|
||||
_get_scenario(session, scenario_id)
|
||||
item = session.get(ScenarioPlannedItem, item_id)
|
||||
if item is None or item.scenario_id != scenario_id:
|
||||
raise HTTPException(404, "Einmalzahlung nicht gefunden")
|
||||
session.delete(item)
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.patch("/{scenario_id}/planned/{item_id}", response_model=ScenarioPlannedOut)
|
||||
def patch_scenario_planned(scenario_id: int, item_id: int, data: ScenarioPlannedIn,
|
||||
session: Session = Depends(get_session)):
|
||||
# Full-Body-Update, Muster patch_modifier.
|
||||
_get_scenario(session, scenario_id)
|
||||
item = session.get(ScenarioPlannedItem, item_id)
|
||||
if item is None or item.scenario_id != scenario_id:
|
||||
raise HTTPException(404, "Einmalzahlung nicht gefunden")
|
||||
for key, value in data.model_dump().items():
|
||||
setattr(item, key, value)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return ScenarioPlannedOut.model_validate(item)
|
||||
|
||||
|
||||
@router.post("/{scenario_id}/project", response_model=ProjectionOut)
|
||||
def project_scenario(scenario_id: int, horizon_days: int | None = None,
|
||||
start_date: date | None = None,
|
||||
session: Session = Depends(get_session)):
|
||||
scenario = _get_scenario(session, scenario_id)
|
||||
settings = get_settings()
|
||||
resolved_horizon = horizon_days if horizon_days is not None else settings.horizon_days
|
||||
resolved_start = start_date if start_date is not None else date.today()
|
||||
result = run_projection(session, scenario, resolved_horizon, resolved_start)
|
||||
points = session.execute(
|
||||
select(ProjectionPoint).where(ProjectionPoint.scenario_id == scenario_id)
|
||||
.order_by(ProjectionPoint.day)
|
||||
).scalars().all()
|
||||
return ProjectionOut(
|
||||
low_point_date=result.low_point_date,
|
||||
low_point_balance=result.low_point_balance,
|
||||
below_zero_date=result.below_zero_date,
|
||||
below_threshold_date=result.below_threshold_date,
|
||||
series=[SeriesPointOut(day=p.day, balance=p.balance) for p in points],
|
||||
)
|
||||
157
finance/app/routers/transactions.py
Normal file
157
finance/app/routers/transactions.py
Normal 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)
|
||||
0
finance/app/services/__init__.py
Normal file
0
finance/app/services/__init__.py
Normal file
116
finance/app/services/admin.py
Normal file
116
finance/app/services/admin.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Admin-Funktionen: Passwortaenderung (GUI+Grafana gemeinsam) und
|
||||
Regel-Neuanwendung (Ausbaustufe 4 Task 2).
|
||||
|
||||
KRITISCH (siehe Global Constraints des Plans): die .env wird im API-Container
|
||||
als Einzeldatei-Bind-Mount eingehaengt. Ein solcher Mount folgt dem Inode -
|
||||
die Datei MUSS in-place ueberschrieben werden (open r+/truncate unter flock),
|
||||
NIEMALS ueber Temp-Datei+rename (das erzeugt einen neuen Inode und würde vom
|
||||
Container nicht mehr gesehen).
|
||||
"""
|
||||
import fcntl
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import current_password_hash, hash_password, verify_password
|
||||
from app.config import get_settings
|
||||
from app.models.tables import Transaction
|
||||
from app.services.categorize import apply_rules
|
||||
|
||||
|
||||
def _sync_grafana_password(old: str, new: str, base_url: str) -> None:
|
||||
"""PUT /api/admin/users/1/password gegen Grafana, Basic-Auth mit dem
|
||||
ALTEN Passwort (admin:old) - Grafana authentifiziert den Request noch mit
|
||||
dem bisherigen Passwort, aendert es aber auf `new`. Wird IMMER vor dem
|
||||
Schreiben der .env aufgerufen: schlaegt Grafana fehl, bleibt die Datei
|
||||
unangetastet (kein inkonsistenter Zwischenzustand GUI-Hash != Grafana)."""
|
||||
url = f"{base_url.rstrip('/')}/api/admin/users/1/password"
|
||||
auth_header = "Basic " + b64encode(f"admin:{old}".encode()).decode()
|
||||
body = json.dumps({"password": new}).encode()
|
||||
req = urllib.request.Request(
|
||||
url, data=body, method="PUT",
|
||||
headers={"Authorization": auth_header, "Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
if resp.status >= 400:
|
||||
raise RuntimeError(
|
||||
f"Grafana-Passwortänderung fehlgeschlagen (HTTP {resp.status})."
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise RuntimeError(
|
||||
f"Grafana-Passwortänderung fehlgeschlagen (HTTP {exc.code})."
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(
|
||||
f"Grafana-Passwortänderung fehlgeschlagen: {exc.reason}"
|
||||
) from exc
|
||||
except (TimeoutError, OSError) as exc:
|
||||
# Ein Hang in der Lesephase (nach erfolgreichem Verbindungsaufbau)
|
||||
# kann ein rohes TimeoutError/socket.timeout werfen, das urlopen NICHT
|
||||
# in ein URLError verpackt (URLError selbst ist zwar ein OSError, aber
|
||||
# dieser Pfad faengt Faelle ab, die es nicht bis dorthin schaffen).
|
||||
# Ohne diesen Fang wuerde die Exception bis in den Router durchschlagen
|
||||
# und dort als 500 statt als deutsche 400-Fehlermeldung enden.
|
||||
raise RuntimeError(
|
||||
f"Grafana-Passwortänderung fehlgeschlagen: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _rewrite_env_file(env_file: Path, new_password: str, new_hash: str) -> None:
|
||||
"""Ersetzt FB_PASSWORD und FB_GUI_PASSWORD_HASH IN-PLACE (open r+ unter
|
||||
flock, truncate) - der Inode der Datei bleibt unveraendert, siehe
|
||||
Modul-Docstring."""
|
||||
with open(env_file, "r+", encoding="utf-8") as f:
|
||||
fcntl.flock(f, fcntl.LOCK_EX)
|
||||
try:
|
||||
lines = f.read().splitlines()
|
||||
out = []
|
||||
for line in lines:
|
||||
if line.startswith("FB_PASSWORD="):
|
||||
out.append(f"FB_PASSWORD='{new_password}'")
|
||||
elif line.startswith("FB_GUI_PASSWORD_HASH="):
|
||||
out.append(f"FB_GUI_PASSWORD_HASH='{new_hash}'")
|
||||
else:
|
||||
out.append(line)
|
||||
f.seek(0)
|
||||
f.write("\n".join(out) + "\n")
|
||||
f.truncate()
|
||||
finally:
|
||||
fcntl.flock(f, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def change_password(old: str, new: str) -> None:
|
||||
"""Aendert das gemeinsame GUI-/Grafana-Passwort. Reihenfolge ist bindend:
|
||||
1) altes Passwort gegen den aktuellen Hash verifizieren,
|
||||
2) Grafana AKTUALISIEREN (bricht bei Fehler ab, ohne die .env
|
||||
anzufassen),
|
||||
3) erst danach die .env in-place neu schreiben.
|
||||
"""
|
||||
if not verify_password(old, current_password_hash()):
|
||||
raise ValueError("Das alte Passwort ist falsch.")
|
||||
|
||||
settings = get_settings()
|
||||
_sync_grafana_password(old, new, settings.grafana_url)
|
||||
|
||||
new_hash = hash_password(new)
|
||||
_rewrite_env_file(settings.env_file, new, new_hash)
|
||||
|
||||
|
||||
def apply_rules_retroactively(session: Session) -> int:
|
||||
"""Wendet die aktuellen Kategorie-Regeln rueckwirkend auf alle
|
||||
bestaetigten, noch unkategorisierten Buchungen an und committet."""
|
||||
txs = session.execute(
|
||||
select(Transaction).where(
|
||||
Transaction.status == "confirmed",
|
||||
Transaction.category_id.is_(None),
|
||||
)
|
||||
).scalars().all()
|
||||
hits = apply_rules(session, txs)
|
||||
session.commit()
|
||||
return hits
|
||||
53
finance/app/services/balances.py
Normal file
53
finance/app/services/balances.py
Normal 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"))
|
||||
20
finance/app/services/categorize.py
Normal file
20
finance/app/services/categorize.py
Normal 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
|
||||
186
finance/app/services/importer.py
Normal file
186
finance/app/services/importer.py
Normal 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)
|
||||
65
finance/app/services/projection_service.py
Normal file
65
finance/app/services/projection_service.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from datetime import date, timedelta
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.engine.loans import loan_schedule
|
||||
from app.engine.projection import project
|
||||
from app.engine.scenario import (PlainModifier, PlainPlanned, PlainRecurring,
|
||||
build_cashflows)
|
||||
from app.models.tables import (Loan, PlannedItem, ProjectionPoint,
|
||||
ProjectionResult, RecurringItem, Scenario,
|
||||
ScenarioLoan, ScenarioModifier,
|
||||
ScenarioPlannedItem)
|
||||
from app.services.balances import total_balance
|
||||
|
||||
|
||||
def run_projection(session: Session, scenario: Scenario, horizon_days: int,
|
||||
start_date: date) -> ProjectionResult:
|
||||
end = start_date + timedelta(days=horizon_days)
|
||||
recurring = []
|
||||
if scenario.include_recurring:
|
||||
recurring = [PlainRecurring(r.id, r.name, Decimal(r.amount), r.rhythm,
|
||||
r.due_day, r.start_date, r.end_date, r.category_id)
|
||||
for r in session.execute(select(RecurringItem)).scalars()]
|
||||
planned = []
|
||||
if scenario.include_planned:
|
||||
planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
|
||||
for p in session.execute(select(PlannedItem)).scalars()]
|
||||
# Szenario-eigene Einmalzahlungen zaehlen IMMER fuer dieses Szenario -
|
||||
# include_planned steuert nur die globalen PlannedItems.
|
||||
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, None)
|
||||
for p in session.execute(select(ScenarioPlannedItem).where(
|
||||
ScenarioPlannedItem.scenario_id == scenario.id)).scalars()]
|
||||
loans = session.execute(
|
||||
select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id)
|
||||
.where(ScenarioLoan.scenario_id == scenario.id)).scalars().all()
|
||||
schedules = [loan_schedule(Decimal(l.principal), Decimal(l.annual_rate_pct),
|
||||
l.term_months, l.payout_date, l.repayment_type)
|
||||
for l in loans]
|
||||
payouts = [(l.payout_date, Decimal(l.principal)) for l in loans]
|
||||
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind,
|
||||
Decimal(m.value), m.end_date)
|
||||
for m in session.execute(select(ScenarioModifier).where(
|
||||
ScenarioModifier.scenario_id == scenario.id)).scalars()]
|
||||
flows = build_cashflows(recurring, planned, schedules, payouts, modifiers,
|
||||
start_date, end)
|
||||
proj = project(total_balance(session), start_date, flows, horizon_days,
|
||||
threshold=get_settings().warn_threshold)
|
||||
session.execute(delete(ProjectionPoint).where(
|
||||
ProjectionPoint.scenario_id == scenario.id))
|
||||
for d, bal in proj.series:
|
||||
session.add(ProjectionPoint(scenario_id=scenario.id, day=d, balance=bal))
|
||||
result = session.get(ProjectionResult, scenario.id) or ProjectionResult(
|
||||
scenario_id=scenario.id, computed_at=datetime.now(timezone.utc),
|
||||
low_point_date=proj.low_point[0], low_point_balance=proj.low_point[1])
|
||||
result.computed_at = datetime.now(timezone.utc)
|
||||
result.low_point_date, result.low_point_balance = proj.low_point
|
||||
result.below_zero_date = proj.first_below_zero
|
||||
result.below_threshold_date = proj.first_below_threshold
|
||||
session.add(result)
|
||||
session.commit()
|
||||
return result
|
||||
371
finance/app/services/suggestions.py
Normal file
371
finance/app/services/suggestions.py
Normal file
@@ -0,0 +1,371 @@
|
||||
"""Vorschlagsalgorithmus fuer wiederkehrende Buchungen (Ausbaustufe 9, v2).
|
||||
|
||||
Ersetzt die reine exakte-Betrags-Gruppierung (v1) durch: Empfaenger-Cluster
|
||||
mit Toleranz (haelt Preisdrift in einer Serie zusammen, trennt aber parallele
|
||||
Vertraege desselben Anbieters), Rhythmus-Erkennung ueber den Median der
|
||||
Buchungsabstaende (monatlich/vierteljaehrlich/jaehrlich statt nur monatlich),
|
||||
einen Aktiv-Check (keine "Leichen"-Serien) sowie einen Merge-Pass fuer
|
||||
Umfirmierungen (Anbieter aendert den Namen, die Serie laeuft inhaltlich
|
||||
weiter). Bindende Spec:
|
||||
docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md.
|
||||
|
||||
Alle Betrags-Toleranzvergleiche verwenden ausschliesslich `Decimal`
|
||||
(CLAUDE.md: "Decimal, nicht float" - Rundungsfehler bei Geldbetraegen sind
|
||||
inakzeptabel). Tage-Vergleiche (Rhythmus, Aktiv-Check, Merge-Luecke) sind
|
||||
ganzzahlige Tage-Arithmetik, niemals float/Decimal-Bruchteile von Tagen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import statistics
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.formats import eur
|
||||
from app.models.tables import RecurringItem, Transaction
|
||||
|
||||
# Betrachtungsfenster (Schritt 1): ~15 Monate. Muss mindestens die zwei
|
||||
# Belege einer jaehrlichen Serie (bis zu 400 Tage auseinander) plus etwas
|
||||
# Puffer fuer Cluster-/Merge-Bildung abdecken.
|
||||
WINDOW_DAYS = 460
|
||||
|
||||
# Rhythmus-Tabelle: (min_tage, max_tage, mindestbelege) je Rhythmus. Der
|
||||
# Median der Buchungsabstaende einer Serie muss ins Intervall fallen, UND es
|
||||
# muessen mindestens so viele Buchungen vorliegen (ein einzelner Zufallstreffer
|
||||
# mit "passendem" Abstand soll nicht als Serie gelten).
|
||||
RHYTHMS: dict[str, tuple[int, int, int]] = {
|
||||
"monthly": (25, 36, 3),
|
||||
"quarterly": (80, 105, 3),
|
||||
"yearly": (330, 400, 2),
|
||||
}
|
||||
|
||||
# Nominelle Schrittweite je Rhythmus in Tagen - Referenzwert fuer Aktiv-Check
|
||||
# und Merge-Luecken-Fenster (Schritt 5/6).
|
||||
STEP_DAYS: dict[str, int] = {"monthly": 30, "quarterly": 91, "yearly": 365}
|
||||
|
||||
# Aktiv-Check (Schritt 5): die letzte Buchung darf hoechstens das 1,75-fache
|
||||
# der Rhythmus-Schrittweite zurueckliegen, sonst gilt die Serie als beendet
|
||||
# ("Leiche") und wird nicht vorgeschlagen. Als Fraction 7/4 ausgedrueckt und
|
||||
# ganzzahlig verglichen (delta_tage * 4 <= schrittweite * 7), damit keine
|
||||
# Gleitkomma-Rundung ueber "aktiv"/"inaktiv" entscheidet.
|
||||
ACTIVITY_FACTOR_NUM = 7
|
||||
ACTIVITY_FACTOR_DEN = 4
|
||||
|
||||
# Relative Toleranzen (immer als Decimal verglichen, nie float):
|
||||
CLUSTER_TOL = Decimal("0.35") # Schritt 3: Betrags-Cluster (haelt Preisdrift zusammen)
|
||||
MERGE_TOL = Decimal("0.25") # Schritt 6: Umfirmierungs-Merge ueber Gruppengrenzen
|
||||
BESTAND_TOL = Decimal("0.10") # Schritt 8: Bestandsabgleich gegen RecurringItem
|
||||
|
||||
# Merge-Luecke (Schritt 6): die Zeit zwischen dem Ende von Serie A und dem
|
||||
# Beginn von Serie B muss zwischen dem 0,4- und 1,6-fachen der
|
||||
# Rhythmus-Schrittweite liegen (als ganzzahlige Bruchvergleiche, aus
|
||||
# demselben Grund wie beim Aktiv-Check).
|
||||
MERGE_GAP_MIN_NUM, MERGE_GAP_MIN_DEN = 4, 10 # 0.4
|
||||
MERGE_GAP_MAX_NUM, MERGE_GAP_MAX_DEN = 16, 10 # 1.6
|
||||
MERGE_DUE_DAY_TOL = 3 # Schritt 6: Faelligkeitstag-Toleranz in Tagen
|
||||
BESTAND_DUE_DAY_TOL = 2 # Schritt 8: Faelligkeitstag-Toleranz in Tagen
|
||||
|
||||
# Bestandsabgleich, Token-Match (Live-Gate-Fund, Nachtrag 4): kuratierte
|
||||
# Fixposten tragen haeufig einen Alias-/Variabel-Namen, der weder Substring
|
||||
# noch betragsaehnlich zum automatisch erkannten Vorschlag ist (Muster:
|
||||
# ein Sammel-Fixposten fuer eine Kreditkartenabrechnung mit variablem Betrag
|
||||
# unter einem Alias-Namen des Anbieters deckt den vom Algorithmus erkannten
|
||||
# Vorschlag desselben Anbieters unter seinem regulaeren Empfaenger-Namen
|
||||
# nicht ab, weil weder Substring noch Betrags-Toleranz greifen). Ein
|
||||
# gemeinsames, hinreichend spezifisches Namens-Token (>=5 Zeichen, um
|
||||
# generische Woerter wie "Bank" nicht faelschlich matchen zu lassen) bei
|
||||
# gleichem Rhythmus und nahem Faelligkeitstag gilt als ausreichendes Indiz
|
||||
# fuer denselben Fixposten.
|
||||
TOKEN_MIN_LEN = 5
|
||||
|
||||
# Volatilitaets-Hinweis (Live-Gate A9-Fund, Nachtrag 4): wenn der
|
||||
# Betrags-Cluster-Split (Schritt 3) die neueste Buchung der Empfaenger-Gruppe
|
||||
# abgetrennt hat (weil sie zu stark vom Serien-Betrag abweicht), ist der
|
||||
# vorgeschlagene Betrag ggf. schon wieder veraltet - keine Unterdrueckung,
|
||||
# nur ein Warnhinweis fuer die Nutzerin/den Nutzer.
|
||||
VOLATILITAETS_HINWEIS = "Beträge schwanken stark – letzte Buchung weicht ab"
|
||||
|
||||
|
||||
def _norm(name: str) -> str:
|
||||
"""Normalisiert einen Empfaenger-Namen fuer Gruppen- und
|
||||
Substring-Vergleich: Bankexporte schreiben denselben Empfaenger nicht
|
||||
einheitlich (Gross-/Kleinschreibung, mehrfache Leerzeichen), das ist fuer
|
||||
die Erkennung irrelevant."""
|
||||
return " ".join(name.split()).casefold()
|
||||
|
||||
|
||||
def _tokens(name: str) -> set[str]:
|
||||
"""Zerlegt einen normalisierten Namen an Nicht-Alphanumerik in Tokens
|
||||
(fuer den Token-Match im Bestandsabgleich, Schritt 8). Nur Tokens ab
|
||||
TOKEN_MIN_LEN Zeichen zaehlen, damit kurze generische Woerter ("eG",
|
||||
"AG", "Bank") keine falschen Treffer erzeugen."""
|
||||
return {tok for tok in re.split(r"[^a-z0-9]+", _norm(name)) if len(tok) >= TOKEN_MIN_LEN}
|
||||
|
||||
|
||||
def _rel_diff(a: Decimal, b: Decimal) -> Decimal:
|
||||
"""Relative Differenz von Betrag a zur Referenz b (immer >= 0), als
|
||||
Decimal. b=0 kommt praktisch nicht vor (eine Nullbuchung bildet keine
|
||||
erkennbare Serie); fuer diesen Sonderfall gilt "keine Aehnlichkeit"."""
|
||||
if b == 0:
|
||||
return Decimal("Infinity") if a != 0 else Decimal("0")
|
||||
return abs(a - b) / abs(b)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Series:
|
||||
"""Eine erkannte Serie: chronologisch sortierte Buchungen eines
|
||||
Betrags-Clusters mit zugeordnetem Rhythmus.
|
||||
|
||||
`volatile` markiert, dass der Cluster-Split (Schritt 3) innerhalb der
|
||||
Empfaenger-Gruppe eine NEUERE, betragsmaessig abweichende Buchung
|
||||
abgetrennt hat - der hier vorgeschlagene Betrag koennte also schon
|
||||
wieder veraltet sein (siehe VOLATILITAETS_HINWEIS)."""
|
||||
items: list[Transaction]
|
||||
rhythm: str
|
||||
volatile: bool = False
|
||||
|
||||
@property
|
||||
def first(self) -> Transaction:
|
||||
return self.items[0]
|
||||
|
||||
@property
|
||||
def last(self) -> Transaction:
|
||||
return self.items[-1]
|
||||
|
||||
|
||||
def _amount_clusters(items: list[Transaction]) -> list[list[Transaction]]:
|
||||
"""Schritt 3: teilt chronologisch sortierte Buchungen einer
|
||||
Empfaenger-Gruppe in Betrags-Cluster. Eine Buchung haengt sich an das
|
||||
Cluster, dessen zuletzt aufgenommenes Mitglied gleiches Vorzeichen und
|
||||
eine relative Differenz <= CLUSTER_TOL hat (greedy, erstes passendes
|
||||
Cluster gewinnt) - das haelt eine langsam driftende Serie (Preiserhoehung)
|
||||
zusammen, trennt aber parallele Vertraege mit deutlich anderem Betrag."""
|
||||
clusters: list[list[Transaction]] = []
|
||||
for t in items:
|
||||
for cluster in clusters:
|
||||
last = cluster[-1]
|
||||
same_sign = (t.amount > 0) == (last.amount > 0)
|
||||
if same_sign and _rel_diff(Decimal(t.amount), Decimal(last.amount)) <= CLUSTER_TOL:
|
||||
cluster.append(t)
|
||||
break
|
||||
else:
|
||||
clusters.append([t])
|
||||
return clusters
|
||||
|
||||
|
||||
def _classify(dates: list[date]) -> str | None:
|
||||
"""Schritt 4: bestimmt den Rhythmus einer Serie ueber den Median der
|
||||
Buchungsabstaende (robust gegen einzelne Ausreisser, z.B.
|
||||
Wochenend-/Feiertagsverschiebung einer einzelnen Buchung)."""
|
||||
if len(dates) < 2:
|
||||
return None
|
||||
gaps = [(b - a).days for a, b in zip(dates, dates[1:])]
|
||||
median_gap = statistics.median(gaps)
|
||||
for rhythm, (lo, hi, min_belege) in RHYTHMS.items():
|
||||
if len(dates) >= min_belege and lo <= median_gap <= hi:
|
||||
return rhythm
|
||||
return None
|
||||
|
||||
|
||||
def _merge_gap_ok(gap_days: int, step: int) -> bool:
|
||||
"""Schritt 6: Luecke zwischen Serienende und -beginn im Fenster
|
||||
[0,4; 1,6] * Schrittweite (ganzzahliger Bruchvergleich, keine Rundung)."""
|
||||
return (gap_days * MERGE_GAP_MIN_DEN >= MERGE_GAP_MIN_NUM * step
|
||||
and gap_days * MERGE_GAP_MAX_DEN <= MERGE_GAP_MAX_NUM * step)
|
||||
|
||||
|
||||
def _mergeable(a: _Series, b: _Series) -> bool:
|
||||
"""Prueft die Umfirmierungs-Merge-Bedingungen aus Schritt 6 fuer ein
|
||||
Paar (A endet, B beginnt danach): gleicher Rhythmus, plausible Luecke,
|
||||
Faelligkeitstag nah beieinander (Transitionspunkte: letzte Buchung von A
|
||||
gegen erste Buchung von B), Betrag nicht sprunghaft veraendert."""
|
||||
if a.rhythm != b.rhythm:
|
||||
return False
|
||||
if a.last.booking_date >= b.first.booking_date:
|
||||
return False
|
||||
step = STEP_DAYS[a.rhythm]
|
||||
gap = (b.first.booking_date - a.last.booking_date).days
|
||||
if not _merge_gap_ok(gap, step):
|
||||
return False
|
||||
if abs(a.last.booking_date.day - b.first.booking_date.day) > MERGE_DUE_DAY_TOL:
|
||||
return False
|
||||
same_sign = (a.last.amount > 0) == (b.first.amount > 0)
|
||||
if not same_sign:
|
||||
return False
|
||||
return _rel_diff(Decimal(b.first.amount), Decimal(a.last.amount)) <= MERGE_TOL
|
||||
|
||||
|
||||
def _try_merge(series_list: list[_Series]) -> list[_Series]:
|
||||
"""Schritt 6: fasst Serien desselben Kontos ueber Gruppengrenzen
|
||||
(unterschiedlicher normalisierter Empfaenger-Name, z.B. nach einer
|
||||
Umfirmierung) zusammen, solange `_mergeable` zutrifft. Laeuft iterativ
|
||||
bis zum Fixpunkt, damit eine bereits gemergte Serie mit einer weiteren,
|
||||
noch juengeren Serie erneut zusammengefasst werden kann (z.B. zwei
|
||||
Umbenennungen hintereinander)."""
|
||||
series_list = list(series_list)
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for i, a in enumerate(series_list):
|
||||
for j, b in enumerate(series_list):
|
||||
if i == j or not _mergeable(a, b):
|
||||
continue
|
||||
merged = _Series(
|
||||
items=sorted(a.items + b.items, key=lambda t: t.booking_date),
|
||||
rhythm=a.rhythm,
|
||||
volatile=a.volatile or b.volatile,
|
||||
)
|
||||
series_list = [s for k, s in enumerate(series_list) if k not in (i, j)]
|
||||
series_list.append(merged)
|
||||
changed = True
|
||||
break
|
||||
if changed:
|
||||
break
|
||||
return series_list
|
||||
|
||||
|
||||
def _covered_by_existing(cand_name: str, cand_amount: Decimal, rhythm: str, due_day: int,
|
||||
existing: list[RecurringItem]) -> bool:
|
||||
"""Schritt 8 (Bestandsabgleich): ein Vorschlag entfaellt, wenn er bereits
|
||||
als Fixposten gepflegt ist - ueber einen von drei Wegen:
|
||||
(a) Namens-Substring-Match (normalisiert, in beide Richtungen: sowohl
|
||||
Kurz- als auch Langschreibweisen kommen in der Praxis in beiden
|
||||
Datenquellen vor);
|
||||
(b) Rhythmus + Faelligkeitstag + Betrag innerhalb enger Toleranz (falls
|
||||
der Fixposten unter einem ganz anderen Namen gepflegt wurde);
|
||||
(c) Token-Match: gleicher Rhythmus, Faelligkeitstag-Differenz <= 2 UND
|
||||
mindestens ein gemeinsames Namens-Token (>=5 Zeichen) - faengt
|
||||
kuratierte Alias-/Variabel-Fixposten, deren Name UND Betrag stark
|
||||
vom automatisch erkannten Vorschlag abweichen (Live-Gate-Fund: ein
|
||||
Sammel-Fixposten unter Alias-Namen des Anbieters deckt den
|
||||
automatisch erkannten Vorschlag desselben Anbieters unter seinem
|
||||
regulaeren Empfaenger-Namen ab, obwohl weder (a) noch (b) greifen)."""
|
||||
cand_norm = _norm(cand_name)
|
||||
cand_tokens = _tokens(cand_name)
|
||||
for item in existing:
|
||||
item_norm = _norm(item.name)
|
||||
if cand_norm in item_norm or item_norm in cand_norm:
|
||||
return True
|
||||
if (item.rhythm == rhythm
|
||||
and abs(item.due_day - due_day) <= BESTAND_DUE_DAY_TOL
|
||||
and _rel_diff(cand_amount, Decimal(item.amount)) <= BESTAND_TOL):
|
||||
return True
|
||||
if (item.rhythm == rhythm
|
||||
and abs(item.due_day - due_day) <= BESTAND_DUE_DAY_TOL
|
||||
and cand_tokens & _tokens(item.name)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def suggest_recurring(session: Session, today: date | None = None) -> list[dict]:
|
||||
"""Ermittelt Vorschlaege fuer wiederkehrende Posten aus bestaetigten
|
||||
Buchungen der letzten WINDOW_DAYS Tage. `today` ist ausschliesslich zu
|
||||
Testzwecken injizierbar (deterministischer Aktiv-Check) - im
|
||||
Produktivbetrieb liefert der Default `date.today()`. Reihenfolge der
|
||||
Schritte gemaess Spec, mit einer bewussten Umstellung gegenueber der
|
||||
Nummerierung dort: der Aktiv-Check (Schritt 5) laeuft NACH dem
|
||||
Umfirmierungs-Merge (Schritt 6) auf der ggf. gemergten Serie - sonst
|
||||
wuerde eine per Umfirmierung fortgesetzte Serie an ihrem alten,
|
||||
laengst inaktiven Teil scheitern, bevor der Merge sie retten kann."""
|
||||
if today is None:
|
||||
today = date.today()
|
||||
cutoff = today - timedelta(days=WINDOW_DAYS)
|
||||
|
||||
# Schritt 1: Datenbasis.
|
||||
txs = session.execute(
|
||||
select(Transaction)
|
||||
.where(Transaction.status == "confirmed", Transaction.booking_date >= cutoff)
|
||||
).scalars().all()
|
||||
|
||||
# Schritt 2: Gruppierung je (Konto, normalisierter Empfaenger).
|
||||
groups: dict[tuple[int, str], list[Transaction]] = {}
|
||||
for t in txs:
|
||||
groups.setdefault((t.account_id, _norm(t.counterparty)), []).append(t)
|
||||
|
||||
# Schritt 3+4: je Gruppe Betrags-Cluster bilden und Rhythmus klassifizieren.
|
||||
series_by_account: dict[int, list[_Series]] = {}
|
||||
for (account_id, _name_norm), items in groups.items():
|
||||
items_sorted = sorted(items, key=lambda t: t.booking_date)
|
||||
clusters = _amount_clusters(items_sorted)
|
||||
classified = [(cluster, _classify([t.booking_date for t in cluster]))
|
||||
for cluster in clusters]
|
||||
# Fuer den Volatilitaets-Check zaehlt eine neuere Buchung nur dann als
|
||||
# "abgetrennt", wenn sie NICHT bereits zu einem ANDEREN qualifizierten
|
||||
# (klassifizierten) Cluster derselben Gruppe gehoert - sonst waeren
|
||||
# zwei parallele, stabile Vertraege (jeder fuer sich eine gueltige
|
||||
# eigene Serie) faelschlich als "volatil" markiert, nur weil der
|
||||
# jeweils andere Vertrag zufaellig spaeter im Monat faellig ist
|
||||
# (Nachtrag 3b, Fable-Gate-Korrektur nach dem ersten Live-Gate-Fund).
|
||||
qualified_items = {t for cluster, rhythm in classified if rhythm is not None
|
||||
for t in cluster}
|
||||
for cluster, rhythm in classified:
|
||||
if rhythm is None:
|
||||
continue
|
||||
# Volatilitaets-Hinweis: hat der Cluster-Split innerhalb DIESER
|
||||
# Empfaenger-Gruppe (gleiches Konto, gleiches Vorzeichen) eine
|
||||
# NEUERE Buchung in einen UNQUALIFIZIERTEN Cluster abgetrennt
|
||||
# (z.B. eine einzelne Ausreisser-Buchung, die allein keine Serie
|
||||
# bildet), ist der hier vorgeschlagene (letzte) Betrag ggf. schon
|
||||
# veraltet.
|
||||
cluster_sign = cluster[-1].amount > 0
|
||||
volatile = any(
|
||||
(t.amount > 0) == cluster_sign
|
||||
and t.booking_date > cluster[-1].booking_date
|
||||
and t not in qualified_items
|
||||
for t in items_sorted
|
||||
)
|
||||
series_by_account.setdefault(account_id, []).append(
|
||||
_Series(items=cluster, rhythm=rhythm, volatile=volatile))
|
||||
|
||||
existing = list(session.execute(select(RecurringItem)).scalars())
|
||||
|
||||
suggestions: list[dict] = []
|
||||
for series_list in series_by_account.values():
|
||||
# Schritt 6: Umfirmierungs-Merge ueber Gruppengrenzen, je Konto.
|
||||
for s in _try_merge(series_list):
|
||||
# Schritt 5: Aktiv-Check auf der (ggf. gemergten) finalen Serie.
|
||||
step = STEP_DAYS[s.rhythm]
|
||||
delta_tage = (today - s.last.booking_date).days
|
||||
if delta_tage * ACTIVITY_FACTOR_DEN > step * ACTIVITY_FACTOR_NUM:
|
||||
continue
|
||||
|
||||
# Schritt 7: Vorschlagswerte aus der neuesten Buchung.
|
||||
last = s.last
|
||||
name = last.counterparty
|
||||
amount = Decimal(last.amount)
|
||||
due_day = last.booking_date.day
|
||||
start_date = last.booking_date if s.rhythm in ("quarterly", "yearly") else None
|
||||
cat_counts = Counter(t.category_id for t in s.items if t.category_id is not None)
|
||||
category_id = cat_counts.most_common(1)[0][0] if cat_counts else None
|
||||
|
||||
hinweis = ""
|
||||
if len(s.items) >= 2:
|
||||
previous = Decimal(s.items[-2].amount)
|
||||
# "Gestiegen" bezieht sich auf den Betragswert (Ausgaben sind
|
||||
# negativ: gestiegen heisst betragsmaessig groesser, also
|
||||
# abs(neu) > abs(alt)), nicht auf das Vorzeichen.
|
||||
if abs(amount) > abs(previous):
|
||||
hinweis = f"Betrag zuletzt gestiegen (vorher {eur(abs(previous))} €)"
|
||||
|
||||
if s.volatile:
|
||||
hinweis = f"{hinweis} {VOLATILITAETS_HINWEIS}".strip()
|
||||
|
||||
# Schritt 8: Bestandsabgleich.
|
||||
if _covered_by_existing(name, amount, s.rhythm, due_day, existing):
|
||||
continue
|
||||
|
||||
suggestions.append({
|
||||
"name": name,
|
||||
"amount": amount,
|
||||
"rhythm": s.rhythm,
|
||||
"due_day": due_day,
|
||||
"start_date": start_date,
|
||||
"category_id": category_id,
|
||||
"hinweis": hinweis,
|
||||
})
|
||||
return suggestions
|
||||
1
finance/app/static/htmx.min.js
vendored
Normal file
1
finance/app/static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
212
finance/app/static/style.css
Normal file
212
finance/app/static/style.css
Normal file
@@ -0,0 +1,212 @@
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
color: #222;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #ccc;
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
nav a {
|
||||
text-decoration: none;
|
||||
color: #06c;
|
||||
}
|
||||
|
||||
nav form {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.4rem 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f2f2f2;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.neg {
|
||||
color: #b00;
|
||||
}
|
||||
|
||||
.amount {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
td.account {
|
||||
word-break: break-all;
|
||||
max-width: 12rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #e0c26b;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: #d4edda;
|
||||
border: 1px solid #7bc088;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.total-balance {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
form.filter-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: end;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
form.filter-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
iframe.grafana {
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #fff3cd;
|
||||
color: #7a5c00;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: #fdecea;
|
||||
color: #b00020;
|
||||
}
|
||||
|
||||
.badge-ok {
|
||||
background: #e6f4ea;
|
||||
color: #1e7e34;
|
||||
}
|
||||
|
||||
#dropzone {
|
||||
border: 2px dashed #999;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
#dropzone.dragover {
|
||||
background: #f0f6ff;
|
||||
border-color: #06c;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: inline-block;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
section.planning-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
section.planning-section fieldset {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.version {
|
||||
margin-top: 2rem;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Ausbaustufe 7: Formular-Paare als Einheit, gesperrte Felder erkennbar. */
|
||||
.entry-form label,
|
||||
.planning-section form label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0 1.25rem 0.6rem 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.entry-form select,
|
||||
.planning-section form select {
|
||||
max-width: 14rem;
|
||||
}
|
||||
.entry-form .entry-art {
|
||||
display: block;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.entry-form .entry-art > label { font-weight: bold; }
|
||||
.entry-form .entry-art .muted { margin-left: 0.5rem; }
|
||||
input:disabled, select:disabled {
|
||||
background: #e3e3e3;
|
||||
color: #8a8a8a;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
label:has(input:disabled), label:has(select:disabled) {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.project-block {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
67
finance/app/templates/_import_list.html
Normal file
67
finance/app/templates/_import_list.html
Normal 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>
|
||||
31
finance/app/templates/_preview_table.html
Normal file
31
finance/app/templates/_preview_table.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Datum</th><th>Verwendungszweck</th><th>Empfänger/Zahler</th><th>Betrag</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tx in transactions %}
|
||||
<tr>
|
||||
<td>{{ tx.booking_date.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ tx.purpose }}</td>
|
||||
<td>{{ tx.counterparty }}</td>
|
||||
<td class="{{ 'neg' if tx.amount < 0 else '' }}">{{ tx.amount|eur }} €</td>
|
||||
<td>{% if tx.is_duplicate %}<span class="badge badge-warning">Duplikat</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5">Keine Buchungen in diesem Import.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
Saldo-Status:
|
||||
{% if balance_ok is none %}
|
||||
<span class="badge">Saldo-Prüfung: nicht verfügbar (Format ohne Saldodaten)</span>
|
||||
{% elif balance_ok %}
|
||||
<span class="badge badge-ok">Saldo plausibel</span>
|
||||
{% else %}
|
||||
<span class="badge badge-error">Saldo-Differenz – bitte prüfen</span>
|
||||
{% endif %}
|
||||
· Eröffnungssaldo: {{ statement.opening_balance|eur if statement.opening_balance is not none else '–' }} €
|
||||
· Endsaldo: {{ statement.closing_balance|eur if statement.closing_balance is not none else '–' }} €
|
||||
· Duplikate: {{ duplicates }}
|
||||
</p>
|
||||
83
finance/app/templates/admin.html
Normal file
83
finance/app/templates/admin.html
Normal file
@@ -0,0 +1,83 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Admin</h1>
|
||||
|
||||
<section class="admin-section">
|
||||
<h2>Passwort ändern</h2>
|
||||
<p class="muted">Gilt gemeinsam für GUI- und Grafana-Login. Bestehende Sitzungen bleiben nach der Änderung gültig.</p>
|
||||
{% if error %}<div class="warning">{{ error }}</div>{% endif %}
|
||||
{% if success %}<div class="success">{{ success }}</div>{% endif %}
|
||||
<form method="post" action="/admin/passwort">
|
||||
<label>Altes Passwort
|
||||
<input type="password" name="alt" required>
|
||||
</label>
|
||||
<label>Neues Passwort (mind. 8 Zeichen)
|
||||
<input type="password" name="neu" minlength="8" required>
|
||||
</label>
|
||||
<label>Neues Passwort wiederholen
|
||||
<input type="password" name="neu2" minlength="8" required>
|
||||
</label>
|
||||
<button type="submit">Passwort ändern</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-section">
|
||||
<h2>Regeln neu anwenden</h2>
|
||||
<p class="muted">Wendet die aktuellen Kategorie-Regeln rückwirkend auf noch nicht kategorisierte, bestätigte Buchungen an.</p>
|
||||
<button type="button" hx-post="/api/category-rules/apply" hx-swap="none"
|
||||
hx-on::after-request="handleApplyRulesResult(event)">
|
||||
Regeln neu anwenden
|
||||
</button>
|
||||
<p id="apply-rules-result"></p>
|
||||
</section>
|
||||
|
||||
<section class="admin-section">
|
||||
<h2>Kategorien</h2>
|
||||
<p class="muted">Kategorien für Buchungen, Fixposten und Szenario-Modifikatoren.
|
||||
Löschen ist bewusst nicht vorgesehen (Kategorien hängen an Buchungen und Regeln).</p>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in categories %}
|
||||
<tr id="cat-row-{{ c.id }}">
|
||||
<td>{{ c.name }}</td>
|
||||
<td><button type="button" onclick="toggleEdit('cat', {{ c.id }}, true)">Bearbeiten</button></td>
|
||||
</tr>
|
||||
<tr id="cat-edit-{{ c.id }}" hidden>
|
||||
<td colspan="2">
|
||||
<form hx-ext="json-form" hx-patch="/api/categories/{{ c.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" value="{{ c.name }}" required maxlength="100"></label>
|
||||
<button type="submit">Speichern</button>
|
||||
<button type="button" onclick="toggleEdit('cat', {{ c.id }}, false)">Abbrechen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="2">Noch keine Kategorien.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<fieldset>
|
||||
<legend>Neue Kategorie anlegen</legend>
|
||||
<form hx-ext="json-form" hx-post="/api/categories" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" required maxlength="100"></label>
|
||||
<button type="submit">Anlegen</button>
|
||||
</form>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
function handleApplyRulesResult(event) {
|
||||
var result = document.getElementById('apply-rules-result');
|
||||
if (event.detail.successful) {
|
||||
var data = JSON.parse(event.detail.xhr.responseText);
|
||||
result.textContent = data.categorized + ' Buchung(en) neu kategorisiert.';
|
||||
} else {
|
||||
result.textContent = 'Fehler beim Anwenden der Regeln.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
87
finance/app/templates/base.html
Normal file
87
finance/app/templates/base.html
Normal file
@@ -0,0 +1,87 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Finanzberatung{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/">Übersicht</a>
|
||||
<a href="/import">Import</a>
|
||||
<a href="/buchungen">Buchungen</a>
|
||||
<a href="/salden">Salden</a>
|
||||
<a href="/planung">Planung</a>
|
||||
<a href="/szenarien">Szenarien</a>
|
||||
<a href="/admin">Admin</a>
|
||||
<a href="/hilfe">Hilfe</a>
|
||||
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit">Logout</button>
|
||||
</form>
|
||||
</nav>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script>
|
||||
document.body.addEventListener('htmx:responseError', function (e) {
|
||||
alert('Fehler: ' + (e.detail.xhr.responseText || e.detail.xhr.status));
|
||||
});
|
||||
|
||||
// Minimale JSON-Kodierung für htmx-Formulare/Selects, die gegen die JSON-APIs
|
||||
// (POST/PATCH /api/...) senden. Felder mit data-type="int" werden als Zahl
|
||||
// kodiert, leere Werte als null, alles andere bleibt String.
|
||||
htmx.defineExtension('json-form', {
|
||||
onEvent: function (name, evt) {
|
||||
if (name === 'htmx:configRequest') {
|
||||
evt.detail.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
},
|
||||
encodeParameters: function (xhr, parameters, elt) {
|
||||
var out = {};
|
||||
for (var k in parameters) {
|
||||
var v = parameters[k];
|
||||
var field = elt.matches && elt.matches('[name="' + k + '"]')
|
||||
? elt
|
||||
: elt.querySelector('[name="' + k + '"]');
|
||||
var kind = field && field.dataset ? field.dataset.type : null;
|
||||
if (v === '') {
|
||||
out[k] = null;
|
||||
} else if (kind === 'int') {
|
||||
out[k] = parseInt(v, 10);
|
||||
} else if (kind === 'amount') {
|
||||
// Deutsche Betragseingabe: "1.234,56" -> "1234.56". Ohne Komma
|
||||
// bleibt der Wert unveraendert (Punkt-Eingaben funktionieren weiter).
|
||||
var s = String(v).replace(/[\s€]/g, '');
|
||||
if (s.indexOf(',') !== -1) {
|
||||
s = s.replace(/\./g, '').replace(',', '.');
|
||||
}
|
||||
out[k] = s;
|
||||
} else {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
// Checkboxen mit data-type="bool" werden unabhaengig vom parameters-Objekt
|
||||
// ausgelesen, weil unmarkierte Checkboxen beim Serialisieren komplett
|
||||
// fehlen wuerden (kein Feld => kein "false" moeglich).
|
||||
if (elt.querySelectorAll) {
|
||||
elt.querySelectorAll('[data-type="bool"]').forEach(function (field) {
|
||||
out[field.name] = field.checked;
|
||||
});
|
||||
}
|
||||
xhr.overrideMimeType('text/json');
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
});
|
||||
|
||||
function toggleEdit(prefix, id, editing) {
|
||||
// UX-Regel: Zeilen werden versteckt (hidden), nie aus dem DOM entfernt.
|
||||
document.getElementById(prefix + '-row-' + id).hidden = editing;
|
||||
document.getElementById(prefix + '-edit-' + id).hidden = !editing;
|
||||
}
|
||||
</script>
|
||||
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
|
||||
</body>
|
||||
</html>
|
||||
110
finance/app/templates/hilfe.html
Normal file
110
finance/app/templates/hilfe.html
Normal file
@@ -0,0 +1,110 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Hilfe – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Gebrauchsanleitung</h1>
|
||||
|
||||
<h2>Was dieses Tool ist</h2>
|
||||
<p>
|
||||
Liquiditätsplanung auf Basis echter Kontoauszüge: Ist-Stand aus importierten
|
||||
Auszügen, Zukunft aus gepflegten Planposten und durchgerechneten Szenarien.
|
||||
</p>
|
||||
|
||||
<h2>Konzepte</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Konten:</strong> entstehen automatisch beim ersten Import (per IBAN).
|
||||
Über die Übersicht umbenennbar (z. B. "DKB Giro").
|
||||
</li>
|
||||
<li>
|
||||
<strong>Kontoauszug-Import:</strong> primärer Weg ist der
|
||||
Kontoumsatz-CSV-Export der Bank (DKB, VR-Bank, HypoVereinsbank) – in die
|
||||
Drop-Zone auf der Import-Seite ziehen (oder in die Inbox
|
||||
<code>~/.local/share/finance_pod/data/inbox/</code> legen und "Inbox
|
||||
scannen"). PDF-Kontoauszüge funktionieren weiterhin als Fallback, falls
|
||||
kein CSV-Export vorliegt. Jeder Import landet als Entwurf: erst nach
|
||||
Kontrolle der Vorschau "Übernehmen" klicken. Der Saldo-Status in der
|
||||
Vorschau ist dreiwertig – "Saldo plausibel" (Anfangssaldo + Buchungen =
|
||||
Endsaldo bzw. lückenlose Saldo-Kette), eine rote Saldo-Differenz, oder
|
||||
"Saldo-Prüfung nicht verfügbar", wenn das Format (z. B. HVB-CSV) keine
|
||||
Saldodaten liefert – dort lohnt ein Blick in die Zeilenzahl und
|
||||
Stichproben vor dem Übernehmen. CSV-Importe mit Datei-Kontostand (DKB)
|
||||
oder lückenloser Saldo-Kette (VR) aktualisieren beim Import automatisch
|
||||
den Konto-Saldo-Anker, sofern er dadurch nicht älter würde als ein bereits
|
||||
vorhandener. Duplikate werden erkannt und nicht doppelt übernommen.
|
||||
Bestätigte Importe lassen sich als Ganzes zurückrollen (Auszug + alle
|
||||
seine Buchungen), z. B. um einen Auszug mit einem verbesserten Parser neu
|
||||
zu importieren; einzelne Buchungen bleiben unlöschbar.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Salden-Seite:</strong> zeigt den Saldo je Konto zu einem
|
||||
wählbaren Stichtag (Default: heute) sowie eine Monatsanfangs-Übersicht
|
||||
vom frühesten Buchungsmonat bis heute, absteigend. Die
|
||||
Monatsanfangs-Werte gelten "Stand: Ende des Vortags" (letzter Tag des
|
||||
Vormonats), damit Buchungen am 1. eines Monats den Ausweis des
|
||||
Monatsanfangs nicht verfälschen. Konten ohne Anker sind mit "ohne Anker —
|
||||
relative Werte" gekennzeichnet.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Kategorien & Regeln:</strong> Buchungen lassen sich kategorisieren;
|
||||
eine Regel ("Muster im Text → Kategorie") kategorisiert künftige Importe
|
||||
automatisch. Regel direkt aus einer Buchung erzeugen: Button in der
|
||||
Buchungsliste.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Wiederkehrende Posten:</strong> Miete, Gehalt, Abos – mit Rhythmus
|
||||
(monatlich/quartalsweise/jährlich) und Fälligkeitstag. Der
|
||||
Vorschlags-Knopf erkennt Kandidaten aus mind. 3 Monaten gleichartiger
|
||||
Buchungen.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Einmalposten:</strong> einzelne künftige Zahlungen (z. B.
|
||||
Steuernachzahlung).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Kredite:</strong> Annuität oder endfällig; Tilgungsplan
|
||||
aufklappbar. Ein Kredit wirkt erst, wenn er einem Szenario zugeordnet ist.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Szenarien:</strong> eine "Was-wäre-wenn"-Rechnung. Basis = alle
|
||||
wiederkehrenden Posten + Einmalposten. Varianten entstehen durch Zuordnen
|
||||
von Krediten und Modifikatoren (Kategorie oder Posten prozentual/absolut
|
||||
kürzen oder ganz streichen). "Durchrechnen" liefert: tiefster Kontostand
|
||||
mit Datum, erstes Datum unter 0 und unter der Warnschwelle.
|
||||
Varianten wie „Best Case“ pflegst du in der Tabelle „Einträge“ des
|
||||
Szenarios: Art „Ende“ lässt einen wiederkehrenden Posten (z. B. eine
|
||||
auslaufende Miete) zu einem Datum enden, ohne den Posten selbst zu
|
||||
ändern; „Prozent“/„Absolut“ kürzen Beträge; „Entfällt“ blendet sie ganz
|
||||
aus; „Einmalzahlung“ ergänzt einen einmaligen Zu- oder Abfluss, der nur
|
||||
in diesem Szenario zählt. Jeder Eintrag ist über „Bearbeiten“ direkt
|
||||
änderbar.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Grafana:</strong> Kontostand-Verläufe, Monatsausgaben nach
|
||||
Kategorie und der Szenario-Vergleich als Kurven (einmal mit demselben
|
||||
Passwort wie hier anmelden, dann erscheint das Dashboard auch in der
|
||||
Übersicht).
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Empfohlener Arbeitsablauf</h2>
|
||||
<ol>
|
||||
<li>Monatlich: neue Auszüge importieren, Vorschau prüfen, übernehmen.</li>
|
||||
<li>
|
||||
Unkategorisierte Buchungen durchsehen; für Wiederkehrendes Regeln
|
||||
anlegen.
|
||||
</li>
|
||||
<li>Planung pflegen: Vorschläge prüfen, Einmalposten eintragen.</li>
|
||||
<li>
|
||||
Fragestellung ("Können wir uns X leisten?") als Szenario-Varianten
|
||||
abbilden und durchrechnen; Entscheidung anhand Tiefpunkt und
|
||||
Unterschreitungsdaten treffen, Kurven in Grafana vergleichen.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h2>Grundregeln</h2>
|
||||
<ul>
|
||||
<li>Bestätigte Buchungen sind die Wahrheit – nie ändern, nur kategorisieren.</li>
|
||||
<li>Zukunft ausschließlich über Szenarien planen.</li>
|
||||
<li>Empfehlungen immer mit durchgerechneten Zahlen begründen.</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
74
finance/app/templates/import.html
Normal file
74
finance/app/templates/import.html
Normal 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 %}
|
||||
84
finance/app/templates/index.html
Normal file
84
finance/app/templates/index.html
Normal file
@@ -0,0 +1,84 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Übersicht – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Übersicht</h1>
|
||||
|
||||
{% if projection and projection.below_threshold_date %}
|
||||
<div class="warning">
|
||||
Achtung: Prognostizierter Saldo unterschreitet den Schwellenwert am
|
||||
<strong>{{ projection.below_threshold_date.strftime('%d.%m.%Y') }}</strong>.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="total-balance {{ 'neg' if total < 0 else '' }}">
|
||||
Gesamtsaldo: {{ total|eur }} €
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Konto</th><th>Bank</th><th>Saldo</th><th>Anker</th>
|
||||
<th>Umbenennen</th><th>Anker pflegen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for account, balance in balances %}
|
||||
<tr>
|
||||
<td class="account">{{ account.name }}</td>
|
||||
<td>{{ account.bank }}</td>
|
||||
<td class="{{ 'neg' if balance < 0 else '' }}">{{ balance|eur }} €</td>
|
||||
<td>
|
||||
{% if account.anchor_date is not none and account.anchor_balance is not none %}
|
||||
Anker: {{ account.anchor_balance|eur }} € am {{ account.anchor_date.strftime('%d.%m.%Y') }}
|
||||
{% else %}
|
||||
kein Anker
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form class="inline-form" hx-ext="json-form" hx-patch="/api/accounts/{{ account.id }}"
|
||||
hx-swap="none" hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<input type="text" name="name" value="{{ account.name }}" required maxlength="100">
|
||||
<button type="submit">Umbenennen</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form class="inline-form" hx-ext="json-form" hx-patch="/api/accounts/{{ account.id }}"
|
||||
hx-swap="none" hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<input type="date" name="anchor_date"
|
||||
value="{{ account.anchor_date.isoformat() if account.anchor_date is not none else '' }}" required>
|
||||
<input type="text" name="anchor_balance" data-type="amount" placeholder="500,00"
|
||||
value="{{ account.anchor_balance|eur if account.anchor_balance is not none else '' }}" required>
|
||||
<button type="submit">Anker setzen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6">Keine Konten angelegt.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Nächste anstehende Posten (30 Tage)</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Datum</th><th>Bezeichnung</th><th>Betrag</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in upcoming %}
|
||||
<tr>
|
||||
<td>{{ item.date.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ item.name }}</td>
|
||||
<td class="{{ 'neg' if item.amount < 0 else '' }}">{{ item.amount|eur }} €</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3">Keine anstehenden Posten.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Grafana-Dashboard</h2>
|
||||
<iframe class="grafana" src="http://{{ request.url.hostname or '127.0.0.1' }}:8097/d/finanzen/finanzen?orgId=1&kiosk"></iframe>
|
||||
<p class="hint">Kein Diagramm sichtbar? Einmal in
|
||||
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank">Grafana anmelden</a>
|
||||
(gleiches Passwort wie hier).</p>
|
||||
{% endblock %}
|
||||
21
finance/app/templates/login.html
Normal file
21
finance/app/templates/login.html
Normal 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>
|
||||
303
finance/app/templates/planning.html
Normal file
303
finance/app/templates/planning.html
Normal file
@@ -0,0 +1,303 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Planung – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Planung</h1>
|
||||
|
||||
<section class="planning-section">
|
||||
<h2>Wiederkehrende Posten</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th>Start</th><th>Ende</th><th>Kategorie</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in recurring %}
|
||||
<tr id="rec-row-{{ r.id }}">
|
||||
<td>{{ r.name }}</td>
|
||||
<td class="{{ 'neg' if r.amount < 0 else '' }}">{{ r.amount|eur }} €</td>
|
||||
<td>{{ r.rhythm|de_label }}</td>
|
||||
<td>{{ r.due_day }}</td>
|
||||
<td>{{ r.start_date.strftime('%d.%m.%Y') if r.start_date else '–' }}</td>
|
||||
<td>{{ r.end_date.strftime('%d.%m.%Y') if r.end_date else '–' }}</td>
|
||||
<td>{{ category_names.get(r.category_id, '–') }}</td>
|
||||
<td>
|
||||
<button type="button" onclick="toggleEdit('rec', {{ r.id }}, true)">Bearbeiten</button>
|
||||
<form class="inline-form" hx-delete="/api/recurring/{{ r.id }}" hx-swap="none"
|
||||
hx-confirm="„{{ r.name }}“ wirklich löschen?"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<button type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="rec-edit-{{ r.id }}" hidden>
|
||||
<td colspan="8">
|
||||
<form hx-ext="json-form" hx-patch="/api/recurring/{{ r.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" value="{{ r.name }}" required></label>
|
||||
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ r.amount|eur }}" required></label>
|
||||
<label>Rhythmus
|
||||
<select name="rhythm" data-type="str">
|
||||
{% for rh in rhythms %}<option value="{{ rh }}" {% if rh == r.rhythm %}selected{% endif %}>{{ rh|de_label }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" value="{{ r.due_day }}" required></label>
|
||||
<label>Start <input type="date" name="start_date" value="{{ r.start_date.isoformat() if r.start_date else '' }}"></label>
|
||||
<label>Ende <input type="date" name="end_date" value="{{ r.end_date.isoformat() if r.end_date else '' }}"></label>
|
||||
<label>Kategorie
|
||||
<select name="category_id" data-type="int">
|
||||
<option value="">–</option>
|
||||
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == r.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Speichern</button>
|
||||
<button type="button" onclick="toggleEdit('rec', {{ r.id }}, false)">Abbrechen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8">Noch keine wiederkehrenden Posten.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<fieldset>
|
||||
<legend>Neuen wiederkehrenden Posten anlegen</legend>
|
||||
<form hx-ext="json-form" hx-post="/api/recurring" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" required></label>
|
||||
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="-49,99" required></label>
|
||||
<label>Rhythmus
|
||||
<select name="rhythm" data-type="str">
|
||||
{% for rh in rhythms %}<option value="{{ rh }}">{{ rh|de_label }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" required></label>
|
||||
<label>Start (optional) <input type="date" name="start_date"></label>
|
||||
<label>Ende (optional) <input type="date" name="end_date"></label>
|
||||
<label>Kategorie
|
||||
<select name="category_id" data-type="int">
|
||||
<option value="">–</option>
|
||||
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Anlegen</button>
|
||||
</form>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Vorschläge aus Buchungen</legend>
|
||||
{% if suggestions %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th>Start</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in suggestions %}
|
||||
<tr>
|
||||
<td>{{ s.name }}</td>
|
||||
<td class="{{ 'neg' if s.amount < 0 else '' }}">
|
||||
{{ s.amount|eur }} €
|
||||
{% if s.hinweis %}<span class="muted">{{ s.hinweis }}</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ s.rhythm|de_label }}</td>
|
||||
<td>{{ s.due_day }}</td>
|
||||
<td>{{ s.start_date.strftime('%d.%m.%Y') if s.start_date else '–' }}</td>
|
||||
<td>
|
||||
<form class="inline-form" hx-ext="json-form" hx-post="/api/recurring" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<input type="hidden" name="name" value="{{ s.name }}">
|
||||
<input type="hidden" name="amount" value="{{ s.amount }}">
|
||||
<input type="hidden" name="rhythm" value="{{ s.rhythm }}">
|
||||
<input type="hidden" name="due_day" data-type="int" value="{{ s.due_day }}">
|
||||
<input type="hidden" name="category_id" data-type="int" value="{{ s.category_id if s.category_id is not none else '' }}">
|
||||
<input type="hidden" name="start_date" value="{{ s.start_date.isoformat() if s.start_date else '' }}">
|
||||
<button type="submit">Vorschlag übernehmen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Keine Vorschläge.</p>
|
||||
{% endif %}
|
||||
<p class="muted">Erkannt werden monatliche, vierteljährliche und jährliche Serien; Betrag = jeweils letzte Buchung.</p>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="planning-section">
|
||||
<h2>Einmalposten</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Betrag</th><th>Fällig am</th><th>Kategorie</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in planned %}
|
||||
<tr id="pln-row-{{ p.id }}">
|
||||
<td>{{ p.name }}</td>
|
||||
<td class="{{ 'neg' if p.amount < 0 else '' }}">{{ p.amount|eur }} €</td>
|
||||
<td>{{ p.due.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ category_names.get(p.category_id, '–') }}</td>
|
||||
<td>
|
||||
<button type="button" onclick="toggleEdit('pln', {{ p.id }}, true)">Bearbeiten</button>
|
||||
<form class="inline-form" hx-delete="/api/planned/{{ p.id }}" hx-swap="none"
|
||||
hx-confirm="„{{ p.name }}“ wirklich löschen?"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<button type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="pln-edit-{{ p.id }}" hidden>
|
||||
<td colspan="5">
|
||||
<form hx-ext="json-form" hx-patch="/api/planned/{{ p.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" value="{{ p.name }}" required></label>
|
||||
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ p.amount|eur }}" required></label>
|
||||
<label>Fällig am <input type="date" name="due" value="{{ p.due.isoformat() }}" required></label>
|
||||
<label>Kategorie
|
||||
<select name="category_id" data-type="int">
|
||||
<option value="">–</option>
|
||||
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == p.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Speichern</button>
|
||||
<button type="button" onclick="toggleEdit('pln', {{ p.id }}, false)">Abbrechen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5">Noch keine Einmalposten.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<fieldset>
|
||||
<legend>Neuen Einmalposten anlegen</legend>
|
||||
<form hx-ext="json-form" hx-post="/api/planned" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" required></label>
|
||||
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="-2000,00" required></label>
|
||||
<label>Fällig am <input type="date" name="due" required></label>
|
||||
<label>Kategorie
|
||||
<select name="category_id" data-type="int">
|
||||
<option value="">–</option>
|
||||
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Anlegen</button>
|
||||
</form>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="planning-section">
|
||||
<h2>Kredite</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Summe</th><th>Zins p.a.</th><th>Laufzeit</th><th>Auszahlung</th><th>Tilgungsart</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for l in loans %}
|
||||
<tr id="loan-row-{{ l.id }}">
|
||||
<td>{{ l.name }}</td>
|
||||
<td>{{ l.principal|eur }} €</td>
|
||||
<td>{{ l.annual_rate_pct|eur }} %</td>
|
||||
<td>{{ l.term_months }} Monate</td>
|
||||
<td>{{ l.payout_date.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ l.repayment_type|de_label }}</td>
|
||||
<td>
|
||||
<button type="button" onclick="toggleEdit('loan', {{ l.id }}, true)">Bearbeiten</button>
|
||||
<form class="inline-form" hx-delete="/api/loans/{{ l.id }}" hx-swap="none"
|
||||
hx-confirm="„{{ l.name }}“ wirklich löschen?"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<button type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Hinweis: eine Kredit-Aenderung veraendert den Tilgungsplan; danach
|
||||
muessen betroffene Szenarien neu durchgerechnet werden. -->
|
||||
<tr id="loan-edit-{{ l.id }}" hidden>
|
||||
<td colspan="7">
|
||||
<form hx-ext="json-form" hx-patch="/api/loans/{{ l.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" value="{{ l.name }}" required></label>
|
||||
<label>Darlehenssumme <input type="text" name="principal" data-type="amount" value="{{ l.principal|eur }}" required></label>
|
||||
<label>Zins p.a. (%) <input type="text" name="annual_rate_pct" data-type="amount" value="{{ l.annual_rate_pct|eur }}" required></label>
|
||||
<label>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" value="{{ l.term_months }}" required></label>
|
||||
<label>Auszahlungsdatum <input type="date" name="payout_date" value="{{ l.payout_date.isoformat() }}" required></label>
|
||||
<label>Tilgungsart
|
||||
<select name="repayment_type" data-type="str">
|
||||
{% for rt in repayment_types %}<option value="{{ rt }}" {% if rt == l.repayment_type %}selected{% endif %}>{{ rt|de_label }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Speichern</button>
|
||||
<button type="button" onclick="toggleEdit('loan', {{ l.id }}, false)">Abbrechen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
<details ontoggle="if(this.open){loadLoanSchedule({{ l.id }})}">
|
||||
<summary>Tilgungsplan</summary>
|
||||
<div id="schedule-{{ l.id }}">Wird beim Öffnen geladen…</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7">Noch keine Kredite.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<fieldset>
|
||||
<legend>Neuen Kredit anlegen</legend>
|
||||
<form hx-ext="json-form" hx-post="/api/loans" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" required></label>
|
||||
<label>Darlehenssumme <input type="text" name="principal" data-type="amount" placeholder="10000,00" required></label>
|
||||
<label>Zins p.a. (%) <input type="text" name="annual_rate_pct" data-type="amount" placeholder="4,5" required></label>
|
||||
<label>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" required></label>
|
||||
<label>Auszahlungsdatum <input type="date" name="payout_date" required></label>
|
||||
<label>Tilgungsart
|
||||
<select name="repayment_type" data-type="str">
|
||||
{% for rt in repayment_types %}<option value="{{ rt }}">{{ rt|de_label }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Anlegen</button>
|
||||
</form>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// json-form-Extension ist zentral in base.html registriert (eine
|
||||
// Definition fuer alle Templates, inkl. data-type="bool"-Handling).
|
||||
// toggleEdit ist ebenfalls zentral in base.html definiert (wird auf
|
||||
// mehreren Seiten fuer Inline-Edit-Formulare gebraucht).
|
||||
|
||||
var loadedSchedules = {};
|
||||
function formatIsoDate(iso) {
|
||||
var parts = iso.split('-');
|
||||
return parts[2] + '.' + parts[1] + '.' + parts[0];
|
||||
}
|
||||
function fmtEur(n) {
|
||||
return Number(n).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
}
|
||||
function loadLoanSchedule(loanId) {
|
||||
if (loadedSchedules[loanId]) return;
|
||||
loadedSchedules[loanId] = true;
|
||||
fetch('/api/loans/' + loanId + '/schedule')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (rows) {
|
||||
var html = '<table><thead><tr><th>Fällig</th><th>Rate</th><th>Zins</th>'
|
||||
+ '<th>Tilgung</th><th>Restschuld</th></tr></thead><tbody>';
|
||||
rows.forEach(function (r) {
|
||||
html += '<tr><td>' + formatIsoDate(r.due) + '</td>'
|
||||
+ '<td>' + fmtEur(r.payment) + ' €</td>'
|
||||
+ '<td>' + fmtEur(r.interest) + ' €</td>'
|
||||
+ '<td>' + fmtEur(r.principal) + ' €</td>'
|
||||
+ '<td>' + fmtEur(r.remaining) + ' €</td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('schedule-' + loanId).innerHTML = html;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
67
finance/app/templates/salden.html
Normal file
67
finance/app/templates/salden.html
Normal file
@@ -0,0 +1,67 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Salden – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Salden</h1>
|
||||
|
||||
<h2>Salden am {{ stichtag.strftime('%d.%m.%Y') }}</h2>
|
||||
<form method="get" action="/salden" class="inline-form">
|
||||
<input type="date" name="stichtag" value="{{ stichtag.isoformat() }}">
|
||||
<button type="submit">Anzeigen</button>
|
||||
</form>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Konto</th><th>Saldo</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for account, balance in balances %}
|
||||
<tr>
|
||||
<td class="account">{{ account.name }}</td>
|
||||
<td class="amount {{ 'neg' if balance < 0 else '' }}">{{ balance|eur }} €</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="2">Keine Konten angelegt.</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="total-row">
|
||||
<td><strong>Gesamt</strong></td>
|
||||
<td class="amount {{ 'neg' if total < 0 else '' }}"><strong>{{ total|eur }} €</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Monatsanfangs-Übersicht</h2>
|
||||
<p class="hint">
|
||||
Stand: Ende des Vortags (letzter Tag des Vormonats) — Buchungen am 1. eines
|
||||
Monats zählen bewusst noch nicht zum "Anfangs"-Saldo, sonst würde der
|
||||
Monatsanfang durch Buchungen des laufenden Monats verzerrt.
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Monat</th>
|
||||
{% for account in accounts %}<th>{{ account.name }}</th>{% endfor %}
|
||||
<th>Gesamt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in months %}
|
||||
<tr data-month="{{ row['month'].isoformat() }}">
|
||||
<td>{{ row['month'].strftime('%m.%Y') }}</td>
|
||||
{% for account, value in row['values'] %}
|
||||
<td class="amount {{ 'neg' if value < 0 else '' }}">{{ value|eur }} €</td>
|
||||
{% endfor %}
|
||||
<td class="amount {{ 'neg' if row['total'] < 0 else '' }}">{{ row['total']|eur }} €</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="{{ accounts|length + 2 }}">Keine bestätigten Buchungen vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if accounts_without_anchor %}
|
||||
<p class="hint">
|
||||
Fußnote „ohne Anker — relative Werte" (Basis 0 statt Datei-Kontostand) gilt für:
|
||||
{{ accounts_without_anchor|map(attribute='name')|join(', ') }}.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
298
finance/app/templates/szenarien.html
Normal file
298
finance/app/templates/szenarien.html
Normal file
@@ -0,0 +1,298 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Szenarien – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Szenarien</h1>
|
||||
|
||||
<section class="planning-section">
|
||||
{% for row in scenario_rows %}
|
||||
{% set sc = row.scenario %}
|
||||
<fieldset>
|
||||
<legend>{{ sc.name }}</legend>
|
||||
<p id="sc-row-{{ sc.id }}">
|
||||
{{ sc.description }}
|
||||
· Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }}
|
||||
· Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }}
|
||||
<button type="button" onclick="toggleEdit('sc', {{ sc.id }}, true)">Bearbeiten</button>
|
||||
</p>
|
||||
<div id="sc-edit-{{ sc.id }}" hidden>
|
||||
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" value="{{ sc.name }}" required></label>
|
||||
<label>Beschreibung <input type="text" name="description" value="{{ sc.description }}"></label>
|
||||
<label><input type="checkbox" name="include_recurring" data-type="bool" {% if sc.include_recurring %}checked{% endif %}> Wiederkehrende Posten einschließen</label>
|
||||
<label><input type="checkbox" name="include_planned" data-type="bool" {% if sc.include_planned %}checked{% endif %}> Einmalposten einschließen</label>
|
||||
<button type="submit">Speichern</button>
|
||||
<button type="button" onclick="toggleEdit('sc', {{ sc.id }}, false)">Abbrechen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>Kredite zuordnen</summary>
|
||||
{% for l in loans %}
|
||||
<label class="inline-form">
|
||||
<input type="checkbox" {% if l.id in row.assigned_loan_ids %}checked{% endif %}
|
||||
onchange="toggleScenarioLoan({{ sc.id }}, {{ l.id }}, this.checked, this)">
|
||||
{{ l.name }}
|
||||
</label>
|
||||
{% else %}
|
||||
<p>Keine Kredite vorhanden.</p>
|
||||
{% endfor %}
|
||||
</details>
|
||||
|
||||
<h3>Einträge</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Was</th><th>Art</th><th>Wert/Betrag</th><th>Datum</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in row.modifiers %}
|
||||
<tr id="mod-row-{{ m.id }}">
|
||||
<td>
|
||||
{% if m.target_type == "category" %}Kategorie: {{ category_names.get(m.target_id, m.target_id) }}
|
||||
{% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %}
|
||||
</td>
|
||||
<td>{{ m.kind|de_label }}</td>
|
||||
<td>{% if m.kind in ('percent', 'absolute') %}{{ m.value|eur }}{% else %}–{% endif %}</td>
|
||||
<td>{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}{% else %}–{% endif %}</td>
|
||||
<td>
|
||||
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, true)">Bearbeiten</button>
|
||||
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<button type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="mod-edit-{{ m.id }}" hidden>
|
||||
<td colspan="5">
|
||||
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Ziel-Typ
|
||||
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
|
||||
<option value="category" {% if m.target_type == 'category' %}selected{% endif %}>Kategorie</option>
|
||||
<option value="recurring" {% if m.target_type == 'recurring' %}selected{% endif %}>Wiederkehrender Posten</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="mod-target-category" {% if m.target_type != 'category' %}style="display:none"{% endif %}>
|
||||
<label>Kategorie
|
||||
<select name="target_id" data-type="int" {% if m.target_type != 'category' %}disabled{% endif %}>
|
||||
{% for c in categories %}<option value="{{ c.id }}" {% if m.target_type == 'category' and c.id == m.target_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</span>
|
||||
<span class="mod-target-recurring" {% if m.target_type != 'recurring' %}style="display:none"{% endif %}>
|
||||
<label>Posten
|
||||
<select name="target_id" data-type="int" {% if m.target_type != 'recurring' %}disabled{% endif %}>
|
||||
{% for r in recurring %}<option value="{{ r.id }}" {% if m.target_type == 'recurring' and r.id == m.target_id %}selected{% endif %}>{{ r.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</span>
|
||||
<label>Art
|
||||
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
|
||||
{% for k in modifier_kinds %}<option value="{{ k }}" {% if k == m.kind %}selected{% endif %}>{{ k|de_label }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Wert <input type="text" name="value" data-type="amount" value="{{ m.value|eur }}" {% if m.kind not in ('percent', 'absolute') %}disabled{% endif %}></label>
|
||||
<label>Endet am <input type="date" name="end_date" value="{{ m.end_date.isoformat() if m.end_date else '' }}" {% if m.kind != 'ende' %}disabled{% endif %}></label>
|
||||
<button type="submit">Speichern</button>
|
||||
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, false)">Abbrechen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for sp in row.planned_items %}
|
||||
<tr id="spi-row-{{ sp.id }}">
|
||||
<td>{{ sp.name }}</td>
|
||||
<td>Einmalzahlung</td>
|
||||
<td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td>
|
||||
<td>{{ sp.due.strftime('%d.%m.%Y') }}</td>
|
||||
<td>
|
||||
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, true)">Bearbeiten</button>
|
||||
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
|
||||
hx-confirm="„{{ sp.name }}“ wirklich löschen?"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<button type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="spi-edit-{{ sp.id }}" hidden>
|
||||
<td colspan="5">
|
||||
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" value="{{ sp.name }}" required></label>
|
||||
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ sp.amount|eur }}" required></label>
|
||||
<label>Fällig am <input type="date" name="due" value="{{ sp.due.isoformat() }}" required></label>
|
||||
<button type="submit">Speichern</button>
|
||||
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, false)">Abbrechen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not row.modifiers and not row.planned_items %}
|
||||
<tr><td colspan="5">Keine Einträge.</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4>Neuer Eintrag</h4>
|
||||
<form class="entry-form" hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" hx-swap="none"
|
||||
data-modifiers-url="/api/scenarios/{{ sc.id }}/modifiers"
|
||||
data-planned-url="/api/scenarios/{{ sc.id }}/planned"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<div class="entry-art">
|
||||
<label>Eintragsart
|
||||
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
|
||||
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
|
||||
<option value="einmal">Einmalzahlung</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="muted">— die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut.</span>
|
||||
</div>
|
||||
<label>Ziel-Typ
|
||||
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
|
||||
<option value="category">Kategorie</option>
|
||||
<option value="recurring">Wiederkehrender Posten</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="mod-target-category">
|
||||
<label>Kategorie
|
||||
<select name="target_id" data-type="int">
|
||||
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</span>
|
||||
<span class="mod-target-recurring" style="display:none">
|
||||
<label>Posten
|
||||
<select name="target_id" data-type="int" disabled title="Für diese Eintragsart nicht relevant">
|
||||
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</span>
|
||||
<label><span class="value-label">Prozentsatz (%)</span> <input type="text" name="value" data-type="amount" value="0"></label>
|
||||
<label>Endet am <input type="date" name="end_date" disabled title="Für diese Eintragsart nicht relevant"></label>
|
||||
<label>Name <input type="text" name="name" disabled title="Für diese Eintragsart nicht relevant"></label>
|
||||
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" disabled title="Für diese Eintragsart nicht relevant"></label>
|
||||
<label>Fällig am <input type="date" name="due" disabled title="Für diese Eintragsart nicht relevant"></label>
|
||||
<button type="submit">Hinzufügen</button>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
<div class="project-block">
|
||||
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<button type="submit">Durchrechnen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if row.result %}
|
||||
<p>
|
||||
Tiefpunkt: {{ row.result.low_point_balance|eur }} € am {{ row.result.low_point_date.strftime('%d.%m.%Y') }}<br>
|
||||
{% if row.result.below_zero_date %}Unterschreitet 0 € ab {{ row.result.below_zero_date.strftime('%d.%m.%Y') }}<br>{% endif %}
|
||||
{% if row.result.below_threshold_date %}Unterschreitet Warnschwelle ab {{ row.result.below_threshold_date.strftime('%d.%m.%Y') }}<br>{% endif %}
|
||||
Kurven in <a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a> ansehen.
|
||||
</p>
|
||||
{% else %}
|
||||
<p>Noch nicht durchgerechnet.</p>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
{% else %}
|
||||
<p>Noch keine Szenarien.</p>
|
||||
{% endfor %}
|
||||
|
||||
<fieldset>
|
||||
<legend>Neues Szenario anlegen</legend>
|
||||
<form hx-ext="json-form" hx-post="/api/scenarios" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||
<label>Name <input type="text" name="name" required></label>
|
||||
<label>Beschreibung <input type="text" name="description"></label>
|
||||
<label><input type="checkbox" name="include_recurring" data-type="bool" checked> Wiederkehrende Posten einschließen</label>
|
||||
<label><input type="checkbox" name="include_planned" data-type="bool" checked> Einmalposten einschließen</label>
|
||||
<button type="submit">Szenario anlegen</button>
|
||||
</form>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
function onModTargetTypeChange(select) {
|
||||
var form = select.closest('form');
|
||||
var catSpan = form.querySelector('.mod-target-category');
|
||||
var recSpan = form.querySelector('.mod-target-recurring');
|
||||
var catSelect = catSpan.querySelector('select');
|
||||
var recSelect = recSpan.querySelector('select');
|
||||
var isCategory = select.value === 'category';
|
||||
catSpan.style.display = isCategory ? '' : 'none';
|
||||
recSpan.style.display = isCategory ? 'none' : '';
|
||||
catSelect.disabled = !isCategory;
|
||||
if (catSelect.disabled) { catSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||
else { catSelect.removeAttribute('title'); }
|
||||
recSelect.disabled = isCategory;
|
||||
if (recSelect.disabled) { recSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||
else { recSelect.removeAttribute('title'); }
|
||||
}
|
||||
|
||||
function onEntryArtChange(select) {
|
||||
// Umschaltmatrix "Neuer Eintrag"/Modifikator-Edit: Felder nur per
|
||||
// disabled schalten (UX-Regel). Fehlt ein Feld (Edit-Formulare haben
|
||||
// kein name/amount/due), wird es uebersprungen.
|
||||
var form = select.closest('form');
|
||||
var art = select.value;
|
||||
var isMod = art !== 'einmal';
|
||||
function setDisabled(name, disabled) {
|
||||
var el = form.querySelector('[name="' + name + '"]');
|
||||
if (el) {
|
||||
el.disabled = disabled;
|
||||
if (disabled) { el.title = 'Für diese Eintragsart nicht relevant'; }
|
||||
else { el.removeAttribute('title'); }
|
||||
}
|
||||
}
|
||||
setDisabled('value', !(art === 'percent' || art === 'absolute'));
|
||||
var valueLabel = form.querySelector('.value-label');
|
||||
if (valueLabel) {
|
||||
valueLabel.textContent = art === 'percent' ? 'Prozentsatz (%)'
|
||||
: art === 'absolute' ? 'Kürzung (€)' : 'Wert';
|
||||
}
|
||||
setDisabled('end_date', art !== 'ende');
|
||||
setDisabled('name', isMod);
|
||||
setDisabled('amount', isMod);
|
||||
setDisabled('due', isMod);
|
||||
var typeSelect = form.querySelector('[name="target_type"]');
|
||||
if (typeSelect) {
|
||||
typeSelect.disabled = !isMod;
|
||||
if (!isMod) { typeSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||
else { typeSelect.removeAttribute('title'); }
|
||||
var isCategory = typeSelect.value === 'category';
|
||||
var catSelect = form.querySelector('.mod-target-category select');
|
||||
var recSelect = form.querySelector('.mod-target-recurring select');
|
||||
if (catSelect) {
|
||||
catSelect.disabled = !isMod || !isCategory;
|
||||
if (catSelect.disabled) { catSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||
else { catSelect.removeAttribute('title'); }
|
||||
}
|
||||
if (recSelect) {
|
||||
recSelect.disabled = !isMod || isCategory;
|
||||
if (recSelect.disabled) { recSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||
else { recSelect.removeAttribute('title'); }
|
||||
}
|
||||
}
|
||||
// POST-Ziel nur beim Anlege-Formular wechseln (Edit-Formulare patchen fix).
|
||||
if (form.hasAttribute('data-planned-url')) {
|
||||
form.setAttribute('hx-post', isMod ? form.getAttribute('data-modifiers-url')
|
||||
: form.getAttribute('data-planned-url'));
|
||||
htmx.process(form);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleScenarioLoan(scenarioId, loanId, checked, checkbox) {
|
||||
fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' })
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) {
|
||||
throw new Error('HTTP ' + resp.status);
|
||||
}
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function (err) {
|
||||
checkbox.checked = !checked;
|
||||
alert('Zuordnung fehlgeschlagen: ' + err.message);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
134
finance/app/templates/transactions.html
Normal file
134
finance/app/templates/transactions.html
Normal file
@@ -0,0 +1,134 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Buchungen – Finanzberatung{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Buchungen</h1>
|
||||
|
||||
<form class="filter-form" method="get" action="/buchungen">
|
||||
<label>Konto
|
||||
<select name="account_id">
|
||||
<option value="">alle</option>
|
||||
{% for a in accounts %}
|
||||
<option value="{{ a.id }}" {% if filters.account_id == a.id %}selected{% endif %}>{{ a.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Von
|
||||
<input type="date" name="date_from" value="{{ filters.date_from.isoformat() if filters.date_from else '' }}">
|
||||
</label>
|
||||
<label>Bis
|
||||
<input type="date" name="date_to" value="{{ filters.date_to.isoformat() if filters.date_to else '' }}">
|
||||
</label>
|
||||
<label>Kategorie
|
||||
<select name="category_id">
|
||||
<option value="">alle</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c.id }}" {% if filters.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Suche
|
||||
<input type="text" name="q" value="{{ filters.q }}" placeholder="Verwendungszweck / Empfänger">
|
||||
</label>
|
||||
<label>Status
|
||||
<select name="status">
|
||||
{% for s in ("confirmed", "draft") %}
|
||||
<option value="{{ s }}" {% if filters.status == s %}selected{% endif %}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Filtern</button>
|
||||
</form>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th><th>Konto</th><th>Verwendungszweck</th><th>Empfänger/Zahler</th>
|
||||
<th>Betrag (€)</th><th>Kategorie</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tx in transactions %}
|
||||
<tr>
|
||||
<td>{{ tx.booking_date.strftime('%d.%m.%Y') }}</td>
|
||||
<td class="account">{{ account_names.get(tx.account_id, tx.account_id) }}</td>
|
||||
<td>{{ tx.purpose }}</td>
|
||||
<td>{{ tx.counterparty }}</td>
|
||||
<td class="amount {{ 'neg' if tx.amount < 0 else '' }}">{{ tx.amount|eur }}</td>
|
||||
<td>
|
||||
<select name="category_id" data-type="int" hx-ext="json-form"
|
||||
hx-patch="/api/transactions/{{ tx.id }}" hx-trigger="change" hx-swap="none">
|
||||
<option value="">–</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c.id }}" {% if tx.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<details>
|
||||
<summary>Regel erzeugen</summary>
|
||||
<form hx-ext="json-form" hx-post="/api/category-rules" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){this.closest('details').open=false}">
|
||||
<input type="hidden" name="pattern" value="{{ tx.counterparty }}">
|
||||
<label>Kategorie
|
||||
<select name="category_id" data-type="int" required>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c.id }}" {% if tx.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Regel speichern</button>
|
||||
</form>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7">Keine Buchungen gefunden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="pager">
|
||||
{% set pager_params = {
|
||||
'account_id': filters.account_id,
|
||||
'date_from': filters.date_from.isoformat() if filters.date_from else None,
|
||||
'date_to': filters.date_to.isoformat() if filters.date_to else None,
|
||||
'category_id': filters.category_id,
|
||||
'q': filters.q,
|
||||
'status': filters.status,
|
||||
} %}
|
||||
{% if page > 1 %}
|
||||
<a href="/buchungen?{% for k, v in pager_params.items() %}{% if v %}{{ k }}={{ v|urlencode }}&{% endif %}{% endfor %}page={{ page - 1 }}">Zurück</a>
|
||||
{% endif %}
|
||||
Seite {{ page }} von {{ total_pages }}
|
||||
{% if page < total_pages %}
|
||||
<a href="/buchungen?{% for k, v in pager_params.items() %}{% if v %}{{ k }}={{ v|urlencode }}&{% endif %}{% endfor %}page={{ page + 1 }}">Weiter</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<fieldset>
|
||||
<legend>Buchung manuell erfassen</legend>
|
||||
<form hx-ext="json-form" hx-post="/api/transactions" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){this.reset(); window.location.reload()}">
|
||||
<label>Konto
|
||||
<select name="account_id" data-type="int" required>
|
||||
{% for a in accounts %}
|
||||
<option value="{{ a.id }}">{{ a.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Buchungsdatum
|
||||
<input type="date" name="booking_date" required>
|
||||
</label>
|
||||
<label>Betrag
|
||||
<input type="text" name="amount" placeholder="-12.50" required>
|
||||
</label>
|
||||
<label>Verwendungszweck
|
||||
<input type="text" name="purpose">
|
||||
</label>
|
||||
<label>Empfänger/Zahler
|
||||
<input type="text" name="counterparty">
|
||||
</label>
|
||||
<button type="submit">Buchung speichern</button>
|
||||
</form>
|
||||
</fieldset>
|
||||
{% endblock %}
|
||||
9
finance/app/version.py
Normal file
9
finance/app/version.py
Normal 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
4
finance/entrypoint.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
alembic upgrade head
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
173
finance/grafana/dashboards/finanzen.json
Normal file
173
finance/grafana/dashboards/finanzen.json
Normal file
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"uid": "finanzen",
|
||||
"title": "Finanzen",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"editable": true,
|
||||
"timezone": "browser",
|
||||
"tags": ["finanzen"],
|
||||
"time": {
|
||||
"from": "now-1y",
|
||||
"to": "now"
|
||||
},
|
||||
"refresh": "",
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "timeseries",
|
||||
"title": "Kontostand-Verlauf",
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "FinanzDB"
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"lineWidth": 1,
|
||||
"fillOpacity": 0,
|
||||
"lineInterpolation": "stepAfter",
|
||||
"spanNulls": true
|
||||
},
|
||||
"unit": "currencyEUR"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||
"tooltip": { "mode": "multi" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "FinanzDB"
|
||||
},
|
||||
"format": "time_series",
|
||||
"rawSql": "SELECT day AS time, account AS metric, balance AS value FROM v_balance_history ORDER BY day",
|
||||
"rawQuery": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "timeseries",
|
||||
"title": "Gesamt-Saldo",
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "FinanzDB"
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"lineWidth": 2,
|
||||
"fillOpacity": 10,
|
||||
"lineInterpolation": "stepAfter",
|
||||
"spanNulls": true
|
||||
},
|
||||
"unit": "currencyEUR"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||
"tooltip": { "mode": "single" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "FinanzDB"
|
||||
},
|
||||
"format": "time_series",
|
||||
"rawSql": "SELECT day AS time, balance AS value FROM v_balance_total ORDER BY day",
|
||||
"rawQuery": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "barchart",
|
||||
"title": "Monat nach Kategorie",
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "FinanzDB"
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "currencyEUR"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||
"stacking": "normal",
|
||||
"xTickLabelRotation": 0
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "FinanzDB"
|
||||
},
|
||||
"format": "time_series",
|
||||
"rawSql": "SELECT month AS time, category AS metric, total AS value FROM v_monthly_by_category ORDER BY month",
|
||||
"rawQuery": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "timeseries",
|
||||
"title": "Szenario-Vergleich",
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "FinanzDB"
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"lineWidth": 1,
|
||||
"fillOpacity": 0,
|
||||
"thresholdsStyle": { "mode": "line" }
|
||||
},
|
||||
"unit": "currencyEUR",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "orange", "value": 0 },
|
||||
{ "color": "green", "value": 500 }
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||
"tooltip": { "mode": "multi" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": {
|
||||
"type": "postgres",
|
||||
"uid": "FinanzDB"
|
||||
},
|
||||
"format": "time_series",
|
||||
"rawSql": "SELECT day AS time, scenario AS metric, balance AS value FROM v_projection ORDER BY day",
|
||||
"rawQuery": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
6
finance/grafana/provisioning/dashboards/provider.yaml
Normal file
6
finance/grafana/provisioning/dashboards/provider.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: 1
|
||||
providers:
|
||||
- name: finanzen
|
||||
type: file
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
13
finance/grafana/provisioning/datasources/postgres.yaml
Normal file
13
finance/grafana/provisioning/datasources/postgres.yaml
Normal 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
2
finance/pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
3
finance/requirements-dev.txt
Normal file
3
finance/requirements-dev.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest==8.4.1
|
||||
httpx==0.28.1
|
||||
9
finance/requirements.txt
Normal file
9
finance/requirements.txt
Normal 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
|
||||
9
finance/scripts/dump_pdf_text.py
Normal file
9
finance/scripts/dump_pdf_text.py
Normal 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)")
|
||||
29
finance/scripts/parser_audit.py
Normal file
29
finance/scripts/parser_audit.py
Normal 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)")
|
||||
166
finance/scripts/parser_vs_csv.py
Normal file
166
finance/scripts/parser_vs_csv.py
Normal 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))
|
||||
0
finance/tests/__init__.py
Normal file
0
finance/tests/__init__.py
Normal file
93
finance/tests/conftest.py
Normal file
93
finance/tests/conftest.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db import Base, get_session
|
||||
import app.models # noqa: F401 ensures all ORM tables are registered on Base.metadata
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
engine = create_engine(
|
||||
"sqlite://", poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
# SQLite does not enforce foreign keys unless told to per-connection. Turn
|
||||
# enforcement on so the tests catch FK violations (missing cascades) the
|
||||
# same way Postgres would in production.
|
||||
@event.listens_for(engine, "connect")
|
||||
def _fk_pragma(dbapi_conn, _):
|
||||
cur = dbapi_conn.cursor()
|
||||
cur.execute("PRAGMA foreign_keys=ON")
|
||||
cur.close()
|
||||
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db, monkeypatch):
|
||||
monkeypatch.setenv("FB_API_KEY", "test-key")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.auth import hash_password
|
||||
monkeypatch.setenv("FB_GUI_PASSWORD_HASH", hash_password("geheim"))
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
|
||||
def _override_get_session():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_session] = _override_get_session
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env_file(tmp_path):
|
||||
"""Erzeugt eine .env-Datei im create_pod_finance.sh-Format (single-quoted
|
||||
KEY='value') mit einem initialen Passwort+Hash. Liefert (Pfad, Passwort)
|
||||
fuer Tests der Admin-Passwortaenderung (Ausbaustufe 4 Task 2), die die
|
||||
Laufzeit-.env-Lesung/-Schreibung ueber FB_ENV_FILE gegen eine tmp_path
|
||||
statt der echten Container-.env pruefen."""
|
||||
from app.auth import hash_password
|
||||
path = tmp_path / ".env"
|
||||
password = "geheim123"
|
||||
path.write_text(
|
||||
f"FB_PASSWORD='{password}'\n"
|
||||
f"FB_GUI_PASSWORD_HASH='{hash_password(password)}'\n"
|
||||
)
|
||||
return path, password
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_env_file(db, env_file, monkeypatch):
|
||||
"""Wie `client`, aber der GUI-Passwort-Hash kommt NICHT aus der
|
||||
FB_GUI_PASSWORD_HASH-Umgebungsvariable, sondern wird zur Laufzeit aus der
|
||||
ueber FB_ENV_FILE referenzierten Datei gelesen (der Pfad, den
|
||||
change_password() in-place ueberschreibt)."""
|
||||
path, _password = env_file
|
||||
monkeypatch.setenv("FB_API_KEY", "test-key")
|
||||
monkeypatch.delenv("FB_GUI_PASSWORD_HASH", raising=False)
|
||||
monkeypatch.setenv("FB_ENV_FILE", str(path))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.auth import _hash_cache
|
||||
_hash_cache.clear()
|
||||
from app.main import app
|
||||
|
||||
def _override_get_session():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_session] = _override_get_session
|
||||
with TestClient(app) as c:
|
||||
yield c, env_file[1]
|
||||
app.dependency_overrides.clear()
|
||||
get_settings.cache_clear()
|
||||
_hash_cache.clear()
|
||||
9
finance/tests/fixtures/README.md
vendored
Normal file
9
finance/tests/fixtures/README.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# Beispiel-Auszüge (nicht committen!)
|
||||
|
||||
Hier pro Bank mindestens einen echten PDF-Kontoauszug ablegen:
|
||||
- `vr_beispiel.pdf` (Volksbank/Raiffeisenbank)
|
||||
- `hvb_beispiel.pdf` (HypoVereinsbank)
|
||||
- `dkb_beispiel.pdf` (DKB)
|
||||
|
||||
Beträge dürfen verfremdet sein, das Layout muss echt sein.
|
||||
PDFs sind via .gitignore vom Repo ausgeschlossen.
|
||||
315
finance/tests/test_admin.py
Normal file
315
finance/tests/test_admin.py
Normal file
@@ -0,0 +1,315 @@
|
||||
import os
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
import app.services.admin as admin_service
|
||||
from app.services.admin import apply_rules_retroactively, change_password
|
||||
from app.models.tables import Account, Category, CategoryRule, Transaction
|
||||
|
||||
|
||||
# --- change_password(): reine Service-Tests (kein HTTP), gegen die
|
||||
# FB_ENV_FILE-Datei aus client_with_env_file -----------------------------
|
||||
|
||||
def test_change_password_wrong_old_raises_without_touching_grafana_or_env(
|
||||
client_with_env_file, env_file, monkeypatch
|
||||
):
|
||||
_client, password = client_with_env_file
|
||||
path, _ = env_file
|
||||
before = path.read_text()
|
||||
calls = []
|
||||
monkeypatch.setattr(admin_service, "_sync_grafana_password",
|
||||
lambda *a, **kw: calls.append(a))
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
change_password("falsches-passwort", "neuesPasswort123")
|
||||
|
||||
assert calls == [] # Grafana darf bei falschem alten Passwort nie aufgerufen werden
|
||||
assert path.read_text() == before
|
||||
|
||||
|
||||
def test_change_password_calls_grafana_before_writing_env(
|
||||
client_with_env_file, env_file, monkeypatch
|
||||
):
|
||||
_client, password = client_with_env_file
|
||||
path, _ = env_file
|
||||
order = []
|
||||
|
||||
def fake_sync(old, new, base_url):
|
||||
order.append("grafana")
|
||||
assert path.read_text() == before # .env noch unveraendert an diesem Punkt
|
||||
|
||||
before = path.read_text()
|
||||
monkeypatch.setattr(admin_service, "_sync_grafana_password", fake_sync)
|
||||
|
||||
change_password(password, "neuesPasswort123")
|
||||
|
||||
order.append("env-written")
|
||||
assert order == ["grafana", "env-written"]
|
||||
assert "FB_PASSWORD='neuesPasswort123'" in path.read_text()
|
||||
|
||||
|
||||
def test_change_password_grafana_failure_leaves_env_unchanged(
|
||||
client_with_env_file, env_file, monkeypatch
|
||||
):
|
||||
_client, password = client_with_env_file
|
||||
path, _ = env_file
|
||||
before = path.read_text()
|
||||
|
||||
def fail(*a, **kw):
|
||||
raise RuntimeError("Grafana-Passwortänderung fehlgeschlagen: boom")
|
||||
|
||||
monkeypatch.setattr(admin_service, "_sync_grafana_password", fail)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
change_password(password, "neuesPasswort123")
|
||||
|
||||
assert path.read_text() == before
|
||||
|
||||
|
||||
def test_change_password_success_rewrites_env_in_place_preserving_inode(
|
||||
client_with_env_file, env_file, monkeypatch
|
||||
):
|
||||
_client, password = client_with_env_file
|
||||
path, _ = env_file
|
||||
monkeypatch.setattr(admin_service, "_sync_grafana_password",
|
||||
lambda *a, **kw: None)
|
||||
|
||||
inode_before = os.stat(path).st_ino
|
||||
change_password(password, "neuesPasswort123")
|
||||
inode_after = os.stat(path).st_ino
|
||||
|
||||
assert inode_before == inode_after # KRITISCH: kein rename, gleicher Inode
|
||||
content = path.read_text()
|
||||
assert "FB_PASSWORD='neuesPasswort123'" in content
|
||||
assert "FB_GUI_PASSWORD_HASH='" in content
|
||||
assert f"FB_GUI_PASSWORD_HASH='{password}'" not in content
|
||||
|
||||
|
||||
def test_change_password_new_password_logs_in_old_does_not(
|
||||
client_with_env_file, monkeypatch
|
||||
):
|
||||
client, password = client_with_env_file
|
||||
monkeypatch.setattr(admin_service, "_sync_grafana_password",
|
||||
lambda *a, **kw: None)
|
||||
|
||||
change_password(password, "neuesPasswort123")
|
||||
|
||||
r = client.post("/login", data={"username": "admin", "password": "neuesPasswort123"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
client.cookies.clear()
|
||||
r2 = client.post("/login", data={"username": "admin", "password": password},
|
||||
follow_redirects=False)
|
||||
assert r2.status_code == 401
|
||||
|
||||
|
||||
# --- /admin GUI-Route: Auth-Gate + Formular-Fehlerpfade -------------------
|
||||
|
||||
def test_admin_page_requires_gui_session(client_with_env_file):
|
||||
client, _password = client_with_env_file
|
||||
r = client.get("/admin", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_admin_page_reachable_after_login(client_with_env_file):
|
||||
client, password = client_with_env_file
|
||||
client.post("/login", data={"username": "admin", "password": password})
|
||||
r = client.get("/admin")
|
||||
assert r.status_code == 200
|
||||
assert "Passwort ändern" in r.text
|
||||
assert "Regeln neu anwenden" in r.text
|
||||
|
||||
|
||||
def test_admin_passwort_wrong_old_returns_400_with_message(
|
||||
client_with_env_file, monkeypatch
|
||||
):
|
||||
client, password = client_with_env_file
|
||||
monkeypatch.setattr(admin_service, "_sync_grafana_password",
|
||||
lambda *a, **kw: None)
|
||||
client.post("/login", data={"username": "admin", "password": password})
|
||||
|
||||
r = client.post("/admin/passwort", data={
|
||||
"alt": "falsch", "neu": "neuesPasswort123", "neu2": "neuesPasswort123",
|
||||
})
|
||||
|
||||
assert r.status_code == 400
|
||||
assert "falsch" in r.text.lower()
|
||||
|
||||
|
||||
def test_admin_passwort_too_short_returns_400(client_with_env_file):
|
||||
client, password = client_with_env_file
|
||||
client.post("/login", data={"username": "admin", "password": password})
|
||||
|
||||
r = client.post("/admin/passwort", data={
|
||||
"alt": password, "neu": "kurz1", "neu2": "kurz1",
|
||||
})
|
||||
|
||||
assert r.status_code == 400
|
||||
assert "8 Zeichen" in r.text
|
||||
|
||||
|
||||
def test_admin_passwort_mismatch_returns_400(client_with_env_file):
|
||||
client, password = client_with_env_file
|
||||
client.post("/login", data={"username": "admin", "password": password})
|
||||
|
||||
r = client.post("/admin/passwort", data={
|
||||
"alt": password, "neu": "neuesPasswort123", "neu2": "andersPasswort123",
|
||||
})
|
||||
|
||||
assert r.status_code == 400
|
||||
assert "stimmt nicht" in r.text.lower() or "wiederholung" in r.text.lower()
|
||||
|
||||
|
||||
def test_admin_passwort_grafana_failure_returns_400_env_unchanged(
|
||||
client_with_env_file, env_file, monkeypatch
|
||||
):
|
||||
client, password = client_with_env_file
|
||||
path, _ = env_file
|
||||
before = path.read_text()
|
||||
|
||||
def fail(*a, **kw):
|
||||
raise RuntimeError("Grafana-Passwortänderung fehlgeschlagen: boom")
|
||||
|
||||
monkeypatch.setattr(admin_service, "_sync_grafana_password", fail)
|
||||
client.post("/login", data={"username": "admin", "password": password})
|
||||
|
||||
r = client.post("/admin/passwort", data={
|
||||
"alt": password, "neu": "neuesPasswort123", "neu2": "neuesPasswort123",
|
||||
})
|
||||
|
||||
assert r.status_code == 400
|
||||
assert "grafana" in r.text.lower()
|
||||
assert path.read_text() == before
|
||||
|
||||
|
||||
def test_admin_passwort_grafana_read_timeout_returns_400_env_unchanged(
|
||||
client_with_env_file, env_file, monkeypatch
|
||||
):
|
||||
"""F1 (Fable-Reject): ein Hang in der Grafana-Lesephase wirft ein rohes
|
||||
TimeoutError statt eines urllib.error.URLError. Ohne expliziten Fang in
|
||||
_sync_grafana_password schlaegt das bis in den Router durch (500 statt
|
||||
deutscher 400-Fehlermeldung) - siehe app/services/admin.py."""
|
||||
client, password = client_with_env_file
|
||||
path, _ = env_file
|
||||
before = path.read_text()
|
||||
|
||||
def hang(request, timeout=None):
|
||||
raise TimeoutError("timed out")
|
||||
|
||||
monkeypatch.setattr("app.services.admin.urllib.request.urlopen", hang)
|
||||
client.post("/login", data={"username": "admin", "password": password})
|
||||
|
||||
r = client.post("/admin/passwort", data={
|
||||
"alt": password, "neu": "neuesPasswort123", "neu2": "neuesPasswort123",
|
||||
})
|
||||
|
||||
assert r.status_code == 400
|
||||
assert "grafana" in r.text.lower()
|
||||
assert path.read_text() == before
|
||||
|
||||
|
||||
def test_admin_passwort_success_returns_200_with_confirmation(
|
||||
client_with_env_file, monkeypatch
|
||||
):
|
||||
client, password = client_with_env_file
|
||||
calls = []
|
||||
monkeypatch.setattr(admin_service, "_sync_grafana_password",
|
||||
lambda *a, **kw: calls.append(a))
|
||||
client.post("/login", data={"username": "admin", "password": password})
|
||||
|
||||
r = client.post("/admin/passwort", data={
|
||||
"alt": password, "neu": "neuesPasswort123", "neu2": "neuesPasswort123",
|
||||
})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert "erfolgreich" in r.text.lower()
|
||||
assert calls # Grafana wurde tatsaechlich aufgerufen
|
||||
|
||||
# Session-Cookie bleibt nach der Aenderung gueltig (Secret unveraendert)
|
||||
r2 = client.get("/admin")
|
||||
assert r2.status_code == 200
|
||||
|
||||
|
||||
# --- Kategorien-Verwaltung --------------------------------------------------
|
||||
|
||||
def test_admin_zeigt_kategorien_verwaltung(client_with_env_file, db):
|
||||
from app.models.tables import Category
|
||||
client, password = client_with_env_file
|
||||
client.post("/login", data={"username": "admin", "password": password})
|
||||
db.add(Category(name="Admin-Kat"))
|
||||
db.commit()
|
||||
r = client.get("/admin").text
|
||||
assert "Kategorien" in r and "Admin-Kat" in r
|
||||
assert "Neue Kategorie anlegen" in r
|
||||
assert 'hx-patch="/api/categories/' in r
|
||||
assert 'hx-post="/api/categories"' in r
|
||||
|
||||
|
||||
# --- Regeln neu anwenden ---------------------------------------------------
|
||||
|
||||
def test_apply_rules_retroactively_categorizes_uncategorized_confirmed_tx(db):
|
||||
account = Account(bank="Test", iban="DE02120300000000202099", name="Giro")
|
||||
db.add(account)
|
||||
db.flush()
|
||||
category = Category(name="Lebensmittel")
|
||||
db.add(category)
|
||||
db.flush()
|
||||
db.add(CategoryRule(pattern="rewe", category_id=category.id, priority=100))
|
||||
tx_confirmed_uncategorized = Transaction(
|
||||
account_id=account.id, booking_date=date(2026, 1, 5),
|
||||
amount=Decimal("-10.00"), purpose="REWE Markt", counterparty="",
|
||||
status="confirmed", dedup_hash="h1",
|
||||
)
|
||||
tx_draft = Transaction(
|
||||
account_id=account.id, booking_date=date(2026, 1, 6),
|
||||
amount=Decimal("-5.00"), purpose="REWE Markt", counterparty="",
|
||||
status="draft", dedup_hash="h2",
|
||||
)
|
||||
tx_already_categorized = Transaction(
|
||||
account_id=account.id, booking_date=date(2026, 1, 7),
|
||||
amount=Decimal("-5.00"), purpose="REWE Markt", counterparty="",
|
||||
status="confirmed", category_id=category.id, dedup_hash="h3",
|
||||
)
|
||||
db.add_all([tx_confirmed_uncategorized, tx_draft, tx_already_categorized])
|
||||
db.commit()
|
||||
|
||||
hits = apply_rules_retroactively(db)
|
||||
|
||||
assert hits == 1
|
||||
db.refresh(tx_confirmed_uncategorized)
|
||||
db.refresh(tx_draft)
|
||||
assert tx_confirmed_uncategorized.category_id == category.id
|
||||
assert tx_draft.category_id is None # Entwuerfe bleiben unangetastet
|
||||
|
||||
|
||||
def test_apply_category_rules_endpoint_requires_auth(client):
|
||||
assert client.post("/api/category-rules/apply").status_code == 401
|
||||
|
||||
|
||||
def test_apply_category_rules_endpoint_categorizes_and_returns_count(client, db):
|
||||
account = Account(bank="Test", iban="DE02120300000000202098", name="Giro")
|
||||
db.add(account)
|
||||
db.flush()
|
||||
category = Category(name="Lebensmittel")
|
||||
db.add(category)
|
||||
db.flush()
|
||||
db.add(CategoryRule(pattern="rewe", category_id=category.id, priority=100))
|
||||
tx = Transaction(
|
||||
account_id=account.id, booking_date=date(2026, 1, 5),
|
||||
amount=Decimal("-10.00"), purpose="REWE Markt", counterparty="",
|
||||
status="confirmed", dedup_hash="h1",
|
||||
)
|
||||
db.add(tx)
|
||||
db.commit()
|
||||
|
||||
r = client.post("/api/category-rules/apply",
|
||||
headers={"Authorization": "Bearer test-key"})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"categorized": 1}
|
||||
db.refresh(tx)
|
||||
assert tx.category_id == category.id
|
||||
62
finance/tests/test_auth.py
Normal file
62
finance/tests/test_auth.py
Normal file
@@ -0,0 +1,62 @@
|
||||
def test_api_requires_key(client):
|
||||
assert client.get("/api/accounts").status_code == 401
|
||||
r = client.get("/api/accounts", headers={"Authorization": "Bearer test-key"})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_login_flow(client):
|
||||
r = client.post("/login", data={"username": "admin", "password": "falsch"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 401
|
||||
r = client.post("/login", data={"username": "admin", "password": "geheim"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert client.get("/api/accounts").status_code == 200 # Cookie reicht
|
||||
|
||||
|
||||
def test_logout_flow(client):
|
||||
r = client.post("/login", data={"username": "admin", "password": "geheim"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert client.get("/api/accounts").status_code == 200
|
||||
|
||||
r = client.post("/logout", follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert client.get("/api/accounts").status_code == 401
|
||||
|
||||
|
||||
def test_tampered_session_cookie_rejected(client):
|
||||
client.cookies.set("fb_session", "gui.invalid-signature")
|
||||
assert client.get("/api/accounts").status_code == 401
|
||||
|
||||
|
||||
def test_current_password_hash_falls_back_to_env_when_no_env_file(client, monkeypatch):
|
||||
# `client`-Fixture setzt FB_GUI_PASSWORD_HASH direkt und keine FB_ENV_FILE,
|
||||
# der Default-Pfad /data/.env existiert im Testlauf nicht -> Fallback.
|
||||
from app.auth import current_password_hash
|
||||
from app.config import get_settings
|
||||
assert current_password_hash() == get_settings().gui_password_hash
|
||||
|
||||
|
||||
def test_current_password_hash_reads_from_env_file_when_present(
|
||||
client_with_env_file,
|
||||
):
|
||||
from app.auth import current_password_hash, verify_password
|
||||
_client, password = client_with_env_file
|
||||
assert verify_password(password, current_password_hash())
|
||||
|
||||
|
||||
def test_current_password_hash_cache_invalidates_on_file_change(
|
||||
client_with_env_file, env_file,
|
||||
):
|
||||
from app.auth import current_password_hash, hash_password, verify_password
|
||||
path, password = env_file
|
||||
first = current_password_hash()
|
||||
assert verify_password(password, first)
|
||||
|
||||
new_hash = hash_password("andereswort999")
|
||||
path.write_text(f"FB_PASSWORD='andereswort999'\nFB_GUI_PASSWORD_HASH='{new_hash}'\n")
|
||||
|
||||
second = current_password_hash()
|
||||
assert second == new_hash
|
||||
assert verify_password("andereswort999", second)
|
||||
120
finance/tests/test_bank_parsers.py
Normal file
120
finance/tests/test_bank_parsers.py
Normal 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 == ""
|
||||
37
finance/tests/test_config.py
Normal file
37
finance/tests/test_config.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
def test_defaults():
|
||||
s = get_settings()
|
||||
assert s.horizon_days == 548
|
||||
assert s.gui_user == "admin"
|
||||
|
||||
|
||||
def test_env_file_defaults_to_container_mount_path(monkeypatch):
|
||||
monkeypatch.delenv("FB_ENV_FILE", raising=False)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert get_settings().env_file == Path("/data/.env")
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_env_file_overridable_via_env_var(monkeypatch, tmp_path):
|
||||
custom = tmp_path / "custom.env"
|
||||
monkeypatch.setenv("FB_ENV_FILE", str(custom))
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert get_settings().env_file == custom
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_grafana_url_default(monkeypatch):
|
||||
monkeypatch.delenv("FB_GRAFANA_URL", raising=False)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert get_settings().grafana_url == "http://localhost:3000"
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
296
finance/tests/test_crud_api.py
Normal file
296
finance/tests/test_crud_api.py
Normal file
@@ -0,0 +1,296 @@
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models.tables import Account, Transaction
|
||||
from app.services.balances import account_balance
|
||||
|
||||
H = {"Authorization": "Bearer test-key"}
|
||||
|
||||
|
||||
def test_account_crud_and_balance(client, db):
|
||||
# Portiert von Statement-closing-Logik auf Konto-Saldo-Anker (Ausbaustufe 3
|
||||
# Task 1): frueher lieferte die closing_balance des juengsten bestaetigten
|
||||
# Statements die Basis, jetzt uebernimmt der Anker (Datum+Betrag am Konto)
|
||||
# dieselbe Rolle. `at` wird explizit gesetzt statt auf date.today() zu
|
||||
# vertrauen, damit der Test unabhaengig vom Ausfuehrungsdatum ist.
|
||||
r = client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": "DE02120300000000202051",
|
||||
"name": "Giro", "type": "giro"})
|
||||
assert r.status_code == 201
|
||||
acc_id = r.json()["id"]
|
||||
acc = db.get(Account, acc_id)
|
||||
acc.anchor_date = date(2026, 6, 30)
|
||||
acc.anchor_balance = Decimal("100.00")
|
||||
db.add(Transaction(account_id=acc_id, booking_date=date(2026, 7, 2),
|
||||
amount=Decimal("-30.00"), purpose="Bar", status="confirmed",
|
||||
dedup_hash="x1"))
|
||||
db.commit()
|
||||
acc = db.get(Account, acc_id)
|
||||
assert account_balance(db, acc, at=date(2026, 7, 15)) == Decimal("70.00")
|
||||
|
||||
|
||||
def test_account_balance_anchor_forward_and_backward(client, db):
|
||||
# Spec-Interface (Plan Ausbaustufe 3 Task 1): anchor_balance +
|
||||
# Sum(tx: anchor_date < booking_date <= at) - Sum(tx: at < booking_date
|
||||
# <= anchor_date). Buchungen VOR und NACH dem Anker duerfen sich nicht
|
||||
# gegenseitig beeinflussen.
|
||||
acc = Account(bank="dkb", iban="DE-ANCHOR-1", name="Anker-Konto", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
anchor_date = date(2026, 6, 30)
|
||||
acc.anchor_date = anchor_date
|
||||
acc.anchor_balance = Decimal("500.00")
|
||||
db.add(Transaction(account_id=acc.id, booking_date=date(2026, 6, 29),
|
||||
amount=Decimal("-20.00"), purpose="vor Anker",
|
||||
status="confirmed", dedup_hash="x2"))
|
||||
db.add(Transaction(account_id=acc.id, booking_date=date(2026, 7, 1),
|
||||
amount=Decimal("50.00"), purpose="nach Anker",
|
||||
status="confirmed", dedup_hash="x3"))
|
||||
db.commit()
|
||||
acc = db.get(Account, acc.id)
|
||||
|
||||
assert account_balance(db, acc, at=anchor_date) == Decimal("500.00")
|
||||
assert account_balance(db, acc, at=anchor_date + timedelta(days=2)) == Decimal("550.00")
|
||||
assert account_balance(db, acc, at=anchor_date - timedelta(days=2)) == Decimal("520.00")
|
||||
|
||||
|
||||
def test_account_balance_anchor_day_transaction_convention(client, db):
|
||||
# Konvention (Plan Step 5): der Anker gilt PER TAGESENDE des Ankerdatums.
|
||||
# Eine Buchung AM Ankertag ist im Anker bereits enthalten -> zaehlt NICHT
|
||||
# zur Vorwaertssumme, wohl aber zur Rueckwaertssumme.
|
||||
acc = Account(bank="dkb", iban="DE-ANCHOR-2", name="Anker-Konto 2", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
anchor_date = date(2026, 6, 30)
|
||||
acc.anchor_date = anchor_date
|
||||
acc.anchor_balance = Decimal("200.00")
|
||||
db.add(Transaction(account_id=acc.id, booking_date=anchor_date,
|
||||
amount=Decimal("15.00"), purpose="am Ankertag",
|
||||
status="confirmed", dedup_hash="x4"))
|
||||
db.commit()
|
||||
acc = db.get(Account, acc.id)
|
||||
|
||||
assert account_balance(db, acc, at=anchor_date + timedelta(days=1)) == Decimal("200.00")
|
||||
assert account_balance(db, acc, at=anchor_date - timedelta(days=1)) == Decimal("185.00")
|
||||
|
||||
|
||||
def test_account_without_anchor_uses_zero_base(client, db):
|
||||
# Ohne Anker: Summe aller bestaetigten Buchungen bis `at` (Basis 0) -
|
||||
# bisheriges Verhalten bleibt fuer Konten ohne Anker erhalten.
|
||||
acc = Account(bank="dkb", iban="DE-NOANCHOR", name="Kein Anker", type="giro")
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
db.add(Transaction(account_id=acc.id, booking_date=date(2026, 7, 1),
|
||||
amount=Decimal("42.00"), purpose="ohne Anker",
|
||||
status="confirmed", dedup_hash="x5"))
|
||||
db.commit()
|
||||
acc = db.get(Account, acc.id)
|
||||
assert account_balance(db, acc, at=date(2026, 7, 15)) == Decimal("42.00")
|
||||
|
||||
|
||||
def test_manual_transaction_dedup(client):
|
||||
acc = client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": "DE99", "name": "G", "type": "giro"}).json()
|
||||
payload = {"account_id": acc["id"], "booking_date": "2026-07-01",
|
||||
"amount": "-10.00", "purpose": "Kaffee", "counterparty": ""}
|
||||
assert client.post("/api/transactions", headers=H, json=payload).status_code == 201
|
||||
assert client.post("/api/transactions", headers=H, json=payload).status_code == 409
|
||||
payload["force"] = True
|
||||
assert client.post("/api/transactions", headers=H, json=payload).status_code == 201
|
||||
|
||||
|
||||
def test_rules_categorize(client):
|
||||
cat = client.post("/api/categories", headers=H, json={"name": "Energie"}).json()
|
||||
client.post("/api/category-rules", headers=H,
|
||||
json={"pattern": "stadtwerke", "category_id": cat["id"]})
|
||||
acc = client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": "DE98", "name": "G2", "type": "giro"}).json()
|
||||
client.post("/api/transactions", headers=H, json={
|
||||
"account_id": acc["id"], "booking_date": "2026-07-03",
|
||||
"amount": "-80.00", "purpose": "STADTWERKE Abschlag", "counterparty": ""})
|
||||
txs = client.get("/api/transactions", headers=H,
|
||||
params={"account_id": acc["id"]}).json()
|
||||
assert txs[0]["category_id"] == cat["id"]
|
||||
|
||||
|
||||
def test_category_patch(client):
|
||||
a = client.post("/api/categories", headers=H, json={"name": "Kat-A"}).json()
|
||||
b = client.post("/api/categories", headers=H, json={"name": "Kat-B"}).json()
|
||||
# Umbenennen
|
||||
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
|
||||
assert r.status_code == 200 and r.json()["name"] == "Kat-A-neu"
|
||||
# Umbenennen auf den EIGENEN Namen ist erlaubt (kein 409)
|
||||
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
|
||||
assert r.status_code == 200
|
||||
# Kollision mit anderer Kategorie -> 409
|
||||
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-B"})
|
||||
assert r.status_code == 409
|
||||
# unbekannte id -> 404
|
||||
assert client.patch("/api/categories/99999", headers=H,
|
||||
json={"name": "x"}).status_code == 404
|
||||
|
||||
|
||||
def test_patch_account_name(client):
|
||||
acc = client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": "DE71", "name": "DE71", "type": "giro"}).json()
|
||||
r = client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"name": "DKB Giro"})
|
||||
assert r.status_code == 200 and r.json()["name"] == "DKB Giro"
|
||||
assert client.patch("/api/accounts/9999", headers=H,
|
||||
json={"name": "x"}).status_code == 404
|
||||
assert client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"name": " "}).status_code == 422
|
||||
|
||||
|
||||
def test_patch_account_anchor_set_and_clear(client):
|
||||
acc = client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": "DE72", "name": "Anker-Test", "type": "giro"}).json()
|
||||
assert acc["anchor_date"] is None
|
||||
assert acc["anchor_balance"] is None
|
||||
|
||||
r = client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"anchor_date": "2026-06-30", "anchor_balance": "500.00"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["anchor_date"] == "2026-06-30"
|
||||
assert Decimal(body["anchor_balance"]) == Decimal("500.00")
|
||||
|
||||
r = client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"anchor_date": None, "anchor_balance": None})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["anchor_date"] is None
|
||||
assert body["anchor_balance"] is None
|
||||
|
||||
|
||||
def test_patch_account_anchor_requires_both_fields(client):
|
||||
acc = client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": "DE73", "name": "Anker-422", "type": "giro"}).json()
|
||||
|
||||
assert client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"anchor_date": "2026-06-30"}).status_code == 422
|
||||
assert client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"anchor_balance": "500.00"}).status_code == 422
|
||||
assert client.patch(f"/api/accounts/{acc['id']}", headers=H,
|
||||
json={"anchor_date": "2026-06-30",
|
||||
"anchor_balance": None}).status_code == 422
|
||||
|
||||
|
||||
def test_patch_transaction_category(client):
|
||||
cat = client.post("/api/categories", headers=H, json={"name": "Freizeit"}).json()
|
||||
acc = client.post("/api/accounts", headers=H, json={
|
||||
"bank": "DKB", "iban": "DE77", "name": "G3", "type": "giro"}).json()
|
||||
tx = client.post("/api/transactions", headers=H, json={
|
||||
"account_id": acc["id"], "booking_date": "2026-07-04",
|
||||
"amount": "-15.00", "purpose": "Kino", "counterparty": ""}).json()
|
||||
tx_id = tx["id"]
|
||||
assert tx["category_id"] is None
|
||||
|
||||
r = client.patch(f"/api/transactions/{tx_id}", headers=H,
|
||||
json={"category_id": cat["id"]})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["category_id"] == cat["id"]
|
||||
|
||||
r = client.patch(f"/api/transactions/{tx_id}", headers=H, json={})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["category_id"] == cat["id"]
|
||||
|
||||
r = client.patch(f"/api/transactions/{tx_id}", headers=H,
|
||||
json={"category_id": None})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["category_id"] is None
|
||||
|
||||
r = client.patch(f"/api/transactions/{tx_id}", headers=H,
|
||||
json={"category_id": 9999})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_scenario_patch_beschreibung_leeren(client):
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "DescNull", "description": "x"}).json()
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}", headers=H, json={"description": None})
|
||||
assert r.status_code == 200 and r.json()["description"] == ""
|
||||
|
||||
|
||||
def test_modifier_ende_validierung(client, db):
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "EndeVal"}).json()
|
||||
# ende ohne Datum -> 422
|
||||
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "ende"})
|
||||
assert r.status_code == 422
|
||||
# ende mit Datum -> 201, end_date in der Antwort
|
||||
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "ende",
|
||||
"end_date": "2026-08-31"})
|
||||
assert r.status_code == 201
|
||||
assert r.json()["end_date"] == "2026-08-31"
|
||||
# andere Arten nullen end_date
|
||||
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "remove",
|
||||
"end_date": "2026-08-31"})
|
||||
assert r.status_code == 201 and r.json()["end_date"] is None
|
||||
|
||||
|
||||
def test_scenario_planned_crud_und_cleanup(client, db):
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "SPI-CRUD"}).json()
|
||||
r = client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
|
||||
"name": "Sonderzahlung", "amount": "5000.00", "due": "2026-07-30"})
|
||||
assert r.status_code == 201
|
||||
item = r.json()
|
||||
assert item["scenario_id"] == sc["id"] and item["amount"] == "5000.00"
|
||||
assert client.get(f"/api/scenarios/{sc['id']}/planned", headers=H).json()[0]["name"] == "Sonderzahlung"
|
||||
# DELETE mit fremder scenario_id -> 404
|
||||
other = client.post("/api/scenarios", headers=H, json={"name": "SPI-Other"}).json()
|
||||
assert client.delete(f"/api/scenarios/{other['id']}/planned/{item['id']}",
|
||||
headers=H).status_code == 404
|
||||
# Szenario loeschen raeumt Einmalzahlungen mit ab (kein FK-Fehler):
|
||||
assert client.delete(f"/api/scenarios/{sc['id']}", headers=H).status_code == 204
|
||||
from app.models.tables import ScenarioPlannedItem
|
||||
assert db.query(ScenarioPlannedItem).count() == 0
|
||||
|
||||
|
||||
def test_modifier_patch(client, db):
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "ModPatch"}).json()
|
||||
m = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "percent",
|
||||
"value": "10.00"}).json()
|
||||
# Wert aendern
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "25.00"})
|
||||
assert r.status_code == 200 and r.json()["value"] == "25.00"
|
||||
# Art auf ende wechseln (mit Datum); value wird vom Validator genullt
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "ende",
|
||||
"end_date": "2026-08-31"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["kind"] == "ende" and r.json()["end_date"] == "2026-08-31"
|
||||
# ende ohne Datum -> 422
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "ende"})
|
||||
assert r.status_code == 422
|
||||
# zurueck auf remove: end_date wird genullt
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "remove",
|
||||
"end_date": "2026-08-31"})
|
||||
assert r.status_code == 200 and r.json()["end_date"] is None
|
||||
# fremdes Szenario -> 404
|
||||
other = client.post("/api/scenarios", headers=H, json={"name": "ModPatch2"}).json()
|
||||
r = client.patch(f"/api/scenarios/{other['id']}/modifiers/{m['id']}", headers=H, json={
|
||||
"target_type": "recurring", "target_id": 1, "kind": "remove"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_scenario_planned_patch(client, db):
|
||||
sc = client.post("/api/scenarios", headers=H, json={"name": "SpiPatch"}).json()
|
||||
p = client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
|
||||
"name": "Zufluss", "amount": "5000.00", "due": "2026-07-30"}).json()
|
||||
r = client.patch(f"/api/scenarios/{sc['id']}/planned/{p['id']}", headers=H, json={
|
||||
"name": "Zufluss geaendert", "amount": "6000.00", "due": "2026-08-15"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["name"] == "Zufluss geaendert"
|
||||
assert body["amount"] == "6000.00" and body["due"] == "2026-08-15"
|
||||
# fremdes Szenario -> 404
|
||||
other = client.post("/api/scenarios", headers=H, json={"name": "SpiPatch2"}).json()
|
||||
r = client.patch(f"/api/scenarios/{other['id']}/planned/{p['id']}", headers=H, json={
|
||||
"name": "x", "amount": "1.00", "due": "2026-08-15"})
|
||||
assert r.status_code == 404
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user