mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 19:30:57 +00:00
130 lines
4.0 KiB
TypeScript
130 lines
4.0 KiB
TypeScript
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<ExchangeOptions>;
|
|
private cachedRate: number | null = null;
|
|
private cacheExpiresAt = 0;
|
|
|
|
constructor(options: ExchangeOptions) {
|
|
this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) };
|
|
}
|
|
|
|
async getBaseRate(pair: CurrencyPair): Promise<number | null> {
|
|
// 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<number> {
|
|
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),
|
|
);
|
|
}
|