# 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 `