Files
edr-platform/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts

107 lines
3.3 KiB
TypeScript

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<number> {
const now = Date.now();
if (this.cachedRate !== null && now < this.cacheExpiresAt) {
return this.cachedRate;
}
const scrapeUrl = this.getScrapeUrl();
const fallbackRate =
this.configService.get<number>('app.cbeExchange.fallbackRate') ?? 130;
const cacheTtlMs =
this.configService.get<number>('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<string>('app.cbeExchange.scrapeUrl') ??
this.configService.get<string>('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(/&quot;/g, '"')
.replace(/&#34;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
}
}