mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 05:23:38 +00:00
feat(exchange): support n-currency conversion, not just USD/ETB
CbeExchangeProvider now parses every currency CBE quotes (USD, DJF, ...) from the single existing daily-rates fetch instead of hardcoding USD only, and ExchangeService.getRate gains a pivot step so a pair neither quoted directly nor as its inverse (e.g. USD->DJF) is derived by triangulating through the provider's base currency (ETB). Fallback rates and the load/save callbacks become per-currency instead of a single USD->ETB scalar. ExchangeService.getRateTable resolves a whole currency->target rate table in one call for pricing loops. No behavior change for existing USD/ETB callers. Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
ResolvedExchangeOptions,
|
||||
} from "./exchange.options";
|
||||
import {
|
||||
CurrencyCode,
|
||||
CurrencyPair,
|
||||
ExchangeRateProvider,
|
||||
} from "./exchange.types";
|
||||
@@ -41,62 +42,77 @@ export interface CbeProviderStatus {
|
||||
/**
|
||||
* 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.
|
||||
* Sources every quoted currency against **ETB** (transactional selling rate)
|
||||
* from CBE's public `daily-exchange-rates` JSON endpoint in a single fetch —
|
||||
* the payload carries every currency CBE quotes that day, not just one — caching
|
||||
* the result and falling back to a configured rate per currency when the fetch
|
||||
* fails. Every other pair (ETB→X, and cross-pairs like USD→DJF) is derived by
|
||||
* {@link ExchangeService}, so this provider only ever reports X→ETB.
|
||||
*/
|
||||
export class CbeExchangeProvider implements ExchangeRateProvider {
|
||||
readonly name = "CBE";
|
||||
readonly baseCurrency: CurrencyCode = "ETB";
|
||||
|
||||
private readonly logger = new Logger(CbeExchangeProvider.name);
|
||||
private readonly options: ResolvedExchangeOptions;
|
||||
private cachedRate: number | null = null;
|
||||
private cachedRates: Map<string, number> | null = null;
|
||||
private cacheExpiresAt = 0;
|
||||
private lastSuccessAt: number | null = null;
|
||||
private lastError: string | null = null;
|
||||
private lastSource: CbeRateSource | null = null;
|
||||
/** Source of the rate last served, per currency code. */
|
||||
private lastSource = new Map<string, CbeRateSource>();
|
||||
/** Rate last served, per currency code — mirrors {@link lastSource}. */
|
||||
private lastServed = new Map<string, number>();
|
||||
|
||||
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") {
|
||||
// CBE only sources X→ETB; everything else is derived upstream.
|
||||
if (pair.to !== "ETB" || pair.from === "ETB") {
|
||||
return null;
|
||||
}
|
||||
return this.getUsdToEtbRate();
|
||||
return this.getRateToEtb(pair.from);
|
||||
}
|
||||
|
||||
/** Health of the CBE feed — what was served last, and whether it is failing. */
|
||||
getStatus(): CbeProviderStatus {
|
||||
/**
|
||||
* Health of the CBE feed for one currency — what was served last, and
|
||||
* whether it is currently failing. For operator-facing status displays.
|
||||
*/
|
||||
getStatus(code: CurrencyCode = "USD"): CbeProviderStatus {
|
||||
return {
|
||||
rate: this.cachedRate,
|
||||
source: this.lastSource,
|
||||
rate: this.lastServed.get(code) ?? null,
|
||||
source: this.lastSource.get(code) ?? null,
|
||||
lastSuccessAt: this.lastSuccessAt,
|
||||
lastError: this.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current CBE USD→ETB **transactional selling** rate.
|
||||
* Returns the current CBE `code`→ETB **transactional selling** rate.
|
||||
*
|
||||
* Cached for `cacheTtlMs`. On a successful fetch the rate is written back via
|
||||
* Cached for `cacheTtlMs`, one fetch serving every currency. On a
|
||||
* successful fetch each currency's 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`.
|
||||
* fetch stale. On failure the chain is: cached rate → `loadFallbackRate(code)`
|
||||
* → static `fallbackRates[code]`.
|
||||
*/
|
||||
private async getUsdToEtbRate(): Promise<number> {
|
||||
private async getRateToEtb(code: CurrencyCode): Promise<number> {
|
||||
const now = Date.now();
|
||||
|
||||
if (this.cachedRate !== null && now < this.cacheExpiresAt) {
|
||||
this.lastSource = "cache";
|
||||
return this.cachedRate;
|
||||
if (this.cachedRates !== null && now < this.cacheExpiresAt) {
|
||||
const cached = this.cachedRates.get(code);
|
||||
if (cached !== undefined) {
|
||||
this.lastSource.set(code, "cache");
|
||||
this.lastServed.set(code, cached);
|
||||
return cached;
|
||||
}
|
||||
// Cache is fresh but never saw this currency quoted — fall through to
|
||||
// stored/default rather than treating it as a live-fetch failure.
|
||||
}
|
||||
|
||||
const { scrapeUrl, fallbackRate, cacheTtlMs, requestTimeoutMs } =
|
||||
const { scrapeUrl, fallbackRates, cacheTtlMs, requestTimeoutMs } =
|
||||
this.options;
|
||||
|
||||
try {
|
||||
@@ -116,54 +132,68 @@ export class CbeExchangeProvider implements ExchangeRateProvider {
|
||||
throw new Error("CBE rates payload contained no daily record");
|
||||
}
|
||||
|
||||
const rate = this.parseUsdRate(day);
|
||||
const rates = this.parseRates(day);
|
||||
const rate = rates.get(code) ?? null;
|
||||
|
||||
if (rate === null) {
|
||||
throw new Error(
|
||||
`USD transactionalSelling not found in CBE record for ${day.Date ?? "unknown date"}`,
|
||||
`${code} transactionalSelling not found in CBE record for ${day.Date ?? "unknown date"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const previous = this.cachedRate;
|
||||
this.cachedRate = rate;
|
||||
const previous = this.cachedRates?.get(code) ?? null;
|
||||
this.cachedRates = rates;
|
||||
this.cacheExpiresAt = now + cacheTtlMs;
|
||||
this.lastSuccessAt = now;
|
||||
this.lastError = null;
|
||||
this.lastSource = "live";
|
||||
this.lastSource.set(code, "live");
|
||||
this.lastServed.set(code, rate);
|
||||
this.logger.log(
|
||||
`CBE USD→ETB rate refreshed — transactionalSelling=${rate} (date=${day.Date ?? "unknown"})`,
|
||||
`CBE ${code}→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);
|
||||
await this.persistFallback(code, 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}`);
|
||||
this.logger.error(
|
||||
`Failed to fetch CBE exchange rate for ${code}. Error: ${message}`,
|
||||
);
|
||||
|
||||
if (this.cachedRate !== null) {
|
||||
this.lastSource = "cache";
|
||||
this.logger.warn(
|
||||
`Using previously cached CBE rate: ${this.cachedRate}`,
|
||||
);
|
||||
return this.cachedRate;
|
||||
const cached = this.cachedRates?.get(code);
|
||||
if (cached !== undefined) {
|
||||
this.lastSource.set(code, "cache");
|
||||
this.lastServed.set(code, cached);
|
||||
this.logger.warn(`Using previously cached CBE rate for ${code}: ${cached}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const stored = await this.loadStoredFallback();
|
||||
const stored = await this.loadStoredFallback(code);
|
||||
if (stored !== null) {
|
||||
this.lastSource = "stored";
|
||||
this.logger.warn(`Using stored fallback CBE rate: ${stored}`);
|
||||
this.lastSource.set(code, "stored");
|
||||
this.lastServed.set(code, stored);
|
||||
this.logger.warn(`Using stored fallback CBE rate for ${code}: ${stored}`);
|
||||
return stored;
|
||||
}
|
||||
|
||||
this.lastSource = "default";
|
||||
this.logger.warn(`Using default fallback CBE rate: ${fallbackRate}`);
|
||||
return fallbackRate;
|
||||
const fallback = fallbackRates[code];
|
||||
if (fallback === undefined) {
|
||||
// No static default configured for this currency either — nothing
|
||||
// left to fall back to.
|
||||
throw new Error(
|
||||
`No CBE rate available for ${code}→ETB (fetch failed and no fallback configured)`,
|
||||
);
|
||||
}
|
||||
this.lastSource.set(code, "default");
|
||||
this.lastServed.set(code, fallback);
|
||||
this.logger.warn(`Using default fallback CBE rate for ${code}: ${fallback}`);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,34 +202,34 @@ export class CbeExchangeProvider implements ExchangeRateProvider {
|
||||
* 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> {
|
||||
private async persistFallback(code: CurrencyCode, rate: number): Promise<void> {
|
||||
const { saveFallbackRate } = this.options;
|
||||
if (!saveFallbackRate) return;
|
||||
|
||||
try {
|
||||
await saveFallbackRate(rate);
|
||||
await saveFallbackRate(code, rate);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to persist CBE fallback rate ${rate}: ${(err as Error).message}`,
|
||||
`Failed to persist CBE fallback rate ${rate} for ${code}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the persisted fallback. Returns `null` — falling through to the
|
||||
* static default — when unconfigured, unusable, or itself failing.
|
||||
* Reads the persisted fallback for `code`. Returns `null` — falling through
|
||||
* to the static default — when unconfigured, unusable, or itself failing.
|
||||
*/
|
||||
private async loadStoredFallback(): Promise<number | null> {
|
||||
private async loadStoredFallback(code: CurrencyCode): Promise<number | null> {
|
||||
const { loadFallbackRate } = this.options;
|
||||
if (!loadFallbackRate) return null;
|
||||
|
||||
try {
|
||||
const stored = await loadFallbackRate();
|
||||
const stored = await loadFallbackRate(code);
|
||||
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}`,
|
||||
`Failed to load stored CBE fallback rate for ${code}: ${(err as Error).message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -217,18 +247,21 @@ export class CbeExchangeProvider implements ExchangeRateProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Pulls every currency's `transactionalSelling` out of a daily record in
|
||||
* one pass. Skips entries missing or unusable — 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;
|
||||
private parseRates(day: CbeDailyRecord): Map<string, number> {
|
||||
const rates = new Map<string, number>();
|
||||
for (const entry of day.ExchangeRate ?? []) {
|
||||
const code = entry?.currency?.CurrencyCode;
|
||||
if (!code) continue;
|
||||
const rate = Number(entry.transactionalSelling);
|
||||
if (Number.isFinite(rate) && rate > 0) {
|
||||
rates.set(code, rate);
|
||||
}
|
||||
}
|
||||
return rates;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CurrencyCode } from "./exchange.types";
|
||||
|
||||
/** Injection token carrying the resolved {@link ExchangeOptions}. */
|
||||
export const EXCHANGE_OPTIONS = Symbol("EXCHANGE_OPTIONS");
|
||||
|
||||
@@ -11,31 +13,34 @@ export interface ExchangeOptions {
|
||||
scrapeUrl?: string;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Last-resort rate for each foreign currency, quoted against the provider's
|
||||
* base currency (ETB for CBE) — used only when the fetch fails, no cached
|
||||
* rate exists, and {@link loadFallbackRate} supplies nothing for that
|
||||
* currency. Every other pair (including ETB→X and cross-pairs like
|
||||
* USD→DJF) is derived from these.
|
||||
* @default { USD: 162, DJF: 0.92 }
|
||||
*/
|
||||
fallbackRate?: number;
|
||||
fallbackRates?: Partial<Record<CurrencyCode, 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}.
|
||||
* Reads the persisted fallback rate for `code` — 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 fallbackRates}.
|
||||
*
|
||||
* Optional: omit it and the provider uses the static `fallbackRate` alone.
|
||||
* Optional: omit it and the provider uses the static `fallbackRates` alone.
|
||||
*/
|
||||
loadFallbackRate?: () => Promise<number | null>;
|
||||
loadFallbackRate?: (code: CurrencyCode) => 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.
|
||||
* Persists a freshly fetched live rate for `code` 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>;
|
||||
saveFallbackRate?: (code: CurrencyCode, rate: number) => Promise<void>;
|
||||
|
||||
/**
|
||||
* How long a successfully fetched rate is cached, in milliseconds.
|
||||
@@ -60,7 +65,7 @@ export type ResolvedExchangeOptions = Required<
|
||||
export const EXCHANGE_DEFAULTS: ResolvedExchangeOptions = {
|
||||
scrapeUrl:
|
||||
"https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC",
|
||||
fallbackRate: 162,
|
||||
fallbackRates: { USD: 162, DJF: 0.92 },
|
||||
cacheTtlMs: 3_600_000,
|
||||
requestTimeoutMs: 8_000,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Inject, Injectable } from "@nestjs/common";
|
||||
|
||||
import { CbeExchangeProvider, CbeProviderStatus } from "./cbe.provider";
|
||||
import { EXCHANGE_OPTIONS, ExchangeOptions } from "./exchange.options";
|
||||
import { CurrencyCode } from "./exchange.types";
|
||||
import { CURRENCY_CODES, CurrencyCode } from "./exchange.types";
|
||||
|
||||
/**
|
||||
* Currency exchange service. Resolves the rate between any supported currency
|
||||
@@ -12,6 +12,8 @@ import { CurrencyCode } from "./exchange.types";
|
||||
* 1. `from === to` → `1`.
|
||||
* 2. Provider supplies the pair directly (e.g. CBE → USD→ETB).
|
||||
* 3. Provider supplies the inverse → return `1 / inverse` (e.g. ETB→USD).
|
||||
* 4. Neither leg is quoted directly (e.g. USD→DJF) → pivot through the
|
||||
* provider's base currency, which quotes both.
|
||||
*
|
||||
* Configure via {@link ExchangeModule.forRoot} / `forRootAsync`.
|
||||
*/
|
||||
@@ -42,17 +44,44 @@ export class ExchangeService {
|
||||
return 1 / inverse;
|
||||
}
|
||||
|
||||
// Neither leg is quoted directly (e.g. USD↔DJF): pivot through the
|
||||
// provider's base currency, which quotes both. Mathematically identical
|
||||
// to converting via that base currency by hand.
|
||||
const base = this.provider.baseCurrency;
|
||||
if (from !== base && to !== base) {
|
||||
const fromToBase = await this.provider.getBaseRate({ from, to: base });
|
||||
const toToBase = await this.provider.getBaseRate({ from: to, to: base });
|
||||
if (fromToBase !== null && toToBase !== null && toToBase > 0) {
|
||||
return fromToBase / toToBase;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No exchange rate available for ${from}→${to} from provider ${this.provider.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A conversion function for every source currency into `target`, resolved
|
||||
* up front so a pricing loop never awaits per row. `fx(code)` is `1` for
|
||||
* `code === target`, throws for a currency the provider cannot rate.
|
||||
*/
|
||||
async getRateTable(
|
||||
target: CurrencyCode,
|
||||
sources: readonly CurrencyCode[] = CURRENCY_CODES,
|
||||
): Promise<Record<string, number>> {
|
||||
const entries = await Promise.all(
|
||||
sources.map(async (code) => [code, await this.getRate(code, target)] as const),
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
getProviderStatus(code: CurrencyCode = "USD"): CbeProviderStatus {
|
||||
return this.provider.getStatus(code);
|
||||
}
|
||||
|
||||
/** Converts `amount` from one currency to another using {@link getRate}. */
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* ISO-4217 currency codes the exchange service can handle.
|
||||
* Extend this union as new currencies are supported.
|
||||
* Extend this list as new currencies are supported.
|
||||
*/
|
||||
export type CurrencyCode = "USD" | "ETB";
|
||||
export const CURRENCY_CODES = ["ETB", "USD", "DJF"] as const;
|
||||
|
||||
export type CurrencyCode = (typeof CURRENCY_CODES)[number];
|
||||
|
||||
/** A directional currency pair, e.g. `{ from: 'USD', to: 'ETB' }`. */
|
||||
export interface CurrencyPair {
|
||||
@@ -11,18 +13,21 @@ export interface CurrencyPair {
|
||||
}
|
||||
|
||||
/**
|
||||
* A source of base exchange rates. Implementations fetch (scrape/API) the rate
|
||||
* for a single canonical direction; the {@link ExchangeService} derives the
|
||||
* inverse and same-currency (1:1) cases on top.
|
||||
* A source of base exchange rates. Implementations fetch (scrape/API) rates
|
||||
* quoted against a single canonical base currency; the {@link ExchangeService}
|
||||
* derives every other pair — inverse, pivot, same-currency (1:1) — on top.
|
||||
*
|
||||
* Today the only implementation is the CBE (Central Bank of Ethiopia) provider,
|
||||
* which sources USD→ETB. New providers (other banks, other base pairs) can be
|
||||
* added without touching consumers.
|
||||
* which quotes everything against ETB. New providers (other banks, other base
|
||||
* currencies) can be added without touching consumers.
|
||||
*/
|
||||
export interface ExchangeRateProvider {
|
||||
/** Human-readable provider name, used in logs (e.g. `'CBE'`). */
|
||||
readonly name: string;
|
||||
|
||||
/** The currency this provider quotes every other currency against. */
|
||||
readonly baseCurrency: CurrencyCode;
|
||||
|
||||
/**
|
||||
* Returns the rate for `pair` (units of `pair.to` per 1 unit of `pair.from`),
|
||||
* or `null` if this provider cannot supply that pair directly.
|
||||
|
||||
@@ -8,6 +8,7 @@ export type {
|
||||
ExchangeAsyncOptions,
|
||||
ResolvedExchangeOptions,
|
||||
} from "./exchange.options";
|
||||
export { CURRENCY_CODES } from "./exchange.types";
|
||||
export type {
|
||||
CurrencyCode,
|
||||
CurrencyPair,
|
||||
|
||||
Reference in New Issue
Block a user