diff --git a/backend/app/api/v1/endpoints/budget.py b/backend/app/api/v1/endpoints/budget.py index 82ac445..b4b62fe 100644 --- a/backend/app/api/v1/endpoints/budget.py +++ b/backend/app/api/v1/endpoints/budget.py @@ -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() diff --git a/backend/app/models/models.py b/backend/app/models/models.py index f830679..32a17c1 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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 diff --git a/backend/app/services/budget_projection.py b/backend/app/services/budget_projection.py index 27d21b2..5ccbc31 100644 --- a/backend/app/services/budget_projection.py +++ b/backend/app/services/budget_projection.py @@ -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 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b6e5568..32d034a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -186,6 +186,9 @@ export interface MonthlyProjection { uncovered_actual: number; gran_total_egresos: number; net_balance: number; + carryover_balance: number; + cumulative_balance: number; + balance_overridden: boolean; } export interface YearlyProjection { @@ -225,6 +228,10 @@ export const getYearlyProjection = (year: number) => api.get(`/budget/projection/${year}`); export const getMonthlyDetail = (year: number, month: number) => api.get(`/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 --- diff --git a/frontend/src/components/budget/YearlyOverview.tsx b/frontend/src/components/budget/YearlyOverview.tsx index 2c2986b..5fd6dc5 100644 --- a/frontend/src/components/budget/YearlyOverview.tsx +++ b/frontend/src/components/budget/YearlyOverview.tsx @@ -1,6 +1,10 @@ +import { useState, useRef, useEffect } from 'react'; +import { Pencil } from 'lucide-react'; + import { type MonthlyProjection } from '@/api'; import { formatAmount } from '@/lib/format'; import { cn } from '@/lib/utils'; +import { Input } from '@/components/ui/input'; import { Table, TableBody, @@ -15,19 +19,65 @@ const MONTH_NAMES = [ 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic', ]; +const FRESH_START_YEAR = 2026; +const FRESH_START_MONTH = 3; + interface YearlyOverviewProps { months: MonthlyProjection[]; selectedMonth: number; + year: number; onSelectMonth: (month: number) => void; + onSaveOverride: (month: number, value: number) => Promise; + onClearOverride: (month: number) => Promise; } export default function YearlyOverview({ months, selectedMonth, + year, onSelectMonth, + onSaveOverride, + onClearOverride, }: YearlyOverviewProps) { const currentMonth = new Date().getMonth() + 1; const currentYear = new Date().getFullYear(); + const [editingMonth, setEditingMonth] = useState(null); + const [editValue, setEditValue] = useState(''); + const inputRef = useRef(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 (
@@ -40,13 +90,19 @@ export default function YearlyOverview({ Otros Gastos Gran Total Ahorro - Balance + Acum. Anterior + Neto Mes + Balance Acum. {months.map((m) => { const isSelected = m.month === selectedMonth; 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 ( onSelectMonth(m.month)} > @@ -78,6 +135,22 @@ export default function YearlyOverview({ {formatAmount(m.projected_savings, 'CRC')} + = 0 + ? 'text-muted-foreground' + : 'text-destructive', + )} + > + {isBeforeFreshStart + ? '—' + : <> + {m.carryover_balance >= 0 ? '+' : ''} + {formatAmount(m.carryover_balance, 'CRC')} + + } + = 0 ? '+' : ''} {formatAmount(m.net_balance, 'CRC')} + { + if (isBeforeFreshStart) return; + e.stopPropagation(); + if (!isEditing) handleStartEdit(m); + }} + > + {isBeforeFreshStart ? ( + + ) : isEditing ? ( + 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()} + /> + ) : ( + = 0 + ? 'text-primary' + : 'text-destructive', + )} + > + {m.balance_overridden && ( + + )} + {m.cumulative_balance >= 0 ? '+' : ''} + {formatAmount(m.cumulative_balance, 'CRC')} + + )} + ); })} diff --git a/frontend/src/hooks/useBudget.ts b/frontend/src/hooks/useBudget.ts index 87e932d..cb83ee2 100644 --- a/frontend/src/hooks/useBudget.ts +++ b/frontend/src/hooks/useBudget.ts @@ -11,6 +11,8 @@ import { createRecurringItem, updateRecurringItem as apiUpdateItem, deleteRecurringItem as apiDeleteItem, + upsertBalanceOverride, + deleteBalanceOverride, } from '@/api'; export function useBudget(initialYear: number) { @@ -71,6 +73,16 @@ export function useBudget(initialYear: number) { 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 { year, setYear, @@ -84,6 +96,8 @@ export function useBudget(initialYear: number) { addItem, updateItem, deleteItem, + saveBalanceOverride, + clearBalanceOverride, refresh: () => Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]), }; } diff --git a/frontend/src/pages/Budget.tsx b/frontend/src/pages/Budget.tsx index b25136a..b99672d 100644 --- a/frontend/src/pages/Budget.tsx +++ b/frontend/src/pages/Budget.tsx @@ -18,8 +18,11 @@ const MONTH_NAMES = [ 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre', ]; +const MIN_YEAR = 2026; +const MAX_YEAR = 2030; + export default function Budget() { - const currentYear = new Date().getFullYear(); + const currentYear = Math.max(MIN_YEAR, new Date().getFullYear()); const { year, setYear, @@ -33,6 +36,8 @@ export default function Budget() { addItem, updateItem, deleteItem, + saveBalanceOverride, + clearBalanceOverride, refresh, } = useBudget(currentYear); @@ -81,11 +86,11 @@ export default function Budget() {

Presupuesto

- {year} -
@@ -194,10 +199,17 @@ export default function Budget() { { setSelectedMonth(m); setSubTab('detail'); }} + onSaveOverride={async (month, value) => { + await saveBalanceOverride(year, month, value); + }} + onClearOverride={async (month) => { + await clearBalanceOverride(year, month); + }} />