Add deferred transactions, revamp budget projections and UI

Adds deferred_to_next_cycle flag to transactions for billing cycle
bleed-over handling. Overhauls budget projection engine and refreshes
Budget page with improved monthly detail and transaction columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-04-03 20:10:23 -06:00
parent 37e04273b9
commit 0fdb5447b7
11 changed files with 845 additions and 276 deletions

View File

@@ -71,81 +71,308 @@ def get_month_range(year: int, month: int) -> tuple[datetime, datetime]:
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 for a calendar month, grouped by source."""
start, end = get_month_range(year, month)
"""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)
results = {}
for source in TransactionSource:
compra = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
Transaction.date >= start,
Transaction.date < end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.COMPRA,
)
).one()
devolucion = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
Transaction.date >= start,
Transaction.date < end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.DEVOLUCION,
)
).one()
count = session.exec(
select(func.count()).where(
Transaction.date >= start,
Transaction.date < end,
Transaction.source == source,
Transaction.transaction_type != TransactionType.DEPOSITO,
)
).one()
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(Transaction.amount), 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(Transaction.amount), 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)
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,
}
dev_normal = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 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(Transaction.amount), 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,
Transaction.transaction_type != TransactionType.DEPOSITO,
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,
Transaction.transaction_type != TransactionType.DEPOSITO,
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(Transaction.amount), 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(Transaction.amount), 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,
Transaction.transaction_type != TransactionType.DEPOSITO,
)
).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 in a calendar month."""
start, end = get_month_range(year, month)
"""Return {category_id: net_amount} for actual transactions.
rows = session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(Transaction.amount),
)
.where(
Transaction.date >= start,
Transaction.date < end,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO,
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
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)
totals: dict[int, float] = {}
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
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(Transaction.amount),
)
.where(
Transaction.date >= cc_start,
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO,
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(Transaction.amount),
)
.where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO,
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(Transaction.amount),
)
.where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO,
)
.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)
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(Transaction.amount),
)
.where(
Transaction.date >= cc_start,
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.transaction_type != TransactionType.DEPOSITO,
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(Transaction.amount),
)
.where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.transaction_type != TransactionType.DEPOSITO,
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:
@@ -215,30 +442,71 @@ def compute_monthly_projection(
if cat_id not in covered_category_ids:
uncovered_actual += amount
# Also add transactions with no category
start, end = get_month_range(year, month)
uncategorized = session.exec(
select(
Transaction.transaction_type,
func.sum(Transaction.amount),
)
.where(
Transaction.date >= start,
Transaction.date < end,
Transaction.category_id.is_(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO,
)
.group_by(Transaction.transaction_type)
).all()
for tx_type, amount in uncategorized:
val = float(amount)
if tx_type == TransactionType.DEVOLUCION:
val = -val
uncovered_actual += val
# 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)
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(Transaction.amount))
.where(
Transaction.date >= cc_start,
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO,
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(Transaction.amount))
.where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO,
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(Transaction.amount))
.where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO,
)
.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
# Savings are NOT deducted — they are already deducted from gross salary
@@ -261,6 +529,7 @@ def compute_monthly_projection(
"expense_items": expense_items,
"savings_items": savings_items,
"actuals_by_source": list(actuals_by_source.values()),
"cc_by_category": cc_by_category,
}