Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
92 KiB
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, keinfloatfür Beträge. - Deutsch für GUI-Texte, Fehlermeldungen, Kommentare, Commit-Messages; Datum TT.MM.JJJJ.
- Engine bleibt rein:
app/engine/*importiert nur Standardbibliothek undapp.engine.*(keinapp.models,app.services). - UX-Regel: nicht passende Felder bleiben sichtbar, werden
disabled+ TooltipFür diese Eintragsart nicht relevant; Fehler über den bestehendenhtmx:responseError-Handler. - Exakte Namen: Art-Literal
set(Label „Neuer Betrag"),absolute→ Label „Kürzung (€)"; Tabellescenario_recurring_items, ModellScenarioRecurringItem; Endpunkt/api/scenarios/{id}/recurring; Alembic-Revisione7f1a9c3b214(down_revision = 'd5e8b3c7a410'); JS-Globalwindow.FBSzenariomitzielTyp,aenderungArt,zahlungArt,init; Engine-Helfergilt_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 figcaptionbleibtfont-weight: bold(ausdrücklicher Nutzerwunsch). Grafana, Views,planning_monthlybleiben 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-alpinemit 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 mitCo-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
- Bestandsdaten nach der Migration unverändert: vorhandene
ende-Einträge behaltenend_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). - 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). - ID-Kollision: ein Posten-Eintrag, dessen
target_idzufällig der ID einer szenario-eigenen Zahlung entspricht, darf diese nicht treffen — Test in Task 3 (test_szenario_zahlung_zaehlt_immer_und_id_kollision). - Artwechsel per PATCH: Prozent mit Zeitraum → Ende nullt
start_date; Ende → Prozent behältend_dateals „Gültig bis" — Test in Task 3 (test_modifier_patch_artwechsel_zeitraum). - Formular zurückschalten: Zahlung „Wiederkehrend" → „Einmalzahlung" muss wieder an
/plannedsenden 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, neuScenarioRecurringItem) - 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(Tabellescenario_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.pyanhä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 ohneFB_TEST_PG_URL, Mustertest_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_pgskipped (Host ohneFB_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 danachpodman 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.pygrün; zusätzlichalembic upgrade head→alembic downgrade -1→alembic upgrade headper CLI (FB_DATABASE_URL=… .venv/bin/alembic …) ohne Fehler; Modell ↔ Migration abgleichen (Spaltentypen, Nullbarkeit, FK-Ziele, Index-Name) perinspectgegenBase.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_dateals letztes Feld (bestehende Positionsaufrufe mitend_datean 5. Stelle bleiben gültig).gilt_am(m: PlainModifier, on: date) -> bool.modified_amount(amount, category_id, recurring_id, modifiers, *, on: date) -> Decimal | None(onist Pflicht, keyword-only).recurrence.due_in_month(month: date, due_day: int) -> date(Fälligkeitstag im Monat vonmonth, auf Monatslänge geklemmt).
-
Step 1: Failing Tests — an
finance/tests/test_scenario_engine.pyanhä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_clampergä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— Importfrom 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." Inbreakdown_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.pyzeigt nur Stdlib undapp.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→percentin 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 + keinfloat.
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}(SchemaRecurringIn, AntwortScenarioRecurringOut=RecurringIn+id+scenario_id);ModifierIn.start_date,kindinkl.set;ScenarioPlannedIn.category_id. -
Step 1: Failing Tests (Service) — an
finance/tests/test_scenario_inputs.pyanhängen (Imports oben umbuild_cashflowsausapp.engine.scenarioundScenarioRecurringItem,Categoryausapp.models.tablesergä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— ImportScenarioRecurringItemergänzen; im Szenario-Zweig (nach demif scenario is None: … return) den Block der Szenario-Einmalzahlungen ersetzen und Szenario-Zahlungen sowiestart_dateergä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 indetail);DELETEdes Szenarios hinterlässt 0 Zeilen inscenario_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 (
/modifiersmitstart_date,/plannedmitcategory_id,/recurring);ScenarioRecurringItem(Task 1). -
Produces:
window.FBSzenario = {zielTyp(select), aenderungArt(select), zahlungArt(select), init(root)}; Template-Kontextrow.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: Zeileabsolute→assert de_label("absolute") == "Kürzung (€)", neue Zeileassert 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— inDE_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 derdoppelzaehlung.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— ImportScenarioRecurringItemergänzen. In_scenario_rowsnachplanned_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_HASHperapp.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 mitprincipal,annual_rate_pct,term_months,payout_date,repayment_type(ORM-Loanoder gleichartig; Engine importiert keine Modelle). -
Step 1: Failing Tests — an
finance/tests/test_loans.pyanhä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: inLoanInterm_months: int = Field(ge=1, le=1200); inLoanPatchterm_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_totalsfü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/appzeigt nur nochengine/loans.py(keine unbenutzten Importe inrouters/planning.py,services/*); 422-Antworten der Laufzeit-Grenzen haben deutschen Kontext in der GUI (Kredit-Formular auf/planungzeigt 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.pyanhä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— imnav { … }-Block nachgap: 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 Makrotortedie 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,/hilfegiltdocument.documentElement.scrollWidth <= 390und alle Nav-Links sind sichtbar; in/aufteilungein 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 umScenarioRecurringItemergänzen;M_alembic-Label ume7f1a9c3b214_szenario_zeitraum;R_scenarios-Label umadd_scenario_recurring;E_scenario-Label zubuild_cashflows, modified_amount<br/>(Stichtag on, gilt_am);E_loans-Label umschedule_for. - Abschnitt „Szenario-Engine ist rein":
load_scenario_inputslädt zusätzlichScenarioRecurringItem(alsPlainRecurringmitid=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; beiendenurend_date), Artset(„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 voninclude_recurring, von Posten-Einträgen nie getroffen (id=None);ScenarioPlannedItem.category_id; Szenarien-Seite mitstatic/szenarien.js(FBSzenario). - Nur Namen verwenden, die im Code existieren.
- Komponenten-Diagramm:
-
Step 3: Version —
finance/VERSION→0.14.0;.venv/bin/python -m pytest -qgrü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}/modifiersmittarget_type(category|recurring),target_id,kind(percent|absolute= Kürzung in €|set= neuer Betrag mit Vorzeichen, nurrecurring|remove|ende),value, optionalstart_date/end_date(Gültig ab/bis, inklusiv; beiendeistend_datePflicht und das Enddatum); ändern perPATCH(voller Body), löschen überDELETE /{id}/modifiers/{mod_id}. Szenario-eigene Einmalzahlungen:/{id}/planned(name,amount,due, optionalcategory_id). Szenario-eigene wiederkehrende Zahlungen:/{id}/recurring(name,amount,rhythm,due_day, optionalstart_date,end_date,category_id) mitGET/POST/PATCH/DELETE; zählen immer, unabhängig voninclude_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 mitstart_dateabbilden, 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 currentvorher notieren (podman exec finance-api_ctr alembic current→d5e8b3c7a410);./create_pod_finance.sh(die Migration läuft automatisch:finance/entrypoint.shführt beim Container-Startalembic upgrade headaus);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. /szenarienenthält „Neue Änderung an Bestehendem", „Neue Zahlung nur in diesem Szenario",szenarien.js?v=0.14.0(Booleans).GET /api/scenarios/{id}/recurringfü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 mitend_datevor = 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.
- Seiten
- Step 4: Live-Klicktest — Playwright-Container gegen
https://fbwl.creature-go.com/(Secure-Cookie aktiv; Passwort im Skript aus~/.local/share/finance_pod/.envlesen, 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; Commitdocs: A14 Plan-Haken; beide Repos pushen erst nach Rückfrage beim Nutzer.