From 0a8e00e227ccc1729711b2cfcc0293738babaea3 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Sat, 21 Mar 2026 18:23:47 -0600 Subject: [PATCH] Add accounts expansion, analytics, exchange rates, API tokens, PWA support, and UI overhaul - Expand Account model with account_type (pension, savings, liability, crypto), new banks/currencies (BTC, XMR, FCL, ROP, VOL, MEMP, MPAT, MORTGAGE), and next_payment field - Add exchange rate endpoint (BCCR integration), analytics endpoint, paste-import for transactions, and API token management - Add PWA manifest, service worker, and app icons - Redesign dashboard, transactions, transfers, and login pages with theme support - Add billing cycle selector, confirm dialog, and paste import modal components - One-time DB reset in deploy workflow for schema migration Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/deploy.yml | 4 + backend/app/api/v1/endpoints/analytics.py | 184 +++++++++ backend/app/api/v1/endpoints/exchange_rate.py | 29 ++ .../api/v1/endpoints/import_transactions.py | 149 +++++++ backend/app/api/v1/endpoints/tokens.py | 66 ++++ backend/app/api/v1/endpoints/transactions.py | 96 ++++- backend/app/api/v1/router.py | 15 +- backend/app/auth.py | 28 +- backend/app/config.py | 2 + backend/app/models/models.py | 66 +++- backend/app/seed.py | 29 +- backend/app/services/__init__.py | 0 backend/app/services/exchange_rate.py | 95 +++++ backend/requirements.txt | 1 + docker-compose.yml | 4 +- frontend/index.html | 10 +- frontend/nginx.conf | 5 + frontend/package.json | 3 +- frontend/pnpm-lock.yaml | 318 +++++++++++++++ frontend/public/icons/icon-192.png | Bin 0 -> 4834 bytes frontend/public/icons/icon-512.png | Bin 0 -> 14350 bytes frontend/public/manifest.json | 27 ++ frontend/public/sw.js | 52 +++ frontend/src/App.tsx | 11 +- frontend/src/ThemeContext.tsx | 31 ++ frontend/src/api.ts | 8 + .../src/components/BillingCycleSelector.tsx | 54 +++ frontend/src/components/ConfirmDialog.tsx | 55 +++ frontend/src/components/Layout.tsx | 38 +- frontend/src/components/PasteImportModal.tsx | 141 +++++++ frontend/src/components/TransactionModal.tsx | 29 +- frontend/src/index.css | 36 ++ frontend/src/main.tsx | 4 + frontend/src/pages/Analytics.tsx | 273 +++++++++++++ frontend/src/pages/Dashboard.tsx | 367 +++++++++++++----- frontend/src/pages/Login.tsx | 22 +- frontend/src/pages/Transactions.tsx | 146 ++++--- frontend/src/pages/Transfers.tsx | 61 ++- frontend/vite.config.ts | 8 + 39 files changed, 2247 insertions(+), 220 deletions(-) create mode 100644 backend/app/api/v1/endpoints/analytics.py create mode 100644 backend/app/api/v1/endpoints/exchange_rate.py create mode 100644 backend/app/api/v1/endpoints/import_transactions.py create mode 100644 backend/app/api/v1/endpoints/tokens.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/exchange_rate.py create mode 100644 frontend/public/icons/icon-192.png create mode 100644 frontend/public/icons/icon-512.png create mode 100644 frontend/public/manifest.json create mode 100644 frontend/public/sw.js create mode 100644 frontend/src/ThemeContext.tsx create mode 100644 frontend/src/components/BillingCycleSelector.tsx create mode 100644 frontend/src/components/ConfirmDialog.tsx create mode 100644 frontend/src/components/PasteImportModal.tsx create mode 100644 frontend/src/pages/Analytics.tsx 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 0000000000000000000000000000000000000000..ef78c2562d2ad9d34d0e4bcdb67f48212cc30796 GIT binary patch literal 4834 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 literal 0 HcmV?d00001 diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..873df6de1a085b071ab0743c2533f12fbcc6ef08 GIT binary patch literal 14350 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 { + 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 ? ( + <> +
+
+ + +
+
+ + +
+
+ +
+ +