Files
WealthySmart/backend/app/api/v1/endpoints/budget.py
Carlos Escalante 0fdb5447b7 Add deferred transactions, revamp budget projections and UI
Adds deferred_to_next_cycle flag to transactions for billing cycle
bleed-over handling. Overhauls budget projection engine and refreshes
Budget page with improved monthly detail and transaction columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:10:23 -06:00

301 lines
8.9 KiB
Python

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()