Compare commits

...

7 Commits

Author SHA256 Message Date
8ff8b39b2e docs: Ausbaustufe-4-Plan abgeschlossen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:47:42 +02:00
1ef297ab89 chore: Version 0.5.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:46:08 +02:00
89a08965db docs: Architektur (Mermaid) und CLAUDE.md fuer kuenftige Sessions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:46:00 +02:00
09b34ae13b fix: lueckenlose taegliche Saldo-Reihen fuer Grafana
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:32:36 +02:00
f5fa3e45e8 feat: Admin-Seite mit Passwortaenderung und Regel-Neuanwendung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:24:33 +02:00
74684c7e6c feat: Kalt-Backup-Skript finance_pod
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:11:53 +02:00
9316d72bb4 docs: Implementierungsplan Ausbaustufe 4 (Backup, Admin, Grafana, Doku)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:01:57 +02:00
20 changed files with 1418 additions and 18 deletions

154
CLAUDE.md Normal file
View File

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

135
backup_finance_pod.sh Executable file
View File

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

View File

@@ -212,6 +212,12 @@ podman exec "$DB_CTR_NAME" psql -U finance -d finance -c \
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 \
@@ -221,6 +227,7 @@ podman run -d --name "$API_CTR_NAME" --pod "$POD_NAME" \
-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=$?)"

314
docs/ARCHITEKTUR.md Normal file
View File

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

View File

@@ -0,0 +1,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` · [ ] 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 · [ ] 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 · [ ] FABLE-TESTAGENT (Suite; Inode-Konstanz beim Schreiben; Mock-Grafana-Reihenfolge: kein .env-Write bei Grafana-Fehler; /admin-Auth; apply-Endpoint zählt korrekt) · [ ] 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) · [ ] 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 · [ ] 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 · [ ] FABLE-TESTAGENT: Faktencheck der Doku gegen den Code (jede benannte Datei/Funktion existiert; Mermaid-Syntax valide; keine echten Daten) · [ ] Commit `docs: Architektur (Mermaid) und CLAUDE.md fuer kuenftige Sessions`
### Task 5: v0.5.0 + Redeploy + Smoke
- [x] VERSION 0.5.0, Build, Redeploy · [ ] 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) · [ ] Backup-Skript einmal final ausführen (frisches Backup nach Abschluss) · [ ] 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

View File

@@ -1 +1 @@
0.4.0
0.5.0

View File

@@ -1,6 +1,8 @@
import hashlib
import hmac
import re
import secrets
from pathlib import Path
from fastapi import HTTPException, Request
from itsdangerous import BadSignature, TimestampSigner
@@ -10,6 +12,48 @@ 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)

View File

@@ -16,6 +16,8 @@ class Settings:
uploads_dir: Path
warn_threshold: Decimal
horizon_days: int
env_file: Path
grafana_url: str
@lru_cache
@@ -31,4 +33,9 @@ def get_settings() -> Settings:
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"),
)

View File

@@ -10,7 +10,7 @@ from app.auth import require_auth
from app.config import get_settings
from app.db import get_engine
from app.models.views import create_views
from app.routers import (accounts, categories, gui, imports, planning,
from app.routers import (accounts, admin, categories, gui, imports, planning,
scenarios, transactions)
from app.routers.gui import templates
from app.version import get_version
@@ -33,6 +33,7 @@ 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")
@@ -44,7 +45,7 @@ def login_form(request: Request):
def login(username: str = Form(...), password: str = Form(...)):
s = get_settings()
if not (hmac.compare_digest(username, s.gui_user)
and auth.verify_password(password, s.gui_password_hash)):
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,

View File

@@ -26,22 +26,83 @@ _ACCOUNT_OFFSET = """
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"""
SELECT t.booking_date AS day, a.name AS account,
COALESCE(b.base, 0)
+ SUM(SUM(t.amount)) OVER (PARTITION BY a.id
ORDER BY t.booking_date) AS balance
FROM transactions t JOIN accounts a ON a.id = t.account_id
LEFT JOIN ({_ACCOUNT_OFFSET}) b ON b.account_id = a.id
WHERE t.status = 'confirmed'
GROUP BY a.id, a.name, t.booking_date, b.base""",
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"""
SELECT booking_date AS day,
(SELECT COALESCE(SUM(base), 0)
FROM ({_ACCOUNT_OFFSET}) s)
+ SUM(SUM(amount)) OVER (ORDER BY booking_date) AS balance
FROM transactions WHERE status = 'confirmed' GROUP BY booking_date""",
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,

View File

@@ -0,0 +1,52 @@
from fastapi import APIRouter, Depends, Form, Request
from sqlalchemy.orm import Session
from app.auth import require_auth
from app.db import get_session
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):
return templates.TemplateResponse(request, "admin.html", {"error": None, "success": None})
@router.post("/admin/passwort", dependencies=[Depends(gui_session)])
def admin_change_password(
request: Request,
alt: str = Form(...),
neu: str = Form(...),
neu2: str = Form(...),
):
# UX-Regel: das Formular selbst bleibt immer sichtbar/bedienbar; Fehler
# werden inline auf derselben Seite gemeldet statt still zu verwerfen.
if len(neu) < 8:
return templates.TemplateResponse(request, "admin.html", {
"error": "Das neue Passwort muss mindestens 8 Zeichen lang sein.",
"success": None,
}, status_code=400)
if neu != neu2:
return templates.TemplateResponse(request, "admin.html", {
"error": "Die Wiederholung stimmt nicht mit dem neuen Passwort überein.",
"success": None,
}, status_code=400)
try:
change_password(alt, neu)
except (ValueError, RuntimeError) as exc:
return templates.TemplateResponse(request, "admin.html", {
"error": str(exc),
"success": None,
}, 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.",
})
@router.post("/api/category-rules/apply", dependencies=[Depends(require_auth)])
def apply_category_rules(session: Session = Depends(get_session)):
return {"categorized": apply_rules_retroactively(session)}

View File

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

View File

@@ -72,6 +72,14 @@ td.account {
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;

View File

@@ -0,0 +1,46 @@
{% 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>
<script>
function handleApplyRulesResult(event) {
var result = document.getElementById('apply-rules-result');
if (event.detail.successful) {
var data = JSON.parse(event.detail.xhr.responseText);
result.textContent = data.categorized + ' Buchung(en) neu kategorisiert.';
} else {
result.textContent = 'Fehler beim Anwenden der Regeln.';
}
}
</script>
{% endblock %}

View File

@@ -14,6 +14,7 @@
<a href="/buchungen">Buchungen</a>
<a href="/salden">Salden</a>
<a href="/planung">Planung</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">

View File

@@ -26,7 +26,9 @@
"custom": {
"drawStyle": "line",
"lineWidth": 1,
"fillOpacity": 0
"fillOpacity": 0,
"lineInterpolation": "stepAfter",
"spanNulls": true
},
"unit": "currencyEUR"
},
@@ -63,7 +65,9 @@
"custom": {
"drawStyle": "line",
"lineWidth": 2,
"fillOpacity": 10
"fillOpacity": 10,
"lineInterpolation": "stepAfter",
"spanNulls": true
},
"unit": "currencyEUR"
},

View File

@@ -47,3 +47,47 @@ def client(db, monkeypatch):
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()

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

@@ -0,0 +1,300 @@
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
# --- Regeln neu anwenden ---------------------------------------------------
def test_apply_rules_retroactively_categorizes_uncategorized_confirmed_tx(db):
account = Account(bank="Test", iban="DE02120300000000202099", name="Giro")
db.add(account)
db.flush()
category = Category(name="Lebensmittel")
db.add(category)
db.flush()
db.add(CategoryRule(pattern="rewe", category_id=category.id, priority=100))
tx_confirmed_uncategorized = Transaction(
account_id=account.id, booking_date=date(2026, 1, 5),
amount=Decimal("-10.00"), purpose="REWE Markt", counterparty="",
status="confirmed", dedup_hash="h1",
)
tx_draft = Transaction(
account_id=account.id, booking_date=date(2026, 1, 6),
amount=Decimal("-5.00"), purpose="REWE Markt", counterparty="",
status="draft", dedup_hash="h2",
)
tx_already_categorized = Transaction(
account_id=account.id, booking_date=date(2026, 1, 7),
amount=Decimal("-5.00"), purpose="REWE Markt", counterparty="",
status="confirmed", category_id=category.id, dedup_hash="h3",
)
db.add_all([tx_confirmed_uncategorized, tx_draft, tx_already_categorized])
db.commit()
hits = apply_rules_retroactively(db)
assert hits == 1
db.refresh(tx_confirmed_uncategorized)
db.refresh(tx_draft)
assert tx_confirmed_uncategorized.category_id == category.id
assert tx_draft.category_id is None # Entwuerfe bleiben unangetastet
def test_apply_category_rules_endpoint_requires_auth(client):
assert client.post("/api/category-rules/apply").status_code == 401
def test_apply_category_rules_endpoint_categorizes_and_returns_count(client, db):
account = Account(bank="Test", iban="DE02120300000000202098", name="Giro")
db.add(account)
db.flush()
category = Category(name="Lebensmittel")
db.add(category)
db.flush()
db.add(CategoryRule(pattern="rewe", category_id=category.id, priority=100))
tx = Transaction(
account_id=account.id, booking_date=date(2026, 1, 5),
amount=Decimal("-10.00"), purpose="REWE Markt", counterparty="",
status="confirmed", dedup_hash="h1",
)
db.add(tx)
db.commit()
r = client.post("/api/category-rules/apply",
headers={"Authorization": "Bearer test-key"})
assert r.status_code == 200
assert r.json() == {"categorized": 1}
db.refresh(tx)
assert tx.category_id == category.id

View File

@@ -28,3 +28,35 @@ def test_logout_flow(client):
def test_tampered_session_cookie_rejected(client):
client.cookies.set("fb_session", "gui.invalid-signature")
assert client.get("/api/accounts").status_code == 401
def test_current_password_hash_falls_back_to_env_when_no_env_file(client, monkeypatch):
# `client`-Fixture setzt FB_GUI_PASSWORD_HASH direkt und keine FB_ENV_FILE,
# der Default-Pfad /data/.env existiert im Testlauf nicht -> Fallback.
from app.auth import current_password_hash
from app.config import get_settings
assert current_password_hash() == get_settings().gui_password_hash
def test_current_password_hash_reads_from_env_file_when_present(
client_with_env_file,
):
from app.auth import current_password_hash, verify_password
_client, password = client_with_env_file
assert verify_password(password, current_password_hash())
def test_current_password_hash_cache_invalidates_on_file_change(
client_with_env_file, env_file,
):
from app.auth import current_password_hash, hash_password, verify_password
path, password = env_file
first = current_password_hash()
assert verify_password(password, first)
new_hash = hash_password("andereswort999")
path.write_text(f"FB_PASSWORD='andereswort999'\nFB_GUI_PASSWORD_HASH='{new_hash}'\n")
second = current_password_hash()
assert second == new_hash
assert verify_password("andereswort999", second)

View File

@@ -1,3 +1,5 @@
from pathlib import Path
from app.config import get_settings
@@ -5,3 +7,31 @@ 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()