From 4d468036c66ba974bb5b9be99c50103efc60549c Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Sun, 22 Mar 2026 14:45:20 -0600 Subject: [PATCH] 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) --- backend/app/api/v1/endpoints/settings.py | 59 ++++++++++ backend/app/api/v1/router.py | 2 + backend/app/models/models.py | 25 ++++ backend/app/services/exchange_rate.py | 140 ++++++++++++++++++----- 4 files changed, 198 insertions(+), 28 deletions(-) create mode 100644 backend/app/api/v1/endpoints/settings.py diff --git a/backend/app/api/v1/endpoints/settings.py b/backend/app/api/v1/endpoints/settings.py new file mode 100644 index 0000000..6757900 --- /dev/null +++ b/backend/app/api/v1/endpoints/settings.py @@ -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 diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 76513ab..f5bbc2b 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -7,6 +7,7 @@ from app.api.v1.endpoints import ( categories, exchange_rate, import_transactions, + settings, tokens, transactions, ) @@ -20,3 +21,4 @@ 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) +api_router.include_router(settings.router) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index c53619c..a5ae048 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -2,6 +2,8 @@ import enum from datetime import datetime from typing import Optional +from sqlalchemy import Column +from sqlalchemy.dialects.postgresql import JSONB from sqlmodel import Field, Relationship, SQLModel @@ -195,3 +197,26 @@ class APITokenRead(SQLModel): created_at: datetime expires_at: Optional[datetime] 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 diff --git a/backend/app/services/exchange_rate.py b/backend/app/services/exchange_rate.py index 6cb0697..18a408e 100644 --- a/backend/app/services/exchange_rate.py +++ b/backend/app/services/exchange_rate.py @@ -10,7 +10,17 @@ 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" +# 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]] = {} +_last_known: ExchangeRate | None = None # survives cache expiry — always holds the last successful rate 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.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(",", ".")) @@ -41,14 +48,92 @@ def _fetch_bccr_rate(indicator: int, date_str: str) -> float | 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: - """Get current USD/CRC rate. Uses in-memory cache + DB fallback.""" - # Check memory cache + """Get current USD/CRC rate. Never returns None once a rate has been fetched.""" + global _last_known + + # 1. Fresh memory cache (< 1 hour) cached = _cache.get("current") if cached and datetime.utcnow() - cached[1] < CACHE_TTL: return cached[0] - # Check DB for recent rate + # 2. Fresh DB rate (< 1 hour) one_hour_ago = datetime.utcnow() - CACHE_TTL db_rate = session.exec( select(ExchangeRate) @@ -56,31 +141,30 @@ def get_current_rate(session: Session) -> ExchangeRate | None: .order_by(col(ExchangeRate.fetched_at).desc()) ).first() if db_rate: - _cache["current"] = (db_rate, datetime.utcnow()) - return db_rate + return _remember(db_rate) - # Fetch from BCCR - today = datetime.now().strftime("%d/%m/%Y") - buy = _fetch_bccr_rate(317, today) - sell = _fetch_bccr_rate(318, today) + # 3. Try all API sources + result = _fetch_rate_from_apis() + if result is not None: + 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: - # Fallback: return most recent DB rate regardless of age - fallback = session.exec( - select(ExchangeRate).order_by(col(ExchangeRate.fetched_at).desc()) - ).first() - return fallback + # 4. Stale DB rate (any age) + fallback = session.exec( + select(ExchangeRate).order_by(col(ExchangeRate.fetched_at).desc()) + ).first() + if fallback: + return _remember(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 + # 5. Last known in-memory rate (survives even if DB is empty) + if _last_known: + return _last_known + + return None def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]: