Add cumulative balance tracking with editable overrides
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>
This commit is contained in:
Carlos Escalante
2026-03-30 12:03:43 -06:00
parent 99d0c4ebd7
commit b68129a171
7 changed files with 343 additions and 7 deletions

View File

@@ -1,3 +1,4 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Path, Query
@@ -7,13 +8,23 @@ 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 compute_monthly_projection
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"])
@@ -97,6 +108,9 @@ class MonthlyProjectionResponse(BaseModel):
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):
@@ -114,14 +128,20 @@ def get_yearly_projection(
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 m in range(1, 13):
data = compute_monthly_projection(session, year, m)
for data in months_data:
monthly = MonthlyProjectionResponse(
month=data["month"],
year=data["year"],
@@ -134,6 +154,9 @@ def get_yearly_projection(
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"]
@@ -208,3 +231,63 @@ def get_monthly_detail(
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()

View File

@@ -333,3 +333,28 @@ class PensionSnapshot(PensionSnapshotBase, table=True):
class PensionSnapshotRead(PensionSnapshotBase):
id: int
created_at: datetime
# --- Balance Override ---
class BalanceOverride(SQLModel, table=True):
__table_args__ = (UniqueConstraint("year", "month"),)
id: Optional[int] = Field(default=None, primary_key=True)
year: int
month: int
override_balance: float
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
class BalanceOverrideCreate(SQLModel):
override_balance: float
class BalanceOverrideRead(SQLModel):
id: int
year: int
month: int
override_balance: float
updated_at: datetime

View File

@@ -4,6 +4,7 @@ from datetime import datetime
from sqlmodel import Session, func, select
from app.models.models import (
BalanceOverride,
RecurringFrequency,
RecurringItem,
RecurringItemType,
@@ -12,6 +13,12 @@ from app.models.models import (
TransactionType,
)
MIN_YEAR = 2026
MAX_YEAR = 2030
# Fresh start: months before this are zeroed out
FRESH_START_YEAR = 2026
FRESH_START_MONTH = 3
def get_effective_amount(item: RecurringItem, month: int, year: int) -> float | None:
"""Return the effective amount for a recurring item in a given month, or None if inactive."""
@@ -255,3 +262,80 @@ def compute_monthly_projection(
"savings_items": savings_items,
"actuals_by_source": list(actuals_by_source.values()),
}
def _get_december_cumulative(session: Session, year: int) -> float:
"""Get the cumulative balance for December of a given year."""
# Check for an override first
override = session.exec(
select(BalanceOverride).where(
BalanceOverride.year == year, BalanceOverride.month == 12
)
).first()
if override:
return override.override_balance
# Compute the full year to get December's cumulative
overrides = session.exec(
select(BalanceOverride).where(BalanceOverride.year == year)
).all()
override_map = {o.month: o.override_balance for o in overrides}
cumulative = 0.0
if year > FRESH_START_YEAR:
cumulative = _get_december_cumulative(session, year - 1)
for m in range(1, 13):
if year == FRESH_START_YEAR and m < FRESH_START_MONTH:
continue
data = compute_monthly_projection(session, year, m)
cumulative += data["net_balance"]
if m in override_map:
cumulative = override_map[m]
return cumulative
def compute_yearly_projection_with_cumulative(
session: Session, year: int
) -> list[dict]:
"""Compute all 12 months with cumulative balance tracking."""
overrides = session.exec(
select(BalanceOverride).where(BalanceOverride.year == year)
).all()
override_map = {o.month: o.override_balance for o in overrides}
# Determine January carryover
if year <= FRESH_START_YEAR:
carryover = 0.0
else:
carryover = _get_december_cumulative(session, year - 1)
months = []
for m in range(1, 13):
data = compute_monthly_projection(session, year, m)
is_before_fresh_start = (
year == FRESH_START_YEAR and m < FRESH_START_MONTH
)
if is_before_fresh_start:
data["carryover_balance"] = 0.0
data["cumulative_balance"] = 0.0
data["balance_overridden"] = False
else:
data["carryover_balance"] = carryover
cumulative = carryover + data["net_balance"]
if m in override_map:
cumulative = override_map[m]
data["balance_overridden"] = True
else:
data["balance_overridden"] = False
data["cumulative_balance"] = cumulative
carryover = cumulative
months.append(data)
return months