import { Logger } from "@nestjs/common"; import { EXCHANGE_DEFAULTS, ExchangeOptions, ResolvedExchangeOptions, } from "./exchange.options"; import { CurrencyPair, ExchangeRateProvider, } from "./exchange.types"; /** One currency's rates within a daily record returned by the CBE endpoint. */ interface CbeExchangeRateEntry { transactionalSelling?: number | string | null; transactionalBuying?: number | string | null; currency?: { CurrencyCode?: string | null } | null; } /** A single day's record from the CBE `daily-exchange-rates` endpoint. */ interface CbeDailyRecord { Date?: string | null; ExchangeRate?: CbeExchangeRateEntry[] | null; } /** Where the most recently served rate came from. */ export type CbeRateSource = "live" | "cache" | "stored" | "default"; /** Health of the CBE feed, for operator-facing status displays. */ export interface CbeProviderStatus { /** The rate most recently served, whatever its source. */ rate: number | null; /** Where that rate came from. `live` means the API answered. */ source: CbeRateSource | null; /** Epoch ms of the last successful live fetch, or `null` if never. */ lastSuccessAt: number | null; /** Message from the most recent failed fetch, cleared on success. */ lastError: string | null; } /** * Commercial Bank of Ethiopia (CBE) rate provider. * * Sources a single canonical direction — **USD→ETB** (transactional selling * rate) — from CBE's public `daily-exchange-rates` JSON endpoint, caching the * result and falling back to a configured rate when the fetch fails. The * inverse (ETB→USD) is derived by {@link ExchangeService}, so this provider * only ever reports USD→ETB. */ export class CbeExchangeProvider implements ExchangeRateProvider { readonly name = "CBE"; private readonly logger = new Logger(CbeExchangeProvider.name); private readonly options: ResolvedExchangeOptions; private cachedRate: number | null = null; private cacheExpiresAt = 0; private lastSuccessAt: number | null = null; private lastError: string | null = null; private lastSource: CbeRateSource | null = null; constructor(options: ExchangeOptions) { this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) }; } async getBaseRate(pair: CurrencyPair): Promise { // CBE only sources USD→ETB; everything else is derived upstream. if (pair.from !== "USD" || pair.to !== "ETB") { return null; } return this.getUsdToEtbRate(); } /** Health of the CBE feed — what was served last, and whether it is failing. */ getStatus(): CbeProviderStatus { return { rate: this.cachedRate, source: this.lastSource, lastSuccessAt: this.lastSuccessAt, lastError: this.lastError, }; } /** * Returns the current CBE USD→ETB **transactional selling** rate. * * Cached for `cacheTtlMs`. On a successful fetch the rate is written back via * `saveFallbackRate`, so the stored fallback is never more than one good * fetch stale. On failure the chain is: cached rate → `loadFallbackRate()` * → static `fallbackRate`. */ private async getUsdToEtbRate(): Promise { const now = Date.now(); if (this.cachedRate !== null && now < this.cacheExpiresAt) { this.lastSource = "cache"; return this.cachedRate; } const { scrapeUrl, fallbackRate, cacheTtlMs, requestTimeoutMs } = this.options; try { const response = await fetch(scrapeUrl, { signal: AbortSignal.timeout(requestTimeoutMs), headers: { Accept: "application/json", "User-Agent": "Mozilla/5.0" }, }); if (!response.ok) { throw new Error(`CBE rates responded with status ${response.status}`); } const payload = (await response.json()) as unknown; const day = this.latestRecord(payload); if (!day) { throw new Error("CBE rates payload contained no daily record"); } const rate = this.parseUsdRate(day); if (rate === null) { throw new Error( `USD transactionalSelling not found in CBE record for ${day.Date ?? "unknown date"}`, ); } const previous = this.cachedRate; this.cachedRate = rate; this.cacheExpiresAt = now + cacheTtlMs; this.lastSuccessAt = now; this.lastError = null; this.lastSource = "live"; this.logger.log( `CBE USD→ETB rate refreshed — transactionalSelling=${rate} (date=${day.Date ?? "unknown"})`, ); // Persist as the new fallback so a later outage reuses the last good // rate. Skipped when unchanged, to avoid pointless writes and audit noise. if (rate !== previous) { await this.persistFallback(rate); } return rate; } catch (err) { const message = (err as Error).message; this.lastError = message; this.logger.error(`Failed to fetch CBE exchange rate. Error: ${message}`); if (this.cachedRate !== null) { this.lastSource = "cache"; this.logger.warn( `Using previously cached CBE rate: ${this.cachedRate}`, ); return this.cachedRate; } const stored = await this.loadStoredFallback(); if (stored !== null) { this.lastSource = "stored"; this.logger.warn(`Using stored fallback CBE rate: ${stored}`); return stored; } this.lastSource = "default"; this.logger.warn(`Using default fallback CBE rate: ${fallbackRate}`); return fallbackRate; } } /** * Writes a freshly fetched rate back as the stored fallback. Failures are * logged and swallowed: persisting the fallback is housekeeping, and must * never fail the pricing call that triggered it. */ private async persistFallback(rate: number): Promise { const { saveFallbackRate } = this.options; if (!saveFallbackRate) return; try { await saveFallbackRate(rate); } catch (err) { this.logger.warn( `Failed to persist CBE fallback rate ${rate}: ${(err as Error).message}`, ); } } /** * Reads the persisted fallback. Returns `null` — falling through to the * static default — when unconfigured, unusable, or itself failing. */ private async loadStoredFallback(): Promise { const { loadFallbackRate } = this.options; if (!loadFallbackRate) return null; try { const stored = await loadFallbackRate(); const rate = Number(stored); return Number.isFinite(rate) && rate > 0 ? rate : null; } catch (err) { this.logger.warn( `Failed to load stored CBE fallback rate: ${(err as Error).message}`, ); return null; } } /** * The endpoint returns an array of daily records (one when `_limit=1`), but * tolerate a bare object in case the shape changes. */ private latestRecord(payload: unknown): CbeDailyRecord | null { const record = Array.isArray(payload) ? payload[0] : payload; return record && typeof record === "object" ? (record as CbeDailyRecord) : null; } /** * Pulls USD `transactionalSelling` out of a daily record. Returns `null` when * the entry is missing or the value isn't a usable positive number — CBE * publishes `0`/`null` for currencies it isn't quoting that day. */ private parseUsdRate(day: CbeDailyRecord): number | null { const usd = day.ExchangeRate?.find( (entry) => entry?.currency?.CurrencyCode === "USD", ); if (!usd) return null; const rate = Number(usd.transactionalSelling); return Number.isFinite(rate) && rate > 0 ? rate : null; } } /** Drops keys whose value is `undefined` so they don't override defaults via spread. */ function stripUndefined(options: ExchangeOptions): ExchangeOptions { return Object.fromEntries( Object.entries(options).filter(([, value]) => value !== undefined), ); }