diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 838277a..2dda641 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,6 +21,8 @@ jobs: ADMIN_USERNAME=${{ secrets.ADMIN_USERNAME }} ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }} LETSENCRYPT_EMAIL=${{ secrets.LETSENCRYPT_EMAIL }} + VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }} + VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }} ENVEOF sed -i 's/^[[:space:]]*//' .env.prod diff --git a/backend/app/api/v1/endpoints/budget.py b/backend/app/api/v1/endpoints/budget.py new file mode 100644 index 0000000..82ac445 --- /dev/null +++ b/backend/app/api/v1/endpoints/budget.py @@ -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"], + ) diff --git a/backend/app/api/v1/endpoints/notifications.py b/backend/app/api/v1/endpoints/notifications.py new file mode 100644 index 0000000..c8d6de0 --- /dev/null +++ b/backend/app/api/v1/endpoints/notifications.py @@ -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]) diff --git a/backend/app/api/v1/endpoints/transactions.py b/backend/app/api/v1/endpoints/transactions.py index d2e56e5..4367de4 100644 --- a/backend/app/api/v1/endpoints/transactions.py +++ b/backend/app/api/v1/endpoints/transactions.py @@ -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 diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index f5bbc2b..7478e63 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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) diff --git a/backend/app/config.py b/backend/app/config.py index e13aa75..99de8e2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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" diff --git a/backend/app/models/models.py b/backend/app/models/models.py index a5ae048..62d5c79 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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": "..."} diff --git a/backend/app/seed.py b/backend/app/seed.py index 86b0a64..6672992 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -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() diff --git a/backend/app/services/budget_projection.py b/backend/app/services/budget_projection.py new file mode 100644 index 0000000..a235881 --- /dev/null +++ b/backend/app/services/budget_projection.py @@ -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()), + } diff --git a/backend/requirements.txt b/backend/requirements.txt index f2af204..4c586e2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,3 +8,5 @@ python-multipart python-dotenv alembic httpx +pywebpush +py-vapid diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9674edf..d35936e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -28,6 +28,8 @@ services: SECRET_KEY: ${SECRET_KEY} ADMIN_USERNAME: ${ADMIN_USERNAME} ADMIN_PASSWORD: ${ADMIN_PASSWORD} + VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} + VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} expose: - "8000" networks: diff --git a/docker-compose.yml b/docker-compose.yml index 3c59ebf..ead8d5a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,8 @@ services: container_name: wealthysmart-backend-dev environment: DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart + VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} + VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} ports: - "8001:8000" volumes: diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 294313c..0755fa8 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -27,6 +27,9 @@ self.addEventListener('fetch', (event) => { return; } + // Only handle http(s) requests — skip chrome-extension:// etc. + if (!url.protocol.startsWith('http')) return; + // Cache-first for static assets if (url.pathname.startsWith('/assets/')) { event.respondWith( @@ -50,3 +53,46 @@ self.addEventListener('fetch', (event) => { // Default: network with cache fallback event.respondWith(fetch(request).catch(() => caches.match(request))); }); + +// --- Push Notifications --- + +self.addEventListener('push', (event) => { + if (!event.data) return; + + let data; + try { + data = event.data.json(); + } catch { + // Fallback for plain-text pushes (e.g. browser test pushes) + data = { title: 'WealthySmart', body: event.data.text() }; + } + + const options = { + body: data.body, + icon: '/icons/icon-192.png', + badge: '/icons/icon-192.png', + data: { url: data.url || '/' }, + vibrate: [200, 100, 200], + tag: 'transaction', + renotify: true, + }; + + event.waitUntil(self.registration.showNotification(data.title, options)); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + const url = event.notification.data?.url || '/'; + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => { + for (const client of windowClients) { + if (client.url.includes(self.location.origin) && 'focus' in client) { + client.navigate(url); + return client.focus(); + } + } + return clients.openWindow(url); + }) + ); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 20464a1..ffc5135 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,8 +4,7 @@ import { ThemeProvider } from './ThemeContext'; import Layout from './components/Layout'; import Login from './pages/Login'; import Dashboard from './pages/Dashboard'; -import Transactions from './pages/Transactions'; -import Transfers from './pages/Transfers'; +import Budget from './pages/Budget'; import Analytics from './pages/Analytics'; function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -30,9 +29,11 @@ function AppRoutes() { } > } /> - } /> + } /> } /> - } /> + {/* Redirect old routes */} + } /> + } /> ); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index cb417ce..75665d3 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -103,3 +103,125 @@ export interface Transaction { category: Category | null; created_at: string; } + +// --- Budget / Recurring Items --- + +export type RecurringItemType = 'INCOME' | 'EXPENSE' | 'SAVINGS'; +export type RecurringFrequency = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'BIANNUAL' | 'YEARLY'; + +export interface RecurringItem { + id: number; + name: string; + amount: number; + currency: string; + item_type: RecurringItemType; + frequency: RecurringFrequency; + day_of_month: number | null; + month_of_year: number | null; + override_amounts: Record | null; + category_id: number | null; + is_active: boolean; + notes: string | null; + created_at: string; + category: Category | null; +} + +export interface RecurringItemCreate { + name: string; + amount: number; + currency?: string; + item_type: RecurringItemType; + frequency?: RecurringFrequency; + day_of_month?: number | null; + month_of_year?: number | null; + override_amounts?: Record | null; + category_id?: number | null; + is_active?: boolean; + notes?: string | null; +} + +export interface RecurringItemUpdate { + name?: string; + amount?: number; + currency?: string; + item_type?: RecurringItemType; + frequency?: RecurringFrequency; + day_of_month?: number | null; + month_of_year?: number | null; + override_amounts?: Record | null; + category_id?: number | null; + is_active?: boolean; + notes?: string | null; +} + +export interface RecurringItemDetail { + id: number; + name: string; + amount: number; + projected_amount: number | null; + used_actual: boolean; + item_type: string; + frequency: string; + category_name: string | null; + category_id: number | null; +} + +export interface ActualsBySource { + source: string; + total_compra: number; + total_devolucion: number; + net: number; + count: number; +} + +export interface MonthlyProjection { + month: number; + year: number; + projected_income: number; + projected_fixed_expenses: number; + projected_savings: number; + actual_credit_card: number; + actual_cash: number; + actual_transfers: number; + uncovered_actual: number; + gran_total_egresos: number; + net_balance: number; +} + +export interface YearlyProjection { + year: number; + months: MonthlyProjection[]; + annual_income: number; + annual_expenses: number; + annual_savings: number; + annual_net: number; +} + +export interface MonthlyDetail { + year: number; + month: number; + income_items: RecurringItemDetail[]; + expense_items: RecurringItemDetail[]; + savings_items: RecurringItemDetail[]; + actuals_by_source: ActualsBySource[]; + total_projected_income: number; + total_projected_expenses: number; + total_projected_savings: number; + uncovered_actual: number; + gran_total_egresos: number; + net_balance: number; +} + +// Budget API functions +export const getRecurringItems = (params?: { item_type?: string; is_active?: boolean }) => + api.get('/budget/recurring', { params }); +export const createRecurringItem = (data: RecurringItemCreate) => + api.post('/budget/recurring', data); +export const updateRecurringItem = (id: number, data: RecurringItemUpdate) => + api.patch(`/budget/recurring/${id}`, data); +export const deleteRecurringItem = (id: number) => + api.delete(`/budget/recurring/${id}`); +export const getYearlyProjection = (year: number) => + api.get(`/budget/projection/${year}`); +export const getMonthlyDetail = (year: number, month: number) => + api.get(`/budget/month/${year}/${month}`); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 979c81c..d395afe 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,18 +1,18 @@ import { NavLink, Outlet, useNavigate } from 'react-router-dom'; import { LayoutDashboard, - CreditCard, + Calculator, BarChart3, - ArrowLeftRight, LogOut, Wallet, Menu, Sun, Moon, } from 'lucide-react'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useAuth } from '../AuthContext'; import { useTheme } from '../ThemeContext'; +import { subscribeToPush } from '../pushNotifications'; import { Button } from '@/components/ui/button'; import { Sheet, @@ -26,9 +26,8 @@ import { cn } from '@/lib/utils'; const navItems = [ { to: '/', icon: LayoutDashboard, label: 'Dashboard' }, - { to: '/transactions', icon: CreditCard, label: 'Transactions' }, + { to: '/budget', icon: Calculator, label: 'Presupuesto' }, { to: '/analytics', icon: BarChart3, label: 'Analytics' }, - { to: '/transfers', icon: ArrowLeftRight, label: 'Cash & Transfers' }, ]; export default function Layout() { @@ -37,6 +36,10 @@ export default function Layout() { const navigate = useNavigate(); const [mobileOpen, setMobileOpen] = useState(false); + useEffect(() => { + subscribeToPush(); + }, []); + const handleLogout = () => { logout(); navigate('/login'); diff --git a/frontend/src/components/budget/MonthlyDetail.tsx b/frontend/src/components/budget/MonthlyDetail.tsx new file mode 100644 index 0000000..758482b --- /dev/null +++ b/frontend/src/components/budget/MonthlyDetail.tsx @@ -0,0 +1,235 @@ +import { type MonthlyDetail as MonthlyDetailType } from '@/api'; +import { formatAmount } from '@/lib/format'; +import { cn } from '@/lib/utils'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { + TrendingUp, + TrendingDown, + PiggyBank, + CreditCard, + Banknote, + ArrowLeftRight, + Info, +} from 'lucide-react'; + +const MONTH_NAMES = [ + '', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', + 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre', +]; + +const SOURCE_LABELS: Record = { + CREDIT_CARD: { label: 'Tarjeta de Crédito', icon: CreditCard }, + CASH: { label: 'Efectivo', icon: Banknote }, + TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight }, +}; + +interface MonthlyDetailProps { + detail: MonthlyDetailType; + loading?: boolean; +} + +export default function MonthlyDetail({ detail, loading }: MonthlyDetailProps) { + if (loading) { + return ( +
+ {[1, 2, 3].map((i) => ( + + + + ))} +
+ ); + } + + return ( +
+

+ Detalle: {MONTH_NAMES[detail.month]} {detail.year} +

+ +
+ {/* Income Card */} + + + + + Ingresos + + + + {detail.income_items.map((item) => ( +
+ {item.name} + + {formatAmount(item.amount, 'CRC')} + +
+ ))} + +
+ Total + + {formatAmount(detail.total_projected_income, 'CRC')} + +
+
+
+ + {/* Expenses Card */} + + + + + Egresos Fijos + + + + {detail.expense_items.map((item) => ( +
+
+ {item.name} + {item.used_actual && ( + + real + + )} +
+
+ {formatAmount(item.amount, 'CRC')} + {item.used_actual && item.projected_amount != null && ( + + {formatAmount(item.projected_amount, 'CRC')} + + )} +
+
+ ))} + {detail.expense_items.length === 0 && ( +

Sin egresos fijos

+ )} + +
+ Total Fijos + + {formatAmount(detail.total_projected_expenses, 'CRC')} + +
+
+
+ + {/* Actuals Card */} + + + + + Transacciones Reales + + + + {detail.actuals_by_source.map((src) => { + const meta = SOURCE_LABELS[src.source]; + if (!meta || src.count === 0) return null; + const Icon = meta.icon; + return ( +
+
+ + {meta.label} + ({src.count}) +
+ + {formatAmount(src.net, 'CRC')} + +
+ ); + })} + {detail.uncovered_actual > 0 && ( + <> + +
+
+ + No cubierto por fijos +
+ {formatAmount(detail.uncovered_actual, 'CRC')} +
+ + )} +
+
+
+ + {/* Savings + Summary */} +
+ {/* Savings */} + {detail.savings_items.length > 0 && ( + + + + + Ahorro + + + + {detail.savings_items.map((item) => ( +
+ {item.name} + {formatAmount(item.amount, 'CRC')} +
+ ))} + +
+ Total Ahorro + + {formatAmount(detail.total_projected_savings, 'CRC')} + +
+
+
+ )} + + {/* Summary */} + = 0 ? 'border-primary/30' : 'border-destructive/30', + )}> + +
+ Total Ingresos + + +{formatAmount(detail.total_projected_income, 'CRC')} + +
+
+ Gran Total Egresos + + -{formatAmount(detail.gran_total_egresos, 'CRC')} + +
+
+ Ahorro + + -{formatAmount(detail.total_projected_savings, 'CRC')} + +
+ +
+ Balance Neto + = 0 ? 'text-primary' : 'text-destructive', + )} + > + {detail.net_balance >= 0 ? '+' : ''} + {formatAmount(detail.net_balance, 'CRC')} + +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/budget/RecurringItemDialog.tsx b/frontend/src/components/budget/RecurringItemDialog.tsx new file mode 100644 index 0000000..9feb6b0 --- /dev/null +++ b/frontend/src/components/budget/RecurringItemDialog.tsx @@ -0,0 +1,310 @@ +import { useState, useEffect } from 'react'; +import { + type RecurringItem, + type RecurringItemCreate, + type RecurringItemUpdate, + type RecurringItemType, + type RecurringFrequency, +} from '@/api'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { Plus, Trash2 } from 'lucide-react'; + +const TYPE_OPTIONS: { value: RecurringItemType; label: string }[] = [ + { value: 'INCOME', label: 'Ingreso' }, + { value: 'EXPENSE', label: 'Egreso' }, + { value: 'SAVINGS', label: 'Ahorro' }, +]; + +const FREQ_OPTIONS: { value: RecurringFrequency; label: string }[] = [ + { value: 'WEEKLY', label: 'Semanal' }, + { value: 'MONTHLY', label: 'Mensual' }, + { value: 'QUARTERLY', label: 'Trimestral' }, + { value: 'BIANNUAL', label: 'Semestral' }, + { value: 'YEARLY', label: 'Anual' }, +]; + +const MONTH_LABELS = [ + '', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', + 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre', +]; + +interface RecurringItemDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + item?: RecurringItem | null; + onSave: (data: RecurringItemCreate | RecurringItemUpdate) => Promise; +} + +export default function RecurringItemDialog({ + open, + onOpenChange, + item, + onSave, +}: RecurringItemDialogProps) { + const isEdit = !!item; + + const [name, setName] = useState(''); + const [amount, setAmount] = useState(''); + const [itemType, setItemType] = useState('EXPENSE'); + const [frequency, setFrequency] = useState('MONTHLY'); + const [dayOfMonth, setDayOfMonth] = useState(''); + const [monthOfYear, setMonthOfYear] = useState(''); + const [overrides, setOverrides] = useState<{ month: string; amount: string }[]>([]); + const [notes, setNotes] = useState(''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open) { + if (item) { + setName(item.name); + setAmount(String(item.amount)); + setItemType(item.item_type); + setFrequency(item.frequency); + setDayOfMonth(item.day_of_month != null ? String(item.day_of_month) : ''); + setMonthOfYear(item.month_of_year != null ? String(item.month_of_year) : ''); + setOverrides( + item.override_amounts + ? Object.entries(item.override_amounts).map(([m, a]) => ({ + month: m, + amount: String(a), + })) + : [], + ); + setNotes(item.notes || ''); + } else { + setName(''); + setAmount(''); + setItemType('EXPENSE'); + setFrequency('MONTHLY'); + setDayOfMonth(''); + setMonthOfYear(''); + setOverrides([]); + setNotes(''); + } + } + }, [open, item]); + + const showDayOfMonth = frequency === 'MONTHLY' || frequency === 'WEEKLY'; + const showMonthOfYear = frequency === 'YEARLY' || frequency === 'BIANNUAL'; + const showOverrides = frequency === 'MONTHLY'; + + const handleSubmit = async () => { + setSaving(true); + try { + const overrideAmounts = + overrides.length > 0 + ? Object.fromEntries( + overrides + .filter((o) => o.month && o.amount) + .map((o) => [o.month, parseFloat(o.amount)]), + ) + : null; + + const data = { + name, + amount: parseFloat(amount), + item_type: itemType, + frequency, + day_of_month: dayOfMonth ? parseInt(dayOfMonth) : null, + month_of_year: monthOfYear ? parseInt(monthOfYear) : null, + override_amounts: overrideAmounts, + notes: notes || null, + }; + + await onSave(data); + onOpenChange(false); + } finally { + setSaving(false); + } + }; + + return ( + + + + {isEdit ? 'Editar' : 'Nuevo'} Item Recurrente + + +
+
+ + setName(e.target.value)} /> +
+ +
+
+ + setAmount(e.target.value)} + /> +
+
+ + +
+
+ +
+
+ + +
+ + {showDayOfMonth && ( +
+ + setDayOfMonth(e.target.value)} + /> +
+ )} + + {showMonthOfYear && ( +
+ + +
+ )} +
+ + {showOverrides && ( +
+
+ + +
+ {overrides.map((o, idx) => ( +
+ + { + const next = [...overrides]; + next[idx].amount = e.target.value; + setOverrides(next); + }} + className="flex-1" + /> + +
+ ))} +
+ )} + +
+ +