mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 14:08:47 +02:00
All checks were successful
Deploy to VPS / deploy (push) Successful in 23s
Ahorro was already deducted from gross salary so displaying it in budget projections was misleading. This removes the Ahorro card, summary line, Proyecciones column, and Ahorro Anual card from the UI, and strips all savings fields from budget API responses. Adds SALARY TransactionType so salary deposits can be distinguished from generic DEPOSITO transfers. When a SALARY transaction arrives, the system auto-increments MEMP and MPAT savings account balances (+200K CRC each) once per month via an idempotent accrual log. New CRUD endpoints at /api/v1/savings-accrual/ allow manual correction of the accrual history. Feb+Mar 2026 are seeded as historical baseline. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
293 lines
8.6 KiB
Python
293 lines
8.6 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).where(
|
|
RecurringItem.item_type != RecurringItemType.SAVINGS
|
|
)
|
|
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
|
|
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_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_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"],
|
|
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_net += data["net_balance"]
|
|
|
|
return YearlyProjectionResponse(
|
|
year=year,
|
|
months=months,
|
|
annual_income=annual_income,
|
|
annual_expenses=annual_expenses,
|
|
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]
|
|
actuals_by_source: list[ActualsBySource]
|
|
total_projected_income: float
|
|
total_projected_expenses: 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"]],
|
|
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"],
|
|
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()
|