mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-05 23:33:38 +00:00
241 lines
7.7 KiB
TypeScript
241 lines
7.7 KiB
TypeScript
import { Logger } from "@nestjs/common";
|
|
|
|
import {
|
|
EXCHANGE_DEFAULTS,
|
|
ExchangeOptions,
|
|
ResolvedExchangeOptions,
|
|
} from "./exchange.options";
|
|
import {
|
|
CurrencyPair,
|
|
ExchangeRateProvider,
|
|
} from "./exchange.types";
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/**
|
|
* Commercial Bank of Ethiopia (CBE) rate provider.
|
|
*
|
|
* 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: 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) };
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/** 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 **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;
|
|
}
|
|
|
|
const { scrapeUrl, fallbackRate, cacheTtlMs, requestTimeoutMs } =
|
|
this.options;
|
|
|
|
try {
|
|
const response = await fetch(scrapeUrl, {
|
|
signal: AbortSignal.timeout(requestTimeoutMs),
|
|
headers: { Accept: "application/json", "User-Agent": "Mozilla/5.0" },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`CBE rates responded with status ${response.status}`);
|
|
}
|
|
|
|
const payload = (await response.json()) as unknown;
|
|
const day = this.latestRecord(payload);
|
|
|
|
if (!day) {
|
|
throw new Error("CBE rates payload contained no daily record");
|
|
}
|
|
|
|
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 — 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
try {
|
|
await saveFallbackRate(rate);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Failed to persist CBE fallback rate ${rate}: ${(err as Error).message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
/** 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),
|
|
);
|
|
}
|