From 8b4961d257121c1b25911d2e8277e99508d67c26 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Fri, 3 Jul 2026 16:13:00 -0600 Subject: [PATCH] Tasa Cero: exclude anchors from budget/analytics aggregates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/app/api/v1/endpoints/analytics.py | 13 ++++++++++--- backend/app/services/budget_projection.py | 15 ++++++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/backend/app/api/v1/endpoints/analytics.py b/backend/app/api/v1/endpoints/analytics.py index 14f3dec..996017b 100644 --- a/backend/app/api/v1/endpoints/analytics.py +++ b/backend/app/api/v1/endpoints/analytics.py @@ -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)) ) diff --git a/backend/app/services/budget_projection.py b/backend/app/services/budget_projection.py index 4685c83..760edd3 100644 --- a/backend/app/services/budget_projection.py +++ b/backend/app/services/budget_projection.py @@ -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()