mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:48: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:
210
backend/app/api/v1/endpoints/budget.py
Normal file
210
backend/app/api/v1/endpoints/budget.py
Normal 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"],
|
||||
)
|
||||
93
backend/app/api/v1/endpoints/notifications.py
Normal file
93
backend/app/api/v1/endpoints/notifications.py
Normal 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])
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "..."}
|
||||
|
||||
@@ -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()
|
||||
|
||||
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()),
|
||||
}
|
||||
@@ -8,3 +8,5 @@ python-multipart
|
||||
python-dotenv
|
||||
alembic
|
||||
httpx
|
||||
pywebpush
|
||||
py-vapid
|
||||
|
||||
Reference in New Issue
Block a user