mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 11:28:47 +02:00
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:
@@ -9,7 +9,7 @@ from sqlmodel import Session, func, select
|
|||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models.models import Category, Transaction, TransactionType
|
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
|
from app.services.exchange_rate import get_converted_amount_expr
|
||||||
|
|
||||||
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||||
@@ -53,7 +53,10 @@ def spending_by_category(
|
|||||||
func.sum(amount_crc).label("total"),
|
func.sum(amount_crc).label("total"),
|
||||||
func.count().label("count"),
|
func.count().label("count"),
|
||||||
)
|
)
|
||||||
.where(Transaction.transaction_type == TransactionType.COMPRA)
|
.where(
|
||||||
|
Transaction.transaction_type == TransactionType.COMPRA,
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
|
)
|
||||||
.group_by(Transaction.category_id)
|
.group_by(Transaction.category_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -126,6 +129,7 @@ def monthly_trend(
|
|||||||
Transaction.transaction_type == TransactionType.COMPRA,
|
Transaction.transaction_type == TransactionType.COMPRA,
|
||||||
Transaction.date >= start,
|
Transaction.date >= start,
|
||||||
Transaction.date < end,
|
Transaction.date < end,
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -171,7 +175,10 @@ def daily_spending(
|
|||||||
func.sum(amount_crc).label("total"),
|
func.sum(amount_crc).label("total"),
|
||||||
func.count().label("count"),
|
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))
|
.group_by(func.date(Transaction.date))
|
||||||
.order_by(func.date(Transaction.date))
|
.order_by(func.date(Transaction.date))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ FRESH_START_MONTH = 3
|
|||||||
# Income-like transaction types that should never be counted as expenses
|
# Income-like transaction types that should never be counted as expenses
|
||||||
INCOME_TYPES = (TransactionType.DEPOSITO, TransactionType.SALARY)
|
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:
|
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."""
|
"""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.coalesce(func.sum(amount_crc), 0),
|
||||||
func.count(),
|
func.count(),
|
||||||
)
|
)
|
||||||
.where(*where)
|
.where(NOT_INSTALLMENT_ANCHOR, *where)
|
||||||
.group_by(Transaction.source, Transaction.transaction_type)
|
.group_by(Transaction.source, Transaction.transaction_type)
|
||||||
).all()
|
).all()
|
||||||
return {(s, t): (float(total), cnt) for s, t, total, cnt in rows}
|
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.source == TransactionSource.CREDIT_CARD,
|
||||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||||
@@ -245,6 +251,7 @@ def compute_actuals_by_category(
|
|||||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||||
@@ -265,6 +272,7 @@ def compute_actuals_by_category(
|
|||||||
Transaction.source != TransactionSource.CREDIT_CARD,
|
Transaction.source != TransactionSource.CREDIT_CARD,
|
||||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
)
|
)
|
||||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||||
).all()
|
).all()
|
||||||
@@ -306,6 +314,7 @@ def compute_cc_by_category(
|
|||||||
Transaction.date < cc_end,
|
Transaction.date < cc_end,
|
||||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||||
@@ -324,6 +333,7 @@ def compute_cc_by_category(
|
|||||||
Transaction.date < prev_end,
|
Transaction.date < prev_end,
|
||||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||||
@@ -440,6 +450,7 @@ def compute_monthly_projection(
|
|||||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(Transaction.transaction_type)
|
.group_by(Transaction.transaction_type)
|
||||||
@@ -455,6 +466,7 @@ def compute_monthly_projection(
|
|||||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||||
)
|
)
|
||||||
.group_by(Transaction.transaction_type)
|
.group_by(Transaction.transaction_type)
|
||||||
@@ -470,6 +482,7 @@ def compute_monthly_projection(
|
|||||||
Transaction.source != TransactionSource.CREDIT_CARD,
|
Transaction.source != TransactionSource.CREDIT_CARD,
|
||||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||||
|
NOT_INSTALLMENT_ANCHOR,
|
||||||
)
|
)
|
||||||
.group_by(Transaction.transaction_type)
|
.group_by(Transaction.transaction_type)
|
||||||
).all()
|
).all()
|
||||||
|
|||||||
Reference in New Issue
Block a user