from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Path, Query from pydantic import BaseModel from sqlmodel import Session, select from app.auth import get_current_user from app.db import get_session from app.models.models import ( BalanceOverride, BalanceOverrideCreate, BalanceOverrideRead, RecurringItem, RecurringItemCreate, RecurringItemRead, RecurringItemType, RecurringItemUpdate, ) from app.services.budget_projection import ( FRESH_START_MONTH, FRESH_START_YEAR, MAX_YEAR, MIN_YEAR, compute_monthly_projection, compute_yearly_projection_with_cumulative, ) router = APIRouter(prefix="/budget", tags=["budget"]) # --- Recurring Item CRUD --- @router.get("/recurring", response_model=list[RecurringItemRead]) def list_recurring_items( item_type: Optional[RecurringItemType] = None, is_active: Optional[bool] = None, session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): query = select(RecurringItem) if item_type: query = query.where(RecurringItem.item_type == item_type) if is_active is not None: query = query.where(RecurringItem.is_active == is_active) query = query.order_by(RecurringItem.item_type, RecurringItem.name) return session.exec(query).all() @router.post("/recurring", response_model=RecurringItemRead, status_code=201) def create_recurring_item( data: RecurringItemCreate, session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): item = RecurringItem.model_validate(data) session.add(item) session.commit() session.refresh(item) return item @router.patch("/recurring/{item_id}", response_model=RecurringItemRead) def update_recurring_item( item_id: int, data: RecurringItemUpdate, session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): item = session.get(RecurringItem, item_id) if not item: raise HTTPException(status_code=404, detail="Recurring item not found") update_data = data.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(item, key, value) session.add(item) session.commit() session.refresh(item) return item @router.delete("/recurring/{item_id}", status_code=204) def delete_recurring_item( item_id: int, session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): item = session.get(RecurringItem, item_id) if not item: raise HTTPException(status_code=404, detail="Recurring item not found") session.delete(item) session.commit() # --- Projection Endpoints --- class MonthlyProjectionResponse(BaseModel): month: int year: int projected_income: float projected_fixed_expenses: float projected_savings: float actual_credit_card: float actual_cash: float actual_transfers: float uncovered_actual: float gran_total_egresos: float net_balance: float carryover_balance: float = 0.0 cumulative_balance: float = 0.0 balance_overridden: bool = False class YearlyProjectionResponse(BaseModel): year: int months: list[MonthlyProjectionResponse] annual_income: float annual_expenses: float annual_savings: float annual_net: float @router.get("/projection/{year}", response_model=YearlyProjectionResponse) def get_yearly_projection( year: int, session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): if year < MIN_YEAR or year > MAX_YEAR: raise HTTPException( status_code=400, detail=f"Year must be between {MIN_YEAR} and {MAX_YEAR}", ) months_data = compute_yearly_projection_with_cumulative(session, year) months = [] annual_income = 0.0 annual_expenses = 0.0 annual_savings = 0.0 annual_net = 0.0 for data in months_data: monthly = MonthlyProjectionResponse( month=data["month"], year=data["year"], projected_income=data["projected_income"], projected_fixed_expenses=data["projected_fixed_expenses"], projected_savings=data["projected_savings"], actual_credit_card=data["actual_credit_card"], actual_cash=data["actual_cash"], actual_transfers=data["actual_transfers"], uncovered_actual=data["uncovered_actual"], gran_total_egresos=data["gran_total_egresos"], net_balance=data["net_balance"], carryover_balance=data["carryover_balance"], cumulative_balance=data["cumulative_balance"], balance_overridden=data["balance_overridden"], ) months.append(monthly) annual_income += data["projected_income"] annual_expenses += data["gran_total_egresos"] annual_savings += data["projected_savings"] annual_net += data["net_balance"] return YearlyProjectionResponse( year=year, months=months, annual_income=annual_income, annual_expenses=annual_expenses, annual_savings=annual_savings, annual_net=annual_net, ) class RecurringItemDetail(BaseModel): id: int name: str amount: float projected_amount: float | None = None used_actual: bool = False item_type: str frequency: str category_name: str | None = None category_id: int | None = None class ActualsBySource(BaseModel): source: str total_compra: float total_devolucion: float net: float count: int class CCCategorySpending(BaseModel): category_name: str amount: float class MonthlyDetailResponse(BaseModel): year: int month: int income_items: list[RecurringItemDetail] expense_items: list[RecurringItemDetail] savings_items: list[RecurringItemDetail] actuals_by_source: list[ActualsBySource] total_projected_income: float total_projected_expenses: float total_projected_savings: float uncovered_actual: float gran_total_egresos: float net_balance: float cc_by_category: list[CCCategorySpending] @router.get("/month/{year}/{month}", response_model=MonthlyDetailResponse) def get_monthly_detail( year: int, month: int = Path(ge=1, le=12), session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): data = compute_monthly_projection(session, year, month) return MonthlyDetailResponse( year=data["year"], month=data["month"], income_items=[RecurringItemDetail(**i) for i in data["income_items"]], expense_items=[RecurringItemDetail(**i) for i in data["expense_items"]], savings_items=[RecurringItemDetail(**i) for i in data["savings_items"]], actuals_by_source=[ActualsBySource(**a) for a in data["actuals_by_source"]], total_projected_income=data["projected_income"], total_projected_expenses=data["projected_fixed_expenses"], total_projected_savings=data["projected_savings"], uncovered_actual=data["uncovered_actual"], gran_total_egresos=data["gran_total_egresos"], net_balance=data["net_balance"], cc_by_category=[CCCategorySpending(**c) for c in data["cc_by_category"]], ) # --- Balance Override CRUD --- @router.put( "/balance-override/{year}/{month}", response_model=BalanceOverrideRead, ) def upsert_balance_override( year: int, month: int = Path(ge=1, le=12), data: BalanceOverrideCreate = ..., session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): if year < MIN_YEAR or year > MAX_YEAR: raise HTTPException(400, f"Year must be between {MIN_YEAR} and {MAX_YEAR}") if year == FRESH_START_YEAR and month < FRESH_START_MONTH: raise HTTPException(400, f"Cannot override before {FRESH_START_YEAR}-{FRESH_START_MONTH:02d}") existing = session.exec( select(BalanceOverride).where( BalanceOverride.year == year, BalanceOverride.month == month ) ).first() if existing: existing.override_balance = data.override_balance existing.updated_at = datetime.utcnow() session.add(existing) session.commit() session.refresh(existing) return existing override = BalanceOverride( year=year, month=month, override_balance=data.override_balance ) session.add(override) session.commit() session.refresh(override) return override @router.delete("/balance-override/{year}/{month}", status_code=204) def delete_balance_override( year: int, month: int = Path(ge=1, le=12), session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): existing = session.exec( select(BalanceOverride).where( BalanceOverride.year == year, BalanceOverride.month == month ) ).first() if not existing: raise HTTPException(404, "No override found for this month") session.delete(existing) session.commit()