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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user