From 9a80f2a997d83cf78f86d22cd01836a92b89905b Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Tue, 7 Apr 2026 20:41:38 -0600 Subject: [PATCH] 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) --- backend/app/api/v1/endpoints/analytics.py | 44 ++++++++++++++++++++--- backend/app/services/exchange_rate.py | 43 ++++++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/backend/app/api/v1/endpoints/analytics.py b/backend/app/api/v1/endpoints/analytics.py index fede431..6f0a594 100644 --- a/backend/app/api/v1/endpoints/analytics.py +++ b/backend/app/api/v1/endpoints/analytics.py @@ -10,6 +10,7 @@ from app.auth import get_current_user from app.db import get_session from app.models.models import Category, Transaction from app.services.budget_projection import get_cycle_range +from app.services.exchange_rate import get_current_rate, get_eur_crc_rate router = APIRouter(prefix="/analytics", tags=["analytics"]) @@ -37,6 +38,17 @@ class DailySpending(BaseModel): count: int +def _get_crc_multipliers(session: Session) -> dict[str, float]: + """Return multipliers to convert each currency to CRC.""" + usd_rate = get_current_rate(session) + eur_rate = get_eur_crc_rate() + return { + "CRC": 1.0, + "USD": usd_rate.sell_rate if usd_rate else 0.0, + "EUR": eur_rate if eur_rate else 0.0, + } + + @router.get("/by-category", response_model=list[CategorySpending]) def spending_by_category( cycle_year: Optional[int] = None, @@ -44,10 +56,18 @@ def spending_by_category( session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): + rates = _get_crc_multipliers(session) + query = ( select( Transaction.category_id, - func.sum(Transaction.amount).label("total"), + func.sum( + case( + (Transaction.currency == "USD", Transaction.amount * rates["USD"]), + (Transaction.currency == "EUR", Transaction.amount * rates["EUR"]), + else_=Transaction.amount, + ) + ).label("total"), func.count().label("count"), ) .where(Transaction.transaction_type == "COMPRA") @@ -88,7 +108,12 @@ def monthly_trend( session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): - """Monthly spending totals using billing cycle boundaries (18th-18th).""" + """Monthly spending totals using billing cycle boundaries (18th-18th). + + total_crc includes all currencies converted to CRC at current rates. + total_usd is the raw USD amount (unconverted) for display purposes. + """ + rates = _get_crc_multipliers(session) now = datetime.now() results = [] month_names = [ @@ -106,8 +131,9 @@ def monthly_trend( func.coalesce( func.sum( case( - (Transaction.currency == "CRC", Transaction.amount), - else_=0, + (Transaction.currency == "USD", Transaction.amount * rates["USD"]), + (Transaction.currency == "EUR", Transaction.amount * rates["EUR"]), + else_=Transaction.amount, ) ), 0, @@ -163,10 +189,18 @@ def daily_spending( session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): + rates = _get_crc_multipliers(session) + query = ( select( func.date(Transaction.date).label("day"), - func.sum(Transaction.amount).label("total"), + func.sum( + case( + (Transaction.currency == "USD", Transaction.amount * rates["USD"]), + (Transaction.currency == "EUR", Transaction.amount * rates["EUR"]), + else_=Transaction.amount, + ) + ).label("total"), func.count().label("count"), ) .where(Transaction.transaction_type == "COMPRA") diff --git a/backend/app/services/exchange_rate.py b/backend/app/services/exchange_rate.py index 18a408e..b507b1a 100644 --- a/backend/app/services/exchange_rate.py +++ b/backend/app/services/exchange_rate.py @@ -23,6 +23,10 @@ _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.""" @@ -167,6 +171,45 @@ def get_current_rate(session: Session) -> ExchangeRate | None: 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)