Add budget module and push notifications for transactions
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:
Carlos Escalante
2026-03-26 22:28:14 -06:00
parent 2cd0d3b2e1
commit 8d76059ae8
25 changed files with 2225 additions and 13 deletions

View File

@@ -0,0 +1,210 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from pydantic import BaseModel
from sqlmodel import Session, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import (
RecurringItem,
RecurringItemCreate,
RecurringItemRead,
RecurringItemType,
RecurringItemUpdate,
)
from app.services.budget_projection import compute_monthly_projection
router = APIRouter(prefix="/budget", tags=["budget"])
# --- Recurring Item CRUD ---
@router.get("/recurring", response_model=list[RecurringItemRead])
def list_recurring_items(
item_type: Optional[RecurringItemType] = None,
is_active: Optional[bool] = None,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
query = select(RecurringItem)
if item_type:
query = query.where(RecurringItem.item_type == item_type)
if is_active is not None:
query = query.where(RecurringItem.is_active == is_active)
query = query.order_by(RecurringItem.item_type, RecurringItem.name)
return session.exec(query).all()
@router.post("/recurring", response_model=RecurringItemRead, status_code=201)
def create_recurring_item(
data: RecurringItemCreate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
item = RecurringItem.model_validate(data)
session.add(item)
session.commit()
session.refresh(item)
return item
@router.patch("/recurring/{item_id}", response_model=RecurringItemRead)
def update_recurring_item(
item_id: int,
data: RecurringItemUpdate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
item = session.get(RecurringItem, item_id)
if not item:
raise HTTPException(status_code=404, detail="Recurring item not found")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(item, key, value)
session.add(item)
session.commit()
session.refresh(item)
return item
@router.delete("/recurring/{item_id}", status_code=204)
def delete_recurring_item(
item_id: int,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
item = session.get(RecurringItem, item_id)
if not item:
raise HTTPException(status_code=404, detail="Recurring item not found")
session.delete(item)
session.commit()
# --- Projection Endpoints ---
class MonthlyProjectionResponse(BaseModel):
month: int
year: int
projected_income: float
projected_fixed_expenses: float
projected_savings: float
actual_credit_card: float
actual_cash: float
actual_transfers: float
uncovered_actual: float
gran_total_egresos: float
net_balance: float
class YearlyProjectionResponse(BaseModel):
year: int
months: list[MonthlyProjectionResponse]
annual_income: float
annual_expenses: float
annual_savings: float
annual_net: float
@router.get("/projection/{year}", response_model=YearlyProjectionResponse)
def get_yearly_projection(
year: int,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
months = []
annual_income = 0.0
annual_expenses = 0.0
annual_savings = 0.0
annual_net = 0.0
for m in range(1, 13):
data = compute_monthly_projection(session, year, m)
monthly = MonthlyProjectionResponse(
month=data["month"],
year=data["year"],
projected_income=data["projected_income"],
projected_fixed_expenses=data["projected_fixed_expenses"],
projected_savings=data["projected_savings"],
actual_credit_card=data["actual_credit_card"],
actual_cash=data["actual_cash"],
actual_transfers=data["actual_transfers"],
uncovered_actual=data["uncovered_actual"],
gran_total_egresos=data["gran_total_egresos"],
net_balance=data["net_balance"],
)
months.append(monthly)
annual_income += data["projected_income"]
annual_expenses += data["gran_total_egresos"]
annual_savings += data["projected_savings"]
annual_net += data["net_balance"]
return YearlyProjectionResponse(
year=year,
months=months,
annual_income=annual_income,
annual_expenses=annual_expenses,
annual_savings=annual_savings,
annual_net=annual_net,
)
class RecurringItemDetail(BaseModel):
id: int
name: str
amount: float
projected_amount: float | None = None
used_actual: bool = False
item_type: str
frequency: str
category_name: str | None = None
category_id: int | None = None
class ActualsBySource(BaseModel):
source: str
total_compra: float
total_devolucion: float
net: float
count: int
class MonthlyDetailResponse(BaseModel):
year: int
month: int
income_items: list[RecurringItemDetail]
expense_items: list[RecurringItemDetail]
savings_items: list[RecurringItemDetail]
actuals_by_source: list[ActualsBySource]
total_projected_income: float
total_projected_expenses: float
total_projected_savings: float
uncovered_actual: float
gran_total_egresos: float
net_balance: float
@router.get("/month/{year}/{month}", response_model=MonthlyDetailResponse)
def get_monthly_detail(
year: int,
month: int = Path(ge=1, le=12),
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
data = compute_monthly_projection(session, year, month)
return MonthlyDetailResponse(
year=data["year"],
month=data["month"],
income_items=[RecurringItemDetail(**i) for i in data["income_items"]],
expense_items=[RecurringItemDetail(**i) for i in data["expense_items"]],
savings_items=[RecurringItemDetail(**i) for i in data["savings_items"]],
actuals_by_source=[ActualsBySource(**a) for a in data["actuals_by_source"]],
total_projected_income=data["projected_income"],
total_projected_expenses=data["projected_fixed_expenses"],
total_projected_savings=data["projected_savings"],
uncovered_actual=data["uncovered_actual"],
gran_total_egresos=data["gran_total_egresos"],
net_balance=data["net_balance"],
)

View File

@@ -0,0 +1,93 @@
import json
import logging
from fastapi import APIRouter, Depends, HTTPException
from pywebpush import WebPushException, webpush
from sqlmodel import Session, select
from app.auth import get_current_user
from app.config import settings
from app.db import get_session
from app.models.models import PushSubscription, PushSubscriptionCreate
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/notifications", tags=["notifications"])
@router.get("/vapid-public-key")
def get_vapid_public_key(_user: str = Depends(get_current_user)):
if not settings.VAPID_PUBLIC_KEY:
raise HTTPException(status_code=503, detail="Push notifications not configured")
return {"publicKey": settings.VAPID_PUBLIC_KEY}
@router.post("/subscribe", status_code=201)
def subscribe(
data: PushSubscriptionCreate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
existing = session.exec(
select(PushSubscription).where(PushSubscription.endpoint == data.endpoint)
).first()
if existing:
existing.p256dh = data.keys["p256dh"]
existing.auth = data.keys["auth"]
session.add(existing)
session.commit()
return {"status": "updated"}
sub = PushSubscription(
endpoint=data.endpoint,
p256dh=data.keys["p256dh"],
auth=data.keys["auth"],
)
session.add(sub)
session.commit()
return {"status": "subscribed"}
@router.delete("/unsubscribe")
def unsubscribe(
data: PushSubscriptionCreate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
existing = session.exec(
select(PushSubscription).where(PushSubscription.endpoint == data.endpoint)
).first()
if existing:
session.delete(existing)
session.commit()
return {"status": "unsubscribed"}
def send_push_to_all(session: Session, title: str, body: str, url: str = "/"):
"""Send a push notification to all registered subscriptions."""
if not settings.VAPID_PRIVATE_KEY or not settings.VAPID_PUBLIC_KEY:
logger.debug("VAPID keys not configured, skipping push notification")
return
subscriptions = session.exec(select(PushSubscription)).all()
payload = json.dumps({"title": title, "body": body, "url": url})
for sub in subscriptions:
subscription_info = {
"endpoint": sub.endpoint,
"keys": {"p256dh": sub.p256dh, "auth": sub.auth},
}
try:
webpush(
subscription_info=subscription_info,
data=payload,
vapid_private_key=settings.VAPID_PRIVATE_KEY,
vapid_claims={"sub": settings.VAPID_CLAIM_EMAIL},
)
except WebPushException as e:
logger.warning("Push failed for %s: %s", sub.endpoint[:50], e)
if e.response and e.response.status_code in (404, 410):
session.delete(sub)
session.commit()
except Exception:
logger.exception("Unexpected push error for %s", sub.endpoint[:50])

View File

@@ -7,8 +7,10 @@ from sqlmodel import Session, col, func, select
from app.auth import get_current_user
from app.db import get_session
from app.api.v1.endpoints.notifications import send_push_to_all
from app.models.models import (
Category,
Currency,
Transaction,
TransactionCreate,
TransactionRead,
@@ -55,6 +57,8 @@ def list_transactions(
category_id: Optional[int] = None,
cycle_year: Optional[int] = None,
cycle_month: Optional[int] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: int = Query(default=50, le=500),
offset: int = 0,
session: Session = Depends(get_session),
@@ -70,6 +74,11 @@ def list_transactions(
if cycle_year and cycle_month:
start, end = get_cycle_range(cycle_year, cycle_month)
query = query.where(Transaction.date >= start, Transaction.date < end)
elif start_date and end_date:
query = query.where(
Transaction.date >= datetime.fromisoformat(start_date),
Transaction.date < datetime.fromisoformat(end_date),
)
query = query.order_by(col(Transaction.date).desc()).offset(offset).limit(limit)
return session.exec(query).all()
@@ -170,6 +179,17 @@ def create_transaction(
session.add(tx)
session.commit()
session.refresh(tx)
# Send push notification
symbol = "" if tx.currency == Currency.CRC else tx.currency.value
amount_str = f"{symbol}{tx.amount:,.0f}" if tx.currency == Currency.CRC else f"{symbol}{tx.amount:,.2f}"
send_push_to_all(
session,
title=f"💳 {tx.merchant}",
body=f"{amount_str}{tx.bank.value} {tx.transaction_type.value.lower()}",
url=f"/budget",
)
return tx

View File

@@ -4,9 +4,11 @@ from app.api.v1.endpoints import (
accounts,
analytics,
auth,
budget,
categories,
exchange_rate,
import_transactions,
notifications,
settings,
tokens,
transactions,
@@ -22,3 +24,5 @@ api_router.include_router(exchange_rate.router)
api_router.include_router(tokens.router)
api_router.include_router(analytics.router)
api_router.include_router(settings.router)
api_router.include_router(budget.router)
api_router.include_router(notifications.router)

View File

@@ -9,6 +9,9 @@ class Settings(BaseSettings):
ADMIN_PASSWORD: str = "admin"
BCCR_API_EMAIL: str = ""
BCCR_API_TOKEN: str = ""
VAPID_PRIVATE_KEY: str = ""
VAPID_PUBLIC_KEY: str = ""
VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev"
class Config:
env_file = ".env"

View File

@@ -2,11 +2,24 @@ import enum
from datetime import datetime
from typing import Optional
from sqlalchemy import Column
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import JSON, Column
from sqlmodel import Field, Relationship, SQLModel
class RecurringItemType(str, enum.Enum):
INCOME = "INCOME"
EXPENSE = "EXPENSE"
SAVINGS = "SAVINGS"
class RecurringFrequency(str, enum.Enum):
WEEKLY = "WEEKLY"
MONTHLY = "MONTHLY"
QUARTERLY = "QUARTERLY"
BIANNUAL = "BIANNUAL"
YEARLY = "YEARLY"
class TransactionType(str, enum.Enum):
COMPRA = "COMPRA"
DEVOLUCION = "DEVOLUCION"
@@ -207,7 +220,7 @@ class UserSettings(SQLModel, table=True):
key: str = Field(index=True, unique=True, default="default")
data: dict = Field(
default_factory=dict,
sa_column=Column(JSONB, nullable=False, server_default="{}"),
sa_column=Column(JSON, nullable=False, server_default="{}"),
)
updated_at: datetime = Field(default_factory=datetime.utcnow)
@@ -220,3 +233,69 @@ class UserSettingsRead(SQLModel):
class UserSettingsUpdate(SQLModel):
data: dict
# --- Recurring Item ---
class RecurringItemBase(SQLModel):
name: str
amount: float
currency: Currency = Currency.CRC
item_type: RecurringItemType
frequency: RecurringFrequency = RecurringFrequency.MONTHLY
day_of_month: Optional[int] = None
month_of_year: Optional[int] = None
override_amounts: Optional[dict] = Field(
default=None,
sa_column=Column(JSON, nullable=True),
)
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
is_active: bool = True
notes: Optional[str] = None
class RecurringItem(RecurringItemBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
category: Optional[Category] = Relationship()
class RecurringItemCreate(RecurringItemBase):
pass
class RecurringItemRead(RecurringItemBase):
id: int
created_at: datetime
category: Optional[CategoryRead] = None
class RecurringItemUpdate(SQLModel):
name: Optional[str] = None
amount: Optional[float] = None
currency: Optional[Currency] = None
item_type: Optional[RecurringItemType] = None
frequency: Optional[RecurringFrequency] = None
day_of_month: Optional[int] = None
month_of_year: Optional[int] = None
override_amounts: Optional[dict] = None
category_id: Optional[int] = None
is_active: Optional[bool] = None
notes: Optional[str] = None
# --- Push Subscription ---
class PushSubscription(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
endpoint: str = Field(unique=True)
p256dh: str
auth: str
created_at: datetime = Field(default_factory=datetime.utcnow)
class PushSubscriptionCreate(SQLModel):
endpoint: str
keys: dict # {"p256dh": "...", "auth": "..."}

View File

@@ -1,7 +1,16 @@
from sqlmodel import Session, select
from app.db import engine
from app.models.models import Account, AccountType, Bank, Category, Currency
from app.models.models import (
Account,
AccountType,
Bank,
Category,
Currency,
RecurringFrequency,
RecurringItem,
RecurringItemType,
)
DEFAULT_CATEGORIES = [
("Groceries", "shopping-cart", "automercado,auto mercado,fresh market,macrobiotica,pricesmart,price smart,grassfedcr,pequeno mundo"),
@@ -45,6 +54,143 @@ DEFAULT_ACCOUNTS = [
]
DEFAULT_RECURRING_ITEMS = [
# Incomes
{
"name": "Alquiler Apt 1",
"amount": 320000,
"item_type": RecurringItemType.INCOME,
"frequency": RecurringFrequency.MONTHLY,
"day_of_month": 1,
"notes": "Tenant rent - start of month",
},
{
"name": "Alquiler Apt 2",
"amount": 360000,
"item_type": RecurringItemType.INCOME,
"frequency": RecurringFrequency.MONTHLY,
"day_of_month": 15,
"notes": "Tenant rent - mid month",
},
{
"name": "Salario Quincenal 1",
"amount": 1400000,
"item_type": RecurringItemType.INCOME,
"frequency": RecurringFrequency.MONTHLY,
"day_of_month": 15,
"notes": "Net salary - mid month",
},
{
"name": "Salario Quincenal 2",
"amount": 1400000,
"item_type": RecurringItemType.INCOME,
"frequency": RecurringFrequency.MONTHLY,
"day_of_month": 30,
"notes": "Net salary - end of month",
},
{
"name": "Aguinaldo",
"amount": 3000000,
"item_type": RecurringItemType.INCOME,
"frequency": RecurringFrequency.YEARLY,
"month_of_year": 12,
"notes": "Yearly bonus",
},
# Fixed expenses
{
"name": "Hipoteca",
"amount": 450000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.MONTHLY,
"notes": "Mortgage payment estimate",
},
{
"name": "Comida y Gasolina",
"amount": 300000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.MONTHLY,
"notes": "Food & Gas estimate",
},
{
"name": "CNFL",
"amount": 50000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.MONTHLY,
"notes": "Electricity",
},
{
"name": "Internet",
"amount": 50000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.MONTHLY,
"notes": "Internet service",
},
{
"name": "Municipalidad",
"amount": 30000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.MONTHLY,
"override_amounts": {"3": 150000, "6": 150000, "9": 150000, "12": 150000},
"notes": "Local gov fees; 150k in property tax quarters",
},
{
"name": "Tennis y Limpieza",
"amount": 150000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.MONTHLY,
"notes": "Tennis lessons + house cleaning",
},
# Cash transfers
{
"name": "Empleada Doméstica",
"amount": 20000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.WEEKLY,
"day_of_month": 0,
"notes": "Weekly maid payment (~80k/month)",
},
{
"name": "Clases de Tennis",
"amount": 50000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.MONTHLY,
"notes": "Monthly tennis lessons cash transfer",
},
# Sporadic
{
"name": "CCE (Country Club)",
"amount": 720000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.YEARLY,
"month_of_year": 2,
"notes": "Yearly country club fee",
},
{
"name": "Seguro Vehicular",
"amount": 150000,
"item_type": RecurringItemType.EXPENSE,
"frequency": RecurringFrequency.BIANNUAL,
"month_of_year": 1,
"notes": "Car insurance every 6 months (Jan, Jul)",
},
# Savings
{
"name": "Ahorro MEMP",
"amount": 200000,
"item_type": RecurringItemType.SAVINGS,
"frequency": RecurringFrequency.MONTHLY,
"notes": "Monthly savings to MEMP account",
},
{
"name": "Ahorro MPAT",
"amount": 200000,
"item_type": RecurringItemType.SAVINGS,
"frequency": RecurringFrequency.MONTHLY,
"notes": "Monthly savings to MPAT account",
},
]
def seed_db():
with Session(engine) as session:
existing = session.exec(select(Category)).first()
@@ -58,3 +204,9 @@ def seed_db():
for bank, currency, label, account_type in DEFAULT_ACCOUNTS:
session.add(Account(bank=bank, currency=currency, label=label, account_type=account_type))
session.commit()
existing_recurring = session.exec(select(RecurringItem)).first()
if not existing_recurring:
for item_data in DEFAULT_RECURRING_ITEMS:
session.add(RecurringItem(**item_data))
session.commit()

View 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()),
}