Compare commits

...

33 Commits

Author SHA256 Message Date
8eee62ee19 fix: Datenschutz-Scrub in Tests/Kommentaren + Spec-Nachtrag Vorschlags-Algorithmus
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:59:29 +02:00
7ca64c91fe docs: Plan-Haekchen Ausbaustufe 9
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:49:38 +02:00
aff8e9b28d fix: Bestandsabgleich per Token-Match und Volatilitaets-Hinweis fuer Vorschlaege
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:46:10 +02:00
a520390e31 chore: Version 0.9.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:28:29 +02:00
220d1fc802 fix: hinweis-Betrag im Vorschlag deutsch formatiert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:28:15 +02:00
bf3f56118e feat: Vorschlaege mit Rhythmus, Start und Hinweis
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:26:48 +02:00
28d1267f0c feat: Vorschlags-Algorithmus v2 (Rhythmen, letzter Betrag, Aktiv-Check, Merge)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:20:04 +02:00
e002d8205f docs: Spec + Plan Ausbaustufe 9 (Vorschlags-Algorithmus v2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:30:31 +02:00
353ff6056c docs: Plan-Haekchen Ausbaustufe 7
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:21:11 +02:00
d6659d8b18 chore: Version 0.8.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:18:15 +02:00
4431640eb2 feat: Kategorien-Verwaltung auf der Admin-Seite
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:17:48 +02:00
b21d7b9839 feat: Eintrags-Formular mit Paar-Layout und Gesperrt-Kennzeichnung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:06:32 +02:00
f03249bff1 feat: eigene Szenarien-Seite /szenarien
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:59:03 +02:00
7b273e218c docs: Spec + Plan Ausbaustufe 7 (Szenarien-Seite, Formular-UX, Kategorien)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:50:43 +02:00
974ef5c208 docs: Plan-Haekchen Ausbaustufe 6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:54:19 +02:00
421899b922 chore: Version 0.7.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:51:17 +02:00
cb44194daf feat: Szenario-Eintraege als eine editierbare Tabelle
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:51:04 +02:00
4843f2e130 feat: PATCH-Endpunkte fuer Szenario-Modifikatoren und -Einmalzahlungen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:43:01 +02:00
1708abeb91 docs: Implementierungsplan Ausbaustufe 6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:39:37 +02:00
39c6a65b1d docs: Design-Spec Ausbaustufe 6 (Szenario-Eintraege-Tabelle)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:37:45 +02:00
2282aa3922 docs: Plan-Haekchen Task 9 (Demo-Szenario live verifiziert)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:05:26 +02:00
f1b5d442bd docs: Ledger Ausbaustufe 5 Tasks 1-8 + Plan-Haekchen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:02:29 +02:00
a192866cc4 chore: Version 0.6.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:59:58 +02:00
033c41f449 feat: Szenario-GUI mit Ende-Modifikator und Einmalzahlungen je Szenario
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:59:42 +02:00
13795fce25 feat: API fuer Szenario-Ende-Modifikator und szenario-eigene Einmalzahlungen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:52:15 +02:00
5519037688 feat: Engine+Schema fuer Szenario-Ende und szenario-eigene Einmalzahlungen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:46:52 +02:00
aa1884c31e feat: Inline-Bearbeiten fuer Fixposten, Einmalposten, Kredite und Szenario-Kopf
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:42:04 +02:00
cbcf2e0439 feat: Start/Ende fuer Fixposten in der GUI + Datumsvalidierung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:35:32 +02:00
805cb4d852 feat: Betragseingabe mit Komma (data-type=amount in json-form)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:30:58 +02:00
fe0e1b5e93 feat: deutsche Betragsanzeige (eur-Filter) und deutsche GUI-Labels
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:23:26 +02:00
bd9df8ab8b docs: Implementierungsplan Ausbaustufe 5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:13:21 +02:00
8e425840e8 docs: Design-Spec Ausbaustufe 5 (deutsche Formate, Posten-Bearbeitung, Szenario-Ende)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:45:39 +02:00
8bbabf6657 docs: Inline-Haekchen Ausbaustufe 4 vervollstaendigt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 08:47:54 +02:00
38 changed files with 4538 additions and 273 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
0.5.0
0.9.0

View File

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

View File

@@ -34,6 +34,7 @@ class PlainModifier:
target_id: int
kind: str
value: Decimal
end_date: date | None = None
def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None,
@@ -55,6 +56,19 @@ def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None
return amount
def _effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None:
"""Fruehestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren."""
end = r.end_date
for m in modifiers:
if m.kind != "ende" or m.end_date is None:
continue
hit = ((m.target_type == "category" and r.category_id == m.target_id)
or (m.target_type == "recurring" and r.id == m.target_id))
if hit:
end = m.end_date if end is None else min(end, m.end_date)
return end
def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned],
loan_schedules: list[list[Installment]],
loan_payouts: list[tuple[date, Decimal]],
@@ -66,7 +80,7 @@ def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned]
if amount is None:
continue
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
r.start_date, r.end_date):
r.start_date, _effective_end(r, modifiers)):
flows.append((d, amount))
for p in planned:
amount = _modified(p.amount, p.category_id, None, modifiers)

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

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

View File

@@ -123,6 +123,21 @@ class ScenarioModifier(Base):
target_id: Mapped[int] = mapped_column(Integer)
kind: Mapped[str] = mapped_column(String(20))
value: Mapped[Decimal] = mapped_column(MONEY, default=Decimal("0"))
# Nur fuer kind="ende" gesetzt: Datum, an dem der Ziel-Posten innerhalb
# dieses Szenarios endet (Ausbaustufe 5).
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
class ScenarioPlannedItem(Base):
"""Einmalzahlung, die NUR in einem bestimmten Szenario zaehlt -
unabhaengig von Scenario.include_planned (das steuert nur die globalen
PlannedItems). Ausbaustufe 5."""
__tablename__ = "scenario_planned_items"
id: Mapped[int] = mapped_column(primary_key=True)
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), index=True)
name: Mapped[str] = mapped_column(String(200))
amount: Mapped[Decimal] = mapped_column(MONEY)
due: Mapped[date] = mapped_column(Date)
class ProjectionPoint(Base):

View File

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

View File

@@ -48,6 +48,22 @@ def create_category(data: CategoryIn, session: Session = Depends(get_session)):
return CategoryOut.model_validate(cat)
@router.patch("/categories/{category_id}", response_model=CategoryOut)
def patch_category(category_id: int, data: CategoryIn,
session: Session = Depends(get_session)):
cat = session.get(Category, category_id)
if cat is None:
raise HTTPException(404, "Kategorie nicht gefunden")
clash = session.execute(
select(Category).where(Category.name == data.name)).scalar()
if clash is not None and clash.id != category_id:
raise HTTPException(409, "Kategorie existiert bereits")
cat.name = data.name
session.commit()
session.refresh(cat)
return CategoryOut.model_validate(cat)
@router.get("/category-rules", response_model=list[CategoryRuleOut])
def list_category_rules(session: Session = Depends(get_session)):
rules = session.execute(

View File

@@ -10,9 +10,11 @@ from app.auth import COOKIE, session_valid
from app.db import get_session
from app.engine.loans import add_months
from app.engine.recurrence import occurrences
from app.formats import de_label, eur
from app.models.tables import (Account, Category, PlannedItem,
ProjectionResult, RecurringItem,
ScenarioLoan, ScenarioModifier, Transaction)
ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem, Transaction)
from app.routers.imports import list_imports as _list_imports
from app.routers.imports import preview as _preview_import
from app.routers.planning import list_loans as _list_loans
@@ -28,6 +30,8 @@ PAGE_SIZE = 50
templates = Jinja2Templates(directory="app/templates")
templates.env.globals["app_version"] = get_version()
templates.env.filters["eur"] = eur
templates.env.filters["de_label"] = de_label
router = APIRouter()
@@ -260,11 +264,17 @@ def _scenario_rows(session: Session, scenarios: list, loans: list) -> list[dict]
}
modifiers = session.execute(
select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id)
.order_by(ScenarioModifier.id)
).scalars().all()
planned_items = session.execute(
select(ScenarioPlannedItem)
.where(ScenarioPlannedItem.scenario_id == sc.id)
.order_by(ScenarioPlannedItem.due)).scalars().all()
rows.append({
"scenario": sc,
"assigned_loan_ids": assigned_loan_ids,
"modifiers": modifiers,
"planned_items": planned_items,
"result": session.get(ProjectionResult, sc.id),
})
return rows
@@ -281,7 +291,6 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
recurring = _list_recurring(session=session)
planned = _list_planned(session=session)
loans = _list_loans(session=session)
scenarios = _list_scenarios(session=session)
return templates.TemplateResponse(request, "planning.html", {
"categories": categories,
"category_names": {c.id: c.name for c in categories},
@@ -290,8 +299,23 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
"suggestions": _recurring_suggestions(session=session),
"planned": planned,
"loans": loans,
"scenario_rows": _scenario_rows(session, scenarios, loans),
"rhythms": ["monthly", "quarterly", "yearly"],
"repayment_types": ["annuity", "bullet"],
"modifier_kinds": ["percent", "absolute", "remove"],
})
@router.get("/szenarien", dependencies=[Depends(gui_session)])
def szenarien_page(request: Request, session: Session = Depends(get_session)):
categories = session.execute(select(Category)).scalars().all()
recurring = _list_recurring(session=session)
loans = _list_loans(session=session)
scenarios = _list_scenarios(session=session)
return templates.TemplateResponse(request, "szenarien.html", {
"categories": categories,
"category_names": {c.id: c.name for c in categories},
"recurring": recurring,
"recurring_names": {r.id: r.name for r in recurring},
"loans": loans,
"scenario_rows": _scenario_rows(session, scenarios, loans),
"modifier_kinds": ["percent", "absolute", "remove", "ende"],
})

View File

@@ -3,7 +3,7 @@ from decimal import Decimal
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
@@ -28,6 +28,13 @@ class RecurringIn(BaseModel):
end_date: date | None = None
category_id: int | None = None
@model_validator(mode="after")
def _ende_nicht_vor_start(self):
if (self.start_date is not None and self.end_date is not None
and self.end_date < self.start_date):
raise ValueError("Ende darf nicht vor Start liegen")
return self
class RecurringOut(RecurringIn):
model_config = ConfigDict(from_attributes=True)
@@ -49,7 +56,9 @@ class SuggestionOut(BaseModel):
amount: Decimal
rhythm: str
due_day: int
start_date: date | None = None
category_id: int | None = None
hinweis: str = ""
def _check_category(session: Session, category_id: int | None) -> None:
@@ -90,6 +99,10 @@ def patch_recurring(item_id: int, data: RecurringPatch,
_check_category(session, fields["category_id"])
for key, value in fields.items():
setattr(item, key, value)
if (item.start_date is not None and item.end_date is not None
and item.end_date < item.start_date):
session.rollback()
raise HTTPException(422, "Ende darf nicht vor Start liegen")
session.commit()
session.refresh(item)
return RecurringOut.model_validate(item)

View File

@@ -3,7 +3,7 @@ from decimal import Decimal
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
@@ -11,7 +11,8 @@ from app.auth import require_auth
from app.config import get_settings
from app.db import get_session
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
Scenario, ScenarioLoan, ScenarioModifier)
Scenario, ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem)
from app.services.projection_service import run_projection
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
@@ -36,12 +37,26 @@ class ScenarioPatch(BaseModel):
include_recurring: bool | None = None
include_planned: bool | None = None
@field_validator("description")
@classmethod
def _leere_beschreibung(cls, v):
return "" if v is None else v
class ModifierIn(BaseModel):
target_type: Literal["category", "recurring"]
target_id: int
kind: Literal["percent", "absolute", "remove"]
kind: Literal["percent", "absolute", "remove", "ende"]
value: Decimal = Decimal("0")
end_date: date | None = None
@model_validator(mode="after")
def _ende_braucht_datum(self):
if self.kind == "ende" and self.end_date is None:
raise ValueError("Art »Ende« erfordert ein Datum")
if self.kind != "ende":
self.end_date = None
return self
class ModifierOut(ModifierIn):
@@ -117,6 +132,8 @@ def delete_scenario(scenario_id: int, session: Session = Depends(get_session)):
ScenarioLoan.scenario_id == scenario_id))
session.execute(delete(ScenarioModifier).where(
ScenarioModifier.scenario_id == scenario_id))
session.execute(delete(ScenarioPlannedItem).where(
ScenarioPlannedItem.scenario_id == scenario_id))
session.delete(scenario)
session.commit()
@@ -164,6 +181,80 @@ def delete_modifier(scenario_id: int, mod_id: int,
session.commit()
@router.patch("/{scenario_id}/modifiers/{mod_id}", response_model=ModifierOut)
def patch_modifier(scenario_id: int, mod_id: int, data: ModifierIn,
session: Session = Depends(get_session)):
# Full-Body-Update: das Edit-Formular sendet immer alle Felder des Typs.
_get_scenario(session, scenario_id)
modifier = session.get(ScenarioModifier, mod_id)
if modifier is None or modifier.scenario_id != scenario_id:
raise HTTPException(404, "Modifikator nicht gefunden")
for key, value in data.model_dump().items():
setattr(modifier, key, value)
session.commit()
session.refresh(modifier)
return ModifierOut.model_validate(modifier)
class ScenarioPlannedIn(BaseModel):
name: str
amount: Decimal
due: date
class ScenarioPlannedOut(ScenarioPlannedIn):
model_config = ConfigDict(from_attributes=True)
id: int
scenario_id: int
@router.get("/{scenario_id}/planned", response_model=list[ScenarioPlannedOut])
def list_scenario_planned(scenario_id: int, session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
return [ScenarioPlannedOut.model_validate(p) for p in session.execute(
select(ScenarioPlannedItem)
.where(ScenarioPlannedItem.scenario_id == scenario_id)
.order_by(ScenarioPlannedItem.due)).scalars()]
@router.post("/{scenario_id}/planned", response_model=ScenarioPlannedOut,
status_code=201)
def add_scenario_planned(scenario_id: int, data: ScenarioPlannedIn,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
item = ScenarioPlannedItem(scenario_id=scenario_id, **data.model_dump())
session.add(item)
session.commit()
session.refresh(item)
return ScenarioPlannedOut.model_validate(item)
@router.delete("/{scenario_id}/planned/{item_id}", status_code=204)
def delete_scenario_planned(scenario_id: int, item_id: int,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
item = session.get(ScenarioPlannedItem, item_id)
if item is None or item.scenario_id != scenario_id:
raise HTTPException(404, "Einmalzahlung nicht gefunden")
session.delete(item)
session.commit()
@router.patch("/{scenario_id}/planned/{item_id}", response_model=ScenarioPlannedOut)
def patch_scenario_planned(scenario_id: int, item_id: int, data: ScenarioPlannedIn,
session: Session = Depends(get_session)):
# Full-Body-Update, Muster patch_modifier.
_get_scenario(session, scenario_id)
item = session.get(ScenarioPlannedItem, item_id)
if item is None or item.scenario_id != scenario_id:
raise HTTPException(404, "Einmalzahlung nicht gefunden")
for key, value in data.model_dump().items():
setattr(item, key, value)
session.commit()
session.refresh(item)
return ScenarioPlannedOut.model_validate(item)
@router.post("/{scenario_id}/project", response_model=ProjectionOut)
def project_scenario(scenario_id: int, horizon_days: int | None = None,
start_date: date | None = None,

View File

@@ -12,7 +12,8 @@ from app.engine.scenario import (PlainModifier, PlainPlanned, PlainRecurring,
build_cashflows)
from app.models.tables import (Loan, PlannedItem, ProjectionPoint,
ProjectionResult, RecurringItem, Scenario,
ScenarioLoan, ScenarioModifier)
ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem)
from app.services.balances import total_balance
@@ -28,6 +29,11 @@ def run_projection(session: Session, scenario: Scenario, horizon_days: int,
if scenario.include_planned:
planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
for p in session.execute(select(PlannedItem)).scalars()]
# Szenario-eigene Einmalzahlungen zaehlen IMMER fuer dieses Szenario -
# include_planned steuert nur die globalen PlannedItems.
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, None)
for p in session.execute(select(ScenarioPlannedItem).where(
ScenarioPlannedItem.scenario_id == scenario.id)).scalars()]
loans = session.execute(
select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id)
.where(ScenarioLoan.scenario_id == scenario.id)).scalars().all()
@@ -35,7 +41,8 @@ def run_projection(session: Session, scenario: Scenario, horizon_days: int,
l.term_months, l.payout_date, l.repayment_type)
for l in loans]
payouts = [(l.payout_date, Decimal(l.principal)) for l in loans]
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind, Decimal(m.value))
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind,
Decimal(m.value), m.end_date)
for m in session.execute(select(ScenarioModifier).where(
ScenarioModifier.scenario_id == scenario.id)).scalars()]
flows = build_cashflows(recurring, planned, schedules, payouts, modifiers,

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
from collections import Counter, defaultdict
from collections import Counter
from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.formats import eur
from app.models.tables import RecurringItem, Transaction
# Betrachtungsfenster (Schritt 1): ~15 Monate. Muss mindestens die zwei
# Belege einer jaehrlichen Serie (bis zu 400 Tage auseinander) plus etwas
# Puffer fuer Cluster-/Merge-Bildung abdecken.
WINDOW_DAYS = 460
def _max_consecutive_months(months: list[tuple[int, int]]) -> int:
if not months:
return 0
best = current = 1
for prev, cur in zip(months, months[1:]):
prev_idx = prev[0] * 12 + prev[1]
cur_idx = cur[0] * 12 + cur[1]
current = current + 1 if cur_idx == prev_idx + 1 else 1
best = max(best, current)
return best
# Rhythmus-Tabelle: (min_tage, max_tage, mindestbelege) je Rhythmus. Der
# Median der Buchungsabstaende einer Serie muss ins Intervall fallen, UND es
# muessen mindestens so viele Buchungen vorliegen (ein einzelner Zufallstreffer
# mit "passendem" Abstand soll nicht als Serie gelten).
RHYTHMS: dict[str, tuple[int, int, int]] = {
"monthly": (25, 36, 3),
"quarterly": (80, 105, 3),
"yearly": (330, 400, 2),
}
# Nominelle Schrittweite je Rhythmus in Tagen - Referenzwert fuer Aktiv-Check
# und Merge-Luecken-Fenster (Schritt 5/6).
STEP_DAYS: dict[str, int] = {"monthly": 30, "quarterly": 91, "yearly": 365}
# Aktiv-Check (Schritt 5): die letzte Buchung darf hoechstens das 1,75-fache
# der Rhythmus-Schrittweite zurueckliegen, sonst gilt die Serie als beendet
# ("Leiche") und wird nicht vorgeschlagen. Als Fraction 7/4 ausgedrueckt und
# ganzzahlig verglichen (delta_tage * 4 <= schrittweite * 7), damit keine
# Gleitkomma-Rundung ueber "aktiv"/"inaktiv" entscheidet.
ACTIVITY_FACTOR_NUM = 7
ACTIVITY_FACTOR_DEN = 4
# Relative Toleranzen (immer als Decimal verglichen, nie float):
CLUSTER_TOL = Decimal("0.35") # Schritt 3: Betrags-Cluster (haelt Preisdrift zusammen)
MERGE_TOL = Decimal("0.25") # Schritt 6: Umfirmierungs-Merge ueber Gruppengrenzen
BESTAND_TOL = Decimal("0.10") # Schritt 8: Bestandsabgleich gegen RecurringItem
# Merge-Luecke (Schritt 6): die Zeit zwischen dem Ende von Serie A und dem
# Beginn von Serie B muss zwischen dem 0,4- und 1,6-fachen der
# Rhythmus-Schrittweite liegen (als ganzzahlige Bruchvergleiche, aus
# demselben Grund wie beim Aktiv-Check).
MERGE_GAP_MIN_NUM, MERGE_GAP_MIN_DEN = 4, 10 # 0.4
MERGE_GAP_MAX_NUM, MERGE_GAP_MAX_DEN = 16, 10 # 1.6
MERGE_DUE_DAY_TOL = 3 # Schritt 6: Faelligkeitstag-Toleranz in Tagen
BESTAND_DUE_DAY_TOL = 2 # Schritt 8: Faelligkeitstag-Toleranz in Tagen
# Bestandsabgleich, Token-Match (Live-Gate-Fund, Nachtrag 4): kuratierte
# Fixposten tragen haeufig einen Alias-/Variabel-Namen, der weder Substring
# noch betragsaehnlich zum automatisch erkannten Vorschlag ist (Muster:
# ein Sammel-Fixposten fuer eine Kreditkartenabrechnung mit variablem Betrag
# unter einem Alias-Namen des Anbieters deckt den vom Algorithmus erkannten
# Vorschlag desselben Anbieters unter seinem regulaeren Empfaenger-Namen
# nicht ab, weil weder Substring noch Betrags-Toleranz greifen). Ein
# gemeinsames, hinreichend spezifisches Namens-Token (>=5 Zeichen, um
# generische Woerter wie "Bank" nicht faelschlich matchen zu lassen) bei
# gleichem Rhythmus und nahem Faelligkeitstag gilt als ausreichendes Indiz
# fuer denselben Fixposten.
TOKEN_MIN_LEN = 5
# Volatilitaets-Hinweis (Live-Gate A9-Fund, Nachtrag 4): wenn der
# Betrags-Cluster-Split (Schritt 3) die neueste Buchung der Empfaenger-Gruppe
# abgetrennt hat (weil sie zu stark vom Serien-Betrag abweicht), ist der
# vorgeschlagene Betrag ggf. schon wieder veraltet - keine Unterdrueckung,
# nur ein Warnhinweis fuer die Nutzerin/den Nutzer.
VOLATILITAETS_HINWEIS = "Beträge schwanken stark letzte Buchung weicht ab"
def 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(
select(Transaction).where(Transaction.status == "confirmed")
select(Transaction)
.where(Transaction.status == "confirmed", Transaction.booking_date >= cutoff)
).scalars().all()
groups: dict[tuple, list[Transaction]] = defaultdict(list)
for t in txs:
groups[(t.account_id, t.counterparty, t.amount)].append(t)
existing = {(r.name, Decimal(r.amount))
for r in session.execute(select(RecurringItem)).scalars()}
# Schritt 2: Gruppierung je (Konto, normalisierter Empfaenger).
groups: dict[tuple[int, str], list[Transaction]] = {}
for t in txs:
groups.setdefault((t.account_id, _norm(t.counterparty)), []).append(t)
# Schritt 3+4: je Gruppe Betrags-Cluster bilden und Rhythmus klassifizieren.
series_by_account: dict[int, list[_Series]] = {}
for (account_id, _name_norm), items in groups.items():
items_sorted = sorted(items, key=lambda t: t.booking_date)
clusters = _amount_clusters(items_sorted)
classified = [(cluster, _classify([t.booking_date for t in cluster]))
for cluster in clusters]
# Fuer den Volatilitaets-Check zaehlt eine neuere Buchung nur dann als
# "abgetrennt", wenn sie NICHT bereits zu einem ANDEREN qualifizierten
# (klassifizierten) Cluster derselben Gruppe gehoert - sonst waeren
# zwei parallele, stabile Vertraege (jeder fuer sich eine gueltige
# eigene Serie) faelschlich als "volatil" markiert, nur weil der
# jeweils andere Vertrag zufaellig spaeter im Monat faellig ist
# (Nachtrag 3b, Fable-Gate-Korrektur nach dem ersten Live-Gate-Fund).
qualified_items = {t for cluster, rhythm in classified if rhythm is not None
for t in cluster}
for cluster, rhythm in classified:
if rhythm is None:
continue
# Volatilitaets-Hinweis: hat der Cluster-Split innerhalb DIESER
# Empfaenger-Gruppe (gleiches Konto, gleiches Vorzeichen) eine
# NEUERE Buchung in einen UNQUALIFIZIERTEN Cluster abgetrennt
# (z.B. eine einzelne Ausreisser-Buchung, die allein keine Serie
# bildet), ist der hier vorgeschlagene (letzte) Betrag ggf. schon
# veraltet.
cluster_sign = cluster[-1].amount > 0
volatile = any(
(t.amount > 0) == cluster_sign
and t.booking_date > cluster[-1].booking_date
and t not in qualified_items
for t in items_sorted
)
series_by_account.setdefault(account_id, []).append(
_Series(items=cluster, rhythm=rhythm, volatile=volatile))
existing = list(session.execute(select(RecurringItem)).scalars())
suggestions: list[dict] = []
for (_account_id, counterparty, amount), items in groups.items():
months = sorted({(t.booking_date.year, t.booking_date.month) for t in items})
if _max_consecutive_months(months) < 3:
continue
name = counterparty
if (name, Decimal(amount)) in existing:
continue
due_day = int(statistics.median(sorted(t.booking_date.day for t in items)))
cat_counts = Counter(t.category_id for t in items if t.category_id is not None)
category_id = cat_counts.most_common(1)[0][0] if cat_counts else None
suggestions.append({
"name": name,
"amount": Decimal(amount),
"rhythm": "monthly",
"due_day": due_day,
"category_id": category_id,
})
for series_list in series_by_account.values():
# Schritt 6: Umfirmierungs-Merge ueber Gruppengrenzen, je Konto.
for s in _try_merge(series_list):
# Schritt 5: Aktiv-Check auf der (ggf. gemergten) finalen Serie.
step = STEP_DAYS[s.rhythm]
delta_tage = (today - s.last.booking_date).days
if delta_tage * ACTIVITY_FACTOR_DEN > step * ACTIVITY_FACTOR_NUM:
continue
# Schritt 7: Vorschlagswerte aus der neuesten Buchung.
last = s.last
name = last.counterparty
amount = Decimal(last.amount)
due_day = last.booking_date.day
start_date = last.booking_date if s.rhythm in ("quarterly", "yearly") else None
cat_counts = Counter(t.category_id for t in s.items if t.category_id is not None)
category_id = cat_counts.most_common(1)[0][0] if cat_counts else None
hinweis = ""
if len(s.items) >= 2:
previous = Decimal(s.items[-2].amount)
# "Gestiegen" bezieht sich auf den Betragswert (Ausgaben sind
# negativ: gestiegen heisst betragsmaessig groesser, also
# abs(neu) > abs(alt)), nicht auf das Vorzeichen.
if abs(amount) > abs(previous):
hinweis = f"Betrag zuletzt gestiegen (vorher {eur(abs(previous))} €)"
if s.volatile:
hinweis = f"{hinweis} {VOLATILITAETS_HINWEIS}".strip()
# Schritt 8: Bestandsabgleich.
if _covered_by_existing(name, amount, s.rhythm, due_day, existing):
continue
suggestions.append({
"name": name,
"amount": amount,
"rhythm": s.rhythm,
"due_day": due_day,
"start_date": start_date,
"category_id": category_id,
"hinweis": hinweis,
})
return suggestions

View File

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

View File

@@ -8,7 +8,7 @@
<td>{{ tx.booking_date.strftime('%d.%m.%Y') }}</td>
<td>{{ tx.purpose }}</td>
<td>{{ tx.counterparty }}</td>
<td class="{{ 'neg' if tx.amount < 0 else '' }}">{{ '%.2f'|format(tx.amount) }} €</td>
<td class="{{ 'neg' if tx.amount < 0 else '' }}">{{ tx.amount|eur }} €</td>
<td>{% if tx.is_duplicate %}<span class="badge badge-warning">Duplikat</span>{% endif %}</td>
</tr>
{% else %}
@@ -25,7 +25,7 @@
{% else %}
<span class="badge badge-error">Saldo-Differenz bitte prüfen</span>
{% endif %}
· Eröffnungssaldo: {{ '%.2f'|format(statement.opening_balance) if statement.opening_balance is not none else '' }} €
· Endsaldo: {{ '%.2f'|format(statement.closing_balance) if statement.closing_balance is not none else '' }} €
· Eröffnungssaldo: {{ statement.opening_balance|eur if statement.opening_balance is not none else '' }} €
· Endsaldo: {{ statement.closing_balance|eur if statement.closing_balance is not none else '' }} €
· Duplikate: {{ duplicates }}
</p>

View File

@@ -32,6 +32,43 @@
<p id="apply-rules-result"></p>
</section>
<section class="admin-section">
<h2>Kategorien</h2>
<p class="muted">Kategorien für Buchungen, Fixposten und Szenario-Modifikatoren.
Löschen ist bewusst nicht vorgesehen (Kategorien hängen an Buchungen und Regeln).</p>
<table>
<thead><tr><th>Name</th><th></th></tr></thead>
<tbody>
{% for c in categories %}
<tr id="cat-row-{{ c.id }}">
<td>{{ c.name }}</td>
<td><button type="button" onclick="toggleEdit('cat', {{ c.id }}, true)">Bearbeiten</button></td>
</tr>
<tr id="cat-edit-{{ c.id }}" hidden>
<td colspan="2">
<form hx-ext="json-form" hx-patch="/api/categories/{{ c.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ c.name }}" required maxlength="100"></label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('cat', {{ c.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="2">Noch keine Kategorien.</td></tr>
{% endfor %}
</tbody>
</table>
<fieldset>
<legend>Neue Kategorie anlegen</legend>
<form hx-ext="json-form" hx-post="/api/categories" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required maxlength="100"></label>
<button type="submit">Anlegen</button>
</form>
</fieldset>
</section>
<script>
function handleApplyRulesResult(event) {
var result = document.getElementById('apply-rules-result');

View File

@@ -14,6 +14,7 @@
<a href="/buchungen">Buchungen</a>
<a href="/salden">Salden</a>
<a href="/planung">Planung</a>
<a href="/szenarien">Szenarien</a>
<a href="/admin">Admin</a>
<a href="/hilfe">Hilfe</a>
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a>
@@ -50,6 +51,14 @@
out[k] = null;
} else if (kind === 'int') {
out[k] = parseInt(v, 10);
} else if (kind === 'amount') {
// Deutsche Betragseingabe: "1.234,56" -> "1234.56". Ohne Komma
// bleibt der Wert unveraendert (Punkt-Eingaben funktionieren weiter).
var s = String(v).replace(/[\s€]/g, '');
if (s.indexOf(',') !== -1) {
s = s.replace(/\./g, '').replace(',', '.');
}
out[k] = s;
} else {
out[k] = v;
}
@@ -66,6 +75,12 @@
return JSON.stringify(out);
}
});
function toggleEdit(prefix, id, editing) {
// UX-Regel: Zeilen werden versteckt (hidden), nie aus dem DOM entfernt.
document.getElementById(prefix + '-row-' + id).hidden = editing;
document.getElementById(prefix + '-edit-' + id).hidden = !editing;
}
</script>
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
</body>

View File

@@ -70,6 +70,13 @@
von Krediten und Modifikatoren (Kategorie oder Posten prozentual/absolut
kürzen oder ganz streichen). "Durchrechnen" liefert: tiefster Kontostand
mit Datum, erstes Datum unter 0 und unter der Warnschwelle.
Varianten wie „Best Case“ pflegst du in der Tabelle „Einträge“ des
Szenarios: Art „Ende“ lässt einen wiederkehrenden Posten (z. B. eine
auslaufende Miete) zu einem Datum enden, ohne den Posten selbst zu
ändern; „Prozent“/„Absolut“ kürzen Beträge; „Entfällt“ blendet sie ganz
aus; „Einmalzahlung“ ergänzt einen einmaligen Zu- oder Abfluss, der nur
in diesem Szenario zählt. Jeder Eintrag ist über „Bearbeiten“ direkt
änderbar.
</li>
<li>
<strong>Grafana:</strong> Kontostand-Verläufe, Monatsausgaben nach

View File

@@ -11,7 +11,7 @@
{% endif %}
<div class="total-balance {{ 'neg' if total < 0 else '' }}">
Gesamtsaldo: {{ '%.2f'|format(total) }} €
Gesamtsaldo: {{ total|eur }} €
</div>
<table>
@@ -26,10 +26,10 @@
<tr>
<td class="account">{{ account.name }}</td>
<td>{{ account.bank }}</td>
<td class="{{ 'neg' if balance < 0 else '' }}">{{ '%.2f'|format(balance) }} €</td>
<td class="{{ 'neg' if balance < 0 else '' }}">{{ balance|eur }} €</td>
<td>
{% if account.anchor_date is not none and account.anchor_balance is not none %}
Anker: {{ '%.2f'|format(account.anchor_balance) }} € am {{ account.anchor_date.strftime('%d.%m.%Y') }}
Anker: {{ account.anchor_balance|eur }} € am {{ account.anchor_date.strftime('%d.%m.%Y') }}
{% else %}
kein Anker
{% endif %}
@@ -46,8 +46,8 @@
hx-swap="none" hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<input type="date" name="anchor_date"
value="{{ account.anchor_date.isoformat() if account.anchor_date is not none else '' }}" required>
<input type="text" name="anchor_balance" placeholder="500.00"
value="{{ account.anchor_balance if account.anchor_balance is not none else '' }}" required>
<input type="text" name="anchor_balance" data-type="amount" placeholder="500,00"
value="{{ account.anchor_balance|eur if account.anchor_balance is not none else '' }}" required>
<button type="submit">Anker setzen</button>
</form>
</td>
@@ -68,7 +68,7 @@
<tr>
<td>{{ item.date.strftime('%d.%m.%Y') }}</td>
<td>{{ item.name }}</td>
<td class="{{ 'neg' if item.amount < 0 else '' }}">{{ '%.2f'|format(item.amount) }} €</td>
<td class="{{ 'neg' if item.amount < 0 else '' }}">{{ item.amount|eur }} €</td>
</tr>
{% else %}
<tr><td colspan="3">Keine anstehenden Posten.</td></tr>

View File

@@ -7,17 +7,20 @@
<h2>Wiederkehrende Posten</h2>
<table>
<thead>
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th>Kategorie</th><th></th></tr>
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th>Start</th><th>Ende</th><th>Kategorie</th><th></th></tr>
</thead>
<tbody>
{% for r in recurring %}
<tr>
<tr id="rec-row-{{ r.id }}">
<td>{{ r.name }}</td>
<td class="{{ 'neg' if r.amount < 0 else '' }}">{{ '%.2f'|format(r.amount) }} €</td>
<td>{{ r.rhythm }}</td>
<td class="{{ 'neg' if r.amount < 0 else '' }}">{{ r.amount|eur }} €</td>
<td>{{ r.rhythm|de_label }}</td>
<td>{{ r.due_day }}</td>
<td>{{ r.start_date.strftime('%d.%m.%Y') if r.start_date else '' }}</td>
<td>{{ r.end_date.strftime('%d.%m.%Y') if r.end_date else '' }}</td>
<td>{{ category_names.get(r.category_id, '') }}</td>
<td>
<button type="button" onclick="toggleEdit('rec', {{ r.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/recurring/{{ r.id }}" hx-swap="none"
hx-confirm="„{{ r.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -25,8 +28,33 @@
</form>
</td>
</tr>
<tr id="rec-edit-{{ r.id }}" hidden>
<td colspan="8">
<form hx-ext="json-form" hx-patch="/api/recurring/{{ r.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ r.name }}" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ r.amount|eur }}" required></label>
<label>Rhythmus
<select name="rhythm" data-type="str">
{% for rh in rhythms %}<option value="{{ rh }}" {% if rh == r.rhythm %}selected{% endif %}>{{ rh|de_label }}</option>{% endfor %}
</select>
</label>
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" value="{{ r.due_day }}" required></label>
<label>Start <input type="date" name="start_date" value="{{ r.start_date.isoformat() if r.start_date else '' }}"></label>
<label>Ende <input type="date" name="end_date" value="{{ r.end_date.isoformat() if r.end_date else '' }}"></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value=""></option>
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == r.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
</select>
</label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('rec', {{ r.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="6">Noch keine wiederkehrenden Posten.</td></tr>
<tr><td colspan="8">Noch keine wiederkehrenden Posten.</td></tr>
{% endfor %}
</tbody>
</table>
@@ -36,13 +64,15 @@
<form hx-ext="json-form" hx-post="/api/recurring" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required></label>
<label>Betrag <input type="text" name="amount" placeholder="-49.99" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="-49,99" required></label>
<label>Rhythmus
<select name="rhythm" data-type="str">
{% for rh in rhythms %}<option value="{{ rh }}">{{ rh }}</option>{% endfor %}
{% for rh in rhythms %}<option value="{{ rh }}">{{ rh|de_label }}</option>{% endfor %}
</select>
</label>
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" required></label>
<label>Start (optional) <input type="date" name="start_date"></label>
<label>Ende (optional) <input type="date" name="end_date"></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value=""></option>
@@ -58,15 +88,19 @@
{% if suggestions %}
<table>
<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>
<tbody>
{% for s in suggestions %}
<tr>
<td>{{ s.name }}</td>
<td class="{{ 'neg' if s.amount < 0 else '' }}">{{ '%.2f'|format(s.amount) }} €</td>
<td>{{ s.rhythm }}</td>
<td class="{{ 'neg' if s.amount < 0 else '' }}">
{{ s.amount|eur }} €
{% if s.hinweis %}<span class="muted">{{ s.hinweis }}</span>{% endif %}
</td>
<td>{{ s.rhythm|de_label }}</td>
<td>{{ s.due_day }}</td>
<td>{{ s.start_date.strftime('%d.%m.%Y') if s.start_date else '' }}</td>
<td>
<form class="inline-form" hx-ext="json-form" hx-post="/api/recurring" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -75,6 +109,7 @@
<input type="hidden" name="rhythm" value="{{ s.rhythm }}">
<input type="hidden" name="due_day" data-type="int" value="{{ s.due_day }}">
<input type="hidden" name="category_id" data-type="int" value="{{ s.category_id if s.category_id is not none else '' }}">
<input type="hidden" name="start_date" value="{{ s.start_date.isoformat() if s.start_date else '' }}">
<button type="submit">Vorschlag übernehmen</button>
</form>
</td>
@@ -83,8 +118,9 @@
</tbody>
</table>
{% 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 %}
<p class="muted">Erkannt werden monatliche, vierteljährliche und jährliche Serien; Betrag = jeweils letzte Buchung.</p>
</fieldset>
</section>
@@ -96,12 +132,13 @@
</thead>
<tbody>
{% for p in planned %}
<tr>
<tr id="pln-row-{{ p.id }}">
<td>{{ p.name }}</td>
<td class="{{ 'neg' if p.amount < 0 else '' }}">{{ '%.2f'|format(p.amount) }} €</td>
<td class="{{ 'neg' if p.amount < 0 else '' }}">{{ p.amount|eur }} €</td>
<td>{{ p.due.strftime('%d.%m.%Y') }}</td>
<td>{{ category_names.get(p.category_id, '') }}</td>
<td>
<button type="button" onclick="toggleEdit('pln', {{ p.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/planned/{{ p.id }}" hx-swap="none"
hx-confirm="„{{ p.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -109,6 +146,24 @@
</form>
</td>
</tr>
<tr id="pln-edit-{{ p.id }}" hidden>
<td colspan="5">
<form hx-ext="json-form" hx-patch="/api/planned/{{ p.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ p.name }}" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ p.amount|eur }}" required></label>
<label>Fällig am <input type="date" name="due" value="{{ p.due.isoformat() }}" required></label>
<label>Kategorie
<select name="category_id" data-type="int">
<option value=""></option>
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == p.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
</select>
</label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('pln', {{ p.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5">Noch keine Einmalposten.</td></tr>
{% endfor %}
@@ -120,7 +175,7 @@
<form hx-ext="json-form" hx-post="/api/planned" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required></label>
<label>Betrag <input type="text" name="amount" placeholder="-2000.00" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="-2000,00" required></label>
<label>Fällig am <input type="date" name="due" required></label>
<label>Kategorie
<select name="category_id" data-type="int">
@@ -141,14 +196,15 @@
</thead>
<tbody>
{% for l in loans %}
<tr>
<tr id="loan-row-{{ l.id }}">
<td>{{ l.name }}</td>
<td>{{ '%.2f'|format(l.principal) }} €</td>
<td>{{ '%.2f'|format(l.annual_rate_pct) }} %</td>
<td>{{ l.principal|eur }} €</td>
<td>{{ l.annual_rate_pct|eur }} %</td>
<td>{{ l.term_months }} Monate</td>
<td>{{ l.payout_date.strftime('%d.%m.%Y') }}</td>
<td>{{ l.repayment_type }}</td>
<td>{{ l.repayment_type|de_label }}</td>
<td>
<button type="button" onclick="toggleEdit('loan', {{ l.id }}, true)">Bearbeiten</button>
<form class="inline-form" hx-delete="/api/loans/{{ l.id }}" hx-swap="none"
hx-confirm="„{{ l.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -156,6 +212,27 @@
</form>
</td>
</tr>
<!-- Hinweis: eine Kredit-Aenderung veraendert den Tilgungsplan; danach
muessen betroffene Szenarien neu durchgerechnet werden. -->
<tr id="loan-edit-{{ l.id }}" hidden>
<td colspan="7">
<form hx-ext="json-form" hx-patch="/api/loans/{{ l.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ l.name }}" required></label>
<label>Darlehenssumme <input type="text" name="principal" data-type="amount" value="{{ l.principal|eur }}" required></label>
<label>Zins p.a. (%) <input type="text" name="annual_rate_pct" data-type="amount" value="{{ l.annual_rate_pct|eur }}" required></label>
<label>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" value="{{ l.term_months }}" required></label>
<label>Auszahlungsdatum <input type="date" name="payout_date" value="{{ l.payout_date.isoformat() }}" required></label>
<label>Tilgungsart
<select name="repayment_type" data-type="str">
{% for rt in repayment_types %}<option value="{{ rt }}" {% if rt == l.repayment_type %}selected{% endif %}>{{ rt|de_label }}</option>{% endfor %}
</select>
</label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('loan', {{ l.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
<tr>
<td colspan="7">
<details ontoggle="if(this.open){loadLoanSchedule({{ l.id }})}">
@@ -175,13 +252,13 @@
<form hx-ext="json-form" hx-post="/api/loans" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required></label>
<label>Darlehenssumme <input type="text" name="principal" placeholder="10000.00" required></label>
<label>Zins p.a. (%) <input type="text" name="annual_rate_pct" placeholder="4.5" required></label>
<label>Darlehenssumme <input type="text" name="principal" data-type="amount" placeholder="10000,00" required></label>
<label>Zins p.a. (%) <input type="text" name="annual_rate_pct" data-type="amount" placeholder="4,5" required></label>
<label>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" required></label>
<label>Auszahlungsdatum <input type="date" name="payout_date" required></label>
<label>Tilgungsart
<select name="repayment_type" data-type="str">
{% for rt in repayment_types %}<option value="{{ rt }}">{{ rt }}</option>{% endfor %}
{% for rt in repayment_types %}<option value="{{ rt }}">{{ rt|de_label }}</option>{% endfor %}
</select>
</label>
<button type="submit">Anlegen</button>
@@ -189,160 +266,20 @@
</fieldset>
</section>
<section class="planning-section">
<h2>Szenarien</h2>
{% for row in scenario_rows %}
{% set sc = row.scenario %}
<fieldset>
<legend>{{ sc.name }}</legend>
<p>
{{ sc.description }}
· Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }}
· Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }}
</p>
<details>
<summary>Kredite zuordnen</summary>
{% for l in loans %}
<label class="inline-form">
<input type="checkbox" {% if l.id in row.assigned_loan_ids %}checked{% endif %}
onchange="toggleScenarioLoan({{ sc.id }}, {{ l.id }}, this.checked, this)">
{{ l.name }}
</label>
{% else %}
<p>Keine Kredite vorhanden.</p>
{% endfor %}
</details>
<details>
<summary>Modifikatoren</summary>
<table>
<thead>
<tr><th>Ziel</th><th>Art</th><th>Wert</th><th></th></tr>
</thead>
<tbody>
{% for m in row.modifiers %}
<tr>
<td>
{% if m.target_type == "category" %}Kategorie: {{ category_names.get(m.target_id, m.target_id) }}
{% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %}
</td>
<td>{{ m.kind }}</td>
<td>{{ '%.2f'|format(m.value) }}</td>
<td>
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Löschen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4">Keine Modifikatoren.</td></tr>
{% endfor %}
</tbody>
</table>
<form hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Ziel-Typ
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
<option value="category">Kategorie</option>
<option value="recurring">Wiederkehrender Posten</option>
</select>
</label>
<span class="mod-target-category">
<label>Kategorie
<select name="target_id" data-type="int">
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</label>
</span>
<span class="mod-target-recurring" style="display:none">
<label>Posten
<select name="target_id" data-type="int" disabled>
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
</select>
</label>
</span>
<label>Art
<select name="kind" data-type="str">
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k }}</option>{% endfor %}
</select>
</label>
<label>Wert <input type="text" name="value" value="0"></label>
<button type="submit">Modifikator hinzufügen</button>
</form>
</details>
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Durchrechnen</button>
</form>
{% if row.result %}
<p>
Tiefpunkt: {{ '%.2f'|format(row.result.low_point_balance) }} € am {{ row.result.low_point_date.strftime('%d.%m.%Y') }}<br>
{% if row.result.below_zero_date %}Unterschreitet 0 € ab {{ row.result.below_zero_date.strftime('%d.%m.%Y') }}<br>{% endif %}
{% if row.result.below_threshold_date %}Unterschreitet Warnschwelle ab {{ row.result.below_threshold_date.strftime('%d.%m.%Y') }}<br>{% endif %}
Kurven in <a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a> ansehen.
</p>
{% else %}
<p>Noch nicht durchgerechnet.</p>
{% endif %}
</fieldset>
{% else %}
<p>Noch keine Szenarien.</p>
{% endfor %}
<fieldset>
<legend>Neues Szenario anlegen</legend>
<form hx-ext="json-form" hx-post="/api/scenarios" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required></label>
<label>Beschreibung <input type="text" name="description"></label>
<label><input type="checkbox" name="include_recurring" data-type="bool" checked> Wiederkehrende Posten einschließen</label>
<label><input type="checkbox" name="include_planned" data-type="bool" checked> Einmalposten einschließen</label>
<button type="submit">Szenario anlegen</button>
</form>
</fieldset>
</section>
<script>
// json-form-Extension ist zentral in base.html registriert (eine
// Definition fuer alle Templates, inkl. data-type="bool"-Handling).
function onModTargetTypeChange(select) {
var form = select.closest('form');
var catSpan = form.querySelector('.mod-target-category');
var recSpan = form.querySelector('.mod-target-recurring');
var catSelect = catSpan.querySelector('select');
var recSelect = recSpan.querySelector('select');
var isCategory = select.value === 'category';
catSpan.style.display = isCategory ? '' : 'none';
recSpan.style.display = isCategory ? 'none' : '';
catSelect.disabled = !isCategory;
recSelect.disabled = isCategory;
}
function toggleScenarioLoan(scenarioId, loanId, checked, checkbox) {
fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' })
.then(function (resp) {
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
window.location.reload();
})
.catch(function (err) {
checkbox.checked = !checked;
alert('Zuordnung fehlgeschlagen: ' + err.message);
});
}
// toggleEdit ist ebenfalls zentral in base.html definiert (wird auf
// mehreren Seiten fuer Inline-Edit-Formulare gebraucht).
var loadedSchedules = {};
function formatIsoDate(iso) {
var parts = iso.split('-');
return parts[2] + '.' + parts[1] + '.' + parts[0];
}
function fmtEur(n) {
return Number(n).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
function loadLoanSchedule(loanId) {
if (loadedSchedules[loanId]) return;
loadedSchedules[loanId] = true;
@@ -353,10 +290,10 @@
+ '<th>Tilgung</th><th>Restschuld</th></tr></thead><tbody>';
rows.forEach(function (r) {
html += '<tr><td>' + formatIsoDate(r.due) + '</td>'
+ '<td>' + Number(r.payment).toFixed(2) + ' €</td>'
+ '<td>' + Number(r.interest).toFixed(2) + ' €</td>'
+ '<td>' + Number(r.principal).toFixed(2) + ' €</td>'
+ '<td>' + Number(r.remaining).toFixed(2) + ' €</td></tr>';
+ '<td>' + fmtEur(r.payment) + ' €</td>'
+ '<td>' + fmtEur(r.interest) + ' €</td>'
+ '<td>' + fmtEur(r.principal) + ' €</td>'
+ '<td>' + fmtEur(r.remaining) + ' €</td></tr>';
});
html += '</tbody></table>';
document.getElementById('schedule-' + loanId).innerHTML = html;

View File

@@ -17,14 +17,14 @@
{% for account, balance in balances %}
<tr>
<td class="account">{{ account.name }}</td>
<td class="amount {{ 'neg' if balance < 0 else '' }}">{{ '%.2f'|format(balance) }} €</td>
<td class="amount {{ 'neg' if balance < 0 else '' }}">{{ balance|eur }} €</td>
</tr>
{% else %}
<tr><td colspan="2">Keine Konten angelegt.</td></tr>
{% endfor %}
<tr class="total-row">
<td><strong>Gesamt</strong></td>
<td class="amount {{ 'neg' if total < 0 else '' }}"><strong>{{ '%.2f'|format(total) }} €</strong></td>
<td class="amount {{ 'neg' if total < 0 else '' }}"><strong>{{ total|eur }} €</strong></td>
</tr>
</tbody>
</table>
@@ -48,9 +48,9 @@
<tr data-month="{{ row['month'].isoformat() }}">
<td>{{ row['month'].strftime('%m.%Y') }}</td>
{% for account, value in row['values'] %}
<td class="amount {{ 'neg' if value < 0 else '' }}">{{ '%.2f'|format(value) }} €</td>
<td class="amount {{ 'neg' if value < 0 else '' }}">{{ value|eur }} €</td>
{% endfor %}
<td class="amount {{ 'neg' if row['total'] < 0 else '' }}">{{ '%.2f'|format(row['total']) }} €</td>
<td class="amount {{ 'neg' if row['total'] < 0 else '' }}">{{ row['total']|eur }} €</td>
</tr>
{% else %}
<tr><td colspan="{{ accounts|length + 2 }}">Keine bestätigten Buchungen vorhanden.</td></tr>

View File

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

View File

@@ -53,7 +53,7 @@
<td class="account">{{ account_names.get(tx.account_id, tx.account_id) }}</td>
<td>{{ tx.purpose }}</td>
<td>{{ tx.counterparty }}</td>
<td class="amount {{ 'neg' if tx.amount < 0 else '' }}">{{ '%.2f'|format(tx.amount) }}</td>
<td class="amount {{ 'neg' if tx.amount < 0 else '' }}">{{ tx.amount|eur }}</td>
<td>
<select name="category_id" data-type="int" hx-ext="json-form"
hx-patch="/api/transactions/{{ tx.id }}" hx-trigger="change" hx-swap="none">

View File

@@ -234,6 +234,21 @@ def test_admin_passwort_success_returns_200_with_confirmation(
assert r2.status_code == 200
# --- Kategorien-Verwaltung --------------------------------------------------
def test_admin_zeigt_kategorien_verwaltung(client_with_env_file, db):
from app.models.tables import Category
client, password = client_with_env_file
client.post("/login", data={"username": "admin", "password": password})
db.add(Category(name="Admin-Kat"))
db.commit()
r = client.get("/admin").text
assert "Kategorien" in r and "Admin-Kat" in r
assert "Neue Kategorie anlegen" in r
assert 'hx-patch="/api/categories/' in r
assert 'hx-post="/api/categories"' in r
# --- Regeln neu anwenden ---------------------------------------------------
def test_apply_rules_retroactively_categorizes_uncategorized_confirmed_tx(db):

View File

@@ -113,6 +113,23 @@ def test_rules_categorize(client):
assert txs[0]["category_id"] == cat["id"]
def test_category_patch(client):
a = client.post("/api/categories", headers=H, json={"name": "Kat-A"}).json()
b = client.post("/api/categories", headers=H, json={"name": "Kat-B"}).json()
# Umbenennen
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
assert r.status_code == 200 and r.json()["name"] == "Kat-A-neu"
# Umbenennen auf den EIGENEN Namen ist erlaubt (kein 409)
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
assert r.status_code == 200
# Kollision mit anderer Kategorie -> 409
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-B"})
assert r.status_code == 409
# unbekannte id -> 404
assert client.patch("/api/categories/99999", headers=H,
json={"name": "x"}).status_code == 404
def test_patch_account_name(client):
acc = client.post("/api/accounts", headers=H, json={
"bank": "DKB", "iban": "DE71", "name": "DE71", "type": "giro"}).json()
@@ -186,3 +203,94 @@ def test_patch_transaction_category(client):
r = client.patch(f"/api/transactions/{tx_id}", headers=H,
json={"category_id": 9999})
assert r.status_code == 404
def test_scenario_patch_beschreibung_leeren(client):
sc = client.post("/api/scenarios", headers=H, json={"name": "DescNull", "description": "x"}).json()
r = client.patch(f"/api/scenarios/{sc['id']}", headers=H, json={"description": None})
assert r.status_code == 200 and r.json()["description"] == ""
def test_modifier_ende_validierung(client, db):
sc = client.post("/api/scenarios", headers=H, json={"name": "EndeVal"}).json()
# ende ohne Datum -> 422
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "ende"})
assert r.status_code == 422
# ende mit Datum -> 201, end_date in der Antwort
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "ende",
"end_date": "2026-08-31"})
assert r.status_code == 201
assert r.json()["end_date"] == "2026-08-31"
# andere Arten nullen end_date
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "remove",
"end_date": "2026-08-31"})
assert r.status_code == 201 and r.json()["end_date"] is None
def test_scenario_planned_crud_und_cleanup(client, db):
sc = client.post("/api/scenarios", headers=H, json={"name": "SPI-CRUD"}).json()
r = client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
"name": "Sonderzahlung", "amount": "5000.00", "due": "2026-07-30"})
assert r.status_code == 201
item = r.json()
assert item["scenario_id"] == sc["id"] and item["amount"] == "5000.00"
assert client.get(f"/api/scenarios/{sc['id']}/planned", headers=H).json()[0]["name"] == "Sonderzahlung"
# DELETE mit fremder scenario_id -> 404
other = client.post("/api/scenarios", headers=H, json={"name": "SPI-Other"}).json()
assert client.delete(f"/api/scenarios/{other['id']}/planned/{item['id']}",
headers=H).status_code == 404
# Szenario loeschen raeumt Einmalzahlungen mit ab (kein FK-Fehler):
assert client.delete(f"/api/scenarios/{sc['id']}", headers=H).status_code == 204
from app.models.tables import ScenarioPlannedItem
assert db.query(ScenarioPlannedItem).count() == 0
def test_modifier_patch(client, db):
sc = client.post("/api/scenarios", headers=H, json={"name": "ModPatch"}).json()
m = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "percent",
"value": "10.00"}).json()
# Wert aendern
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "25.00"})
assert r.status_code == 200 and r.json()["value"] == "25.00"
# Art auf ende wechseln (mit Datum); value wird vom Validator genullt
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "ende",
"end_date": "2026-08-31"})
assert r.status_code == 200
assert r.json()["kind"] == "ende" and r.json()["end_date"] == "2026-08-31"
# ende ohne Datum -> 422
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "ende"})
assert r.status_code == 422
# zurueck auf remove: end_date wird genullt
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "remove",
"end_date": "2026-08-31"})
assert r.status_code == 200 and r.json()["end_date"] is None
# fremdes Szenario -> 404
other = client.post("/api/scenarios", headers=H, json={"name": "ModPatch2"}).json()
r = client.patch(f"/api/scenarios/{other['id']}/modifiers/{m['id']}", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "remove"})
assert r.status_code == 404
def test_scenario_planned_patch(client, db):
sc = client.post("/api/scenarios", headers=H, json={"name": "SpiPatch"}).json()
p = client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
"name": "Zufluss", "amount": "5000.00", "due": "2026-07-30"}).json()
r = client.patch(f"/api/scenarios/{sc['id']}/planned/{p['id']}", headers=H, json={
"name": "Zufluss geaendert", "amount": "6000.00", "due": "2026-08-15"})
assert r.status_code == 200
body = r.json()
assert body["name"] == "Zufluss geaendert"
assert body["amount"] == "6000.00" and body["due"] == "2026-08-15"
# fremdes Szenario -> 404
other = client.post("/api/scenarios", headers=H, json={"name": "SpiPatch2"}).json()
r = client.patch(f"/api/scenarios/{other['id']}/planned/{p['id']}", headers=H, json={
"name": "x", "amount": "1.00", "due": "2026-08-15"})
assert r.status_code == 404

View File

@@ -0,0 +1,43 @@
from decimal import Decimal
from app.formats import DE_LABELS, de_label, eur
def test_eur_basisformat():
assert eur(Decimal("2474.00")) == "2.474,00"
def test_eur_negativ_mit_mehreren_tausenderpunkten():
assert eur(Decimal("-1234567.5")) == "-1.234.567,50"
def test_eur_null_und_kleinbetraege():
assert eur(Decimal("0")) == "0,00"
assert eur(Decimal("-0.5")) == "-0,50"
assert eur(Decimal("999.99")) == "999,99"
def test_eur_rundung_half_up():
assert eur(Decimal("1.005")) == "1,01"
assert eur(Decimal("-1.005")) == "-1,01"
def test_eur_akzeptiert_strings_und_int():
# Templates reichen teils DB-Werte als str/int durch (z.B. Suggestions).
assert eur("1234.5") == "1.234,50"
assert eur(7) == "7,00"
def test_de_label_bekannte_und_unbekannte_werte():
assert de_label("monthly") == "monatlich"
assert de_label("quarterly") == "vierteljährlich"
assert de_label("yearly") == "jährlich"
assert de_label("annuity") == "Annuität"
assert de_label("bullet") == "endfällig"
assert de_label("percent") == "Prozent"
assert de_label("absolute") == "Absolut"
assert de_label("remove") == "Entfällt"
assert de_label("ende") == "Ende"
assert de_label("weirdvalue") == "weirdvalue"
assert set(DE_LABELS) == {"monthly", "quarterly", "yearly", "annuity",
"bullet", "percent", "absolute", "remove", "ende"}

View File

@@ -1,13 +1,15 @@
from datetime import date, timedelta
from decimal import Decimal
from app.models.tables import Account
from app.formats import eur
from app.models.tables import (Account, Loan, PlannedItem, RecurringItem,
Scenario, ScenarioModifier, ScenarioPlannedItem)
H = {"Authorization": "Bearer test-key"}
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)
assert r.status_code in (302, 303), path
assert r.headers["location"] == "/login"
@@ -15,7 +17,7 @@ def test_pages_require_login(client):
def test_pages_render_after_login(client):
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)
assert r.status_code == 200
assert "Finanzberatung" in r.text
@@ -34,7 +36,7 @@ def test_index_shows_account_anchor(client, db):
r = client.get("/")
assert r.status_code == 200
assert "Anker: 321.00" in r.text
assert "Anker: 321,00" in r.text
assert "30.06.2026" in r.text
assert "kein Anker" in r.text
# Inline-Pflegeformular fuer den Anker muss je Konto vorhanden sein.
@@ -42,6 +44,45 @@ def test_index_shows_account_anchor(client, db):
assert 'name="anchor_balance"' in r.text
def test_gui_zeigt_deutsche_betragsformate(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
acc = Account(bank="dkb", iban="DE-FMT-1", name="Formatkonto", type="giro")
db.add(acc)
db.flush()
acc.anchor_date = date(2026, 6, 30)
acc.anchor_balance = Decimal("12345.60")
db.commit()
r = client.get("/")
assert "12.345,60" in r.text
assert "12345.60" not in r.text
def test_planung_zeigt_deutsche_labels(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(RecurringItem(name="Miete-Label-Test", amount=Decimal("-600.00"),
rhythm="monthly", due_day=1))
db.commit()
r = client.get("/planung")
assert "monatlich" in r.text
assert "-600,00" in r.text
def test_betragsfelder_haben_amount_typ_und_deutsche_platzhalter(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
planung = client.get("/planung").text
# Fixposten-, Einmalposten-, Kredit-Formulare senden deutsche Eingaben:
assert planung.count('data-type="amount"') >= 4
assert '-49,99' in planung and '-2000,00' in planung
assert '-49.99' not in planung and '-2000.00' not in planung
# Anker-Formular wird nur je Konto gerendert - ohne Konto in der DB gaebe
# es keine Zeile mit dem Anker-Input (Template-Zweig "Keine Konten
# angelegt"), daher hier ein Konto anlegen wie in den Nachbartests.
db.add(Account(bank="dkb", iban="DE-AMT-1", name="Amount-Test", type="giro"))
db.commit()
index = client.get("/").text
assert 'placeholder="500,00"' in index
def test_import_page_has_dropzone_and_list(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/import")
@@ -185,6 +226,18 @@ def test_planning_page_has_all_sections(client):
assert heading in r.text
def test_planung_fixposten_zeigt_start_und_ende(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(RecurringItem(name="Befristet-GUI", amount=Decimal("-5.00"),
rhythm="monthly", due_day=1,
start_date=date(2026, 1, 1), end_date=date(2026, 12, 31)))
db.commit()
r = client.get("/planung").text
assert "<th>Start</th>" in r and "<th>Ende</th>" in r
assert "01.01.2026" in r and "31.12.2026" in r
assert 'name="start_date"' in r and 'name="end_date"' in r
def test_planning_page_shows_empty_suggestions_hint(client):
# UX-Regel: das "Vorschläge aus Buchungen"-Fieldset ist immer sichtbar,
# auch ohne Daten - statt komplett zu verschwinden zeigt es einen Hinweis.
@@ -193,6 +246,33 @@ def test_planning_page_shows_empty_suggestions_hint(client):
assert r.status_code == 200
assert "Vorschläge aus Buchungen" 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):
@@ -234,7 +314,7 @@ def test_salden_page_stichtag_and_month_overview(client, db):
expected_stichtag = Decimal("1000.00") + Decimal("111.11") + Decimal("22.22")
r = client.get(f"/salden?stichtag={stichtag.isoformat()}")
assert r.status_code == 200
assert f"{expected_stichtag:.2f}" in r.text
assert eur(expected_stichtag) in r.text
# Monatsuebersicht: genau 3 Monatszeilen (m2, m1, m0).
assert r.text.count('data-month="') == 3
@@ -245,7 +325,7 @@ def test_salden_page_stichtag_and_month_overview(client, db):
# m2), d.h. NACH Buchung 1 (in m2), aber VOR Buchung 2 (die faellt erst
# in m1 selbst und darf den Monatsanfang nicht verfaelschen).
expected_m1_start = Decimal("1000.00") + Decimal("111.11")
assert f"{expected_m1_start:.2f}" in r.text
assert eur(expected_m1_start) in r.text
def test_salden_stichtag_garbage_falls_back_to_today(client):
@@ -257,6 +337,30 @@ def test_salden_stichtag_garbage_falls_back_to_today(client):
assert date.today().strftime('%d.%m.%Y') in r.text
def test_planung_hat_bearbeiten_formulare(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(RecurringItem(name="EditR", amount=Decimal("-5.00"), rhythm="monthly", due_day=1))
db.add(PlannedItem(name="EditP", amount=Decimal("-7.00"), due=date(2026, 8, 1)))
db.add(Loan(name="EditL", principal=Decimal("1000.00"),
annual_rate_pct=Decimal("4.50"), term_months=12,
payout_date=date(2026, 8, 1), repayment_type="annuity"))
db.commit()
r = client.get("/planung").text
assert r.count(">Bearbeiten</button>") >= 3
for fragment in ('hx-patch="/api/recurring/', 'hx-patch="/api/planned/',
'hx-patch="/api/loans/'):
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):
# UX-Regel: Konten ohne Anker sind immer sichtbar als relative Werte
# gekennzeichnet, nicht stillschweigend wie geankerte Konten dargestellt.
@@ -268,3 +372,84 @@ def test_salden_shows_ohne_anker_footnote(client, db):
r = client.get("/salden")
assert r.status_code == 200
assert "ohne Anker" in r.text and "relative Werte" in r.text
def test_szenario_gui_ende_und_einmalzahlungen(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
sc = Scenario(name="GUI-Ende", description="")
db.add(sc)
db.flush()
db.add(ScenarioModifier(scenario_id=sc.id, target_type="recurring",
target_id=1, kind="ende", value=Decimal("0"),
end_date=date(2026, 8, 31)))
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Zufluss-GUI",
amount=Decimal("5000.00"), due=date(2026, 7, 30)))
db.commit()
r = client.get("/szenarien").text
assert 'value="ende"' in r # neue Art im Dropdown
assert 'name="end_date"' in r # Datumsfeld im Modifikator-Formular
assert "31.08.2026" in r # ende-Modifikator zeigt Datum
assert "Einträge" in r
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
assert "Zufluss-GUI" in r and "5.000,00" in r and "30.07.2026" in r
def test_szenario_eintraege_tabelle(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
sc = Scenario(name="Eintraege-GUI", description="")
db.add(sc)
db.flush()
db.add(ScenarioModifier(scenario_id=sc.id, target_type="recurring",
target_id=1, kind="ende", value=Decimal("0"),
end_date=date(2026, 8, 31)))
db.add(ScenarioModifier(scenario_id=sc.id, target_type="category",
target_id=1, kind="percent", value=Decimal("25.00")))
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Zufluss-Eintrag",
amount=Decimal("5000.00"), due=date(2026, 7, 30)))
db.commit()
r = client.get("/szenarien").text
assert "Einträge" in r
# alte details-Abschnitte sind ersetzt
assert "<summary>Modifikatoren</summary>" not in r
assert "Einmalzahlungen in diesem Szenario" not in r
# gemischte Zeilen in EINER Tabelle
assert "31.08.2026" in r and "25,00" in r and "Zufluss-Eintrag" in r
assert ">Einmalzahlung<" in r
# Inline-Edit je Typ
assert f'hx-patch="/api/scenarios/{sc.id}/modifiers/' in r
assert f'hx-patch="/api/scenarios/{sc.id}/planned/' in r
# Anlege-Formular: Eintragsart inkl. Einmalzahlung + Endpunkt-Wechsel-Attribute
assert 'value="einmal"' in r
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
assert "onEntryArtChange" in r and "onModKindChange" not in r
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

View File

@@ -22,3 +22,15 @@ def test_account_transaction_roundtrip(db):
db.add(tx)
db.commit()
assert db.query(Transaction).one().amount == Decimal("-50.00")
def test_scenario_planned_item_roundtrip(db):
from app.models.tables import Scenario, ScenarioPlannedItem
sc = Scenario(name="SPI-Test", description="")
db.add(sc)
db.flush()
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Einmalzahlung",
amount=Decimal("5000.00"), due=date(2026, 7, 30)))
db.commit()
item = db.query(ScenarioPlannedItem).one()
assert item.amount == Decimal("5000.00") and item.scenario_id == sc.id

View File

@@ -1,8 +1,13 @@
from datetime import date
from decimal import Decimal
from itertools import count
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"}
@@ -34,6 +39,29 @@ def test_scenario_projection(client, db):
assert len(body["series"]) == 92
def test_projektion_mit_ende_modifier_und_szenario_einmalzahlung(client, db):
_seed_balance(db)
rec = client.post("/api/recurring", headers=H, json={
"name": "Miete-Proj", "amount": "-1000.00", "rhythm": "monthly",
"due_day": 1}).json()
sc = client.post("/api/scenarios", headers=H,
json={"name": "BestCase-Test", "include_planned": False}).json()
client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": rec["id"], "kind": "ende",
"end_date": "2026-08-31"})
client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
"name": "Zufluss", "amount": "5000.00", "due": "2026-07-30"})
r = client.post(f"/api/scenarios/{sc['id']}/project", headers=H,
params={"horizon_days": 150, "start_date": "2026-07-15"})
assert r.status_code == 200
series = {p["day"]: Decimal(p["balance"]) for p in r.json()["series"]}
# Start 1000; +5000 am 30.07.; Miete nur noch am 01.08. (ende 31.08.);
# include_planned=False, aber die SZENARIO-Zahlung zaehlt trotzdem.
assert series["2026-07-30"] == Decimal("6000.00")
assert series["2026-08-01"] == Decimal("5000.00")
assert series["2026-12-01"] == Decimal("5000.00") # keine Miete mehr ab Sept.
def test_loan_in_scenario_keeps_balance_positive(client, db):
_seed_balance(db)
client.post("/api/recurring", headers=H, json={
@@ -49,54 +77,294 @@ def test_loan_in_scenario_keeps_balance_positive(client, db):
assert Decimal(body["low_point_balance"]) > Decimal("0")
def _tx(acc, d, amount, counterparty, dedup, category_id=None):
return Transaction(account_id=acc.id, booking_date=d, amount=Decimal(amount),
purpose="", counterparty=counterparty, status="confirmed",
dedup_hash=dedup, category_id=category_id)
_iban_seq = count(1)
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):
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
db.add(acc)
db.flush()
acc = _acc(db)
cat = Category(name="Miete")
db.add(cat)
db.flush()
# Dez 2025 -> Jan 2026 -> Feb 2026: 3 aufeinanderfolgende Monate ueber den
# Jahreswechsel hinweg (prueft die Monats-Linearisierung y*12+m).
db.add(_tx(acc, date(2025, 12, 1), "-600.00", "Vermieter", "h1", cat.id))
db.add(_tx(acc, date(2026, 1, 15), "-600.00", "Vermieter", "h2", cat.id))
db.add(_tx(acc, date(2026, 2, 28), "-600.00", "Vermieter", "h3", cat.id))
# Jahreswechsel hinweg, gleichmaessiger 31-Tage-Abstand (Median im
# monatlichen Fenster 25-36 Tage; die alte Jahreswechsel-Pruefung galt der
# Monats-Linearisierung, die es im neuen Tage-basierten Median-Ansatz
# 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()
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"),
"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):
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
db.add(acc)
db.flush()
db.add(_tx(acc, date(2026, 3, 10), "-50.00", "Zweimonatig", "h1"))
db.add(_tx(acc, date(2026, 4, 10), "-50.00", "Zweimonatig", "h2"))
acc = _acc(db)
_tx(db, acc, date(2026, 3, 10), "-50.00", "Zweimonatig")
_tx(db, acc, date(2026, 4, 10), "-50.00", "Zweimonatig")
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):
r = client.post("/api/recurring", headers=H, json={
"name": "Falschrum", "amount": "-10.00", "rhythm": "monthly", "due_day": 1,
"start_date": "2026-10-01", "end_date": "2026-09-01"})
assert r.status_code == 422
def test_recurring_start_ende_roundtrip_und_patch_validierung(client):
r = client.post("/api/recurring", headers=H, json={
"name": "Befristet", "amount": "-10.00", "rhythm": "monthly", "due_day": 1,
"start_date": "2026-01-01", "end_date": "2026-12-31"})
assert r.status_code == 201
item = r.json()
assert item["start_date"] == "2026-01-01" and item["end_date"] == "2026-12-31"
# PATCH, der Ende vor den (bestehenden) Start schieben will -> 422:
bad = client.patch(f"/api/recurring/{item['id']}", headers=H,
json={"end_date": "2025-06-30"})
assert bad.status_code == 422
# Datum explizit loeschen (null) ist erlaubt:
ok = client.patch(f"/api/recurring/{item['id']}", headers=H,
json={"end_date": None})
assert ok.status_code == 200 and ok.json()["end_date"] is None
def test_suggest_recurring_excludes_existing_recurring_item(db):
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
db.add(acc)
db.flush()
db.add(_tx(acc, date(2026, 1, 5), "-30.00", "Streaming", "h1"))
db.add(_tx(acc, date(2026, 2, 5), "-30.00", "Streaming", "h2"))
db.add(_tx(acc, date(2026, 3, 5), "-30.00", "Streaming", "h3"))
acc = _acc(db)
_tx(db, acc, date(2026, 1, 5), "-30.00", "Streaming")
_tx(db, acc, date(2026, 2, 5), "-30.00", "Streaming")
_tx(db, acc, date(2026, 3, 5), "-30.00", "Streaming")
db.add(RecurringItem(name="Streaming", amount=Decimal("-30.00"),
rhythm="monthly", due_day=5))
db.commit()
# Gleicher Name + Betrag wie ein bereits vorhandenes RecurringItem -> ausgelassen.
assert suggest_recurring(db) == []
# Gleicher Name wie ein bereits vorhandenes RecurringItem -> Bestandsabgleich
# (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()

View File

@@ -57,3 +57,51 @@ def test_absolute_modifier_clamps_to_zero():
kind="absolute", value=Decimal("300"))]
flows = build_cashflows(rec, [], [], [], mods, date(2026, 8, 1), date(2026, 8, 31))
assert flows == [(date(2026, 8, 10), Decimal("0.00"))]
def test_ende_modifier_beendet_posten_im_szenario():
r = PlainRecurring(1, "Miete", Decimal("-2000.00"), "monthly", 1, None, None, None)
m = PlainModifier("recurring", 1, "ende", Decimal("0"), date(2026, 8, 31))
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 12, 31))
assert [d for d, _ in flows] == [date(2026, 7, 1), date(2026, 8, 1)]
assert all(a == Decimal("-2000.00") for _, a in flows)
def test_ende_modifier_minimum_mit_eigenem_ende():
# Posten endet selbst am 15.10.; Szenario-Ende 31.08. ist frueher -> gewinnt.
r = PlainRecurring(1, "Miete", Decimal("-100.00"), "monthly", 1,
None, date(2026, 10, 15), None)
m = PlainModifier("recurring", 1, "ende", Decimal("0"), date(2026, 8, 31))
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 12, 31))
assert [d for d, _ in flows] == [date(2026, 7, 1), date(2026, 8, 1)]
# Umgekehrt: Szenario-Ende NACH eigenem Ende aendert nichts.
m_spaet = PlainModifier("recurring", 1, "ende", Decimal("0"), date(2026, 12, 31))
flows2 = build_cashflows([r], [], [], [], [m_spaet], date(2026, 7, 1), date(2026, 12, 31))
assert [d for d, _ in flows2][-1] == date(2026, 10, 1)
def test_ende_modifier_auf_kategorie_trifft_alle_posten_der_kategorie():
r1 = PlainRecurring(1, "Miete A", Decimal("-10.00"), "monthly", 1, None, None, 7)
r2 = PlainRecurring(2, "Miete B", Decimal("-20.00"), "monthly", 1, None, None, 7)
r3 = PlainRecurring(3, "Strom", Decimal("-5.00"), "monthly", 1, None, None, 9)
m = PlainModifier("category", 7, "ende", Decimal("0"), date(2026, 7, 31))
flows = build_cashflows([r1, r2, r3], [], [], [], [m],
date(2026, 7, 1), date(2026, 9, 30))
betraege = sorted(a for _, a in flows)
# Miete A/B nur je 1x (Juli), Strom 3x (Juli-Sept).
assert betraege == [Decimal("-20.00"), Decimal("-10.00"),
Decimal("-5.00"), Decimal("-5.00"), Decimal("-5.00")]
def test_ende_modifier_ohne_datum_wirkt_nicht():
r = PlainRecurring(1, "Miete", Decimal("-10.00"), "monthly", 1, None, None, None)
m = PlainModifier("recurring", 1, "ende", Decimal("0"), None)
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 9, 30))
assert len(flows) == 3
def test_ende_modifier_aendert_betraege_nicht():
r = PlainRecurring(1, "Miete", Decimal("-2000.00"), "monthly", 1, None, None, None)
m = PlainModifier("recurring", 1, "ende", Decimal("50"), date(2026, 12, 31))
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 8, 31))
assert all(a == Decimal("-2000.00") for _, a in flows)