mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 10:28:48 +02:00
Add budget module and push notifications for transactions
All checks were successful
Deploy to VPS / deploy (push) Successful in 34s
All checks were successful
Deploy to VPS / deploy (push) Successful in 34s
Budget: recurring items CRUD, yearly/monthly projections with no-double-count logic, and full UI (overview, monthly detail, recurring items manager). Push notifications: Web Push via VAPID keys, triggered on transaction creation from n8n. Includes service worker handlers, frontend subscription flow, and a test button on the Dashboard (temporary). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
254
backend/app/services/budget_projection.py
Normal file
254
backend/app/services/budget_projection.py
Normal file
@@ -0,0 +1,254 @@
|
||||
import calendar
|
||||
from datetime import datetime
|
||||
|
||||
from sqlmodel import Session, func, select
|
||||
|
||||
from app.models.models import (
|
||||
RecurringFrequency,
|
||||
RecurringItem,
|
||||
RecurringItemType,
|
||||
Transaction,
|
||||
TransactionSource,
|
||||
TransactionType,
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
freq = item.frequency
|
||||
|
||||
if freq == RecurringFrequency.MONTHLY:
|
||||
if item.override_amounts and str(month) in item.override_amounts:
|
||||
return float(item.override_amounts[str(month)])
|
||||
return item.amount
|
||||
|
||||
if freq == RecurringFrequency.WEEKLY:
|
||||
# Count occurrences of the weekday in this month
|
||||
# day_of_month stores day-of-week: 0=Monday
|
||||
weekday = item.day_of_month if item.day_of_month is not None else 0
|
||||
cal = calendar.monthcalendar(year, month)
|
||||
count = sum(1 for week in cal if week[weekday] != 0)
|
||||
return item.amount * count
|
||||
|
||||
if freq == RecurringFrequency.QUARTERLY:
|
||||
# Active in months 3, 6, 9, 12 by default
|
||||
if month % 3 == 0:
|
||||
if item.override_amounts and str(month) in item.override_amounts:
|
||||
return float(item.override_amounts[str(month)])
|
||||
return item.amount
|
||||
return None
|
||||
|
||||
if freq == RecurringFrequency.BIANNUAL:
|
||||
# Active in month_of_year and 6 months later
|
||||
base = item.month_of_year or 1
|
||||
second = base + 6 if base <= 6 else base - 6
|
||||
if month in (base, second):
|
||||
return item.amount
|
||||
return None
|
||||
|
||||
if freq == RecurringFrequency.YEARLY:
|
||||
if month == (item.month_of_year or 12):
|
||||
return item.amount
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_month_range(year: int, month: int) -> tuple[datetime, datetime]:
|
||||
"""Return (start, end) for a calendar month."""
|
||||
start = datetime(year, month, 1)
|
||||
if month == 12:
|
||||
end = datetime(year + 1, 1, 1)
|
||||
else:
|
||||
end = datetime(year, month + 1, 1)
|
||||
return start, end
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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,
|
||||
)
|
||||
).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)
|
||||
|
||||
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]
|
||||
)
|
||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||
).all()
|
||||
|
||||
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
|
||||
return totals
|
||||
|
||||
|
||||
def compute_monthly_projection(
|
||||
session: Session, year: int, month: int
|
||||
) -> dict:
|
||||
"""Compute full monthly projection with no-double-count logic."""
|
||||
items = session.exec(
|
||||
select(RecurringItem).where(RecurringItem.is_active == True) # noqa: E712
|
||||
).all()
|
||||
|
||||
actuals_by_source = compute_actuals_by_source(session, year, month)
|
||||
actuals_by_category = compute_actuals_by_category(session, year, month)
|
||||
|
||||
income_items = []
|
||||
expense_items = []
|
||||
savings_items = []
|
||||
|
||||
total_income = 0.0
|
||||
total_fixed_expenses = 0.0
|
||||
total_savings = 0.0
|
||||
|
||||
for item in items:
|
||||
effective = get_effective_amount(item, month, year)
|
||||
if effective is None:
|
||||
continue
|
||||
|
||||
detail = {
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"amount": effective,
|
||||
"item_type": item.item_type.value,
|
||||
"frequency": item.frequency.value,
|
||||
"category_name": item.category.name if item.category else None,
|
||||
"category_id": item.category_id,
|
||||
"used_actual": False,
|
||||
}
|
||||
|
||||
if item.item_type == RecurringItemType.INCOME:
|
||||
income_items.append(detail)
|
||||
total_income += effective
|
||||
|
||||
elif item.item_type == RecurringItemType.EXPENSE:
|
||||
# No-double-count: if category has actuals, use actual instead
|
||||
if item.category_id and item.category_id in actuals_by_category:
|
||||
actual_amount = actuals_by_category[item.category_id]
|
||||
detail["amount"] = actual_amount
|
||||
detail["projected_amount"] = effective
|
||||
detail["used_actual"] = True
|
||||
total_fixed_expenses += actual_amount
|
||||
else:
|
||||
total_fixed_expenses += effective
|
||||
expense_items.append(detail)
|
||||
|
||||
elif item.item_type == RecurringItemType.SAVINGS:
|
||||
savings_items.append(detail)
|
||||
total_savings += effective
|
||||
|
||||
# Sum actuals from sources for categories NOT covered by recurring items
|
||||
covered_category_ids = {
|
||||
item.category_id
|
||||
for item in items
|
||||
if item.item_type == RecurringItemType.EXPENSE
|
||||
and item.category_id is not None
|
||||
and get_effective_amount(item, month, year) is not None
|
||||
}
|
||||
|
||||
uncovered_actual = 0.0
|
||||
for cat_id, amount in actuals_by_category.items():
|
||||
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]
|
||||
)
|
||||
.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
|
||||
|
||||
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)
|
||||
|
||||
gran_total = total_fixed_expenses + uncovered_actual
|
||||
# Savings are NOT deducted — they are already deducted from gross salary
|
||||
# (the income amounts are net, post-savings)
|
||||
net_balance = total_income - gran_total
|
||||
|
||||
return {
|
||||
"year": year,
|
||||
"month": month,
|
||||
"projected_income": total_income,
|
||||
"projected_fixed_expenses": total_fixed_expenses,
|
||||
"projected_savings": total_savings,
|
||||
"actual_credit_card": actual_credit_card,
|
||||
"actual_cash": actual_cash,
|
||||
"actual_transfers": actual_transfers,
|
||||
"uncovered_actual": uncovered_actual,
|
||||
"gran_total_egresos": gran_total,
|
||||
"net_balance": net_balance,
|
||||
"income_items": income_items,
|
||||
"expense_items": expense_items,
|
||||
"savings_items": savings_items,
|
||||
"actuals_by_source": list(actuals_by_source.values()),
|
||||
}
|
||||
Reference in New Issue
Block a user