Add accounts expansion, analytics, exchange rates, API tokens, PWA support, and UI overhaul
All checks were successful
Deploy to VPS / deploy (push) Successful in 45s

- 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) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-03-21 18:23:47 -06:00
parent 1257b0dd61
commit 0a8e00e227
39 changed files with 2247 additions and 220 deletions

View File

@@ -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
]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)