Files
bin/finance/app/routers/planning.py
T

326 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import date
from decimal import Decimal
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field, model_validator
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.auth import require_auth
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"],
dependencies=[Depends(require_auth)])
# ---------------------------------------------------------------- Recurring
class RecurringIn(BaseModel):
name: str
amount: Decimal
rhythm: Literal["monthly", "quarterly", "yearly"]
due_day: int = Field(ge=1, le=31)
start_date: date | None = None
end_date: date | None = None
category_id: int | None = None
@model_validator(mode="after")
def _ende_nicht_vor_start(self):
if (self.start_date is not None and self.end_date is not None
and self.end_date < self.start_date):
raise ValueError("Ende darf nicht vor Start liegen")
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
class RecurringPatch(BaseModel):
name: str | None = None
amount: Decimal | None = None
rhythm: Literal["monthly", "quarterly", "yearly"] | None = None
due_day: int | None = Field(default=None, ge=1, le=31)
start_date: date | None = None
end_date: date | None = None
category_id: int | None = None
doppelzaehlung_bestaetigt: bool = False
class SuggestionOut(BaseModel):
name: str
amount: Decimal
rhythm: str
due_day: int
start_date: date | None = None
category_id: int | None = None
hinweis: str = ""
def _check_category(session: Session, category_id: int | None) -> None:
if category_id is not None and session.get(Category, category_id) is 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)
for r in session.execute(select(RecurringItem)).scalars()]
@router.post("/recurring", response_model=RecurringOut, status_code=201)
def create_recurring(data: RecurringCreate, session: Session = Depends(get_session)):
_check_category(session, data.category_id)
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)
return RecurringOut.model_validate(item)
# Muss vor /recurring/{item_id} stehen, sonst faengt die {id}-Route den Pfad ab.
@router.get("/recurring/suggestions", response_model=list[SuggestionOut])
def recurring_suggestions(session: Session = Depends(get_session)):
return suggest_recurring(session)
@router.patch("/recurring/{item_id}", response_model=RecurringOut)
def patch_recurring(item_id: int, data: RecurringPatch,
session: Session = Depends(get_session)):
item = session.get(RecurringItem, item_id)
if item is None:
raise HTTPException(404, "Fixposten nicht gefunden")
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():
setattr(item, key, value)
if (item.start_date is not None and item.end_date is not None
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)
@router.delete("/recurring/{item_id}", status_code=204)
def delete_recurring(item_id: int, session: Session = Depends(get_session)):
item = session.get(RecurringItem, item_id)
if item is None:
raise HTTPException(404, "Fixposten nicht gefunden")
session.delete(item)
session.commit()
# ------------------------------------------------------------------ Planned
class PlannedIn(BaseModel):
name: str
amount: Decimal
due: date
category_id: int | None = None
class PlannedOut(PlannedIn):
model_config = ConfigDict(from_attributes=True)
id: int
class PlannedPatch(BaseModel):
name: str | None = None
amount: Decimal | None = None
due: date | None = None
category_id: int | None = None
@router.get("/planned", response_model=list[PlannedOut])
def list_planned(session: Session = Depends(get_session)):
return [PlannedOut.model_validate(p)
for p in session.execute(select(PlannedItem)).scalars()]
@router.post("/planned", response_model=PlannedOut, status_code=201)
def create_planned(data: PlannedIn, session: Session = Depends(get_session)):
_check_category(session, data.category_id)
item = PlannedItem(**data.model_dump())
session.add(item)
session.commit()
session.refresh(item)
return PlannedOut.model_validate(item)
@router.patch("/planned/{item_id}", response_model=PlannedOut)
def patch_planned(item_id: int, data: PlannedPatch,
session: Session = Depends(get_session)):
item = session.get(PlannedItem, item_id)
if item is None:
raise HTTPException(404, "Einmalposten nicht gefunden")
fields = data.model_dump(exclude_unset=True)
if "category_id" in fields:
_check_category(session, fields["category_id"])
for key, value in fields.items():
setattr(item, key, value)
session.commit()
session.refresh(item)
return PlannedOut.model_validate(item)
@router.delete("/planned/{item_id}", status_code=204)
def delete_planned(item_id: int, session: Session = Depends(get_session)):
item = session.get(PlannedItem, item_id)
if item is None:
raise HTTPException(404, "Einmalposten nicht gefunden")
session.delete(item)
session.commit()
# --------------------------------------------------------------------- Loans
class LoanIn(BaseModel):
name: str
principal: Decimal
annual_rate_pct: Decimal
term_months: int
payout_date: date
repayment_type: Literal["annuity", "bullet"] = "annuity"
class LoanCreate(LoanIn):
doppelzaehlung_bestaetigt: bool = False
class LoanOut(LoanIn):
model_config = ConfigDict(from_attributes=True)
id: int
class LoanPatch(BaseModel):
name: str | None = None
principal: Decimal | None = None
annual_rate_pct: Decimal | None = None
term_months: int | None = None
payout_date: date | None = None
repayment_type: Literal["annuity", "bullet"] | None = None
doppelzaehlung_bestaetigt: bool = False
class InstallmentOut(BaseModel):
due: date
payment: Decimal
interest: Decimal
principal: Decimal
remaining: Decimal
@router.get("/loans", response_model=list[LoanOut])
def list_loans(session: Session = Depends(get_session)):
return [LoanOut.model_validate(l)
for l in session.execute(select(Loan)).scalars()]
@router.post("/loans", response_model=LoanOut, status_code=201)
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)
return LoanOut.model_validate(loan)
@router.patch("/loans/{loan_id}", response_model=LoanOut)
def patch_loan(loan_id: int, data: LoanPatch, session: Session = Depends(get_session)):
loan = session.get(Loan, loan_id)
if loan is None:
raise HTTPException(404, "Kredit nicht gefunden")
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)
@router.delete("/loans/{loan_id}", status_code=204)
def delete_loan(loan_id: int, session: Session = Depends(get_session)):
loan = session.get(Loan, loan_id)
if loan is None:
raise HTTPException(404, "Kredit nicht gefunden")
# Remove scenario assignments first: no DB-level cascade exists, so a
# dangling ScenarioLoan row would raise a foreign-key violation (500 on
# Postgres).
session.execute(delete(ScenarioLoan).where(ScenarioLoan.loan_id == loan_id))
session.delete(loan)
session.commit()
@router.get("/loans/{loan_id}/schedule", response_model=list[InstallmentOut])
def get_loan_schedule(loan_id: int, session: Session = Depends(get_session)):
loan = session.get(Loan, loan_id)
if loan is None:
raise HTTPException(404, "Kredit nicht gefunden")
plan = loan_schedule(Decimal(loan.principal), Decimal(loan.annual_rate_pct),
loan.term_months, loan.payout_date, loan.repayment_type)
return [InstallmentOut(due=i.due, payment=i.payment, interest=i.interest,
principal=i.principal, remaining=i.remaining)
for i in plan]