Files
bin/docs/superpowers/plans/2026-07-20-ausbaustufe-5.md
2026-07-20 12:05:26 +02:00

1212 lines
57 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ausbaustufe 5 Implementation Plan — Deutsche Formate, Posten-Bearbeitung, Szenario-Ende (v0.6.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:** Deutsche Zahlenformate in der gesamten Web-GUI, Inline-Bearbeitung aller Planungs-Objekte inkl. Start/Ende bei Fixposten, Szenario-Modifikator-Art »Ende« plus szenario-eigene Einmalzahlungen; Release v0.6.0 mit Live-Demo-Szenario »Best Case«.
**Architecture:** Anzeige-Formatierung als reine Funktionen in neuem Modul `app/formats.py` (Jinja-Filter `eur`/`de_label`, kein `locale`-Modul); Eingabe-Konvertierung clientseitig in der bestehenden json-form-htmx-Extension (`data-type="amount"`), API bleibt strikt Punkt-Dezimal. Szenario-Ende als neue Modifikator-Art `ende` (nullable `end_date`-Spalte) in der reinen Engine; szenario-eigene Einmalzahlungen als neue Tabelle `scenario_planned_items`, die `services/projection_service.py` unabhängig von `include_planned` einspeist. Eine Alembic-Migration.
**Tech Stack:** FastAPI + Pydantic v2, SQLAlchemy 2 (Mapped), Alembic, Jinja2, htmx + eigene json-form-Extension, pytest (SQLite in-memory via `conftest.py`), Podman-Pod-Deployment via `create_pod_finance.sh`.
**Spec:** `docs/superpowers/specs/2026-07-20-ausbaustufe-5-design.md`
## Global Constraints
- **Geldbeträge immer `decimal.Decimal`** (DB-Typ `MONEY = Numeric(12,2)`), niemals float-Arithmetik (CLAUDE.md).
- **GUI-Texte, Fehlermeldungen, Commit-Messages auf Deutsch**; Datumsformat TT.MM.JJJJ.
- **UX-Regel:** Bedienelemente sichtbar lassen (`hidden`/`disabled`, nie aus dem DOM entfernen); Fehler inline bzw. über den bestehenden `htmx:responseError`-Handler.
- **API-Werte/DB-Werte bleiben englisch** (`monthly`, `annuity`, `percent`, `ende`, …) — nur Anzeige-Labels sind deutsch.
- **Fable-Testagent-Gate:** Jeder Task braucht VOR dem Commit eine Abnahme durch den Fable-Testagenten (Faktencheck/Live-Test je nach Task). Kein Task ist ohne dieses Gate abgeschlossen.
- **Ledger:** Nach jedem abgeschlossenen Task ein Eintrag in `.superpowers/sdd/progress.md` (Commit-Range + Fable-Befund).
- **DATENSCHUTZ:** `tests/fixtures/*` = echte Kontodaten, nie committen/zitieren. Die konkreten Demo-Daten (Empfängernamen, Beträge, Termine des »Best Case«) stammen aus dem Nutzer-Chat und dürfen NUR in die Live-DB und den Chat — nicht in Commits, Ledger, Tests oder Doku.
- **Testlauf immer:** `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` — Basis-Erwartung: alle grün (aktuell 154 passed).
- **Arbeitsverzeichnis:** alle Pfade unten relativ zu `/home/wlfb/bin/finance` (Ausnahme: Ledger/Plan unter `/home/wlfb/bin`).
---
### Task 1: Anzeige-Formatierung — `app/formats.py` + Template-Umstellung
**Files:**
- Create: `app/formats.py`
- Create: `tests/test_formats.py`
- Modify: `app/routers/gui.py` (Filter-Registrierung, nach Zeile 30 `templates.env.globals[...]`)
- Modify: `app/templates/index.html`, `app/templates/salden.html`, `app/templates/transactions.html`, `app/templates/planning.html`, `app/templates/_preview_table.html`
- Modify: `tests/test_gui.py` (bestehende Punktformat-Assertions)
**Interfaces:**
- Produces: `app.formats.eur(value) -> str` (Decimal/str/int → `""`-lose deutsche Darstellung, z.B. `Decimal("-2474.5")``"-2.474,50"`, ohne €-Zeichen), `app.formats.de_label(value: str) -> str` (englischer API-Wert → deutsches Label, unbekannt → Rohwert), `app.formats.DE_LABELS: dict[str, str]`. Jinja-Filter `|eur` und `|de_label` in allen Templates verfügbar. Spätere Tasks (2, 4, 7) nutzen beide Filter.
- [x] **Step 1: Failing Tests schreiben**`tests/test_formats.py`:
```python
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"}
```
- [x] **Step 2: Fehlschlag verifizieren**
Run: `.venv/bin/python -m pytest tests/test_formats.py -q`
Expected: FAIL/ERROR mit `ModuleNotFoundError: No module named 'app.formats'`
- [x] **Step 3: `app/formats.py` implementieren**
```python
"""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)
```
- [x] **Step 4: Tests grün verifizieren**
Run: `.venv/bin/python -m pytest tests/test_formats.py -q`
Expected: alle Tests PASS
- [x] **Step 5: Filter registrieren**`app/routers/gui.py`, direkt nach `templates.env.globals["app_version"] = get_version()`:
```python
from app.formats import de_label, eur # (oben bei den Imports einsortieren)
templates.env.filters["eur"] = eur
templates.env.filters["de_label"] = de_label
```
- [x] **Step 6: Failing GUI-Test für deutsche Anzeige** — in `tests/test_gui.py` ergänzen:
```python
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
```
`RecurringItem` zum Import in `tests/test_gui.py` ergänzen: `from app.models.tables import Account, RecurringItem`.
Run: `.venv/bin/python -m pytest tests/test_gui.py -q` → die zwei neuen Tests FAIL (Punktformat/englische Labels im HTML).
- [x] **Step 7: Templates umstellen** — alle `{{ '%.2f'|format(X) }}`-Vorkommen ersetzen durch `{{ X|eur }}` (Fundstellen: `index.html` Zeilen 14/29/32/71, `salden.html` 20/27/51/53, `transactions.html` 56, `_preview_table.html` 11/28/29, `planning.html` 16/67/101/146/147/231/284). In `planning.html` zusätzlich Labels:
- Zeile 17 `{{ r.rhythm }}``{{ r.rhythm|de_label }}`; Zeile 68 (Vorschläge) `{{ s.rhythm }}``{{ s.rhythm|de_label }}`; Zeile 148 `{{ l.repayment_type }}``{{ l.repayment_type|de_label }}`; Zeile 230 `{{ m.kind }}``{{ m.kind|de_label }}`.
- Dropdown-Optionen: `<option value="{{ rh }}">{{ rh|de_label }}</option>`, `<option value="{{ rt }}">{{ rt|de_label }}</option>`, `<option value="{{ k }}">{{ k|de_label }}</option>`.
- Kredit-Zins Zeile 147: `{{ l.annual_rate_pct|eur }} %`.
- Tilgungsplan-JS (`loadLoanSchedule`): Hilfsfunktion ergänzen und alle vier `Number(r.X).toFixed(2)` ersetzen:
```javascript
function fmtEur(n) {
return Number(n).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
// in loadLoanSchedule:
+ '<td>' + fmtEur(r.payment) + ' €</td>'
+ '<td>' + fmtEur(r.interest) + ' €</td>'
+ '<td>' + fmtEur(r.principal) + ' €</td>'
+ '<td>' + fmtEur(r.remaining) + ' €</td></tr>';
```
- [x] **Step 8: Bestehende Punktformat-Assertions anpassen**
Run: `grep -rn '\.00\|\.50\|\.99' tests/test_gui.py` und jede Assertion prüfen, die gerendertes HTML gegen Punktformat testet. Bekannt: `test_index_shows_account_anchor` erwartet `"Anker: 321.00"` → ändern in `"Anker: 321,00"`. Weitere Treffer analog auf Komma-Format umstellen (NUR Assertions gegen `r.text`/HTML — JSON-API-Assertions bleiben Punktformat!).
- [x] **Step 9: Gesamte Suite grün**
Run: `.venv/bin/python -m pytest -q`
Expected: alle Tests passed (154 alt + 8 neu)
- [x] **Step 10: Fable-Testagent-Abnahme einholen** (Faktencheck: Filter-Korrektheit inkl. Rundung/negativer Werte, Vollständigkeit der Template-Umstellung via grep `'%.2f'` → 0 Treffer in `app/templates/`, keine API-Format-Änderung). Erst nach VERIFIED weiter.
- [x] **Step 11: Commit**
```bash
cd /home/wlfb/bin && git add finance/app/formats.py finance/tests/test_formats.py finance/app/routers/gui.py finance/app/templates/ finance/tests/test_gui.py
git commit -m "feat: deutsche Betragsanzeige (eur-Filter) und deutsche GUI-Labels"
```
---
### Task 2: Deutsche Betrags-Eingabe — json-form `data-type="amount"`
**Files:**
- Modify: `app/templates/base.html` (json-form-Extension, `encodeParameters`)
- Modify: `app/templates/planning.html` (alle Betrags-Inputs), `app/templates/index.html` (Anker-Saldo-Input)
- Modify: `tests/test_gui.py`
**Interfaces:**
- Consumes: Filter `|eur` aus Task 1 (Vorbefüllung des Anker-Inputs).
- Produces: json-form-Konvention `data-type="amount"`: Eingabewert wird vor dem JSON-Versand normalisiert (Leerzeichen/`€` entfernen; enthält der Wert ein Komma → Tausenderpunkte entfernen, Komma→Punkt; ohne Komma unverändert). Tasks 4 und 7 verwenden `data-type="amount"` in ihren Formularen.
- [x] **Step 1: Failing Test — Attribute im gerenderten HTML** (die JS-Konvertierung selbst ist serverseitig nicht testbar; sie wird im Fable-Live-Test geprüft). In `tests/test_gui.py`:
```python
def test_betragsfelder_haben_amount_typ_und_deutsche_platzhalter(client):
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
index = client.get("/").text
assert 'placeholder="500,00"' in index
```
Run: `.venv/bin/python -m pytest tests/test_gui.py::test_betragsfelder_haben_amount_typ_und_deutsche_platzhalter -q` → FAIL
- [x] **Step 2: json-form-Extension erweitern**`base.html`, in `encodeParameters` vor dem `else`-Zweig:
```javascript
} 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 {
```
- [x] **Step 3: Eingabefelder umstellen**
- `planning.html` Fixposten-Formular: `<input type="text" name="amount" data-type="amount" placeholder="-49,99" required>`
- `planning.html` Einmalposten-Formular: `placeholder="-2000,00"`, `data-type="amount"`
- `planning.html` Kredit-Formular: `principal``data-type="amount" placeholder="10000,00"`; `annual_rate_pct``data-type="amount" placeholder="4,5"`
- `planning.html` Modifikator-Formular: `<input type="text" name="value" data-type="amount" value="0">`
- `index.html` Anker-Formular: `<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>`
- Die versteckten Felder der Vorschlags-Übernahme (`<input type="hidden" name="amount" value="{{ s.amount }}">`) bekommen KEIN `data-type="amount"` — der Server liefert dort Punktformat, und die amount-Normalisierung ließe es unverändert; ohne Attribut bleibt die Semantik explizit.
- [x] **Step 4: Tests grün + Suite**
Run: `.venv/bin/python -m pytest tests/test_gui.py -q && .venv/bin/python -m pytest -q`
Expected: alles PASS
- [x] **Step 5: Fable-Testagent-Abnahme** — diesmal MIT Live-Anteil: gegen die laufende Instanz (oder lokal gestartete App) im Browser/per curl prüfen, dass ein Fixposten mit Eingabe `1.234,56` als `1234.56` gespeichert wird und `-49,99` korrekt ankommt. Erst nach VERIFIED weiter. (Hinweis: Wenn erst beim Release-Task deployt wird, den Live-Anteil per `uvicorn`-Teststart gegen SQLite erledigen: `cd finance && FB_DATABASE_URL=sqlite:///./demo.sqlite FB_GUI_PASSWORD_HASH=$(.venv/bin/python -c "from app.auth import hash_password; print(hash_password('demo'))") .venv/bin/uvicorn app.main:app --port 8123` — Wegwerf-DB danach löschen.)
- [x] **Step 6: Commit**
```bash
cd /home/wlfb/bin && git add finance/app/templates/ finance/tests/test_gui.py
git commit -m "feat: Betragseingabe mit Komma (data-type=amount in json-form)"
```
---
### Task 3: Fixposten — Start/Ende in der GUI + Datumsvalidierung
**Files:**
- Modify: `app/routers/planning.py` (`RecurringIn`, `patch_recurring`)
- Modify: `app/templates/planning.html` (Tabelle + Anlege-Formular)
- Modify: `tests/test_planning_api.py`, `tests/test_gui.py`
**Interfaces:**
- Consumes: bestehende Felder `RecurringItem.start_date/end_date`, `RecurringIn/RecurringPatch.start_date/end_date` (existieren schon), Filter `|eur`/`|de_label`.
- Produces: Validierungsregel »`end_date >= start_date`, sonst 422 mit Meldung „Ende darf nicht vor Start liegen"« bei POST und PATCH `/api/recurring`. GUI-Spalten »Start«/»Ende«. Task 4 baut das Bearbeiten-Formular auf dieselben Felder.
- [x] **Step 1: Failing API-Tests** — in `tests/test_planning_api.py`:
```python
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
```
Run: `.venv/bin/python -m pytest tests/test_planning_api.py -q` → die neuen Tests FAIL (POST liefert 201 statt 422 bzw. PATCH 200 statt 422)
- [x] **Step 2: Validierung implementieren**`app/routers/planning.py`:
Import ergänzen: `from pydantic import BaseModel, ConfigDict, Field, model_validator`
In `RecurringIn` (gilt via Vererbung NICHT für `RecurringPatch` — dort prüft der Router den Merge-Zustand):
```python
class RecurringIn(BaseModel):
name: str
amount: Decimal
rhythm: Literal["monthly", "quarterly", "yearly"]
due_day: int = Field(ge=1, le=31)
start_date: date | None = None
end_date: date | None = None
category_id: int | None = None
@model_validator(mode="after")
def _ende_nicht_vor_start(self):
if (self.start_date is not None and self.end_date is not None
and self.end_date < self.start_date):
raise ValueError("Ende darf nicht vor Start liegen")
return self
```
In `patch_recurring` nach der `setattr`-Schleife, vor `session.commit()`:
```python
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")
```
- [x] **Step 3: API-Tests grün**
Run: `.venv/bin/python -m pytest tests/test_planning_api.py -q` → PASS
- [x] **Step 4: Failing GUI-Test** — in `tests/test_gui.py`:
```python
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
```
Run: → FAIL
- [x] **Step 5: Template**`planning.html`, Fixposten-Tabelle:
Kopfzeile: `<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>`
Datenzeile (nach der Fälligkeitstag-Zelle):
```html
<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>
```
`colspan` der Leerzeile von 6 auf 8 erhöhen. Anlege-Formular (nach Fälligkeitstag):
```html
<label>Start (optional) <input type="date" name="start_date"></label>
<label>Ende (optional) <input type="date" name="end_date"></label>
```
(Leere Datumsfelder sendet die json-form-Extension bereits als `null`.)
- [x] **Step 6: Suite grün**
Run: `.venv/bin/python -m pytest -q` → PASS
- [x] **Step 7: Fable-Testagent-Abnahme** (Faktencheck: Validierung POST+PATCH inkl. Null-Löschen; GUI-Spalten; `occurrences` klammert Start/Ende korrekt — bestehende Engine-Tests referenzieren). Erst nach VERIFIED weiter.
- [x] **Step 8: Commit**
```bash
cd /home/wlfb/bin && git add finance/app/routers/planning.py finance/app/templates/planning.html finance/tests/
git commit -m "feat: Start/Ende fuer Fixposten in der GUI + Datumsvalidierung"
```
---
### Task 4: Inline-Bearbeiten — Fixposten, Einmalposten, Kredite, Szenario-Kopf
**Files:**
- Modify: `app/templates/planning.html`
- Modify: `tests/test_gui.py`
**Interfaces:**
- Consumes: `PATCH /api/recurring/{id}`, `/api/planned/{id}`, `/api/loans/{id}`, `/api/scenarios/{id}` (existieren alle); json-form inkl. `data-type="amount"` (Task 2); Filter `|eur`/`|de_label` (Task 1); Start/Ende-Felder (Task 3).
- Produces: JS-Helfer `toggleEdit(prefix, id, editing)` in `planning.html`; Formularzeilen-IDs `rec-edit-{id}`, `pln-edit-{id}`, `loan-edit-{id}`, `sc-edit-{id}` und Anzeige-IDs `rec-row-{id}`, `pln-row-{id}`, `loan-row-{id}`, `sc-head-{id}`. Task 7 folgt demselben Muster nicht (Szenario-Unterobjekte haben eigene Formulare), benötigt aber `toggleEdit` nicht.
- [x] **Step 1: Failing GUI-Test**
```python
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.add(Scenario(name="EditS", description="d"))
db.commit()
r = client.get("/planung").text
assert r.count(">Bearbeiten</button>") >= 4
for fragment in ('hx-patch="/api/recurring/', 'hx-patch="/api/planned/',
'hx-patch="/api/loans/', 'hx-patch="/api/scenarios/'):
assert fragment in r, fragment
```
Imports in `tests/test_gui.py` ergänzen: `from app.models.tables import Account, Loan, PlannedItem, RecurringItem, Scenario`
Run: `.venv/bin/python -m pytest tests/test_gui.py::test_planung_hat_bearbeiten_formulare -q` → FAIL
- [x] **Step 2: JS-Helfer in `planning.html`** (in den bestehenden `<script>`-Block):
```javascript
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;
}
```
- [x] **Step 3: Fixposten-Zeile umbauen** — Anzeige-`<tr>` bekommt `id="rec-row-{{ r.id }}"`, in der Aktions-Zelle vor dem Löschen-Formular:
```html
<button type="button" onclick="toggleEdit('rec', {{ r.id }}, true)">Bearbeiten</button>
```
Direkt nach der Anzeige-Zeile die Bearbeitungszeile:
```html
<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>
```
- [x] **Step 4: Einmalposten analog** — Anzeige-`<tr id="pln-row-{{ p.id }}">`, Button `toggleEdit('pln', {{ p.id }}, true)`, Bearbeitungszeile:
```html
<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>
```
- [x] **Step 5: Kredite analog** — Anzeige-`<tr id="loan-row-{{ l.id }}">`, Button `toggleEdit('loan', {{ l.id }}, true)`, Bearbeitungszeile (`colspan="7"`):
```html
<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>
```
Hinweis im Template als Kommentar: eine Kredit-Änderung ändert den Tilgungsplan; Szenarien müssen danach neu durchgerechnet werden.
- [x] **Step 6: Szenario-Kopf** — der beschreibende `<p>` im Szenario-Fieldset bekommt `id="sc-head-{{ sc.id }}"` und daneben einen Button `<button type="button" onclick="toggleEdit('sc', sc_id, true)">Bearbeiten</button>` (mit `{{ sc.id }}`); direkt danach ein verstecktes `<div id="sc-edit-{{ sc.id }}" hidden>`:
```html
<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>
```
Achtung `toggleEdit`-Prefix `sc` erwartet `sc-row-{id}` — hier heißt das Anzeige-Element `sc-head-{id}`. Deshalb `toggleEdit` generisch halten: die Anzeige-ID als `prefix + '-row-' + id` ODER Sonderfall vermeiden, indem der `<p>` die ID `sc-row-{{ sc.id }}` bekommt (einfachste Lösung — so umsetzen, `sc-head` nicht verwenden).
- [x] **Step 7: Tests + Suite grün**
Run: `.venv/bin/python -m pytest -q` → PASS
- [x] **Step 8: Fable-Testagent-Abnahme** — Live-Test (uvicorn-Teststart wie in Task 2 oder deployte Instanz): je Bereich einmal Bearbeiten→Ändern→Speichern→Reload prüfen, inkl. deutscher Betragseingabe im Edit-Formular (vorbefüllt `1.234,56` muss nach Speichern unverändert bleiben) und Abbrechen. Erst nach VERIFIED weiter.
- [x] **Step 9: Commit**
```bash
cd /home/wlfb/bin && git add finance/app/templates/planning.html finance/tests/test_gui.py
git commit -m "feat: Inline-Bearbeiten fuer Fixposten, Einmalposten, Kredite und Szenario-Kopf"
```
---
### Task 5: Migration + Modelle + Engine — Modifikator-Art `ende`, Tabelle `scenario_planned_items`
**Files:**
- Modify: `app/models/tables.py` (`ScenarioModifier.end_date`, neue Klasse `ScenarioPlannedItem`)
- Create: `alembic/versions/c4d7e2a91b53_szenario_ende.py`
- Modify: `app/engine/scenario.py` (`PlainModifier.end_date`, effektives Ende in `build_cashflows`)
- Modify: `tests/test_scenario_engine.py`, `tests/test_models.py`
**Interfaces:**
- Consumes: `engine.recurrence.occurrences(rhythm, due_day, window_start, window_end, item_start, item_end)` (unverändert).
- Produces: `ScenarioModifier.end_date: date | None`; ORM-Klasse `ScenarioPlannedItem(id, scenario_id, name, amount, due)` (Tabelle `scenario_planned_items`); `PlainModifier(target_type, target_id, kind, value, end_date: date | None = None)` — Task 6 baut API/Service darauf. Modifikator-Art-String: `"ende"`.
- [x] **Step 1: Failing Engine-Tests** — in `tests/test_scenario_engine.py` (vorhandene Imports: `build_cashflows`, `PlainRecurring`, `PlainPlanned`, `PlainModifier`, `date`, `Decimal` — prüfen und ggf. ergänzen):
```python
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)
```
Run: `.venv/bin/python -m pytest tests/test_scenario_engine.py -q` → FAIL (`PlainModifier` nimmt kein 5. Argument)
- [x] **Step 2: Engine implementieren**`app/engine/scenario.py`:
`PlainModifier` erweitern:
```python
@dataclass(frozen=True)
class PlainModifier:
target_type: str
target_id: int
kind: str
value: Decimal
end_date: date | None = None
```
Neue Hilfsfunktion + Nutzung in `build_cashflows` (die `for r in recurring:`-Schleife):
```python
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
```
In `build_cashflows`:
```python
for r in recurring:
amount = _modified(r.amount, r.category_id, r.id, modifiers)
if amount is None:
continue
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
r.start_date, _effective_end(r, modifiers)):
flows.append((d, amount))
```
`_modified` braucht KEINE Änderung (`ende` trifft keinen der kind-Zweige und lässt den Betrag unverändert) — der Test aus Step 1 sichert das ab.
- [x] **Step 3: Engine-Tests grün**
Run: `.venv/bin/python -m pytest tests/test_scenario_engine.py -q` → PASS
- [x] **Step 4: Failing Modell-Test** — in `tests/test_models.py`:
```python
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
```
Run: → FAIL (`ImportError`)
- [x] **Step 5: Modelle**`app/models/tables.py`:
In `ScenarioModifier` ergänzen:
```python
# 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)
```
Neue Klasse (nach `ScenarioModifier`):
```python
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)
```
Run: `.venv/bin/python -m pytest tests/test_models.py -q` → PASS
- [x] **Step 6: Alembic-Migration** — Create `alembic/versions/c4d7e2a91b53_szenario_ende.py`:
```python
"""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')
```
- [x] **Step 7: Migration in Wegwerf-DB verifizieren** (env.py liest `FB_DATABASE_URL`):
```bash
cd /home/wlfb/bin/finance
FB_DATABASE_URL=sqlite:////tmp/claude-1010/-home-wlfb-bin/42d50545-5cdd-41c9-9ade-aba54af164df/scratchpad/mig_check.sqlite .venv/bin/python -m alembic upgrade head
FB_DATABASE_URL=sqlite:////tmp/claude-1010/-home-wlfb-bin/42d50545-5cdd-41c9-9ade-aba54af164df/scratchpad/mig_check.sqlite .venv/bin/python -c "
import sqlalchemy as sa
e = sa.create_engine('sqlite:////tmp/claude-1010/-home-wlfb-bin/42d50545-5cdd-41c9-9ade-aba54af164df/scratchpad/mig_check.sqlite')
insp = sa.inspect(e)
assert 'scenario_planned_items' in insp.get_table_names()
assert any(c['name'] == 'end_date' for c in insp.get_columns('scenario_modifiers'))
print('Migration OK')"
```
Expected: `Migration OK`. Danach Wegwerf-Datei löschen.
- [x] **Step 8: Suite grün**
Run: `.venv/bin/python -m pytest -q` → PASS
- [x] **Step 9: Fable-Testagent-Abnahme** (Faktencheck: Minimum-Regel, Kategorie-Treffer, Migration up/down konsistent zu `tables.py`, `_modified` unangetastet für `ende`). Erst nach VERIFIED weiter.
- [x] **Step 10: Commit**
```bash
cd /home/wlfb/bin && git add finance/app/models/tables.py finance/app/engine/scenario.py finance/alembic/versions/c4d7e2a91b53_szenario_ende.py finance/tests/
git commit -m "feat: Engine+Schema fuer Szenario-Ende und szenario-eigene Einmalzahlungen"
```
---
### Task 6: API + Projektions-Service — `ende`-Validierung, Szenario-Einmalzahlungen-CRUD
**Files:**
- Modify: `app/routers/scenarios.py`
- Modify: `app/services/projection_service.py`
- Modify: `tests/test_crud_api.py`, `tests/test_planning_api.py` (Projektion)
**Interfaces:**
- Consumes: `ScenarioPlannedItem`, `ScenarioModifier.end_date`, `PlainModifier(..., end_date)` aus Task 5.
- Produces: `ModifierIn`/`ModifierOut` mit `kind`-Literal `"ende"` und Feld `end_date: date | None`; Endpunkte `GET/POST /api/scenarios/{id}/planned` (`ScenarioPlannedOut(id, scenario_id, name, amount, due)`), `DELETE /api/scenarios/{id}/planned/{item_id}`. Task 7 (GUI) und Task 9 (Demo) rufen genau diese Endpunkte.
- [x] **Step 1: Failing API-Tests** — in `tests/test_crud_api.py`:
```python
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
```
Und in `tests/test_planning_api.py` (Projektion nutzt `_seed_balance`-Muster der Datei):
```python
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.
```
Run: `.venv/bin/python -m pytest tests/test_crud_api.py tests/test_planning_api.py -q` → neue Tests FAIL
- [x] **Step 2: `ModifierIn` erweitern**`app/routers/scenarios.py`:
Imports: `from pydantic import BaseModel, ConfigDict, model_validator` und `ScenarioPlannedItem` in den `app.models.tables`-Import aufnehmen.
```python
class ModifierIn(BaseModel):
target_type: Literal["category", "recurring"]
target_id: int
kind: Literal["percent", "absolute", "remove", "ende"]
value: Decimal = Decimal("0")
end_date: date | None = None
@model_validator(mode="after")
def _ende_braucht_datum(self):
if self.kind == "ende" and self.end_date is None:
raise ValueError("Art »Ende« erfordert ein Datum")
if self.kind != "ende":
self.end_date = None
return self
```
(`ModifierOut` erbt `end_date` automatisch; `add_modifier` übergibt es via `**data.model_dump()` bereits.)
- [x] **Step 3: Szenario-Einmalzahlungen-Endpunkte**`app/routers/scenarios.py` (vor `project_scenario` einfügen):
```python
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()
```
In `delete_scenario` zusätzlich (bei den bestehenden Cleanup-Deletes):
```python
session.execute(delete(ScenarioPlannedItem).where(
ScenarioPlannedItem.scenario_id == scenario_id))
```
- [x] **Step 4: Projektions-Service**`app/services/projection_service.py`:
`ScenarioPlannedItem` in den `app.models.tables`-Import aufnehmen. Nach dem `planned`-Block:
```python
# 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()]
```
Modifier-Konstruktion um `end_date` ergänzen:
```python
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()]
```
- [x] **Step 5: Tests + Suite grün**
Run: `.venv/bin/python -m pytest -q` → PASS
- [x] **Step 6: Fable-Testagent-Abnahme** (Faktencheck + Handrechnung der Projektions-Zahlen aus Step 1; 404-Pfade; Cleanup bei delete_scenario). Erst nach VERIFIED weiter.
- [x] **Step 7: Commit**
```bash
cd /home/wlfb/bin && git add finance/app/routers/scenarios.py finance/app/services/projection_service.py finance/tests/
git commit -m "feat: API fuer Szenario-Ende-Modifikator und szenario-eigene Einmalzahlungen"
```
---
### Task 7: Szenario-GUI — Art »Ende«, Einmalzahlungen-Fieldset, Hilfe
**Files:**
- Modify: `app/routers/gui.py` (`_scenario_rows`, `planung_page`)
- Modify: `app/templates/planning.html` (Modifikator-Formular/-Tabelle, neues Fieldset)
- Modify: `app/templates/hilfe.html`
- Modify: `tests/test_gui.py`
**Interfaces:**
- Consumes: Endpunkte und Schemata aus Task 6; Filter `|eur`/`|de_label`; `ScenarioPlannedItem`.
- Produces: Template-Kontext `row.planned_items` je Szenario; `modifier_kinds = ["percent", "absolute", "remove", "ende"]`; JS `onModKindChange(select)`.
- [x] **Step 1: Failing GUI-Test**
```python
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("/planung").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 "Einmalzahlungen in diesem Szenario" in r
assert "Zufluss-GUI" in r and "5.000,00" in r and "30.07.2026" in r
assert f'hx-post="/api/scenarios/{sc.id}/planned"' in r
```
Imports ergänzen: `ScenarioModifier, ScenarioPlannedItem` aus `app.models.tables`.
Run: → FAIL
- [x] **Step 2: GUI-Router**`app/routers/gui.py`: `ScenarioPlannedItem` importieren. In `planung_page`: `"modifier_kinds": ["percent", "absolute", "remove", "ende"],`. In `_scenario_rows` je Szenario ergänzen:
```python
planned_items = session.execute(
select(ScenarioPlannedItem)
.where(ScenarioPlannedItem.scenario_id == sc.id)
.order_by(ScenarioPlannedItem.due)).scalars().all()
```
und `"planned_items": planned_items,` ins `rows.append`-Dict.
- [x] **Step 3: Modifikator-Formular + -Tabelle**`planning.html`:
Tabelle: Wert-Zelle ersetzen durch
```html
<td>
{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '' }}
{% else %}{{ m.value|eur }}{% endif %}
</td>
```
Formular: Art-Select bekommt `onchange="onModKindChange(this)"`; nach dem Wert-Label:
```html
<label>Wert <input type="text" name="value" data-type="amount" value="0"></label>
<label>Endet am <input type="date" name="end_date" disabled></label>
```
JS (in den `<script>`-Block):
```javascript
function onModKindChange(select) {
var form = select.closest('form');
var isEnde = select.value === 'ende';
form.querySelector('[name="end_date"]').disabled = !isEnde;
form.querySelector('[name="value"]').disabled = isEnde;
}
```
(disabled-Felder fehlen in der Serialisierung → `value` fällt auf Default 0, `end_date` auf null — passt zur Validierung aus Task 6. UX-Regel eingehalten: Felder bleiben sichtbar, nur `disabled`.)
- [x] **Step 4: Einmalzahlungen-Fieldset** — in `planning.html` im Szenario-Loop nach dem Modifikatoren-`</details>`:
```html
<details>
<summary>Einmalzahlungen in diesem Szenario</summary>
<table>
<thead><tr><th>Name</th><th>Betrag</th><th>Fällig am</th><th></th></tr></thead>
<tbody>
{% for sp in row.planned_items %}
<tr>
<td>{{ sp.name }}</td>
<td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td>
<td>{{ sp.due.strftime('%d.%m.%Y') }}</td>
<td>
<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>
{% else %}
<tr><td colspan="4">Keine szenario-eigenen Einmalzahlungen.</td></tr>
{% endfor %}
</tbody>
</table>
<form hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/planned" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" required></label>
<label>Fällig am <input type="date" name="due" required></label>
<button type="submit">Einmalzahlung hinzufügen</button>
</form>
<p class="hint">Zählt nur in diesem Szenario — unabhängig von „Einmalposten einschließen“.</p>
</details>
```
- [x] **Step 5: Hilfe-Abschnitt**`hilfe.html`, im Szenarien-`<li>` (Zeile ~68) ergänzen (generisch, KEINE echten Daten):
```html
Varianten wie „Best Case“ entstehen über Modifikatoren: Art „Ende“ lässt
einen wiederkehrenden Posten (z. B. eine auslaufende Miete) innerhalb des
Szenarios zu einem Datum enden, ohne den Posten selbst zu ändern; „Prozent“/
„Absolut“ kürzen Beträge; „Entfällt“ blendet sie ganz aus. Szenario-eigene
Einmalzahlungen (z. B. ein erwarteter Zufluss) zählen nur in ihrem Szenario.
```
- [x] **Step 6: Suite grün**
Run: `.venv/bin/python -m pytest -q` → PASS
- [x] **Step 7: Fable-Testagent-Abnahme** — Live-Test (uvicorn-Teststart, Muster Task 2): Modifikator »Ende« mit Datumsfeld-Umschaltung anlegen, Einmalzahlung anlegen/löschen, Szenario durchrechnen, Ergebnis plausibel. Erst nach VERIFIED weiter.
- [x] **Step 8: Commit**
```bash
cd /home/wlfb/bin && git add finance/app/routers/gui.py finance/app/templates/ finance/tests/test_gui.py
git commit -m "feat: Szenario-GUI mit Ende-Modifikator und Einmalzahlungen je Szenario"
```
---
### Task 8: Release v0.6.0 — Version, Redeploy, Live-Smoke
**Files:**
- Modify: `finance/VERSION` (`0.5.0``0.6.0`)
- Modify: `.superpowers/sdd/progress.md` (Ledger, unter `/home/wlfb/bin`)
**Interfaces:**
- Consumes: alle Tasks 17 committet, Suite grün.
- Produces: laufender Pod v0.6.0 mit angewendeter Migration `c4d7e2a91b53` — Voraussetzung für Task 9.
- [x] **Step 1: Gesamte Suite final**
Run: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q`
Expected: alle Tests passed, 0 failed
- [x] **Step 2: Version hochzählen**
```bash
echo "0.6.0" > /home/wlfb/bin/finance/VERSION
cd /home/wlfb/bin && git add finance/VERSION && git commit -m "chore: Version 0.6.0"
```
- [x] **Step 3: Redeploy** (baut Image mit Tag 0.6.0, Entrypoint führt `alembic upgrade head` aus):
```bash
cd /home/wlfb/bin && ./create_pod_finance.sh
```
Expected: Skript endet erfolgreich, Service aktiv.
- [x] **Step 4: Live-Smoke**
```bash
systemctl --user is-active pod-finance_pod.service # -> active
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8096/login # -> 200
curl -s http://127.0.0.1:8096/api/version # -> {"version":"0.6.0"}
podman exec finance-db_ctr psql -U finance -d finance -c "\d scenario_planned_items" | head -5
podman exec finance-db_ctr psql -U finance -d finance -c "SELECT column_name FROM information_schema.columns WHERE table_name='scenario_modifiers' AND column_name='end_date';"
```
Expected: Tabelle existiert, Spalte `end_date` vorhanden. (DB-Name/User ggf. aus `~/.local/share/finance_pod/.env` ablesen, falls abweichend.)
- [x] **Step 5: Fable-Testagent-Abnahme** — Live: Login in die GUI, `/planung` rendert deutsche Formate, ein Test-Fixposten mit Komma-Eingabe anlegen und wieder löschen, Migration-Status wie Step 4. Erst nach VERIFIED weiter.
- [x] **Step 6: Ledger + Push**
`.superpowers/sdd/progress.md`: je Task 18 eine Zeile (Commit-Range, Fable-Befund) im Stil der bestehenden Einträge. Dann:
```bash
cd /home/wlfb/bin && git add .superpowers/sdd/progress.md docs/superpowers/plans/2026-07-20-ausbaustufe-5.md && git commit -m "docs: Ledger Ausbaustufe 5 + Plan-Haekchen" && git push
```
---
### Task 9: Demo »Best Case« live anlegen + Nutzer-Erklärung (kein Code)
**Files:** keine Repo-Änderungen (nur Live-DB + Chat). **DATENSCHUTZ:** Die konkreten Empfänger/Beträge/Termine stammen aus dem Nutzer-Chat vom 2026-07-20 — sie dürfen in Live-DB und Chat-Antwort auftauchen, aber NICHT in Commits/Ledger/Doku (Ledger-Eintrag nur generisch: »Demo-Szenario live angelegt und durchgerechnet«).
**Interfaces:**
- Consumes: deployte v0.6.0 (Task 8); `POST /api/scenarios`, `POST /api/scenarios/{id}/modifiers` (kind `ende`), `POST /api/scenarios/{id}/planned`, `POST /api/scenarios/{id}/project`; GUI-Login via `FB_PASSWORD` aus `~/.local/share/finance_pod/.env`.
- [x] **Step 1: Bestehende Fixposten prüfen** — per API (`FB_API_KEY` aus der `.env`; Header `Authorization: Bearer <key>`): `GET http://127.0.0.1:8096/api/recurring` und mit den zwei im Chat genannten Mietposten abgleichen (Empfänger + Betrag). Fehlen sie: über `GET /api/recurring/suggestions` prüfen, ob sie als Vorschlag erkannt sind, und mit den Chat-Daten als monatliche Fixposten anlegen (`POST /api/recurring`, negativer Betrag, `due_day` aus den echten Buchungen der Vorschläge).
- [x] **Step 2: Szenario »Best Case« anlegen**`POST /api/scenarios` mit `{"name": "Best Case", "description": "<kurze deutsche Beschreibung>"}` (Standard-Flags belassen).
- [x] **Step 3: Die zwei »Ende«-Modifikatoren setzen** — je Mietposten `POST /api/scenarios/{id}/modifiers` mit `{"target_type": "recurring", "target_id": <fixposten-id>, "kind": "ende", "end_date": "<termin aus dem Chat>"}`.
- [x] **Step 4: Szenario-Einmalzahlung anlegen**`POST /api/scenarios/{id}/planned` mit Name/Betrag/Datum aus dem Chat (positiver Betrag = Zufluss).
- [x] **Step 5: Durchrechnen + Plausibilisierung**`POST /api/scenarios/{id}/project`; prüfen, dass die Serie die Einmalzahlung am Stichtag springt und die beiden Mietabflüsse nach ihren Endterminen fehlen (Vergleich mit einem Basis-Szenario ohne Modifikatoren, falls vorhanden).
- [x] **Step 6: Fable-Testagent-Abnahme** — Live-Faktencheck der Szenario-Einträge und der Projektion (Handrechnung der Sprünge). Erst nach VERIFIED weiter.
- [x] **Step 7: Nutzer-Erklärung im Chat** — Schritt-für-Schritt beschreiben, wie man die Eingabe in der GUI macht (Planung → Szenario anlegen → Modifikatoren-Details → Art »Ende« wählen → Datum; Einmalzahlungen-Details → Betrag mit Komma eingeben), plus Ergebnis-Zusammenfassung (Tiefpunkt, Verlauf) und Grafana-Hinweis. Ledger-Eintrag (generisch) ergänzen und committen.
---
## Self-Review (durchgeführt beim Planschreiben)
- **Spec-Abdeckung:** Teil 1 → Tasks 12; Teil 2 → Tasks 34; Teil 3 → Tasks 57; Teil 4 (Demo/Release/Hilfe) → Tasks 7 (Hilfe), 8, 9. Migration-Test (Spec) → Task 5 Step 7. Keine Lücken gefunden.
- **Platzhalter:** keine; alle Steps enthalten konkreten Code/Kommandos.
- **Typ-Konsistenz:** `PlainModifier(..., end_date=None)` (Task 5) = Nutzung in Task 6; `ScenarioPlannedOut(id, scenario_id, name, amount, due)` (Task 6) = GUI-Kontext `row.planned_items` (Task 7); Modifikator-Art-String überall `"ende"`; `toggleEdit`-IDs `*-row-`/`*-edit-` konsistent (Szenario-Kopf nutzt `sc-row-{id}`, siehe Task 4 Step 6).