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

@@ -21,6 +21,8 @@ jobs:
ADMIN_USERNAME=${{ secrets.ADMIN_USERNAME }} ADMIN_USERNAME=${{ secrets.ADMIN_USERNAME }}
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }} ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
LETSENCRYPT_EMAIL=${{ secrets.LETSENCRYPT_EMAIL }} LETSENCRYPT_EMAIL=${{ secrets.LETSENCRYPT_EMAIL }}
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
ENVEOF ENVEOF
sed -i 's/^[[:space:]]*//' .env.prod sed -i 's/^[[:space:]]*//' .env.prod

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.auth import get_current_user
from app.db import get_session from app.db import get_session
from app.api.v1.endpoints.notifications import send_push_to_all
from app.models.models import ( from app.models.models import (
Category, Category,
Currency,
Transaction, Transaction,
TransactionCreate, TransactionCreate,
TransactionRead, TransactionRead,
@@ -55,6 +57,8 @@ def list_transactions(
category_id: Optional[int] = None, category_id: Optional[int] = None,
cycle_year: Optional[int] = None, cycle_year: Optional[int] = None,
cycle_month: 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), limit: int = Query(default=50, le=500),
offset: int = 0, offset: int = 0,
session: Session = Depends(get_session), session: Session = Depends(get_session),
@@ -70,6 +74,11 @@ def list_transactions(
if cycle_year and cycle_month: if cycle_year and cycle_month:
start, end = get_cycle_range(cycle_year, cycle_month) start, end = get_cycle_range(cycle_year, cycle_month)
query = query.where(Transaction.date >= start, Transaction.date < end) 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) query = query.order_by(col(Transaction.date).desc()).offset(offset).limit(limit)
return session.exec(query).all() return session.exec(query).all()
@@ -170,6 +179,17 @@ def create_transaction(
session.add(tx) session.add(tx)
session.commit() session.commit()
session.refresh(tx) 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 return tx

View File

@@ -4,9 +4,11 @@ from app.api.v1.endpoints import (
accounts, accounts,
analytics, analytics,
auth, auth,
budget,
categories, categories,
exchange_rate, exchange_rate,
import_transactions, import_transactions,
notifications,
settings, settings,
tokens, tokens,
transactions, transactions,
@@ -22,3 +24,5 @@ api_router.include_router(exchange_rate.router)
api_router.include_router(tokens.router) api_router.include_router(tokens.router)
api_router.include_router(analytics.router) api_router.include_router(analytics.router)
api_router.include_router(settings.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" ADMIN_PASSWORD: str = "admin"
BCCR_API_EMAIL: str = "" BCCR_API_EMAIL: str = ""
BCCR_API_TOKEN: str = "" BCCR_API_TOKEN: str = ""
VAPID_PRIVATE_KEY: str = ""
VAPID_PUBLIC_KEY: str = ""
VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev"
class Config: class Config:
env_file = ".env" env_file = ".env"

View File

@@ -2,11 +2,24 @@ import enum
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import Column from sqlalchemy import JSON, Column
from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field, Relationship, SQLModel 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): class TransactionType(str, enum.Enum):
COMPRA = "COMPRA" COMPRA = "COMPRA"
DEVOLUCION = "DEVOLUCION" DEVOLUCION = "DEVOLUCION"
@@ -207,7 +220,7 @@ class UserSettings(SQLModel, table=True):
key: str = Field(index=True, unique=True, default="default") key: str = Field(index=True, unique=True, default="default")
data: dict = Field( data: dict = Field(
default_factory=dict, 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) updated_at: datetime = Field(default_factory=datetime.utcnow)
@@ -220,3 +233,69 @@ class UserSettingsRead(SQLModel):
class UserSettingsUpdate(SQLModel): class UserSettingsUpdate(SQLModel):
data: dict 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 sqlmodel import Session, select
from app.db import engine 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 = [ DEFAULT_CATEGORIES = [
("Groceries", "shopping-cart", "automercado,auto mercado,fresh market,macrobiotica,pricesmart,price smart,grassfedcr,pequeno mundo"), ("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(): def seed_db():
with Session(engine) as session: with Session(engine) as session:
existing = session.exec(select(Category)).first() existing = session.exec(select(Category)).first()
@@ -58,3 +204,9 @@ def seed_db():
for bank, currency, label, account_type in DEFAULT_ACCOUNTS: for bank, currency, label, account_type in DEFAULT_ACCOUNTS:
session.add(Account(bank=bank, currency=currency, label=label, account_type=account_type)) session.add(Account(bank=bank, currency=currency, label=label, account_type=account_type))
session.commit() 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()),
}

View File

@@ -8,3 +8,5 @@ python-multipart
python-dotenv python-dotenv
alembic alembic
httpx httpx
pywebpush
py-vapid

View File

@@ -28,6 +28,8 @@ services:
SECRET_KEY: ${SECRET_KEY} SECRET_KEY: ${SECRET_KEY}
ADMIN_USERNAME: ${ADMIN_USERNAME} ADMIN_USERNAME: ${ADMIN_USERNAME}
ADMIN_PASSWORD: ${ADMIN_PASSWORD} ADMIN_PASSWORD: ${ADMIN_PASSWORD}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
expose: expose:
- "8000" - "8000"
networks: networks:

View File

@@ -23,6 +23,8 @@ services:
container_name: wealthysmart-backend-dev container_name: wealthysmart-backend-dev
environment: environment:
DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
ports: ports:
- "8001:8000" - "8001:8000"
volumes: volumes:

View File

@@ -27,6 +27,9 @@ self.addEventListener('fetch', (event) => {
return; return;
} }
// Only handle http(s) requests — skip chrome-extension:// etc.
if (!url.protocol.startsWith('http')) return;
// Cache-first for static assets // Cache-first for static assets
if (url.pathname.startsWith('/assets/')) { if (url.pathname.startsWith('/assets/')) {
event.respondWith( event.respondWith(
@@ -50,3 +53,46 @@ self.addEventListener('fetch', (event) => {
// Default: network with cache fallback // Default: network with cache fallback
event.respondWith(fetch(request).catch(() => caches.match(request))); 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);
})
);
});

View File

@@ -4,8 +4,7 @@ import { ThemeProvider } from './ThemeContext';
import Layout from './components/Layout'; import Layout from './components/Layout';
import Login from './pages/Login'; import Login from './pages/Login';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import Transactions from './pages/Transactions'; import Budget from './pages/Budget';
import Transfers from './pages/Transfers';
import Analytics from './pages/Analytics'; import Analytics from './pages/Analytics';
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -30,9 +29,11 @@ function AppRoutes() {
} }
> >
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/transactions" element={<Transactions />} /> <Route path="/budget" element={<Budget />} />
<Route path="/analytics" element={<Analytics />} /> <Route path="/analytics" element={<Analytics />} />
<Route path="/transfers" element={<Transfers />} /> {/* Redirect old routes */}
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
</Route> </Route>
</Routes> </Routes>
); );

View File

@@ -103,3 +103,125 @@ export interface Transaction {
category: Category | null; category: Category | null;
created_at: string; 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<string, number> | 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<string, number> | 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<string, number> | 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<RecurringItem[]>('/budget/recurring', { params });
export const createRecurringItem = (data: RecurringItemCreate) =>
api.post<RecurringItem>('/budget/recurring', data);
export const updateRecurringItem = (id: number, data: RecurringItemUpdate) =>
api.patch<RecurringItem>(`/budget/recurring/${id}`, data);
export const deleteRecurringItem = (id: number) =>
api.delete(`/budget/recurring/${id}`);
export const getYearlyProjection = (year: number) =>
api.get<YearlyProjection>(`/budget/projection/${year}`);
export const getMonthlyDetail = (year: number, month: number) =>
api.get<MonthlyDetail>(`/budget/month/${year}/${month}`);

View File

@@ -1,18 +1,18 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'; import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { import {
LayoutDashboard, LayoutDashboard,
CreditCard, Calculator,
BarChart3, BarChart3,
ArrowLeftRight,
LogOut, LogOut,
Wallet, Wallet,
Menu, Menu,
Sun, Sun,
Moon, Moon,
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useAuth } from '../AuthContext'; import { useAuth } from '../AuthContext';
import { useTheme } from '../ThemeContext'; import { useTheme } from '../ThemeContext';
import { subscribeToPush } from '../pushNotifications';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Sheet, Sheet,
@@ -26,9 +26,8 @@ import { cn } from '@/lib/utils';
const navItems = [ const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' }, { to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/transactions', icon: CreditCard, label: 'Transactions' }, { to: '/budget', icon: Calculator, label: 'Presupuesto' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' }, { to: '/analytics', icon: BarChart3, label: 'Analytics' },
{ to: '/transfers', icon: ArrowLeftRight, label: 'Cash & Transfers' },
]; ];
export default function Layout() { export default function Layout() {
@@ -37,6 +36,10 @@ export default function Layout() {
const navigate = useNavigate(); const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
subscribeToPush();
}, []);
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
navigate('/login'); navigate('/login');

View File

@@ -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<string, { label: string; icon: typeof CreditCard }> = {
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 (
<div className="grid gap-4 md:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="h-48" />
</Card>
))}
</div>
);
}
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">
Detalle: {MONTH_NAMES[detail.month]} {detail.year}
</h3>
<div className="grid gap-4 md:grid-cols-3">
{/* Income Card */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-primary" />
Ingresos
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{detail.income_items.map((item) => (
<div key={item.id} className="flex items-center justify-between text-sm">
<span className="truncate mr-2">{item.name}</span>
<span className="font-mono text-primary whitespace-nowrap">
{formatAmount(item.amount, 'CRC')}
</span>
</div>
))}
<Separator />
<div className="flex items-center justify-between font-semibold text-sm">
<span>Total</span>
<span className="font-mono text-primary">
{formatAmount(detail.total_projected_income, 'CRC')}
</span>
</div>
</CardContent>
</Card>
{/* Expenses Card */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<TrendingDown className="w-4 h-4 text-destructive" />
Egresos Fijos
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{detail.expense_items.map((item) => (
<div key={item.id} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1 truncate mr-2">
<span className="truncate">{item.name}</span>
{item.used_actual && (
<Badge variant="secondary" className="text-[10px] px-1 py-0 shrink-0">
real
</Badge>
)}
</div>
<div className="text-right whitespace-nowrap">
<span className="font-mono">{formatAmount(item.amount, 'CRC')}</span>
{item.used_actual && item.projected_amount != null && (
<span className="block text-[10px] text-muted-foreground font-mono line-through">
{formatAmount(item.projected_amount, 'CRC')}
</span>
)}
</div>
</div>
))}
{detail.expense_items.length === 0 && (
<p className="text-sm text-muted-foreground">Sin egresos fijos</p>
)}
<Separator />
<div className="flex items-center justify-between font-semibold text-sm">
<span>Total Fijos</span>
<span className="font-mono">
{formatAmount(detail.total_projected_expenses, 'CRC')}
</span>
</div>
</CardContent>
</Card>
{/* Actuals Card */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<CreditCard className="w-4 h-4" />
Transacciones Reales
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{detail.actuals_by_source.map((src) => {
const meta = SOURCE_LABELS[src.source];
if (!meta || src.count === 0) return null;
const Icon = meta.icon;
return (
<div key={src.source} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1.5">
<Icon className="w-3.5 h-3.5 text-muted-foreground" />
<span>{meta.label}</span>
<span className="text-xs text-muted-foreground">({src.count})</span>
</div>
<span className="font-mono whitespace-nowrap">
{formatAmount(src.net, 'CRC')}
</span>
</div>
);
})}
{detail.uncovered_actual > 0 && (
<>
<Separator />
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1">
<Info className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">No cubierto por fijos</span>
</div>
<span className="font-mono">{formatAmount(detail.uncovered_actual, 'CRC')}</span>
</div>
</>
)}
</CardContent>
</Card>
</div>
{/* Savings + Summary */}
<div className="grid gap-4 md:grid-cols-2">
{/* Savings */}
{detail.savings_items.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<PiggyBank className="w-4 h-4" />
Ahorro
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{detail.savings_items.map((item) => (
<div key={item.id} className="flex items-center justify-between text-sm">
<span>{item.name}</span>
<span className="font-mono">{formatAmount(item.amount, 'CRC')}</span>
</div>
))}
<Separator />
<div className="flex items-center justify-between font-semibold text-sm">
<span>Total Ahorro</span>
<span className="font-mono">
{formatAmount(detail.total_projected_savings, 'CRC')}
</span>
</div>
</CardContent>
</Card>
)}
{/* Summary */}
<Card className={cn(
'border-2',
detail.net_balance >= 0 ? 'border-primary/30' : 'border-destructive/30',
)}>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between text-sm">
<span>Total Ingresos</span>
<span className="font-mono font-medium text-primary">
+{formatAmount(detail.total_projected_income, 'CRC')}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span>Gran Total Egresos</span>
<span className="font-mono font-medium">
-{formatAmount(detail.gran_total_egresos, 'CRC')}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span>Ahorro</span>
<span className="font-mono font-medium">
-{formatAmount(detail.total_projected_savings, 'CRC')}
</span>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="font-semibold">Balance Neto</span>
<span
className={cn(
'font-mono font-bold text-lg',
detail.net_balance >= 0 ? 'text-primary' : 'text-destructive',
)}
>
{detail.net_balance >= 0 ? '+' : ''}
{formatAmount(detail.net_balance, 'CRC')}
</span>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -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<void>;
}
export default function RecurringItemDialog({
open,
onOpenChange,
item,
onSave,
}: RecurringItemDialogProps) {
const isEdit = !!item;
const [name, setName] = useState('');
const [amount, setAmount] = useState('');
const [itemType, setItemType] = useState<RecurringItemType>('EXPENSE');
const [frequency, setFrequency] = useState<RecurringFrequency>('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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'Editar' : 'Nuevo'} Item Recurrente</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="ri-name">Nombre</Label>
<Input id="ri-name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="ri-amount">Monto (CRC)</Label>
<Input
id="ri-amount"
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>Tipo</Label>
<Select value={itemType} onValueChange={(v) => v && setItemType(v as RecurringItemType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Frecuencia</Label>
<Select value={frequency} onValueChange={(v) => v && setFrequency(v as RecurringFrequency)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREQ_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{showDayOfMonth && (
<div className="space-y-1.5">
<Label htmlFor="ri-day">
{frequency === 'WEEKLY' ? 'Día de semana (0=Lun)' : 'Día del mes'}
</Label>
<Input
id="ri-day"
type="number"
value={dayOfMonth}
onChange={(e) => setDayOfMonth(e.target.value)}
/>
</div>
)}
{showMonthOfYear && (
<div className="space-y-1.5">
<Label>Mes</Label>
<Select value={monthOfYear} onValueChange={(v) => v && setMonthOfYear(v)}>
<SelectTrigger>
<SelectValue placeholder="Seleccionar" />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<SelectItem key={m} value={String(m)}>
{MONTH_LABELS[m]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{showOverrides && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">
Montos por mes (sobreescrituras)
</Label>
<Button
variant="ghost"
size="sm"
onClick={() => setOverrides([...overrides, { month: '', amount: '' }])}
>
<Plus className="w-3 h-3 mr-1" />
Agregar
</Button>
</div>
{overrides.map((o, idx) => (
<div key={idx} className="flex items-center gap-2">
<Select
value={o.month}
onValueChange={(v) => {
if (!v) return;
const next = [...overrides];
next[idx].month = v;
setOverrides(next);
}}
>
<SelectTrigger className="w-28">
<SelectValue placeholder="Mes" />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<SelectItem key={m} value={String(m)}>
{MONTH_LABELS[m]}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number"
placeholder="Monto"
value={o.amount}
onChange={(e) => {
const next = [...overrides];
next[idx].amount = e.target.value;
setOverrides(next);
}}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
onClick={() => setOverrides(overrides.filter((_, i) => i !== idx))}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
))}
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="ri-notes">Notas</Label>
<Textarea
id="ri-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button onClick={handleSubmit} disabled={saving || !name || !amount}>
{saving ? 'Guardando...' : isEdit ? 'Guardar' : 'Crear'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,184 @@
import { useState, useMemo } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import {
type RecurringItem,
type RecurringItemCreate,
type RecurringItemUpdate,
} from '@/api';
import { formatAmount } from '@/lib/format';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { DataTable } from '@/components/ui/data-table';
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
import { Pencil, Plus, Trash2 } from 'lucide-react';
import RecurringItemDialog from './RecurringItemDialog';
import ConfirmDialog from '@/components/ConfirmDialog';
const TYPE_LABELS: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' }> = {
INCOME: { label: 'Ingreso', variant: 'default' },
EXPENSE: { label: 'Egreso', variant: 'secondary' },
SAVINGS: { label: 'Ahorro', variant: 'outline' },
};
const FREQ_LABELS: Record<string, string> = {
WEEKLY: 'Semanal',
MONTHLY: 'Mensual',
QUARTERLY: 'Trimestral',
BIANNUAL: 'Semestral',
YEARLY: 'Anual',
};
interface RecurringItemsManagerProps {
items: RecurringItem[];
onAdd: (data: RecurringItemCreate) => Promise<void>;
onUpdate: (id: number, data: RecurringItemUpdate) => Promise<void>;
onDelete: (id: number) => Promise<void>;
}
export default function RecurringItemsManager({
items,
onAdd,
onUpdate,
onDelete,
}: RecurringItemsManagerProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editItem, setEditItem] = useState<RecurringItem | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const handleEdit = (item: RecurringItem) => {
setEditItem(item);
setDialogOpen(true);
};
const handleAdd = () => {
setEditItem(null);
setDialogOpen(true);
};
const handleSave = async (data: RecurringItemCreate | RecurringItemUpdate) => {
if (editItem) {
await onUpdate(editItem.id, data as RecurringItemUpdate);
} else {
await onAdd(data as RecurringItemCreate);
}
};
const handleDelete = async () => {
if (deleteId != null) {
await onDelete(deleteId);
setDeleteId(null);
}
};
const columns = useMemo<ColumnDef<RecurringItem, unknown>[]>(
() => [
{
accessorKey: 'name',
header: ({ column }) => <DataTableColumnHeader column={column} title="Nombre" />,
cell: ({ row }) => (
<div>
<span className="font-medium">{row.original.name}</span>
{!row.original.is_active && (
<Badge variant="outline" className="ml-2 text-[10px]">inactivo</Badge>
)}
</div>
),
},
{
accessorKey: 'item_type',
header: ({ column }) => <DataTableColumnHeader column={column} title="Tipo" />,
cell: ({ row }) => {
const meta = TYPE_LABELS[row.original.item_type];
return <Badge variant={meta?.variant ?? 'secondary'}>{meta?.label ?? row.original.item_type}</Badge>;
},
},
{
accessorKey: 'frequency',
header: ({ column }) => <DataTableColumnHeader column={column} title="Frecuencia" />,
cell: ({ row }) => (
<span className="text-sm">{FREQ_LABELS[row.original.frequency] ?? row.original.frequency}</span>
),
},
{
accessorKey: 'amount',
meta: { className: 'text-right' },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Monto" className="justify-end" />
),
cell: ({ row }) => (
<span className="font-mono text-sm">
{formatAmount(row.original.amount, row.original.currency)}
</span>
),
},
{
id: 'actions',
meta: { className: 'text-right' },
size: 80,
enableSorting: false,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
title="Editar"
aria-label="Editar"
onClick={() => handleEdit(row.original)}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Eliminar"
aria-label="Eliminar"
onClick={() => setDeleteId(row.original.id)}
className="hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
),
},
],
[],
);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Items Recurrentes</h3>
<Button size="sm" onClick={handleAdd}>
<Plus className="w-4 h-4 mr-1" />
Nuevo
</Button>
</div>
<DataTable
columns={columns}
data={items}
pagination
pageSize={20}
initialSorting={[{ id: 'item_type', desc: false }]}
emptyMessage="No hay items recurrentes."
/>
<RecurringItemDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
item={editItem}
onSave={handleSave}
/>
{deleteId != null && (
<ConfirmDialog
title="Eliminar item"
message="Esta acción no se puede deshacer."
confirmLabel="Eliminar"
onConfirm={handleDelete}
onCancel={() => setDeleteId(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { type MonthlyProjection } from '@/api';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
const MONTH_NAMES = [
'', 'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
interface YearlyOverviewProps {
months: MonthlyProjection[];
selectedMonth: number;
onSelectMonth: (month: number) => void;
}
export default function YearlyOverview({
months,
selectedMonth,
onSelectMonth,
}: YearlyOverviewProps) {
const currentMonth = new Date().getMonth() + 1;
const currentYear = new Date().getFullYear();
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Mes</TableHead>
<TableHead className="text-right">Ingresos</TableHead>
<TableHead className="text-right">Egresos Fijos</TableHead>
<TableHead className="text-right">Otros Gastos</TableHead>
<TableHead className="text-right">Gran Total</TableHead>
<TableHead className="text-right">Ahorro</TableHead>
<TableHead className="text-right">Balance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{months.map((m) => {
const isSelected = m.month === selectedMonth;
const isCurrent = m.month === currentMonth && m.year === currentYear;
return (
<TableRow
key={m.month}
className={cn(
'cursor-pointer transition-colors',
isSelected && 'bg-accent',
isCurrent && !isSelected && 'bg-accent/40',
)}
onClick={() => onSelectMonth(m.month)}
>
<TableCell className="font-medium">
{MONTH_NAMES[m.month]}
{isCurrent && (
<span className="ml-1.5 inline-block w-1.5 h-1.5 rounded-full bg-primary" />
)}
</TableCell>
<TableCell className="text-right font-mono text-sm text-primary">
{formatAmount(m.projected_income, 'CRC')}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatAmount(m.projected_fixed_expenses, 'CRC')}
</TableCell>
<TableCell className="text-right font-mono text-sm text-muted-foreground">
{formatAmount(m.uncovered_actual, 'CRC')}
</TableCell>
<TableCell className="text-right font-mono text-sm font-medium">
{formatAmount(m.gran_total_egresos, 'CRC')}
</TableCell>
<TableCell className="text-right font-mono text-sm text-muted-foreground">
{formatAmount(m.projected_savings, 'CRC')}
</TableCell>
<TableCell
className={cn(
'text-right font-mono text-sm font-semibold',
m.net_balance >= 0 ? 'text-primary' : 'text-destructive',
)}
>
{m.net_balance >= 0 ? '+' : ''}
{formatAmount(m.net_balance, 'CRC')}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { useState, useEffect, useCallback } from 'react';
import {
type YearlyProjection,
type MonthlyDetail,
type RecurringItem,
type RecurringItemCreate,
type RecurringItemUpdate,
getYearlyProjection,
getMonthlyDetail,
getRecurringItems,
createRecurringItem,
updateRecurringItem as apiUpdateItem,
deleteRecurringItem as apiDeleteItem,
} from '@/api';
export function useBudget(initialYear: number) {
const [year, setYear] = useState(initialYear);
const [selectedMonth, setSelectedMonth] = useState<number>(new Date().getMonth() + 1);
const [projection, setProjection] = useState<YearlyProjection | null>(null);
const [monthDetail, setMonthDetail] = useState<MonthlyDetail | null>(null);
const [recurringItems, setRecurringItems] = useState<RecurringItem[]>([]);
const [loading, setLoading] = useState(true);
const [monthLoading, setMonthLoading] = useState(false);
const fetchProjection = useCallback(async () => {
setLoading(true);
try {
const { data } = await getYearlyProjection(year);
setProjection(data);
} finally {
setLoading(false);
}
}, [year]);
const fetchMonthDetail = useCallback(async () => {
setMonthLoading(true);
try {
const { data } = await getMonthlyDetail(year, selectedMonth);
setMonthDetail(data);
} finally {
setMonthLoading(false);
}
}, [year, selectedMonth]);
const fetchRecurringItems = useCallback(async () => {
const { data } = await getRecurringItems();
setRecurringItems(data);
}, []);
useEffect(() => {
fetchProjection();
fetchRecurringItems();
}, [fetchProjection, fetchRecurringItems]);
useEffect(() => {
fetchMonthDetail();
}, [fetchMonthDetail]);
const addItem = async (data: RecurringItemCreate) => {
await createRecurringItem(data);
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
};
const updateItem = async (id: number, data: RecurringItemUpdate) => {
await apiUpdateItem(id, data);
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
};
const deleteItem = async (id: number) => {
await apiDeleteItem(id);
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
};
return {
year,
setYear,
selectedMonth,
setSelectedMonth,
projection,
monthDetail,
recurringItems,
loading,
monthLoading,
addItem,
updateItem,
deleteItem,
refresh: () => Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]),
};
}

View File

@@ -0,0 +1,205 @@
import { useState, useEffect, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Calculator, Loader2 } from 'lucide-react';
import api, { type Transaction } from '@/api';
import { useBudget } from '@/hooks/useBudget';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import YearlyOverview from '@/components/budget/YearlyOverview';
import MonthlyDetail from '@/components/budget/MonthlyDetail';
import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
import TransactionList from '@/components/TransactionList';
const MONTH_NAMES = [
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
export default function Budget() {
const currentYear = new Date().getFullYear();
const {
year,
setYear,
selectedMonth,
setSelectedMonth,
projection,
monthDetail,
recurringItems,
loading,
monthLoading,
addItem,
updateItem,
deleteItem,
refresh,
} = useBudget(currentYear);
// Transaction list state for the selected month
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [txLoading, setTxLoading] = useState(false);
const [txSearch, setTxSearch] = useState('');
const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH' | 'TRANSFER'>('CREDIT_CARD');
const fetchTransactions = useCallback(async () => {
setTxLoading(true);
try {
// Use calendar month date range
const startDate = `${year}-${String(selectedMonth).padStart(2, '0')}-01`;
const endMonth = selectedMonth === 12 ? 1 : selectedMonth + 1;
const endYear = selectedMonth === 12 ? year + 1 : year;
const endDate = `${endYear}-${String(endMonth).padStart(2, '0')}-01`;
const { data } = await api.get<Transaction[]>('/transactions/', {
params: {
source: txSource,
search: txSearch || undefined,
limit: 200,
start_date: startDate,
end_date: endDate,
},
});
setTransactions(data);
} finally {
setTxLoading(false);
}
}, [year, selectedMonth, txSource, txSearch]);
useEffect(() => {
fetchTransactions();
}, [fetchTransactions]);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Calculator className="w-6 h-6 text-primary" />
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" onClick={() => setYear(year - 1)}>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="w-16 text-center font-semibold tabular-nums">{year}</span>
<Button variant="outline" size="icon" onClick={() => setYear(year + 1)}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Resumen</TabsTrigger>
<TabsTrigger value="items">Items Recurrentes</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6 mt-4">
{/* Annual Summary */}
{projection && (
<div className="grid gap-3 grid-cols-2 md:grid-cols-4">
<Card>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Ingresos Anuales</p>
<p className="text-lg font-bold font-mono text-primary">
{formatAmount(projection.annual_income, 'CRC')}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Egresos Anuales</p>
<p className="text-lg font-bold font-mono">
{formatAmount(projection.annual_expenses, 'CRC')}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Ahorro Anual</p>
<p className="text-lg font-bold font-mono">
{formatAmount(projection.annual_savings, 'CRC')}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Balance Neto Anual</p>
<p
className={cn(
'text-lg font-bold font-mono',
projection.annual_net >= 0 ? 'text-primary' : 'text-destructive',
)}
>
{projection.annual_net >= 0 ? '+' : ''}
{formatAmount(projection.annual_net, 'CRC')}
</p>
</CardContent>
</Card>
</div>
)}
{/* Yearly Overview Table */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : projection ? (
<Card>
<CardContent className="p-0">
<YearlyOverview
months={projection.months}
selectedMonth={selectedMonth}
onSelectMonth={setSelectedMonth}
/>
</CardContent>
</Card>
) : null}
{/* Monthly Detail */}
{monthDetail && <MonthlyDetail detail={monthDetail} loading={monthLoading} />}
{/* Transactions for selected month */}
<div className="space-y-3">
<h3 className="text-lg font-semibold">
Transacciones {MONTH_NAMES[selectedMonth]} {year}
</h3>
<Tabs
value={txSource}
onValueChange={(v) => setTxSource(v as typeof txSource)}
>
<TabsList>
<TabsTrigger value="CREDIT_CARD">Tarjeta</TabsTrigger>
<TabsTrigger value="CASH">Efectivo</TabsTrigger>
<TabsTrigger value="TRANSFER">Transferencias</TabsTrigger>
</TabsList>
</Tabs>
<TransactionList
transactions={transactions}
loading={txLoading}
source={txSource}
search={txSearch}
onSearchChange={setTxSearch}
onRefresh={() => {
fetchTransactions();
refresh();
}}
showCategory={txSource === 'CREDIT_CARD'}
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : txSource === 'CASH' ? 'efectivo' : 'transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
/>
</div>
</TabsContent>
<TabsContent value="items" className="mt-4">
<RecurringItemsManager
items={recurringItems}
onAdd={addItem}
onUpdate={updateItem}
onDelete={deleteItem}
/>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import {
Pencil, Pencil,
Check, Check,
X, X,
BellRing,
} from 'lucide-react'; } from 'lucide-react';
import api, { type Account, type Transaction } from '../api'; import api, { type Account, type Transaction } from '../api';
@@ -124,6 +125,7 @@ export default function Dashboard() {
const [editValue, setEditValue] = useState(''); const [editValue, setEditValue] = useState('');
const [exchangeRate, setExchangeRate] = useState<{ buy_rate: number; sell_rate: number } | null>(null); const [exchangeRate, setExchangeRate] = useState<{ buy_rate: number; sell_rate: number } | null>(null);
const [configSection, setConfigSection] = useState<string | null>(null); const [configSection, setConfigSection] = useState<string | null>(null);
const [testingPush, setTestingPush] = useState(false);
const { settings, patchSection } = useSettings(); const { settings, patchSection } = useSettings();
@@ -316,6 +318,48 @@ export default function Dashboard() {
</CardContent> </CardContent>
</Card> </Card>
{/* Test push notification */}
<Card className="border-dashed border-yellow-500/50">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium">Test Push Notification</p>
<p className="text-xs text-muted-foreground">Creates a mock transaction to trigger a push notification</p>
</div>
<Button
variant="outline"
size="sm"
disabled={testingPush}
onClick={async () => {
setTestingPush(true);
try {
const merchants = ['Walmart', 'AutoMercado', 'Uber Eats', 'Amazon', 'PriceSmart'];
const amounts = [4500, 12350, 8900, 25000, 67800];
const i = Math.floor(Math.random() * merchants.length);
await api.post('/transactions/', {
merchant: merchants[i],
amount: amounts[i],
currency: 'CRC',
date: new Date().toISOString(),
bank: 'BAC',
source: 'CREDIT_CARD',
transaction_type: 'COMPRA',
reference: `test-push-${Date.now()}`,
notes: '[TEST] Push notification test — safe to delete',
});
fetchData();
} catch (e) {
console.error('Test push failed:', e);
} finally {
setTestingPush(false);
}
}}
>
<BellRing className="w-4 h-4 mr-2" />
{testingPush ? 'Sending...' : 'Send test'}
</Button>
</CardContent>
</Card>
{/* Section config dialog */} {/* Section config dialog */}
{configSection && settings.dashboard.sections[configSection] && ( {configSection && settings.dashboard.sections[configSection] && (
<SectionConfigDialog <SectionConfigDialog

View File

@@ -4,6 +4,7 @@ import { Wallet, ArrowRight, AlertCircle } from 'lucide-react';
import { login } from '../api'; import { login } from '../api';
import { useAuth } from '../AuthContext'; import { useAuth } from '../AuthContext';
import { subscribeToPush } from '../pushNotifications';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -24,6 +25,7 @@ export default function Login() {
try { try {
await login(username, password); await login(username, password);
setAuthenticated(true); setAuthenticated(true);
subscribeToPush();
navigate('/'); navigate('/');
} catch { } catch {
setError('Invalid credentials'); setError('Invalid credentials');

View File

@@ -0,0 +1,51 @@
import api from './api';
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export async function subscribeToPush(): Promise<void> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return;
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
return;
}
try {
const { data } = await api.get<{ publicKey: string }>('/notifications/vapid-public-key');
const registration = await navigator.serviceWorker.ready;
const existing = await registration.pushManager.getSubscription();
if (existing) {
await sendSubscriptionToServer(existing);
return;
}
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(data.publicKey),
});
await sendSubscriptionToServer(subscription);
} catch (err) {
console.warn('Push subscription failed:', err);
}
}
async function sendSubscriptionToServer(subscription: PushSubscription): Promise<void> {
const json = subscription.toJSON();
await api.post('/notifications/subscribe', {
endpoint: json.endpoint,
keys: json.keys,
});
}