diff --git a/finance/alembic/versions/c4d7e2a91b53_szenario_ende.py b/finance/alembic/versions/c4d7e2a91b53_szenario_ende.py new file mode 100644 index 0000000..241caee --- /dev/null +++ b/finance/alembic/versions/c4d7e2a91b53_szenario_ende.py @@ -0,0 +1,37 @@ +"""szenario ende + scenario_planned_items + +Revision ID: c4d7e2a91b53 +Revises: 1ef6a356f028 +Create Date: 2026-07-20 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'c4d7e2a91b53' +down_revision: Union[str, Sequence[str], None] = '1ef6a356f028' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('scenario_modifiers', sa.Column('end_date', sa.Date(), nullable=True)) + op.create_table( + 'scenario_planned_items', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('scenario_id', sa.Integer(), sa.ForeignKey('scenarios.id'), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False), + sa.Column('due', sa.Date(), nullable=False), + ) + op.create_index('ix_scenario_planned_items_scenario_id', + 'scenario_planned_items', ['scenario_id']) + + +def downgrade() -> None: + op.drop_index('ix_scenario_planned_items_scenario_id', 'scenario_planned_items') + op.drop_table('scenario_planned_items') + op.drop_column('scenario_modifiers', 'end_date') diff --git a/finance/app/engine/scenario.py b/finance/app/engine/scenario.py index cc7c00d..7edf0e6 100644 --- a/finance/app/engine/scenario.py +++ b/finance/app/engine/scenario.py @@ -34,6 +34,7 @@ class PlainModifier: target_id: int kind: str value: Decimal + end_date: date | None = None def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None, @@ -55,6 +56,19 @@ def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None return amount +def _effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None: + """Fruehestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren.""" + end = r.end_date + for m in modifiers: + if m.kind != "ende" or m.end_date is None: + continue + hit = ((m.target_type == "category" and r.category_id == m.target_id) + or (m.target_type == "recurring" and r.id == m.target_id)) + if hit: + end = m.end_date if end is None else min(end, m.end_date) + return end + + def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned], loan_schedules: list[list[Installment]], loan_payouts: list[tuple[date, Decimal]], @@ -66,7 +80,7 @@ def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned] if amount is None: continue for d in occurrences(r.rhythm, r.due_day, window_start, window_end, - r.start_date, r.end_date): + r.start_date, _effective_end(r, modifiers)): flows.append((d, amount)) for p in planned: amount = _modified(p.amount, p.category_id, None, modifiers) diff --git a/finance/app/models/tables.py b/finance/app/models/tables.py index 26e1f7a..d7a260e 100644 --- a/finance/app/models/tables.py +++ b/finance/app/models/tables.py @@ -123,6 +123,21 @@ class ScenarioModifier(Base): target_id: Mapped[int] = mapped_column(Integer) kind: Mapped[str] = mapped_column(String(20)) value: Mapped[Decimal] = mapped_column(MONEY, default=Decimal("0")) + # Nur fuer kind="ende" gesetzt: Datum, an dem der Ziel-Posten innerhalb + # dieses Szenarios endet (Ausbaustufe 5). + end_date: Mapped[date | None] = mapped_column(Date, nullable=True) + + +class ScenarioPlannedItem(Base): + """Einmalzahlung, die NUR in einem bestimmten Szenario zaehlt - + unabhaengig von Scenario.include_planned (das steuert nur die globalen + PlannedItems). Ausbaustufe 5.""" + __tablename__ = "scenario_planned_items" + id: Mapped[int] = mapped_column(primary_key=True) + scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), index=True) + name: Mapped[str] = mapped_column(String(200)) + amount: Mapped[Decimal] = mapped_column(MONEY) + due: Mapped[date] = mapped_column(Date) class ProjectionPoint(Base): diff --git a/finance/tests/test_models.py b/finance/tests/test_models.py index ce9bd86..0f502af 100644 --- a/finance/tests/test_models.py +++ b/finance/tests/test_models.py @@ -22,3 +22,15 @@ def test_account_transaction_roundtrip(db): db.add(tx) db.commit() assert db.query(Transaction).one().amount == Decimal("-50.00") + + +def test_scenario_planned_item_roundtrip(db): + from app.models.tables import Scenario, ScenarioPlannedItem + sc = Scenario(name="SPI-Test", description="") + db.add(sc) + db.flush() + db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Einmalzahlung", + amount=Decimal("5000.00"), due=date(2026, 7, 30))) + db.commit() + item = db.query(ScenarioPlannedItem).one() + assert item.amount == Decimal("5000.00") and item.scenario_id == sc.id diff --git a/finance/tests/test_scenario_engine.py b/finance/tests/test_scenario_engine.py index 8410d55..748b609 100644 --- a/finance/tests/test_scenario_engine.py +++ b/finance/tests/test_scenario_engine.py @@ -57,3 +57,51 @@ def test_absolute_modifier_clamps_to_zero(): kind="absolute", value=Decimal("300"))] flows = build_cashflows(rec, [], [], [], mods, date(2026, 8, 1), date(2026, 8, 31)) assert flows == [(date(2026, 8, 10), Decimal("0.00"))] + + +def test_ende_modifier_beendet_posten_im_szenario(): + r = PlainRecurring(1, "Miete", Decimal("-2000.00"), "monthly", 1, None, None, None) + m = PlainModifier("recurring", 1, "ende", Decimal("0"), date(2026, 8, 31)) + flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 12, 31)) + assert [d for d, _ in flows] == [date(2026, 7, 1), date(2026, 8, 1)] + assert all(a == Decimal("-2000.00") for _, a in flows) + + +def test_ende_modifier_minimum_mit_eigenem_ende(): + # Posten endet selbst am 15.10.; Szenario-Ende 31.08. ist frueher -> gewinnt. + r = PlainRecurring(1, "Miete", Decimal("-100.00"), "monthly", 1, + None, date(2026, 10, 15), None) + m = PlainModifier("recurring", 1, "ende", Decimal("0"), date(2026, 8, 31)) + flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 12, 31)) + assert [d for d, _ in flows] == [date(2026, 7, 1), date(2026, 8, 1)] + # Umgekehrt: Szenario-Ende NACH eigenem Ende aendert nichts. + m_spaet = PlainModifier("recurring", 1, "ende", Decimal("0"), date(2026, 12, 31)) + flows2 = build_cashflows([r], [], [], [], [m_spaet], date(2026, 7, 1), date(2026, 12, 31)) + assert [d for d, _ in flows2][-1] == date(2026, 10, 1) + + +def test_ende_modifier_auf_kategorie_trifft_alle_posten_der_kategorie(): + r1 = PlainRecurring(1, "Miete A", Decimal("-10.00"), "monthly", 1, None, None, 7) + r2 = PlainRecurring(2, "Miete B", Decimal("-20.00"), "monthly", 1, None, None, 7) + r3 = PlainRecurring(3, "Strom", Decimal("-5.00"), "monthly", 1, None, None, 9) + m = PlainModifier("category", 7, "ende", Decimal("0"), date(2026, 7, 31)) + flows = build_cashflows([r1, r2, r3], [], [], [], [m], + date(2026, 7, 1), date(2026, 9, 30)) + betraege = sorted(a for _, a in flows) + # Miete A/B nur je 1x (Juli), Strom 3x (Juli-Sept). + assert betraege == [Decimal("-20.00"), Decimal("-10.00"), + Decimal("-5.00"), Decimal("-5.00"), Decimal("-5.00")] + + +def test_ende_modifier_ohne_datum_wirkt_nicht(): + r = PlainRecurring(1, "Miete", Decimal("-10.00"), "monthly", 1, None, None, None) + m = PlainModifier("recurring", 1, "ende", Decimal("0"), None) + flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 9, 30)) + assert len(flows) == 3 + + +def test_ende_modifier_aendert_betraege_nicht(): + r = PlainRecurring(1, "Miete", Decimal("-2000.00"), "monthly", 1, None, None, None) + m = PlainModifier("recurring", 1, "ende", Decimal("50"), date(2026, 12, 31)) + flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 8, 31)) + assert all(a == Decimal("-2000.00") for _, a in flows)