mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 14:28:47 +02:00
All checks were successful
Deploy to VPS / deploy (push) Successful in 21s
- New BalanceOverride table for manual balance adjustments per month
- Cumulative balance computation with cross-year carryover
- Three new columns: Acum. Anterior, Neto Mes, Balance Acum.
- Inline editing on Balance Acum. cell (pencil icon for overrides)
- Year navigation clamped to 2026–2030, fresh start at March 2026
- PUT/DELETE /budget/balance-override/{year}/{month} endpoints
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
294 lines
8.7 KiB
Python
294 lines
8.7 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 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
|
|
|
|
|
|
@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"],
|
|
)
|
|
|
|
|
|
# --- 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()
|