From d929ed6573331031a1eda3dab3575a1638aac856 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Wed, 15 Apr 2026 19:13:29 -0600 Subject: [PATCH] Remove Ahorro from budget UI, add SALARY type and savings auto-accrual 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) --- backend/app/api/v1/endpoints/budget.py | 14 +--- backend/app/api/v1/endpoints/salarios.py | 6 +- .../app/api/v1/endpoints/savings_accrual.py | 83 +++++++++++++++++++ backend/app/api/v1/endpoints/transactions.py | 14 +++- backend/app/api/v1/router.py | 2 + backend/app/db.py | 46 ++++++++++ backend/app/models/models.py | 34 ++++++++ backend/app/services/budget_projection.py | 42 +++++----- backend/app/services/savings_accrual.py | 62 ++++++++++++++ frontend/src/api.ts | 43 ++++++++-- .../src/components/budget/MonthlyDetail.tsx | 38 +-------- .../components/budget/RecurringItemDialog.tsx | 1 - .../budget/RecurringItemsManager.tsx | 1 - .../src/components/budget/YearlyOverview.tsx | 4 - frontend/src/pages/Proyecciones.tsx | 10 +-- 15 files changed, 304 insertions(+), 96 deletions(-) create mode 100644 backend/app/api/v1/endpoints/savings_accrual.py create mode 100644 backend/app/services/savings_accrual.py diff --git a/backend/app/api/v1/endpoints/budget.py b/backend/app/api/v1/endpoints/budget.py index 3712de7..f20f63c 100644 --- a/backend/app/api/v1/endpoints/budget.py +++ b/backend/app/api/v1/endpoints/budget.py @@ -39,7 +39,9 @@ def list_recurring_items( session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): - query = select(RecurringItem) + 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: @@ -101,7 +103,6 @@ class MonthlyProjectionResponse(BaseModel): year: int projected_income: float projected_fixed_expenses: float - projected_savings: float actual_credit_card: float actual_cash: float actual_transfers: float @@ -118,7 +119,6 @@ class YearlyProjectionResponse(BaseModel): months: list[MonthlyProjectionResponse] annual_income: float annual_expenses: float - annual_savings: float annual_net: float @@ -138,7 +138,6 @@ def get_yearly_projection( months = [] annual_income = 0.0 annual_expenses = 0.0 - annual_savings = 0.0 annual_net = 0.0 for data in months_data: @@ -147,7 +146,6 @@ def get_yearly_projection( 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"], @@ -161,7 +159,6 @@ def get_yearly_projection( 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( @@ -169,7 +166,6 @@ def get_yearly_projection( months=months, annual_income=annual_income, annual_expenses=annual_expenses, - annual_savings=annual_savings, annual_net=annual_net, ) @@ -204,11 +200,9 @@ class MonthlyDetailResponse(BaseModel): 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 @@ -228,11 +222,9 @@ def get_monthly_detail( 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"], diff --git a/backend/app/api/v1/endpoints/salarios.py b/backend/app/api/v1/endpoints/salarios.py index 0772adc..c9c0ba4 100644 --- a/backend/app/api/v1/endpoints/salarios.py +++ b/backend/app/api/v1/endpoints/salarios.py @@ -12,6 +12,8 @@ from app.services.exchange_rate import get_converted_amount_expr router = APIRouter(prefix="/salarios", tags=["salarios"]) +SALARIO_TYPES = (TransactionType.SALARY, TransactionType.DEPOSITO) + class SalariosSummary(BaseModel): count: int @@ -28,7 +30,7 @@ def list_salarios( ): query = ( select(Transaction) - .where(Transaction.transaction_type == TransactionType.DEPOSITO) + .where(col(Transaction.transaction_type).in_(SALARIO_TYPES)) .order_by(col(Transaction.date).desc()) .offset(offset) .limit(limit) @@ -47,7 +49,7 @@ def salarios_summary( func.count(), func.coalesce(func.sum(amount_crc), 0), func.max(Transaction.date), - ).where(Transaction.transaction_type == TransactionType.DEPOSITO) + ).where(col(Transaction.transaction_type).in_(SALARIO_TYPES)) ).first() return SalariosSummary( count=result[0] if result else 0, diff --git a/backend/app/api/v1/endpoints/savings_accrual.py b/backend/app/api/v1/endpoints/savings_accrual.py new file mode 100644 index 0000000..7b24705 --- /dev/null +++ b/backend/app/api/v1/endpoints/savings_accrual.py @@ -0,0 +1,83 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Path +from sqlmodel import Session, col, select + +from app.auth import get_current_user +from app.db import get_session +from app.models.models import ( + SavingsAccrual, + SavingsAccrualCreate, + SavingsAccrualRead, + SavingsAccrualUpdate, +) + +router = APIRouter(prefix="/savings-accrual", tags=["savings-accrual"]) + + +@router.get("/", response_model=list[SavingsAccrualRead]) +def list_accruals( + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + query = select(SavingsAccrual).order_by( + col(SavingsAccrual.year).desc(), col(SavingsAccrual.month).desc() + ) + return session.exec(query).all() + + +@router.post("/", response_model=SavingsAccrualRead, status_code=201) +def create_accrual( + data: SavingsAccrualCreate, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + existing = session.exec( + select(SavingsAccrual).where( + SavingsAccrual.year == data.year, + SavingsAccrual.month == data.month, + ) + ).first() + if existing: + raise HTTPException( + status_code=409, + detail=f"Accrual for {data.year}-{data.month:02d} already exists (id={existing.id})", + ) + accrual = SavingsAccrual.model_validate(data) + accrual.applied_at = datetime.utcnow() + session.add(accrual) + session.commit() + session.refresh(accrual) + return accrual + + +@router.patch("/{accrual_id}", response_model=SavingsAccrualRead) +def update_accrual( + accrual_id: int, + data: SavingsAccrualUpdate, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + accrual = session.get(SavingsAccrual, accrual_id) + if not accrual: + raise HTTPException(status_code=404, detail="Accrual not found") + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(accrual, key, value) + session.add(accrual) + session.commit() + session.refresh(accrual) + return accrual + + +@router.delete("/{accrual_id}", status_code=204) +def delete_accrual( + accrual_id: int = Path(...), + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + accrual = session.get(SavingsAccrual, accrual_id) + if not accrual: + raise HTTPException(status_code=404, detail="Accrual not found") + session.delete(accrual) + session.commit() diff --git a/backend/app/api/v1/endpoints/transactions.py b/backend/app/api/v1/endpoints/transactions.py index e2d8ddd..6d2fd35 100644 --- a/backend/app/api/v1/endpoints/transactions.py +++ b/backend/app/api/v1/endpoints/transactions.py @@ -199,14 +199,20 @@ def create_transaction( symbols = {Currency.CRC: "₡", Currency.USD: "$", Currency.EUR: "€"} symbol = symbols.get(tx.currency, tx.currency.value) amount_str = f"{symbol}{tx.amount:,.0f}" if tx.currency == Currency.CRC else f"{symbol}{tx.amount:,.2f}" - is_deposit = tx.transaction_type == TransactionType.DEPOSITO + is_income = tx.transaction_type in (TransactionType.DEPOSITO, TransactionType.SALARY) + is_salary = tx.transaction_type == TransactionType.SALARY + label = "salario" if is_salary else ("depósito" if is_income else tx.transaction_type.value.lower()) send_push_to_all( session, - title=f"{'🏦' if is_deposit else '💳'} {tx.merchant}", - body=f"{amount_str} — {tx.bank.value} {'depósito' if is_deposit else tx.transaction_type.value.lower()}", - url="/salarios" if is_deposit else "/budget", + title=f"{'🏦' if is_income else '💳'} {tx.merchant}", + body=f"{amount_str} — {tx.bank.value} {label}", + url="/salarios" if is_income else "/budget", ) + if is_salary: + from app.services.savings_accrual import maybe_apply_monthly_savings + maybe_apply_monthly_savings(session, tx) + return tx diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 9b55042..8427c50 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -12,6 +12,7 @@ from app.api.v1.endpoints import ( notifications, pensions, salarios, + savings_accrual, settings, tokens, transactions, @@ -32,3 +33,4 @@ api_router.include_router(notifications.router) api_router.include_router(salarios.router) api_router.include_router(pensions.router) api_router.include_router(municipal_receipts.router) +api_router.include_router(savings_accrual.router) diff --git a/backend/app/db.py b/backend/app/db.py index d3c7525..80abf89 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -29,6 +29,52 @@ def run_migrations(): except Exception: conn.rollback() + try: + conn.execute( + text("ALTER TYPE transactiontype ADD VALUE IF NOT EXISTS 'SALARY'") + ) + conn.commit() + except Exception: + conn.rollback() + + try: + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS savingsaccrual ( + id SERIAL PRIMARY KEY, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + memp_amount DOUBLE PRECISION NOT NULL DEFAULT 200000, + mpat_amount DOUBLE PRECISION NOT NULL DEFAULT 200000, + trigger_transaction_id INTEGER, + applied_at TIMESTAMP NOT NULL DEFAULT NOW(), + notes TEXT, + CONSTRAINT savingsaccrual_year_month_key UNIQUE (year, month) + ) + """ + ) + ) + conn.commit() + except Exception: + conn.rollback() + + try: + conn.execute( + text( + """ + INSERT INTO savingsaccrual (year, month, memp_amount, mpat_amount, notes) + VALUES + (2026, 2, 200000, 200000, 'Seeded: historical baseline'), + (2026, 3, 200000, 200000, 'Seeded: historical baseline') + ON CONFLICT (year, month) DO NOTHING + """ + ) + ) + conn.commit() + except Exception: + conn.rollback() + def get_session(): with Session(engine) as session: diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 5d5edb4..0e457bd 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -24,6 +24,7 @@ class TransactionType(str, enum.Enum): COMPRA = "COMPRA" DEVOLUCION = "DEVOLUCION" DEPOSITO = "DEPOSITO" + SALARY = "SALARY" class TransactionSource(str, enum.Enum): @@ -363,6 +364,39 @@ class BalanceOverrideRead(SQLModel): updated_at: datetime +# --- Savings Accrual --- + + +class SavingsAccrualBase(SQLModel): + year: int + month: int + memp_amount: float = 200000.0 + mpat_amount: float = 200000.0 + trigger_transaction_id: Optional[int] = None + notes: Optional[str] = None + + +class SavingsAccrual(SavingsAccrualBase, table=True): + __table_args__ = (UniqueConstraint("year", "month"),) + id: Optional[int] = Field(default=None, primary_key=True) + applied_at: datetime = Field(default_factory=datetime.utcnow) + + +class SavingsAccrualCreate(SavingsAccrualBase): + pass + + +class SavingsAccrualRead(SavingsAccrualBase): + id: int + applied_at: datetime + + +class SavingsAccrualUpdate(SQLModel): + memp_amount: Optional[float] = None + mpat_amount: Optional[float] = None + notes: Optional[str] = None + + # --- Municipal Receipt --- diff --git a/backend/app/services/budget_projection.py b/backend/app/services/budget_projection.py index d0ee40d..695cec4 100644 --- a/backend/app/services/budget_projection.py +++ b/backend/app/services/budget_projection.py @@ -1,7 +1,7 @@ import calendar from datetime import datetime -from sqlmodel import Session, func, select +from sqlmodel import Session, col, func, select from app.models.models import ( BalanceOverride, @@ -20,6 +20,9 @@ MAX_YEAR = 2030 FRESH_START_YEAR = 2026 FRESH_START_MONTH = 3 +# Income-like transaction types that should never be counted as expenses +INCOME_TYPES = (TransactionType.DEPOSITO, TransactionType.SALARY) + 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.""" @@ -158,7 +161,7 @@ def compute_actuals_by_source( Transaction.date >= start, Transaction.date < end, Transaction.source == source, - Transaction.transaction_type != TransactionType.DEPOSITO, + col(Transaction.transaction_type).notin_(INCOME_TYPES), Transaction.deferred_to_next_cycle == False, # noqa: E712 ) ).one() @@ -167,7 +170,7 @@ def compute_actuals_by_source( Transaction.date >= prev_start, Transaction.date < prev_end, Transaction.source == source, - Transaction.transaction_type != TransactionType.DEPOSITO, + col(Transaction.transaction_type).notin_(INCOME_TYPES), Transaction.deferred_to_next_cycle == True, # noqa: E712 ) ).one() @@ -203,7 +206,7 @@ def compute_actuals_by_source( Transaction.date >= cal_start, Transaction.date < cal_end, Transaction.source == source, - Transaction.transaction_type != TransactionType.DEPOSITO, + col(Transaction.transaction_type).notin_(INCOME_TYPES), ) ).one() @@ -257,7 +260,7 @@ def compute_actuals_by_category( Transaction.date < cc_end, Transaction.source == TransactionSource.CREDIT_CARD, Transaction.category_id.is_not(None), # type: ignore[union-attr] - Transaction.transaction_type != TransactionType.DEPOSITO, + col(Transaction.transaction_type).notin_(INCOME_TYPES), Transaction.deferred_to_next_cycle == False, # noqa: E712 ) .group_by(Transaction.category_id, Transaction.transaction_type) @@ -277,7 +280,7 @@ def compute_actuals_by_category( Transaction.date < prev_end, Transaction.source == TransactionSource.CREDIT_CARD, Transaction.category_id.is_not(None), # type: ignore[union-attr] - Transaction.transaction_type != TransactionType.DEPOSITO, + col(Transaction.transaction_type).notin_(INCOME_TYPES), Transaction.deferred_to_next_cycle == True, # noqa: E712 ) .group_by(Transaction.category_id, Transaction.transaction_type) @@ -297,7 +300,7 @@ def compute_actuals_by_category( Transaction.date < cal_end, Transaction.source != TransactionSource.CREDIT_CARD, Transaction.category_id.is_not(None), # type: ignore[union-attr] - Transaction.transaction_type != TransactionType.DEPOSITO, + col(Transaction.transaction_type).notin_(INCOME_TYPES), ) .group_by(Transaction.category_id, Transaction.transaction_type) ).all() @@ -338,7 +341,7 @@ def compute_cc_by_category( Transaction.date >= cc_start, Transaction.date < cc_end, Transaction.source == TransactionSource.CREDIT_CARD, - Transaction.transaction_type != TransactionType.DEPOSITO, + col(Transaction.transaction_type).notin_(INCOME_TYPES), Transaction.deferred_to_next_cycle == False, # noqa: E712 ) .group_by(Transaction.category_id, Transaction.transaction_type) @@ -356,7 +359,7 @@ def compute_cc_by_category( Transaction.date >= prev_start, Transaction.date < prev_end, Transaction.source == TransactionSource.CREDIT_CARD, - Transaction.transaction_type != TransactionType.DEPOSITO, + col(Transaction.transaction_type).notin_(INCOME_TYPES), Transaction.deferred_to_next_cycle == True, # noqa: E712 ) .group_by(Transaction.category_id, Transaction.transaction_type) @@ -385,7 +388,10 @@ def compute_monthly_projection( ) -> dict: """Compute full monthly projection with no-double-count logic.""" items = session.exec( - select(RecurringItem).where(RecurringItem.is_active == True) # noqa: E712 + select(RecurringItem).where( + RecurringItem.is_active == True, # noqa: E712 + RecurringItem.item_type != RecurringItemType.SAVINGS, + ) ).all() actuals_by_source = compute_actuals_by_source(session, year, month) @@ -393,11 +399,9 @@ def compute_monthly_projection( income_items = [] expense_items = [] - savings_items = [] total_income = 0.0 total_fixed_expenses = 0.0 - total_savings = 0.0 for item in items: effective = get_effective_amount(item, month, year) @@ -431,10 +435,6 @@ def compute_monthly_projection( total_fixed_expenses += effective expense_items.append(detail) - elif item.item_type == RecurringItemType.SAVINGS: - savings_items.append(detail) - total_savings += effective - # Sum actuals from sources for categories NOT covered by recurring items covered_category_ids = { item.category_id @@ -476,7 +476,7 @@ def compute_monthly_projection( Transaction.date < cc_end, Transaction.source == TransactionSource.CREDIT_CARD, Transaction.category_id.is_(None), # type: ignore[union-attr] - Transaction.transaction_type != TransactionType.DEPOSITO, + col(Transaction.transaction_type).notin_(INCOME_TYPES), Transaction.deferred_to_next_cycle == False, # noqa: E712 ) .group_by(Transaction.transaction_type) @@ -491,7 +491,7 @@ def compute_monthly_projection( Transaction.date < prev_end, Transaction.source == TransactionSource.CREDIT_CARD, Transaction.category_id.is_(None), # type: ignore[union-attr] - Transaction.transaction_type != TransactionType.DEPOSITO, + col(Transaction.transaction_type).notin_(INCOME_TYPES), Transaction.deferred_to_next_cycle == True, # noqa: E712 ) .group_by(Transaction.transaction_type) @@ -506,7 +506,7 @@ def compute_monthly_projection( Transaction.date < cal_end, Transaction.source != TransactionSource.CREDIT_CARD, Transaction.category_id.is_(None), # type: ignore[union-attr] - Transaction.transaction_type != TransactionType.DEPOSITO, + col(Transaction.transaction_type).notin_(INCOME_TYPES), ) .group_by(Transaction.transaction_type) ).all() @@ -518,8 +518,6 @@ def compute_monthly_projection( cc_by_category = compute_cc_by_category(session, year, month) gran_total = total_fixed_expenses + uncovered_actual - # Savings are NOT deducted — they are already deducted from gross salary - # (the income amounts are net, post-savings) net_balance = total_income - gran_total return { @@ -527,7 +525,6 @@ def compute_monthly_projection( "month": month, "projected_income": total_income, "projected_fixed_expenses": total_fixed_expenses, - "projected_savings": total_savings, "actual_credit_card": actual_credit_card, "actual_cash": actual_cash, "actual_transfers": actual_transfers, @@ -536,7 +533,6 @@ def compute_monthly_projection( "net_balance": net_balance, "income_items": income_items, "expense_items": expense_items, - "savings_items": savings_items, "actuals_by_source": list(actuals_by_source.values()), "cc_by_category": cc_by_category, } diff --git a/backend/app/services/savings_accrual.py b/backend/app/services/savings_accrual.py new file mode 100644 index 0000000..b593c5d --- /dev/null +++ b/backend/app/services/savings_accrual.py @@ -0,0 +1,62 @@ +from sqlmodel import Session, select + +from app.models.models import ( + Account, + AccountType, + Bank, + SavingsAccrual, + Transaction, +) + +MEMP_MONTHLY = 200000.0 +MPAT_MONTHLY = 200000.0 + + +def _get_savings_account(session: Session, bank: Bank) -> Account | None: + return session.exec( + select(Account).where( + Account.account_type == AccountType.SAVINGS, + Account.bank == bank, + ) + ).first() + + +def maybe_apply_monthly_savings(session: Session, tx: Transaction) -> SavingsAccrual | None: + """Apply monthly savings contribution if this is the first salary of the month. + + Idempotent: if a SavingsAccrual row already exists for (year, month), do nothing. + Bumps MEMP and MPAT savings account balances and records the accrual. + """ + year = tx.date.year + month = tx.date.month + + existing = session.exec( + select(SavingsAccrual).where( + SavingsAccrual.year == year, + SavingsAccrual.month == month, + ) + ).first() + if existing: + return None + + memp = _get_savings_account(session, Bank.MEMP) + mpat = _get_savings_account(session, Bank.MPAT) + if memp is None or mpat is None: + return None + + memp.balance += MEMP_MONTHLY + mpat.balance += MPAT_MONTHLY + session.add(memp) + session.add(mpat) + + accrual = SavingsAccrual( + year=year, + month=month, + memp_amount=MEMP_MONTHLY, + mpat_amount=MPAT_MONTHLY, + trigger_transaction_id=tx.id, + ) + session.add(accrual) + session.commit() + session.refresh(accrual) + return accrual diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b8586f2..7a650fa 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -160,7 +160,7 @@ export interface Transaction { // --- Budget / Recurring Items --- -export type RecurringItemType = 'INCOME' | 'EXPENSE' | 'SAVINGS'; +export type RecurringItemType = 'INCOME' | 'EXPENSE'; export type RecurringFrequency = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'BIANNUAL' | 'YEARLY'; export interface RecurringItem { @@ -233,7 +233,6 @@ export interface MonthlyProjection { year: number; projected_income: number; projected_fixed_expenses: number; - projected_savings: number; actual_credit_card: number; actual_cash: number; actual_transfers: number; @@ -250,7 +249,6 @@ export interface YearlyProjection { months: MonthlyProjection[]; annual_income: number; annual_expenses: number; - annual_savings: number; annual_net: number; } @@ -259,17 +257,52 @@ export interface MonthlyDetail { month: number; income_items: RecurringItemDetail[]; expense_items: RecurringItemDetail[]; - savings_items: RecurringItemDetail[]; actuals_by_source: ActualsBySource[]; total_projected_income: number; total_projected_expenses: number; - total_projected_savings: number; uncovered_actual: number; gran_total_egresos: number; net_balance: number; cc_by_category: { category_name: string; amount: number }[]; } +// --- Savings Accrual --- + +export interface SavingsAccrual { + id: number; + year: number; + month: number; + memp_amount: number; + mpat_amount: number; + trigger_transaction_id: number | null; + applied_at: string; + notes: string | null; +} + +export interface SavingsAccrualCreate { + year: number; + month: number; + memp_amount?: number; + mpat_amount?: number; + trigger_transaction_id?: number | null; + notes?: string | null; +} + +export interface SavingsAccrualUpdate { + memp_amount?: number; + mpat_amount?: number; + notes?: string | null; +} + +export const getSavingsAccruals = () => + api.get('/savings-accrual/'); +export const createSavingsAccrual = (data: SavingsAccrualCreate) => + api.post('/savings-accrual/', data); +export const updateSavingsAccrual = (id: number, data: SavingsAccrualUpdate) => + api.patch(`/savings-accrual/${id}`, data); +export const deleteSavingsAccrual = (id: number) => + api.delete(`/savings-accrual/${id}`); + // Budget API functions export const getRecurringItems = (params?: { item_type?: string; is_active?: boolean }) => api.get('/budget/recurring', { params }); diff --git a/frontend/src/components/budget/MonthlyDetail.tsx b/frontend/src/components/budget/MonthlyDetail.tsx index ce386f9..a5e2c4d 100644 --- a/frontend/src/components/budget/MonthlyDetail.tsx +++ b/frontend/src/components/budget/MonthlyDetail.tsx @@ -16,7 +16,6 @@ import { import { TrendingUp, TrendingDown, - PiggyBank, CreditCard, Banknote, ArrowLeftRight, @@ -346,8 +345,8 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction )} - {/* Actuals + Savings + Summary */} -
+ {/* Actuals + Summary */} +
{/* Cash & Transfer Actuals Card */} @@ -401,33 +400,6 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction - {/* Savings */} - {detail.savings_items.length > 0 && ( - - - - - Ahorro - - - - {detail.savings_items.map((item) => ( -
- {item.name} - {formatAmount(item.amount, 'CRC')} -
- ))} - -
- Total Ahorro - - {formatAmount(detail.total_projected_savings, 'CRC')} - -
-
-
- )} - {/* Summary */}
-
- Ahorro - - -{formatAmount(detail.total_projected_savings, 'CRC')} - -
Balance Neto diff --git a/frontend/src/components/budget/RecurringItemDialog.tsx b/frontend/src/components/budget/RecurringItemDialog.tsx index 9feb6b0..5585375 100644 --- a/frontend/src/components/budget/RecurringItemDialog.tsx +++ b/frontend/src/components/budget/RecurringItemDialog.tsx @@ -29,7 +29,6 @@ import { Plus, Trash2 } from 'lucide-react'; const TYPE_OPTIONS: { value: RecurringItemType; label: string }[] = [ { value: 'INCOME', label: 'Ingreso' }, { value: 'EXPENSE', label: 'Egreso' }, - { value: 'SAVINGS', label: 'Ahorro' }, ]; const FREQ_OPTIONS: { value: RecurringFrequency; label: string }[] = [ diff --git a/frontend/src/components/budget/RecurringItemsManager.tsx b/frontend/src/components/budget/RecurringItemsManager.tsx index 3cff817..ab1b2c0 100644 --- a/frontend/src/components/budget/RecurringItemsManager.tsx +++ b/frontend/src/components/budget/RecurringItemsManager.tsx @@ -17,7 +17,6 @@ import ConfirmDialog from '@/components/ConfirmDialog'; const TYPE_LABELS: Record = { INCOME: { label: 'Ingreso', variant: 'default' }, EXPENSE: { label: 'Egreso', variant: 'secondary' }, - SAVINGS: { label: 'Ahorro', variant: 'outline' }, }; const FREQ_LABELS: Record = { diff --git a/frontend/src/components/budget/YearlyOverview.tsx b/frontend/src/components/budget/YearlyOverview.tsx index 7b56acc..4b025a6 100644 --- a/frontend/src/components/budget/YearlyOverview.tsx +++ b/frontend/src/components/budget/YearlyOverview.tsx @@ -89,7 +89,6 @@ export default function YearlyOverview({ Egresos Fijos Otros Gastos Gran Total - Ahorro Acum. Anterior Neto Mes Balance Acum. @@ -132,9 +131,6 @@ export default function YearlyOverview({ {formatAmount(m.gran_total_egresos, 'CRC')} - - {formatAmount(m.projected_savings, 'CRC')} - +

Ingresos Anuales

@@ -63,14 +63,6 @@ export default function Proyecciones() {

- - -

Ahorro Anual

-

- {formatAmount(projection.annual_savings, 'CRC')} -

-
-

Balance Neto Anual