Files
bin/docs/superpowers/plans/2026-09-30-ausbaustufe-14.md
rdmsandClaude Opus 5.5 4c3fa25cd0 docs: A14 Plan-Haken
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:43:46 +02:00

92 KiB
Raw Permalink Blame History

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) <noreply@anthropic.com>.
  • 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:

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):

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:

    category_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)

Neu direkt nach ScenarioPlannedItem:

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:
"""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):
"""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

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:

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:

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):

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:

    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:

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:
@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
@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:

    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:
            amount = modified_amount(r.amount, r.category_id, r.id, modifiers,
                                     on=due_in_month(first, r.due_day))
            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

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):

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

    # 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

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

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:

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

    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:
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
                               Scenario, ScenarioLoan, ScenarioModifier,
                               ScenarioPlannedItem, ScenarioRecurringItem)
from app.routers.planning import RecurringIn, _check_category

ModifierIn ersetzen:

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:

    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:

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

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:////<scratchpad>/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:

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 '<option value="">(keine)</option>' 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 "<th>Zeitraum</th>" 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:
'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 = `
<form id="a" class="entry-form aenderung-form" hx-post="/api/scenarios/1/modifiers">
  <select name="kind"><option value="percent">Prozent</option><option value="absolute">Kürzung (€)</option>
    <option value="set">Neuer Betrag</option><option value="remove">Entfällt</option><option value="ende">Ende</option></select>
  <select name="target_type"><option value="category">Kategorie</option><option value="recurring">Wiederkehrender Posten</option></select>
  <span class="mod-target-category"><select name="target_id"><option value="1">K</option></select></span>
  <span class="mod-target-recurring" style="display:none"><select name="target_id" disabled><option value="2">P</option></select></span>
  <label><span class="value-label">Prozentsatz (%)</span><input name="value"></label>
  <input type="date" name="start_date">
  <label><span class="bis-label">Gültig bis</span><input type="date" name="end_date"></label>
</form>
<form id="z" class="entry-form zahlung-form" hx-post="/api/scenarios/1/planned"
      data-planned-url="/api/scenarios/1/planned" data-recurring-url="/api/scenarios/1/recurring">
  <select class="zahlung-art"><option value="einmal">Einmalzahlung</option><option value="wiederkehrend">Wiederkehrend</option></select>
  <input name="name"><input name="amount"><select name="category_id"><option value="">(keine)</option></select>
  <input type="date" name="due">
  <select name="rhythm"><option value="monthly">monatlich</option></select>
  <input name="due_day"><input type="date" name="start_date"><input type="date" name="end_date">
</form>`;

function load() {
  const dom = new JSDOM(`<!doctype html><body>${HTML}</body>`, { 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):
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

/* 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:
  <script src="/static/szenarien.js?v={{ app_version }}"></script>

und nach if (window.FBDoppel) { window.FBDoppel.init(document); }:

    if (window.FBSzenario) { window.FBSzenario.init(document); }
  • Step 7: gui.py — Import ScenarioRecurringItem ergänzen. In _scenario_rows nach planned_items:
        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:
{% 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 <h3>Einträge</h3> bis vor <hr> ersetzen durch:

    <h3>Einträge</h3>
    <table>
      <thead>
        <tr><th>Was</th><th>Art</th><th>Wert/Betrag</th><th>Zeitraum</th><th></th></tr>
      </thead>
      <tbody>
        {% for m in row.modifiers %}
        <tr id="mod-row-{{ m.id }}">
          <td>
            {% if m.target_type == "category" %}Kategorie: {{ category_names.get(m.target_id, m.target_id) }}
            {% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %}
          </td>
          <td>{{ m.kind|de_label }}</td>
          <td>{% if m.kind in ('percent', 'absolute', 'set') %}{{ m.value|eur }}{% else %}–{% endif %}</td>
          <td>{% 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 %}</td>
          <td>
            <button type="button" onclick="toggleEdit('mod', {{ m.id }}, true)">Bearbeiten</button>
            <form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
                  hx-on::after-request="if(event.detail.successful){window.location.reload()}">
              <button type="submit">Löschen</button>
            </form>
          </td>
        </tr>
        <tr id="mod-edit-{{ m.id }}" hidden data-sort-follow>
          <td colspan="5">
            <form class="aenderung-form" hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
                  hx-on::after-request="if(event.detail.successful){window.location.reload()}">
              <label>Art
                <select name="kind" data-type="str" onchange="FBSzenario.aenderungArt(this)">
                  {% for k in modifier_kinds %}<option value="{{ k }}" {% if k == m.kind %}selected{% endif %}>{{ k|de_label }}</option>{% endfor %}
                </select>
              </label>
              <label>Ziel-Typ
                <select name="target_type" data-type="str" onchange="FBSzenario.zielTyp(this)">
                  <option value="category" {% if m.target_type == 'category' %}selected{% endif %}>Kategorie</option>
                  <option value="recurring" {% if m.target_type == 'recurring' %}selected{% endif %}>Wiederkehrender Posten</option>
                </select>
              </label>
              <span class="mod-target-category" {% if m.target_type != 'category' %}style="display:none"{% endif %}>
                <label>Kategorie
                  <select name="target_id" data-type="int" {% if m.target_type != 'category' %}disabled{% endif %}>
                    {% for c in categories %}<option value="{{ c.id }}" {% if m.target_type == 'category' and c.id == m.target_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
                  </select>
                </label>
              </span>
              <span class="mod-target-recurring" {% if m.target_type != 'recurring' %}style="display:none"{% endif %}>
                <label>Posten
                  <select name="target_id" data-type="int" {% if m.target_type != 'recurring' %}disabled{% endif %}>
                    {% for r in recurring %}<option value="{{ r.id }}" {% if m.target_type == 'recurring' and r.id == m.target_id %}selected{% endif %}>{{ r.name }}</option>{% endfor %}
                  </select>
                </label>
              </span>
              <label><span class="value-label">Wert</span> <input type="text" name="value" data-type="amount" value="{{ m.value|eur }}" {% if m.kind not in ('percent', 'absolute', 'set') %}disabled title="{{ TIP }}"{% endif %}></label>
              <label>Gültig ab <input type="date" name="start_date" value="{{ m.start_date.isoformat() if m.start_date else '' }}" {% if m.kind == 'ende' %}disabled title="{{ TIP }}"{% endif %}></label>
              <label><span class="bis-label">{{ 'Endet am' if m.kind == 'ende' else 'Gültig bis' }}</span> <input type="date" name="end_date" value="{{ m.end_date.isoformat() if m.end_date else '' }}"></label>
              <button type="submit">Speichern</button>
              <button type="button" onclick="toggleEdit('mod', {{ m.id }}, false)">Abbrechen</button>
            </form>
          </td>
        </tr>
        {% endfor %}
        {% for ri in row.recurring_items %}
        <tr id="sri-row-{{ ri.id }}">
          <td>{{ ri.name }}{% if ri.category_id %} <span class="muted">({{ category_names.get(ri.category_id, '') }})</span>{% endif %}</td>
          <td>Wiederkehrend ({{ ri.rhythm|de_label }}, am {{ ri.due_day }}.)</td>
          <td class="{{ 'neg' if ri.amount < 0 else '' }}">{{ ri.amount|eur }} €</td>
          <td>{{ zeitraum(ri.start_date, ri.end_date) }}</td>
          <td>
            <button type="button" onclick="toggleEdit('sri', {{ ri.id }}, true)">Bearbeiten</button>
            <form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/recurring/{{ ri.id }}" hx-swap="none"
                  hx-confirm="„{{ ri.name }}“ wirklich löschen?"
                  hx-on::after-request="if(event.detail.successful){window.location.reload()}">
              <button type="submit">Löschen</button>
            </form>
          </td>
        </tr>
        <tr id="sri-edit-{{ ri.id }}" hidden data-sort-follow>
          <td colspan="5">
            <form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/recurring/{{ ri.id }}" hx-swap="none"
                  hx-on::after-request="if(event.detail.successful){window.location.reload()}">
              <label>Name <input type="text" name="name" value="{{ ri.name }}" required></label>
              <label>Betrag <input type="text" name="amount" data-type="amount" value="{{ ri.amount|eur }}" required></label>
              <label>Kategorie
                <select name="category_id" data-type="int">
                  <option value="">(keine)</option>
                  {% for c in categories %}<option value="{{ c.id }}" {% if c.id == ri.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
                </select>
              </label>
              <label>Rhythmus
                <select name="rhythm" data-type="str">
                  {% for rh in rhythms %}<option value="{{ rh }}" {% if rh == ri.rhythm %}selected{% endif %}>{{ rh|de_label }}</option>{% endfor %}
                </select>
              </label>
              <label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" value="{{ ri.due_day }}" required></label>
              <label>Start <input type="date" name="start_date" value="{{ ri.start_date.isoformat() if ri.start_date else '' }}"></label>
              <label>Ende <input type="date" name="end_date" value="{{ ri.end_date.isoformat() if ri.end_date else '' }}"></label>
              <button type="submit">Speichern</button>
              <button type="button" onclick="toggleEdit('sri', {{ ri.id }}, false)">Abbrechen</button>
            </form>
          </td>
        </tr>
        {% endfor %}
        {% for sp in row.planned_items %}
        <tr id="spi-row-{{ sp.id }}">
          <td>{{ sp.name }}{% if sp.category_id %} <span class="muted">({{ category_names.get(sp.category_id, '') }})</span>{% endif %}</td>
          <td>Einmalzahlung</td>
          <td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td>
          <td>{{ sp.due.strftime('%d.%m.%Y') }}</td>
          <td>
            <button type="button" onclick="toggleEdit('spi', {{ sp.id }}, true)">Bearbeiten</button>
            <form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
                  hx-confirm="„{{ sp.name }}“ wirklich löschen?"
                  hx-on::after-request="if(event.detail.successful){window.location.reload()}">
              <button type="submit">Löschen</button>
            </form>
          </td>
        </tr>
        <tr id="spi-edit-{{ sp.id }}" hidden data-sort-follow>
          <td colspan="5">
            <form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
                  hx-on::after-request="if(event.detail.successful){window.location.reload()}">
              <label>Name <input type="text" name="name" value="{{ sp.name }}" required></label>
              <label>Betrag <input type="text" name="amount" data-type="amount" value="{{ sp.amount|eur }}" required></label>
              <label>Kategorie
                <select name="category_id" data-type="int">
                  <option value="">(keine)</option>
                  {% for c in categories %}<option value="{{ c.id }}" {% if c.id == sp.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
                </select>
              </label>
              <label>Fällig am <input type="date" name="due" value="{{ sp.due.isoformat() }}" required></label>
              <button type="submit">Speichern</button>
              <button type="button" onclick="toggleEdit('spi', {{ sp.id }}, false)">Abbrechen</button>
            </form>
          </td>
        </tr>
        {% endfor %}
        {% if not row.modifiers and not row.planned_items and not row.recurring_items %}
        <tr><td colspan="5">Keine Einträge.</td></tr>
        {% endif %}
      </tbody>
    </table>

    <h4>Neue Änderung an Bestehendem</h4>
    <form class="entry-form aenderung-form" hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" hx-swap="none"
          hx-on::after-request="if(event.detail.successful){window.location.reload()}">
      <div class="entry-art">
        <label>Art
          <select name="kind" data-type="str" onchange="FBSzenario.aenderungArt(this)">
            {% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
          </select>
        </label>
        <span class="muted">— die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut.</span>
      </div>
      <label>Ziel-Typ
        <select name="target_type" data-type="str" onchange="FBSzenario.zielTyp(this)">
          <option value="category">Kategorie</option>
          <option value="recurring">Wiederkehrender Posten</option>
        </select>
      </label>
      <span class="mod-target-category">
        <label>Kategorie
          <select name="target_id" data-type="int">
            {% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
          </select>
        </label>
      </span>
      <span class="mod-target-recurring" style="display:none">
        <label>Posten
          <select name="target_id" data-type="int" disabled title="{{ TIP }}">
            {% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
          </select>
        </label>
      </span>
      <label><span class="value-label">Prozentsatz (%)</span> <input type="text" name="value" data-type="amount" value="0"></label>
      <label>Gültig ab <input type="date" name="start_date"></label>
      <label><span class="bis-label">Gültig bis</span> <input type="date" name="end_date"></label>
      <button type="submit">Hinzufügen</button>
    </form>

    <h4>Neue Zahlung nur in diesem Szenario</h4>
    <form class="entry-form zahlung-form" hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/planned" hx-swap="none"
          data-planned-url="/api/scenarios/{{ sc.id }}/planned"
          data-recurring-url="/api/scenarios/{{ sc.id }}/recurring"
          hx-on::after-request="if(event.detail.successful){window.location.reload()}">
      <div class="entry-art">
        <label>Art
          <select class="zahlung-art" onchange="FBSzenario.zahlungArt(this)">
            <option value="einmal">Einmalzahlung</option>
            <option value="wiederkehrend">Wiederkehrend</option>
          </select>
        </label>
        <span class="muted">— zählt nur in diesem Szenario, auch ohne „Wiederkehrende Posten/Einmalposten einschließen“.</span>
      </div>
      <label>Name <input type="text" name="name" required></label>
      <label>Betrag <input type="text" name="amount" data-type="amount" placeholder="-250,00" required></label>
      <label>Kategorie
        <select name="category_id" data-type="int">
          <option value="">(keine)</option>
          {% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
        </select>
      </label>
      <label>Fällig am <input type="date" name="due" required></label>
      <label>Rhythmus
        <select name="rhythm" data-type="str" disabled title="{{ TIP }}">
          {% for rh in rhythms %}<option value="{{ rh }}">{{ rh|de_label }}</option>{% endfor %}
        </select>
      </label>
      <label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" required disabled title="{{ TIP }}"></label>
      <label>Start <input type="date" name="start_date" disabled title="{{ TIP }}"></label>
      <label>Ende <input type="date" name="end_date" disabled title="{{ TIP }}"></label>
      <button type="submit">Hinzufügen</button>
    </form>

Im <script>-Block am Ende von szenarien.html die Funktionen onModTargetTypeChange und onEntryArtChange löschen (ersetzt durch FBSzenario); toggleScenarioLoan bleibt.

  • Step 9: Tests laufen lassen — .venv/bin/python -m pytest -q → alle grün; grep -rn "onEntryArtChange\|onModTargetTypeChange" finance/app → keine Treffer. JS-Kommando aus Step 3 → alle grün, DOM-Tests nicht geskippt.

  • Step 10: Commit

git add finance/app/static/szenarien.js finance/app/templates/szenarien.html finance/app/templates/base.html \
        finance/app/routers/gui.py finance/app/formats.py \
        finance/tests/test_gui.py finance/tests/test_formats.py finance/tests/js/szenarien.dom.test.js
git commit -m "feat: Szenarien-Seite mit zwei Formularen, Zeitraum-Spalte und wiederkehrenden Szenario-Zahlungen (A14-T4)"
  • Step 11: Fable-Gate — Browser-Test gegen lokalen uvicorn (Wegwerf-SQLite wie Task 3, FB_GUI_PASSWORD_HASH per app.auth.hash_password, synthetische Daten: 1 Kategorie, 1 Fixposten, 1 Szenario) per Playwright-Container (podman run --rm --network host mcr.microsoft.com/playwright/python:v1.47.0-jammy …): Login; im Formular „Neue Änderung" jede Art durchschalten und gesperrte Felder prüfen; „Neuer Betrag ab " für den Posten anlegen → Zeile mit „ab TT.MM.JJJJ"; „Gültig ab" > „Gültig bis" → Fehlermeldung, Formular bleibt; „Neue Zahlung" → Wiederkehrend anlegen (mit Kategorie) → Zeile „Wiederkehrend (…)"; zurück auf Einmalzahlung → anlegen landet als Einmalzahlung; Bearbeiten/Speichern einer wiederkehrenden Zahlung und eines Eintrags (Art wechseln auf Ende → „Gültig ab" gesperrt); Löschen; „Durchrechnen" → 200; Screenshots bei 1200 px und 390 px ansehen; danach aufräumen.

Task 5: Backend-Kleinpunkte — Tilgungsplan-Helfer, Kredit-Laufzeit

Files:

  • Modify: finance/app/engine/loans.py, finance/app/routers/planning.py, finance/app/services/breakdown.py, finance/app/services/projection_service.py, finance/app/services/planning_chart.py
  • Test: finance/tests/test_loans.py, finance/tests/test_planning_api.py

Interfaces:

  • Produces: app.engine.loans.schedule_for(loan) -> list[Installment] für jedes Objekt mit principal, annual_rate_pct, term_months, payout_date, repayment_type (ORM-Loan oder gleichartig; Engine importiert keine Modelle).

  • Step 1: Failing Tests — an finance/tests/test_loans.py anhängen:

def test_schedule_for_entspricht_loan_schedule():
    from types import SimpleNamespace
    from app.engine.loans import schedule_for
    kredit = SimpleNamespace(principal="1200.00", annual_rate_pct="3.00", term_months=12,
                             payout_date=date(2030, 1, 31), repayment_type="annuity")
    assert schedule_for(kredit) == loan_schedule(Decimal("1200.00"), Decimal("3.00"), 12,
                                                 date(2030, 1, 31), "annuity")


def test_keine_manuelle_tilgungsplan_konvertierung_mehr():
    from pathlib import Path
    app_dir = Path(__file__).resolve().parent.parent / "app"
    engine_loans = app_dir / "engine" / "loans.py"
    treffer = [str(p.relative_to(app_dir)) for p in app_dir.rglob("*.py")
               if p != engine_loans and "loan_schedule(Decimal(" in p.read_text()]
    assert treffer == []

(date, Decimal, loan_schedule sind in test_loans.py bereits importiert.)

An finance/tests/test_planning_api.py anhängen:

KREDIT = {"name": "Laufzeit-Test", "principal": "1000.00", "annual_rate_pct": "2.00",
          "payout_date": "2030-01-01", "repayment_type": "annuity"}


@pytest.mark.parametrize("laufzeit", [0, -5, 1201])
def test_kredit_laufzeit_ausserhalb_1_bis_1200(client, laufzeit):
    r = client.post("/api/loans", headers=H, json={**KREDIT, "term_months": laufzeit})
    assert r.status_code == 422


def test_kredit_laufzeit_grenzen_und_patch(client):
    a = client.post("/api/loans", headers=H, json={**KREDIT, "term_months": 1})
    b = client.post("/api/loans", headers=H, json={**KREDIT, "name": "L2", "term_months": 1200})
    assert (a.status_code, b.status_code) == (201, 201)
    url = f"/api/loans/{a.json()['id']}"
    assert client.patch(url, headers=H, json={"term_months": 0}).status_code == 422
    assert client.patch(url, headers=H, json={"term_months": 1201}).status_code == 422
    r = client.patch(url, headers=H, json={"term_months": 1200})
    assert r.status_code == 200 and r.json()["term_months"] == 1200

(H ist in test_planning_api.py definiert; import pytest oben ergänzen.)

  • Step 2: Tests laufen lassen — .venv/bin/python -m pytest -q tests/test_loans.py tests/test_planning_api.py → FAIL.

  • Step 3: engine/loans.py — am Dateiende:

def schedule_for(loan) -> list[Installment]:
    """Tilgungsplan eines Kredit-Objekts (ORM-Loan oder gleichartig mit
    principal, annual_rate_pct, term_months, payout_date, repayment_type).
    Ersetzt die wiederholte Feld-für-Feld-Übergabe an loan_schedule (A14)."""
    return loan_schedule(Decimal(loan.principal), Decimal(loan.annual_rate_pct),
                         loan.term_months, loan.payout_date, loan.repayment_type)
  • Step 4: Aufrufstellen umstellen (Import jeweils from app.engine.loans import schedule_for; loan_schedule-Import entfernen, wo er danach unbenutzt ist):

    • routers/planning.py::get_loan_schedule: plan = schedule_for(loan)
    • services/breakdown.py::compute_breakdown: loans = [PlainLoan(l.id, l.name, schedule_for(l)) for l in inputs.loans]
    • services/projection_service.py::run_projection: schedules = [schedule_for(l) for l in inputs.loans]
    • services/planning_chart.py::planning_monthly_totals: for inst in schedule_for(loan):
  • Step 5: Laufzeit-Validierung — routers/planning.py: in LoanIn term_months: int = Field(ge=1, le=1200); in LoanPatch term_months: int | None = Field(None, ge=1, le=1200).

  • Step 6: Tests laufen lassen — .venv/bin/python -m pytest -q → alle grün.

  • Step 7: Commit

git add finance/app/engine/loans.py finance/app/routers/planning.py finance/app/services/breakdown.py \
        finance/app/services/projection_service.py finance/app/services/planning_chart.py \
        finance/tests/test_loans.py finance/tests/test_planning_api.py
git commit -m "refactor: Tilgungsplan-Helfer schedule_for, Kredit-Laufzeit 1–1200 Monate (A14-T5)"
  • Step 8: Fable-Gate — Verhaltensgleichheit: Projektion, Aufteilung (ein Monat mit Kreditrate) und planning_monthly_totals für ein synthetisches Setup mit Annuitäten- und endfälligem Kredit vor/nach dem Commit identisch (zwei Checkouts in einem Wegwerf-Verzeichnis); grep -rn "loan_schedule" finance/app zeigt nur noch engine/loans.py (keine unbenutzten Importe in routers/planning.py, services/*); 422-Antworten der Laufzeit-Grenzen haben deutschen Kontext in der GUI (Kredit-Formular auf /planung zeigt Fehler per Alert, Formular bleibt).

Task 6: GUI-Kleinpunkte — Navigation 390 px, gewähltes Tortensegment oben, .legende-zusatz

Files:

  • Modify: finance/app/static/style.css, finance/app/templates/_torte_macros.html

  • Test: finance/tests/test_aufteilung_gui.py

  • Step 1: Failing Tests — an finance/tests/test_aufteilung_gui.py anhängen:

from pathlib import Path
from types import SimpleNamespace

CSS = (Path(__file__).resolve().parent.parent / "app" / "static" / "style.css").read_text()


def test_nav_bricht_um():
    nav = re.search(r"(?m)^nav\s*\{([^}]*)\}", CSS).group(1)
    assert re.search(r"flex-wrap:\s*wrap", nav)


def test_legende_zusatz_nicht_fett_und_figcaption_bleibt_fett():
    assert re.search(r"\.legende-zusatz\s*\{[^}]*font-weight:\s*normal", CSS)
    # Nutzerwunsch A14: Bildunterschrift der Torte bleibt fett.
    assert re.search(r"\.torte figcaption\s*\{[^}]*font-weight:\s*bold", CSS)


def test_gewaehltes_segment_zuletzt_gezeichnet():
    from app.routers.gui import templates

    def seg(label, gewaehlt):
        s = SimpleNamespace(path=f"M0 0 L{label}", color="s1", label=label, merged=False, members=[],
                value=Decimal("1.00"), share=Decimal("50.0"))
        return {"s": s, "q": None, "gewaehlt": gewaehlt, "zusatz": None}

    torte = templates.env.get_template("_torte_macros.html").module.torte
    html = str(torte([seg("Alpha", True), seg("Beta", False)], "Titel"))
    svg = html.split("<svg", 1)[1].split("</svg>", 1)[0]
    assert svg.index("M0 0 LBeta") < svg.index("M0 0 LAlpha")
    legende = html.split('<ul class="legende">', 1)[1]
    assert legende.index("Alpha") < legende.index("Beta")   # Legende behält Reihenfolge

(re und Decimal sind in test_aufteilung_gui.py bereits importiert; Path/SimpleNamespace oben bei den Imports einsortieren.)

  • Step 2: Tests laufen lassen — .venv/bin/python -m pytest -q tests/test_aufteilung_gui.py → 3 FAIL (figcaption-Teil besteht bereits).

  • Step 3: style.css — im nav { … }-Block nach gap: 1rem;:

  flex-wrap: wrap;
  row-gap: 0.4rem;

Nach .legende li.gewaehlt { font-weight: bold; }:

.legende-zusatz { font-weight: normal; color: #666; }

.torte figcaption { font-weight: bold; … } nicht ändern.

  • Step 4: _torte_macros.html — im Makro torte die SVG-Schleife ersetzen (Legende unverändert):
    {# Gewähltes Segment zuletzt zeichnen, damit sein Umriss nicht von
       Nachbarsegmenten überdeckt wird (A14). #}
    {% for seg in (segmente|rejectattr('gewaehlt')|list) + (segmente|selectattr('gewaehlt')|list) %}{% set s = seg.s %}
  • Step 5: Tests laufen lassen — .venv/bin/python -m pytest -q → alle grün.

  • Step 6: Commit

git add finance/app/static/style.css finance/app/templates/_torte_macros.html finance/tests/test_aufteilung_gui.py
git commit -m "fix: Navigation bricht auf schmalen Bildschirmen um, gewähltes Tortensegment oben, Legendenzusatz nicht fett (A14-T6)"
  • Step 7: Fable-Gate — Playwright gegen lokalen uvicorn (Wegwerf-SQLite, synthetische Daten mit ≥ 4 Kategorien): bei 390 px auf /, /buchungen, /planung, /szenarien, /aufteilung, /admin, /hilfe gilt document.documentElement.scrollWidth <= 390 und alle Nav-Links sind sichtbar; in /aufteilung ein mittleres Segment klicken → Screenshot vergrößert ansehen: dunkler Umriss vollständig sichtbar; Legendenzeile der „Übrige"-Gruppe gewählt → Zusatz in Klammern nicht fett, Bildunterschrift fett.

Task 7: Doku, Beratungsumgebung, Release-Commit v0.14.0

Files:

  • Modify: finance/app/templates/hilfe.html, docs/ARCHITEKTUR.md, finance/VERSION

  • Modify (anderes Repo): /home/wlfb/fb/.claude/skills/finanz-api/SKILL.md, /home/wlfb/fb/.claude/skills/finanzberatung/SKILL.md

  • Step 1: hilfe.html — im <li> „Szenarien":

    • „Modifikatoren (Kategorie oder Posten prozentual/absolut kürzen oder ganz streichen)" ersetzen durch „Einträgen (Kategorie oder Posten prozentual oder um einen Betrag kürzen, auf einen neuen Betrag setzen oder ganz streichen – auch nur für einen Zeitraum)".
    • Den Text von „Varianten wie „Best Case“ pflegst du …" bis „… direkt änderbar." ersetzen durch:
    Varianten wie „Best Case“ pflegst du in der Tabelle „Einträge“ des
    Szenarios. Das Formular „Neue Änderung an Bestehendem“ ändert
    vorhandene Posten oder Kategorien: „Prozent“ und „Kürzung (€)“ kürzen
    Beträge, „Neuer Betrag“ ersetzt den Betrag eines Postens (mit
    Vorzeichen, z. B. -250,00), „Entfällt“ blendet ihn aus, „Ende“ lässt
    einen wiederkehrenden Posten zu einem Datum enden. Mit „Gültig ab“ und
    „Gültig bis“ wirken Prozent, Kürzung, Neuer Betrag und Entfällt nur in
    diesem Zeitraum (Grenzen einschließlich, leer = unbegrenzt). Beispiel:
    ein Posten wird mit „Prozent 50 ab 16.10.“ halbiert – ist er am 14.
    fällig, zählt er im Oktober noch voll, ab November zur Hälfte. Das
    Formular „Neue Zahlung nur in diesem Szenario“ ergänzt eine
    Einmalzahlung oder eine wiederkehrende Zahlung (Rhythmus,
    Fälligkeitstag, Start/Ende, optional mit Kategorie), die nur in diesem
    Szenario zählt – auch wenn „Wiederkehrende Posten einschließen“ bzw.
    „Einmalposten einschließen“ aus ist. Jeder Eintrag ist über
    „Bearbeiten“ direkt änderbar.
  • Step 2: docs/ARCHITEKTUR.md —

    • Komponenten-Diagramm: M_tables-Label um ScenarioRecurringItem ergänzen; M_alembic-Label um e7f1a9c3b214_szenario_zeitraum; R_scenarios-Label um add_scenario_recurring; E_scenario-Label zu build_cashflows, modified_amount<br/>(Stichtag on, gilt_am); E_loans-Label um schedule_for.
    • Abschnitt „Szenario-Engine ist rein": load_scenario_inputs lädt zusätzlich ScenarioRecurringItem (als PlainRecurring mit id=None) — Liste der geladenen Modelle ergänzen.
    • Neuer Konzeptabsatz „Szenario-Einträge mit Zeitraum (Ausbaustufe 14).": ScenarioModifier.start_date/end_date = „Gültig ab/bis" (inklusiv; bei ende nur end_date), Art set („Neuer Betrag", nur Posten), engine.scenario.modified_amount(..., on=) mit Stichtag je Fälligkeit (build_cashflows) bzw. recurrence.due_in_month (breakdown_rows), Anlage-Reihenfolge; scenario_recurring_items + /api/scenarios/{id}/recurring, zählt unabhängig von include_recurring, von Posten-Einträgen nie getroffen (id=None); ScenarioPlannedItem.category_id; Szenarien-Seite mit static/szenarien.js (FBSzenario).
    • Nur Namen verwenden, die im Code existieren.
  • Step 3: Version — finance/VERSION → 0.14.0; .venv/bin/python -m pytest -q grün.

  • Step 4: Commit (Repo ~/bin)

git add finance/app/templates/hilfe.html docs/ARCHITEKTUR.md finance/VERSION
git commit -m "chore: Release v0.14.0 (Szenario-Zeitraum, Neuer Betrag, Szenario-Zahlungen)"
  • Step 5: Beratungsumgebung —

    • /home/wlfb/fb/.claude/skills/finanz-api/SKILL.md, Abschnitt „Szenarien (/api/scenarios)": den Satz ab „Modifikator hinzufügen:" ersetzen durch: „Eintrag (Modifikator) hinzufügen: POST /{id}/modifiers mit target_type (category|recurring), target_id, kind (percent|absolute = Kürzung in €|set = neuer Betrag mit Vorzeichen, nur recurring|remove|ende), value, optional start_date/end_date (Gültig ab/bis, inklusiv; bei ende ist end_date Pflicht und das Enddatum); ändern per PATCH (voller Body), löschen über DELETE /{id}/modifiers/{mod_id}. Szenario-eigene Einmalzahlungen: /{id}/planned (name, amount, due, optional category_id). Szenario-eigene wiederkehrende Zahlungen: /{id}/recurring (name, amount, rhythm, due_day, optional start_date, end_date, category_id) mit GET/POST/PATCH/DELETE; zählen immer, unabhängig von include_recurring."
    • /home/wlfb/fb/.claude/skills/finanzberatung/SKILL.md, beim Punkt „Kürzungspotenzial": ergänzen „Änderungen ab einem Stichtag (z. B. Rate halbiert ab Oktober) als Eintrag mit start_date abbilden, nicht als Ende + neuer Posten; neue laufende Zahlungen nur für eine Variante über /api/scenarios/{id}/recurring."
    • Commit im Repo /home/wlfb/fb: git -C /home/wlfb/fb add .claude/skills/finanz-api/SKILL.md .claude/skills/finanzberatung/SKILL.md && git -C /home/wlfb/fb commit -m "docs: Szenario-Zeitraum, Neuer Betrag, Szenario-Zahlungen (Tool v0.14.0)" (mit Co-Authored-By-Zeile). Nicht pushen.
  • Step 6: Fable-Gate — Faktencheck aller Doku-Aussagen gegen Code (Feldnamen, Arten, Fehlertexte, Endpunkte, Stichtag-Regel, Funktionsnamen), Mermaid valide (z. B. per mermaid-cli-Container rendern), Hilfe-Text stimmt mit dem Verhalten überein; keine echten Namen/Beträge in Doku.


Task 8: Backup, Migration, Redeploy, Live-Smoke (nach Whole-Branch-Review)

  • Step 1: Backup — ./backup_finance_pod.sh (vor Redeploy).
  • Step 2: Redeploy — alembic current vorher notieren (podman exec finance-api_ctr alembic current → d5e8b3c7a410); ./create_pod_finance.sh (die Migration läuft automatisch: finance/entrypoint.sh führt beim Container-Start alembic upgrade head aus); GET /api/version (mit Bearer) → 0.14.0; podman exec finance-api_ctr alembic current → e7f1a9c3b214 (head).
  • Step 3: Live-Smoke (curl mit Session-Cookie gegen 127.0.0.1:8096; nur Statuscodes, Zählwerte, Booleans — DATENSCHUTZ):
    • Seiten /, /buchungen, /salden, /import, /planung, /szenarien, /aufteilung, /admin, /hilfe → 200.
    • /szenarien enthält „Neue Änderung an Bestehendem", „Neue Zahlung nur in diesem Szenario", szenarien.js?v=0.14.0 (Booleans).
    • GET /api/scenarios/{id}/recurring für jedes Szenario → 200, leere Liste.
    • Bestandszahlen (Szenarien, Einträge je Art, Einmalzahlungen, Buchungen, Kategorien, Fixposten, Kredite) vor = nach; vorhandene ende-Einträge behalten ihr Datum (Anzahl mit end_date vor = nach); start_date überall NULL.
    • Projektionen aller durchgerechneten Szenarien: Tiefpunkt-Datum und -Betrag vor = nach (per SQL verglichen, nur Boolean ausgeben) — die Rechenänderung darf Bestandsszenarien ohne Zeitraum nicht verändern.
    • Das Live-Szenario des Nutzers nicht ändern.
  • Step 4: Live-Klicktest — Playwright-Container gegen https://fbwl.creature-go.com/ (Secure-Cookie aktiv; Passwort im Skript aus ~/.local/share/finance_pod/.env lesen, nie ausgeben): /szenarien öffnen, in „Neue Änderung" Art „Ende" wählen ⇒ Boolean „Gültig ab gesperrt"; in „Neue Zahlung" „Wiederkehrend" wählen ⇒ Boolean „Rhythmus aktiv, Fällig am gesperrt". Nichts absenden.
  • Step 5: Fable-Release-Gate (read-only Live-Verifikation) + Ledger-Eintrag „AUSBAUSTUFE 14 ABGESCHLOSSEN" in .superpowers/sdd/progress.md (inkl. A15-Kandidaten aus den Reviews); Plan-Haken; Commit docs: A14 Plan-Haken; beide Repos pushen erst nach Rückfrage beim Nutzer.