From c065ade0d5ac149c7ca1301390e526b93bf6683f5896cc53a4ac391ba536923a Mon Sep 17 00:00:00 2001 From: wlfb Date: Wed, 30 Sep 2026 11:17:50 +0200 Subject: [PATCH] docs: Plan Ausbaustufe 14 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../plans/2026-09-30-ausbaustufe-14.md | 1671 +++++++++++++++++ 1 file changed, 1671 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-30-ausbaustufe-14.md diff --git a/docs/superpowers/plans/2026-09-30-ausbaustufe-14.md b/docs/superpowers/plans/2026-09-30-ausbaustufe-14.md new file mode 100644 index 0000000..b9eadb4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-ausbaustufe-14.md @@ -0,0 +1,1671 @@ +# Ausbaustufe 14: Szenario-Einträge mit Zeitraum, „Neuer Betrag", szenario-eigene wiederkehrende Zahlungen — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Szenario-Einträge (Prozent, Kürzung, Neuer Betrag, Entfällt) wirken optional nur in einem Zeitraum „Gültig ab/bis"; neue Eintragsart „Neuer Betrag"; Szenarien bekommen eigene wiederkehrende Zahlungen (mit Kategorie) und Einmalzahlungen mit Kategorie; dazu fünf Kleinpunkte aus der A14-Kandidatenliste; Release v0.14.0. + +**Architecture:** Eine Alembic-Revision erweitert `scenario_modifiers` (`start_date`), `scenario_planned_items` (`category_id`) und legt `scenario_recurring_items` an. Die reine Engine (`engine/scenario.py`) bewertet Einträge künftig je Stichtag (`modified_amount(..., on=)`): Projektion je Fälligkeit, Aufteilung am Fälligkeitstag des Monats. `load_scenario_inputs` lädt szenario-eigene Zahlungen als `PlainRecurring(id=None, …)`, damit Posten-Einträge sie nie treffen. Die Szenarien-Seite bekommt zwei Formulare; ihre Umschaltlogik zieht aus dem Template in `static/szenarien.js` (`window.FBSzenario`, jsdom-testbar wie `doppelzaehlung.js`). + +**Tech Stack:** FastAPI, Pydantic v2, SQLAlchemy 2.0, Alembic, Jinja2, htmx 2.0.6, pytest (SQLite-Fixtures `db`/`client`), node:test + jsdom (per Podman), Postgres 17 (Wegwerf-Container für Migrationstest). + +**Spec:** `docs/superpowers/specs/2026-09-30-ausbaustufe-14-design.md` + +## Global Constraints + +- **DATENSCHUTZ:** `finance/tests/fixtures/*` = echte Kontodaten, nie lesen/committen/zitieren. Tests nur mit erfundenen Werten; der Posten aus dem Anlass heißt in Tests/Doku nie beim echten Namen. Live-Smoke: nur Statuscodes, Zählwerte, Booleans. +- **Geld = `decimal.Decimal`** überall, kein `float` für Beträge. +- **Deutsch** für GUI-Texte, Fehlermeldungen, Kommentare, Commit-Messages; Datum TT.MM.JJJJ. +- **Engine bleibt rein:** `app/engine/*` importiert nur Standardbibliothek und `app.engine.*` (kein `app.models`, `app.services`). +- **UX-Regel:** nicht passende Felder bleiben sichtbar, werden `disabled` + Tooltip `Für diese Eintragsart nicht relevant`; Fehler über den bestehenden `htmx:responseError`-Handler. +- **Exakte Namen:** Art-Literal `set` (Label „Neuer Betrag"), `absolute` → Label „Kürzung (€)"; Tabelle `scenario_recurring_items`, Modell `ScenarioRecurringItem`; Endpunkt `/api/scenarios/{id}/recurring`; Alembic-Revision `e7f1a9c3b214` (`down_revision = 'd5e8b3c7a410'`); JS-Global `window.FBSzenario` mit `zielTyp`, `aenderungArt`, `zahlungArt`, `init`; Engine-Helfer `gilt_am`, `modified_amount(..., *, on)`, `recurrence.due_in_month`, `loans.schedule_for`. +- **Fehlertexte (exakt):** „Art »Ende« erfordert ein Datum", „Art »Neuer Betrag« ist nur für Posten möglich", „»Gültig ab« liegt nach »Gültig bis«", „Wiederkehrende Zahlung nicht gefunden", „Kategorie nicht gefunden". +- **Nicht ändern:** `.torte figcaption` bleibt `font-weight: bold` (ausdrücklicher Nutzerwunsch). Grafana, Views, `planning_monthly` bleiben unverändert. +- **Test-Suite grün:** `cd finance && .venv/bin/python -m pytest -q` — Basis **377 passed, 3 skipped**; jede Task erhöht „passed". +- **JS-Tests** laufen auf dem Host nicht (kein node): Gate per Podman `docker.io/library/node:22-alpine` mit jsdom, Dateien explizit übergeben (kein Verzeichnis-Argument). +- **Fable-Testagent-Gate je Task** (CLAUDE.md): Implementer committet, Fable prüft; kein Task gilt vor dem Gate als abgeschlossen. Ledger-Eintrag je Task in `.superpowers/sdd/progress.md`. +- **Direkt auf `main`** (wie A13). Commits enden mit `Co-Authored-By: Claude Opus 5.5 (1M context) `. +- **Live-Szenario des Nutzers nicht anfassen** — der Nutzer passt „sein" Szenario selbst an. + +## Review Focus + +1. **Bestandsdaten nach der Migration unverändert:** vorhandene `ende`-Einträge behalten `end_date`, Prozent/Absolut/Entfällt ohne Datum wirken weiter unbegrenzt; Downgrade stellt die alte Semantik her — Test in Task 1 (`test_migration_a14_up_down`, Postgres). +2. **Grenztage inklusiv:** eine Fälligkeit genau am „Gültig ab"- bzw. „Gültig bis"-Tag wird geändert, der Tag davor/danach nicht — Test in Task 2 (`test_zeitraum_grenzen_inklusiv`). +3. **ID-Kollision:** ein Posten-Eintrag, dessen `target_id` zufällig der ID einer szenario-eigenen Zahlung entspricht, darf diese nicht treffen — Test in Task 3 (`test_szenario_zahlung_zaehlt_immer_und_id_kollision`). +4. **Artwechsel per PATCH:** Prozent mit Zeitraum → Ende nullt `start_date`; Ende → Prozent behält `end_date` als „Gültig bis" — Test in Task 3 (`test_modifier_patch_artwechsel_zeitraum`). +5. **Formular zurückschalten:** Zahlung „Wiederkehrend" → „Einmalzahlung" muss wieder an `/planned` senden und Rhythmus/Fälligkeitstag/Start/Ende sperren (sonst 422 oder falscher Endpunkt) — Test in Task 4 (`szenarien.dom.test.js`, „Zahlung: Einmal → Wiederkehrend → Einmal"). + +--- + +### Task 1: Datenmodell + Alembic-Revision + +**Files:** +- Create: `finance/alembic/versions/e7f1a9c3b214_szenario_zeitraum.py` +- Modify: `finance/app/models/tables.py` (`ScenarioModifier`, `ScenarioPlannedItem`, neu `ScenarioRecurringItem`) +- Test: `finance/tests/test_models.py`, Create: `finance/tests/test_migration_pg.py` + +**Interfaces:** +- Produces: `ScenarioModifier.start_date: date | None`; `ScenarioPlannedItem.category_id: int | None`; `ScenarioRecurringItem` (Tabelle `scenario_recurring_items`: `id`, `scenario_id`, `name`, `amount`, `rhythm`, `due_day`, `start_date`, `end_date`, `category_id`). + +- [ ] **Step 1: Failing Test (SQLite-Roundtrip)** — an `finance/tests/test_models.py` anhängen: + +```python +def test_a14_szenario_felder_roundtrip(db): + from app.models.tables import (Category, Scenario, ScenarioModifier, + ScenarioPlannedItem, ScenarioRecurringItem) + cat = Category(name="A14-Kat") + sc = Scenario(name="A14-Modell", description="") + db.add_all([cat, sc]) + db.flush() + db.add_all([ + ScenarioModifier(scenario_id=sc.id, target_type="recurring", target_id=1, + kind="set", value=Decimal("-80.00"), + start_date=date(2030, 2, 1), end_date=date(2030, 3, 31)), + ScenarioPlannedItem(scenario_id=sc.id, name="E", amount=Decimal("-5.00"), + due=date(2030, 1, 1), category_id=cat.id), + ScenarioRecurringItem(scenario_id=sc.id, name="W", amount=Decimal("-10.00"), + rhythm="monthly", due_day=14, + start_date=date(2030, 1, 16), end_date=None, + category_id=cat.id), + ]) + db.commit() + m = db.query(ScenarioModifier).one() + assert (m.kind, m.start_date, m.end_date) == ("set", date(2030, 2, 1), date(2030, 3, 31)) + assert db.query(ScenarioPlannedItem).one().category_id == cat.id + w = db.query(ScenarioRecurringItem).one() + assert (w.scenario_id, w.rhythm, w.due_day, w.category_id) == (sc.id, "monthly", 14, cat.id) +``` + +- [ ] **Step 2: Test laufen lassen** — `cd finance && .venv/bin/python -m pytest -q tests/test_models.py::test_a14_szenario_felder_roundtrip` → FAIL (`ImportError: cannot import name 'ScenarioRecurringItem'`). + +- [ ] **Step 3: Modelle** — in `finance/app/models/tables.py`: + +`ScenarioModifier` (Kommentar über `end_date` ersetzen, `start_date` ergänzen): + +```python +class ScenarioModifier(Base): + __tablename__ = "scenario_modifiers" + id: Mapped[int] = mapped_column(primary_key=True) + scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id")) + target_type: Mapped[str] = mapped_column(String(20)) + target_id: Mapped[int] = mapped_column(Integer) + kind: Mapped[str] = mapped_column(String(20)) + value: Mapped[Decimal] = mapped_column(MONEY, default=Decimal("0")) + # kind="ende": Datum, an dem der Ziel-Posten in diesem Szenario endet (A5). + # Alle anderen Arten: „Gültig bis" (inklusiv, NULL = unbegrenzt, A14). + end_date: Mapped[date | None] = mapped_column(Date, nullable=True) + # „Gültig ab" (inklusiv, NULL = unbegrenzt); bei kind="ende" immer NULL (A14). + start_date: Mapped[date | None] = mapped_column(Date, nullable=True) +``` + +`ScenarioPlannedItem` bekommt als letzte Spalte: + +```python + category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True) +``` + +Neu direkt nach `ScenarioPlannedItem`: + +```python +class ScenarioRecurringItem(Base): + """Wiederkehrende Zahlung, die NUR in einem bestimmten Szenario zählt - + unabhängig von Scenario.include_recurring. Posten-Einträge + (target_type="recurring") treffen sie nie, Kategorie-Einträge schon. + Ausbaustufe 14.""" + __tablename__ = "scenario_recurring_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) + rhythm: Mapped[str] = mapped_column(String(20)) + due_day: Mapped[int] = mapped_column(Integer) + start_date: Mapped[date | None] = mapped_column(Date, nullable=True) + end_date: Mapped[date | None] = mapped_column(Date, nullable=True) + category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True) +``` + +- [ ] **Step 4: Migration** — `finance/alembic/versions/e7f1a9c3b214_szenario_zeitraum.py`: + +```python +"""szenario zeitraum, neuer betrag, szenario-eigene wiederkehrende zahlungen + +Revision ID: e7f1a9c3b214 +Revises: d5e8b3c7a410 +Create Date: 2026-09-30 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'e7f1a9c3b214' +down_revision: Union[str, Sequence[str], None] = 'd5e8b3c7a410' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +FK_PLANNED_CATEGORY = 'fk_scenario_planned_items_category_id' + + +def upgrade() -> None: + # Bestandsdaten: percent/absolute/remove haben end_date NULL (API hat es + # bisher genullt) und gelten damit unverändert unbegrenzt. + op.add_column('scenario_modifiers', sa.Column('start_date', sa.Date(), nullable=True)) + op.add_column('scenario_planned_items', + sa.Column('category_id', sa.Integer(), nullable=True)) + op.create_foreign_key(FK_PLANNED_CATEGORY, 'scenario_planned_items', + 'categories', ['category_id'], ['id']) + op.create_table( + 'scenario_recurring_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('rhythm', sa.String(length=20), nullable=False), + sa.Column('due_day', sa.Integer(), nullable=False), + sa.Column('start_date', sa.Date(), nullable=True), + sa.Column('end_date', sa.Date(), nullable=True), + sa.Column('category_id', sa.Integer(), sa.ForeignKey('categories.id'), nullable=True), + ) + op.create_index('ix_scenario_recurring_items_scenario_id', + 'scenario_recurring_items', ['scenario_id']) + + +def downgrade() -> None: + # Alte Semantik: "set" gab es nicht, end_date nur bei "ende". + op.execute("DELETE FROM scenario_modifiers WHERE kind = 'set'") + op.execute("UPDATE scenario_modifiers SET end_date = NULL WHERE kind <> 'ende'") + op.drop_index('ix_scenario_recurring_items_scenario_id', 'scenario_recurring_items') + op.drop_table('scenario_recurring_items') + op.drop_constraint(FK_PLANNED_CATEGORY, 'scenario_planned_items', type_='foreignkey') + op.drop_column('scenario_planned_items', 'category_id') + op.drop_column('scenario_modifiers', 'start_date') +``` + +- [ ] **Step 5: Postgres-Migrationstest** — `finance/tests/test_migration_pg.py` (skip ohne `FB_TEST_PG_URL`, Muster `test_views_pg.py`): + +```python +"""Postgres-only: Alembic-Revision e7f1a9c3b214 (Ausbaustufe 14) hoch/runter +auf einer Wegwerf-DB (Name muss auf '_test' enden). Beispiel: + podman run -d --rm --name a14-pg -e POSTGRES_PASSWORD=wegwerf \ + -e POSTGRES_DB=fb_test -p 127.0.0.1:55432:5432 docker.io/library/postgres:17.10 + FB_TEST_PG_URL=postgresql+psycopg://postgres:wegwerf@127.0.0.1:55432/fb_test \ + .venv/bin/python -m pytest -q tests/test_migration_pg.py +""" +import os +from pathlib import Path + +import pytest +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, inspect, text +from sqlalchemy.engine import make_url + +PG_URL = os.environ.get("FB_TEST_PG_URL") +pytestmark = pytest.mark.skipif(not PG_URL, reason="FB_TEST_PG_URL nicht gesetzt") +ROOT = Path(__file__).resolve().parent.parent + + +@pytest.fixture +def alembic_cfg(monkeypatch): + assert make_url(PG_URL).database.endswith("_test"), "nur Wegwerf-DB *_test!" + monkeypatch.setenv("FB_DATABASE_URL", PG_URL) + from app.config import get_settings + get_settings.cache_clear() + engine = create_engine(PG_URL) + with engine.begin() as conn: + conn.execute(text("DROP SCHEMA public CASCADE; CREATE SCHEMA public")) + cfg = Config(str(ROOT / "alembic.ini")) + cfg.set_main_option("script_location", str(ROOT / "alembic")) + yield cfg, engine + # Schema leeren, damit test_views_pg.py (create_all) nicht auf dem + # herabgestuften Alembic-Stand aufsetzt. + with engine.begin() as conn: + conn.execute(text("DROP SCHEMA public CASCADE; CREATE SCHEMA public")) + engine.dispose() + get_settings.cache_clear() + + +def test_migration_a14_up_down(alembic_cfg): + cfg, engine = alembic_cfg + command.upgrade(cfg, "d5e8b3c7a410") + with engine.begin() as conn: + conn.execute(text("INSERT INTO scenarios (id, name, description, include_recurring, " + "include_planned) VALUES (1, 'S', '', true, true)")) + conn.execute(text( + "INSERT INTO scenario_modifiers (id, scenario_id, target_type, target_id, kind, " + "value, end_date) VALUES " + "(1, 1, 'recurring', 7, 'ende', 0, '2030-10-15'), " + "(2, 1, 'category', 3, 'percent', 10, NULL)")) + command.upgrade(cfg, "head") + with engine.begin() as conn: + rows = conn.execute(text("SELECT id, kind, value, start_date, end_date " + "FROM scenario_modifiers ORDER BY id")).all() + assert [(r.id, r.kind, str(r.value), r.start_date, str(r.end_date)) + for r in rows] == [(1, "ende", "0.00", None, "2030-10-15"), + (2, "percent", "10.00", None, "None")] + conn.execute(text( + "INSERT INTO scenario_modifiers (id, scenario_id, target_type, target_id, kind, " + "value, start_date, end_date) VALUES " + "(3, 1, 'recurring', 7, 'set', -80, '2030-11-01', NULL), " + "(4, 1, 'recurring', 7, 'percent', 50, '2030-11-01', '2030-12-31')")) + conn.execute(text( + "INSERT INTO scenario_recurring_items (scenario_id, name, amount, rhythm, due_day) " + "VALUES (1, 'W', -10, 'monthly', 14)")) + insp = inspect(engine) + assert "scenario_recurring_items" in insp.get_table_names() + assert "category_id" in {c["name"] for c in insp.get_columns("scenario_planned_items")} + command.downgrade(cfg, "d5e8b3c7a410") + with engine.begin() as conn: + rows = conn.execute(text("SELECT id, kind, end_date FROM scenario_modifiers " + "ORDER BY id")).all() + assert [(r.id, r.kind, str(r.end_date)) for r in rows] == [ + (1, "ende", "2030-10-15"), (2, "percent", "None"), (4, "percent", "None")] + insp = inspect(engine) + assert "scenario_recurring_items" not in insp.get_table_names() + assert "start_date" not in {c["name"] for c in insp.get_columns("scenario_modifiers")} +``` + +- [ ] **Step 6: Tests laufen lassen** — `.venv/bin/python -m pytest -q` → alle grün, `test_migration_pg` skipped (Host ohne `FB_TEST_PG_URL`). Dann mit Wegwerf-Postgres (Kommando aus dem Docstring) `FB_TEST_PG_URL=… .venv/bin/python -m pytest -q tests/test_migration_pg.py tests/test_views_pg.py` → PASS; Container danach `podman stop a14-pg`. + +- [ ] **Step 7: Commit** + +```bash +git add finance/app/models/tables.py finance/alembic/versions/e7f1a9c3b214_szenario_zeitraum.py \ + finance/tests/test_models.py finance/tests/test_migration_pg.py +git commit -m "feat: Szenario-Zeitraum, Neuer Betrag, szenario-eigene Zahlungen – Datenmodell + Migration (A14-T1)" +``` + +- [ ] **Step 8: Fable-Gate** — Wegwerf-Postgres selbst starten; `test_migration_pg.py` + `test_views_pg.py` grün; zusätzlich `alembic upgrade head` → `alembic downgrade -1` → `alembic upgrade head` per CLI (`FB_DATABASE_URL=… .venv/bin/alembic …`) ohne Fehler; Modell ↔ Migration abgleichen (Spaltentypen, Nullbarkeit, FK-Ziele, Index-Name) per `inspect` gegen `Base.metadata`; bestätigen, dass Live-DB unberührt ist (keine Verbindung zur Produktiv-DB). + +--- + +### Task 2: Engine — Stichtag, Zeitraum, „Neuer Betrag" + +**Files:** +- Modify: `finance/app/engine/scenario.py`, `finance/app/engine/breakdown.py`, `finance/app/engine/recurrence.py` +- Test: `finance/tests/test_scenario_engine.py`, `finance/tests/test_breakdown.py`, `finance/tests/test_recurrence.py`, `finance/tests/test_scenario_inputs.py` (bestehender Aufruf anpassen) + +**Interfaces:** +- Consumes: nichts aus Task 1 (Engine ist rein). +- Produces: + - `PlainRecurring.id: int | None` (`None` = szenario-eigene Zahlung). + - `PlainModifier(target_type, target_id, kind, value, end_date=None, start_date=None)` — **`start_date` als letztes Feld** (bestehende Positionsaufrufe mit `end_date` an 5. Stelle bleiben gültig). + - `gilt_am(m: PlainModifier, on: date) -> bool`. + - `modified_amount(amount, category_id, recurring_id, modifiers, *, on: date) -> Decimal | None` (`on` ist Pflicht, keyword-only). + - `recurrence.due_in_month(month: date, due_day: int) -> date` (Fälligkeitstag im Monat von `month`, auf Monatslänge geklemmt). + +- [ ] **Step 1: Failing Tests** — an `finance/tests/test_scenario_engine.py` anhängen: + +```python +D = Decimal + + +def _rec(amount="-100.00", due_day=14, id=1, cat=None): + return PlainRecurring(id, "R", D(amount), "monthly", due_day, None, None, cat) + + +def test_zeitraum_grenzen_inklusiv(): + mods = [PlainModifier("recurring", 1, "percent", D("50"), + end_date=date(2030, 3, 14), start_date=date(2030, 2, 14))] + flows = build_cashflows([_rec()], [], [], [], mods, date(2030, 1, 1), date(2030, 4, 30)) + assert flows == [(date(2030, 1, 14), D("-100.00")), + (date(2030, 2, 14), D("-50.00")), + (date(2030, 3, 14), D("-50.00")), + (date(2030, 4, 14), D("-100.00"))] + + +def test_ab_stichtag_erst_folgemonat(): + # Fällig am 14., gültig ab 16.10. -> Oktober voll, ab November halbiert. + mods = [PlainModifier("recurring", 1, "percent", D("50"), start_date=date(2030, 10, 16))] + flows = build_cashflows([_rec("-200.00")], [], [], [], mods, + date(2030, 10, 1), date(2030, 12, 31)) + assert flows == [(date(2030, 10, 14), D("-200.00")), + (date(2030, 11, 14), D("-100.00")), + (date(2030, 12, 14), D("-100.00"))] + + +def test_neuer_betrag_dann_prozent(): + mods = [PlainModifier("recurring", 1, "set", D("-80.00"), start_date=date(2030, 2, 1)), + PlainModifier("recurring", 1, "percent", D("25"))] + flows = build_cashflows([_rec(due_day=1)], [], [], [], mods, + date(2030, 1, 1), date(2030, 2, 28)) + assert flows == [(date(2030, 1, 1), D("-75.00")), (date(2030, 2, 1), D("-60.00"))] + + +def test_entfaellt_nur_im_zeitraum(): + mods = [PlainModifier("recurring", 1, "remove", D("0"), + end_date=date(2030, 2, 28), start_date=date(2030, 2, 1))] + flows = build_cashflows([_rec(due_day=1)], [], [], [], mods, + date(2030, 1, 1), date(2030, 3, 31)) + assert [d for d, _ in flows] == [date(2030, 1, 1), date(2030, 3, 1)] + + +def test_szenario_zahlung_ohne_id_nur_von_kategorie_getroffen(): + rec = [PlainRecurring(None, "S", D("-100.00"), "monthly", 1, None, None, 7)] + mods = [PlainModifier("recurring", 1, "remove", D("0")), + PlainModifier("category", 7, "percent", D("10"))] + flows = build_cashflows(rec, [], [], [], mods, date(2030, 1, 1), date(2030, 1, 31)) + assert flows == [(date(2030, 1, 1), D("-90.00"))] + + +def test_einmalposten_stichtag_ist_faelligkeit(): + planned = [PlainPlanned("E", D("-100.00"), date(2030, 1, 31), 7), + PlainPlanned("F", D("-100.00"), date(2030, 2, 1), 7)] + mods = [PlainModifier("category", 7, "percent", D("50"), start_date=date(2030, 2, 1))] + flows = build_cashflows([], planned, [], [], mods, date(2030, 1, 1), date(2030, 2, 28)) + assert flows == [(date(2030, 1, 31), D("-100.00")), (date(2030, 2, 1), D("-50.00"))] + + +def test_ende_unveraendert_mit_zeitraum_feldern(): + # "ende" wirkt weiter nur über effective_end, nie als Betragsänderung. + mods = [PlainModifier("recurring", 1, "ende", D("0"), date(2030, 2, 15))] + flows = build_cashflows([_rec(due_day=1)], [], [], [], mods, + date(2030, 1, 1), date(2030, 4, 30)) + assert flows == [(date(2030, 1, 1), D("-100.00")), (date(2030, 2, 1), D("-100.00"))] +``` + +An `finance/tests/test_breakdown.py` anhängen: + +```python +def test_stichtag_ist_faelligkeitstag_im_monat(): + r = PlainRecurring(1, "R1", D("-200.00"), "monthly", 14, None, None, None) + mods = [PlainModifier("recurring", 1, "percent", D("50"), start_date=date(2030, 10, 16))] + assert by_name(rows(month=date(2030, 10, 1), recurring=[r], modifiers=mods)) == {"R1": D("-200.00")} + assert by_name(rows(month=date(2030, 11, 1), recurring=[r], modifiers=mods)) == {"R1": D("-100.00")} + + +def test_stichtag_monatsende_geklemmt(): + # Fällig am 31.: im Februar ist der Stichtag der 28.02. (liegt im Zeitraum). + r = PlainRecurring(1, "R1", D("-100.00"), "monthly", 31, None, None, None) + mods = [PlainModifier("recurring", 1, "remove", D("0"), end_date=date(2030, 2, 28))] + assert rows(month=date(2030, 2, 1), recurring=[r], modifiers=mods) == [] + assert by_name(rows(month=date(2030, 3, 1), recurring=[r], modifiers=mods)) == {"R1": D("-100.00")} + + +def test_szenario_zahlung_ohne_recurring_id(): + r = PlainRecurring(None, "S", D("-30.00"), "monthly", 1, None, None, 1) + rs = rows(recurring=[r]) + assert by_name(rs) == {"S": D("-30.00")} + assert (rs[0].recurring_id, rs[0].category) == (None, "Wohnen") +``` + +An `finance/tests/test_recurrence.py` anhängen (Import `due_in_month` ergänzen): + +```python +def test_due_in_month_klemmt_auf_monatsende(): + from app.engine.recurrence import due_in_month + assert due_in_month(date(2030, 2, 1), 31) == date(2030, 2, 28) + assert due_in_month(date(2032, 2, 10), 30) == date(2032, 2, 29) + assert due_in_month(date(2030, 10, 1), 14) == date(2030, 10, 14) +``` + +In `finance/tests/test_scenario_inputs.py::test_modifikator_helfer_oeffentlich` die zwei `modified_amount`-Aufrufe um `on=date(2030, 1, 1)` ergänzen: + +```python + assert modified_amount(Decimal("-100.00"), None, 1, mods, on=date(2030, 1, 1)) == Decimal("-90.00") + assert modified_amount(Decimal("-100.00"), None, 2, mods, on=date(2030, 1, 1)) == Decimal("-100.00") +``` + +- [ ] **Step 2: Tests laufen lassen** — `.venv/bin/python -m pytest -q tests/test_scenario_engine.py tests/test_breakdown.py tests/test_recurrence.py tests/test_scenario_inputs.py` → FAIL (`unexpected keyword argument 'start_date'` / `'on'`, `cannot import name 'due_in_month'`). + +- [ ] **Step 3: `recurrence.py`** — nach `_clamp` ergänzen: + +```python +def due_in_month(month: date, due_day: int) -> date: + """Fälligkeitstag `due_day` im Monat von `month`, auf die Monatslänge + geklemmt (gleiche Regel wie occurrences).""" + return _clamp(month.year, month.month, due_day) +``` + +- [ ] **Step 4: `scenario.py`** — Dataclasses und Helfer ersetzen: + +```python +@dataclass(frozen=True) +class PlainRecurring: + id: int | None # None = szenario-eigene Zahlung (Posten-Einträge treffen sie nie) + name: str + amount: Decimal + rhythm: str + due_day: int + start_date: date | None + end_date: date | None + category_id: int | None +``` + +```python +@dataclass(frozen=True) +class PlainModifier: + target_type: str + target_id: int + kind: str + value: Decimal + end_date: date | None = None # ende: Enddatum; sonst „Gültig bis" (inklusiv) + start_date: date | None = None # „Gültig ab" (inklusiv); bei ende immer None + + +def _trifft(m: PlainModifier, category_id: int | None, recurring_id: int | None) -> bool: + return ((m.target_type == "category" and category_id is not None + and category_id == m.target_id) + or (m.target_type == "recurring" and recurring_id is not None + and recurring_id == m.target_id)) + + +def gilt_am(m: PlainModifier, on: date) -> bool: + """Liegt der Stichtag im Zeitraum des Eintrags (Grenzen inklusiv)?""" + return ((m.start_date is None or on >= m.start_date) + and (m.end_date is None or on <= m.end_date)) + + +def modified_amount(amount: Decimal, category_id: int | None, recurring_id: int | None, + modifiers: list[PlainModifier], *, on: date) -> Decimal | None: + """Betrag einer Fälligkeit am Stichtag `on` nach allen treffenden, dort + gültigen Einträgen in Anlage-Reihenfolge. None = entfällt. Art "ende" + wirkt nur über effective_end.""" + for m in modifiers: + if m.kind == "ende" or not _trifft(m, category_id, recurring_id) or not gilt_am(m, on): + continue + if m.kind == "remove": + return None + if m.kind == "set": + amount = m.value + elif m.kind == "percent": + amount = (amount * (Decimal(100) - m.value) / Decimal(100)).quantize(CENT, ROUND_HALF_UP) + elif m.kind == "absolute": + if amount < 0: + amount = min(Decimal("0"), amount + m.value) + else: + amount = max(Decimal("0"), amount - m.value) + return amount + + +def effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None: + """Frühestes 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 or not _trifft(m, r.category_id, r.id): + continue + end = m.end_date if end is None else min(end, m.end_date) + return end +``` + +In `build_cashflows` die zwei Schleifen über `recurring`/`planned` ersetzen: + +```python + for r in recurring: + for d in occurrences(r.rhythm, r.due_day, window_start, window_end, + r.start_date, effective_end(r, modifiers)): + amount = modified_amount(r.amount, r.category_id, r.id, modifiers, on=d) + if amount is not None: + flows.append((d, amount)) + for p in planned: + if not window_start <= p.due <= window_end: + continue + amount = modified_amount(p.amount, p.category_id, None, modifiers, on=p.due) + if amount is not None: + flows.append((p.due, amount)) +``` + +- [ ] **Step 5: `breakdown.py`** — Import `from app.engine.recurrence import STEP, due_in_month`; im Modul-Docstring den Satz „Modifikatoren wirken exakt wie in build_cashflows (gleiche Helfer)." ersetzen durch „Modifikatoren wirken wie in build_cashflows (gleiche Helfer); Stichtag je Fixposten ist sein Fälligkeitstag im Monat, je Einmalposten das Fälligkeitsdatum." In `breakdown_rows`: + +```python + amount = modified_amount(r.amount, r.category_id, r.id, modifiers, + on=due_in_month(first, r.due_day)) +``` + +```python + amount = modified_amount(p.amount, p.category_id, None, modifiers, on=p.due) +``` + +- [ ] **Step 6: Tests laufen lassen** — `.venv/bin/python -m pytest -q` → alle grün. `grep -n "^from\|^import" app/engine/scenario.py app/engine/breakdown.py` zeigt nur Stdlib und `app.engine.*`. + +- [ ] **Step 7: Commit** + +```bash +git add finance/app/engine/scenario.py finance/app/engine/breakdown.py finance/app/engine/recurrence.py \ + finance/tests/test_scenario_engine.py finance/tests/test_breakdown.py \ + finance/tests/test_recurrence.py finance/tests/test_scenario_inputs.py +git commit -m "feat: Szenario-Einträge je Stichtag mit Zeitraum und Art Neuer Betrag (A14-T2)" +``` + +- [ ] **Step 8: Fable-Gate** — unabhängige Handrechnung mit eigenen Werten: vierteljährlicher Posten mit Zeitraum, der eine Fälligkeit einschließt und eine ausschließt; `set` → `absolute` → `percent` in Folge; Kategorie- und Posten-Eintrag auf denselben Posten; Aufteilung eines vierteljährlichen Postens in einem Monat ohne Fälligkeit (Stichtag = Fälligkeitstag dieses Monats); Einträge ohne Datum liefern exakt dieselben Ergebnisse wie vor dem Commit (Projektion eines synthetischen Szenarios vor/nach dem Commit vergleichen, z. B. zwei Checkouts in einem Wegwerf-Verzeichnis); Reinheit + kein `float`. + +--- + +### Task 3: Ladefunktion + API (Einträge, `/recurring`, Kategorie an Einmalzahlungen) + +**Files:** +- Modify: `finance/app/services/projection_service.py` (`load_scenario_inputs`) +- Modify: `finance/app/routers/scenarios.py` +- Test: `finance/tests/test_scenario_inputs.py`, `finance/tests/test_crud_api.py` + +**Interfaces:** +- Consumes: `ScenarioRecurringItem`, `ScenarioModifier.start_date`, `ScenarioPlannedItem.category_id` (Task 1); `PlainRecurring(id=None, …)`, `PlainModifier(..., start_date=…)`, `build_cashflows` (Task 2); `app.routers.planning.RecurringIn`, `app.routers.planning._check_category` (bestehend). +- Produces: `GET/POST /api/scenarios/{id}/recurring`, `PATCH/DELETE /api/scenarios/{id}/recurring/{item_id}` (Schema `RecurringIn`, Antwort `ScenarioRecurringOut` = `RecurringIn` + `id` + `scenario_id`); `ModifierIn.start_date`, `kind` inkl. `set`; `ScenarioPlannedIn.category_id`. + +- [ ] **Step 1: Failing Tests (Service)** — an `finance/tests/test_scenario_inputs.py` anhängen (Imports oben um `build_cashflows` aus `app.engine.scenario` und `ScenarioRecurringItem`, `Category` aus `app.models.tables` ergänzen): + +```python +def test_szenario_zahlung_zaehlt_immer_und_id_kollision(db): + rec = RecurringItem(name="R", amount=Decimal("-10.00"), rhythm="monthly", due_day=1) + sc = Scenario(name="S-A14", description="", include_recurring=False, + include_planned=False) + db.add_all([rec, sc]) + db.flush() + sri = ScenarioRecurringItem(scenario_id=sc.id, name="SR", amount=Decimal("-100.00"), + rhythm="monthly", due_day=1) + db.add(sri) + db.flush() + # Posten-Eintrag, dessen target_id zufällig der ID der Szenario-Zahlung entspricht: + db.add(ScenarioModifier(scenario_id=sc.id, target_type="recurring", + target_id=sri.id, kind="remove", value=Decimal("0"))) + db.commit() + inp = load_scenario_inputs(db, sc) + # include_recurring=False: globaler Posten fehlt, Szenario-Zahlung zählt, id=None. + assert [(r.id, r.name) for r in inp.recurring] == [(None, "SR")] + flows = build_cashflows(inp.recurring, inp.planned, [], [], inp.modifiers, + date(2030, 1, 1), date(2030, 1, 31)) + assert flows == [(date(2030, 1, 1), Decimal("-100.00"))] + # System-Plan kennt keine Szenario-Zahlungen. + assert [r.name for r in load_scenario_inputs(db, None).recurring] == ["R"] + + +def test_zeitraum_und_kategorie_werden_geladen(db): + cat = Category(name="K-A14") + sc = Scenario(name="S-A14b", description="") + db.add_all([cat, sc]) + db.flush() + db.add_all([ + ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=cat.id, + kind="percent", value=Decimal("50"), + start_date=date(2030, 2, 1), end_date=date(2030, 3, 31)), + ScenarioPlannedItem(scenario_id=sc.id, name="E", amount=Decimal("-5.00"), + due=date(2030, 2, 1), category_id=cat.id), + ]) + db.commit() + inp = load_scenario_inputs(db, sc) + m = inp.modifiers[0] + assert (m.start_date, m.end_date) == (date(2030, 2, 1), date(2030, 3, 31)) + assert [(p.name, p.category_id) for p in inp.planned] == [("E", cat.id)] +``` + +- [ ] **Step 2: Failing Tests (API)** — in `finance/tests/test_crud_api.py`: + +Bestehende Erwartungen an die neue Semantik anpassen: in `test_modifier_ende_validierung` den letzten Block ersetzen durch + +```python + # andere Arten behalten end_date als „Gültig bis" + 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"] == "2026-08-31" +``` + +und in `test_modifier_patch` den Block „zurueck auf remove" ersetzen durch + +```python + # zurueck auf remove: end_date bleibt als „Gültig bis" erhalten + 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"] == "2026-08-31" +``` + +Neue Tests anhängen: + +```python +def _msgs(r): + return " ".join(e["msg"] for e in r.json()["detail"]) + + +def test_modifier_zeitraum_und_neuer_betrag(client): + sc = client.post("/api/scenarios", headers=H, json={"name": "Zeitraum"}).json() + url = f"/api/scenarios/{sc['id']}/modifiers" + r = client.post(url, headers=H, json={ + "target_type": "recurring", "target_id": 1, "kind": "percent", "value": "50", + "start_date": "2026-10-16", "end_date": "2027-03-31"}) + assert r.status_code == 201 + assert (r.json()["start_date"], r.json()["end_date"]) == ("2026-10-16", "2027-03-31") + r = client.post(url, headers=H, json={ + "target_type": "recurring", "target_id": 1, "kind": "percent", "value": "50", + "start_date": "2027-04-01", "end_date": "2027-03-31"}) + assert r.status_code == 422 and "»Gültig ab« liegt nach »Gültig bis«" in _msgs(r) + r = client.post(url, headers=H, json={ + "target_type": "category", "target_id": 1, "kind": "set", "value": "-80.00"}) + assert r.status_code == 422 and "Art »Neuer Betrag« ist nur für Posten möglich" in _msgs(r) + r = client.post(url, headers=H, json={ + "target_type": "recurring", "target_id": 1, "kind": "set", "value": "-137.50", + "start_date": "2026-10-16"}) + assert r.status_code == 201 + assert (r.json()["kind"], r.json()["value"], r.json()["end_date"]) == ("set", "-137.50", None) + r = client.post(url, headers=H, json={ + "target_type": "recurring", "target_id": 1, "kind": "ende", + "start_date": "2026-10-01", "end_date": "2026-10-15"}) + assert r.status_code == 201 + assert (r.json()["start_date"], r.json()["end_date"]) == (None, "2026-10-15") + + +def test_modifier_patch_artwechsel_zeitraum(client): + sc = client.post("/api/scenarios", headers=H, json={"name": "Artwechsel"}).json() + m = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={ + "target_type": "recurring", "target_id": 1, "kind": "percent", "value": "50", + "start_date": "2026-10-16", "end_date": "2027-03-31"}).json() + url = f"/api/scenarios/{sc['id']}/modifiers/{m['id']}" + r = client.patch(url, headers=H, json={ + "target_type": "recurring", "target_id": 1, "kind": "ende", + "start_date": "2026-10-16", "end_date": "2026-12-31"}) + assert r.status_code == 200 + assert (r.json()["start_date"], r.json()["end_date"]) == (None, "2026-12-31") + r = client.patch(url, headers=H, json={ + "target_type": "recurring", "target_id": 1, "kind": "percent", "value": "10", + "end_date": "2026-12-31"}) + assert r.status_code == 200 + assert (r.json()["kind"], r.json()["start_date"], r.json()["end_date"]) == \ + ("percent", None, "2026-12-31") + + +def test_scenario_recurring_crud_und_cleanup(client, db): + from app.models.tables import Category, ScenarioRecurringItem + cat = Category(name="Wohnen-SR") + db.add(cat) + db.commit() + sc = client.post("/api/scenarios", headers=H, json={"name": "SR-CRUD"}).json() + url = f"/api/scenarios/{sc['id']}/recurring" + body = {"name": "Miete neu", "amount": "-137.50", "rhythm": "monthly", + "due_day": 14, "start_date": "2026-10-16", "category_id": cat.id} + r = client.post(url, headers=H, json=body) + assert r.status_code == 201 + item = r.json() + assert (item["scenario_id"], item["amount"], item["end_date"], item["category_id"]) == \ + (sc["id"], "-137.50", None, cat.id) + assert [i["name"] for i in client.get(url, headers=H).json()] == ["Miete neu"] + r = client.patch(f"{url}/{item['id']}", headers=H, json={ + "name": "Miete neu", "amount": "-250.00", "rhythm": "quarterly", "due_day": 1, + "start_date": None, "end_date": "2027-12-31", "category_id": None}) + assert r.status_code == 200 + assert (r.json()["amount"], r.json()["rhythm"], r.json()["category_id"]) == \ + ("-250.00", "quarterly", None) + # Validierung wie globale Fixposten + assert client.post(url, headers=H, json={**body, "due_day": 0}).status_code == 422 + assert client.post(url, headers=H, json={**body, "rhythm": "weekly"}).status_code == 422 + assert client.post(url, headers=H, json={**body, "end_date": "2026-10-01"}).status_code == 422 + r = client.post(url, headers=H, json={**body, "category_id": 9999}) + assert r.status_code == 404 and r.json()["detail"] == "Kategorie nicht gefunden" + # fremdes/fehlendes Szenario + other = client.post("/api/scenarios", headers=H, json={"name": "SR-Other"}).json() + r = client.delete(f"/api/scenarios/{other['id']}/recurring/{item['id']}", headers=H) + assert r.status_code == 404 and r.json()["detail"] == "Wiederkehrende Zahlung nicht gefunden" + assert client.get("/api/scenarios/99999/recurring", headers=H).status_code == 404 + # Szenario löschen räumt mit ab (kein FK-Fehler) + assert client.delete(f"/api/scenarios/{sc['id']}", headers=H).status_code == 204 + assert db.query(ScenarioRecurringItem).count() == 0 + + +def test_scenario_recurring_delete(client): + sc = client.post("/api/scenarios", headers=H, json={"name": "SR-Del"}).json() + url = f"/api/scenarios/{sc['id']}/recurring" + item = client.post(url, headers=H, json={ + "name": "X", "amount": "-1.00", "rhythm": "monthly", "due_day": 1}).json() + assert client.delete(f"{url}/{item['id']}", headers=H).status_code == 204 + assert client.get(url, headers=H).json() == [] + + +def test_scenario_planned_mit_kategorie(client, db): + from app.models.tables import Category + cat = Category(name="Kat-SPI") + db.add(cat) + db.commit() + sc = client.post("/api/scenarios", headers=H, json={"name": "SPI-Kat"}).json() + url = f"/api/scenarios/{sc['id']}/planned" + r = client.post(url, headers=H, json={ + "name": "E", "amount": "-5.00", "due": "2026-11-01", "category_id": cat.id}) + assert r.status_code == 201 and r.json()["category_id"] == cat.id + assert client.post(url, headers=H, json={ + "name": "E", "amount": "-5.00", "due": "2026-11-01", + "category_id": 9999}).status_code == 404 + r = client.patch(f"{url}/{r.json()['id']}", headers=H, json={ + "name": "E", "amount": "-5.00", "due": "2026-11-01", "category_id": None}) + assert r.status_code == 200 and r.json()["category_id"] is None +``` + +- [ ] **Step 3: Tests laufen lassen** — `.venv/bin/python -m pytest -q tests/test_scenario_inputs.py tests/test_crud_api.py` → FAIL. + +- [ ] **Step 4: `load_scenario_inputs`** — Import `ScenarioRecurringItem` ergänzen; im Szenario-Zweig (nach dem `if scenario is None: … return`) den Block der Szenario-Einmalzahlungen ersetzen und Szenario-Zahlungen sowie `start_date` ergänzen: + +```python + # Szenario-eigene Zahlungen zählen IMMER für dieses Szenario - + # include_recurring/include_planned steuern nur die globalen Posten. + # id=None: Posten-Einträge (target_type="recurring") treffen sie nie, + # die IDs zweier Tabellen könnten sonst kollidieren. + recurring += [PlainRecurring(None, r.name, Decimal(r.amount), r.rhythm, r.due_day, + r.start_date, r.end_date, r.category_id) + for r in session.execute(select(ScenarioRecurringItem).where( + ScenarioRecurringItem.scenario_id == scenario.id) + .order_by(ScenarioRecurringItem.id)).scalars()] + planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id) + for p in session.execute(select(ScenarioPlannedItem).where( + ScenarioPlannedItem.scenario_id == scenario.id) + .order_by(ScenarioPlannedItem.id)).scalars()] +``` + +und die Modifikator-Liste: + +```python + modifiers = [PlainModifier(m.target_type, m.target_id, m.kind, + Decimal(m.value), m.end_date, m.start_date) + for m in session.execute(select(ScenarioModifier).where( + ScenarioModifier.scenario_id == scenario.id) + .order_by(ScenarioModifier.id)).scalars()] +``` + +Docstring von `load_scenario_inputs` um „keine Szenario-Zahlungen" ergänzen: „… keine Modifikatoren, keine Szenario-Einmalposten und keine szenario-eigenen wiederkehrenden Zahlungen." + +- [ ] **Step 5: `routers/scenarios.py`** — Importe: + +```python +from app.models.tables import (Loan, ProjectionPoint, ProjectionResult, + Scenario, ScenarioLoan, ScenarioModifier, + ScenarioPlannedItem, ScenarioRecurringItem) +from app.routers.planning import RecurringIn, _check_category +``` + +`ModifierIn` ersetzen: + +```python +class ModifierIn(BaseModel): + target_type: Literal["category", "recurring"] + target_id: int + kind: Literal["percent", "absolute", "set", "remove", "ende"] + value: Decimal = Decimal("0") + start_date: date | None = None + end_date: date | None = None + + @model_validator(mode="after") + def _art_und_zeitraum(self): + # end_date = Enddatum bei "ende", sonst „Gültig bis" (A14). + if self.kind == "ende": + if self.end_date is None: + raise ValueError("Art »Ende« erfordert ein Datum") + self.start_date = None + if self.kind == "set" and self.target_type != "recurring": + raise ValueError("Art »Neuer Betrag« ist nur für Posten möglich") + if (self.start_date is not None and self.end_date is not None + and self.start_date > self.end_date): + raise ValueError("»Gültig ab« liegt nach »Gültig bis«") + return self +``` + +In `delete_scenario` nach dem `ScenarioPlannedItem`-Delete: + +```python + session.execute(delete(ScenarioRecurringItem).where( + ScenarioRecurringItem.scenario_id == scenario_id)) +``` + +`ScenarioPlannedIn` um `category_id: int | None = None` ergänzen; in `add_scenario_planned` und `patch_scenario_planned` direkt nach `_get_scenario(...)` jeweils `_check_category(session, data.category_id)`. + +Am Dateiende anhängen: + +```python +class ScenarioRecurringOut(RecurringIn): + model_config = ConfigDict(from_attributes=True) + id: int + scenario_id: int + + +def _get_scenario_recurring(session: Session, scenario_id: int, + item_id: int) -> ScenarioRecurringItem: + item = session.get(ScenarioRecurringItem, item_id) + if item is None or item.scenario_id != scenario_id: + raise HTTPException(404, "Wiederkehrende Zahlung nicht gefunden") + return item + + +@router.get("/{scenario_id}/recurring", response_model=list[ScenarioRecurringOut]) +def list_scenario_recurring(scenario_id: int, session: Session = Depends(get_session)): + _get_scenario(session, scenario_id) + return [ScenarioRecurringOut.model_validate(r) for r in session.execute( + select(ScenarioRecurringItem) + .where(ScenarioRecurringItem.scenario_id == scenario_id) + .order_by(ScenarioRecurringItem.name, ScenarioRecurringItem.id)).scalars()] + + +@router.post("/{scenario_id}/recurring", response_model=ScenarioRecurringOut, + status_code=201) +def add_scenario_recurring(scenario_id: int, data: RecurringIn, + session: Session = Depends(get_session)): + # Bewusst ohne Doppelzählungs-Prüfung (gilt nur für globale Fixposten). + _get_scenario(session, scenario_id) + _check_category(session, data.category_id) + item = ScenarioRecurringItem(scenario_id=scenario_id, **data.model_dump()) + session.add(item) + session.commit() + session.refresh(item) + return ScenarioRecurringOut.model_validate(item) + + +@router.patch("/{scenario_id}/recurring/{item_id}", response_model=ScenarioRecurringOut) +def patch_scenario_recurring(scenario_id: int, item_id: int, data: RecurringIn, + session: Session = Depends(get_session)): + # Full-Body-Update, Muster patch_scenario_planned. + _get_scenario(session, scenario_id) + item = _get_scenario_recurring(session, scenario_id, item_id) + _check_category(session, data.category_id) + for key, value in data.model_dump().items(): + setattr(item, key, value) + session.commit() + session.refresh(item) + return ScenarioRecurringOut.model_validate(item) + + +@router.delete("/{scenario_id}/recurring/{item_id}", status_code=204) +def delete_scenario_recurring(scenario_id: int, item_id: int, + session: Session = Depends(get_session)): + _get_scenario(session, scenario_id) + session.delete(_get_scenario_recurring(session, scenario_id, item_id)) + session.commit() +``` + +Prüfen, dass `app.routers.planning` `app.routers.scenarios` nicht importiert (kein Zirkelimport): `grep -n "scenarios" finance/app/routers/planning.py` → keine Import-Zeile. + +- [ ] **Step 6: Tests laufen lassen** — `.venv/bin/python -m pytest -q` → alle grün. + +- [ ] **Step 7: Commit** + +```bash +git add finance/app/services/projection_service.py finance/app/routers/scenarios.py \ + finance/tests/test_scenario_inputs.py finance/tests/test_crud_api.py +git commit -m "feat: API für Szenario-Zeitraum, Neuer Betrag und szenario-eigene wiederkehrende Zahlungen (A14-T3)" +``` + +- [ ] **Step 8: Fable-Gate** — Live-Approximation gegen lokalen uvicorn mit Wegwerf-SQLite (`FB_DATABASE_URL=sqlite://///a14.sqlite`, `FB_API_KEY=test`, `.venv/bin/alembic upgrade head`, `.venv/bin/uvicorn app.main:app --port 8199`): per curl ein Fixposten (monatlich am 14., erfundener Betrag), Szenario mit „Prozent 50 ab <16. eines Monats>" und einer szenario-eigenen wiederkehrenden Zahlung mit Kategorie; `POST /api/scenarios/{id}/project` → Tiefpunkt/Serie gegen eigene Handrechnung; alle 422/404-Fälle aus Step 2 per curl nachstellen (deutsche Texte in `detail`); `DELETE` des Szenarios hinterlässt 0 Zeilen in `scenario_recurring_items`; danach aufräumen. + +--- + +### Task 4: Szenarien-Seite — zwei Formulare, Einträge-Tabelle, `szenarien.js` + +**Files:** +- Create: `finance/app/static/szenarien.js`, `finance/tests/js/szenarien.dom.test.js` +- Modify: `finance/app/templates/szenarien.html`, `finance/app/templates/base.html`, `finance/app/routers/gui.py` (`_scenario_rows`, `szenarien_page`), `finance/app/formats.py` +- Test: `finance/tests/test_gui.py`, `finance/tests/test_formats.py` + +**Interfaces:** +- Consumes: Endpunkte aus Task 3 (`/modifiers` mit `start_date`, `/planned` mit `category_id`, `/recurring`); `ScenarioRecurringItem` (Task 1). +- Produces: `window.FBSzenario = {zielTyp(select), aenderungArt(select), zahlungArt(select), init(root)}`; Template-Kontext `row.recurring_items`, `rhythms`; `DE_LABELS["absolute"] = "Kürzung (€)"`, `DE_LABELS["set"] = "Neuer Betrag"`. + +- [ ] **Step 1: Failing Tests (Python)** — `finance/tests/test_formats.py::test_de_label_bekannte_und_unbekannte_werte`: Zeile `absolute` → `assert de_label("absolute") == "Kürzung (€)"`, neue Zeile `assert de_label("set") == "Neuer Betrag"`, Schlüsselmenge um `"set"` ergänzen. + +In `finance/tests/test_gui.py`: den bestehenden Import aus `app.models.tables` um `Category` und `ScenarioRecurringItem` ergänzen. In `test_szenario_eintraege_tabelle` die letzte Zeile ersetzen durch `assert "FBSzenario.aenderungArt" in r and "onEntryArtChange" not in r`. In `test_neuer_eintrag_formular_struktur` `assert 'class="entry-form"' in r` ersetzen durch `assert 'class="entry-form aenderung-form"' in r and 'class="entry-form zahlung-form"' in r` (beide Formulare tragen jetzt zwei Klassen). In `test_statische_dateien_mit_versionskennung` die Dateiliste um `"szenarien.js"` ergänzen. Neue Tests anhängen: + +```python +def test_szenario_zwei_formulare(client, db): + client.post("/login", data={"username": "admin", "password": "geheim"}) + sc = Scenario(name="Formulare-A14", description="") + db.add(sc) + db.commit() + r = client.get("/szenarien").text + assert "Neue Änderung an Bestehendem" in r and "Neue Zahlung nur in diesem Szenario" in r + assert 'class="entry-form aenderung-form"' in r and 'class="entry-form zahlung-form"' in r + assert f'hx-post="/api/scenarios/{sc.id}/modifiers"' in r + assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r + assert f'data-recurring-url="/api/scenarios/{sc.id}/recurring"' in r + assert 'value="set">Neuer Betrag<' in r and 'value="absolute">Kürzung (€)<' in r + for feld in ('name="start_date"', 'name="end_date"', 'name="rhythm"', + 'name="due_day"', 'name="category_id"', 'name="due"'): + assert feld in r, feld + assert 'class="zahlung-art"' in r and '' in r + assert 'class="bis-label"' in r + + +def test_szenario_eintraege_zeitraum_und_wiederkehrend(client, db): + client.post("/login", data={"username": "admin", "password": "geheim"}) + cat = Category(name="Kat-Anzeige") + sc = Scenario(name="Anzeige-A14", description="") + db.add_all([cat, sc]) + db.flush() + db.add_all([ + ScenarioModifier(scenario_id=sc.id, target_type="recurring", target_id=1, + kind="percent", value=Decimal("50"), start_date=date(2026, 10, 16)), + ScenarioModifier(scenario_id=sc.id, target_type="recurring", target_id=1, + kind="set", value=Decimal("-80.00"), + start_date=date(2026, 11, 1), end_date=date(2027, 3, 31)), + ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=cat.id, + kind="remove", value=Decimal("0"), end_date=date(2027, 6, 30)), + ScenarioRecurringItem(scenario_id=sc.id, name="Zahlung-W", amount=Decimal("-137.50"), + rhythm="monthly", due_day=14, start_date=date(2026, 10, 16), + category_id=cat.id), + ScenarioPlannedItem(scenario_id=sc.id, name="Zahlung-E", amount=Decimal("-5.00"), + due=date(2026, 12, 1), category_id=cat.id), + ]) + db.commit() + r = client.get("/szenarien").text + assert "Zeitraum" in r + assert "ab 16.10.2026" in r and "01.11.2026 – 31.03.2027" in r and "bis 30.06.2027" in r + assert ">Neuer Betrag<" in r and "-80,00" in r + assert "Zahlung-W" in r and "Wiederkehrend (monatlich, am 14.)" in r and "-137,50" in r + assert f'hx-patch="/api/scenarios/{sc.id}/recurring/' in r + assert f'hx-delete="/api/scenarios/{sc.id}/recurring/' in r + assert "Kat-Anzeige" in r and "Zahlung-E" in r + assert 'id="sri-row-' in r and 'id="sri-edit-' in r +``` + +- [ ] **Step 2: Failing Tests (JS)** — `finance/tests/js/szenarien.dom.test.js`: + +```js +'use strict'; +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +let JSDOM = null; +try { ({ JSDOM } = require('jsdom')); } catch (e) { /* jsdom fehlt -> skip */ } +const SRC = fs.readFileSync(path.join(__dirname, '../../app/static/szenarien.js'), 'utf8'); +const TIP = 'Für diese Eintragsart nicht relevant'; + +const HTML = ` +
+ + + + + + + +
+
+ + + + + +
`; + +function load() { + const dom = new JSDOM(`${HTML}`, { runScripts: 'outside-only' }); + const w = dom.window; + const processed = []; + w.htmx = { process: (el) => processed.push(el.id) }; + w.eval(SRC); + w.FBSzenario.init(w.document); + const d = w.document; + return { w, a: d.getElementById('a'), z: d.getElementById('z'), processed }; +} + +function f(form, name) { return form.querySelector(`[name="${name}"]`); } +function art(w, form, wert) { + const sel = form.querySelector('[name="kind"]'); + sel.value = wert; + w.FBSzenario.aenderungArt(sel); +} + +test('Prozent: Wert und Zeitraum aktiv', { skip: !JSDOM }, () => { + const { a } = load(); + assert.equal(f(a, 'value').disabled, false); + assert.equal(a.querySelector('.value-label').textContent, 'Prozentsatz (%)'); + assert.equal(f(a, 'start_date').disabled, false); + assert.equal(f(a, 'end_date').disabled, false); + assert.equal(a.querySelector('.bis-label').textContent, 'Gültig bis'); +}); + +test('Kürzung: Label Kürzung (€)', { skip: !JSDOM }, () => { + const { w, a } = load(); + art(w, a, 'absolute'); + assert.equal(a.querySelector('.value-label').textContent, 'Kürzung (€)'); +}); + +test('Neuer Betrag: Ziel-Typ fest auf Posten, Kategorie gesperrt', { skip: !JSDOM }, () => { + const { w, a } = load(); + art(w, a, 'set'); + const typ = f(a, 'target_type'); + assert.equal(typ.value, 'recurring'); + assert.equal(typ.querySelector('option[value="category"]').disabled, true); + assert.equal(a.querySelector('.mod-target-recurring select').disabled, false); + assert.equal(a.querySelector('.mod-target-category select').disabled, true); + assert.equal(a.querySelector('.value-label').textContent, 'Neuer Betrag (€)'); + art(w, a, 'percent'); + assert.equal(typ.querySelector('option[value="category"]').disabled, false); +}); + +test('Ende: Gültig ab und Wert gesperrt, Label Endet am', { skip: !JSDOM }, () => { + const { w, a } = load(); + art(w, a, 'ende'); + assert.equal(f(a, 'start_date').disabled, true); + assert.equal(f(a, 'start_date').title, TIP); + assert.equal(f(a, 'value').disabled, true); + assert.equal(f(a, 'end_date').disabled, false); + assert.equal(a.querySelector('.bis-label').textContent, 'Endet am'); +}); + +test('Entfällt: Wert gesperrt, Zeitraum aktiv', { skip: !JSDOM }, () => { + const { w, a } = load(); + art(w, a, 'remove'); + assert.equal(f(a, 'value').disabled, true); + assert.equal(a.querySelector('.value-label').textContent, 'Wert'); + assert.equal(f(a, 'start_date').disabled, false); + assert.equal(a.querySelector('.bis-label').textContent, 'Gültig bis'); +}); + +test('Zahlung: Einmal → Wiederkehrend → Einmal', { skip: !JSDOM }, () => { + const { w, z, processed } = load(); + const sel = z.querySelector('.zahlung-art'); + const wiederkehrend = ['rhythm', 'due_day', 'start_date', 'end_date']; + assert.equal(z.getAttribute('hx-post'), '/api/scenarios/1/planned'); + assert.equal(f(z, 'due').disabled, false); + wiederkehrend.forEach((n) => assert.equal(f(z, n).disabled, true, n)); + sel.value = 'wiederkehrend'; + w.FBSzenario.zahlungArt(sel); + assert.equal(z.getAttribute('hx-post'), '/api/scenarios/1/recurring'); + assert.equal(f(z, 'due').disabled, true); + assert.equal(f(z, 'due').title, TIP); + wiederkehrend.forEach((n) => assert.equal(f(z, n).disabled, false, n)); + sel.value = 'einmal'; + w.FBSzenario.zahlungArt(sel); + assert.equal(z.getAttribute('hx-post'), '/api/scenarios/1/planned'); + wiederkehrend.forEach((n) => assert.equal(f(z, n).disabled, true, n)); + ['name', 'amount', 'category_id'].forEach((n) => assert.equal(f(z, n).disabled, false, n)); + assert.ok(processed.includes('z')); +}); +``` + +- [ ] **Step 3: Tests laufen lassen** — `.venv/bin/python -m pytest -q tests/test_gui.py tests/test_formats.py` → FAIL. JS per Podman (Host hat kein node): + +```bash +podman run --rm -v "$PWD":/w:Z -w /w docker.io/library/node:22-alpine \ + sh -c 'npm i --silent --no-save --prefix /tmp/j jsdom >/dev/null 2>&1; NODE_PATH=/tmp/j/node_modules node --test tests/js/szenarien.dom.test.js tests/js/doppelzaehlung.test.js tests/js/doppelzaehlung.dom.test.js tests/js/sort.test.js tests/js/sort.dom.test.js' +``` + +Erwartet: `szenarien.dom.test.js` FAIL (Datei `szenarien.js` fehlt). + +- [ ] **Step 4: `formats.py`** — in `DE_LABELS` `"absolute": "Kürzung (€)"` und nach `"absolute"` neu `"set": "Neuer Betrag"`. + +- [ ] **Step 5: `static/szenarien.js`** + +```js +/* Szenarien-Seite (Ausbaustufe 14): Umschaltlogik der Formulare + * „Neue Änderung an Bestehendem" (.aenderung-form, auch in den Edit-Zeilen + * der Einträge) und „Neue Zahlung nur in diesem Szenario" (.zahlung-form). + * UX-Regel: Felder werden nur per disabled gesperrt, nie entfernt; + * gesperrte Felder tragen den Tooltip TIP. Gesperrte Felder sendet htmx + * nicht mit. + */ +(function () { + 'use strict'; + + var TIP = 'Für diese Eintragsart nicht relevant'; + var WERT_LABEL = { percent: 'Prozentsatz (%)', absolute: 'Kürzung (€)', set: 'Neuer Betrag (€)' }; + + function sperren(el, gesperrt) { + if (!el) { return; } + el.disabled = gesperrt; + if (gesperrt) { el.title = TIP; } else { el.removeAttribute('title'); } + } + + function feld(form, name) { return form.querySelector('[name="' + name + '"]'); } + + function beschriften(form, cls, text) { + var el = form.querySelector('.' + cls); + if (el) { el.textContent = text; } + } + + // Ziel-Typ: passende Auswahl (Kategorie oder Posten) zeigen und freigeben. + function zielTyp(select) { + var form = select.closest('form'); + var istKategorie = select.value === 'category'; + var kat = form.querySelector('.mod-target-category'); + var rec = form.querySelector('.mod-target-recurring'); + kat.style.display = istKategorie ? '' : 'none'; + rec.style.display = istKategorie ? 'none' : ''; + sperren(kat.querySelector('select'), !istKategorie); + sperren(rec.querySelector('select'), istKategorie); + } + + function aenderungArt(select) { + var form = select.closest('form'); + var art = select.value; + var mitWert = Object.prototype.hasOwnProperty.call(WERT_LABEL, art); + sperren(feld(form, 'value'), !mitWert); + beschriften(form, 'value-label', mitWert ? WERT_LABEL[art] : 'Wert'); + sperren(feld(form, 'start_date'), art === 'ende'); + beschriften(form, 'bis-label', art === 'ende' ? 'Endet am' : 'Gültig bis'); + var typ = feld(form, 'target_type'); + var kategorieOption = typ.querySelector('option[value="category"]'); + // „Neuer Betrag" gibt es nur für Posten. + if (art === 'set') { typ.value = 'recurring'; } + kategorieOption.disabled = art === 'set'; + zielTyp(typ); + } + + function zahlungArt(select) { + var form = select.closest('form'); + var einmal = select.value === 'einmal'; + sperren(feld(form, 'due'), !einmal); + ['rhythm', 'due_day', 'start_date', 'end_date'].forEach(function (name) { + sperren(feld(form, name), einmal); + }); + form.setAttribute('hx-post', form.getAttribute(einmal ? 'data-planned-url' : 'data-recurring-url')); + if (window.htmx) { window.htmx.process(form); } + } + + function init(root) { + root.querySelectorAll('.aenderung-form select[name="kind"]').forEach(aenderungArt); + root.querySelectorAll('.zahlung-form select.zahlung-art').forEach(zahlungArt); + } + + window.FBSzenario = { zielTyp: zielTyp, aenderungArt: aenderungArt, zahlungArt: zahlungArt, init: init }; +})(); +``` + +- [ ] **Step 6: `base.html`** — nach der `doppelzaehlung.js`-Zeile: + +```html + +``` + +und nach `if (window.FBDoppel) { window.FBDoppel.init(document); }`: + +```js + if (window.FBSzenario) { window.FBSzenario.init(document); } +``` + +- [ ] **Step 7: `gui.py`** — Import `ScenarioRecurringItem` ergänzen. In `_scenario_rows` nach `planned_items`: + +```python + recurring_items = session.execute( + select(ScenarioRecurringItem) + .where(ScenarioRecurringItem.scenario_id == sc.id) + .order_by(ScenarioRecurringItem.name, ScenarioRecurringItem.id)).scalars().all() +``` + +und `"recurring_items": recurring_items,` ins Row-Dict. In `szenarien_page`: `"modifier_kinds": ["percent", "absolute", "set", "remove", "ende"],` und neu `"rhythms": ["monthly", "quarterly", "yearly"],`. + +- [ ] **Step 8: `szenarien.html`** — direkt nach `{% block content %}` das Makro: + +```jinja +{% macro zeitraum(von, bis) -%} + {%- if von and bis -%}{{ von.strftime('%d.%m.%Y') }} – {{ bis.strftime('%d.%m.%Y') }} + {%- elif von -%}ab {{ von.strftime('%d.%m.%Y') }} + {%- elif bis -%}bis {{ bis.strftime('%d.%m.%Y') }} + {%- else -%}–{%- endif -%} +{%- endmacro %} +{% set TIP = 'Für diese Eintragsart nicht relevant' %} +``` + +Den gesamten Bereich von `

Einträge

` bis vor `
` ersetzen durch: + +```jinja +

Einträge

+ + + + + + {% for m in row.modifiers %} + + + + + + + + + + + {% endfor %} + {% for ri in row.recurring_items %} + + + + + + + + + + + {% endfor %} + {% for sp in row.planned_items %} + + + + + + + + + + + {% endfor %} + {% if not row.modifiers and not row.planned_items and not row.recurring_items %} + + {% endif %} + +
WasArtWert/BetragZeitraum
+ {% 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 %} + {{ m.kind|de_label }}{% if m.kind in ('percent', 'absolute', 'set') %}{{ m.value|eur }}{% else %}–{% endif %}{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}{% else %}{{ zeitraum(m.start_date, m.end_date) }}{% endif %} + +
+ +
+
{{ ri.name }}{% if ri.category_id %} ({{ category_names.get(ri.category_id, '') }}){% endif %}Wiederkehrend ({{ ri.rhythm|de_label }}, am {{ ri.due_day }}.){{ ri.amount|eur }} €{{ zeitraum(ri.start_date, ri.end_date) }} + +
+ +
+
{{ sp.name }}{% if sp.category_id %} ({{ category_names.get(sp.category_id, '') }}){% endif %}Einmalzahlung{{ sp.amount|eur }} €{{ sp.due.strftime('%d.%m.%Y') }} + +
+ +
+
Keine Einträge.
+ +

Neue Änderung an Bestehendem

+
+
+ + — die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut. +
+ + + + + + + + + +
+ +

Neue Zahlung nur in diesem Szenario

+
+
+ + — zählt nur in diesem Szenario, auch ohne „Wiederkehrende Posten/Einmalposten einschließen“. +
+ + + + + + + + + +
+``` + +Im `