mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 10:28:48 +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.db import get_session
|
||||||
from app.models.models import Category, Transaction
|
from app.models.models import Category, Transaction
|
||||||
from app.services.budget_projection import get_cycle_range
|
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"])
|
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||||
|
|
||||||
@@ -38,17 +38,6 @@ class DailySpending(BaseModel):
|
|||||||
count: int
|
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])
|
@router.get("/by-category", response_model=list[CategorySpending])
|
||||||
def spending_by_category(
|
def spending_by_category(
|
||||||
cycle_year: Optional[int] = None,
|
cycle_year: Optional[int] = None,
|
||||||
@@ -56,18 +45,12 @@ def spending_by_category(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
rates = _get_crc_multipliers(session)
|
amount_crc = get_converted_amount_expr(session)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(
|
select(
|
||||||
Transaction.category_id,
|
Transaction.category_id,
|
||||||
func.sum(
|
func.sum(amount_crc).label("total"),
|
||||||
case(
|
|
||||||
(Transaction.currency == "USD", Transaction.amount * rates["USD"]),
|
|
||||||
(Transaction.currency == "EUR", Transaction.amount * rates["EUR"]),
|
|
||||||
else_=Transaction.amount,
|
|
||||||
)
|
|
||||||
).label("total"),
|
|
||||||
func.count().label("count"),
|
func.count().label("count"),
|
||||||
)
|
)
|
||||||
.where(Transaction.transaction_type == "COMPRA")
|
.where(Transaction.transaction_type == "COMPRA")
|
||||||
@@ -113,7 +96,7 @@ def monthly_trend(
|
|||||||
total_crc includes all currencies converted to CRC at current rates.
|
total_crc includes all currencies converted to CRC at current rates.
|
||||||
total_usd is the raw USD amount (unconverted) for display purposes.
|
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()
|
now = datetime.now()
|
||||||
results = []
|
results = []
|
||||||
month_names = [
|
month_names = [
|
||||||
@@ -128,16 +111,7 @@ def monthly_trend(
|
|||||||
row = session.exec(
|
row = session.exec(
|
||||||
select(
|
select(
|
||||||
func.count(),
|
func.count(),
|
||||||
func.coalesce(
|
func.coalesce(func.sum(amount_crc), 0),
|
||||||
func.sum(
|
|
||||||
case(
|
|
||||||
(Transaction.currency == "USD", Transaction.amount * rates["USD"]),
|
|
||||||
(Transaction.currency == "EUR", Transaction.amount * rates["EUR"]),
|
|
||||||
else_=Transaction.amount,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
func.coalesce(
|
func.coalesce(
|
||||||
func.sum(
|
func.sum(
|
||||||
case(
|
case(
|
||||||
@@ -189,18 +163,12 @@ def daily_spending(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
rates = _get_crc_multipliers(session)
|
amount_crc = get_converted_amount_expr(session)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(
|
select(
|
||||||
func.date(Transaction.date).label("day"),
|
func.date(Transaction.date).label("day"),
|
||||||
func.sum(
|
func.sum(amount_crc).label("total"),
|
||||||
case(
|
|
||||||
(Transaction.currency == "USD", Transaction.amount * rates["USD"]),
|
|
||||||
(Transaction.currency == "EUR", Transaction.amount * rates["EUR"]),
|
|
||||||
else_=Transaction.amount,
|
|
||||||
)
|
|
||||||
).label("total"),
|
|
||||||
func.count().label("count"),
|
func.count().label("count"),
|
||||||
)
|
)
|
||||||
.where(Transaction.transaction_type == "COMPRA")
|
.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.auth import get_current_user
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models.models import Transaction, TransactionRead, TransactionType
|
from app.models.models import Transaction, TransactionRead, TransactionType
|
||||||
|
from app.services.exchange_rate import get_converted_amount_expr
|
||||||
|
|
||||||
router = APIRouter(prefix="/salarios", tags=["salarios"])
|
router = APIRouter(prefix="/salarios", tags=["salarios"])
|
||||||
|
|
||||||
@@ -40,10 +41,11 @@ def salarios_summary(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
amount_crc = get_converted_amount_expr(session)
|
||||||
result = session.exec(
|
result = session.exec(
|
||||||
select(
|
select(
|
||||||
func.count(),
|
func.count(),
|
||||||
func.coalesce(func.sum(Transaction.amount), 0),
|
func.coalesce(func.sum(amount_crc), 0),
|
||||||
func.max(Transaction.date),
|
func.max(Transaction.date),
|
||||||
).where(Transaction.transaction_type == TransactionType.DEPOSITO)
|
).where(Transaction.transaction_type == TransactionType.DEPOSITO)
|
||||||
).first()
|
).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.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"])
|
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
||||||
|
|
||||||
@@ -110,6 +111,7 @@ def list_billing_cycles(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
min_date, max_date = result
|
min_date, max_date = result
|
||||||
|
amount_crc = get_converted_amount_expr(session)
|
||||||
|
|
||||||
cycles = []
|
cycles = []
|
||||||
# Determine which cycle the min_date falls into
|
# Determine which cycle the min_date falls into
|
||||||
@@ -129,7 +131,7 @@ def list_billing_cycles(
|
|||||||
|
|
||||||
# Count transactions in this cycle
|
# Count transactions in this cycle
|
||||||
count_result = session.exec(
|
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
|
Transaction.date >= start, Transaction.date < end
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -7,6 +8,7 @@ from app.api.v1.router import api_router
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import init_db, run_migrations
|
from app.db import init_db, run_migrations
|
||||||
from app.seed import seed_db
|
from app.seed import seed_db
|
||||||
|
from app.services.exchange_rate import refresh_rates_periodically
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -14,7 +16,15 @@ async def lifespan(app: FastAPI):
|
|||||||
init_db()
|
init_db()
|
||||||
run_migrations()
|
run_migrations()
|
||||||
seed_db()
|
seed_db()
|
||||||
|
rate_refresh_task = asyncio.create_task(refresh_rates_periodically())
|
||||||
|
try:
|
||||||
yield
|
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)
|
app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.models.models import (
|
|||||||
TransactionSource,
|
TransactionSource,
|
||||||
TransactionType,
|
TransactionType,
|
||||||
)
|
)
|
||||||
|
from app.services.exchange_rate import get_converted_amount_expr
|
||||||
|
|
||||||
MIN_YEAR = 2026
|
MIN_YEAR = 2026
|
||||||
MAX_YEAR = 2030
|
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)
|
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
|
||||||
cal_start, cal_end = get_month_range(year, month)
|
cal_start, cal_end = get_month_range(year, month)
|
||||||
|
|
||||||
|
amount_crc = get_converted_amount_expr(session)
|
||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
for source in TransactionSource:
|
for source in TransactionSource:
|
||||||
if source == TransactionSource.CREDIT_CARD:
|
if source == TransactionSource.CREDIT_CARD:
|
||||||
start, end = cc_start, cc_end
|
start, end = cc_start, cc_end
|
||||||
# Normal transactions in this cycle (not deferred)
|
# Normal transactions in this cycle (not deferred)
|
||||||
compra_normal = session.exec(
|
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 >= start,
|
||||||
Transaction.date < end,
|
Transaction.date < end,
|
||||||
Transaction.source == source,
|
Transaction.source == source,
|
||||||
@@ -120,7 +123,7 @@ def compute_actuals_by_source(
|
|||||||
).one()
|
).one()
|
||||||
# Deferred from previous cycle
|
# Deferred from previous cycle
|
||||||
compra_deferred = session.exec(
|
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_start,
|
||||||
Transaction.date < prev_end,
|
Transaction.date < prev_end,
|
||||||
Transaction.source == source,
|
Transaction.source == source,
|
||||||
@@ -131,7 +134,7 @@ def compute_actuals_by_source(
|
|||||||
compra = float(compra_normal) + float(compra_deferred)
|
compra = float(compra_normal) + float(compra_deferred)
|
||||||
|
|
||||||
dev_normal = session.exec(
|
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 >= start,
|
||||||
Transaction.date < end,
|
Transaction.date < end,
|
||||||
Transaction.source == source,
|
Transaction.source == source,
|
||||||
@@ -140,7 +143,7 @@ def compute_actuals_by_source(
|
|||||||
)
|
)
|
||||||
).one()
|
).one()
|
||||||
dev_deferred = session.exec(
|
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_start,
|
||||||
Transaction.date < prev_end,
|
Transaction.date < prev_end,
|
||||||
Transaction.source == source,
|
Transaction.source == source,
|
||||||
@@ -180,7 +183,7 @@ def compute_actuals_by_source(
|
|||||||
else:
|
else:
|
||||||
# Cash / Transfer: calendar month, no deferred logic
|
# Cash / Transfer: calendar month, no deferred logic
|
||||||
compra = session.exec(
|
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_start,
|
||||||
Transaction.date < cal_end,
|
Transaction.date < cal_end,
|
||||||
Transaction.source == source,
|
Transaction.source == source,
|
||||||
@@ -188,7 +191,7 @@ def compute_actuals_by_source(
|
|||||||
)
|
)
|
||||||
).one()
|
).one()
|
||||||
devolucion = session.exec(
|
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_start,
|
||||||
Transaction.date < cal_end,
|
Transaction.date < cal_end,
|
||||||
Transaction.source == source,
|
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)
|
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
|
||||||
cal_start, cal_end = get_month_range(year, month)
|
cal_start, cal_end = get_month_range(year, month)
|
||||||
|
|
||||||
|
amount_crc = get_converted_amount_expr(session)
|
||||||
|
|
||||||
totals: dict[int, float] = {}
|
totals: dict[int, float] = {}
|
||||||
|
|
||||||
def _merge_rows(rows: list) -> None:
|
def _merge_rows(rows: list) -> None:
|
||||||
@@ -245,7 +250,7 @@ def compute_actuals_by_category(
|
|||||||
select(
|
select(
|
||||||
Transaction.category_id,
|
Transaction.category_id,
|
||||||
Transaction.transaction_type,
|
Transaction.transaction_type,
|
||||||
func.sum(Transaction.amount),
|
func.sum(amount_crc),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Transaction.date >= cc_start,
|
Transaction.date >= cc_start,
|
||||||
@@ -265,7 +270,7 @@ def compute_actuals_by_category(
|
|||||||
select(
|
select(
|
||||||
Transaction.category_id,
|
Transaction.category_id,
|
||||||
Transaction.transaction_type,
|
Transaction.transaction_type,
|
||||||
func.sum(Transaction.amount),
|
func.sum(amount_crc),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Transaction.date >= prev_start,
|
Transaction.date >= prev_start,
|
||||||
@@ -285,7 +290,7 @@ def compute_actuals_by_category(
|
|||||||
select(
|
select(
|
||||||
Transaction.category_id,
|
Transaction.category_id,
|
||||||
Transaction.transaction_type,
|
Transaction.transaction_type,
|
||||||
func.sum(Transaction.amount),
|
func.sum(amount_crc),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Transaction.date >= cal_start,
|
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_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)
|
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] = {}
|
totals: dict[int | None, float] = {}
|
||||||
|
|
||||||
def _merge(rows: list) -> None:
|
def _merge(rows: list) -> None:
|
||||||
@@ -325,7 +332,7 @@ def compute_cc_by_category(
|
|||||||
select(
|
select(
|
||||||
Transaction.category_id,
|
Transaction.category_id,
|
||||||
Transaction.transaction_type,
|
Transaction.transaction_type,
|
||||||
func.sum(Transaction.amount),
|
func.sum(amount_crc),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Transaction.date >= cc_start,
|
Transaction.date >= cc_start,
|
||||||
@@ -343,7 +350,7 @@ def compute_cc_by_category(
|
|||||||
select(
|
select(
|
||||||
Transaction.category_id,
|
Transaction.category_id,
|
||||||
Transaction.transaction_type,
|
Transaction.transaction_type,
|
||||||
func.sum(Transaction.amount),
|
func.sum(amount_crc),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Transaction.date >= prev_start,
|
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)
|
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
|
||||||
cal_start, cal_end = get_month_range(year, month)
|
cal_start, cal_end = get_month_range(year, month)
|
||||||
|
|
||||||
|
amount_crc = get_converted_amount_expr(session)
|
||||||
|
|
||||||
def _sum_uncategorized(rows: list) -> float:
|
def _sum_uncategorized(rows: list) -> float:
|
||||||
total = 0.0
|
total = 0.0
|
||||||
for tx_type, amount in rows:
|
for tx_type, amount in rows:
|
||||||
@@ -461,7 +470,7 @@ def compute_monthly_projection(
|
|||||||
# CC uncategorized: this cycle (not deferred)
|
# CC uncategorized: this cycle (not deferred)
|
||||||
uncovered_actual += _sum_uncategorized(
|
uncovered_actual += _sum_uncategorized(
|
||||||
session.exec(
|
session.exec(
|
||||||
select(Transaction.transaction_type, func.sum(Transaction.amount))
|
select(Transaction.transaction_type, func.sum(amount_crc))
|
||||||
.where(
|
.where(
|
||||||
Transaction.date >= cc_start,
|
Transaction.date >= cc_start,
|
||||||
Transaction.date < cc_end,
|
Transaction.date < cc_end,
|
||||||
@@ -476,7 +485,7 @@ def compute_monthly_projection(
|
|||||||
# CC uncategorized: deferred from previous cycle
|
# CC uncategorized: deferred from previous cycle
|
||||||
uncovered_actual += _sum_uncategorized(
|
uncovered_actual += _sum_uncategorized(
|
||||||
session.exec(
|
session.exec(
|
||||||
select(Transaction.transaction_type, func.sum(Transaction.amount))
|
select(Transaction.transaction_type, func.sum(amount_crc))
|
||||||
.where(
|
.where(
|
||||||
Transaction.date >= prev_start,
|
Transaction.date >= prev_start,
|
||||||
Transaction.date < prev_end,
|
Transaction.date < prev_end,
|
||||||
@@ -491,7 +500,7 @@ def compute_monthly_projection(
|
|||||||
# Non-CC uncategorized: calendar month
|
# Non-CC uncategorized: calendar month
|
||||||
uncovered_actual += _sum_uncategorized(
|
uncovered_actual += _sum_uncategorized(
|
||||||
session.exec(
|
session.exec(
|
||||||
select(Transaction.transaction_type, func.sum(Transaction.amount))
|
select(Transaction.transaction_type, func.sum(amount_crc))
|
||||||
.where(
|
.where(
|
||||||
Transaction.date >= cal_start,
|
Transaction.date >= cal_start,
|
||||||
Transaction.date < cal_end,
|
Transaction.date < cal_end,
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from sqlalchemy import case
|
||||||
from sqlmodel import Session, col, select
|
from sqlmodel import Session, col, select
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.db import engine
|
||||||
from app.models.models import ExchangeRate
|
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 indicators: 317 = buy, 318 = sell
|
||||||
BCCR_URL = "https://gee.bccr.fi.cr/Indicadores/Suscripciones/WS/wsindicadoreseconomicos.asmx/ObtenerIndicadoresEconomicos"
|
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
|
_last_known: ExchangeRate | None = None # survives cache expiry — always holds the last successful rate
|
||||||
CACHE_TTL = timedelta(hours=1)
|
CACHE_TTL = timedelta(hours=1)
|
||||||
|
|
||||||
# EUR/CRC mid-market rate cache
|
# Generic X/CRC mid-market rate cache (by currency code)
|
||||||
_eur_crc_cache: dict[str, tuple[float, datetime]] = {}
|
_xcrc_cache: dict[str, tuple[float, datetime]] = {}
|
||||||
_last_known_eur_crc: float | None = None
|
_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:
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _fetch_eur_crc_mid() -> float | None:
|
def _fetch_fiat_crc_mid(code: str) -> float | None:
|
||||||
"""Derive EUR/CRC mid-market rate from ExchangeRate-API (USD-based).
|
"""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:
|
try:
|
||||||
resp = httpx.get(EXCHANGERATE_API_URL, timeout=10)
|
resp = httpx.get(EXCHANGERATE_API_URL, timeout=10)
|
||||||
@@ -182,32 +195,175 @@ def _fetch_eur_crc_mid() -> float | None:
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
if data.get("result") == "success":
|
if data.get("result") == "success":
|
||||||
crc = data["rates"].get("CRC")
|
crc = data["rates"].get("CRC")
|
||||||
eur = data["rates"].get("EUR")
|
x = data["rates"].get(code)
|
||||||
if crc and eur:
|
if crc and x:
|
||||||
return float(crc) / float(eur)
|
return float(crc) / float(x)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_eur_crc_rate() -> float | None:
|
def _fetch_crypto_crc(code: str) -> float | None:
|
||||||
"""Get current EUR→CRC mid-market rate (cached 1 hour)."""
|
"""Fetch {code}/CRC spot from CoinGecko."""
|
||||||
global _last_known_eur_crc
|
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:
|
if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
|
||||||
return cached[0]
|
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:
|
if rate is not None:
|
||||||
_eur_crc_cache["current"] = (rate, datetime.utcnow())
|
_xcrc_cache[code] = (rate, datetime.utcnow())
|
||||||
_last_known_eur_crc = rate
|
_last_known_xcrc[code] = rate
|
||||||
return rate
|
return rate
|
||||||
|
|
||||||
if _last_known_eur_crc:
|
return _last_known_xcrc.get(code)
|
||||||
return _last_known_eur_crc
|
|
||||||
|
|
||||||
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]:
|
def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]:
|
||||||
|
|||||||
Reference in New Issue
Block a user