diff --git a/backend/app/api/v1/endpoints/analytics.py b/backend/app/api/v1/endpoints/analytics.py index 6f0a594..d677bba 100644 --- a/backend/app/api/v1/endpoints/analytics.py +++ b/backend/app/api/v1/endpoints/analytics.py @@ -10,7 +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 +from app.services.exchange_rate import get_converted_amount_expr router = APIRouter(prefix="/analytics", tags=["analytics"]) @@ -38,17 +38,6 @@ 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, @@ -56,18 +45,12 @@ def spending_by_category( session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): - rates = _get_crc_multipliers(session) + amount_crc = get_converted_amount_expr(session) query = ( select( Transaction.category_id, - func.sum( - case( - (Transaction.currency == "USD", Transaction.amount * rates["USD"]), - (Transaction.currency == "EUR", Transaction.amount * rates["EUR"]), - else_=Transaction.amount, - ) - ).label("total"), + func.sum(amount_crc).label("total"), func.count().label("count"), ) .where(Transaction.transaction_type == "COMPRA") @@ -113,7 +96,7 @@ def monthly_trend( 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) + amount_crc = get_converted_amount_expr(session) now = datetime.now() results = [] month_names = [ @@ -128,16 +111,7 @@ def monthly_trend( row = session.exec( select( func.count(), - func.coalesce( - func.sum( - case( - (Transaction.currency == "USD", Transaction.amount * rates["USD"]), - (Transaction.currency == "EUR", Transaction.amount * rates["EUR"]), - else_=Transaction.amount, - ) - ), - 0, - ), + func.coalesce(func.sum(amount_crc), 0), func.coalesce( func.sum( case( @@ -189,18 +163,12 @@ def daily_spending( session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): - rates = _get_crc_multipliers(session) + amount_crc = get_converted_amount_expr(session) query = ( select( func.date(Transaction.date).label("day"), - func.sum( - case( - (Transaction.currency == "USD", Transaction.amount * rates["USD"]), - (Transaction.currency == "EUR", Transaction.amount * rates["EUR"]), - else_=Transaction.amount, - ) - ).label("total"), + func.sum(amount_crc).label("total"), func.count().label("count"), ) .where(Transaction.transaction_type == "COMPRA") diff --git a/backend/app/api/v1/endpoints/salarios.py b/backend/app/api/v1/endpoints/salarios.py index 9503e21..0772adc 100644 --- a/backend/app/api/v1/endpoints/salarios.py +++ b/backend/app/api/v1/endpoints/salarios.py @@ -8,6 +8,7 @@ from sqlmodel import Session, col, func, select from app.auth import get_current_user from app.db import get_session from app.models.models import Transaction, TransactionRead, TransactionType +from app.services.exchange_rate import get_converted_amount_expr router = APIRouter(prefix="/salarios", tags=["salarios"]) @@ -40,10 +41,11 @@ def salarios_summary( session: Session = Depends(get_session), _user: str = Depends(get_current_user), ): + amount_crc = get_converted_amount_expr(session) result = session.exec( select( func.count(), - func.coalesce(func.sum(Transaction.amount), 0), + func.coalesce(func.sum(amount_crc), 0), func.max(Transaction.date), ).where(Transaction.transaction_type == TransactionType.DEPOSITO) ).first() diff --git a/backend/app/api/v1/endpoints/transactions.py b/backend/app/api/v1/endpoints/transactions.py index 0bdd334..e2d8ddd 100644 --- a/backend/app/api/v1/endpoints/transactions.py +++ b/backend/app/api/v1/endpoints/transactions.py @@ -20,6 +20,7 @@ from app.models.models import ( ) from app.services.budget_projection import get_cycle_range, get_previous_cycle +from app.services.exchange_rate import get_converted_amount_expr router = APIRouter(prefix="/transactions", tags=["transactions"]) @@ -110,6 +111,7 @@ def list_billing_cycles( return [] min_date, max_date = result + amount_crc = get_converted_amount_expr(session) cycles = [] # Determine which cycle the min_date falls into @@ -129,7 +131,7 @@ def list_billing_cycles( # Count transactions in this cycle count_result = session.exec( - select(func.count(), func.coalesce(func.sum(Transaction.amount), 0)).where( + select(func.count(), func.coalesce(func.sum(amount_crc), 0)).where( Transaction.date >= start, Transaction.date < end ) ).first() diff --git a/backend/app/main.py b/backend/app/main.py index 5a8ec95..1e566d1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,4 @@ +import asyncio from contextlib import asynccontextmanager from fastapi import FastAPI @@ -7,6 +8,7 @@ from app.api.v1.router import api_router from app.config import settings from app.db import init_db, run_migrations from app.seed import seed_db +from app.services.exchange_rate import refresh_rates_periodically @asynccontextmanager @@ -14,7 +16,15 @@ async def lifespan(app: FastAPI): init_db() run_migrations() seed_db() - yield + rate_refresh_task = asyncio.create_task(refresh_rates_periodically()) + try: + yield + finally: + rate_refresh_task.cancel() + try: + await rate_refresh_task + except asyncio.CancelledError: + pass app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan) diff --git a/backend/app/services/budget_projection.py b/backend/app/services/budget_projection.py index 5a7470f..d0ee40d 100644 --- a/backend/app/services/budget_projection.py +++ b/backend/app/services/budget_projection.py @@ -12,6 +12,7 @@ from app.models.models import ( TransactionSource, TransactionType, ) +from app.services.exchange_rate import get_converted_amount_expr MIN_YEAR = 2026 MAX_YEAR = 2030 @@ -104,13 +105,15 @@ def compute_actuals_by_source( prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m) cal_start, cal_end = get_month_range(year, month) + amount_crc = get_converted_amount_expr(session) + results = {} for source in TransactionSource: if source == TransactionSource.CREDIT_CARD: start, end = cc_start, cc_end # Normal transactions in this cycle (not deferred) compra_normal = session.exec( - select(func.coalesce(func.sum(Transaction.amount), 0)).where( + select(func.coalesce(func.sum(amount_crc), 0)).where( Transaction.date >= start, Transaction.date < end, Transaction.source == source, @@ -120,7 +123,7 @@ def compute_actuals_by_source( ).one() # Deferred from previous cycle compra_deferred = session.exec( - select(func.coalesce(func.sum(Transaction.amount), 0)).where( + select(func.coalesce(func.sum(amount_crc), 0)).where( Transaction.date >= prev_start, Transaction.date < prev_end, Transaction.source == source, @@ -131,7 +134,7 @@ def compute_actuals_by_source( compra = float(compra_normal) + float(compra_deferred) dev_normal = session.exec( - select(func.coalesce(func.sum(Transaction.amount), 0)).where( + select(func.coalesce(func.sum(amount_crc), 0)).where( Transaction.date >= start, Transaction.date < end, Transaction.source == source, @@ -140,7 +143,7 @@ def compute_actuals_by_source( ) ).one() dev_deferred = session.exec( - select(func.coalesce(func.sum(Transaction.amount), 0)).where( + select(func.coalesce(func.sum(amount_crc), 0)).where( Transaction.date >= prev_start, Transaction.date < prev_end, Transaction.source == source, @@ -180,7 +183,7 @@ def compute_actuals_by_source( else: # Cash / Transfer: calendar month, no deferred logic compra = session.exec( - select(func.coalesce(func.sum(Transaction.amount), 0)).where( + select(func.coalesce(func.sum(amount_crc), 0)).where( Transaction.date >= cal_start, Transaction.date < cal_end, Transaction.source == source, @@ -188,7 +191,7 @@ def compute_actuals_by_source( ) ).one() devolucion = session.exec( - select(func.coalesce(func.sum(Transaction.amount), 0)).where( + select(func.coalesce(func.sum(amount_crc), 0)).where( Transaction.date >= cal_start, Transaction.date < cal_end, Transaction.source == source, @@ -230,6 +233,8 @@ def compute_actuals_by_category( prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m) cal_start, cal_end = get_month_range(year, month) + amount_crc = get_converted_amount_expr(session) + totals: dict[int, float] = {} def _merge_rows(rows: list) -> None: @@ -245,7 +250,7 @@ def compute_actuals_by_category( select( Transaction.category_id, Transaction.transaction_type, - func.sum(Transaction.amount), + func.sum(amount_crc), ) .where( Transaction.date >= cc_start, @@ -265,7 +270,7 @@ def compute_actuals_by_category( select( Transaction.category_id, Transaction.transaction_type, - func.sum(Transaction.amount), + func.sum(amount_crc), ) .where( Transaction.date >= prev_start, @@ -285,7 +290,7 @@ def compute_actuals_by_category( select( Transaction.category_id, Transaction.transaction_type, - func.sum(Transaction.amount), + func.sum(amount_crc), ) .where( Transaction.date >= cal_start, @@ -310,6 +315,8 @@ def compute_cc_by_category( prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m) prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m) + amount_crc = get_converted_amount_expr(session) + totals: dict[int | None, float] = {} def _merge(rows: list) -> None: @@ -325,7 +332,7 @@ def compute_cc_by_category( select( Transaction.category_id, Transaction.transaction_type, - func.sum(Transaction.amount), + func.sum(amount_crc), ) .where( Transaction.date >= cc_start, @@ -343,7 +350,7 @@ def compute_cc_by_category( select( Transaction.category_id, Transaction.transaction_type, - func.sum(Transaction.amount), + func.sum(amount_crc), ) .where( Transaction.date >= prev_start, @@ -449,6 +456,8 @@ def compute_monthly_projection( prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m) cal_start, cal_end = get_month_range(year, month) + amount_crc = get_converted_amount_expr(session) + def _sum_uncategorized(rows: list) -> float: total = 0.0 for tx_type, amount in rows: @@ -461,7 +470,7 @@ def compute_monthly_projection( # CC uncategorized: this cycle (not deferred) uncovered_actual += _sum_uncategorized( session.exec( - select(Transaction.transaction_type, func.sum(Transaction.amount)) + select(Transaction.transaction_type, func.sum(amount_crc)) .where( Transaction.date >= cc_start, Transaction.date < cc_end, @@ -476,7 +485,7 @@ def compute_monthly_projection( # CC uncategorized: deferred from previous cycle uncovered_actual += _sum_uncategorized( session.exec( - select(Transaction.transaction_type, func.sum(Transaction.amount)) + select(Transaction.transaction_type, func.sum(amount_crc)) .where( Transaction.date >= prev_start, Transaction.date < prev_end, @@ -491,7 +500,7 @@ def compute_monthly_projection( # Non-CC uncategorized: calendar month uncovered_actual += _sum_uncategorized( session.exec( - select(Transaction.transaction_type, func.sum(Transaction.amount)) + select(Transaction.transaction_type, func.sum(amount_crc)) .where( Transaction.date >= cal_start, Transaction.date < cal_end, diff --git a/backend/app/services/exchange_rate.py b/backend/app/services/exchange_rate.py index b507b1a..25b6f0a 100644 --- a/backend/app/services/exchange_rate.py +++ b/backend/app/services/exchange_rate.py @@ -1,12 +1,21 @@ +import asyncio +import logging import xml.etree.ElementTree as ET from datetime import datetime, timedelta import httpx +from sqlalchemy import case from sqlmodel import Session, col, select from app.config import settings +from app.db import engine from app.models.models import ExchangeRate +logger = logging.getLogger(__name__) + +# Scheduled refresh interval — 4x/day +REFRESH_INTERVAL_SECONDS = 6 * 3600 + # BCCR indicators: 317 = buy, 318 = sell BCCR_URL = "https://gee.bccr.fi.cr/Indicadores/Suscripciones/WS/wsindicadoreseconomicos.asmx/ObtenerIndicadoresEconomicos" @@ -23,9 +32,13 @@ _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 +# Generic X/CRC mid-market rate cache (by currency code) +_xcrc_cache: dict[str, tuple[float, datetime]] = {} +_last_known_xcrc: dict[str, float] = {} + +# CoinGecko ids for supported crypto codes +_COINGECKO_IDS = {"BTC": "bitcoin", "XMR": "monero"} +COINGECKO_URL = "https://api.coingecko.com/api/v3/simple/price" def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None: @@ -171,10 +184,10 @@ 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). +def _fetch_fiat_crc_mid(code: str) -> float | None: + """Derive {code}/CRC mid-market rate from ExchangeRate-API (USD-based). - EUR/CRC = CRC_per_USD / EUR_per_USD + X/CRC = CRC_per_USD / X_per_USD """ try: resp = httpx.get(EXCHANGERATE_API_URL, timeout=10) @@ -182,32 +195,175 @@ def _fetch_eur_crc_mid() -> float | None: 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) + x = data["rates"].get(code) + if crc and x: + return float(crc) / float(x) 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 +def _fetch_crypto_crc(code: str) -> float | None: + """Fetch {code}/CRC spot from CoinGecko.""" + coin_id = _COINGECKO_IDS.get(code) + if not coin_id: + return None + try: + resp = httpx.get( + COINGECKO_URL, + params={"ids": coin_id, "vs_currencies": "crc"}, + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + price = data.get(coin_id, {}).get("crc") + if price: + return float(price) + except Exception: + pass + return None - cached = _eur_crc_cache.get("current") + +def get_crc_rate(code: str) -> float | None: + """Get current {code}→CRC rate (cached 1 hour). Fiat via ExchangeRate-API, crypto via CoinGecko.""" + if code == "CRC": + return 1.0 + + cached = _xcrc_cache.get(code) if cached and datetime.utcnow() - cached[1] < CACHE_TTL: return cached[0] - rate = _fetch_eur_crc_mid() + if code in _COINGECKO_IDS: + rate = _fetch_crypto_crc(code) + else: + rate = _fetch_fiat_crc_mid(code) + if rate is not None: - _eur_crc_cache["current"] = (rate, datetime.utcnow()) - _last_known_eur_crc = rate + _xcrc_cache[code] = (rate, datetime.utcnow()) + _last_known_xcrc[code] = rate return rate - if _last_known_eur_crc: - return _last_known_eur_crc + return _last_known_xcrc.get(code) - return None + +def get_crc_multipliers(session: Session) -> dict[str, float]: + """Return {currency_code: CRC_multiplier} for every supported currency.""" + from app.models.models import Currency + + multipliers: dict[str, float] = {"CRC": 1.0} + + usd_rate = get_current_rate(session) + if usd_rate: + multipliers["USD"] = usd_rate.sell_rate + + for code in (c.value for c in Currency): + if code in multipliers: + continue + rate = get_crc_rate(code) + if rate is not None: + multipliers[code] = rate + + return multipliers + + +def get_converted_amount_expr(session: Session): + """Return a SQLAlchemy expression converting Transaction.amount to CRC. + + Builds a CASE that multiplies by the per-currency CRC rate; CRC passes through. + Missing rates fall back to 1.0 (treat as CRC) rather than 0.0 so a transient + API outage does not silently zero out foreign-currency totals. + """ + from app.models.models import Transaction + + multipliers = get_crc_multipliers(session) + whens = [ + (Transaction.currency == code, Transaction.amount * mult) + for code, mult in multipliers.items() + if code != "CRC" + ] + if not whens: + return Transaction.amount + return case(*whens, else_=Transaction.amount) + + +def _refresh_usd_rate() -> bool: + """Force-fetch USD/CRC from APIs and persist to DB. Returns True on success.""" + fetched = _fetch_rate_from_apis() + if fetched is None: + return False + buy, sell = fetched + with Session(engine) as session: + rate = ExchangeRate(date=datetime.utcnow(), buy_rate=buy, sell_rate=sell) + session.add(rate) + session.commit() + session.refresh(rate) + _remember(rate) + return True + + +def _refresh_other_rate(code: str) -> bool: + """Force-fetch {code}/CRC and update in-memory cache. Returns True on success.""" + if code in _COINGECKO_IDS: + rate = _fetch_crypto_crc(code) + else: + rate = _fetch_fiat_crc_mid(code) + if rate is None: + return False + _xcrc_cache[code] = (rate, datetime.utcnow()) + _last_known_xcrc[code] = rate + return True + + +def refresh_all_rates() -> dict[str, bool]: + """Force-refresh every supported currency. + + Each currency is refreshed independently — one failure does not affect others. + On success the DB (for USD) and in-memory caches are updated. On failure the + previous value is retained via `_last_known_*` / stale-DB fallback, so callers + always see the most recent working rate. + """ + from app.models.models import Currency + + results: dict[str, bool] = {} + + try: + results["USD"] = _refresh_usd_rate() + except Exception: + logger.exception("USD rate refresh failed") + results["USD"] = False + + for currency in Currency: + code = currency.value + if code in ("CRC", "USD"): + continue + try: + results[code] = _refresh_other_rate(code) + except Exception: + logger.exception("%s rate refresh failed", code) + results[code] = False + + return results + + +async def refresh_rates_periodically( + interval_seconds: int = REFRESH_INTERVAL_SECONDS, +) -> None: + """Background loop that refreshes all currency rates every `interval_seconds`. + + Never raises — failures are logged and the last-known rates are retained. + Runs one refresh immediately on startup, then sleeps on the fixed interval. + """ + while True: + try: + report = await asyncio.to_thread(refresh_all_rates) + ok = sorted(k for k, v in report.items() if v) + failed = sorted(k for k, v in report.items() if not v) + logger.info( + "Exchange rate refresh complete: ok=%s failed=%s", ok, failed + ) + except Exception: + logger.exception("Exchange rate refresh loop crashed") + await asyncio.sleep(interval_seconds) def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]: