import { Logger } from "@nestjs/common"; import { EXCHANGE_DEFAULTS, ExchangeOptions } from "./exchange.options"; import { CurrencyPair, ExchangeRateProvider, } from "./exchange.types"; /** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */ const USD_RATE_REGEX = /currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/; /** * Central Bank of Ethiopia (CBE) rate provider. * * Sources a single canonical direction — **USD→ETB** (selling rate) — by * scraping ethio.forex, caching the result, and falling back to a configured * rate when the scrape 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: Required; private cachedRate: number | null = null; private cacheExpiresAt = 0; 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(); } /** * Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex. * Cached for `cacheTtlMs`; on failure reuses the last cached rate, else * returns `fallbackRate`. */ private async getUsdToEtbRate(): Promise { const now = Date.now(); if (this.cachedRate !== null && now < this.cacheExpiresAt) { return this.cachedRate; } const { scrapeUrl, fallbackRate, cacheTtlMs, requestTimeoutMs } = this.options; try { const response = await fetch(scrapeUrl, { signal: AbortSignal.timeout(requestTimeoutMs), headers: { "User-Agent": "Mozilla/5.0" }, }); if (!response.ok) { throw new Error(`CBE scrape responded with status ${response.status}`); } const html = await response.text(); const rates = this.parseScrapedRates(html); if (!rates) { throw new Error("USD rate not found in ethio.forex page HTML"); } const rate = rates.selling; if (!Number.isFinite(rate) || rate <= 0) { throw new Error(`Invalid selling rate parsed: ${rate}`); } this.cachedRate = rate; this.cacheExpiresAt = now + cacheTtlMs; this.logger.log( `CBE USD→ETB rate refreshed from ethio.forex — buying=${rates.buying} selling=${rate}`, ); return rate; } catch (err) { this.logger.error( `Failed to scrape CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`, ); if (this.cachedRate !== null) { this.logger.warn( `Using previously cached CBE rate: ${this.cachedRate}`, ); return this.cachedRate; } return fallbackRate; } } private parseScrapedRates( html: string, ): { buying: number; selling: number } | null { const decoded = this.unescapeHtml(html); const match = USD_RATE_REGEX.exec(decoded); if (!match) return null; const buying = Number(match[1]); const selling = Number(match[2]); if (!Number.isFinite(buying) || !Number.isFinite(selling)) return null; return { buying, selling }; } private unescapeHtml(html: string): string { return html .replace(/"/g, '"') .replace(/"/g, '"') .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">"); } } /** 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), ); }