From 9e856ec65513334853a0cba88eb2f5643728fc0eac9bcc2c938c194fb79548a2 Mon Sep 17 00:00:00 2001 From: wlfb Date: Tue, 29 Sep 2026 07:30:58 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20API=20meldet=20m=C3=B6gliche=20Doppelz?= =?UTF-8?q?=C3=A4hlung=20mit=20409,=20Speichern=20nur=20mit=20Best=C3=A4ti?= =?UTF-8?q?gung=20(A12-T3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- finance/app/routers/planning.py | 75 +++++++++++++++++++-- finance/tests/test_double_count_api.py | 91 ++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 finance/tests/test_double_count_api.py diff --git a/finance/app/routers/planning.py b/finance/app/routers/planning.py index f84841c..52b83e4 100644 --- a/finance/app/routers/planning.py +++ b/finance/app/routers/planning.py @@ -12,6 +12,10 @@ from app.db import get_session from app.engine.loans import loan_schedule from app.models.tables import (Category, Loan, PlannedItem, RecurringItem, ScenarioLoan) +from app.services.double_count import (CODE as DOPPELZAEHLUNG_CODE, Konflikt, + KreditDaten, PostenDaten, + conflicts_for_loan, conflicts_for_recurring, + kredit_aus, posten_aus) from app.services.suggestions import suggest_recurring router = APIRouter(prefix="/api", tags=["planning"], @@ -36,6 +40,11 @@ class RecurringIn(BaseModel): return self +class RecurringCreate(RecurringIn): + # Nur Anfrage: bestätigt eine erkannte Doppelzählung (nicht gespeichert). + doppelzaehlung_bestaetigt: bool = False + + class RecurringOut(RecurringIn): model_config = ConfigDict(from_attributes=True) id: int @@ -49,6 +58,7 @@ class RecurringPatch(BaseModel): start_date: date | None = None end_date: date | None = None category_id: int | None = None + doppelzaehlung_bestaetigt: bool = False class SuggestionOut(BaseModel): @@ -66,6 +76,26 @@ def _check_category(session: Session, category_id: int | None) -> None: raise HTTPException(404, "Kategorie nicht gefunden") +_CENT = Decimal("0.01") +_FLAG = "doppelzaehlung_bestaetigt" + + +def _doppelzaehlung(konflikte: list[Konflikt]) -> HTTPException: + """409 mit strukturierter Erklärung; die GUI zeigt daraus das Warnfenster.""" + return HTTPException(409, detail={ + "code": DOPPELZAEHLUNG_CODE, + "meldung": ("Der Betrag entspricht vermutlich einer Kreditrate, die bereits " + "über „Kredite“ eingeplant ist (oder umgekehrt). Beide zusammen " + "zählen die Rate doppelt – Planung und Szenarien wären zu " + "pessimistisch. Nur speichern, wenn das wirklich gewollt ist."), + "konflikte": [{"posten": k.posten_name, + "posten_betrag": str(k.posten_betrag.quantize(_CENT)), + "kredit": k.kredit_name, + "kredit_rate": str(k.kredit_rate.quantize(_CENT))} + for k in konflikte], + }) + + @router.get("/recurring", response_model=list[RecurringOut]) def list_recurring(session: Session = Depends(get_session)): return [RecurringOut.model_validate(r) @@ -73,9 +103,16 @@ def list_recurring(session: Session = Depends(get_session)): @router.post("/recurring", response_model=RecurringOut, status_code=201) -def create_recurring(data: RecurringIn, session: Session = Depends(get_session)): +def create_recurring(data: RecurringCreate, session: Session = Depends(get_session)): _check_category(session, data.category_id) - item = RecurringItem(**data.model_dump()) + fields = data.model_dump(exclude={_FLAG}) + if not data.doppelzaehlung_bestaetigt: + konflikte = conflicts_for_recurring(session, PostenDaten( + None, fields["name"], fields["amount"], fields["rhythm"], + fields["start_date"], fields["end_date"])) + if konflikte: + raise _doppelzaehlung(konflikte) + item = RecurringItem(**fields) session.add(item) session.commit() session.refresh(item) @@ -94,7 +131,8 @@ def patch_recurring(item_id: int, data: RecurringPatch, item = session.get(RecurringItem, item_id) if item is None: raise HTTPException(404, "Fixposten nicht gefunden") - fields = data.model_dump(exclude_unset=True) + vorher = {k.paar for k in conflicts_for_recurring(session, posten_aus(item))} + fields = data.model_dump(exclude_unset=True, exclude={_FLAG}) if "category_id" in fields: _check_category(session, fields["category_id"]) for key, value in fields.items(): @@ -103,6 +141,12 @@ def patch_recurring(item_id: int, data: RecurringPatch, and item.end_date < item.start_date): session.rollback() raise HTTPException(422, "Ende darf nicht vor Start liegen") + if not data.doppelzaehlung_bestaetigt: + neu = [k for k in conflicts_for_recurring(session, posten_aus(item)) + if k.paar not in vorher] + if neu: + session.rollback() + raise _doppelzaehlung(neu) session.commit() session.refresh(item) return RecurringOut.model_validate(item) @@ -188,6 +232,10 @@ class LoanIn(BaseModel): repayment_type: Literal["annuity", "bullet"] = "annuity" +class LoanCreate(LoanIn): + doppelzaehlung_bestaetigt: bool = False + + class LoanOut(LoanIn): model_config = ConfigDict(from_attributes=True) id: int @@ -200,6 +248,7 @@ class LoanPatch(BaseModel): term_months: int | None = None payout_date: date | None = None repayment_type: Literal["annuity", "bullet"] | None = None + doppelzaehlung_bestaetigt: bool = False class InstallmentOut(BaseModel): @@ -217,8 +266,15 @@ def list_loans(session: Session = Depends(get_session)): @router.post("/loans", response_model=LoanOut, status_code=201) -def create_loan(data: LoanIn, session: Session = Depends(get_session)): - loan = Loan(**data.model_dump()) +def create_loan(data: LoanCreate, session: Session = Depends(get_session)): + fields = data.model_dump(exclude={_FLAG}) + if not data.doppelzaehlung_bestaetigt: + konflikte = conflicts_for_loan(session, KreditDaten( + None, fields["name"], fields["principal"], fields["annual_rate_pct"], + fields["term_months"], fields["payout_date"], fields["repayment_type"])) + if konflikte: + raise _doppelzaehlung(konflikte) + loan = Loan(**fields) session.add(loan) session.commit() session.refresh(loan) @@ -230,8 +286,15 @@ def patch_loan(loan_id: int, data: LoanPatch, session: Session = Depends(get_ses loan = session.get(Loan, loan_id) if loan is None: raise HTTPException(404, "Kredit nicht gefunden") - for key, value in data.model_dump(exclude_unset=True).items(): + vorher = {k.paar for k in conflicts_for_loan(session, kredit_aus(loan))} + for key, value in data.model_dump(exclude_unset=True, exclude={_FLAG}).items(): setattr(loan, key, value) + if not data.doppelzaehlung_bestaetigt: + neu = [k for k in conflicts_for_loan(session, kredit_aus(loan)) + if k.paar not in vorher] + if neu: + session.rollback() + raise _doppelzaehlung(neu) session.commit() session.refresh(loan) return LoanOut.model_validate(loan) diff --git a/finance/tests/test_double_count_api.py b/finance/tests/test_double_count_api.py new file mode 100644 index 0000000..3af54e5 --- /dev/null +++ b/finance/tests/test_double_count_api.py @@ -0,0 +1,91 @@ +from decimal import Decimal + +H = {"Authorization": "Bearer test-key"} +KREDIT = {"name": "Auto", "principal": "1200.00", "annual_rate_pct": "0", + "term_months": 12, "payout_date": "2026-01-15", "repayment_type": "annuity"} +POSTEN = {"name": "Rate Auto", "amount": "-100.00", "rhythm": "monthly", "due_day": 15} + + +def _count(client, path): + return len(client.get(path, headers=H).json()) + + +def test_posten_zu_bestehendem_kredit_409(client): + assert client.post("/api/loans", headers=H, json=KREDIT).status_code == 201 + r = client.post("/api/recurring", headers=H, json=POSTEN) + assert r.status_code == 409 + d = r.json()["detail"] + assert d["code"] == "doppelzaehlung_kredit" + assert "doppelt" in d["meldung"] + assert d["konflikte"] == [{"posten": "Rate Auto", "posten_betrag": "-100.00", + "kredit": "Auto", "kredit_rate": "100.00"}] + assert _count(client, "/api/recurring") == 0 + + +def test_posten_mit_bestaetigung_gespeichert(client): + client.post("/api/loans", headers=H, json=KREDIT) + r = client.post("/api/recurring", headers=H, + json={**POSTEN, "doppelzaehlung_bestaetigt": True}) + assert r.status_code == 201 + assert "doppelzaehlung_bestaetigt" not in r.json() + assert _count(client, "/api/recurring") == 1 + + +def test_kredit_zu_bestehendem_posten_409_und_bestaetigung(client): + client.post("/api/recurring", headers=H, json=POSTEN) + r = client.post("/api/loans", headers=H, json=KREDIT) + assert r.status_code == 409 + assert r.json()["detail"]["konflikte"][0]["posten"] == "Rate Auto" + assert _count(client, "/api/loans") == 0 + r = client.post("/api/loans", headers=H, json={**KREDIT, "doppelzaehlung_bestaetigt": True}) + assert r.status_code == 201 + assert "doppelzaehlung_bestaetigt" not in r.json() + + +def test_ohne_konflikt_unveraendert(client): + client.post("/api/loans", headers=H, json=KREDIT) + r = client.post("/api/recurring", headers=H, json={**POSTEN, "amount": "-50.00"}) + assert r.status_code == 201 + + +def test_patch_posten_erzeugt_konflikt_409_ohne_aenderung(client): + client.post("/api/loans", headers=H, json=KREDIT) + item = client.post("/api/recurring", headers=H, json={**POSTEN, "amount": "-50.00"}).json() + r = client.patch(f"/api/recurring/{item['id']}", headers=H, json={"amount": "-100.00"}) + assert r.status_code == 409 + stored = client.get("/api/recurring", headers=H).json()[0] + assert Decimal(stored["amount"]) == Decimal("-50.00") + r = client.patch(f"/api/recurring/{item['id']}", headers=H, + json={"amount": "-100.00", "doppelzaehlung_bestaetigt": True}) + assert r.status_code == 200 + assert Decimal(r.json()["amount"]) == Decimal("-100.00") + + +def test_patch_nur_name_bei_bestehendem_konflikt_ohne_flag(client): + client.post("/api/loans", headers=H, json=KREDIT) + item = client.post("/api/recurring", headers=H, + json={**POSTEN, "doppelzaehlung_bestaetigt": True}).json() + r = client.patch(f"/api/recurring/{item['id']}", headers=H, json={"name": "Rate Auto neu"}) + assert r.status_code == 200 + loan = client.get("/api/loans", headers=H).json()[0] + r = client.patch(f"/api/loans/{loan['id']}", headers=H, json={"name": "Auto neu"}) + assert r.status_code == 200 + + +def test_patch_kredit_erzeugt_konflikt_409(client): + client.post("/api/recurring", headers=H, json=POSTEN) + loan = client.post("/api/loans", headers=H, json={**KREDIT, "principal": "2400.00"}).json() + r = client.patch(f"/api/loans/{loan['id']}", headers=H, json={"principal": "1200.00"}) + assert r.status_code == 409 + assert Decimal(client.get("/api/loans", headers=H).json()[0]["principal"]) == Decimal("2400.00") + r = client.patch(f"/api/loans/{loan['id']}", headers=H, + json={"principal": "1200.00", "doppelzaehlung_bestaetigt": True}) + assert r.status_code == 200 + + +def test_vorschlag_uebernehmen_nutzt_denselben_endpunkt(client): + # „Vorschlag übernehmen" sendet dieselben Felder (inkl. start_date/category_id=null). + client.post("/api/loans", headers=H, json=KREDIT) + r = client.post("/api/recurring", headers=H, json={ + **POSTEN, "start_date": None, "category_id": None}) + assert r.status_code == 409