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}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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() {
))}