diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 838277a..e0b57f7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -24,6 +24,10 @@ jobs: ENVEOF sed -i 's/^[[:space:]]*//' .env.prod + - name: Reset database (one-time schema migration) + run: | + docker compose -f docker-compose.prod.yml --env-file .env.prod down -v || true + - name: Build and deploy run: | docker compose -f docker-compose.prod.yml --env-file .env.prod build diff --git a/backend/app/api/v1/endpoints/analytics.py b/backend/app/api/v1/endpoints/analytics.py new file mode 100644 index 0000000..a670678 --- /dev/null +++ b/backend/app/api/v1/endpoints/analytics.py @@ -0,0 +1,184 @@ +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlmodel import Session, func, select + +from app.auth import get_current_user +from app.db import get_session +from app.models.models import Category, Transaction +from app.api.v1.endpoints.transactions import get_cycle_range + +router = APIRouter(prefix="/analytics", tags=["analytics"]) + + +class CategorySpending(BaseModel): + category_id: Optional[int] + category_name: str + total: float + count: int + percentage: float + + +class MonthlyTrend(BaseModel): + year: int + month: int + label: str + total_crc: float + total_usd: float + count: int + + +class DailySpending(BaseModel): + date: str + total: float + count: int + + +@router.get("/by-category", response_model=list[CategorySpending]) +def spending_by_category( + cycle_year: Optional[int] = None, + cycle_month: Optional[int] = None, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + query = ( + select( + Transaction.category_id, + func.sum(Transaction.amount).label("total"), + func.count().label("count"), + ) + .where(Transaction.transaction_type == "COMPRA") + .group_by(Transaction.category_id) + ) + + if cycle_year and cycle_month: + start, end = get_cycle_range(cycle_year, cycle_month) + query = query.where(Transaction.date >= start, Transaction.date < end) + + rows = session.exec(query).all() + + grand_total = sum(r[1] for r in rows) or 1 + + results = [] + for category_id, total, count in rows: + cat_name = "Uncategorized" + if category_id: + cat = session.get(Category, category_id) + if cat: + cat_name = cat.name + results.append( + CategorySpending( + category_id=category_id, + category_name=cat_name, + total=float(total), + count=count, + percentage=round(float(total) / grand_total * 100, 1), + ) + ) + + return sorted(results, key=lambda x: x.total, reverse=True) + + +@router.get("/monthly-trend", response_model=list[MonthlyTrend]) +def monthly_trend( + months: int = 6, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + """Monthly spending totals using billing cycle boundaries (18th-18th).""" + now = datetime.now() + results = [] + month_names = [ + "", "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ] + + y, m = now.year, now.month + for _ in range(months): + start, end = get_cycle_range(y, m) + + row = session.exec( + select( + func.count(), + func.coalesce( + func.sum( + func.case( + (Transaction.currency == "CRC", Transaction.amount), + else_=0, + ) + ), + 0, + ), + func.coalesce( + func.sum( + func.case( + (Transaction.currency == "USD", Transaction.amount), + else_=0, + ) + ), + 0, + ), + ) + .where( + Transaction.transaction_type == "COMPRA", + Transaction.date >= start, + Transaction.date < end, + ) + ).first() + + count = row[0] if row else 0 + total_crc = float(row[1]) if row else 0.0 + total_usd = float(row[2]) if row else 0.0 + + end_month = m + 1 if m < 12 else 1 + label = f"{month_names[m]} - {month_names[end_month]}" + + results.append( + MonthlyTrend( + year=y, + month=m, + label=label, + total_crc=total_crc, + total_usd=total_usd, + count=count, + ) + ) + + # Previous month + if m == 1: + y, m = y - 1, 12 + else: + m -= 1 + + return list(reversed(results)) + + +@router.get("/daily-spending", response_model=list[DailySpending]) +def daily_spending( + cycle_year: Optional[int] = None, + cycle_month: Optional[int] = None, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + query = ( + select( + func.date(Transaction.date).label("day"), + func.sum(Transaction.amount).label("total"), + func.count().label("count"), + ) + .where(Transaction.transaction_type == "COMPRA") + .group_by(func.date(Transaction.date)) + .order_by(func.date(Transaction.date)) + ) + + if cycle_year and cycle_month: + start, end = get_cycle_range(cycle_year, cycle_month) + query = query.where(Transaction.date >= start, Transaction.date < end) + + rows = session.exec(query).all() + return [ + DailySpending(date=str(day), total=float(total), count=count) + for day, total, count in rows + ] diff --git a/backend/app/api/v1/endpoints/exchange_rate.py b/backend/app/api/v1/endpoints/exchange_rate.py new file mode 100644 index 0000000..f727259 --- /dev/null +++ b/backend/app/api/v1/endpoints/exchange_rate.py @@ -0,0 +1,29 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session + +from app.auth import get_current_user +from app.db import get_session +from app.models.models import ExchangeRateRead +from app.services.exchange_rate import get_current_rate, get_rate_history + +router = APIRouter(prefix="/exchange-rate", tags=["exchange-rate"]) + + +@router.get("/", response_model=ExchangeRateRead) +def current_rate( + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + rate = get_current_rate(session) + if not rate: + raise HTTPException(status_code=503, detail="Exchange rate unavailable") + return rate + + +@router.get("/history", response_model=list[ExchangeRateRead]) +def rate_history( + days: int = 30, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + return get_rate_history(session, days) diff --git a/backend/app/api/v1/endpoints/import_transactions.py b/backend/app/api/v1/endpoints/import_transactions.py new file mode 100644 index 0000000..2746ad3 --- /dev/null +++ b/backend/app/api/v1/endpoints/import_transactions.py @@ -0,0 +1,149 @@ +import hashlib +import re +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends +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 ( + Bank, + Currency, + Transaction, + TransactionSource, + TransactionType, +) +from app.api.v1.endpoints.transactions import auto_categorize + +router = APIRouter(prefix="/import", tags=["import"]) + + +class PasteImportRequest(BaseModel): + text: str + bank: Bank = Bank.BAC + source: TransactionSource = TransactionSource.CREDIT_CARD + + +class PasteImportResult(BaseModel): + imported: int + duplicates: int + errors: list[str] + + +def make_reference_hash(date: datetime, merchant: str, amount: float, currency: str) -> str: + raw = f"{date.isoformat()}|{merchant.strip().upper()}|{amount}|{currency}" + return hashlib.sha256(raw.encode()).hexdigest()[:16] + + +def parse_bac_line(line: str) -> Optional[dict]: + """Parse a single BAC statement line. + + Format: DD/MM/YYYY\tMERCHANT\\CITY\\COUNTRY\tAMOUNT CURRENCY + """ + line = line.strip() + if not line: + return None + + parts = re.split(r"\t+", line) + if len(parts) < 3: + # Try multiple spaces as delimiter + parts = re.split(r"\s{2,}", line) + if len(parts) < 3: + return None + + # Parse date + date_str = parts[0].strip() + try: + date = datetime.strptime(date_str, "%d/%m/%Y") + except ValueError: + return None + + # Parse merchant\city\country + merchant_raw = parts[1].strip() + merchant_parts = merchant_raw.split("\\") + merchant = merchant_parts[0].strip() + city = merchant_parts[1].strip() if len(merchant_parts) > 1 else None + + # Parse amount + currency + amount_str = parts[2].strip() + # Extract currency (last 3 chars) + match = re.match(r"^([\d,.-]+)\s*(CRC|USD)$", amount_str, re.IGNORECASE) + if not match: + return None + + amount_raw = match.group(1).replace(",", "") + currency = match.group(2).upper() + amount = float(amount_raw) + + # Determine transaction type + is_refund = amount < 0 or "PAGO RECIBIDO" in merchant.upper() + tx_type = TransactionType.DEVOLUCION if is_refund else TransactionType.COMPRA + amount = abs(amount) + + return { + "date": date, + "merchant": merchant, + "city": city, + "amount": amount, + "currency": currency, + "transaction_type": tx_type, + } + + +@router.post("/paste", response_model=PasteImportResult) +def paste_import( + req: PasteImportRequest, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + imported = 0 + duplicates = 0 + errors: list[str] = [] + + lines = req.text.strip().splitlines() + + for i, line in enumerate(lines, 1): + if not line.strip(): + continue + + parsed = parse_bac_line(line) + if parsed is None: + errors.append(f"Line {i}: could not parse") + continue + + # Generate reference hash for duplicate detection + ref_hash = make_reference_hash( + parsed["date"], parsed["merchant"], parsed["amount"], parsed["currency"] + ) + + # Check for duplicates + existing = session.exec( + select(Transaction).where(Transaction.reference == ref_hash) + ).first() + if existing: + duplicates += 1 + continue + + tx = Transaction( + amount=parsed["amount"], + currency=Currency(parsed["currency"]), + merchant=parsed["merchant"], + city=parsed["city"], + date=parsed["date"], + transaction_type=parsed["transaction_type"], + source=req.source, + bank=req.bank, + reference=ref_hash, + ) + tx.category_id = auto_categorize(tx.merchant, session) + + session.add(tx) + imported += 1 + + if imported > 0: + session.commit() + + return PasteImportResult(imported=imported, duplicates=duplicates, errors=errors) diff --git a/backend/app/api/v1/endpoints/tokens.py b/backend/app/api/v1/endpoints/tokens.py new file mode 100644 index 0000000..d53c145 --- /dev/null +++ b/backend/app/api/v1/endpoints/tokens.py @@ -0,0 +1,66 @@ +import secrets +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlmodel import Session, select + +from app.auth import get_current_user, hash_token +from app.db import get_session +from app.models.models import APIToken, APITokenCreate, APITokenRead + +router = APIRouter(prefix="/tokens", tags=["tokens"]) + + +class TokenCreatedResponse(BaseModel): + token: str + name: str + expires_at: Optional[datetime] + + +@router.post("/", response_model=TokenCreatedResponse, status_code=201) +def create_token( + data: APITokenCreate, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + plaintext = secrets.token_urlsafe(32) + expires_at = None + if data.expires_days: + expires_at = datetime.utcnow() + timedelta(days=data.expires_days) + + api_token = APIToken( + name=data.name, + token_hash=hash_token(plaintext), + expires_at=expires_at, + ) + session.add(api_token) + session.commit() + session.refresh(api_token) + + return TokenCreatedResponse(token=plaintext, name=api_token.name, expires_at=api_token.expires_at) + + +@router.get("/", response_model=list[APITokenRead]) +def list_tokens( + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + return list(session.exec( + select(APIToken).where(APIToken.is_active == True).order_by(APIToken.created_at.desc()) + ).all()) + + +@router.delete("/{token_id}", status_code=204) +def revoke_token( + token_id: int, + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + token = session.get(APIToken, token_id) + if not token: + raise HTTPException(status_code=404, detail="Token not found") + token.is_active = False + session.add(token) + session.commit() diff --git a/backend/app/api/v1/endpoints/transactions.py b/backend/app/api/v1/endpoints/transactions.py index 9f40581..d2e56e5 100644 --- a/backend/app/api/v1/endpoints/transactions.py +++ b/backend/app/api/v1/endpoints/transactions.py @@ -1,7 +1,9 @@ +from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query -from sqlmodel import Session, col, select +from pydantic import BaseModel +from sqlmodel import Session, col, func, select from app.auth import get_current_user from app.db import get_session @@ -17,6 +19,24 @@ from app.models.models import ( router = APIRouter(prefix="/transactions", tags=["transactions"]) +def get_cycle_range(year: int, month: int) -> tuple[datetime, datetime]: + """Return (start, end) for billing cycle: month/18 to month+1/18.""" + start = datetime(year, month, 18) + if month == 12: + end = datetime(year + 1, 1, 18) + else: + end = datetime(year, month + 1, 18) + return start, end + + +class BillingCycle(BaseModel): + year: int + month: int + label: str + count: int + total: float + + def auto_categorize(merchant: str, session: Session) -> Optional[int]: categories = session.exec(select(Category)).all() merchant_lower = merchant.lower() @@ -33,6 +53,8 @@ def list_transactions( source: Optional[TransactionSource] = None, search: Optional[str] = None, category_id: Optional[int] = None, + cycle_year: Optional[int] = None, + cycle_month: Optional[int] = None, limit: int = Query(default=50, le=500), offset: int = 0, session: Session = Depends(get_session), @@ -45,10 +67,72 @@ def list_transactions( query = query.where(Transaction.category_id == category_id) if search: query = query.where(col(Transaction.merchant).ilike(f"%{search}%")) + if cycle_year and cycle_month: + start, end = get_cycle_range(cycle_year, cycle_month) + query = query.where(Transaction.date >= start, Transaction.date < end) query = query.order_by(col(Transaction.date).desc()).offset(offset).limit(limit) return session.exec(query).all() +@router.get("/cycles", response_model=list[BillingCycle]) +def list_billing_cycles( + session: Session = Depends(get_session), + _user: str = Depends(get_current_user), +): + """Return available billing cycles based on transaction dates.""" + # Get date range of all transactions + result = session.exec( + select(func.min(Transaction.date), func.max(Transaction.date)) + ).first() + if not result or not result[0]: + return [] + + min_date, max_date = result + + cycles = [] + # Determine which cycle the min_date falls into + if min_date.day < 18: + # Falls in previous month's cycle + if min_date.month == 1: + y, m = min_date.year - 1, 12 + else: + y, m = min_date.year, min_date.month - 1 + else: + y, m = min_date.year, min_date.month + + while True: + start, end = get_cycle_range(y, m) + if start > max_date: + break + + # Count transactions in this cycle + count_result = session.exec( + select(func.count(), func.coalesce(func.sum(Transaction.amount), 0)).where( + Transaction.date >= start, Transaction.date < end + ) + ).first() + count = count_result[0] if count_result else 0 + total = float(count_result[1]) if count_result else 0.0 + + if count > 0: + month_names = [ + "", "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ] + end_month = m + 1 if m < 12 else 1 + end_year = y if m < 12 else y + 1 + label = f"{month_names[m]} 18 - {month_names[end_month]} 18, {end_year}" + cycles.append(BillingCycle(year=y, month=m, label=label, count=count, total=total)) + + # Next month + if m == 12: + y, m = y + 1, 1 + else: + m += 1 + + return list(reversed(cycles)) + + @router.get("/recent", response_model=list[TransactionRead]) def recent_transactions( limit: int = Query(default=5, le=20), @@ -71,6 +155,16 @@ def create_transaction( _user: str = Depends(get_current_user), ): tx = Transaction.model_validate(data) + # Duplicate detection by reference + if tx.reference: + existing = session.exec( + select(Transaction).where(Transaction.reference == tx.reference) + ).first() + if existing: + raise HTTPException( + status_code=409, + detail=f"Duplicate transaction: reference '{tx.reference}' already exists (id={existing.id})", + ) if tx.category_id is None: tx.category_id = auto_categorize(tx.merchant, session) session.add(tx) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 48f8237..76513ab 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,9 +1,22 @@ from fastapi import APIRouter -from app.api.v1.endpoints import accounts, auth, categories, transactions +from app.api.v1.endpoints import ( + accounts, + analytics, + auth, + categories, + exchange_rate, + import_transactions, + tokens, + transactions, +) api_router = APIRouter(prefix="/api/v1") api_router.include_router(auth.router) api_router.include_router(accounts.router) api_router.include_router(categories.router) api_router.include_router(transactions.router) +api_router.include_router(import_transactions.router) +api_router.include_router(exchange_rate.router) +api_router.include_router(tokens.router) +api_router.include_router(analytics.router) diff --git a/backend/app/auth.py b/backend/app/auth.py index 5a62f65..edb8f98 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,8 +1,10 @@ +import hashlib from datetime import datetime, timedelta from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt +from sqlmodel import Session, select from app.config import settings @@ -16,7 +18,12 @@ def create_access_token(subject: str) -> str: return jwt.encode({"sub": subject, "exp": expire}, settings.SECRET_KEY, algorithm=ALGORITHM) +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + def get_current_user(token: str = Depends(oauth2_scheme)) -> str: + # Try JWT first try: payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) username: str = payload.get("sub") @@ -24,4 +31,23 @@ def get_current_user(token: str = Depends(oauth2_scheme)) -> str: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) return username except JWTError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + pass + + # Fallback: check API token + from app.db import get_session + from app.models.models import APIToken + + token_hash = hash_token(token) + with next(get_session()) as session: + api_token = session.exec( + select(APIToken).where( + APIToken.token_hash == token_hash, + APIToken.is_active == True, + ) + ).first() + if api_token: + if api_token.expires_at and api_token.expires_at < datetime.utcnow(): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") + return f"api:{api_token.name}" + + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) diff --git a/backend/app/config.py b/backend/app/config.py index 60e51a7..e13aa75 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -7,6 +7,8 @@ class Settings(BaseSettings): ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 30 # 30 days ADMIN_USERNAME: str = "admin" ADMIN_PASSWORD: str = "admin" + BCCR_API_EMAIL: str = "" + BCCR_API_TOKEN: str = "" class Config: env_file = ".env" diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 020c434..c53619c 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -19,12 +19,28 @@ class TransactionSource(str, enum.Enum): class Currency(str, enum.Enum): CRC = "CRC" USD = "USD" + BTC = "BTC" + XMR = "XMR" class Bank(str, enum.Enum): BAC = "BAC" BCR = "BCR" DAVIVIENDA = "DAVIVIENDA" + FCL = "FCL" + ROP = "ROP" + VOL = "VOL" + MEMP = "MEMP" + MPAT = "MPAT" + MORTGAGE = "MORTGAGE" + + +class AccountType(str, enum.Enum): + BANK = "BANK" + PENSION = "PENSION" + CRYPTO = "CRYPTO" + SAVINGS = "SAVINGS" + LIABILITY = "LIABILITY" # --- Category --- @@ -64,8 +80,10 @@ class CategoryUpdate(SQLModel): class AccountBase(SQLModel): bank: Bank currency: Currency - label: str = Field(description="e.g. 'BAC Colones', 'BAC Dólares'") + label: str balance: float = 0.0 + account_type: AccountType = AccountType.BANK + next_payment: Optional[float] = None class Account(AccountBase, table=True): @@ -85,6 +103,7 @@ class AccountRead(AccountBase): class AccountUpdate(SQLModel): balance: Optional[float] = None label: Optional[str] = None + next_payment: Optional[float] = None # --- Transaction --- @@ -99,7 +118,7 @@ class TransactionBase(SQLModel): card_type: Optional[str] = None card_last4: Optional[str] = None authorization_code: Optional[str] = None - reference: Optional[str] = None + reference: Optional[str] = Field(default=None, index=True) transaction_type: TransactionType = TransactionType.COMPRA source: TransactionSource = TransactionSource.CREDIT_CARD bank: Bank = Bank.BAC @@ -133,3 +152,46 @@ class TransactionUpdate(SQLModel): source: Optional[TransactionSource] = None notes: Optional[str] = None category_id: Optional[int] = None + + +# --- Exchange Rate --- + + +class ExchangeRate(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + date: datetime + buy_rate: float + sell_rate: float + fetched_at: datetime = Field(default_factory=datetime.utcnow) + + +class ExchangeRateRead(SQLModel): + buy_rate: float + sell_rate: float + date: datetime + fetched_at: datetime + + +# --- API Token --- + + +class APIToken(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + token_hash: str = Field(index=True) + created_at: datetime = Field(default_factory=datetime.utcnow) + expires_at: Optional[datetime] = None + is_active: bool = True + + +class APITokenCreate(SQLModel): + name: str + expires_days: Optional[int] = None + + +class APITokenRead(SQLModel): + id: int + name: str + created_at: datetime + expires_at: Optional[datetime] + is_active: bool diff --git a/backend/app/seed.py b/backend/app/seed.py index 36be337..86b0a64 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -1,7 +1,7 @@ from sqlmodel import Session, select from app.db import engine -from app.models.models import Account, Bank, Category, Currency +from app.models.models import Account, AccountType, Bank, Category, Currency DEFAULT_CATEGORIES = [ ("Groceries", "shopping-cart", "automercado,auto mercado,fresh market,macrobiotica,pricesmart,price smart,grassfedcr,pequeno mundo"), @@ -23,10 +23,25 @@ DEFAULT_CATEGORIES = [ ] DEFAULT_ACCOUNTS = [ - (Bank.BAC, Currency.CRC, "BAC Colones", 0.0), - (Bank.BAC, Currency.USD, "BAC Dólares", 0.0), - (Bank.BCR, Currency.CRC, "BCR Colones", 0.0), - (Bank.DAVIVIENDA, Currency.CRC, "Davivienda Colones", 0.0), + # Bank accounts + (Bank.BAC, Currency.CRC, "BAC", AccountType.BANK), + (Bank.BAC, Currency.USD, "BAC", AccountType.BANK), + (Bank.BCR, Currency.CRC, "BCR", AccountType.BANK), + (Bank.BCR, Currency.USD, "BCR", AccountType.BANK), + (Bank.DAVIVIENDA, Currency.CRC, "DAV", AccountType.BANK), + (Bank.DAVIVIENDA, Currency.USD, "DAV", AccountType.BANK), + # Pensions (CRC) + (Bank.FCL, Currency.CRC, "FCL", AccountType.PENSION), + (Bank.ROP, Currency.CRC, "ROP", AccountType.PENSION), + (Bank.VOL, Currency.CRC, "VOL", AccountType.PENSION), + # Savings (CRC) + (Bank.MEMP, Currency.CRC, "MEMP", AccountType.SAVINGS), + (Bank.MPAT, Currency.CRC, "MPAT", AccountType.SAVINGS), + # Liabilities + (Bank.MORTGAGE, Currency.USD, "Mortgage", AccountType.LIABILITY), + # Crypto + (Bank.BAC, Currency.BTC, "BTC", AccountType.CRYPTO), + (Bank.BAC, Currency.XMR, "XMR", AccountType.CRYPTO), ] @@ -40,6 +55,6 @@ def seed_db(): existing_acc = session.exec(select(Account)).first() if not existing_acc: - for bank, currency, label, balance in DEFAULT_ACCOUNTS: - session.add(Account(bank=bank, currency=currency, label=label, balance=balance)) + for bank, currency, label, account_type in DEFAULT_ACCOUNTS: + session.add(Account(bank=bank, currency=currency, label=label, account_type=account_type)) session.commit() diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/exchange_rate.py b/backend/app/services/exchange_rate.py new file mode 100644 index 0000000..6cb0697 --- /dev/null +++ b/backend/app/services/exchange_rate.py @@ -0,0 +1,95 @@ +import xml.etree.ElementTree as ET +from datetime import datetime, timedelta + +import httpx +from sqlmodel import Session, col, select + +from app.config import settings +from app.models.models import ExchangeRate + +# BCCR indicators: 317 = buy, 318 = sell +BCCR_URL = "https://gee.bccr.fi.cr/Indicadores/Suscripciones/WS/wsindicadoreseconomicos.asmx/ObtenerIndicadoresEconomicos" + +_cache: dict[str, tuple[ExchangeRate, datetime]] = {} +CACHE_TTL = timedelta(hours=1) + + +def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None: + """Fetch a single indicator from BCCR API.""" + try: + params = { + "Indicador": str(indicator), + "FechaInicio": date_str, + "FechaFinal": date_str, + "Nombre": settings.BCCR_API_EMAIL or "WealthySmart", + "SubNiveles": "N", + "CorreoElectronico": settings.BCCR_API_EMAIL or "no-reply@example.com", + "Token": settings.BCCR_API_TOKEN or "", + } + resp = httpx.get(BCCR_URL, params=params, timeout=10) + resp.raise_for_status() + + # Parse XML response + root = ET.fromstring(resp.text) + # The value is in INGC011_DES_DATOS > NUM_VALOR + ns = {"": "http://ws.sdde.bccr.fi.cr"} + for datos in root.iter(): + if datos.tag.endswith("NUM_VALOR"): + return float(datos.text.strip().replace(",", ".")) + except Exception: + pass + return None + + +def get_current_rate(session: Session) -> ExchangeRate | None: + """Get current USD/CRC rate. Uses in-memory cache + DB fallback.""" + # Check memory cache + cached = _cache.get("current") + if cached and datetime.utcnow() - cached[1] < CACHE_TTL: + return cached[0] + + # Check DB for recent rate + one_hour_ago = datetime.utcnow() - CACHE_TTL + db_rate = session.exec( + select(ExchangeRate) + .where(ExchangeRate.fetched_at > one_hour_ago) + .order_by(col(ExchangeRate.fetched_at).desc()) + ).first() + if db_rate: + _cache["current"] = (db_rate, datetime.utcnow()) + return db_rate + + # Fetch from BCCR + today = datetime.now().strftime("%d/%m/%Y") + buy = _fetch_bccr_rate(317, today) + sell = _fetch_bccr_rate(318, today) + + if buy is None or sell is None: + # Fallback: return most recent DB rate regardless of age + fallback = session.exec( + select(ExchangeRate).order_by(col(ExchangeRate.fetched_at).desc()) + ).first() + return fallback + + rate = ExchangeRate( + date=datetime.utcnow(), + buy_rate=buy, + sell_rate=sell, + ) + session.add(rate) + session.commit() + session.refresh(rate) + _cache["current"] = (rate, datetime.utcnow()) + return rate + + +def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]: + """Get historical exchange rates.""" + cutoff = datetime.utcnow() - timedelta(days=days) + return list( + session.exec( + select(ExchangeRate) + .where(ExchangeRate.date > cutoff) + .order_by(col(ExchangeRate.date).desc()) + ).all() + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index 2a6d145..f2af204 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,3 +7,4 @@ passlib[bcrypt] python-multipart python-dotenv alembic +httpx diff --git a/docker-compose.yml b/docker-compose.yml index 1ff1fb4..3c59ebf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,7 @@ services: environment: DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart ports: - - "8000:8000" + - "8001:8000" volumes: - ./backend:/app depends_on: @@ -37,7 +37,7 @@ services: dockerfile: Dockerfile container_name: wealthysmart-frontend-dev ports: - - "5174:5173" + - "5175:5173" volumes: - ./frontend:/app - /app/node_modules diff --git a/frontend/index.html b/frontend/index.html index dbe73ba..823f1f5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,11 +1,19 @@ - + + + + WealthySmart diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 78b73fb..58f7d1e 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -18,6 +18,11 @@ server { proxy_read_timeout 120s; } + # No cache for service worker + location /sw.js { + add_header Cache-Control "no-cache"; + } + # Cache immutable assets location /assets/ { expires 1y; diff --git a/frontend/package.json b/frontend/package.json index 9301cba..6526a80 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,8 @@ "lucide-react": "^0.562.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.12.0" + "react-router-dom": "^7.12.0", + "recharts": "^3.8.0" }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 6e76cb1..9055118 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: react-router-dom: specifier: ^7.12.0 version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + recharts: + specifier: ^3.8.0 + version: 3.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1) devDependencies: '@tailwindcss/vite': specifier: ^4.1.18 @@ -220,6 +223,17 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/pluginutils@1.0.0-rc.7': resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} @@ -348,6 +362,12 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@swc/core-darwin-arm64@1.15.18': resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==} engines: {node: '>=10'} @@ -513,6 +533,33 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -524,6 +571,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@vitejs/plugin-react-swc@4.3.0': resolution: {integrity: sha512-mOkXCII839dHyAt/gpoSlm28JIVDwhZ6tnG6wJxUy2bmOx7UaPjvOyIDf3SFv5s7Eo7HVaq6kRcu6YMEzt5Z7w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -540,6 +590,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -551,6 +605,53 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -583,11 +684,17 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.45.1: + resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} + esbuild@0.27.4: resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} hasBin: true + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -645,6 +752,16 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -763,6 +880,21 @@ packages: peerDependencies: react: ^19.2.4 + react-is@19.2.4: + resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==} + + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-router-dom@7.13.1: resolution: {integrity: sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==} engines: {node: '>=20.0.0'} @@ -784,6 +916,25 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + recharts@3.8.0: + resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + rollup@4.59.1: resolution: {integrity: sha512-iZKH8BeoCwTCBTZBZWQQMreekd4mdomwdjIQ40GC1oZm6o+8PnNMIxFOiCsGMWeS8iDJ7KZcl7KwmKk/0HOQpA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -806,6 +957,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -815,6 +969,14 @@ packages: engines: {node: '>=14.17'} hasBin: true + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -954,6 +1116,18 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + '@rolldown/pluginutils@1.0.0-rc.7': {} '@rollup/rollup-android-arm-eabi@4.59.1': @@ -1031,6 +1205,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.1': optional: true + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + '@swc/core-darwin-arm64@1.15.18': optional: true @@ -1151,6 +1329,30 @@ snapshots: tailwindcss: 4.2.2 vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/estree@1.0.8': {} '@types/react-dom@19.2.3(@types/react@19.2.14)': @@ -1161,6 +1363,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/use-sync-external-store@0.0.6': {} + '@vitejs/plugin-react-swc@4.3.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 @@ -1184,6 +1388,8 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + clsx@2.1.1: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -1192,6 +1398,46 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + decimal.js-light@2.5.1: {} + delayed-stream@1.0.0: {} detect-libc@2.1.2: {} @@ -1222,6 +1468,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.45.1: {} + esbuild@0.27.4: optionalDependencies: '@esbuild/aix-ppc64': 0.27.4 @@ -1251,6 +1499,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-x64': 0.27.4 + eventemitter3@5.0.4: {} + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -1302,6 +1552,12 @@ snapshots: dependencies: function-bind: 1.1.2 + immer@10.2.0: {} + + immer@11.1.4: {} + + internmap@2.0.3: {} + jiti@2.6.1: {} lightningcss-android-arm64@1.32.0: @@ -1388,6 +1644,17 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-is@19.2.4: {} + + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + react-router-dom@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -1404,6 +1671,34 @@ snapshots: react@19.2.4: {} + recharts@3.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.45.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.4) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + + reselect@5.1.1: {} + rollup@4.59.1: dependencies: '@types/estree': 1.0.8 @@ -1445,6 +1740,8 @@ snapshots: tapable@2.3.0: {} + tiny-invariant@1.3.3: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -1452,6 +1749,27 @@ snapshots: typescript@5.9.3: {} + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0): dependencies: esbuild: 0.27.4 diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png new file mode 100644 index 0000000..ef78c25 Binary files /dev/null and b/frontend/public/icons/icon-192.png differ diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png new file mode 100644 index 0000000..873df6d Binary files /dev/null and b/frontend/public/icons/icon-512.png differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..3b2908e --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,27 @@ +{ + "name": "WealthySmart", + "short_name": "WealthySmart", + "description": "Smart personal finance management", + "start_url": "/", + "display": "standalone", + "background_color": "#0f172a", + "theme_color": "#0f172a", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..294313c --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,52 @@ +const CACHE_NAME = 'wealthysmart-v1'; +const STATIC_ASSETS = ['/', '/index.html']; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Network-first for API calls + if (url.pathname.startsWith('/api/')) { + event.respondWith(fetch(request).catch(() => caches.match(request))); + return; + } + + // Cache-first for static assets + if (url.pathname.startsWith('/assets/')) { + event.respondWith( + caches.match(request).then((cached) => cached || fetch(request).then((res) => { + const clone = res.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + return res; + })) + ); + return; + } + + // Network-first for navigation, fallback to cached index.html + if (request.mode === 'navigate') { + event.respondWith( + fetch(request).catch(() => caches.match('/index.html')) + ); + return; + } + + // Default: network with cache fallback + event.respondWith(fetch(request).catch(() => caches.match(request))); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 788f2a6..20464a1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,12 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { AuthProvider, useAuth } from './AuthContext'; +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 Analytics from './pages/Analytics'; function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated } = useAuth(); @@ -29,6 +31,7 @@ function AppRoutes() { > } /> } /> + } /> } /> @@ -38,9 +41,11 @@ function AppRoutes() { export default function App() { return ( - - - + + + + + ); } diff --git a/frontend/src/ThemeContext.tsx b/frontend/src/ThemeContext.tsx new file mode 100644 index 0000000..3730008 --- /dev/null +++ b/frontend/src/ThemeContext.tsx @@ -0,0 +1,31 @@ +import { createContext, useContext, useEffect, useState } from 'react'; + +type Theme = 'light' | 'dark'; + +const ThemeContext = createContext<{ + theme: Theme; + toggleTheme: () => void; +}>({ theme: 'dark', toggleTheme: () => {} }); + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [theme, setTheme] = useState(() => { + const saved = localStorage.getItem('theme') as Theme; + if (saved) return saved; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + }); + + useEffect(() => { + document.documentElement.classList.toggle('dark', theme === 'dark'); + localStorage.setItem('theme', theme); + }, [theme]); + + const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')); + + return ( + + {children} + + ); +} + +export const useTheme = () => useContext(ThemeContext); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c12cb44..afd2491 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -38,6 +38,8 @@ export interface Account { currency: string; label: string; balance: number; + account_type: string; + next_payment: number | null; updated_at: string; } @@ -48,6 +50,12 @@ export interface Category { auto_match_patterns: string | null; } +export interface ImportResult { + imported: number; + duplicates: number; + errors: string[]; +} + export interface Transaction { id: number; amount: number; diff --git a/frontend/src/components/BillingCycleSelector.tsx b/frontend/src/components/BillingCycleSelector.tsx new file mode 100644 index 0000000..b21b993 --- /dev/null +++ b/frontend/src/components/BillingCycleSelector.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; +import { Calendar, ChevronDown } from 'lucide-react'; +import api from '../api'; + +export interface CycleOption { + year: number; + month: number; + label: string; + count: number; + total: number; +} + +interface Props { + value: { year: number; month: number } | null; + onChange: (cycle: { year: number; month: number } | null) => void; +} + +export default function BillingCycleSelector({ value, onChange }: Props) { + const [cycles, setCycles] = useState([]); + + useEffect(() => { + api.get('/transactions/cycles').then((r) => setCycles(r.data)); + }, []); + + const selectedKey = value ? `${value.year}-${value.month}` : ''; + + return ( +
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..30d384d --- /dev/null +++ b/frontend/src/components/ConfirmDialog.tsx @@ -0,0 +1,55 @@ +import { AlertTriangle, X } from 'lucide-react'; + +interface Props { + title: string; + message: string; + confirmLabel?: string; + onConfirm: () => void; + onCancel: () => void; + loading?: boolean; +} + +export default function ConfirmDialog({ title, message, confirmLabel = 'Delete', onConfirm, onCancel, loading }: Props) { + return ( +
+
e.stopPropagation()} + > +
+

{title}

+ +
+ +
+
+
+ +
+

{message}

+
+
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 634dd99..f97a686 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -2,23 +2,29 @@ import { NavLink, Outlet, useNavigate } from 'react-router-dom'; import { LayoutDashboard, CreditCard, + BarChart3, ArrowLeftRight, LogOut, Wallet, Menu, X, + Sun, + Moon, } from 'lucide-react'; import { useState } from 'react'; import { useAuth } from '../AuthContext'; +import { useTheme } from '../ThemeContext'; const navItems = [ { to: '/', icon: LayoutDashboard, label: 'Dashboard' }, { to: '/transactions', icon: CreditCard, label: 'Transactions' }, + { to: '/analytics', icon: BarChart3, label: 'Analytics' }, { to: '/transfers', icon: ArrowLeftRight, label: 'Cash & Transfers' }, ]; export default function Layout() { const { logout } = useAuth(); + const { theme, toggleTheme } = useTheme(); const navigate = useNavigate(); const [mobileOpen, setMobileOpen] = useState(false); @@ -28,16 +34,16 @@ export default function Layout() { }; return ( -
+
{/* Top bar */} -
+
-
- +
+
- WealthySmart + WealthySmart
@@ -51,8 +57,8 @@ export default function Layout() { className={({ isActive }) => `flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ isActive - ? 'bg-emerald-500/10 text-emerald-400' - : 'text-slate-400 hover:text-white hover:bg-slate-800/50' + ? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]' + : 'text-text-muted hover:text-text-primary hover:bg-surface-hover' }` } > @@ -63,15 +69,21 @@ export default function Layout() {
+ @@ -80,7 +92,7 @@ export default function Layout() { {/* Mobile nav */} {mobileOpen && ( -
+
{navItems.map(({ to, icon: Icon, label }) => ( `flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${ isActive - ? 'bg-emerald-500/10 text-emerald-400' - : 'text-slate-400 hover:text-white hover:bg-slate-800/50' + ? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]' + : 'text-text-muted hover:text-text-primary hover:bg-surface-hover' }` } > @@ -101,7 +113,7 @@ export default function Layout() { ))} +
+ +
+ {!result ? ( + <> +
+
+ + +
+
+ + +
+
+ +
+ +