Tasa Cero: exclude anchors from budget/analytics aggregates

The anchor stays visible in listings but only its cuotas count toward
actuals — future cuotas intentionally show up in future months as
committed spend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-07-03 16:13:00 -06:00
parent 088062f757
commit 8b4961d257
2 changed files with 24 additions and 4 deletions

View File

@@ -9,7 +9,7 @@ from sqlmodel import Session, func, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import Category, Transaction, TransactionType
from app.services.budget_projection import get_cycle_range
from app.services.budget_projection import NOT_INSTALLMENT_ANCHOR, get_cycle_range
from app.services.exchange_rate import get_converted_amount_expr
router = APIRouter(prefix="/analytics", tags=["analytics"])
@@ -53,7 +53,10 @@ def spending_by_category(
func.sum(amount_crc).label("total"),
func.count().label("count"),
)
.where(Transaction.transaction_type == TransactionType.COMPRA)
.where(
Transaction.transaction_type == TransactionType.COMPRA,
NOT_INSTALLMENT_ANCHOR,
)
.group_by(Transaction.category_id)
)
@@ -126,6 +129,7 @@ def monthly_trend(
Transaction.transaction_type == TransactionType.COMPRA,
Transaction.date >= start,
Transaction.date < end,
NOT_INSTALLMENT_ANCHOR,
)
).first()
@@ -171,7 +175,10 @@ def daily_spending(
func.sum(amount_crc).label("total"),
func.count().label("count"),
)
.where(Transaction.transaction_type == TransactionType.COMPRA)
.where(
Transaction.transaction_type == TransactionType.COMPRA,
NOT_INSTALLMENT_ANCHOR,
)
.group_by(func.date(Transaction.date))
.order_by(func.date(Transaction.date))
)

View File

@@ -23,6 +23,11 @@ FRESH_START_MONTH = 3
# Income-like transaction types that should never be counted as expenses
INCOME_TYPES = (TransactionType.DEPOSITO, TransactionType.SALARY)
# Tasa Cero: the original purchase ("anchor") is excluded from all budget
# aggregates — its generated cuota transactions are counted instead.
# See app/services/installments.py.
NOT_INSTALLMENT_ANCHOR = Transaction.is_installment_anchor == False # noqa: E712
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."""
@@ -133,7 +138,7 @@ def compute_actuals_by_source(
func.coalesce(func.sum(amount_crc), 0),
func.count(),
)
.where(*where)
.where(NOT_INSTALLMENT_ANCHOR, *where)
.group_by(Transaction.source, Transaction.transaction_type)
).all()
return {(s, t): (float(total), cnt) for s, t, total, cnt in rows}
@@ -225,6 +230,7 @@ def compute_actuals_by_category(
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
@@ -245,6 +251,7 @@ def compute_actuals_by_category(
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
@@ -265,6 +272,7 @@ def compute_actuals_by_category(
Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
@@ -306,6 +314,7 @@ def compute_cc_by_category(
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
@@ -324,6 +333,7 @@ def compute_cc_by_category(
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
@@ -440,6 +450,7 @@ def compute_monthly_projection(
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.transaction_type)
@@ -455,6 +466,7 @@ def compute_monthly_projection(
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.transaction_type)
@@ -470,6 +482,7 @@ def compute_monthly_projection(
Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
)
.group_by(Transaction.transaction_type)
).all()