Files
WealthySmart/backend/app/services/budget_projection.py
Carlos Escalante d929ed6573
All checks were successful
Deploy to VPS / deploy (push) Successful in 23s
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) <noreply@anthropic.com>
2026-04-15 19:13:29 -06:00

616 lines
22 KiB
Python

import calendar
from datetime import datetime
from sqlmodel import Session, col, func, select
from app.models.models import (
BalanceOverride,
RecurringFrequency,
RecurringItem,
RecurringItemType,
Transaction,
TransactionSource,
TransactionType,
)
from app.services.exchange_rate import get_converted_amount_expr
MIN_YEAR = 2026
MAX_YEAR = 2030
# Fresh start: months before this are zeroed out
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."""
freq = item.frequency
if freq == RecurringFrequency.MONTHLY:
if item.override_amounts and str(month) in item.override_amounts:
return float(item.override_amounts[str(month)])
return item.amount
if freq == RecurringFrequency.WEEKLY:
# Count occurrences of the weekday in this month
# day_of_month stores day-of-week: 0=Monday
weekday = item.day_of_month if item.day_of_month is not None else 0
cal = calendar.monthcalendar(year, month)
count = sum(1 for week in cal if week[weekday] != 0)
return item.amount * count
if freq == RecurringFrequency.QUARTERLY:
# Active in months 3, 6, 9, 12 by default
if month % 3 == 0:
if item.override_amounts and str(month) in item.override_amounts:
return float(item.override_amounts[str(month)])
return item.amount
return None
if freq == RecurringFrequency.BIANNUAL:
# Active in month_of_year and 6 months later
base = item.month_of_year or 1
second = base + 6 if base <= 6 else base - 6
if month in (base, second):
return item.amount
return None
if freq == RecurringFrequency.YEARLY:
if month == (item.month_of_year or 12):
return item.amount
return None
return None
def get_month_range(year: int, month: int) -> tuple[datetime, datetime]:
"""Return (start, end) for a calendar month."""
start = datetime(year, month, 1)
if month == 12:
end = datetime(year + 1, 1, 1)
else:
end = datetime(year, month + 1, 1)
return start, end
def get_cycle_range(year: int, month: int) -> tuple[datetime, datetime]:
"""Return (start, end) for billing cycle: month/18 to month+1/18."""
start = datetime(year, month, 18)
if month == 12:
end = datetime(year + 1, 1, 18)
else:
end = datetime(year, month + 1, 18)
return start, end
def get_previous_cycle(year: int, month: int) -> tuple[int, int]:
"""Return (year, month) for the billing cycle preceding the given one."""
if month == 1:
return year - 1, 12
return year, month - 1
def compute_actuals_by_source(
session: Session, year: int, month: int
) -> dict[str, dict]:
"""Query actual transaction totals grouped by source.
Credit card uses billing cycle (18th-18th) with deferred logic.
Cash/Transfer use calendar month (1st-1st).
"""
# CC billing cycle for budget month M is the cycle that *ends* around the 18th of M
# i.e. cycle (M-1): from (M-1)/18 to M/18, paid with month M salary
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month)
amount_crc = get_converted_amount_expr(session)
results = {}
for source in TransactionSource:
if source == TransactionSource.CREDIT_CARD:
start, end = cc_start, cc_end
# Normal transactions in this cycle (not deferred)
compra_normal = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start,
Transaction.date < end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.COMPRA,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
).one()
# Deferred from previous cycle
compra_deferred = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.COMPRA,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
).one()
compra = float(compra_normal) + float(compra_deferred)
dev_normal = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start,
Transaction.date < end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.DEVOLUCION,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
).one()
dev_deferred = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.DEVOLUCION,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
).one()
devolucion = float(dev_normal) + float(dev_deferred)
count_normal = session.exec(
select(func.count()).where(
Transaction.date >= start,
Transaction.date < end,
Transaction.source == source,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
).one()
count_deferred = session.exec(
select(func.count()).where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == source,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
).one()
count = count_normal + count_deferred
results[source.value] = {
"source": source.value,
"total_compra": compra,
"total_devolucion": devolucion,
"net": compra - devolucion,
"count": count,
}
else:
# Cash / Transfer: calendar month, no deferred logic
compra = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.COMPRA,
)
).one()
devolucion = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.DEVOLUCION,
)
).one()
count = session.exec(
select(func.count()).where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source == source,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
)
).one()
compra_val = float(compra)
devolucion_val = float(devolucion)
results[source.value] = {
"source": source.value,
"total_compra": compra_val,
"total_devolucion": devolucion_val,
"net": compra_val - devolucion_val,
"count": count,
}
return results
def compute_actuals_by_category(
session: Session, year: int, month: int
) -> dict[int, float]:
"""Return {category_id: net_amount} for actual transactions.
Credit card uses billing cycle (18th-18th) with deferred logic.
Cash/Transfer use calendar month (1st-1st).
"""
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month)
amount_crc = get_converted_amount_expr(session)
totals: dict[int, float] = {}
def _merge_rows(rows: list) -> None:
for cat_id, tx_type, amount in rows:
val = float(amount)
if tx_type == TransactionType.DEVOLUCION:
val = -val
totals[cat_id] = totals.get(cat_id, 0) + val
# 1) CC normal in this cycle (not deferred)
_merge_rows(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(amount_crc),
)
.where(
Transaction.date >= cc_start,
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
# 2) CC deferred from previous cycle
_merge_rows(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(amount_crc),
)
.where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
# 3) Non-CC: calendar month
_merge_rows(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(amount_crc),
)
.where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
return totals
def compute_cc_by_category(
session: Session, year: int, month: int
) -> list[dict]:
"""Return credit card spending by category for the billing cycle."""
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
amount_crc = get_converted_amount_expr(session)
totals: dict[int | None, float] = {}
def _merge(rows: list) -> None:
for cat_id, tx_type, amount in rows:
val = float(amount)
if tx_type == TransactionType.DEVOLUCION:
val = -val
totals[cat_id] = totals.get(cat_id, 0) + val
# CC normal in this cycle
_merge(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(amount_crc),
)
.where(
Transaction.date >= cc_start,
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
# CC deferred from previous cycle
_merge(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(amount_crc),
)
.where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
# Resolve category names
from app.models.models import Category
result = []
for cat_id, amount in totals.items():
if amount <= 0:
continue
if cat_id is not None:
cat = session.get(Category, cat_id)
name = cat.name if cat else "Sin categoría"
else:
name = "Sin categoría"
result.append({"category_name": name, "amount": round(amount, 2)})
return sorted(result, key=lambda x: x["amount"], reverse=True)
def compute_monthly_projection(
session: Session, year: int, month: int
) -> dict:
"""Compute full monthly projection with no-double-count logic."""
items = session.exec(
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)
actuals_by_category = compute_actuals_by_category(session, year, month)
income_items = []
expense_items = []
total_income = 0.0
total_fixed_expenses = 0.0
for item in items:
effective = get_effective_amount(item, month, year)
if effective is None:
continue
detail = {
"id": item.id,
"name": item.name,
"amount": effective,
"item_type": item.item_type.value,
"frequency": item.frequency.value,
"category_name": item.category.name if item.category else None,
"category_id": item.category_id,
"used_actual": False,
}
if item.item_type == RecurringItemType.INCOME:
income_items.append(detail)
total_income += effective
elif item.item_type == RecurringItemType.EXPENSE:
# No-double-count: if category has actuals, use actual instead
if item.category_id and item.category_id in actuals_by_category:
actual_amount = actuals_by_category[item.category_id]
detail["amount"] = actual_amount
detail["projected_amount"] = effective
detail["used_actual"] = True
total_fixed_expenses += actual_amount
else:
total_fixed_expenses += effective
expense_items.append(detail)
# Sum actuals from sources for categories NOT covered by recurring items
covered_category_ids = {
item.category_id
for item in items
if item.item_type == RecurringItemType.EXPENSE
and item.category_id is not None
and get_effective_amount(item, month, year) is not None
}
uncovered_actual = 0.0
for cat_id, amount in actuals_by_category.items():
if cat_id not in covered_category_ids:
uncovered_actual += amount
# Also add transactions with no category (hybrid ranges + deferred)
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month)
amount_crc = get_converted_amount_expr(session)
def _sum_uncategorized(rows: list) -> float:
total = 0.0
for tx_type, amount in rows:
val = float(amount)
if tx_type == TransactionType.DEVOLUCION:
val = -val
total += val
return total
# CC uncategorized: this cycle (not deferred)
uncovered_actual += _sum_uncategorized(
session.exec(
select(Transaction.transaction_type, func.sum(amount_crc))
.where(
Transaction.date >= cc_start,
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.transaction_type)
).all()
)
# CC uncategorized: deferred from previous cycle
uncovered_actual += _sum_uncategorized(
session.exec(
select(Transaction.transaction_type, func.sum(amount_crc))
.where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.transaction_type)
).all()
)
# Non-CC uncategorized: calendar month
uncovered_actual += _sum_uncategorized(
session.exec(
select(Transaction.transaction_type, func.sum(amount_crc))
.where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
)
.group_by(Transaction.transaction_type)
).all()
)
actual_credit_card = actuals_by_source.get("CREDIT_CARD", {}).get("net", 0)
actual_cash = actuals_by_source.get("CASH", {}).get("net", 0)
actual_transfers = actuals_by_source.get("TRANSFER", {}).get("net", 0)
cc_by_category = compute_cc_by_category(session, year, month)
gran_total = total_fixed_expenses + uncovered_actual
net_balance = total_income - gran_total
return {
"year": year,
"month": month,
"projected_income": total_income,
"projected_fixed_expenses": total_fixed_expenses,
"actual_credit_card": actual_credit_card,
"actual_cash": actual_cash,
"actual_transfers": actual_transfers,
"uncovered_actual": uncovered_actual,
"gran_total_egresos": gran_total,
"net_balance": net_balance,
"income_items": income_items,
"expense_items": expense_items,
"actuals_by_source": list(actuals_by_source.values()),
"cc_by_category": cc_by_category,
}
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