"""Month-frozen FX resolution for the unified spend ledger. The platform's :mod:`app.core.fx_rate` resolves a single *live* CNY/USD rate (refreshed weekly). That is correct for ingest-time conversion (cloud cost USD is frozen per-fact at ingest), but the finance ledger normalises *historical* native-currency spend to USD and must use the rate that was true **in the spend's own month** — otherwise May's software bill silently re-values every time the live rate moves, and a closed period's USD total drifts. Accounting reconciles against the period rate, frozen. This module persists per-month rates in ``finance_fx_monthly`` and resolves them with this precedence: 1. A frozen ``finance_fx_monthly`` row for ``(period, currency)``. 2. For ``USD`` → always 1.0 (no row needed). 3. The open/current month with no frozen row yet → the live platform rate (:func:`app.core.fx_rate.get_usd_per_cny`), recorded as a ``live_fallback`` row and flagged ``fx_basis='live'`` on the spend fact so the dashboard can mark it provisional. Historical backfill pulls the month's last-published ECB rate from frankfurter.dev (same source as the weekly cron), so a fresh deploy can seed accurate month rates for every period that already has spend. """ from __future__ import annotations import calendar from dataclasses import dataclass from datetime import UTC, date, datetime from decimal import Decimal import httpx from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.fx_rate import get_usd_per_cny from app.core.safe_logging import get_safe_logger from app.domains.finance.models import FinanceFxMonthly logger = get_safe_logger(__name__) # frankfurter.dev historical endpoint — same provider as the weekly # ``ai_spending.cny_rate_refresh`` cron. ``/v1/{date}?base=CNY&symbols=USD`` # returns ``rates.USD`` = USD per 1 CNY for the most recent publish on or # before ``date`` (ECB doesn't publish on weekends/holidays; frankfurter # returns the prior business day, which is exactly what we want for a # month-end rate). _FX_HIST_BASE = "https://api.frankfurter.dev/v1" # Plausibility band for USD-per-CNY, matching app.core.fx_rate. _USD_PER_CNY_MIN = Decimal("0.05") _USD_PER_CNY_MAX = Decimal("1.0") # Generic floor for any currency's USD-per-unit — rejects 0 / negative / # absurd values from a bad fetch or fat-fingered manual entry. JPY # (~0.0064) is the lowest mainstream currency we'd plausibly see, so the # floor sits an order of magnitude below it. _GENERIC_MIN = Decimal("0.0001") _GENERIC_MAX = Decimal("1000") @dataclass(slots=True) class FxResolution: """Resolved month rate + how it was sourced.""" usd_per_unit: Decimal # 'native_usd' (currency==USD) | 'month_frozen' | 'live' fx_basis: str frozen: bool def month_last_day(period: str) -> date: """Last calendar day of a ``YYYY-MM`` period.""" year, month = (int(x) for x in period.split("-")) last = calendar.monthrange(year, month)[1] return date(year, month, last) def _current_period() -> str: """Current ``YYYY-MM`` in UTC. The ledger keys on calendar month; UTC is the canonical clock for "is this period still open" (cron cadence is UTC-anchored). Spend facts themselves carry the source domain's own period semantics. """ now = datetime.now(UTC) return f"{now.year:04d}-{now.month:02d}" def _clamp_ok(currency: str, rate: Decimal) -> bool: cur = currency.upper() if cur == "CNY": return _USD_PER_CNY_MIN <= rate <= _USD_PER_CNY_MAX return _GENERIC_MIN <= rate <= _GENERIC_MAX async def get_monthly_row( db: AsyncSession, currency: str, period: str, ) -> FinanceFxMonthly | None: return ( await db.execute( select(FinanceFxMonthly).where( FinanceFxMonthly.period == period, FinanceFxMonthly.currency == currency.upper(), ) ) ).scalar_one_or_none() async def resolve_usd_per_unit( db: AsyncSession, currency: str, period: str, ) -> FxResolution: """Resolve the USD-per-unit rate to use for ``currency`` in ``period``. Never raises — a missing rate for a non-USD currency in a historical month falls through to the live platform rate (basis ``live``) so a rollup of a not-yet-backfilled month still produces a USD figure (flagged provisional) rather than a hard failure that zeroes the period. The monthly backfill cron fills the frozen row on its next run and the next rollup picks it up. """ cur = currency.upper() if cur == "USD": return FxResolution(Decimal("1"), "native_usd", True) row = await get_monthly_row(db, cur, period) if row is not None and _clamp_ok(cur, Decimal(str(row.usd_per_unit))): rate = Decimal(str(row.usd_per_unit)) return FxResolution( rate, "month_frozen" if row.frozen else "live", bool(row.frozen), ) # No usable frozen row. For CNY (the platform's primary pair) use the # live platform rate first — it's the freshest CNY source and avoids a # network hop on the hot path. if cur == "CNY": live = await get_usd_per_cny(db) if live is not None: return FxResolution(Decimal(str(live)), "live", False) # Any currency with no frozen row and no platform rate (every non-CNY # currency, and CNY when the platform rate is stale): best-effort fetch # this month's rate from frankfurter and PERSIST it as a live_fallback # row. Persisting means the rest of this rollup run reads it back from # the row (natural per-run cache → one fetch per currency, not per # row), and a later FX-refresh cron upgrades it to a frozen rate. # Critically this replaces the old 1.0 sentinel, which booked e.g. # ¥1000 as $1000 (~156× over) into the headline USD total for any # non-CNY plan in the window before the weekly FX cron backfilled it. fetched = await fetch_rate_from_frankfurter(cur, period) if fetched is not None: await upsert_monthly_rate( db, cur, period, fetched, source="frankfurter", frozen=False, note="live fallback (resolved during rollup)", ) return FxResolution(fetched, "live", False) # Genuinely unresolvable (frankfurter unreachable AND no stored/platform # rate). Leave the amount unconverted (1.0) and flag basis 'live' so it # is visibly provisional rather than a hard failure that zeroes the # period; the next FX-refresh / period-close backfills the real rate. logger.warning( "finance.fx: no monthly rate for %s %s and no live/frankfurter " "fallback; leaving amount unconverted (usd_per_unit=1.0, " "basis=live). Backfill via finance.fx_monthly_refresh or pin.", cur, period, ) return FxResolution(Decimal("1"), "live", False) async def upsert_monthly_rate( db: AsyncSession, currency: str, period: str, usd_per_unit: Decimal, *, source: str, frozen: bool, note: str | None = None, ) -> FinanceFxMonthly: """Insert or update the ``(period, currency)`` rate row. A ``frozen`` row is never overwritten by a non-frozen (live_fallback) write — once a month is finalised its rate is authoritative. A frozen write always wins (manual pin / month-close backfill). """ cur = currency.upper() row = await get_monthly_row(db, cur, period) if row is None: row = FinanceFxMonthly( period=period, currency=cur, usd_per_unit=usd_per_unit, source=source, frozen=frozen, note=note, ) db.add(row) return row if row.frozen and not frozen: # Don't let a live fallback clobber a finalised rate. return row row.usd_per_unit = usd_per_unit row.source = source row.frozen = frozen if note is not None: row.note = note return row async def fetch_rate_from_frankfurter( currency: str, period: str, ) -> Decimal | None: """Fetch USD-per-unit for ``currency`` on ``period``'s last day. Returns ``None`` on any fetch / parse / plausibility failure — the caller decides whether that's a soft-skip (cron) or leaves the live fallback in place. Never raises on a network error. """ cur = currency.upper() if cur == "USD": return Decimal("1") on = month_last_day(period).isoformat() url = f"{_FX_HIST_BASE}/{on}?base={cur}&symbols=USD" try: async with httpx.AsyncClient(timeout=20, trust_env=False) as client: resp = await client.get(url) resp.raise_for_status() data = resp.json() except (httpx.HTTPError, ValueError) as exc: logger.warning("finance.fx: frankfurter fetch failed for %s: %s", url, exc) return None usd = (data.get("rates") or {}).get("USD") if usd is None: logger.warning("finance.fx: frankfurter response missing rates.USD for %s", url) return None try: rate = Decimal(str(usd)) except (TypeError, ValueError): logger.warning("finance.fx: frankfurter rates.USD not numeric: %r", usd) return None if not _clamp_ok(cur, rate): logger.warning( "finance.fx: fetched %s %s rate %s outside plausibility band; " "rejected.", cur, period, rate, ) return None return rate