feat: API meldet mögliche Doppelzählung mit 409, Speichern nur mit Bestätigung (A12-T3)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 07:30:58 +02:00
co-authored by Claude Opus 5.5
parent 42d3d95e48
commit 9e856ec655
2 changed files with 160 additions and 6 deletions
+69 -6
View File
@@ -12,6 +12,10 @@ from app.db import get_session
from app.engine.loans import loan_schedule from app.engine.loans import loan_schedule
from app.models.tables import (Category, Loan, PlannedItem, RecurringItem, from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
ScenarioLoan) 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 from app.services.suggestions import suggest_recurring
router = APIRouter(prefix="/api", tags=["planning"], router = APIRouter(prefix="/api", tags=["planning"],
@@ -36,6 +40,11 @@ class RecurringIn(BaseModel):
return self return self
class RecurringCreate(RecurringIn):
# Nur Anfrage: bestätigt eine erkannte Doppelzählung (nicht gespeichert).
doppelzaehlung_bestaetigt: bool = False
class RecurringOut(RecurringIn): class RecurringOut(RecurringIn):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
@@ -49,6 +58,7 @@ class RecurringPatch(BaseModel):
start_date: date | None = None start_date: date | None = None
end_date: date | None = None end_date: date | None = None
category_id: int | None = None category_id: int | None = None
doppelzaehlung_bestaetigt: bool = False
class SuggestionOut(BaseModel): class SuggestionOut(BaseModel):
@@ -66,6 +76,26 @@ def _check_category(session: Session, category_id: int | None) -> None:
raise HTTPException(404, "Kategorie nicht gefunden") 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]) @router.get("/recurring", response_model=list[RecurringOut])
def list_recurring(session: Session = Depends(get_session)): def list_recurring(session: Session = Depends(get_session)):
return [RecurringOut.model_validate(r) 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) @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) _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.add(item)
session.commit() session.commit()
session.refresh(item) session.refresh(item)
@@ -94,7 +131,8 @@ def patch_recurring(item_id: int, data: RecurringPatch,
item = session.get(RecurringItem, item_id) item = session.get(RecurringItem, item_id)
if item is None: if item is None:
raise HTTPException(404, "Fixposten nicht gefunden") 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: if "category_id" in fields:
_check_category(session, fields["category_id"]) _check_category(session, fields["category_id"])
for key, value in fields.items(): 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): and item.end_date < item.start_date):
session.rollback() session.rollback()
raise HTTPException(422, "Ende darf nicht vor Start liegen") 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.commit()
session.refresh(item) session.refresh(item)
return RecurringOut.model_validate(item) return RecurringOut.model_validate(item)
@@ -188,6 +232,10 @@ class LoanIn(BaseModel):
repayment_type: Literal["annuity", "bullet"] = "annuity" repayment_type: Literal["annuity", "bullet"] = "annuity"
class LoanCreate(LoanIn):
doppelzaehlung_bestaetigt: bool = False
class LoanOut(LoanIn): class LoanOut(LoanIn):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
@@ -200,6 +248,7 @@ class LoanPatch(BaseModel):
term_months: int | None = None term_months: int | None = None
payout_date: date | None = None payout_date: date | None = None
repayment_type: Literal["annuity", "bullet"] | None = None repayment_type: Literal["annuity", "bullet"] | None = None
doppelzaehlung_bestaetigt: bool = False
class InstallmentOut(BaseModel): class InstallmentOut(BaseModel):
@@ -217,8 +266,15 @@ def list_loans(session: Session = Depends(get_session)):
@router.post("/loans", response_model=LoanOut, status_code=201) @router.post("/loans", response_model=LoanOut, status_code=201)
def create_loan(data: LoanIn, session: Session = Depends(get_session)): def create_loan(data: LoanCreate, session: Session = Depends(get_session)):
loan = Loan(**data.model_dump()) 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.add(loan)
session.commit() session.commit()
session.refresh(loan) 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) loan = session.get(Loan, loan_id)
if loan is None: if loan is None:
raise HTTPException(404, "Kredit nicht gefunden") 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) 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.commit()
session.refresh(loan) session.refresh(loan)
return LoanOut.model_validate(loan) return LoanOut.model_validate(loan)
+91
View File
@@ -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