implement exchange settings management and fallback rate handling

This commit is contained in:
Marshal
2026-08-04 11:22:47 +00:00
parent 7c78a815eb
commit 56697d8fc5
22 changed files with 805 additions and 85 deletions

View File

@@ -1,30 +1,62 @@
import { Logger } from "@nestjs/common";
import { EXCHANGE_DEFAULTS, ExchangeOptions } from "./exchange.options";
import {
EXCHANGE_DEFAULTS,
ExchangeOptions,
ResolvedExchangeOptions,
} 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.]+)\]/;
/** 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;
}
/**
* Central Bank of Ethiopia (CBE) rate provider.
* Commercial 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.
* 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: Required<ExchangeOptions>;
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) };
@@ -38,15 +70,29 @@ export class CbeExchangeProvider implements ExchangeRateProvider {
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 **selling** rate scraped from ethio.forex.
* Cached for `cacheTtlMs`; on failure reuses the last cached rate, else
* returns `fallbackRate`.
* 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<number> {
const now = Date.now();
if (this.cachedRate !== null && now < this.cacheExpiresAt) {
this.lastSource = "cache";
return this.cachedRate;
}
@@ -56,68 +102,133 @@ export class CbeExchangeProvider implements ExchangeRateProvider {
try {
const response = await fetch(scrapeUrl, {
signal: AbortSignal.timeout(requestTimeoutMs),
headers: { "User-Agent": "Mozilla/5.0" },
headers: { Accept: "application/json", "User-Agent": "Mozilla/5.0" },
});
if (!response.ok) {
throw new Error(`CBE scrape responded with status ${response.status}`);
throw new Error(`CBE rates responded with status ${response.status}`);
}
const html = await response.text();
const rates = this.parseScrapedRates(html);
const payload = (await response.json()) as unknown;
const day = this.latestRecord(payload);
if (!rates) {
throw new Error("USD rate not found in ethio.forex page HTML");
if (!day) {
throw new Error("CBE rates payload contained no daily record");
}
const rate = rates.selling;
if (!Number.isFinite(rate) || rate <= 0) {
throw new Error(`Invalid selling rate parsed: ${rate}`);
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 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}`,
`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;
}
}
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;
/**
* 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<void> {
const { saveFallbackRate } = this.options;
if (!saveFallbackRate) return;
const buying = Number(match[1]);
const selling = Number(match[2]);
if (!Number.isFinite(buying) || !Number.isFinite(selling)) return null;
return { buying, selling };
try {
await saveFallbackRate(rate);
} catch (err) {
this.logger.warn(
`Failed to persist CBE fallback rate ${rate}: ${(err as Error).message}`,
);
}
}
private unescapeHtml(html: string): string {
return html
.replace(/&quot;/g, '"')
.replace(/&#34;/g, '"')
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
/**
* Reads the persisted fallback. Returns `null` — falling through to the
* static default — when unconfigured, unusable, or itself failing.
*/
private async loadStoredFallback(): Promise<number | null> {
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;
}
}

View File

@@ -4,18 +4,39 @@ export const EXCHANGE_OPTIONS = Symbol("EXCHANGE_OPTIONS");
/** Configuration for the {@link ExchangeService} and its CBE provider. */
export interface ExchangeOptions {
/**
* ethio.forex CBET page scraped for USD buying/selling rates.
* @default 'https://ethio.forex/bank/CBET'
* CBE daily-exchange-rates JSON endpoint. Returns an array of daily records;
* `_limit=1&_sort=Date%3ADESC` narrows it to the most recent day.
* @default 'https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC'
*/
scrapeUrl?: string;
/**
* Base USD→ETB rate used when scraping fails and no previously cached rate
* exists. The ETB→USD direction is derived as its inverse.
* @default 130
* Last-resort USD→ETB rate, used only when the fetch fails, no cached rate
* exists, and {@link loadFallbackRate} supplies nothing. The ETB→USD
* direction is derived as its inverse.
* @default 162
*/
fallbackRate?: number;
/**
* Reads the persisted fallback rate — the last known good CBE rate, or one
* set by an operator. Consulted only when the live fetch fails and no cached
* rate is available; a `null` result falls through to {@link fallbackRate}.
*
* Optional: omit it and the provider uses the static `fallbackRate` alone.
*/
loadFallbackRate?: () => Promise<number | null>;
/**
* Persists a freshly fetched live rate as the new fallback, so the stored
* value is never more than one successful fetch stale. Called after every
* successful fetch that produced a changed rate.
*
* Failures here are logged and swallowed — persisting the fallback must
* never break the pricing call that triggered it.
*/
saveFallbackRate?: (rate: number) => Promise<void>;
/**
* How long a successfully fetched rate is cached, in milliseconds.
* @default 3_600_000 (1 hour)
@@ -23,16 +44,23 @@ export interface ExchangeOptions {
cacheTtlMs?: number;
/**
* Timeout for the scrape HTTP request, in milliseconds.
* Timeout for the rate HTTP request, in milliseconds.
* @default 8_000
*/
requestTimeoutMs?: number;
}
/** Defaults applied to any unset {@link ExchangeOptions} field. */
export const EXCHANGE_DEFAULTS: Required<ExchangeOptions> = {
scrapeUrl: "https://ethio.forex/bank/CBET",
fallbackRate: 130,
/** The scalar options, all resolved — the callbacks stay genuinely optional. */
export type ResolvedExchangeOptions = Required<
Omit<ExchangeOptions, "loadFallbackRate" | "saveFallbackRate">
> &
Pick<ExchangeOptions, "loadFallbackRate" | "saveFallbackRate">;
/** Defaults applied to any unset scalar {@link ExchangeOptions} field. */
export const EXCHANGE_DEFAULTS: ResolvedExchangeOptions = {
scrapeUrl:
"https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC",
fallbackRate: 162,
cacheTtlMs: 3_600_000,
requestTimeoutMs: 8_000,
};

View File

@@ -1,6 +1,6 @@
import { Inject, Injectable } from "@nestjs/common";
import { CbeExchangeProvider } from "./cbe.provider";
import { CbeExchangeProvider, CbeProviderStatus } from "./cbe.provider";
import { EXCHANGE_OPTIONS, ExchangeOptions } from "./exchange.options";
import { CurrencyCode } from "./exchange.types";
@@ -47,6 +47,14 @@ export class ExchangeService {
);
}
/**
* Health of the underlying rate feed — what was served last and whether it
* is currently failing. For operator-facing status displays.
*/
getProviderStatus(): CbeProviderStatus {
return this.provider.getStatus();
}
/** Converts `amount` from one currency to another using {@link getRate}. */
async convert(
amount: number,

View File

@@ -1,8 +1,13 @@
export { ExchangeService } from "./exchange.service";
export { ExchangeModule } from "./exchange.module";
export { CbeExchangeProvider } from "./cbe.provider";
export type { CbeProviderStatus, CbeRateSource } from "./cbe.provider";
export { EXCHANGE_OPTIONS, EXCHANGE_DEFAULTS } from "./exchange.options";
export type { ExchangeOptions, ExchangeAsyncOptions } from "./exchange.options";
export type {
ExchangeOptions,
ExchangeAsyncOptions,
ResolvedExchangeOptions,
} from "./exchange.options";
export type {
CurrencyCode,
CurrencyPair,