Files
bin/finance/app/routers/planning.py
2026-07-20 11:35:32 +02:00

261 lines
8.7 KiB
Python

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.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 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
class SuggestionOut(BaseModel):
name: str
amount: Decimal
rhythm: str
due_day: int
category_id: int | None = None
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")
@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: RecurringIn, session: Session = Depends(get_session)):
_check_category(session, data.category_id)
item = RecurringItem(**data.model_dump())
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")
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)
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")
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 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
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: LoanIn, session: Session = Depends(get_session)):
loan = Loan(**data.model_dump())
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")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(loan, key, value)
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]