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