from datetime import date from decimal import Decimal from typing import Literal from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, ConfigDict, Field 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 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) 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]