diff --git a/finance/app/routers/scenarios.py b/finance/app/routers/scenarios.py index a7d4839..6951ed8 100644 --- a/finance/app/routers/scenarios.py +++ b/finance/app/routers/scenarios.py @@ -3,7 +3,7 @@ from decimal import Decimal from typing import Literal from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, field_validator, model_validator from sqlalchemy import delete, select from sqlalchemy.orm import Session @@ -11,7 +11,8 @@ from app.auth import require_auth from app.config import get_settings from app.db import get_session from app.models.tables import (Loan, ProjectionPoint, ProjectionResult, - Scenario, ScenarioLoan, ScenarioModifier) + Scenario, ScenarioLoan, ScenarioModifier, + ScenarioPlannedItem) from app.services.projection_service import run_projection router = APIRouter(prefix="/api/scenarios", tags=["scenarios"], @@ -36,12 +37,26 @@ class ScenarioPatch(BaseModel): include_recurring: bool | None = None include_planned: bool | None = None + @field_validator("description") + @classmethod + def _leere_beschreibung(cls, v): + return "" if v is None else v + class ModifierIn(BaseModel): target_type: Literal["category", "recurring"] target_id: int - kind: Literal["percent", "absolute", "remove"] + kind: Literal["percent", "absolute", "remove", "ende"] value: Decimal = Decimal("0") + end_date: date | None = None + + @model_validator(mode="after") + def _ende_braucht_datum(self): + if self.kind == "ende" and self.end_date is None: + raise ValueError("Art »Ende« erfordert ein Datum") + if self.kind != "ende": + self.end_date = None + return self class ModifierOut(ModifierIn): @@ -117,6 +132,8 @@ def delete_scenario(scenario_id: int, session: Session = Depends(get_session)): ScenarioLoan.scenario_id == scenario_id)) session.execute(delete(ScenarioModifier).where( ScenarioModifier.scenario_id == scenario_id)) + session.execute(delete(ScenarioPlannedItem).where( + ScenarioPlannedItem.scenario_id == scenario_id)) session.delete(scenario) session.commit() @@ -164,6 +181,50 @@ def delete_modifier(scenario_id: int, mod_id: int, session.commit() +class ScenarioPlannedIn(BaseModel): + name: str + amount: Decimal + due: date + + +class ScenarioPlannedOut(ScenarioPlannedIn): + model_config = ConfigDict(from_attributes=True) + id: int + scenario_id: int + + +@router.get("/{scenario_id}/planned", response_model=list[ScenarioPlannedOut]) +def list_scenario_planned(scenario_id: int, session: Session = Depends(get_session)): + _get_scenario(session, scenario_id) + return [ScenarioPlannedOut.model_validate(p) for p in session.execute( + select(ScenarioPlannedItem) + .where(ScenarioPlannedItem.scenario_id == scenario_id) + .order_by(ScenarioPlannedItem.due)).scalars()] + + +@router.post("/{scenario_id}/planned", response_model=ScenarioPlannedOut, + status_code=201) +def add_scenario_planned(scenario_id: int, data: ScenarioPlannedIn, + session: Session = Depends(get_session)): + _get_scenario(session, scenario_id) + item = ScenarioPlannedItem(scenario_id=scenario_id, **data.model_dump()) + session.add(item) + session.commit() + session.refresh(item) + return ScenarioPlannedOut.model_validate(item) + + +@router.delete("/{scenario_id}/planned/{item_id}", status_code=204) +def delete_scenario_planned(scenario_id: int, item_id: int, + session: Session = Depends(get_session)): + _get_scenario(session, scenario_id) + item = session.get(ScenarioPlannedItem, item_id) + if item is None or item.scenario_id != scenario_id: + raise HTTPException(404, "Einmalzahlung nicht gefunden") + session.delete(item) + session.commit() + + @router.post("/{scenario_id}/project", response_model=ProjectionOut) def project_scenario(scenario_id: int, horizon_days: int | None = None, start_date: date | None = None, diff --git a/finance/app/services/projection_service.py b/finance/app/services/projection_service.py index 1fcbbe8..b2991d8 100644 --- a/finance/app/services/projection_service.py +++ b/finance/app/services/projection_service.py @@ -12,7 +12,8 @@ from app.engine.scenario import (PlainModifier, PlainPlanned, PlainRecurring, build_cashflows) from app.models.tables import (Loan, PlannedItem, ProjectionPoint, ProjectionResult, RecurringItem, Scenario, - ScenarioLoan, ScenarioModifier) + ScenarioLoan, ScenarioModifier, + ScenarioPlannedItem) from app.services.balances import total_balance @@ -28,6 +29,11 @@ def run_projection(session: Session, scenario: Scenario, horizon_days: int, if scenario.include_planned: planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id) for p in session.execute(select(PlannedItem)).scalars()] + # Szenario-eigene Einmalzahlungen zaehlen IMMER fuer dieses Szenario - + # include_planned steuert nur die globalen PlannedItems. + planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, None) + for p in session.execute(select(ScenarioPlannedItem).where( + ScenarioPlannedItem.scenario_id == scenario.id)).scalars()] loans = session.execute( select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id) .where(ScenarioLoan.scenario_id == scenario.id)).scalars().all() @@ -35,7 +41,8 @@ def run_projection(session: Session, scenario: Scenario, horizon_days: int, l.term_months, l.payout_date, l.repayment_type) for l in loans] payouts = [(l.payout_date, Decimal(l.principal)) for l in loans] - modifiers = [PlainModifier(m.target_type, m.target_id, m.kind, Decimal(m.value)) + modifiers = [PlainModifier(m.target_type, m.target_id, m.kind, + Decimal(m.value), m.end_date) for m in session.execute(select(ScenarioModifier).where( ScenarioModifier.scenario_id == scenario.id)).scalars()] flows = build_cashflows(recurring, planned, schedules, payouts, modifiers, diff --git a/finance/tests/test_crud_api.py b/finance/tests/test_crud_api.py index 0e0b2aa..2016eb8 100644 --- a/finance/tests/test_crud_api.py +++ b/finance/tests/test_crud_api.py @@ -186,3 +186,46 @@ def test_patch_transaction_category(client): r = client.patch(f"/api/transactions/{tx_id}", headers=H, json={"category_id": 9999}) assert r.status_code == 404 + + +def test_scenario_patch_beschreibung_leeren(client): + sc = client.post("/api/scenarios", headers=H, json={"name": "DescNull", "description": "x"}).json() + r = client.patch(f"/api/scenarios/{sc['id']}", headers=H, json={"description": None}) + assert r.status_code == 200 and r.json()["description"] == "" + + +def test_modifier_ende_validierung(client, db): + sc = client.post("/api/scenarios", headers=H, json={"name": "EndeVal"}).json() + # ende ohne Datum -> 422 + r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={ + "target_type": "recurring", "target_id": 1, "kind": "ende"}) + assert r.status_code == 422 + # ende mit Datum -> 201, end_date in der Antwort + r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={ + "target_type": "recurring", "target_id": 1, "kind": "ende", + "end_date": "2026-08-31"}) + assert r.status_code == 201 + assert r.json()["end_date"] == "2026-08-31" + # andere Arten nullen end_date + 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"] is None + + +def test_scenario_planned_crud_und_cleanup(client, db): + sc = client.post("/api/scenarios", headers=H, json={"name": "SPI-CRUD"}).json() + r = client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={ + "name": "Sonderzahlung", "amount": "5000.00", "due": "2026-07-30"}) + assert r.status_code == 201 + item = r.json() + assert item["scenario_id"] == sc["id"] and item["amount"] == "5000.00" + assert client.get(f"/api/scenarios/{sc['id']}/planned", headers=H).json()[0]["name"] == "Sonderzahlung" + # DELETE mit fremder scenario_id -> 404 + other = client.post("/api/scenarios", headers=H, json={"name": "SPI-Other"}).json() + assert client.delete(f"/api/scenarios/{other['id']}/planned/{item['id']}", + headers=H).status_code == 404 + # Szenario loeschen raeumt Einmalzahlungen mit ab (kein FK-Fehler): + assert client.delete(f"/api/scenarios/{sc['id']}", headers=H).status_code == 204 + from app.models.tables import ScenarioPlannedItem + assert db.query(ScenarioPlannedItem).count() == 0 diff --git a/finance/tests/test_planning_api.py b/finance/tests/test_planning_api.py index 92bbb2a..26501d0 100644 --- a/finance/tests/test_planning_api.py +++ b/finance/tests/test_planning_api.py @@ -34,6 +34,29 @@ def test_scenario_projection(client, db): assert len(body["series"]) == 92 +def test_projektion_mit_ende_modifier_und_szenario_einmalzahlung(client, db): + _seed_balance(db) + rec = client.post("/api/recurring", headers=H, json={ + "name": "Miete-Proj", "amount": "-1000.00", "rhythm": "monthly", + "due_day": 1}).json() + sc = client.post("/api/scenarios", headers=H, + json={"name": "BestCase-Test", "include_planned": False}).json() + client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={ + "target_type": "recurring", "target_id": rec["id"], "kind": "ende", + "end_date": "2026-08-31"}) + client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={ + "name": "Zufluss", "amount": "5000.00", "due": "2026-07-30"}) + r = client.post(f"/api/scenarios/{sc['id']}/project", headers=H, + params={"horizon_days": 150, "start_date": "2026-07-15"}) + assert r.status_code == 200 + series = {p["day"]: Decimal(p["balance"]) for p in r.json()["series"]} + # Start 1000; +5000 am 30.07.; Miete nur noch am 01.08. (ende 31.08.); + # include_planned=False, aber die SZENARIO-Zahlung zaehlt trotzdem. + assert series["2026-07-30"] == Decimal("6000.00") + assert series["2026-08-01"] == Decimal("5000.00") + assert series["2026-12-01"] == Decimal("5000.00") # keine Miete mehr ab Sept. + + def test_loan_in_scenario_keeps_balance_positive(client, db): _seed_balance(db) client.post("/api/recurring", headers=H, json={