Add user settings endpoint and exchange rate fallback APIs

Backend now stores user settings (dashboard config) in a JSONB column and
exposes CRUD via /settings/. Exchange rate service gains multiple fallback
providers (ExchangeRate-API, currency-api, FloatRates) so USD/CRC rates
stay available when BCCR is down.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-03-22 14:45:20 -06:00
parent 58ab395d95
commit 4d468036c6
4 changed files with 198 additions and 28 deletions

View File

@@ -0,0 +1,59 @@
from datetime import datetime
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import UserSettings, UserSettingsRead, UserSettingsUpdate
router = APIRouter(prefix="/settings", tags=["settings"])
DEFAULT_SETTINGS = {
"dashboard": {
"sections": {
"crc_accounts": {"label": "CRC Accounts", "color": "primary", "cardColor": "primary", "visible": True, "order": 0, "expanded": False},
"usd_accounts": {"label": "USD Accounts", "color": "chart-1", "cardColor": "chart-1", "visible": True, "order": 1, "expanded": False},
"pension": {"label": "Pension", "color": "chart-2", "cardColor": "chart-2", "visible": True, "order": 2, "expanded": False},
"savings": {"label": "Savings", "color": "chart-3", "cardColor": "chart-3", "visible": True, "order": 3, "expanded": False},
"liabilities": {"label": "Liabilities", "color": "destructive", "cardColor": "destructive", "visible": True, "order": 4, "expanded": False},
"crypto": {"label": "Crypto", "color": "chart-4", "cardColor": "chart-4", "visible": True, "order": 5, "expanded": False},
}
}
}
@router.get("/", response_model=UserSettingsRead)
def get_settings(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
settings = session.exec(
select(UserSettings).where(UserSettings.key == "default")
).first()
if not settings:
settings = UserSettings(key="default", data=DEFAULT_SETTINGS)
session.add(settings)
session.commit()
session.refresh(settings)
return settings
@router.patch("/", response_model=UserSettingsRead)
def update_settings(
body: UserSettingsUpdate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
settings = session.exec(
select(UserSettings).where(UserSettings.key == "default")
).first()
if not settings:
settings = UserSettings(key="default", data=body.data)
else:
settings.data = body.data
settings.updated_at = datetime.utcnow()
session.add(settings)
session.commit()
session.refresh(settings)
return settings

View File

@@ -7,6 +7,7 @@ from app.api.v1.endpoints import (
categories, categories,
exchange_rate, exchange_rate,
import_transactions, import_transactions,
settings,
tokens, tokens,
transactions, transactions,
) )
@@ -20,3 +21,4 @@ api_router.include_router(import_transactions.router)
api_router.include_router(exchange_rate.router) api_router.include_router(exchange_rate.router)
api_router.include_router(tokens.router) api_router.include_router(tokens.router)
api_router.include_router(analytics.router) api_router.include_router(analytics.router)
api_router.include_router(settings.router)

View File

@@ -2,6 +2,8 @@ import enum
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import Column
from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field, Relationship, SQLModel from sqlmodel import Field, Relationship, SQLModel
@@ -195,3 +197,26 @@ class APITokenRead(SQLModel):
created_at: datetime created_at: datetime
expires_at: Optional[datetime] expires_at: Optional[datetime]
is_active: bool is_active: bool
# --- User Settings ---
class UserSettings(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
key: str = Field(index=True, unique=True, default="default")
data: dict = Field(
default_factory=dict,
sa_column=Column(JSONB, nullable=False, server_default="{}"),
)
updated_at: datetime = Field(default_factory=datetime.utcnow)
class UserSettingsRead(SQLModel):
key: str
data: dict
updated_at: datetime
class UserSettingsUpdate(SQLModel):
data: dict

View File

@@ -10,7 +10,17 @@ from app.models.models import ExchangeRate
# BCCR indicators: 317 = buy, 318 = sell # BCCR indicators: 317 = buy, 318 = sell
BCCR_URL = "https://gee.bccr.fi.cr/Indicadores/Suscripciones/WS/wsindicadoreseconomicos.asmx/ObtenerIndicadoresEconomicos" BCCR_URL = "https://gee.bccr.fi.cr/Indicadores/Suscripciones/WS/wsindicadoreseconomicos.asmx/ObtenerIndicadoresEconomicos"
# Fallback APIs (no API key required, all support CRC)
EXCHANGERATE_API_URL = "https://open.er-api.com/v6/latest/USD"
CURRENCY_API_URL = "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json"
CURRENCY_API_FALLBACK_URL = "https://latest.currency-api.pages.dev/v1/currencies/usd.json"
FLOATRATES_URL = "https://www.floatrates.com/daily/usd.json"
# Typical buy/sell spread for USD/CRC (~0.5% each side of mid-market)
_SPREAD = 0.005
_cache: dict[str, tuple[ExchangeRate, datetime]] = {} _cache: dict[str, tuple[ExchangeRate, datetime]] = {}
_last_known: ExchangeRate | None = None # survives cache expiry — always holds the last successful rate
CACHE_TTL = timedelta(hours=1) CACHE_TTL = timedelta(hours=1)
@@ -29,10 +39,7 @@ def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
resp = httpx.get(BCCR_URL, params=params, timeout=10) resp = httpx.get(BCCR_URL, params=params, timeout=10)
resp.raise_for_status() resp.raise_for_status()
# Parse XML response
root = ET.fromstring(resp.text) 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(): for datos in root.iter():
if datos.tag.endswith("NUM_VALOR"): if datos.tag.endswith("NUM_VALOR"):
return float(datos.text.strip().replace(",", ".")) return float(datos.text.strip().replace(",", "."))
@@ -41,14 +48,92 @@ def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
return None return None
def _fetch_bccr() -> tuple[float, float] | None:
"""Try BCCR official API. Returns (buy, sell) or None."""
today = datetime.now().strftime("%d/%m/%Y")
buy = _fetch_bccr_rate(317, today)
sell = _fetch_bccr_rate(318, today)
if buy is not None and sell is not None:
return (buy, sell)
return None
def _mid_to_buy_sell(mid: float) -> tuple[float, float]:
"""Convert a mid-market rate to approximate buy/sell with a spread."""
return (mid * (1 - _SPREAD), mid * (1 + _SPREAD))
def _fetch_exchangerate_api() -> tuple[float, float] | None:
"""Try ExchangeRate-API (open.er-api.com). No key required."""
try:
resp = httpx.get(EXCHANGERATE_API_URL, timeout=10)
resp.raise_for_status()
data = resp.json()
if data.get("result") == "success":
crc = data["rates"].get("CRC")
if crc:
return _mid_to_buy_sell(float(crc))
except Exception:
pass
return None
def _fetch_currency_api() -> tuple[float, float] | None:
"""Try fawazahmed0/currency-api (CDN-hosted). No key required."""
for url in (CURRENCY_API_URL, CURRENCY_API_FALLBACK_URL):
try:
resp = httpx.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
crc = data.get("usd", {}).get("crc")
if crc:
return _mid_to_buy_sell(float(crc))
except Exception:
continue
return None
def _fetch_floatrates() -> tuple[float, float] | None:
"""Try FloatRates. No key required."""
try:
resp = httpx.get(FLOATRATES_URL, timeout=10)
resp.raise_for_status()
data = resp.json()
crc_data = data.get("crc")
if crc_data and "rate" in crc_data:
return _mid_to_buy_sell(float(crc_data["rate"]))
except Exception:
pass
return None
def _fetch_rate_from_apis() -> tuple[float, float] | None:
"""Try all sources in order: BCCR → ExchangeRate-API → currency-api → FloatRates."""
for fetcher in (_fetch_bccr, _fetch_exchangerate_api, _fetch_currency_api, _fetch_floatrates):
result = fetcher()
if result is not None:
return result
return None
def _remember(rate: ExchangeRate) -> ExchangeRate:
"""Store rate in both TTL cache and permanent last-known holder."""
global _last_known
_cache["current"] = (rate, datetime.utcnow())
_last_known = rate
return rate
def get_current_rate(session: Session) -> ExchangeRate | None: def get_current_rate(session: Session) -> ExchangeRate | None:
"""Get current USD/CRC rate. Uses in-memory cache + DB fallback.""" """Get current USD/CRC rate. Never returns None once a rate has been fetched."""
# Check memory cache global _last_known
# 1. Fresh memory cache (< 1 hour)
cached = _cache.get("current") cached = _cache.get("current")
if cached and datetime.utcnow() - cached[1] < CACHE_TTL: if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
return cached[0] return cached[0]
# Check DB for recent rate # 2. Fresh DB rate (< 1 hour)
one_hour_ago = datetime.utcnow() - CACHE_TTL one_hour_ago = datetime.utcnow() - CACHE_TTL
db_rate = session.exec( db_rate = session.exec(
select(ExchangeRate) select(ExchangeRate)
@@ -56,31 +141,30 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
.order_by(col(ExchangeRate.fetched_at).desc()) .order_by(col(ExchangeRate.fetched_at).desc())
).first() ).first()
if db_rate: if db_rate:
_cache["current"] = (db_rate, datetime.utcnow()) return _remember(db_rate)
return db_rate
# Fetch from BCCR # 3. Try all API sources
today = datetime.now().strftime("%d/%m/%Y") result = _fetch_rate_from_apis()
buy = _fetch_bccr_rate(317, today) if result is not None:
sell = _fetch_bccr_rate(318, today) buy, sell = result
rate = ExchangeRate(date=datetime.utcnow(), buy_rate=buy, sell_rate=sell)
session.add(rate)
session.commit()
session.refresh(rate)
return _remember(rate)
if buy is None or sell is None: # 4. Stale DB rate (any age)
# Fallback: return most recent DB rate regardless of age fallback = session.exec(
fallback = session.exec( select(ExchangeRate).order_by(col(ExchangeRate.fetched_at).desc())
select(ExchangeRate).order_by(col(ExchangeRate.fetched_at).desc()) ).first()
).first() if fallback:
return fallback return _remember(fallback)
rate = ExchangeRate( # 5. Last known in-memory rate (survives even if DB is empty)
date=datetime.utcnow(), if _last_known:
buy_rate=buy, return _last_known
sell_rate=sell,
) return None
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]: def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]: