import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; const DEFAULT_SCRAPE_URL = 'https://ethio.forex/bank/CBET'; /** 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.]+)\]/; @Injectable() export class CbeExchangeService { private readonly logger = new Logger(CbeExchangeService.name); private cachedRate: number | null = null; private cacheExpiresAt = 0; constructor(private readonly configService: ConfigService) {} /** * Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex. * Cached for CBE_EXCHANGE_CACHE_TTL_MS; falls back to CBE_EXCHANGE_FALLBACK_RATE on failure. */ async getUsdToEtbRate(): Promise { const now = Date.now(); if (this.cachedRate !== null && now < this.cacheExpiresAt) { return this.cachedRate; } const scrapeUrl = this.getScrapeUrl(); const fallbackRate = this.configService.get('app.cbeExchange.fallbackRate') ?? 130; const cacheTtlMs = this.configService.get('app.cbeExchange.cacheTtlMs') ?? 3_600_000; try { const response = await fetch(scrapeUrl, { signal: AbortSignal.timeout(8_000), 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 getScrapeUrl(): string { const configured = this.configService.get('app.cbeExchange.scrapeUrl') ?? this.configService.get('app.cbeExchange.apiUrl'); return configured?.trim() || DEFAULT_SCRAPE_URL; } 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, '>'); } }