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]