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

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