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 typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Path, Query 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.auth import get_current_user
from app.db import get_session from app.db import get_session
from app.models.models import ( from app.models.models import (
BalanceOverride,
BalanceOverrideCreate,
BalanceOverrideRead,
RecurringItem, RecurringItem,
RecurringItemCreate, RecurringItemCreate,
RecurringItemRead, RecurringItemRead,
RecurringItemType, RecurringItemType,
RecurringItemUpdate, 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"]) router = APIRouter(prefix="/budget", tags=["budget"])
@@ -97,6 +108,9 @@ class MonthlyProjectionResponse(BaseModel):
uncovered_actual: float uncovered_actual: float
gran_total_egresos: float gran_total_egresos: float
net_balance: float net_balance: float
carryover_balance: float = 0.0
cumulative_balance: float = 0.0
balance_overridden: bool = False
class YearlyProjectionResponse(BaseModel): class YearlyProjectionResponse(BaseModel):
@@ -114,14 +128,20 @@ def get_yearly_projection(
session: Session = Depends(get_session), session: Session = Depends(get_session),
_user: str = Depends(get_current_user), _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 = [] months = []
annual_income = 0.0 annual_income = 0.0
annual_expenses = 0.0 annual_expenses = 0.0
annual_savings = 0.0 annual_savings = 0.0
annual_net = 0.0 annual_net = 0.0
for m in range(1, 13): for data in months_data:
data = compute_monthly_projection(session, year, m)
monthly = MonthlyProjectionResponse( monthly = MonthlyProjectionResponse(
month=data["month"], month=data["month"],
year=data["year"], year=data["year"],
@@ -134,6 +154,9 @@ def get_yearly_projection(
uncovered_actual=data["uncovered_actual"], uncovered_actual=data["uncovered_actual"],
gran_total_egresos=data["gran_total_egresos"], gran_total_egresos=data["gran_total_egresos"],
net_balance=data["net_balance"], net_balance=data["net_balance"],
carryover_balance=data["carryover_balance"],
cumulative_balance=data["cumulative_balance"],
balance_overridden=data["balance_overridden"],
) )
months.append(monthly) months.append(monthly)
annual_income += data["projected_income"] annual_income += data["projected_income"]
@@ -208,3 +231,63 @@ def get_monthly_detail(
gran_total_egresos=data["gran_total_egresos"], gran_total_egresos=data["gran_total_egresos"],
net_balance=data["net_balance"], 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): class PensionSnapshotRead(PensionSnapshotBase):
id: int id: int
created_at: datetime 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 sqlmodel import Session, func, select
from app.models.models import ( from app.models.models import (
BalanceOverride,
RecurringFrequency, RecurringFrequency,
RecurringItem, RecurringItem,
RecurringItemType, RecurringItemType,
@@ -12,6 +13,12 @@ from app.models.models import (
TransactionType, 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: 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.""" """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, "savings_items": savings_items,
"actuals_by_source": list(actuals_by_source.values()), "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

View File

@@ -186,6 +186,9 @@ export interface MonthlyProjection {
uncovered_actual: number; uncovered_actual: number;
gran_total_egresos: number; gran_total_egresos: number;
net_balance: number; net_balance: number;
carryover_balance: number;
cumulative_balance: number;
balance_overridden: boolean;
} }
export interface YearlyProjection { export interface YearlyProjection {
@@ -225,6 +228,10 @@ export const getYearlyProjection = (year: number) =>
api.get<YearlyProjection>(`/budget/projection/${year}`); api.get<YearlyProjection>(`/budget/projection/${year}`);
export const getMonthlyDetail = (year: number, month: number) => export const getMonthlyDetail = (year: number, month: number) =>
api.get<MonthlyDetail>(`/budget/month/${year}/${month}`); api.get<MonthlyDetail>(`/budget/month/${year}/${month}`);
export const upsertBalanceOverride = (year: number, month: number, override_balance: number) =>
api.put(`/budget/balance-override/${year}/${month}`, { override_balance });
export const deleteBalanceOverride = (year: number, month: number) =>
api.delete(`/budget/balance-override/${year}/${month}`);
// --- Salarios --- // --- Salarios ---

View File

@@ -1,6 +1,10 @@
import { useState, useRef, useEffect } from 'react';
import { Pencil } from 'lucide-react';
import { type MonthlyProjection } from '@/api'; import { type MonthlyProjection } from '@/api';
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { import {
Table, Table,
TableBody, TableBody,
@@ -15,19 +19,65 @@ const MONTH_NAMES = [
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
]; ];
const FRESH_START_YEAR = 2026;
const FRESH_START_MONTH = 3;
interface YearlyOverviewProps { interface YearlyOverviewProps {
months: MonthlyProjection[]; months: MonthlyProjection[];
selectedMonth: number; selectedMonth: number;
year: number;
onSelectMonth: (month: number) => void; onSelectMonth: (month: number) => void;
onSaveOverride: (month: number, value: number) => Promise<void>;
onClearOverride: (month: number) => Promise<void>;
} }
export default function YearlyOverview({ export default function YearlyOverview({
months, months,
selectedMonth, selectedMonth,
year,
onSelectMonth, onSelectMonth,
onSaveOverride,
onClearOverride,
}: YearlyOverviewProps) { }: YearlyOverviewProps) {
const currentMonth = new Date().getMonth() + 1; const currentMonth = new Date().getMonth() + 1;
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const [editingMonth, setEditingMonth] = useState<number | null>(null);
const [editValue, setEditValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editingMonth !== null && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editingMonth]);
const handleStartEdit = (m: MonthlyProjection) => {
setEditingMonth(m.month);
setEditValue(String(Math.round(m.cumulative_balance)));
};
const handleSave = async () => {
if (editingMonth === null) return;
const trimmed = editValue.trim();
if (trimmed === '') {
await onClearOverride(editingMonth);
} else {
const num = parseFloat(trimmed);
if (!isNaN(num)) {
await onSaveOverride(editingMonth, num);
}
}
setEditingMonth(null);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSave();
} else if (e.key === 'Escape') {
setEditingMonth(null);
}
};
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -40,13 +90,19 @@ export default function YearlyOverview({
<TableHead className="text-right">Otros Gastos</TableHead> <TableHead className="text-right">Otros Gastos</TableHead>
<TableHead className="text-right">Gran Total</TableHead> <TableHead className="text-right">Gran Total</TableHead>
<TableHead className="text-right">Ahorro</TableHead> <TableHead className="text-right">Ahorro</TableHead>
<TableHead className="text-right">Balance</TableHead> <TableHead className="text-right">Acum. Anterior</TableHead>
<TableHead className="text-right">Neto Mes</TableHead>
<TableHead className="text-right">Balance Acum.</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{months.map((m) => { {months.map((m) => {
const isSelected = m.month === selectedMonth; const isSelected = m.month === selectedMonth;
const isCurrent = m.month === currentMonth && m.year === currentYear; const isCurrent = m.month === currentMonth && m.year === currentYear;
const isBeforeFreshStart =
year === FRESH_START_YEAR && m.month < FRESH_START_MONTH;
const isEditing = editingMonth === m.month;
return ( return (
<TableRow <TableRow
key={m.month} key={m.month}
@@ -54,6 +110,7 @@ export default function YearlyOverview({
'cursor-pointer transition-colors', 'cursor-pointer transition-colors',
isSelected && 'bg-accent', isSelected && 'bg-accent',
isCurrent && !isSelected && 'bg-accent/40', isCurrent && !isSelected && 'bg-accent/40',
isBeforeFreshStart && 'opacity-40',
)} )}
onClick={() => onSelectMonth(m.month)} onClick={() => onSelectMonth(m.month)}
> >
@@ -78,6 +135,22 @@ export default function YearlyOverview({
<TableCell className="text-right font-mono text-sm text-muted-foreground"> <TableCell className="text-right font-mono text-sm text-muted-foreground">
{formatAmount(m.projected_savings, 'CRC')} {formatAmount(m.projected_savings, 'CRC')}
</TableCell> </TableCell>
<TableCell
className={cn(
'text-right font-mono text-sm',
m.carryover_balance >= 0
? 'text-muted-foreground'
: 'text-destructive',
)}
>
{isBeforeFreshStart
? '—'
: <>
{m.carryover_balance >= 0 ? '+' : ''}
{formatAmount(m.carryover_balance, 'CRC')}
</>
}
</TableCell>
<TableCell <TableCell
className={cn( className={cn(
'text-right font-mono text-sm font-semibold', 'text-right font-mono text-sm font-semibold',
@@ -87,6 +160,44 @@ export default function YearlyOverview({
{m.net_balance >= 0 ? '+' : ''} {m.net_balance >= 0 ? '+' : ''}
{formatAmount(m.net_balance, 'CRC')} {formatAmount(m.net_balance, 'CRC')}
</TableCell> </TableCell>
<TableCell
className="text-right font-mono text-sm font-semibold p-0 pr-2"
onClick={(e) => {
if (isBeforeFreshStart) return;
e.stopPropagation();
if (!isEditing) handleStartEdit(m);
}}
>
{isBeforeFreshStart ? (
<span className="px-2"></span>
) : isEditing ? (
<Input
ref={inputRef}
type="number"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="h-7 w-36 text-right font-mono text-sm ml-auto"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded cursor-pointer hover:bg-muted/50',
m.cumulative_balance >= 0
? 'text-primary'
: 'text-destructive',
)}
>
{m.balance_overridden && (
<Pencil className="w-3 h-3 text-amber-500 shrink-0" />
)}
{m.cumulative_balance >= 0 ? '+' : ''}
{formatAmount(m.cumulative_balance, 'CRC')}
</span>
)}
</TableCell>
</TableRow> </TableRow>
); );
})} })}

View File

@@ -11,6 +11,8 @@ import {
createRecurringItem, createRecurringItem,
updateRecurringItem as apiUpdateItem, updateRecurringItem as apiUpdateItem,
deleteRecurringItem as apiDeleteItem, deleteRecurringItem as apiDeleteItem,
upsertBalanceOverride,
deleteBalanceOverride,
} from '@/api'; } from '@/api';
export function useBudget(initialYear: number) { export function useBudget(initialYear: number) {
@@ -71,6 +73,16 @@ export function useBudget(initialYear: number) {
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]); await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
}; };
const saveBalanceOverride = async (overrideYear: number, month: number, value: number) => {
await upsertBalanceOverride(overrideYear, month, value);
await fetchProjection();
};
const clearBalanceOverride = async (overrideYear: number, month: number) => {
await deleteBalanceOverride(overrideYear, month);
await fetchProjection();
};
return { return {
year, year,
setYear, setYear,
@@ -84,6 +96,8 @@ export function useBudget(initialYear: number) {
addItem, addItem,
updateItem, updateItem,
deleteItem, deleteItem,
saveBalanceOverride,
clearBalanceOverride,
refresh: () => Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]), refresh: () => Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]),
}; };
} }

View File

@@ -18,8 +18,11 @@ const MONTH_NAMES = [
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
]; ];
const MIN_YEAR = 2026;
const MAX_YEAR = 2030;
export default function Budget() { export default function Budget() {
const currentYear = new Date().getFullYear(); const currentYear = Math.max(MIN_YEAR, new Date().getFullYear());
const { const {
year, year,
setYear, setYear,
@@ -33,6 +36,8 @@ export default function Budget() {
addItem, addItem,
updateItem, updateItem,
deleteItem, deleteItem,
saveBalanceOverride,
clearBalanceOverride,
refresh, refresh,
} = useBudget(currentYear); } = useBudget(currentYear);
@@ -81,11 +86,11 @@ export default function Budget() {
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1> <h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="outline" size="icon" onClick={() => setYear(year - 1)}> <Button variant="outline" size="icon" disabled={year <= MIN_YEAR} onClick={() => setYear(year - 1)}>
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
</Button> </Button>
<span className="w-16 text-center font-semibold tabular-nums">{year}</span> <span className="w-16 text-center font-semibold tabular-nums">{year}</span>
<Button variant="outline" size="icon" onClick={() => setYear(year + 1)}> <Button variant="outline" size="icon" disabled={year >= MAX_YEAR} onClick={() => setYear(year + 1)}>
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</Button> </Button>
</div> </div>
@@ -194,10 +199,17 @@ export default function Budget() {
<YearlyOverview <YearlyOverview
months={projection.months} months={projection.months}
selectedMonth={selectedMonth} selectedMonth={selectedMonth}
year={year}
onSelectMonth={(m) => { onSelectMonth={(m) => {
setSelectedMonth(m); setSelectedMonth(m);
setSubTab('detail'); setSubTab('detail');
}} }}
onSaveOverride={async (month, value) => {
await saveBalanceOverride(year, month, value);
}}
onClearOverride={async (month) => {
await clearBalanceOverride(year, month);
}}
/> />
</CardContent> </CardContent>
</Card> </Card>