mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:48:48 +02:00
Convert USD and EUR to CRC in analytics endpoints
All checks were successful
Deploy to VPS / deploy (push) Successful in 13s
All checks were successful
Deploy to VPS / deploy (push) Successful in 13s
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) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +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
|
||||||
|
|
||||||
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||||
|
|
||||||
@@ -37,6 +38,17 @@ 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,
|
||||||
@@ -44,10 +56,18 @@ 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)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(
|
select(
|
||||||
Transaction.category_id,
|
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"),
|
func.count().label("count"),
|
||||||
)
|
)
|
||||||
.where(Transaction.transaction_type == "COMPRA")
|
.where(Transaction.transaction_type == "COMPRA")
|
||||||
@@ -88,7 +108,12 @@ def monthly_trend(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
_user: str = Depends(get_current_user),
|
_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()
|
now = datetime.now()
|
||||||
results = []
|
results = []
|
||||||
month_names = [
|
month_names = [
|
||||||
@@ -106,8 +131,9 @@ def monthly_trend(
|
|||||||
func.coalesce(
|
func.coalesce(
|
||||||
func.sum(
|
func.sum(
|
||||||
case(
|
case(
|
||||||
(Transaction.currency == "CRC", Transaction.amount),
|
(Transaction.currency == "USD", Transaction.amount * rates["USD"]),
|
||||||
else_=0,
|
(Transaction.currency == "EUR", Transaction.amount * rates["EUR"]),
|
||||||
|
else_=Transaction.amount,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
0,
|
0,
|
||||||
@@ -163,10 +189,18 @@ 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)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(
|
select(
|
||||||
func.date(Transaction.date).label("day"),
|
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"),
|
func.count().label("count"),
|
||||||
)
|
)
|
||||||
.where(Transaction.transaction_type == "COMPRA")
|
.where(Transaction.transaction_type == "COMPRA")
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ _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
|
||||||
|
_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:
|
def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
|
||||||
"""Fetch a single indicator from BCCR API."""
|
"""Fetch a single indicator from BCCR API."""
|
||||||
@@ -167,6 +171,45 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
|
|||||||
return 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]:
|
def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]:
|
||||||
"""Get historical exchange rates."""
|
"""Get historical exchange rates."""
|
||||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||||
|
|||||||
Reference in New Issue
Block a user