Remove Ahorro from budget UI, add SALARY type and savings auto-accrual
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>
This commit is contained in:
Carlos Escalante
2026-04-15 19:13:29 -06:00
parent 94a8a894a6
commit d929ed6573
15 changed files with 304 additions and 96 deletions

View File

@@ -39,7 +39,9 @@ def list_recurring_items(
session: Session = Depends(get_session), session: Session = Depends(get_session),
_user: str = Depends(get_current_user), _user: str = Depends(get_current_user),
): ):
query = select(RecurringItem) query = select(RecurringItem).where(
RecurringItem.item_type != RecurringItemType.SAVINGS
)
if item_type: if item_type:
query = query.where(RecurringItem.item_type == item_type) query = query.where(RecurringItem.item_type == item_type)
if is_active is not None: if is_active is not None:
@@ -101,7 +103,6 @@ class MonthlyProjectionResponse(BaseModel):
year: int year: int
projected_income: float projected_income: float
projected_fixed_expenses: float projected_fixed_expenses: float
projected_savings: float
actual_credit_card: float actual_credit_card: float
actual_cash: float actual_cash: float
actual_transfers: float actual_transfers: float
@@ -118,7 +119,6 @@ class YearlyProjectionResponse(BaseModel):
months: list[MonthlyProjectionResponse] months: list[MonthlyProjectionResponse]
annual_income: float annual_income: float
annual_expenses: float annual_expenses: float
annual_savings: float
annual_net: float annual_net: float
@@ -138,7 +138,6 @@ def get_yearly_projection(
months = [] months = []
annual_income = 0.0 annual_income = 0.0
annual_expenses = 0.0 annual_expenses = 0.0
annual_savings = 0.0
annual_net = 0.0 annual_net = 0.0
for data in months_data: for data in months_data:
@@ -147,7 +146,6 @@ def get_yearly_projection(
year=data["year"], year=data["year"],
projected_income=data["projected_income"], projected_income=data["projected_income"],
projected_fixed_expenses=data["projected_fixed_expenses"], projected_fixed_expenses=data["projected_fixed_expenses"],
projected_savings=data["projected_savings"],
actual_credit_card=data["actual_credit_card"], actual_credit_card=data["actual_credit_card"],
actual_cash=data["actual_cash"], actual_cash=data["actual_cash"],
actual_transfers=data["actual_transfers"], actual_transfers=data["actual_transfers"],
@@ -161,7 +159,6 @@ def get_yearly_projection(
months.append(monthly) months.append(monthly)
annual_income += data["projected_income"] annual_income += data["projected_income"]
annual_expenses += data["gran_total_egresos"] annual_expenses += data["gran_total_egresos"]
annual_savings += data["projected_savings"]
annual_net += data["net_balance"] annual_net += data["net_balance"]
return YearlyProjectionResponse( return YearlyProjectionResponse(
@@ -169,7 +166,6 @@ def get_yearly_projection(
months=months, months=months,
annual_income=annual_income, annual_income=annual_income,
annual_expenses=annual_expenses, annual_expenses=annual_expenses,
annual_savings=annual_savings,
annual_net=annual_net, annual_net=annual_net,
) )
@@ -204,11 +200,9 @@ class MonthlyDetailResponse(BaseModel):
month: int month: int
income_items: list[RecurringItemDetail] income_items: list[RecurringItemDetail]
expense_items: list[RecurringItemDetail] expense_items: list[RecurringItemDetail]
savings_items: list[RecurringItemDetail]
actuals_by_source: list[ActualsBySource] actuals_by_source: list[ActualsBySource]
total_projected_income: float total_projected_income: float
total_projected_expenses: float total_projected_expenses: float
total_projected_savings: float
uncovered_actual: float uncovered_actual: float
gran_total_egresos: float gran_total_egresos: float
net_balance: float net_balance: float
@@ -228,11 +222,9 @@ def get_monthly_detail(
month=data["month"], month=data["month"],
income_items=[RecurringItemDetail(**i) for i in data["income_items"]], income_items=[RecurringItemDetail(**i) for i in data["income_items"]],
expense_items=[RecurringItemDetail(**i) for i in data["expense_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"]], actuals_by_source=[ActualsBySource(**a) for a in data["actuals_by_source"]],
total_projected_income=data["projected_income"], total_projected_income=data["projected_income"],
total_projected_expenses=data["projected_fixed_expenses"], total_projected_expenses=data["projected_fixed_expenses"],
total_projected_savings=data["projected_savings"],
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"],

View File

@@ -12,6 +12,8 @@ from app.services.exchange_rate import get_converted_amount_expr
router = APIRouter(prefix="/salarios", tags=["salarios"]) router = APIRouter(prefix="/salarios", tags=["salarios"])
SALARIO_TYPES = (TransactionType.SALARY, TransactionType.DEPOSITO)
class SalariosSummary(BaseModel): class SalariosSummary(BaseModel):
count: int count: int
@@ -28,7 +30,7 @@ def list_salarios(
): ):
query = ( query = (
select(Transaction) select(Transaction)
.where(Transaction.transaction_type == TransactionType.DEPOSITO) .where(col(Transaction.transaction_type).in_(SALARIO_TYPES))
.order_by(col(Transaction.date).desc()) .order_by(col(Transaction.date).desc())
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
@@ -47,7 +49,7 @@ def salarios_summary(
func.count(), func.count(),
func.coalesce(func.sum(amount_crc), 0), func.coalesce(func.sum(amount_crc), 0),
func.max(Transaction.date), func.max(Transaction.date),
).where(Transaction.transaction_type == TransactionType.DEPOSITO) ).where(col(Transaction.transaction_type).in_(SALARIO_TYPES))
).first() ).first()
return SalariosSummary( return SalariosSummary(
count=result[0] if result else 0, count=result[0] if result else 0,

View File

@@ -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()

View File

@@ -199,14 +199,20 @@ def create_transaction(
symbols = {Currency.CRC: "", Currency.USD: "$", Currency.EUR: ""} symbols = {Currency.CRC: "", Currency.USD: "$", Currency.EUR: ""}
symbol = symbols.get(tx.currency, tx.currency.value) 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}" 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( send_push_to_all(
session, session,
title=f"{'🏦' if is_deposit else '💳'} {tx.merchant}", title=f"{'🏦' if is_income else '💳'} {tx.merchant}",
body=f"{amount_str}{tx.bank.value} {'depósito' if is_deposit else tx.transaction_type.value.lower()}", body=f"{amount_str}{tx.bank.value} {label}",
url="/salarios" if is_deposit else "/budget", 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 return tx

View File

@@ -12,6 +12,7 @@ from app.api.v1.endpoints import (
notifications, notifications,
pensions, pensions,
salarios, salarios,
savings_accrual,
settings, settings,
tokens, tokens,
transactions, transactions,
@@ -32,3 +33,4 @@ api_router.include_router(notifications.router)
api_router.include_router(salarios.router) api_router.include_router(salarios.router)
api_router.include_router(pensions.router) api_router.include_router(pensions.router)
api_router.include_router(municipal_receipts.router) api_router.include_router(municipal_receipts.router)
api_router.include_router(savings_accrual.router)

View File

@@ -29,6 +29,52 @@ def run_migrations():
except Exception: except Exception:
conn.rollback() 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(): def get_session():
with Session(engine) as session: with Session(engine) as session:

View File

@@ -24,6 +24,7 @@ class TransactionType(str, enum.Enum):
COMPRA = "COMPRA" COMPRA = "COMPRA"
DEVOLUCION = "DEVOLUCION" DEVOLUCION = "DEVOLUCION"
DEPOSITO = "DEPOSITO" DEPOSITO = "DEPOSITO"
SALARY = "SALARY"
class TransactionSource(str, enum.Enum): class TransactionSource(str, enum.Enum):
@@ -363,6 +364,39 @@ class BalanceOverrideRead(SQLModel):
updated_at: datetime 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 --- # --- Municipal Receipt ---

View File

@@ -1,7 +1,7 @@
import calendar import calendar
from datetime import datetime from datetime import datetime
from sqlmodel import Session, func, select from sqlmodel import Session, col, func, select
from app.models.models import ( from app.models.models import (
BalanceOverride, BalanceOverride,
@@ -20,6 +20,9 @@ MAX_YEAR = 2030
FRESH_START_YEAR = 2026 FRESH_START_YEAR = 2026
FRESH_START_MONTH = 3 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: 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."""
@@ -158,7 +161,7 @@ def compute_actuals_by_source(
Transaction.date >= start, Transaction.date >= start,
Transaction.date < end, Transaction.date < end,
Transaction.source == source, Transaction.source == source,
Transaction.transaction_type != TransactionType.DEPOSITO, col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == False, # noqa: E712 Transaction.deferred_to_next_cycle == False, # noqa: E712
) )
).one() ).one()
@@ -167,7 +170,7 @@ def compute_actuals_by_source(
Transaction.date >= prev_start, Transaction.date >= prev_start,
Transaction.date < prev_end, Transaction.date < prev_end,
Transaction.source == source, Transaction.source == source,
Transaction.transaction_type != TransactionType.DEPOSITO, col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == True, # noqa: E712 Transaction.deferred_to_next_cycle == True, # noqa: E712
) )
).one() ).one()
@@ -203,7 +206,7 @@ def compute_actuals_by_source(
Transaction.date >= cal_start, Transaction.date >= cal_start,
Transaction.date < cal_end, Transaction.date < cal_end,
Transaction.source == source, Transaction.source == source,
Transaction.transaction_type != TransactionType.DEPOSITO, col(Transaction.transaction_type).notin_(INCOME_TYPES),
) )
).one() ).one()
@@ -257,7 +260,7 @@ def compute_actuals_by_category(
Transaction.date < cc_end, Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD, Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr] 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 Transaction.deferred_to_next_cycle == False, # noqa: E712
) )
.group_by(Transaction.category_id, Transaction.transaction_type) .group_by(Transaction.category_id, Transaction.transaction_type)
@@ -277,7 +280,7 @@ def compute_actuals_by_category(
Transaction.date < prev_end, Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD, Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr] 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 Transaction.deferred_to_next_cycle == True, # noqa: E712
) )
.group_by(Transaction.category_id, Transaction.transaction_type) .group_by(Transaction.category_id, Transaction.transaction_type)
@@ -297,7 +300,7 @@ def compute_actuals_by_category(
Transaction.date < cal_end, Transaction.date < cal_end,
Transaction.source != TransactionSource.CREDIT_CARD, Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr] 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) .group_by(Transaction.category_id, Transaction.transaction_type)
).all() ).all()
@@ -338,7 +341,7 @@ def compute_cc_by_category(
Transaction.date >= cc_start, Transaction.date >= cc_start,
Transaction.date < cc_end, Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD, 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 Transaction.deferred_to_next_cycle == False, # noqa: E712
) )
.group_by(Transaction.category_id, Transaction.transaction_type) .group_by(Transaction.category_id, Transaction.transaction_type)
@@ -356,7 +359,7 @@ def compute_cc_by_category(
Transaction.date >= prev_start, Transaction.date >= prev_start,
Transaction.date < prev_end, Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD, 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 Transaction.deferred_to_next_cycle == True, # noqa: E712
) )
.group_by(Transaction.category_id, Transaction.transaction_type) .group_by(Transaction.category_id, Transaction.transaction_type)
@@ -385,7 +388,10 @@ def compute_monthly_projection(
) -> dict: ) -> dict:
"""Compute full monthly projection with no-double-count logic.""" """Compute full monthly projection with no-double-count logic."""
items = session.exec( 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() ).all()
actuals_by_source = compute_actuals_by_source(session, year, month) actuals_by_source = compute_actuals_by_source(session, year, month)
@@ -393,11 +399,9 @@ def compute_monthly_projection(
income_items = [] income_items = []
expense_items = [] expense_items = []
savings_items = []
total_income = 0.0 total_income = 0.0
total_fixed_expenses = 0.0 total_fixed_expenses = 0.0
total_savings = 0.0
for item in items: for item in items:
effective = get_effective_amount(item, month, year) effective = get_effective_amount(item, month, year)
@@ -431,10 +435,6 @@ def compute_monthly_projection(
total_fixed_expenses += effective total_fixed_expenses += effective
expense_items.append(detail) 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 # Sum actuals from sources for categories NOT covered by recurring items
covered_category_ids = { covered_category_ids = {
item.category_id item.category_id
@@ -476,7 +476,7 @@ def compute_monthly_projection(
Transaction.date < cc_end, Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD, Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr] 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 Transaction.deferred_to_next_cycle == False, # noqa: E712
) )
.group_by(Transaction.transaction_type) .group_by(Transaction.transaction_type)
@@ -491,7 +491,7 @@ def compute_monthly_projection(
Transaction.date < prev_end, Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD, Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr] 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 Transaction.deferred_to_next_cycle == True, # noqa: E712
) )
.group_by(Transaction.transaction_type) .group_by(Transaction.transaction_type)
@@ -506,7 +506,7 @@ def compute_monthly_projection(
Transaction.date < cal_end, Transaction.date < cal_end,
Transaction.source != TransactionSource.CREDIT_CARD, Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr] 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) .group_by(Transaction.transaction_type)
).all() ).all()
@@ -518,8 +518,6 @@ def compute_monthly_projection(
cc_by_category = compute_cc_by_category(session, year, month) cc_by_category = compute_cc_by_category(session, year, month)
gran_total = total_fixed_expenses + uncovered_actual 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 net_balance = total_income - gran_total
return { return {
@@ -527,7 +525,6 @@ def compute_monthly_projection(
"month": month, "month": month,
"projected_income": total_income, "projected_income": total_income,
"projected_fixed_expenses": total_fixed_expenses, "projected_fixed_expenses": total_fixed_expenses,
"projected_savings": total_savings,
"actual_credit_card": actual_credit_card, "actual_credit_card": actual_credit_card,
"actual_cash": actual_cash, "actual_cash": actual_cash,
"actual_transfers": actual_transfers, "actual_transfers": actual_transfers,
@@ -536,7 +533,6 @@ def compute_monthly_projection(
"net_balance": net_balance, "net_balance": net_balance,
"income_items": income_items, "income_items": income_items,
"expense_items": expense_items, "expense_items": expense_items,
"savings_items": savings_items,
"actuals_by_source": list(actuals_by_source.values()), "actuals_by_source": list(actuals_by_source.values()),
"cc_by_category": cc_by_category, "cc_by_category": cc_by_category,
} }

View File

@@ -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

View File

@@ -160,7 +160,7 @@ export interface Transaction {
// --- Budget / Recurring Items --- // --- Budget / Recurring Items ---
export type RecurringItemType = 'INCOME' | 'EXPENSE' | 'SAVINGS'; export type RecurringItemType = 'INCOME' | 'EXPENSE';
export type RecurringFrequency = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'BIANNUAL' | 'YEARLY'; export type RecurringFrequency = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'BIANNUAL' | 'YEARLY';
export interface RecurringItem { export interface RecurringItem {
@@ -233,7 +233,6 @@ export interface MonthlyProjection {
year: number; year: number;
projected_income: number; projected_income: number;
projected_fixed_expenses: number; projected_fixed_expenses: number;
projected_savings: number;
actual_credit_card: number; actual_credit_card: number;
actual_cash: number; actual_cash: number;
actual_transfers: number; actual_transfers: number;
@@ -250,7 +249,6 @@ export interface YearlyProjection {
months: MonthlyProjection[]; months: MonthlyProjection[];
annual_income: number; annual_income: number;
annual_expenses: number; annual_expenses: number;
annual_savings: number;
annual_net: number; annual_net: number;
} }
@@ -259,17 +257,52 @@ export interface MonthlyDetail {
month: number; month: number;
income_items: RecurringItemDetail[]; income_items: RecurringItemDetail[];
expense_items: RecurringItemDetail[]; expense_items: RecurringItemDetail[];
savings_items: RecurringItemDetail[];
actuals_by_source: ActualsBySource[]; actuals_by_source: ActualsBySource[];
total_projected_income: number; total_projected_income: number;
total_projected_expenses: number; total_projected_expenses: number;
total_projected_savings: number;
uncovered_actual: number; uncovered_actual: number;
gran_total_egresos: number; gran_total_egresos: number;
net_balance: number; net_balance: number;
cc_by_category: { category_name: string; amount: 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<SavingsAccrual[]>('/savings-accrual/');
export const createSavingsAccrual = (data: SavingsAccrualCreate) =>
api.post<SavingsAccrual>('/savings-accrual/', data);
export const updateSavingsAccrual = (id: number, data: SavingsAccrualUpdate) =>
api.patch<SavingsAccrual>(`/savings-accrual/${id}`, data);
export const deleteSavingsAccrual = (id: number) =>
api.delete(`/savings-accrual/${id}`);
// Budget API functions // Budget API functions
export const getRecurringItems = (params?: { item_type?: string; is_active?: boolean }) => export const getRecurringItems = (params?: { item_type?: string; is_active?: boolean }) =>
api.get<RecurringItem[]>('/budget/recurring', { params }); api.get<RecurringItem[]>('/budget/recurring', { params });

View File

@@ -16,7 +16,6 @@ import {
import { import {
TrendingUp, TrendingUp,
TrendingDown, TrendingDown,
PiggyBank,
CreditCard, CreditCard,
Banknote, Banknote,
ArrowLeftRight, ArrowLeftRight,
@@ -346,8 +345,8 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
</Card> </Card>
)} )}
{/* Actuals + Savings + Summary */} {/* Actuals + Summary */}
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2">
{/* Cash & Transfer Actuals Card */} {/* Cash & Transfer Actuals Card */}
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
@@ -401,33 +400,6 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
</CardContent> </CardContent>
</Card> </Card>
{/* Savings */}
{detail.savings_items.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<PiggyBank className="w-4 h-4" />
Ahorro
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{detail.savings_items.map((item) => (
<div key={item.id} className="flex items-center justify-between text-sm">
<span>{item.name}</span>
<span data-sensitive className="font-mono">{formatAmount(item.amount, 'CRC')}</span>
</div>
))}
<Separator />
<div className="flex items-center justify-between font-semibold text-sm">
<span>Total Ahorro</span>
<span data-sensitive className="font-mono">
{formatAmount(detail.total_projected_savings, 'CRC')}
</span>
</div>
</CardContent>
</Card>
)}
{/* Summary */} {/* Summary */}
<Card className={cn( <Card className={cn(
'border-2', 'border-2',
@@ -446,12 +418,6 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
-{formatAmount(detail.gran_total_egresos, 'CRC')} -{formatAmount(detail.gran_total_egresos, 'CRC')}
</span> </span>
</div> </div>
<div className="flex items-center justify-between text-sm">
<span>Ahorro</span>
<span data-sensitive className="font-mono font-medium">
-{formatAmount(detail.total_projected_savings, 'CRC')}
</span>
</div>
<Separator /> <Separator />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-semibold">Balance Neto</span> <span className="font-semibold">Balance Neto</span>

View File

@@ -29,7 +29,6 @@ import { Plus, Trash2 } from 'lucide-react';
const TYPE_OPTIONS: { value: RecurringItemType; label: string }[] = [ const TYPE_OPTIONS: { value: RecurringItemType; label: string }[] = [
{ value: 'INCOME', label: 'Ingreso' }, { value: 'INCOME', label: 'Ingreso' },
{ value: 'EXPENSE', label: 'Egreso' }, { value: 'EXPENSE', label: 'Egreso' },
{ value: 'SAVINGS', label: 'Ahorro' },
]; ];
const FREQ_OPTIONS: { value: RecurringFrequency; label: string }[] = [ const FREQ_OPTIONS: { value: RecurringFrequency; label: string }[] = [

View File

@@ -17,7 +17,6 @@ import ConfirmDialog from '@/components/ConfirmDialog';
const TYPE_LABELS: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' }> = { const TYPE_LABELS: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' }> = {
INCOME: { label: 'Ingreso', variant: 'default' }, INCOME: { label: 'Ingreso', variant: 'default' },
EXPENSE: { label: 'Egreso', variant: 'secondary' }, EXPENSE: { label: 'Egreso', variant: 'secondary' },
SAVINGS: { label: 'Ahorro', variant: 'outline' },
}; };
const FREQ_LABELS: Record<string, string> = { const FREQ_LABELS: Record<string, string> = {

View File

@@ -89,7 +89,6 @@ export default function YearlyOverview({
<TableHead className="text-right">Egresos Fijos</TableHead> <TableHead className="text-right">Egresos Fijos</TableHead>
<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">Acum. Anterior</TableHead> <TableHead className="text-right">Acum. Anterior</TableHead>
<TableHead className="text-right">Neto Mes</TableHead> <TableHead className="text-right">Neto Mes</TableHead>
<TableHead className="text-right">Balance Acum.</TableHead> <TableHead className="text-right">Balance Acum.</TableHead>
@@ -132,9 +131,6 @@ export default function YearlyOverview({
<TableCell data-sensitive className="text-right font-mono text-sm font-medium"> <TableCell data-sensitive className="text-right font-mono text-sm font-medium">
{formatAmount(m.gran_total_egresos, 'CRC')} {formatAmount(m.gran_total_egresos, 'CRC')}
</TableCell> </TableCell>
<TableCell data-sensitive className="text-right font-mono text-sm text-muted-foreground">
{formatAmount(m.projected_savings, 'CRC')}
</TableCell>
<TableCell <TableCell
className={cn( className={cn(
'text-right font-mono text-sm', 'text-right font-mono text-sm',

View File

@@ -46,7 +46,7 @@ export default function Proyecciones() {
{/* Annual summary cards */} {/* Annual summary cards */}
{projection && ( {projection && (
<div className="grid gap-3 grid-cols-2 md:grid-cols-4"> <div className="grid gap-3 grid-cols-1 md:grid-cols-3">
<Card> <Card>
<CardContent className="pt-4 pb-3 px-4"> <CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Ingresos Anuales</p> <p className="text-xs text-muted-foreground">Ingresos Anuales</p>
@@ -63,14 +63,6 @@ export default function Proyecciones() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Ahorro Anual</p>
<p data-sensitive className="text-lg font-bold font-mono">
{formatAmount(projection.annual_savings, 'CRC')}
</p>
</CardContent>
</Card>
<Card> <Card>
<CardContent className="pt-4 pb-3 px-4"> <CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Balance Neto Anual</p> <p className="text-xs text-muted-foreground">Balance Neto Anual</p>