Compare commits

..

28 Commits

Author SHA256 Message Date
224baba326 fix: Grafana-Zeitfenster deckt Projektionshorizont ab (Szenario-Vergleich sichtbar)
Das Szenario-Vergleich-Panel zeichnet die Projektion (Zukunft, horizon_days ab
heute). Kein Panel nutzt $__timeFilter, daher begrenzt allein das
Dashboard-Zeitfenster die Anzeige. Mit time.to='now' wurde die gesamte Kurve ab
morgen abgeschnitten (nur ~4 Tage <= heute sichtbar, Y-Achse auf deren schmalen
Bereich fixiert). Fix: time.to='now+19M' deckt den 548-Tage-Horizont ab.
Regressionstest tests/test_grafana_dashboard.py haelt die Invariante fest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 20:39:23 +02:00
dec1cb9075 chore: stop_finance_pod.sh - Naechste-Schritte-Hinweis auskommentiert
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 10:37:09 +02:00
81a0ebaf9d docs: Reverse-Proxy-Doku auf reales Live-Setup (fbwl.creature-go.com via sv005/Traefik)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 09:45:06 +02:00
6ba6ffa9df feat: stop_finance_pod.sh - kontrolliertes Herunterfahren des Pods
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:32:26 +02:00
51d85c8de6 chore: Release v0.10.0 (Reverse-Proxy-Tauglichkeit)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 07:31:27 +02:00
208c0a4df4 docs: Reverse-Proxy-Snippets (Traefik/Apache) + .env-Schalter 2026-07-21 07:25:41 +02:00
fa45527faf docs: Plan A10 praezisiert (Domain-Policy + GF_SUBPATH-Platzierung vor API-Container) 2026-07-21 07:21:57 +02:00
2610b71b06 feat: create_pod_finance.sh Grafana-Sub-Pfad + interne URLs sub-path-bewusst 2026-07-21 07:19:42 +02:00
df5b29d8ff feat: uvicorn Proxy-Headers fuer Reverse-Proxy-Betrieb 2026-07-21 07:14:53 +02:00
a575c57d5b feat: Grafana-Links aus FB_GRAFANA_PUBLIC_URL (Sub-Pfad-tauglich) 2026-07-21 07:11:27 +02:00
52f527a375 feat: Session-Cookie secure-Flag per FB_SESSION_COOKIE_SECURE 2026-07-21 07:06:42 +02:00
fa2d32aa8f fix: config-Kommentar auf neutrale Beispiel-Domain (fb.example.de) 2026-07-21 07:04:47 +02:00
e061796997 feat: FB_GRAFANA_PUBLIC_URL + FB_SESSION_COOKIE_SECURE in Settings 2026-07-20 22:56:26 +02:00
7a8e63dc76 docs: Plan Ausbaustufe 10 (Reverse-Proxy-Tauglichkeit) 2026-07-20 22:54:59 +02:00
8eee62ee19 fix: Datenschutz-Scrub in Tests/Kommentaren + Spec-Nachtrag Vorschlags-Algorithmus
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:59:29 +02:00
7ca64c91fe docs: Plan-Haekchen Ausbaustufe 9
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:49:38 +02:00
aff8e9b28d fix: Bestandsabgleich per Token-Match und Volatilitaets-Hinweis fuer Vorschlaege
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:46:10 +02:00
a520390e31 chore: Version 0.9.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:28:29 +02:00
220d1fc802 fix: hinweis-Betrag im Vorschlag deutsch formatiert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:28:15 +02:00
bf3f56118e feat: Vorschlaege mit Rhythmus, Start und Hinweis
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:26:48 +02:00
28d1267f0c feat: Vorschlags-Algorithmus v2 (Rhythmen, letzter Betrag, Aktiv-Check, Merge)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:20:04 +02:00
e002d8205f docs: Spec + Plan Ausbaustufe 9 (Vorschlags-Algorithmus v2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:30:31 +02:00
353ff6056c docs: Plan-Haekchen Ausbaustufe 7
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:21:11 +02:00
d6659d8b18 chore: Version 0.8.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:18:15 +02:00
4431640eb2 feat: Kategorien-Verwaltung auf der Admin-Seite
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:17:48 +02:00
b21d7b9839 feat: Eintrags-Formular mit Paar-Layout und Gesperrt-Kennzeichnung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:06:32 +02:00
f03249bff1 feat: eigene Szenarien-Seite /szenarien
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:59:03 +02:00
7b273e218c docs: Spec + Plan Ausbaustufe 7 (Szenarien-Seite, Formular-UX, Kategorien)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:50:43 +02:00
34 changed files with 3234 additions and 356 deletions

View File

@@ -131,7 +131,15 @@ App muss sie **in-place** überschreiben (`open` im Modus `r+`, `flock`,
`truncate` — siehe `services/admin.py::_rewrite_env_file`), niemals über `truncate` — siehe `services/admin.py::_rewrite_env_file`), niemals über
Temp-Datei+`rename` (neuer Inode, vom laufenden Mount nicht mehr gesehen). Temp-Datei+`rename` (neuer Inode, vom laufenden Mount nicht mehr gesehen).
Host-seitige `sed`-Edits in `create_pod_finance.sh` selbst sind unkritisch, Host-seitige `sed`-Edits in `create_pod_finance.sh` selbst sind unkritisch,
weil das Skript die Container ohnehin bei jedem Lauf neu erstellt. weil das Skript die Container ohnehin bei jedem Lauf neu erstellt. Das Tool ist
**produktiv über den Reverse-Proxy erreichbar** unter
`https://fbwl.creature-go.com/` (App) + `/grafana/` (Grafana-Unterpfad);
aktiviert rein über die zwei optionalen `.env`-Variablen
(`FB_GRAFANA_PUBLIC_URL`, `FB_SESSION_COOKIE_SECURE`), leer ⇒ Lokalbetrieb.
Kette sv005 (Apache/TLS) → WireGuard → sv006 Traefik → Pod, **vollständig
dokumentiert in `docs/reverse-proxy.md`** (dort auch die Regel: Traefik-Backends
`10.0.2.2:PORT`, nicht `127.0.0.1`). Achtung: mit `FB_SESSION_COOKIE_SECURE=true`
geht Browser-Login nur noch über die HTTPS-Domain (curl-Tests weiter ok).
**Disaster Recovery = `BIND_DIR`-Backup + Repo + Skript.** Für vollständige **Disaster Recovery = `BIND_DIR`-Backup + Repo + Skript.** Für vollständige
Wiederherstellung werden **beide** gebraucht: ein Backup von Wiederherstellung werden **beide** gebraucht: ein Backup von

View File

@@ -211,6 +211,29 @@ podman exec "$DB_CTR_NAME" psql -U finance -d finance -c \
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO finance_read;" ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO finance_read;"
echo "Role 'finance_read' is ready." echo "Role 'finance_read' is ready."
# --- Reverse-Proxy-Betrieb (optional) ----------------------------------------
# FB_GRAFANA_PUBLIC_URL wird nur gesetzt, wenn das Tool hinter einem Reverse
# Proxy unter einem Sub-Pfad laufen soll (z.B.
# 'https://fb.example.de/grafana/'). Ist sie leer (Default,
# Direktbetrieb auf 127.0.0.1), bleibt alles wie bisher: Grafana serviert an
# der Wurzel, Health-Check und Passwort-Sync sprechen /api/... ohne Praefix.
# Muss VOR dem API-Container berechnet werden, da FB_GRAFANA_URL (unten) den
# Praefix schon dort braucht - nicht erst vor dem Grafana-Container.
GF_SUBPATH=''
GRAFANA_SUBPATH_ARGS=()
if [ -n "${FB_GRAFANA_PUBLIC_URL:-}" ]; then
# Pfadanteil der oeffentlichen URL extrahieren, Slash(es) am Ende entfernen:
# 'https://host/grafana/' -> '/grafana'. Dieser Praefix wird sowohl fuer den
# internen Health-Check als auch fuer die interne Grafana-API-URL
# (FB_GRAFANA_URL, Passwort-Sync in der GUI) gebraucht, weil
# serve_from_sub_path ALLE Grafana-Routen unter den Sub-Pfad haengt.
GF_SUBPATH=$(printf '%s' "$FB_GRAFANA_PUBLIC_URL" | sed -E 's#^[a-z]+://[^/]+##; s#/+$##')
GRAFANA_SUBPATH_ARGS=(
-e "GF_SERVER_ROOT_URL=$FB_GRAFANA_PUBLIC_URL"
-e "GF_SERVER_SERVE_FROM_SUB_PATH=true"
)
fi
# API container (runs alembic upgrade head on start via entrypoint.sh) # 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 # 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, # password change) is a SINGLE-FILE bind mount. Unlike a directory mount,
@@ -226,6 +249,9 @@ podman run -d --name "$API_CTR_NAME" --pod "$POD_NAME" \
-e FB_GUI_PASSWORD_HASH \ -e FB_GUI_PASSWORD_HASH \
-e FB_INBOX_DIR=/data/inbox \ -e FB_INBOX_DIR=/data/inbox \
-e FB_UPLOADS_DIR=/data/uploads \ -e FB_UPLOADS_DIR=/data/uploads \
-e FB_GRAFANA_PUBLIC_URL="${FB_GRAFANA_PUBLIC_URL:-}" \
-e FB_SESSION_COOKIE_SECURE="${FB_SESSION_COOKIE_SECURE:-}" \
-e FB_GRAFANA_URL="http://localhost:3000${GF_SUBPATH}" \
-v "$DATA_DIR:/data:Z" \ -v "$DATA_DIR:/data:Z" \
-v "$ENV_FILE:/data/.env:Z" \ -v "$ENV_FILE:/data/.env:Z" \
"$API_IMAGE" "$API_IMAGE"
@@ -240,6 +266,7 @@ podman run -d --name "$GRAFANA_CTR_NAME" --pod "$POD_NAME" \
-e GF_SECURITY_ADMIN_PASSWORD="$FB_PASSWORD" \ -e GF_SECURITY_ADMIN_PASSWORD="$FB_PASSWORD" \
-e GF_SECURITY_ALLOW_EMBEDDING=true \ -e GF_SECURITY_ALLOW_EMBEDDING=true \
-e GF_SECURITY_COOKIE_SAMESITE=lax \ -e GF_SECURITY_COOKIE_SAMESITE=lax \
"${GRAFANA_SUBPATH_ARGS[@]}" \
-e GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/finanzen.json \ -e GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/finanzen.json \
-e FINANCE_READ_PASSWORD \ -e FINANCE_READ_PASSWORD \
-v "$GRAFANA_PROVISIONING_DIR:/etc/grafana/provisioning:Z,ro" \ -v "$GRAFANA_PROVISIONING_DIR:/etc/grafana/provisioning:Z,ro" \
@@ -326,7 +353,7 @@ echo "To view logs: journalctl --user -u pod-${POD_NAME}.service -f"
# Wait for API and Grafana readiness # Wait for API and Grafana readiness
CHECK_URL_API="http://$HOST_LOCAL_IP:$API_HOST_PORT/login" CHECK_URL_API="http://$HOST_LOCAL_IP:$API_HOST_PORT/login"
CHECK_URL_GRAFANA="http://$HOST_LOCAL_IP:$GRAFANA_HOST_PORT/api/health" CHECK_URL_GRAFANA="http://$HOST_LOCAL_IP:$GRAFANA_HOST_PORT${GF_SUBPATH}/api/health"
for attempt in $(seq 1 30); do for attempt in $(seq 1 30); do
API_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL_API" || true) 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) GRAFANA_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL_GRAFANA" || true)

View File

@@ -196,6 +196,11 @@ sequenceDiagram
## 3. Deployment-Diagramm ## 3. Deployment-Diagramm
> Reverse-Proxy-Betrieb (eine Subdomain, Grafana als `/grafana/`-Unterpfad):
> siehe **`docs/reverse-proxy.md`** (Ausbaustufe 10). Aktivierung rein über
> `.env` (`FB_GRAFANA_PUBLIC_URL`, `FB_SESSION_COOKIE_SECURE`); leer = lokaler
> Direktbetrieb unverändert.
```mermaid ```mermaid
flowchart TB flowchart TB
subgraph HOST["Host wlfb (rootless Podman)"] subgraph HOST["Host wlfb (rootless Podman)"]

258
docs/reverse-proxy.md Normal file
View File

@@ -0,0 +1,258 @@
# Reverse-Proxy-Betrieb (Ausbaustufe 10) — LIVE
Das Finanzberatungs-Tool ist öffentlich erreichbar unter
**`https://fbwl.creature-go.com/`** (App/GUI) mit **Grafana als Unterpfad
`https://fbwl.creature-go.com/grafana/`**. Aktiviert wird das ausschließlich
über zwei `.env`-Schalter auf sv006; ohne sie läuft der unveränderte
Lokalbetrieb auf `127.0.0.1` weiter (per Live-Redeploy + Sub-Pfad-Smoke belegt).
Die Proxy-Konfiguration (sv005 Apache, sv006 Traefik) liegt außerhalb dieses
Repos — dieses Dokument hält den realen Stand fest, damit Änderungen am Tool
die Kette nicht brechen.
## Kette (Datenfluss)
```
Browser ──HTTPS──► sv005 (DesTEngSsv005)
Virtualmin/Apache, TLS-Terminierung, WireGuard-IP 10.8.0.1
ServerName fbwl.creature-go.com ──ProxyPass──► http://10.8.0.6:8080
──WireGuard (wg0)──►
sv006 (DesTEngSsv006 — DIESER Rechner)
Traefik: rootless-Podman-Pod "traefik_pod" (User trf),
Entrypoint "wghttp" published auf 10.8.0.6:8080
Router Host(fbwl.creature-go.com) ──► http://10.0.2.2:8096 (App/API)
Router Host(fbwl.creature-go.com) && /grafana ──► http://10.0.2.2:8097 (Grafana)
finance_pod: 127.0.0.1:8096 (App), 127.0.0.1:8097 (Grafana)
```
**Warum `10.0.2.2`?** Traefik läuft in einem *rootless*-Podman-Pod mit
`slirp4netns:allow_host_loopback=true`. Aus Sicht des Traefik-Containers ist
`127.0.0.1` der **Container selbst**; die auf dem Host an `127.0.0.1:8096/8097`
veröffentlichten Pod-Ports erreicht er über die slirp4netns-Host-Loopback-Adresse
**`10.0.2.2`**. Backend-URLs im Traefik-Router deshalb IMMER `10.0.2.2:PORT`,
niemals `127.0.0.1:PORT`.
**Warum funktioniert `X-Forwarded-Proto` sauber?** sv005 hat die WireGuard-IP
`10.8.0.1`, und Traefiks Entrypoint `wghttp` vertraut per
`forwardedHeaders.trustedIPs: 10.8.0.1/32` genau dieser Quelle. Das von sv005
gesetzte `X-Forwarded-Proto: https` wird also übernommen und (via uvicorn
`--proxy-headers`) bis zur App durchgereicht.
## Teil 1 — finance_pod (`.env` auf sv006)
In `~/.local/share/finance_pod/.env` (alle Werte **single-quoted**):
```sh
FB_GRAFANA_PUBLIC_URL='https://fbwl.creature-go.com/grafana/'
FB_SESSION_COOKIE_SECURE='true'
```
Übernehmen: `./stop_finance_pod.sh``.env` editieren → `./create_pod_finance.sh`.
Wirkung von `FB_GRAFANA_PUBLIC_URL` (leer ⇒ Lokalbetrieb, Grafana-Fallback
`http://<host>:8097`, Grafana an der Wurzel):
- `create_pod_finance.sh` startet Grafana mit `GF_SERVER_ROOT_URL` +
`GF_SERVER_SERVE_FROM_SUB_PATH=true`;
- der **Pfadanteil** der URL (`/grafana`) wird zum Präfix für den internen
Grafana-Health-Check UND für die interne `FB_GRAFANA_URL`
(`http://localhost:3000/grafana`, für die GUI-Passwortänderung) — beides in
`create_pod_finance.sh` automatisch abgeleitet;
- alle GUI-Links/iframes bilden sich aus dieser URL (Jinja-Global
`grafana_public_base`, siehe `app/routers/gui.py`).
**Achtung Secure-Cookie:** Mit `FB_SESSION_COOKIE_SECURE='true'` akzeptiert ein
**Browser** das Session-Cookie nur über HTTPS — ein direkter Login über
`http://127.0.0.1:8096` funktioniert dann nicht mehr, nur noch über die
HTTPS-Domain. Automatisierte Tests mit `curl` (`-c/-b`) laufen weiter, weil curl
das Secure-Flag ignoriert (bewährt bei Redeploy-Smoke-Tests).
## Teil 2 — Traefik-Router (sv006, verwaltet als User `trf`)
Traefik ist ein **geteilter** rootless-Podman-Pod `traefik_pod` (User `trf`,
Config unter `/home/trf/.local/share/traefik_pod/`; als `wlfb` NICHT lesbar).
Statik: Entrypoint `wghttp` auf `:8080` (published `10.8.0.6:8080`),
`forwardedHeaders.trustedIPs: 10.8.0.1/32`; File-Provider `dynamic/` mit
`watch: true` (Änderungen laden automatisch, kein Neustart, andere Dienste
unberührt).
**Wichtig — Black Hole:** Es gibt einen Catch-all-Router
(`HostRegexp('{any:.*}')`, `priority: 1`, Middleware `ipAllowList 127.0.0.1/32`),
der jeden **nicht explizit gerouteten** Host sofort mit **403** abweist. Jeder
öffentliche Dienst braucht daher einen eigenen Router mit
`entryPoints: ["wghttp"]` und `priority > 1`.
Router-Datei `dynamic/fbwl.yml`:
```yaml
http:
routers:
finance-grafana:
rule: "Host(`fbwl.creature-go.com`) && PathPrefix(`/grafana`)"
priority: 120 # ueber dem Black Hole (priority 1)
service: finance-grafana
entryPoints: ["wghttp"]
finance-app:
rule: "Host(`fbwl.creature-go.com`)"
priority: 110
service: finance-app
entryPoints: ["wghttp"]
services:
finance-grafana:
loadBalancer:
servers:
- url: "http://10.0.2.2:8097" # slirp4netns-Host-Loopback, NICHT 127.0.0.1
finance-app:
loadBalancer:
servers:
- url: "http://10.0.2.2:8096"
```
## Teil 3 — sv005 Apache (Virtualmin, Domain fbwl.creature-go.com)
Virtualmin → Server `fbwl.creature-go.com` → „Edit Directives" (bearbeitet
`/etc/apache2/sites-available/fbwl.creature-go.com.conf`). Aufgebaut nach dem
Muster des bestehenden `affine.creature-go.com`-vhosts: `/.well-known` lokal
(ACME/Let's-Encrypt-Erneuerung), HTTP→HTTPS-Redirect, dann Weiterleitung der
ganzen Domain an Traefik (`10.8.0.6:8080`) mit erhaltenem Host-Header. TLS-Cert
verwaltet Virtualmin/Let's Encrypt.
### Port 80
```apache
SuexecUserGroup #1015 #1011
ServerName fbwl.creature-go.com
ServerAlias www.fbwl.creature-go.com
ServerAlias mail.fbwl.creature-go.com
ServerAlias webmail.fbwl.creature-go.com
ServerAlias admin.fbwl.creature-go.com
DocumentRoot /home/fbwl/public_html
ErrorLog /var/log/virtualmin/fbwl.creature-go.com_error_log
CustomLog /var/log/virtualmin/fbwl.creature-go.com_access_log combined
ScriptAlias /cgi-bin/ /home/fbwl/cgi-bin/
DirectoryIndex index.php index.htm index.html
<Directory /home/fbwl/public_html>
Options -Indexes +IncludesNOEXEC +SymLinksIfOwnerMatch +ExecCGI
Require all granted
AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch
AddHandler fcgid-script .php
AddHandler fcgid-script .php8.4
FCGIWrapper /home/fbwl/fcgi-bin/php8.4.fcgi .php
FCGIWrapper /home/fbwl/fcgi-bin/php8.4.fcgi .php8.4
</Directory>
<Directory /home/fbwl/cgi-bin>
Require all granted
AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch
</Directory>
ProxyPass /.well-known !
RewriteEngine on
RewriteCond %{HTTP_HOST} =webmail.fbwl.creature-go.com
RewriteRule ^/(?!\.well-known)(.*)$ https://fbwl.creature-go.com:20000/ [R=301,L]
RewriteCond %{HTTP_HOST} =admin.fbwl.creature-go.com
RewriteRule ^/(?!\.well-known)(.*)$ https://fbwl.creature-go.com:10000/ [R=301,L]
# --- Finanzberatungs-Tool: HTTP -> HTTPS (ausser ACME-Challenge) ---
RewriteCond %{HTTPS} off
RewriteCond %{REQUEST_URI} !^/\.well-known/
RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L]
RemoveHandler .php
RemoveHandler .php8.4
FcgidMaxRequestLen 1073741824
```
### Port 443
```apache
SuexecUserGroup #1015 #1011
ServerName fbwl.creature-go.com
ServerAlias www.fbwl.creature-go.com
ServerAlias mail.fbwl.creature-go.com
ServerAlias webmail.fbwl.creature-go.com
ServerAlias admin.fbwl.creature-go.com
DocumentRoot /home/fbwl/public_html
ErrorLog /var/log/virtualmin/fbwl.creature-go.com_error_log
CustomLog /var/log/virtualmin/fbwl.creature-go.com_access_log combined
ScriptAlias /cgi-bin/ /home/fbwl/cgi-bin/
DirectoryIndex index.php index.htm index.html
<Directory /home/fbwl/public_html>
Options -Indexes +IncludesNOEXEC +SymLinksIfOwnerMatch +ExecCGI
Require all granted
AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch
AddHandler fcgid-script .php
AddHandler fcgid-script .php8.4
FCGIWrapper /home/fbwl/fcgi-bin/php8.4.fcgi .php
FCGIWrapper /home/fbwl/fcgi-bin/php8.4.fcgi .php8.4
</Directory>
<Directory /home/fbwl/cgi-bin>
Require all granted
AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch
</Directory>
ProxyPass /.well-known !
RewriteEngine on
RewriteCond %{HTTP_HOST} =webmail.fbwl.creature-go.com
RewriteRule ^/(?!\.well-known)(.*)$ https://fbwl.creature-go.com:20000/ [R=301,L]
RewriteCond %{HTTP_HOST} =admin.fbwl.creature-go.com
RewriteRule ^/(?!\.well-known)(.*)$ https://fbwl.creature-go.com:10000/ [R=301,L]
# --- Finanzberatungs-Tool: Weiterleitung an Traefik (sv006, wghttp) ---
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
# WebSocket (Grafana Live) an Traefik durchreichen
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule .* ws://10.8.0.6:8080%{REQUEST_URI} [P,L]
# Haupt-Weiterleitung (Traefik trennt App vs. /grafana selbst)
ProxyPass / http://10.8.0.6:8080/
ProxyPassReverse / http://10.8.0.6:8080/
RemoveHandler .php
RemoveHandler .php8.4
FcgidMaxRequestLen 1073741824
SSLEngine on
SSLCertificateFile /etc/ssl/virtualmin/17848735353695059/ssl.cert
SSLCertificateKeyFile /etc/ssl/virtualmin/17848735353695059/ssl.key
SSLProtocol all -SSLv2 -SSLv3 -TLSv1 -TLSv1.1
SSLCACertificateFile /etc/ssl/virtualmin/17848735353695059/ssl.ca
```
Hinweis: `www.`/`mail.fbwl…` gehen (wie bei affine) über den Proxy an Traefik
und landen dort mangels Router im Black Hole (403) — fürs Tool irrelevant.
## Cutover-Reihenfolge (falls die Kette neu aufgebaut wird)
1. DNS `fbwl.creature-go.com` → sv005; Cert via Virtualmin/Let's Encrypt.
2. Traefik-Router (Teil 2) ablegen — `watch` lädt automatisch.
3. sv005 Apache (Teil 3) eintragen — Apache reload.
4. Testen (unten), solange Tool noch im Lokalmodus (`/grafana` kommt erst mit Schritt 5).
5. `.env`-Cutover (Teil 1) + Redeploy — **`FB_SESSION_COOKIE_SECURE='true'` zuletzt**.
## Verifikation
```bash
# Von aussen:
curl -sI https://fbwl.creature-go.com/login | head -1 # -> 200
curl -sI https://fbwl.creature-go.com/grafana/api/health | head -1 # -> 200
curl -I http://fbwl.creature-go.com/ 2>&1 | head -3 # -> 301 auf https
# Auf sv006 (Kette bis Traefik, Host-Header simuliert):
for p in /login /grafana/api/health; do
curl -sS --max-time 5 -o /dev/null -w "$p -> %{http_code}\n" \
-H 'Host: fbwl.creature-go.com' "http://10.8.0.6:8080$p"
done
```
Der Grafana-iframe erfordert eine Grafana-Session (Embedding an, anonym aus): in
der GUI einmal „Grafana anmelden" (→ `…/grafana`) mit dem gemeinsamen Passwort.
## Für künftige Claude-Sessions — was das für Änderungen am Tool bedeutet
- **Domain/Links des Tools hängen ausschließlich an `FB_GRAFANA_PUBLIC_URL`**
(+ Templates via `grafana_public_base`). Kein Hostname im Code/Image — ein
Domainwechsel ist eine reine `.env`-Änderung + Redeploy (plus je ein Eintrag
in Traefik-Router und sv005-Apache).
- **Neuer öffentlicher Pfad/Dienst hinter derselben Domain**: Traefik-Router in
`dynamic/` mit `entryPoints: ["wghttp"]`, `priority > 1`, Backend
`http://10.0.2.2:PORT`; an sv005 meist nichts nötig, weil `ProxyPass /` die
ganze Domain weiterreicht und Traefik nach Pfad trennt.
- **Neuer Port am Pod**: in `create_pod_finance.sh` als `127.0.0.1:PORT`
veröffentlichen → Traefik erreicht ihn als `10.0.2.2:PORT`.
- **Grafana-Sub-Pfad**: `GF_SERVER_ROOT_URL` + `serve_from_sub_path` und die
präfixierten internen URLs werden in `create_pod_finance.sh` aus
`FB_GRAFANA_PUBLIC_URL` abgeleitet — den Pfad (`/grafana`) nicht ohne Grund
ändern; er steckt auch im Health-Check und in `FB_GRAFANA_URL`.
- **Lokaler Test trotz Live-Proxy**: `.env`-Schalter leeren (echter Lokalmodus)
ODER mit `curl` testen; ein echter Browser braucht die HTTPS-Domain, weil das
Session-Cookie `Secure` ist.
- **Traefik ist geteilt** (User `trf`): dessen Config ist als `wlfb` nicht
editierbar. Router-/Entrypoint-Änderungen müssen als `trf` erfolgen; den
Black-Hole-Router und fremde Dienste nicht anfassen.

View File

@@ -0,0 +1,776 @@
# Ausbaustufe 10: Reverse-Proxy-Tauglichkeit — Implementation Plan
> **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:** Das Finanzberatungs-Tool wird über genau eine Subdomain
`https://fb.wolfundlaemmlein.de/` erreichbar, mit Grafana als Unterpfad
`/grafana/` — ohne dass der Lokalbetrieb (Direktzugriff auf `127.0.0.1:8096`/
`:8097`) sich ändert.
**Architecture:** Alle proxy-spezifischen Werte kommen ausschließlich aus der
`.env` (kein Hostname in Image/Repo). Ist `FB_GRAFANA_PUBLIC_URL` leer, läuft
alles exakt wie in v0.9.0. Ist sie gesetzt, (a) betreibt `create_pod_finance.sh`
Grafana nativ unter dem Sub-Pfad (`GF_SERVER_ROOT_URL` + `serve_from_sub_path`)
und passt internen Health-Check + interne Grafana-API-URL um den `/grafana`-
Präfix an, (b) bilden die Templates alle Grafana-Links/iframes aus der
öffentlichen URL statt aus `<host>:8097`, (c) setzt der Login das
Session-Cookie mit `secure`-Flag, (d) vertraut uvicorn den Proxy-Headern
(`X-Forwarded-Proto`/`-Host`). Die eigentliche Traefik-/Apache-Konfiguration
liegt außerhalb dieses Repos; docs/ liefert nur Beispiel-Snippets.
**Tech Stack:** FastAPI/Starlette, Jinja2, uvicorn, Podman (rootless Pod,
systemd `--user`), Grafana OSS 12.1.0, Bash-Deployskript.
## Global Constraints
- **Lokalbetrieb bleibt bit-identisch**, solange `FB_GRAFANA_PUBLIC_URL` leer
ist. Jede Änderung MUSS diesen Fall als No-Op behandeln.
- **Kein Hostname/keine Domain als Funktionswert ins Repo oder Image.**
Proxy-Werte nur via `.env` (`FB_GRAFANA_PUBLIC_URL`,
`FB_SESSION_COOKIE_SECURE`). **Quellcode inkl. Kommentaren und Tests bleibt
domain-neutral** — nur die Beispiel-Domain `fb.example.de`, niemals die echte
Domain. **Ausnahme: die Deployment-Doku `docs/reverse-proxy.md`** beschreibt
genau diese Installation und darf die echte Domain
`fb.wolfundlaemmlein.de` nennen (öffentlicher DNS-Name, keine Kontodaten).
(Nutzerentscheidung 2026-07-21: Code neutral, Doku echt.)
- **`.env`-Werte single-quoted** (`KEY='wert'`) — `create_pod_finance.sh`
sourced per `set -a; . "$ENV_FILE"`.
- **Zwei getrennte Grafana-URLs:** `FB_GRAFANA_URL` (intern, Server→Grafana,
Passwort-Sync) ≠ `FB_GRAFANA_PUBLIC_URL` (öffentlich, Browser-Links). Nie
vermischen.
- **Geld = `decimal.Decimal`** (hier nicht berührt, gilt generell).
- **Deutsch** für GUI-Texte, Kommentare, Commit-Messages. Datumsformat
TT.MM.JJJJ.
- **Test-Suite bleibt grün, Basis 199 Tests** (`cd finance && .venv/bin/python
-m pytest -q`). Jede neue Funktion bekommt Tests → Endzahl > 199.
- **Fable-Testagent-Gate je Task vor dem Commit** (Nutzer-Vorgabe, CLAUDE.md).
- **Release am Ende als v0.10.0** (`finance/VERSION`), Redeploy, Live-Check
Lokalbetrieb unverändert.
---
### Task 1: Config — neue Settings `grafana_public_url` + `session_cookie_secure`
**Files:**
- Modify: `finance/app/config.py`
- Test: `finance/tests/test_config.py`
**Interfaces:**
- Produces: `Settings.grafana_public_url: str` (aus `FB_GRAFANA_PUBLIC_URL`,
Default `""`), `Settings.session_cookie_secure: bool` (aus
`FB_SESSION_COOKIE_SECURE`, Default `False`). Bool-Parsing via Modul-Helfer
`_env_bool(value: str) -> bool` (truthy: `1/true/yes/on`, case-insensitiv).
- [x] **Step 1: Failing Tests schreiben** — an `finance/tests/test_config.py`
anhängen (oben `import pytest` ergänzen, falls nicht vorhanden):
```python
def test_grafana_public_url_default(monkeypatch):
monkeypatch.delenv("FB_GRAFANA_PUBLIC_URL", raising=False)
get_settings.cache_clear()
try:
assert get_settings().grafana_public_url == ""
finally:
get_settings.cache_clear()
def test_grafana_public_url_from_env(monkeypatch):
monkeypatch.setenv("FB_GRAFANA_PUBLIC_URL", "https://fb.example.de/grafana/")
get_settings.cache_clear()
try:
assert get_settings().grafana_public_url == "https://fb.example.de/grafana/"
finally:
get_settings.cache_clear()
def test_session_cookie_secure_default_false(monkeypatch):
monkeypatch.delenv("FB_SESSION_COOKIE_SECURE", raising=False)
get_settings.cache_clear()
try:
assert get_settings().session_cookie_secure is False
finally:
get_settings.cache_clear()
@pytest.mark.parametrize("val,expected", [
("true", True), ("True", True), ("1", True), ("yes", True), ("on", True),
("false", False), ("0", False), ("", False), ("nope", False),
])
def test_session_cookie_secure_parsing(monkeypatch, val, expected):
monkeypatch.setenv("FB_SESSION_COOKIE_SECURE", val)
get_settings.cache_clear()
try:
assert get_settings().session_cookie_secure is expected
finally:
get_settings.cache_clear()
```
- [x] **Step 2: Tests laufen lassen, Fehlschlag prüfen**
Run: `cd finance && .venv/bin/python -m pytest tests/test_config.py -q`
Expected: FAIL (`AttributeError: ... 'grafana_public_url'` / `session_cookie_secure`).
- [x] **Step 3: Implementieren** — `finance/app/config.py`. Bool-Helfer vor
`get_settings` einfügen, zwei Felder ins `@dataclass Settings` und zwei
Zeilen in den `Settings(...)`-Konstruktor:
```python
def _env_bool(value: str) -> bool:
"""Interpretiert einen Env-Wert als Wahrheitswert. Truthy sind (case-
insensitiv) 1/true/yes/on; alles andere (inkl. leer) ist False."""
return value.strip().lower() in ("1", "true", "yes", "on")
```
Ins `Settings`-Dataclass (nach `grafana_url: str`) ergänzen:
```python
grafana_public_url: str
session_cookie_secure: bool
```
In `get_settings()` in den `return Settings(...)`-Aufruf (nach
`grafana_url=...`) ergänzen:
```python
# Öffentliche Grafana-Basis-URL für Browser-Links/iframes hinter einem
# Reverse Proxy (z.B. 'https://fb.wolfundlaemmlein.de/grafana/'). Leer =
# Direktbetrieb, Templates fallen auf http://<host>:8097 zurück. NICHT
# zu verwechseln mit grafana_url (intern, Server→Grafana).
grafana_public_url=e("FB_GRAFANA_PUBLIC_URL", ""),
# Session-Cookie mit secure-Flag ausliefern (nur über HTTPS gültig).
# Für den Reverse-Proxy-Betrieb; Default False für lokalen HTTP-Zugriff.
session_cookie_secure=_env_bool(e("FB_SESSION_COOKIE_SECURE", "")),
```
- [x] **Step 4: Tests grün**
Run: `cd finance && .venv/bin/python -m pytest tests/test_config.py -q`
Expected: PASS.
- [x] **Step 5: Volle Suite**
Run: `cd finance && .venv/bin/python -m pytest -q`
Expected: PASS (> 199).
- [x] **Step 6: Fable-Gate + Commit**
```bash
git add finance/app/config.py finance/tests/test_config.py
git commit -m "feat: FB_GRAFANA_PUBLIC_URL + FB_SESSION_COOKIE_SECURE in Settings"
```
---
### Task 2: Session-Cookie `secure`-Flag im Login setzen
**Files:**
- Modify: `finance/app/main.py:44-53` (Login-Handler)
- Test: `finance/tests/test_auth.py`
**Interfaces:**
- Consumes: `Settings.session_cookie_secure` (Task 1).
- Löst die OFFENE ENTSCHEIDUNG aus Ausbaustufe 2 (Session-Cookie ohne
`secure`) auf: jetzt per Env konfigurierbar, Default unverändert (kein
`secure` im Lokalbetrieb).
- [x] **Step 1: Failing Tests** — an `finance/tests/test_auth.py` anhängen.
Sicherstellen, dass oben importiert ist: `from app.config import get_settings`.
Die Tests nutzen die vorhandene `client`-Fixture (setzt Passwort-Hash) und
schalten das Flag zur Laufzeit um (Login liest `get_settings()` je Request):
```python
def test_login_cookie_secure_when_enabled(client, monkeypatch):
monkeypatch.setenv("FB_SESSION_COOKIE_SECURE", "true")
get_settings.cache_clear()
r = client.post("/login", data={"username": "admin", "password": "geheim"},
follow_redirects=False)
assert r.status_code == 303
assert "secure" in r.headers["set-cookie"].lower()
def test_login_cookie_not_secure_by_default(client, monkeypatch):
monkeypatch.delenv("FB_SESSION_COOKIE_SECURE", raising=False)
get_settings.cache_clear()
r = client.post("/login", data={"username": "admin", "password": "geheim"},
follow_redirects=False)
assert r.status_code == 303
assert "secure" not in r.headers["set-cookie"].lower()
```
- [x] **Step 2: Fehlschlag prüfen**
Run: `cd finance && .venv/bin/python -m pytest tests/test_auth.py::test_login_cookie_secure_when_enabled -q`
Expected: FAIL (`assert "secure" in ...`, Cookie hat noch kein secure-Flag).
- [x] **Step 3: Implementieren** — in `finance/app/main.py` den
`set_cookie`-Aufruf im Login um `secure=` ergänzen:
```python
@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",
secure=s.session_cookie_secure)
return resp
```
- [x] **Step 4: Tests grün**
Run: `cd finance && .venv/bin/python -m pytest tests/test_auth.py -q`
Expected: PASS.
- [x] **Step 5: Volle Suite**
Run: `cd finance && .venv/bin/python -m pytest -q`
Expected: PASS.
- [x] **Step 6: Fable-Gate + Commit**
```bash
git add finance/app/main.py finance/tests/test_auth.py
git commit -m "feat: Session-Cookie secure-Flag per FB_SESSION_COOKIE_SECURE"
```
---
### Task 3: Grafana-Links über `FB_GRAFANA_PUBLIC_URL` (Jinja-Global + Templates)
**Files:**
- Modify: `finance/app/routers/gui.py` (Import + Jinja-Global)
- Modify: `finance/app/templates/base.html:20`
- Modify: `finance/app/templates/index.html:80,82`
- Modify: `finance/app/templates/szenarien.html:191`
- Test: `finance/tests/test_gui.py`
**Interfaces:**
- Consumes: `Settings.grafana_public_url` (Task 1).
- Produces: Jinja-Global `grafana_public_base(request) -> str`. Rückgabe: bei
gesetzter `FB_GRAFANA_PUBLIC_URL` diese ohne abschließende Slashes; sonst
`http://<request-host>:8097`. Templates hängen bei Bedarf `/d/finanzen/...`
an — mit sub-path-URL ergibt das `.../grafana/d/finanzen/...`.
- [x] **Step 1: Failing Tests** — an `finance/tests/test_gui.py` anhängen. Oben
`from app.config import get_settings` ergänzen, falls nicht vorhanden:
```python
def test_grafana_links_use_public_url_when_set(client, monkeypatch):
client.post("/login", data={"username": "admin", "password": "geheim"})
monkeypatch.setenv("FB_GRAFANA_PUBLIC_URL", "https://fb.example.de/grafana/")
get_settings.cache_clear()
try:
r = client.get("/")
assert r.status_code == 200
# iframe + Anmelde-Link nutzen die öffentliche URL, kein :8097 mehr.
assert "https://fb.example.de/grafana/d/finanzen/finanzen" in r.text
assert ":8097" not in r.text
# Nav-Link im base-Template ebenfalls.
assert 'href="https://fb.example.de/grafana"' in r.text
finally:
get_settings.cache_clear()
def test_grafana_links_fallback_to_host_port_when_unset(client, monkeypatch):
client.post("/login", data={"username": "admin", "password": "geheim"})
monkeypatch.delenv("FB_GRAFANA_PUBLIC_URL", raising=False)
get_settings.cache_clear()
try:
r = client.get("/")
assert r.status_code == 200
assert ":8097/d/finanzen/finanzen" in r.text
assert "fb.example.de" not in r.text
finally:
get_settings.cache_clear()
```
- [x] **Step 2: Fehlschlag prüfen**
Run: `cd finance && .venv/bin/python -m pytest tests/test_gui.py::test_grafana_links_use_public_url_when_set -q`
Expected: FAIL (Templates nutzen noch `<host>:8097`, `:8097` ist im Text).
- [x] **Step 3a: Jinja-Global implementieren** — `finance/app/routers/gui.py`.
Import ergänzen (bei den `from app...`-Imports):
```python
from app.config import get_settings
```
Nach dem Block, der `templates` konfiguriert (`templates.env.globals[...]`),
einfügen:
```python
def grafana_public_base(request: Request) -> str:
"""Öffentliche Grafana-Basis-URL für Browser-Links/iframes. Ist
FB_GRAFANA_PUBLIC_URL gesetzt (Reverse-Proxy-Betrieb, z.B.
'https://fb.example.de/grafana/'), wird sie ohne abschließenden
Slash zurückgegeben; sonst der lokale Fallback http://<host>:8097
(Direktbetrieb ohne Proxy). Templates hängen bei Bedarf '/d/...' an."""
public = get_settings().grafana_public_url
if public:
return public.rstrip("/")
host = request.url.hostname or "127.0.0.1"
return f"http://{host}:8097"
templates.env.globals["grafana_public_base"] = grafana_public_base
```
- [x] **Step 3b: Templates umstellen.**
`finance/app/templates/base.html` Zeile 20:
```html
<a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a>
```
`finance/app/templates/index.html` Zeilen 80 und 82:
```html
<iframe class="grafana" src="{{ grafana_public_base(request) }}/d/finanzen/finanzen?orgId=1&kiosk"></iframe>
```
```html
<a href="{{ grafana_public_base(request) }}" target="_blank">Grafana anmelden</a>
```
`finance/app/templates/szenarien.html` Zeile 191:
```html
Kurven in <a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a> ansehen.
```
- [x] **Step 4: Tests grün**
Run: `cd finance && .venv/bin/python -m pytest tests/test_gui.py -q`
Expected: PASS.
- [x] **Step 5: Kontrollgriff — keine hartkodierten `:8097` mehr in Templates**
Run: `grep -rn ":8097" finance/app/templates`
Expected: keine Treffer.
- [x] **Step 6: Volle Suite**
Run: `cd finance && .venv/bin/python -m pytest -q`
Expected: PASS.
- [x] **Step 7: Fable-Gate + Commit**
```bash
git add finance/app/routers/gui.py finance/app/templates/base.html \
finance/app/templates/index.html finance/app/templates/szenarien.html \
finance/tests/test_gui.py
git commit -m "feat: Grafana-Links aus FB_GRAFANA_PUBLIC_URL (Sub-Pfad-tauglich)"
```
---
### Task 4: uvicorn Proxy-Headers im Entrypoint
**Files:**
- Modify: `finance/entrypoint.sh`
- Test: `finance/tests/test_entrypoint.py` (Create)
**Interfaces:**
- Produces: uvicorn startet mit `--proxy-headers --forwarded-allow-ips='*'`,
damit Starlette hinter dem Proxy `X-Forwarded-Proto`/`-Host` auswertet
(korrektes https/Host in `request.url`). `*` ist vertretbar, weil der Pod
nur an `127.0.0.1` gebunden ist und ausschließlich der lokale Traefik ihn
erreicht (dokumentiert in docs/reverse-proxy.md, Task 6).
- [x] **Step 1: Failing Test** — `finance/tests/test_entrypoint.py` anlegen:
```python
from pathlib import Path
ENTRYPOINT = Path(__file__).resolve().parent.parent / "entrypoint.sh"
def test_entrypoint_enables_proxy_headers():
text = ENTRYPOINT.read_text()
assert "--proxy-headers" in text
assert "--forwarded-allow-ips" in text
```
- [x] **Step 2: Fehlschlag prüfen**
Run: `cd finance && .venv/bin/python -m pytest tests/test_entrypoint.py -q`
Expected: FAIL (Flags fehlen noch).
- [x] **Step 3: Implementieren** — `finance/entrypoint.sh` vollständig:
```sh
#!/bin/sh
set -e
alembic upgrade head
# --proxy-headers + --forwarded-allow-ips='*': hinter dem Reverse Proxy
# (Traefik→Apache/TLS) wertet uvicorn X-Forwarded-Proto/-Host aus, damit
# request.url das öffentliche https/Host statt des pod-internen http sieht.
# '*' ist vertretbar, weil der Pod nur an 127.0.0.1 gebunden ist und nur der
# lokale Traefik ihn erreicht (siehe docs/reverse-proxy.md). Im Direktbetrieb
# ohne Proxy sendet niemand X-Forwarded-*, also bleibt das Verhalten gleich.
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 \
--proxy-headers --forwarded-allow-ips='*'
```
- [x] **Step 4: Test grün + Shell-Syntaxcheck**
Run: `cd finance && .venv/bin/python -m pytest tests/test_entrypoint.py -q && sh -n entrypoint.sh && echo OK`
Expected: PASS + `OK`.
- [x] **Step 5: Volle Suite**
Run: `cd finance && .venv/bin/python -m pytest -q`
Expected: PASS.
- [x] **Step 6: Fable-Gate + Commit**
```bash
git add finance/entrypoint.sh finance/tests/test_entrypoint.py
git commit -m "feat: uvicorn Proxy-Headers fuer Reverse-Proxy-Betrieb"
```
---
### Task 5: `create_pod_finance.sh` — Grafana Sub-Pfad + sub-path-bewusste interne URLs
**Files:**
- Modify: `create_pod_finance.sh` (Repo-Wurzel)
**Interfaces:**
- Consumes: `FB_GRAFANA_PUBLIC_URL`, `FB_SESSION_COOKIE_SECURE` aus der `.env`
(via `set -a; . "$ENV_FILE"`). Beide sind NICHT Teil der generierten
Standard-`.env` — der Nutzer trägt sie beim Umzug hinter den Proxy manuell
(single-quoted) ein. Fehlen sie, ist der Lokalbetrieb unverändert.
- Produces: Im Sub-Pfad-Modus lauffähige Grafana-Instanz unter `/grafana/`,
interner Health-Check + interne Grafana-API-URL (`FB_GRAFANA_URL`) mit
`/grafana`-Präfix. Reines Shell-Skript ohne Unit-Test; Verifikation über
`bash -n`, isolierten Test der Sub-Pfad-Extraktion und den Live-Redeploy in
Task 7.
- [x] **Step 1: Sub-Pfad-Berechnung einfügen** — in `create_pod_finance.sh`
VOR dem **API**-`podman run` (`podman run -d --name "$API_CTR_NAME" ...`)
diesen Block einfügen. WICHTIG: nicht erst vor dem Grafana-Container — der
API-Container läuft im Skript zuerst und verwendet bereits `${GF_SUBPATH}`
(siehe Step 3), also muss der Block davor stehen, sonst wäre `FB_GRAFANA_URL`
im Sub-Pfad-Modus fälschlich präfixlos:
```bash
# --- Reverse-Proxy-Betrieb (optional) ----------------------------------------
# FB_GRAFANA_PUBLIC_URL wird nur gesetzt, wenn das Tool hinter einem Reverse
# Proxy unter einem Sub-Pfad laufen soll (z.B.
# 'https://fb.example.de/grafana/'). Ist sie leer (Default,
# Direktbetrieb auf 127.0.0.1), bleibt alles wie bisher: Grafana serviert an
# der Wurzel, Health-Check und Passwort-Sync sprechen /api/... ohne Praefix.
GF_SUBPATH=''
GRAFANA_SUBPATH_ARGS=()
if [ -n "${FB_GRAFANA_PUBLIC_URL:-}" ]; then
# Pfadanteil der oeffentlichen URL extrahieren, Slash(es) am Ende entfernen:
# 'https://host/grafana/' -> '/grafana'. Dieser Praefix wird sowohl fuer den
# internen Health-Check als auch fuer die interne Grafana-API-URL
# (FB_GRAFANA_URL, Passwort-Sync in der GUI) gebraucht, weil
# serve_from_sub_path ALLE Grafana-Routen unter den Sub-Pfad haengt.
GF_SUBPATH=$(printf '%s' "$FB_GRAFANA_PUBLIC_URL" | sed -E 's#^[a-z]+://[^/]+##; s#/+$##')
GRAFANA_SUBPATH_ARGS=(
-e "GF_SERVER_ROOT_URL=$FB_GRAFANA_PUBLIC_URL"
-e "GF_SERVER_SERVE_FROM_SUB_PATH=true"
)
fi
```
- [x] **Step 2: Grafana-Container um die Sub-Pfad-Args ergänzen** — im
`podman run -d --name "$GRAFANA_CTR_NAME" ...`-Aufruf eine Zeile
einfügen (z.B. direkt nach `-e GF_SECURITY_COOKIE_SAMESITE=lax \`):
```bash
"${GRAFANA_SUBPATH_ARGS[@]}" \
```
(Bei leerem Array expandiert das unter bash zu nichts — Lokalbetrieb
unverändert.)
- [x] **Step 3: API-Container um die drei Env-Durchreichungen ergänzen** — im
`podman run -d --name "$API_CTR_NAME" ...`-Aufruf nach der Zeile
`-e FB_UPLOADS_DIR=/data/uploads \` einfügen:
```bash
-e FB_GRAFANA_PUBLIC_URL="${FB_GRAFANA_PUBLIC_URL:-}" \
-e FB_SESSION_COOKIE_SECURE="${FB_SESSION_COOKIE_SECURE:-}" \
-e FB_GRAFANA_URL="http://localhost:3000${GF_SUBPATH}" \
```
(`FB_GRAFANA_URL` erhält im Lokalbetrieb `http://localhost:3000` — identisch
zum bisherigen Config-Default; im Sub-Pfad-Modus `http://localhost:3000/grafana`,
damit die GUI-Passwortänderung die Grafana-Admin-API unter dem Präfix trifft.)
- [x] **Step 4: Grafana-Health-Check-URL sub-path-bewusst machen** — die Zeile
```bash
CHECK_URL_GRAFANA="http://$HOST_LOCAL_IP:$GRAFANA_HOST_PORT/api/health"
```
ersetzen durch:
```bash
CHECK_URL_GRAFANA="http://$HOST_LOCAL_IP:$GRAFANA_HOST_PORT${GF_SUBPATH}/api/health"
```
- [x] **Step 5: Statische Verifikation**
Run: `bash -n create_pod_finance.sh && echo SYNTAX-OK`
Expected: `SYNTAX-OK`.
Sub-Pfad-Extraktion isoliert prüfen (darf NICHT die echte Domain benutzen):
```bash
for u in 'https://fb.example.de/grafana/' 'https://fb.example.de/grafana' 'http://host.tld/g/' ''; do
printf '%s -> [%s]\n' "$u" "$(printf '%s' "$u" | sed -E 's#^[a-z]+://[^/]+##; s#/+$##')"
done
```
Expected:
```
https://fb.example.de/grafana/ -> [/grafana]
https://fb.example.de/grafana -> [/grafana]
http://host.tld/g/ -> [/g]
-> []
```
- [x] **Step 6: Fable-Gate (adversariale Skript-Review) + Commit**
Fable prüft insbesondere: Lokalbetrieb (leere Variable) ist echter No-Op
(leeres Array, leerer Präfix, `FB_GRAFANA_URL=http://localhost:3000`);
Sub-Pfad-Extraktion robust; keine Domain im Skript.
```bash
git add create_pod_finance.sh
git commit -m "feat: create_pod_finance.sh Grafana-Sub-Pfad + interne URLs sub-path-bewusst"
```
---
### Task 6: Doku — Reverse-Proxy-Snippets (Traefik + Apache) und Env-Variablen
**Files:**
- Create: `docs/reverse-proxy.md`
- Modify: `docs/ARCHITEKTUR.md` (Verweis im Deployment-Abschnitt)
- Modify: `CLAUDE.md` (zwei neue `.env`-Variablen in den Betriebsnotizen)
**Interfaces:**
- Produces: Betriebsdoku für den Proxy-Umzug. Beispiel-Snippets sind
Vorlagen — die eigentliche Proxy-Konfiguration macht der Nutzer.
- [x] **Step 1: `docs/reverse-proxy.md` anlegen** mit folgendem Inhalt:
````markdown
# Reverse-Proxy-Betrieb (Ausbaustufe 10)
Ziel: Erreichbarkeit über **eine** Subdomain
`https://fb.wolfundlaemmlein.de/`, Grafana als Unterpfad `/grafana/`.
Kette: Internet → **sv003** (Apache, TLS-Terminierung) → WireGuard-VPN →
**sv006** (Traefik, ohne TLS, `10.8.0.6:8080`) → finance_pod
(`127.0.0.1:8096` GUI/API, `127.0.0.1:8097` Grafana).
Die Proxy-Konfiguration selbst (Traefik, Apache) liegt **außerhalb** dieses
Repos — die folgenden Snippets sind Vorlagen. Tool-seitig genügt es, in der
`.env` unter `~/.local/share/finance_pod/.env` zwei Variablen zu setzen und
`./create_pod_finance.sh` erneut auszuführen.
## `.env`-Schalter (single-quoted!)
```sh
FB_GRAFANA_PUBLIC_URL='https://fb.wolfundlaemmlein.de/grafana/'
FB_SESSION_COOKIE_SECURE='true'
```
- `FB_GRAFANA_PUBLIC_URL` — öffentliche Grafana-Basis-URL (mit `/grafana/`,
abschließender Slash empfohlen). Bewirkt:
- `create_pod_finance.sh` startet Grafana mit `GF_SERVER_ROOT_URL` +
`GF_SERVER_SERVE_FROM_SUB_PATH=true`;
- interner Health-Check und interne Grafana-API-URL (`FB_GRAFANA_URL`, für
die GUI-Passwortänderung) erhalten automatisch den `/grafana`-Präfix;
- alle GUI-Links/iframes zeigen auf die öffentliche URL statt `<host>:8097`.
- **Leer lassen** = unveränderter Direktbetrieb auf `127.0.0.1`.
- `FB_SESSION_COOKIE_SECURE='true'` — Session-Cookie nur über HTTPS. Vor der
Internet-Freigabe setzen. Default (leer/`false`) für lokalen HTTP-Zugriff.
Nach dem Editieren: `./create_pod_finance.sh` (übernimmt die `.env`
unverändert, baut Pod/Container neu). HTTPS/HSTS und Login-Rate-Limiting sind
Proxy-Sache (Apache auf sv003 bzw. fail2ban) — außerhalb dieses Repos.
## Traefik (sv006) — Datei-Provider, Beispiel
Kein Prefix-Stripping für `/grafana` (Grafana serviert dank
`serve_from_sub_path` selbst unter dem Sub-Pfad):
```yaml
# /etc/traefik/dynamic/finance.yml
http:
routers:
finance-grafana:
rule: "Host(`fb.wolfundlaemmlein.de`) && PathPrefix(`/grafana`)"
priority: 20
service: finance-grafana
entryPoints: [web]
finance-app:
rule: "Host(`fb.wolfundlaemmlein.de`)"
priority: 10
service: finance-app
entryPoints: [web]
services:
finance-grafana:
loadBalancer:
servers:
- url: "http://127.0.0.1:8097"
finance-app:
loadBalancer:
servers:
- url: "http://127.0.0.1:8096"
```
## Apache (sv003) — VHost, Beispiel
TLS terminiert hier; Weiterleitung an Traefik über die WireGuard-IP. `X-
Forwarded-Proto https` ist wichtig, damit uvicorn (mit `--proxy-headers`) das
öffentliche Schema erkennt:
```apache
<VirtualHost *:443>
ServerName fb.wolfundlaemmlein.de
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/fb.wolfundlaemmlein.de/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/fb.wolfundlaemmlein.de/privkey.pem
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
ProxyPass / http://10.8.0.6:8080/
ProxyPassReverse / http://10.8.0.6:8080/
</VirtualHost>
```
## Zurück in den Lokalbetrieb
`FB_GRAFANA_PUBLIC_URL` und `FB_SESSION_COOKIE_SECURE` aus der `.env`
entfernen (oder leeren) und `./create_pod_finance.sh` erneut ausführen.
````
- [x] **Step 2: Verweis in `docs/ARCHITEKTUR.md`** — im Deployment-Abschnitt
(Abschnitt 3) einen Hinweis auf die neue Doku ergänzen, z.B.:
```markdown
> Reverse-Proxy-Betrieb (eine Subdomain, Grafana als `/grafana/`-Unterpfad):
> siehe **`docs/reverse-proxy.md`** (Ausbaustufe 10). Aktivierung rein über
> `.env` (`FB_GRAFANA_PUBLIC_URL`, `FB_SESSION_COOKIE_SECURE`); leer = lokaler
> Direktbetrieb unverändert.
```
- [x] **Step 3: `CLAUDE.md`** — im Abschnitt zur `.env` die zwei neuen
Variablen kurz erwähnen (ein Satz), inkl. Verweis auf `docs/reverse-proxy.md`.
- [x] **Step 4: Fable-Gate (Faktencheck gegen Skript/Config/Templates) + Commit**
Fable prüft: Snippets stimmen mit dem tatsächlichen Verhalten aus Task 15
überein (Ports 8096/8097, kein Prefix-Stripping, `serve_from_sub_path`,
Env-Namen exakt).
```bash
git add docs/reverse-proxy.md docs/ARCHITEKTUR.md CLAUDE.md
git commit -m "docs: Reverse-Proxy-Snippets (Traefik/Apache) + .env-Schalter"
```
---
### Task 7: Release v0.10.0 — Version, Redeploy, Live-Check Lokalbetrieb
**Files:**
- Modify: `finance/VERSION` (0.9.0 → 0.10.0)
- Modify: `docs/superpowers/plans/2026-07-20-ausbaustufe-10.md` (Haken)
- Modify: `.superpowers/sdd/progress.md` (Ledger-Eintrag)
**Interfaces:**
- Consumes: alle vorherigen Tasks.
- Live-Verifikation, dass der **Lokalbetrieb** (leere Proxy-Variablen) mit
v0.10.0 unverändert funktioniert.
- [x] **Step 1: Version hochzählen**
`finance/VERSION`:
```
0.10.0
```
- [x] **Step 2: Volle Suite**
Run: `cd finance && .venv/bin/python -m pytest -q`
Expected: PASS (> 199).
- [x] **Step 3: Redeploy (Lokalbetrieb, `.env` OHNE Proxy-Variablen)**
Run: `./create_pod_finance.sh`
Expected: Läuft durch bis „API is reachable ... (200)" und „Grafana is
reachable ... (200)"; Service enabled+active. Der Health-Check spricht ohne
Präfix `:8097/api/health` (weil `FB_GRAFANA_PUBLIC_URL` leer).
- [x] **Step 4: Live-Check Lokalbetrieb unverändert**
```bash
# Login → 303 + Cookie (Cookie OHNE Secure, da FB_SESSION_COOKIE_SECURE leer)
curl -si -c /tmp/fb_cookies -X POST http://127.0.0.1:8096/login \
-d 'username=admin' -d "password=$FB_PASSWORD" | grep -i 'HTTP/\|set-cookie'
# Version-Endpoint
curl -s -b /tmp/fb_cookies http://127.0.0.1:8096/api/version
# Grafana-Fallback-Link im Dashboard zeigt :8097 (kein fb.* / kein /grafana)
curl -s -b /tmp/fb_cookies http://127.0.0.1:8096/ | grep -o 'http://[^"]*:8097[^"]*d/finanzen[^"]*' | head -1
# Seiten 200
for p in / /buchungen /salden /planung /szenarien /import /admin /hilfe; do
printf '%s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' -b /tmp/fb_cookies http://127.0.0.1:8096$p)"
done
# Grafana direkt erreichbar (Wurzel, kein Sub-Pfad)
curl -s -o /dev/null -w 'grafana:%{http_code}\n' http://127.0.0.1:8097/api/health
rm -f /tmp/fb_cookies
```
Expected: Login `303` + `set-cookie: fb_session=...` **ohne** `Secure`;
`/api/version` → `{"version":"0.10.0"}`; Grafana-Link enthält `:8097/d/finanzen`
und **kein** `/grafana`; alle Seiten `200`; `grafana:200`.
- [x] **Step 5: Plan-Haken + Ledger** — alle Task-Checkboxen dieses Plans
setzen; in `.superpowers/sdd/progress.md` einen A10-Abschluss-Eintrag
ergänzen (Commit-Range, Fable-Befunde je Task, Live-Check-Ergebnis; **keine**
echten Kontodaten/Domain-Secrets).
- [x] **Step 6: Fable-Release-Gate + Commit**
Fable verifiziert: Suite grün, Version live `0.10.0`, Lokalbetrieb im
Live-Check unverändert (Cookie ohne Secure, Grafana-Fallback `:8097`),
Bestandsdaten (3 Konten / 1968 Buchungen) unberührt.
```bash
git add finance/VERSION docs/superpowers/plans/2026-07-20-ausbaustufe-10.md \
.superpowers/sdd/progress.md
git commit -m "chore: Release v0.10.0 (Reverse-Proxy-Tauglichkeit)"
```
---
## Self-Review (Controller)
**Spec-Coverage:**
1. Grafana Sub-Pfad (`GF_SERVER_ROOT_URL` + `serve_from_sub_path`) → Task 5 ✓
2. `FB_GRAFANA_PUBLIC_URL` in Templates, leer ⇒ `<host>:8097`-Fallback,
kein Hostname im Repo → Task 1 (Config) + Task 3 (Global/Templates) ✓
3. Härtung: secure-Cookie per Env (löst A2-Entscheidung) → Task 1+2;
uvicorn Proxy-Headers → Task 4 ✓
4. Doku-Snippets Traefik + Apache in docs/ → Task 6 ✓
- Zusätzlich abgedeckt (Folgewirkung von serve_from_sub_path): interner
Health-Check + interne Grafana-API-URL sub-path-bewusst → Task 5 ✓
- Release v0.10.0 + Live-Check Lokalbetrieb → Task 7 ✓
**Typ-/Namens-Konsistenz:** `grafana_public_url`/`session_cookie_secure`
(Task 1) → konsumiert in Task 2 (`s.session_cookie_secure`) und Task 3
(`grafana_public_base`). Env-Namen `FB_GRAFANA_PUBLIC_URL`,
`FB_SESSION_COOKIE_SECURE`, `FB_GRAFANA_URL` überall identisch. `GF_SUBPATH`
konsistent in Task 5.

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
0.7.0 0.10.0

View File

@@ -5,6 +5,12 @@ from functools import lru_cache
from pathlib import Path from pathlib import Path
def _env_bool(value: str) -> bool:
"""Interpretiert einen Env-Wert als Wahrheitswert. Truthy sind (case-
insensitiv) 1/true/yes/on; alles andere (inkl. leer) ist False."""
return value.strip().lower() in ("1", "true", "yes", "on")
@dataclass(frozen=True) @dataclass(frozen=True)
class Settings: class Settings:
database_url: str database_url: str
@@ -18,6 +24,8 @@ class Settings:
horizon_days: int horizon_days: int
env_file: Path env_file: Path
grafana_url: str grafana_url: str
grafana_public_url: str
session_cookie_secure: bool
@lru_cache @lru_cache
@@ -38,4 +46,12 @@ def get_settings() -> Settings:
# 4 Task 2). Default passt zum Container-Mountpunkt "/data/.env". # 4 Task 2). Default passt zum Container-Mountpunkt "/data/.env".
env_file=Path(e("FB_ENV_FILE", "/data/.env")), env_file=Path(e("FB_ENV_FILE", "/data/.env")),
grafana_url=e("FB_GRAFANA_URL", "http://localhost:3000"), grafana_url=e("FB_GRAFANA_URL", "http://localhost:3000"),
# Öffentliche Grafana-Basis-URL für Browser-Links/iframes hinter einem
# Reverse Proxy (z.B. 'https://fb.example.de/grafana/'). Leer =
# Direktbetrieb, Templates fallen auf http://<host>:8097 zurück. NICHT
# zu verwechseln mit grafana_url (intern, Server→Grafana).
grafana_public_url=e("FB_GRAFANA_PUBLIC_URL", ""),
# Session-Cookie mit secure-Flag ausliefern (nur über HTTPS gültig).
# Für den Reverse-Proxy-Betrieb; Default False für lokalen HTTP-Zugriff.
session_cookie_secure=_env_bool(e("FB_SESSION_COOKIE_SECURE", "")),
) )

View File

@@ -49,7 +49,8 @@ def login(username: str = Form(...), password: str = Form(...)):
return HTMLResponse("Login fehlgeschlagen", status_code=401) return HTMLResponse("Login fehlgeschlagen", status_code=401)
resp = RedirectResponse("/", status_code=303) resp = RedirectResponse("/", status_code=303)
resp.set_cookie(auth.COOKIE, auth.make_session_token(), httponly=True, resp.set_cookie(auth.COOKIE, auth.make_session_token(), httponly=True,
max_age=auth.MAX_AGE, samesite="lax") max_age=auth.MAX_AGE, samesite="lax",
secure=s.session_cookie_secure)
return resp return resp

View File

@@ -1,8 +1,10 @@
from fastapi import APIRouter, Depends, Form, Request from fastapi import APIRouter, Depends, Form, Request
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth import require_auth from app.auth import require_auth
from app.db import get_session from app.db import get_session
from app.models.tables import Category
from app.routers.gui import gui_session, templates from app.routers.gui import gui_session, templates
from app.services.admin import apply_rules_retroactively, change_password from app.services.admin import apply_rules_retroactively, change_password
@@ -10,8 +12,12 @@ router = APIRouter()
@router.get("/admin", dependencies=[Depends(gui_session)]) @router.get("/admin", dependencies=[Depends(gui_session)])
def admin_page(request: Request): def admin_page(request: Request, session: Session = Depends(get_session)):
return templates.TemplateResponse(request, "admin.html", {"error": None, "success": None}) 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)]) @router.post("/admin/passwort", dependencies=[Depends(gui_session)])
@@ -20,18 +26,24 @@ def admin_change_password(
alt: str = Form(...), alt: str = Form(...),
neu: str = Form(...), neu: str = Form(...),
neu2: 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 # UX-Regel: das Formular selbst bleibt immer sichtbar/bedienbar; Fehler
# werden inline auf derselben Seite gemeldet statt still zu verwerfen. # 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: if len(neu) < 8:
return templates.TemplateResponse(request, "admin.html", { return templates.TemplateResponse(request, "admin.html", {
"error": "Das neue Passwort muss mindestens 8 Zeichen lang sein.", "error": "Das neue Passwort muss mindestens 8 Zeichen lang sein.",
"success": None, "success": None,
"categories": categories,
}, status_code=400) }, status_code=400)
if neu != neu2: if neu != neu2:
return templates.TemplateResponse(request, "admin.html", { return templates.TemplateResponse(request, "admin.html", {
"error": "Die Wiederholung stimmt nicht mit dem neuen Passwort überein.", "error": "Die Wiederholung stimmt nicht mit dem neuen Passwort überein.",
"success": None, "success": None,
"categories": categories,
}, status_code=400) }, status_code=400)
try: try:
change_password(alt, neu) change_password(alt, neu)
@@ -39,11 +51,13 @@ def admin_change_password(
return templates.TemplateResponse(request, "admin.html", { return templates.TemplateResponse(request, "admin.html", {
"error": str(exc), "error": str(exc),
"success": None, "success": None,
"categories": categories,
}, status_code=400) }, status_code=400)
return templates.TemplateResponse(request, "admin.html", { return templates.TemplateResponse(request, "admin.html", {
"error": None, "error": None,
"success": "Passwort erfolgreich geändert (gilt für GUI und Grafana). " "success": "Passwort erfolgreich geändert (gilt für GUI und Grafana). "
"Bestehende Sitzungen bleiben angemeldet.", "Bestehende Sitzungen bleiben angemeldet.",
"categories": categories,
}) })

View File

@@ -48,6 +48,22 @@ def create_category(data: CategoryIn, session: Session = Depends(get_session)):
return CategoryOut.model_validate(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]) @router.get("/category-rules", response_model=list[CategoryRuleOut])
def list_category_rules(session: Session = Depends(get_session)): def list_category_rules(session: Session = Depends(get_session)):
rules = session.execute( rules = session.execute(

View File

@@ -7,6 +7,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth import COOKIE, session_valid from app.auth import COOKIE, session_valid
from app.config import get_settings
from app.db import get_session from app.db import get_session
from app.engine.loans import add_months from app.engine.loans import add_months
from app.engine.recurrence import occurrences from app.engine.recurrence import occurrences
@@ -30,6 +31,22 @@ PAGE_SIZE = 50
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
templates.env.globals["app_version"] = get_version() templates.env.globals["app_version"] = get_version()
def grafana_public_base(request: Request) -> str:
"""Öffentliche Grafana-Basis-URL für Browser-Links/iframes. Ist
FB_GRAFANA_PUBLIC_URL gesetzt (Reverse-Proxy-Betrieb, z.B.
'https://fb.example.de/grafana/'), wird sie ohne abschließenden
Slash zurückgegeben; sonst der lokale Fallback http://<host>:8097
(Direktbetrieb ohne Proxy). Templates hängen bei Bedarf '/d/...' an."""
public = get_settings().grafana_public_url
if public:
return public.rstrip("/")
host = request.url.hostname or "127.0.0.1"
return f"http://{host}:8097"
templates.env.globals["grafana_public_base"] = grafana_public_base
templates.env.filters["eur"] = eur templates.env.filters["eur"] = eur
templates.env.filters["de_label"] = de_label templates.env.filters["de_label"] = de_label
router = APIRouter() router = APIRouter()
@@ -291,7 +308,6 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
recurring = _list_recurring(session=session) recurring = _list_recurring(session=session)
planned = _list_planned(session=session) planned = _list_planned(session=session)
loans = _list_loans(session=session) loans = _list_loans(session=session)
scenarios = _list_scenarios(session=session)
return templates.TemplateResponse(request, "planning.html", { return templates.TemplateResponse(request, "planning.html", {
"categories": categories, "categories": categories,
"category_names": {c.id: c.name for c in categories}, "category_names": {c.id: c.name for c in categories},
@@ -300,8 +316,23 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
"suggestions": _recurring_suggestions(session=session), "suggestions": _recurring_suggestions(session=session),
"planned": planned, "planned": planned,
"loans": loans, "loans": loans,
"scenario_rows": _scenario_rows(session, scenarios, loans),
"rhythms": ["monthly", "quarterly", "yearly"], "rhythms": ["monthly", "quarterly", "yearly"],
"repayment_types": ["annuity", "bullet"], "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"], "modifier_kinds": ["percent", "absolute", "remove", "ende"],
}) })

View File

@@ -56,7 +56,9 @@ class SuggestionOut(BaseModel):
amount: Decimal amount: Decimal
rhythm: str rhythm: str
due_day: int due_day: int
start_date: date | None = None
category_id: int | None = None category_id: int | None = None
hinweis: str = ""
def _check_category(session: Session, category_id: int | None) -> None: def _check_category(session: Session, category_id: int | None) -> None:

View File

@@ -1,52 +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 import statistics
from collections import Counter, defaultdict from collections import Counter
from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.formats import eur
from app.models.tables import RecurringItem, Transaction 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
def _max_consecutive_months(months: list[tuple[int, int]]) -> int: # Rhythmus-Tabelle: (min_tage, max_tage, mindestbelege) je Rhythmus. Der
if not months: # Median der Buchungsabstaende einer Serie muss ins Intervall fallen, UND es
return 0 # muessen mindestens so viele Buchungen vorliegen (ein einzelner Zufallstreffer
best = current = 1 # mit "passendem" Abstand soll nicht als Serie gelten).
for prev, cur in zip(months, months[1:]): RHYTHMS: dict[str, tuple[int, int, int]] = {
prev_idx = prev[0] * 12 + prev[1] "monthly": (25, 36, 3),
cur_idx = cur[0] * 12 + cur[1] "quarterly": (80, 105, 3),
current = current + 1 if cur_idx == prev_idx + 1 else 1 "yearly": (330, 400, 2),
best = max(best, current) }
return best
# 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 suggest_recurring(session: Session) -> list[dict]: 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( txs = session.execute(
select(Transaction).where(Transaction.status == "confirmed") select(Transaction)
.where(Transaction.status == "confirmed", Transaction.booking_date >= cutoff)
).scalars().all() ).scalars().all()
groups: dict[tuple, list[Transaction]] = defaultdict(list)
for t in txs:
groups[(t.account_id, t.counterparty, t.amount)].append(t)
existing = {(r.name, Decimal(r.amount)) # Schritt 2: Gruppierung je (Konto, normalisierter Empfaenger).
for r in session.execute(select(RecurringItem)).scalars()} 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] = [] suggestions: list[dict] = []
for (_account_id, counterparty, amount), items in groups.items(): for series_list in series_by_account.values():
months = sorted({(t.booking_date.year, t.booking_date.month) for t in items}) # Schritt 6: Umfirmierungs-Merge ueber Gruppengrenzen, je Konto.
if _max_consecutive_months(months) < 3: 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 continue
name = counterparty
if (name, Decimal(amount)) in existing: # Schritt 7: Vorschlagswerte aus der neuesten Buchung.
continue last = s.last
due_day = int(statistics.median(sorted(t.booking_date.day for t in items))) name = last.counterparty
cat_counts = Counter(t.category_id for t in items if t.category_id is not None) 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 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({ suggestions.append({
"name": name, "name": name,
"amount": Decimal(amount), "amount": amount,
"rhythm": "monthly", "rhythm": s.rhythm,
"due_day": due_day, "due_day": due_day,
"start_date": start_date,
"category_id": category_id, "category_id": category_id,
"hinweis": hinweis,
}) })
return suggestions return suggestions

View File

@@ -179,3 +179,34 @@ section.planning-section fieldset {
text-align: center; text-align: center;
font-size: 0.75rem; font-size: 0.75rem;
} }
/* Ausbaustufe 7: Formular-Paare als Einheit, gesperrte Felder erkennbar. */
.entry-form label,
.planning-section form label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0 1.25rem 0.6rem 0;
white-space: nowrap;
}
.entry-form select,
.planning-section form select {
max-width: 14rem;
}
.entry-form .entry-art {
display: block;
margin-bottom: 0.75rem;
}
.entry-form .entry-art > label { font-weight: bold; }
.entry-form .entry-art .muted { margin-left: 0.5rem; }
input:disabled, select:disabled {
background: #e3e3e3;
color: #8a8a8a;
cursor: not-allowed;
}
label:has(input:disabled), label:has(select:disabled) {
opacity: 0.55;
}
.project-block {
margin-top: 1rem;
}

View File

@@ -32,6 +32,43 @@
<p id="apply-rules-result"></p> <p id="apply-rules-result"></p>
</section> </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> <script>
function handleApplyRulesResult(event) { function handleApplyRulesResult(event) {
var result = document.getElementById('apply-rules-result'); var result = document.getElementById('apply-rules-result');

View File

@@ -14,9 +14,10 @@
<a href="/buchungen">Buchungen</a> <a href="/buchungen">Buchungen</a>
<a href="/salden">Salden</a> <a href="/salden">Salden</a>
<a href="/planung">Planung</a> <a href="/planung">Planung</a>
<a href="/szenarien">Szenarien</a>
<a href="/admin">Admin</a> <a href="/admin">Admin</a>
<a href="/hilfe">Hilfe</a> <a href="/hilfe">Hilfe</a>
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a> <a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a>
<form method="post" action="/logout"> <form method="post" action="/logout">
<button type="submit">Logout</button> <button type="submit">Logout</button>
</form> </form>
@@ -74,6 +75,12 @@
return JSON.stringify(out); 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> </script>
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer> <footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
</body> </body>

View File

@@ -77,8 +77,8 @@
</table> </table>
<h2>Grafana-Dashboard</h2> <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> <iframe class="grafana" src="{{ grafana_public_base(request) }}/d/finanzen/finanzen?orgId=1&kiosk"></iframe>
<p class="hint">Kein Diagramm sichtbar? Einmal in <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> <a href="{{ grafana_public_base(request) }}" target="_blank">Grafana anmelden</a>
(gleiches Passwort wie hier).</p> (gleiches Passwort wie hier).</p>
{% endblock %} {% endblock %}

View File

@@ -88,15 +88,19 @@
{% if suggestions %} {% if suggestions %}
<table> <table>
<thead> <thead>
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th></th></tr> <tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th>Start</th><th></th></tr>
</thead> </thead>
<tbody> <tbody>
{% for s in suggestions %} {% for s in suggestions %}
<tr> <tr>
<td>{{ s.name }}</td> <td>{{ s.name }}</td>
<td class="{{ 'neg' if s.amount < 0 else '' }}">{{ s.amount|eur }} €</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.rhythm|de_label }}</td>
<td>{{ s.due_day }}</td> <td>{{ s.due_day }}</td>
<td>{{ s.start_date.strftime('%d.%m.%Y') if s.start_date else '' }}</td>
<td> <td>
<form class="inline-form" hx-ext="json-form" hx-post="/api/recurring" hx-swap="none" <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()}"> hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -105,6 +109,7 @@
<input type="hidden" name="rhythm" value="{{ s.rhythm }}"> <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="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="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> <button type="submit">Vorschlag übernehmen</button>
</form> </form>
</td> </td>
@@ -113,8 +118,9 @@
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
<p class="muted">Keine Vorschläge — erkannt werden Serien aus mindestens 3 Monaten gleichartiger Buchungen.</p> <p class="muted">Keine Vorschläge.</p>
{% endif %} {% endif %}
<p class="muted">Erkannt werden monatliche, vierteljährliche und jährliche Serien; Betrag = jeweils letzte Buchung.</p>
</fieldset> </fieldset>
</section> </section>
@@ -260,278 +266,11 @@
</fieldset> </fieldset>
</section> </section>
<section class="planning-section">
<h2>Szenarien</h2>
{% 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>
<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>
<label>Ziel-Typ
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
<option value="category">Kategorie</option>
<option value="recurring">Wiederkehrender Posten</option>
</select>
</label>
<span class="mod-target-category">
<label>Kategorie
<select name="target_id" data-type="int">
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</label>
</span>
<span class="mod-target-recurring" style="display:none">
<label>Posten
<select name="target_id" data-type="int" disabled>
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
</select>
</label>
</span>
<label>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>
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Durchrechnen</button>
</form>
{% if row.result %}
<p>
Tiefpunkt: {{ 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> <script>
// json-form-Extension ist zentral in base.html registriert (eine // json-form-Extension ist zentral in base.html registriert (eine
// Definition fuer alle Templates, inkl. data-type="bool"-Handling). // Definition fuer alle Templates, inkl. data-type="bool"-Handling).
// toggleEdit ist ebenfalls zentral in base.html definiert (wird auf
function toggleEdit(prefix, id, editing) { // mehreren Seiten fuer Inline-Edit-Formulare gebraucht).
// 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;
}
function onModTargetTypeChange(select) {
var form = select.closest('form');
var catSpan = form.querySelector('.mod-target-category');
var recSpan = form.querySelector('.mod-target-recurring');
var catSelect = catSpan.querySelector('select');
var recSelect = recSpan.querySelector('select');
var isCategory = select.value === 'category';
catSpan.style.display = isCategory ? '' : 'none';
recSpan.style.display = isCategory ? 'none' : '';
catSelect.disabled = !isCategory;
recSelect.disabled = isCategory;
}
function 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);
}
}
function toggleScenarioLoan(scenarioId, loanId, checked, checkbox) {
fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' })
.then(function (resp) {
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
window.location.reload();
})
.catch(function (err) {
checkbox.checked = !checked;
alert('Zuordnung fehlgeschlagen: ' + err.message);
});
}
var loadedSchedules = {}; var loadedSchedules = {};
function formatIsoDate(iso) { function formatIsoDate(iso) {

View File

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

View File

@@ -1,4 +1,11 @@
#!/bin/sh #!/bin/sh
set -e set -e
alembic upgrade head alembic upgrade head
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 # --proxy-headers + --forwarded-allow-ips='*': hinter dem Reverse Proxy
# (Traefik→Apache/TLS) wertet uvicorn X-Forwarded-Proto/-Host aus, damit
# request.url das öffentliche https/Host statt des pod-internen http sieht.
# '*' ist vertretbar, weil der Pod nur an 127.0.0.1 gebunden ist und nur der
# lokale Traefik ihn erreicht (siehe docs/reverse-proxy.md). Im Direktbetrieb
# ohne Proxy sendet niemand X-Forwarded-*, also bleibt das Verhalten gleich.
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 \
--proxy-headers --forwarded-allow-ips='*'

View File

@@ -8,7 +8,7 @@
"tags": ["finanzen"], "tags": ["finanzen"],
"time": { "time": {
"from": "now-1y", "from": "now-1y",
"to": "now" "to": "now+19M"
}, },
"refresh": "", "refresh": "",
"panels": [ "panels": [

View File

@@ -234,6 +234,21 @@ def test_admin_passwort_success_returns_200_with_confirmation(
assert r2.status_code == 200 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 --------------------------------------------------- # --- Regeln neu anwenden ---------------------------------------------------
def test_apply_rules_retroactively_categorizes_uncategorized_confirmed_tx(db): def test_apply_rules_retroactively_categorizes_uncategorized_confirmed_tx(db):

View File

@@ -1,3 +1,6 @@
from app.config import get_settings
def test_api_requires_key(client): def test_api_requires_key(client):
assert client.get("/api/accounts").status_code == 401 assert client.get("/api/accounts").status_code == 401
r = client.get("/api/accounts", headers={"Authorization": "Bearer test-key"}) r = client.get("/api/accounts", headers={"Authorization": "Bearer test-key"})
@@ -60,3 +63,21 @@ def test_current_password_hash_cache_invalidates_on_file_change(
second = current_password_hash() second = current_password_hash()
assert second == new_hash assert second == new_hash
assert verify_password("andereswort999", second) assert verify_password("andereswort999", second)
def test_login_cookie_secure_when_enabled(client, monkeypatch):
monkeypatch.setenv("FB_SESSION_COOKIE_SECURE", "true")
get_settings.cache_clear()
r = client.post("/login", data={"username": "admin", "password": "geheim"},
follow_redirects=False)
assert r.status_code == 303
assert "secure" in r.headers["set-cookie"].lower()
def test_login_cookie_not_secure_by_default(client, monkeypatch):
monkeypatch.delenv("FB_SESSION_COOKIE_SECURE", raising=False)
get_settings.cache_clear()
r = client.post("/login", data={"username": "admin", "password": "geheim"},
follow_redirects=False)
assert r.status_code == 303
assert "secure" not in r.headers["set-cookie"].lower()

View File

@@ -1,5 +1,7 @@
from pathlib import Path from pathlib import Path
import pytest
from app.config import get_settings from app.config import get_settings
@@ -35,3 +37,43 @@ def test_grafana_url_default(monkeypatch):
assert get_settings().grafana_url == "http://localhost:3000" assert get_settings().grafana_url == "http://localhost:3000"
finally: finally:
get_settings.cache_clear() get_settings.cache_clear()
def test_grafana_public_url_default(monkeypatch):
monkeypatch.delenv("FB_GRAFANA_PUBLIC_URL", raising=False)
get_settings.cache_clear()
try:
assert get_settings().grafana_public_url == ""
finally:
get_settings.cache_clear()
def test_grafana_public_url_from_env(monkeypatch):
monkeypatch.setenv("FB_GRAFANA_PUBLIC_URL", "https://fb.example.de/grafana/")
get_settings.cache_clear()
try:
assert get_settings().grafana_public_url == "https://fb.example.de/grafana/"
finally:
get_settings.cache_clear()
def test_session_cookie_secure_default_false(monkeypatch):
monkeypatch.delenv("FB_SESSION_COOKIE_SECURE", raising=False)
get_settings.cache_clear()
try:
assert get_settings().session_cookie_secure is False
finally:
get_settings.cache_clear()
@pytest.mark.parametrize("val,expected", [
("true", True), ("True", True), ("1", True), ("yes", True), ("on", True),
("false", False), ("0", False), ("", False), ("nope", False),
])
def test_session_cookie_secure_parsing(monkeypatch, val, expected):
monkeypatch.setenv("FB_SESSION_COOKIE_SECURE", val)
get_settings.cache_clear()
try:
assert get_settings().session_cookie_secure is expected
finally:
get_settings.cache_clear()

View File

@@ -113,6 +113,23 @@ def test_rules_categorize(client):
assert txs[0]["category_id"] == cat["id"] 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): def test_patch_account_name(client):
acc = client.post("/api/accounts", headers=H, json={ acc = client.post("/api/accounts", headers=H, json={
"bank": "DKB", "iban": "DE71", "name": "DE71", "type": "giro"}).json() "bank": "DKB", "iban": "DE71", "name": "DE71", "type": "giro"}).json()

View File

@@ -0,0 +1,9 @@
from pathlib import Path
ENTRYPOINT = Path(__file__).resolve().parent.parent / "entrypoint.sh"
def test_entrypoint_enables_proxy_headers():
text = ENTRYPOINT.read_text()
assert "--proxy-headers" in text
assert "--forwarded-allow-ips" in text

View File

@@ -0,0 +1,51 @@
"""Regressionstests fuer das provisionierte Grafana-Dashboard finanzen.json.
Hintergrund: Kein Panel nutzt $__timeFilter, jedes liefert alle Zeilen und
Grafana beschneidet auf das Dashboard-Zeitfenster. Das Szenario-Vergleich-Panel
zeichnet die Projektion (Zukunft, horizon_days ab heute). Steht 'to' auf 'now',
wird die gesamte Kurve ab morgen abgeschnitten (nur die ~4 Tage <= heute
sichtbar, Y-Achse auf deren schmalen Bereich fixiert). Dieser Test haelt fest,
dass 'to' weit genug in die Zukunft reicht.
"""
import json
import re
from pathlib import Path
from app.config import get_settings
DASHBOARD = Path(__file__).resolve().parent.parent / "grafana" / "dashboards" / "finanzen.json"
_UNIT_DAYS = {"y": 365, "M": 30, "w": 7, "d": 1, "h": 1 / 24}
def _relative_to_days(expr: str) -> float:
"""Grafana-Relativausdruck ('now', 'now+19M', 'now-1y') -> vorzeichen-
behafteter Tages-Offset relativ zu 'now' (Zukunft positiv)."""
expr = expr.strip()
if expr == "now":
return 0.0
m = re.fullmatch(r"now([+-])(\d+)([yMwdh])", expr)
if not m:
raise ValueError(f"Unerwarteter Grafana-Zeitausdruck: {expr!r}")
sign = 1 if m.group(1) == "+" else -1
return sign * int(m.group(2)) * _UNIT_DAYS[m.group(3)]
def _load() -> dict:
return json.loads(DASHBOARD.read_text())
def test_dashboard_has_szenario_panel():
titles = [p.get("title") for p in _load().get("panels", [])]
assert "Szenario-Vergleich" in titles
def test_time_window_covers_projection_horizon():
d = _load()
horizon = get_settings().horizon_days
to_days = _relative_to_days(d["time"]["to"])
assert to_days >= horizon, (
f"time.to reicht nur {to_days} Tage in die Zukunft, deckt aber den "
f"Projektionshorizont von {horizon} Tagen nicht ab -> Projektionskurve "
f"wird abgeschnitten"
)

View File

@@ -1,6 +1,7 @@
from datetime import date, timedelta from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
from app.config import get_settings
from app.formats import eur from app.formats import eur
from app.models.tables import (Account, Loan, PlannedItem, RecurringItem, from app.models.tables import (Account, Loan, PlannedItem, RecurringItem,
Scenario, ScenarioModifier, ScenarioPlannedItem) Scenario, ScenarioModifier, ScenarioPlannedItem)
@@ -9,7 +10,7 @@ H = {"Authorization": "Bearer test-key"}
def test_pages_require_login(client): def test_pages_require_login(client):
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/hilfe"): for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/hilfe"):
r = client.get(path, follow_redirects=False) r = client.get(path, follow_redirects=False)
assert r.status_code in (302, 303), path assert r.status_code in (302, 303), path
assert r.headers["location"] == "/login" assert r.headers["location"] == "/login"
@@ -17,7 +18,7 @@ def test_pages_require_login(client):
def test_pages_render_after_login(client): def test_pages_render_after_login(client):
client.post("/login", data={"username": "admin", "password": "geheim"}) client.post("/login", data={"username": "admin", "password": "geheim"})
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/hilfe"): for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/hilfe"):
r = client.get(path) r = client.get(path)
assert r.status_code == 200 assert r.status_code == 200
assert "Finanzberatung" in r.text assert "Finanzberatung" in r.text
@@ -246,6 +247,33 @@ def test_planning_page_shows_empty_suggestions_hint(client):
assert r.status_code == 200 assert r.status_code == 200
assert "Vorschläge aus Buchungen" in r.text assert "Vorschläge aus Buchungen" in r.text
assert "Keine Vorschläge" in r.text assert "Keine Vorschläge" in r.text
assert ("Erkannt werden monatliche, vierteljährliche und jährliche "
"Serien; Betrag = jeweils letzte Buchung.") in r.text
def test_vorschlaege_zeigen_rhythmus_und_start(client, db):
# Synthetische vierteljaehrliche Serie relativ zu date.today(), da die
# Route suggest_recurring() ohne today-Injektion aufruft (echter
# Aktiv-Check gegen date.today()). Schrittweite ~91 Tage rueckwaerts,
# letzte Buchung 30 Tage vor heute (innerhalb des Aktiv-Fensters).
from app.models.tables import Transaction
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()
today = date.today()
booking_dates = [today - timedelta(days=d) for d in (303, 212, 121, 30)]
for d in booking_dates:
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.isoformat()}"))
db.commit()
r = client.get("/planung").text
letzte_buchung = booking_dates[-1]
assert "vierteljährlich" in r
assert letzte_buchung.strftime("%d.%m.%Y") in r
assert 'name="start_date"' in r
def test_salden_page_stichtag_and_month_overview(client, db): def test_salden_page_stichtag_and_month_overview(client, db):
@@ -317,15 +345,23 @@ def test_planung_hat_bearbeiten_formulare(client, db):
db.add(Loan(name="EditL", principal=Decimal("1000.00"), db.add(Loan(name="EditL", principal=Decimal("1000.00"),
annual_rate_pct=Decimal("4.50"), term_months=12, annual_rate_pct=Decimal("4.50"), term_months=12,
payout_date=date(2026, 8, 1), repayment_type="annuity")) payout_date=date(2026, 8, 1), repayment_type="annuity"))
db.add(Scenario(name="EditS", description="d"))
db.commit() db.commit()
r = client.get("/planung").text r = client.get("/planung").text
assert r.count(">Bearbeiten</button>") >= 4 assert r.count(">Bearbeiten</button>") >= 3
for fragment in ('hx-patch="/api/recurring/', 'hx-patch="/api/planned/', for fragment in ('hx-patch="/api/recurring/', 'hx-patch="/api/planned/',
'hx-patch="/api/loans/', 'hx-patch="/api/scenarios/'): 'hx-patch="/api/loans/'):
assert fragment in r, fragment assert fragment in r, fragment
def test_szenarien_hat_bearbeiten_formular(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(Scenario(name="EditS", description="d"))
db.commit()
r = client.get("/szenarien").text
assert r.count(">Bearbeiten</button>") >= 1
assert 'hx-patch="/api/scenarios/' in r
def test_salden_shows_ohne_anker_footnote(client, db): def test_salden_shows_ohne_anker_footnote(client, db):
# UX-Regel: Konten ohne Anker sind immer sichtbar als relative Werte # UX-Regel: Konten ohne Anker sind immer sichtbar als relative Werte
# gekennzeichnet, nicht stillschweigend wie geankerte Konten dargestellt. # gekennzeichnet, nicht stillschweigend wie geankerte Konten dargestellt.
@@ -350,7 +386,7 @@ def test_szenario_gui_ende_und_einmalzahlungen(client, db):
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Zufluss-GUI", db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Zufluss-GUI",
amount=Decimal("5000.00"), due=date(2026, 7, 30))) amount=Decimal("5000.00"), due=date(2026, 7, 30)))
db.commit() db.commit()
r = client.get("/planung").text r = client.get("/szenarien").text
assert 'value="ende"' in r # neue Art im Dropdown assert 'value="ende"' in r # neue Art im Dropdown
assert 'name="end_date"' in r # Datumsfeld im Modifikator-Formular assert 'name="end_date"' in r # Datumsfeld im Modifikator-Formular
assert "31.08.2026" in r # ende-Modifikator zeigt Datum assert "31.08.2026" in r # ende-Modifikator zeigt Datum
@@ -372,7 +408,7 @@ def test_szenario_eintraege_tabelle(client, db):
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Zufluss-Eintrag", db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Zufluss-Eintrag",
amount=Decimal("5000.00"), due=date(2026, 7, 30))) amount=Decimal("5000.00"), due=date(2026, 7, 30)))
db.commit() db.commit()
r = client.get("/planung").text r = client.get("/szenarien").text
assert "Einträge" in r assert "Einträge" in r
# alte details-Abschnitte sind ersetzt # alte details-Abschnitte sind ersetzt
assert "<summary>Modifikatoren</summary>" not in r assert "<summary>Modifikatoren</summary>" not in r
@@ -387,3 +423,63 @@ def test_szenario_eintraege_tabelle(client, db):
assert 'value="einmal"' in r assert 'value="einmal"' in r
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
assert "onEntryArtChange" in r and "onModKindChange" not in r assert "onEntryArtChange" in r and "onModKindChange" not in r
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"
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
def test_grafana_links_use_public_url_when_set(client, monkeypatch):
client.post("/login", data={"username": "admin", "password": "geheim"})
monkeypatch.setenv("FB_GRAFANA_PUBLIC_URL", "https://fb.example.de/grafana/")
get_settings.cache_clear()
try:
r = client.get("/")
assert r.status_code == 200
# iframe + Anmelde-Link nutzen die öffentliche URL, kein :8097 mehr.
assert "https://fb.example.de/grafana/d/finanzen/finanzen" in r.text
assert ":8097" not in r.text
# Nav-Link im base-Template ebenfalls.
assert 'href="https://fb.example.de/grafana"' in r.text
finally:
get_settings.cache_clear()
def test_grafana_links_fallback_to_host_port_when_unset(client, monkeypatch):
client.post("/login", data={"username": "admin", "password": "geheim"})
monkeypatch.delenv("FB_GRAFANA_PUBLIC_URL", raising=False)
get_settings.cache_clear()
try:
r = client.get("/")
assert r.status_code == 200
assert ":8097/d/finanzen/finanzen" in r.text
assert "fb.example.de" not in r.text
finally:
get_settings.cache_clear()

View File

@@ -1,8 +1,13 @@
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from itertools import count
from app.models.tables import Account, Category, RecurringItem, Transaction from app.models.tables import Account, Category, RecurringItem, Transaction
from app.services.suggestions import suggest_recurring from app.services.suggestions import _Series, _try_merge, suggest_recurring
# Feste Vergleichs-"heute" fuer alle Vorschlags-Tests (Ausbaustufe 9): macht
# den Aktiv-Check deterministisch, ohne echtes date.today() im Testlauf.
TODAY = date(2026, 7, 20)
H = {"Authorization": "Bearer test-key"} H = {"Authorization": "Bearer test-key"}
@@ -72,42 +77,60 @@ def test_loan_in_scenario_keeps_balance_positive(client, db):
assert Decimal(body["low_point_balance"]) > Decimal("0") assert Decimal(body["low_point_balance"]) > Decimal("0")
def _tx(acc, d, amount, counterparty, dedup, category_id=None): _iban_seq = count(1)
return Transaction(account_id=acc.id, booking_date=d, amount=Decimal(amount),
purpose="", counterparty=counterparty, status="confirmed",
dedup_hash=dedup, category_id=category_id) def _acc(db) -> int:
"""Legt ein Konto an und gibt dessen id zurueck. IBAN ist je Aufruf
eindeutig (falls ein Test mehrere Konten braucht), Praefix "DE" plus
laufende Nummer reicht dafuer aus."""
acc = Account(bank="dkb", iban=f"DE{next(_iban_seq):032d}"[:34], name="G", type="giro")
db.add(acc)
db.flush()
return acc.id
def _tx(db, acc_id, d, amount, cp, cat=None):
"""Legt eine bestaetigte Buchung an. dedup_hash aus den Nutzdaten
abgeleitet reicht fuer Testzwecke (muss nur innerhalb eines Tests
eindeutig sein)."""
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}"))
def test_suggest_recurring_three_consecutive_months_with_year_wrap(db): def test_suggest_recurring_three_consecutive_months_with_year_wrap(db):
acc = Account(bank="dkb", iban="DE01", name="G", type="giro") acc = _acc(db)
db.add(acc)
db.flush()
cat = Category(name="Miete") cat = Category(name="Miete")
db.add(cat) db.add(cat)
db.flush() db.flush()
# Dez 2025 -> Jan 2026 -> Feb 2026: 3 aufeinanderfolgende Monate ueber den # Dez 2025 -> Jan 2026 -> Feb 2026: 3 aufeinanderfolgende Monate ueber den
# Jahreswechsel hinweg (prueft die Monats-Linearisierung y*12+m). # Jahreswechsel hinweg, gleichmaessiger 31-Tage-Abstand (Median im
db.add(_tx(acc, date(2025, 12, 1), "-600.00", "Vermieter", "h1", cat.id)) # monatlichen Fenster 25-36 Tage; die alte Jahreswechsel-Pruefung galt der
db.add(_tx(acc, date(2026, 1, 15), "-600.00", "Vermieter", "h2", cat.id)) # Monats-Linearisierung, die es im neuen Tage-basierten Median-Ansatz
db.add(_tx(acc, date(2026, 2, 28), "-600.00", "Vermieter", "h3", cat.id)) # nicht mehr braucht).
_tx(db, acc, date(2025, 12, 1), "-600.00", "Vermieter", cat.id)
_tx(db, acc, date(2026, 1, 1), "-600.00", "Vermieter", cat.id)
_tx(db, acc, date(2026, 2, 1), "-600.00", "Vermieter", cat.id)
db.commit() db.commit()
out = suggest_recurring(db) out = suggest_recurring(db, today=date(2026, 2, 10))
# Median der Tage [1, 15, 28] = 15; Betrag unveraendert uebernommen. # due_day = Tag der NEUESTEN Buchung (nicht mehr Median, Spec Schritt 7);
# Betrag unveraendert, keine Preissteigerung -> hinweis leer.
assert out == [{"name": "Vermieter", "amount": Decimal("-600.00"), assert out == [{"name": "Vermieter", "amount": Decimal("-600.00"),
"rhythm": "monthly", "due_day": 15, "category_id": cat.id}] "rhythm": "monthly", "due_day": 1, "start_date": None,
"category_id": cat.id, "hinweis": ""}]
def test_suggest_recurring_two_months_no_suggestion(db): def test_suggest_recurring_two_months_no_suggestion(db):
acc = Account(bank="dkb", iban="DE01", name="G", type="giro") acc = _acc(db)
db.add(acc) _tx(db, acc, date(2026, 3, 10), "-50.00", "Zweimonatig")
db.flush() _tx(db, acc, date(2026, 4, 10), "-50.00", "Zweimonatig")
db.add(_tx(acc, date(2026, 3, 10), "-50.00", "Zweimonatig", "h1"))
db.add(_tx(acc, date(2026, 4, 10), "-50.00", "Zweimonatig", "h2"))
db.commit() db.commit()
assert suggest_recurring(db) == [] # Nur 2 Belege: Mindestbelege fuer monthly (>=3) nicht erreicht.
assert suggest_recurring(db, today=date(2026, 4, 20)) == []
def test_recurring_ende_vor_start_wird_abgelehnt(client): def test_recurring_ende_vor_start_wird_abgelehnt(client):
@@ -135,15 +158,213 @@ def test_recurring_start_ende_roundtrip_und_patch_validierung(client):
def test_suggest_recurring_excludes_existing_recurring_item(db): def test_suggest_recurring_excludes_existing_recurring_item(db):
acc = Account(bank="dkb", iban="DE01", name="G", type="giro") acc = _acc(db)
db.add(acc) _tx(db, acc, date(2026, 1, 5), "-30.00", "Streaming")
db.flush() _tx(db, acc, date(2026, 2, 5), "-30.00", "Streaming")
db.add(_tx(acc, date(2026, 1, 5), "-30.00", "Streaming", "h1")) _tx(db, acc, date(2026, 3, 5), "-30.00", "Streaming")
db.add(_tx(acc, date(2026, 2, 5), "-30.00", "Streaming", "h2"))
db.add(_tx(acc, date(2026, 3, 5), "-30.00", "Streaming", "h3"))
db.add(RecurringItem(name="Streaming", amount=Decimal("-30.00"), db.add(RecurringItem(name="Streaming", amount=Decimal("-30.00"),
rhythm="monthly", due_day=5)) rhythm="monthly", due_day=5))
db.commit() db.commit()
# Gleicher Name + Betrag wie ein bereits vorhandenes RecurringItem -> ausgelassen. # Gleicher Name wie ein bereits vorhandenes RecurringItem -> Bestandsabgleich
assert suggest_recurring(db) == [] # (Schritt 8, Namens-Match) greift, unabhaengig vom Betrag.
assert suggest_recurring(db, today=date(2026, 3, 20)) == []
# --------------------------------------------------- Ausbaustufe 9: Algorithmus v2
# Synthetische Faelle aus der Spec (siehe
# docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md).
def test_suggest_letzter_betrag_bei_preiserhoehung(db):
acc = _acc(db)
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 (deutsches Format)
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")}
# Nachtrag 3b: beide Vertraege sind fuer sich genommen stabile,
# qualifizierte Serien - die jeweils neuere Buchung des ANDEREN Vertrags
# gehoert selbst zu einer qualifizierten Serie und darf deshalb NICHT als
# "abgetrennte neueste Buchung" gewertet werden (sonst waere einer der
# beiden faelschlich als "volatil" markiert, nur weil der andere Vertrag
# einen Tag spaeter faellig ist).
assert all(s["hinweis"] == "" for s in out)
def test_try_merge_kombiniert_serien_ueber_gruppengrenzen():
# Direkter, isolierter Test der Merge-Mechanik (Schritt 6): siehe Report
# fuer den rechnerischen Nachweis, dass ein End-to-End-Szenario, in dem
# die ALTE und die NEUE Serie GLEICHZEITIG unabhaengig voneinander den
# Aktiv-Check bestehen, fuer keinen der drei Rhythmen innerhalb von
# WINDOW_DAYS=460 konstruierbar ist (die alte Serie ist beim Aktiv-Check
# immer laengst "tot", sobald die neue genug eigene Belege hat, bzw. bei
# yearly passt die noetige Gesamtspanne nicht ins Fenster). Deshalb hier
# `_try_merge` direkt gegen zwei synthetische `_Series` geprueft, ganz ohne
# DB/Fenster/Aktiv-Check-Interaktion.
a = _Series(items=[
Transaction(booking_date=date(2026, 1, 3), amount=Decimal("-50.00"),
counterparty="Alte Firma GmbH", category_id=None),
Transaction(booking_date=date(2026, 2, 3), amount=Decimal("-50.00"),
counterparty="Alte Firma GmbH", category_id=None),
Transaction(booking_date=date(2026, 3, 3), amount=Decimal("-50.00"),
counterparty="Alte Firma GmbH", category_id=None),
], rhythm="monthly")
b = _Series(items=[
Transaction(booking_date=date(2026, 4, 5), amount=Decimal("-52.00"),
counterparty="Neue Firma GmbH", category_id=None),
Transaction(booking_date=date(2026, 5, 5), amount=Decimal("-52.00"),
counterparty="Neue Firma GmbH", category_id=None),
Transaction(booking_date=date(2026, 6, 5), amount=Decimal("-52.00"),
counterparty="Neue Firma GmbH", category_id=None),
], rhythm="monthly")
merged = _try_merge([a, b])
# Luecke A-Ende->B-Anfang = 33 Tage (in [12,48]), Faelligkeitstag 3 vs 5
# (Differenz 2 <= 3), Betrag +4% (<=25%) -> Bedingungen erfuellt, genau
# EINE kombinierte Serie mit allen 6 Buchungen, juengste zuerst.
assert len(merged) == 1
assert len(merged[0].items) == 6
assert merged[0].last.counterparty == "Neue Firma GmbH"
def test_suggest_umfirmierung_merge_verschiebt_kategorie_mehrheit(db):
# End-to-End-Nachweis, dass Schritt 6 tatsaechlich in `suggest_recurring`
# verdrahtet ist: da die Anzahl der Vorschlaege sich (bewiesenermassen,
# siehe Report) end-to-end NICHT als Diskriminator eignet (die alte Serie
# faellt so oder so per Aktiv-Check heraus), wird hier die
# Kategorie-Mehrheit als Diskriminator genutzt - die haengt direkt davon
# ab, ob die Buchungen der alten Serie ueber den Merge in die Zaehlung
# eingehen. Alte Serie: 4 Buchungen Kategorie A. Neue Serie: 3 Buchungen
# Kategorie B. Ohne Merge zaehlen nur die 3 B-Buchungen (Mehrheit B). Mit
# Merge kommen die 4 A-Buchungen dazu und kippen die Mehrheit auf A.
acc = _acc(db)
cat_a = Category(name="Alt-Kategorie")
cat_b = Category(name="Neu-Kategorie")
db.add(cat_a)
db.add(cat_b)
db.flush()
for d in [date(2026, 1, 3), date(2026, 2, 3), date(2026, 3, 3), date(2026, 4, 3)]:
_tx(db, acc, d, "-50.00", "Alte Firma GmbH", cat_a.id)
for d in [date(2026, 5, 5), date(2026, 6, 5), date(2026, 7, 5)]:
_tx(db, acc, d, "-52.00", "Neue Firma GmbH", cat_b.id)
db.commit()
out = suggest_recurring(db, today=date(2026, 7, 20))
assert len(out) == 1
assert out[0]["name"] == "Neue Firma GmbH" and out[0]["amount"] == Decimal("-52.00")
# Kategorie-Mehrheit kippt durch den Merge von B (3) auf A (4):
assert out[0]["category_id"] == cat_a.id
# ------------------------------------------------- Nachtrag 4 (Live-Gate-Fund)
# Live-Gate-Fund (Muster, keine echten Kontodaten - Namen/Betraege hier rein
# synthetisch): ein kuratiertes Sammel-Fixposten unter Alias-Namen des
# Anbieters ("Kreditkarten-Abrechnung ... (variabel)") deckte den vom
# Algorithmus erkannten Vorschlag desselben Anbieters unter dessen
# regulaerem Empfaenger-Namen nicht ab, weil weder Substring- noch
# Betrags-Toleranz-Regel griffen.
def test_suggest_alias_recurring_item_token_match(db):
acc = _acc(db)
db.add(RecurringItem(name="Kreditkarten-Abrechnung Musterbank (variabel)",
amount=Decimal("-250.00"), rhythm="monthly", due_day=7))
for d, a in [(date(2026, 4, 5), "-560.00"), (date(2026, 5, 5), "-575.00"),
(date(2026, 6, 5), "-590.00"), (date(2026, 7, 5), "-575.00")]:
_tx(db, acc, d, a, "Musterbank Neustadt eG")
db.commit()
# Substring-Match (a) schlaegt fehl (kein Teilstring gemeinsam), Betrags-
# Toleranz (b) auch (-575 vs. -250.00, >10%) - erst der Token-Match (c)
# ueber das gemeinsame Token "musterbank" (Rhythmus gleich, due_day 5 vs. 7
# -> Differenz 2 <= 2) deckt den Vorschlag ab.
assert suggest_recurring(db, today=TODAY) == []
def test_suggest_volatilitaetshinweis_bei_abgetrennter_neuester_buchung(db):
acc = _acc(db)
for d in [date(2026, 1, 5), date(2026, 2, 5), date(2026, 3, 5), date(2026, 4, 5)]:
_tx(db, acc, d, "-200.00", "Schwankender Anbieter GmbH")
# Neueste Buchung weicht >35% vom Serien-Betrag ab -> eigener Cluster,
# klassifiziert selbst nicht (nur 1 Buchung) -> die vorgeschlagene Serie
# bleibt die -200.00-Serie, aber mit Volatilitaets-Warnhinweis.
_tx(db, acc, date(2026, 5, 5), "-600.00", "Schwankender Anbieter GmbH")
db.commit()
out = suggest_recurring(db, today=date(2026, 5, 20))
assert len(out) == 1
assert out[0]["amount"] == Decimal("-200.00") # Betrag NICHT durch die 600er-Buchung verfaelscht
assert "schwanken" in out[0]["hinweis"].lower()

53
stop_finance_pod.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/bin/bash
# Fährt den finance_pod kontrolliert herunter: stoppt den systemd-User-Service
# (der den Pod verwaltet) und danach - falls noch vorhanden - den Pod selbst.
# Gedacht als expliziter Wartungsschritt, z.B. vor dem Editieren der .env
# (Reverse-Proxy-Umzug, siehe docs/reverse-proxy.md) und dem erneuten Ausführen
# von ./create_pod_finance.sh.
#
# KEIN Datenverlust: Postgres-/Grafana-Daten liegen im Bind-Mount
# ~/.local/share/finance_pod und bleiben beim Stoppen erhalten. Der Pod wird
# nur GESTOPPT, nicht entfernt - das Entfernen/Neuerstellen erledigt
# create_pod_finance.sh ohnehin bei jedem Lauf.
set -e
POD_NAME='finance_pod'
SERVICE="pod-${POD_NAME}.service"
# 1. systemd-User-Service stoppen (er startet und verwaltet den Pod). Nur
# stoppen, nicht disablen - create_pod_finance.sh macht am Ende wieder
# `enable --now`. `stop` allein hält den Service bis dahin unten.
if systemctl --user list-units --type=service --all 2>/dev/null | \
grep -q "$SERVICE"; then
echo "Stoppe systemd-User-Service $SERVICE ..."
systemctl --user stop "$SERVICE" || true
else
echo "Service $SERVICE ist nicht registriert - nichts zu stoppen."
fi
# 2. Falls der Pod noch läuft (z.B. unmanaged außerhalb systemd gestartet),
# ebenfalls stoppen. --ignore: kein Fehler, wenn er nicht existiert.
if podman pod exists "$POD_NAME"; then
echo "Stoppe Pod $POD_NAME ..."
podman pod stop --ignore --time 15 "$POD_NAME" || true
else
echo "Pod $POD_NAME existiert nicht."
fi
# 3. Status zur Kontrolle ausgeben.
echo
echo "--- Status nach dem Herunterfahren ---"
printf 'Service aktiv? '
systemctl --user is-active "$SERVICE" || true
echo "Pod/Container:"
podman pod ps --filter "name=$POD_NAME" || true
echo
echo "Fertig heruntergefahren."
# echo "Fertig heruntergefahren. Nächste Schritte:"
# echo " 1. .env editieren: \$EDITOR ~/.local/share/finance_pod/.env"
# echo " (Reverse-Proxy: FB_GRAFANA_PUBLIC_URL + FB_SESSION_COOKIE_SECURE"
# echo " single-quoted setzen - siehe docs/reverse-proxy.md)"
# echo " 2. Neu ausrollen: ./create_pod_finance.sh"