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