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)

View File

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

View File

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

View File

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

View File

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

View File

View File

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