Files
WealthySmart/backend/app/services/exchange_rate.py
Carlos Escalante 9a80f2a997
All checks were successful
Deploy to VPS / deploy (push) Successful in 13s
Convert USD and EUR to CRC in analytics endpoints
All three analytics endpoints (by-category, monthly-trend, daily-spending)
now convert foreign currency amounts to CRC using current exchange rates.
EUR/CRC rate derived from ExchangeRate-API (USD-based cross rate).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 20:41:38 -06:00

223 lines
7.1 KiB
Python

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"
# 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)
# EUR/CRC mid-market rate cache
_eur_crc_cache: dict[str, tuple[float, datetime]] = {}
_last_known_eur_crc: float | None = None
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()
root = ET.fromstring(resp.text)
for datos in root.iter():
if datos.tag.endswith("NUM_VALOR"):
return float(datos.text.strip().replace(",", "."))
except Exception:
pass
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. 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]
# 2. Fresh DB rate (< 1 hour)
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:
return _remember(db_rate)
# 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)
# 4. Stale DB rate (any age)
fallback = session.exec(
select(ExchangeRate).order_by(col(ExchangeRate.fetched_at).desc())
).first()
if fallback:
return _remember(fallback)
# 5. Last known in-memory rate (survives even if DB is empty)
if _last_known:
return _last_known
return None
def _fetch_eur_crc_mid() -> float | None:
"""Derive EUR/CRC mid-market rate from ExchangeRate-API (USD-based).
EUR/CRC = CRC_per_USD / EUR_per_USD
"""
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")
eur = data["rates"].get("EUR")
if crc and eur:
return float(crc) / float(eur)
except Exception:
pass
return None
def get_eur_crc_rate() -> float | None:
"""Get current EUR→CRC mid-market rate (cached 1 hour)."""
global _last_known_eur_crc
cached = _eur_crc_cache.get("current")
if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
return cached[0]
rate = _fetch_eur_crc_mid()
if rate is not None:
_eur_crc_cache["current"] = (rate, datetime.utcnow())
_last_known_eur_crc = rate
return rate
if _last_known_eur_crc:
return _last_known_eur_crc
return None
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()
)