mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 07:28:47 +02:00
Convert all currencies to CRC and poll rates every 6h
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s
Budget/transactions/salarios totals summed Transaction.amount directly,
so USD/EUR entries were treated as CRC and effectively disappeared from
the dashboard (the analytics fix in 9a80f2a only covered analytics).
Adds a shared get_converted_amount_expr() helper driven by the full
Currency enum — USD/EUR via ExchangeRate-API, BTC/XMR via CoinGecko —
and wires it into every func.sum(Transaction.amount) site.
Also starts a background task in the FastAPI lifespan that force-refreshes
every currency 4x/day, persisting USD to the DB and updating in-memory
caches for the rest. Failures are swallowed per-currency so a CoinGecko
outage cannot take out USD/EUR, and the last-known rate is always retained.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user