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

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