diff --git a/packages/api-common/src/services/exchange/cbe.provider.ts b/packages/api-common/src/services/exchange/cbe.provider.ts index 3aa4fadba..74ba79025 100644 --- a/packages/api-common/src/services/exchange/cbe.provider.ts +++ b/packages/api-common/src/services/exchange/cbe.provider.ts @@ -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 | 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(); + /** Rate last served, per currency code — mirrors {@link lastSource}. */ + private lastServed = new Map(); constructor(options: ExchangeOptions) { this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) }; } async getBaseRate(pair: CurrencyPair): Promise { - // 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 { + private async getRateToEtb(code: CurrencyCode): Promise { 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 { + private async persistFallback(code: CurrencyCode, rate: number): Promise { 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 { + private async loadStoredFallback(code: CurrencyCode): Promise { 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 { + const rates = new Map(); + 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; } } diff --git a/packages/api-common/src/services/exchange/exchange.options.ts b/packages/api-common/src/services/exchange/exchange.options.ts index 6ccf83489..1c00ee00f 100644 --- a/packages/api-common/src/services/exchange/exchange.options.ts +++ b/packages/api-common/src/services/exchange/exchange.options.ts @@ -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>; /** - * 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; + loadFallbackRate?: (code: CurrencyCode) => Promise; /** - * 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; + saveFallbackRate?: (code: CurrencyCode, rate: number) => Promise; /** * 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, }; diff --git a/packages/api-common/src/services/exchange/exchange.service.ts b/packages/api-common/src/services/exchange/exchange.service.ts index 135bafb6d..f86466db8 100644 --- a/packages/api-common/src/services/exchange/exchange.service.ts +++ b/packages/api-common/src/services/exchange/exchange.service.ts @@ -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> { + 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}. */ diff --git a/packages/api-common/src/services/exchange/exchange.types.ts b/packages/api-common/src/services/exchange/exchange.types.ts index 384ad93b3..0ac77603f 100644 --- a/packages/api-common/src/services/exchange/exchange.types.ts +++ b/packages/api-common/src/services/exchange/exchange.types.ts @@ -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. diff --git a/packages/api-common/src/services/exchange/index.ts b/packages/api-common/src/services/exchange/index.ts index f2f88891a..4e893098f 100644 --- a/packages/api-common/src/services/exchange/index.ts +++ b/packages/api-common/src/services/exchange/index.ts @@ -8,6 +8,7 @@ export type { ExchangeAsyncOptions, ResolvedExchangeOptions, } from "./exchange.options"; +export { CURRENCY_CODES } from "./exchange.types"; export type { CurrencyCode, CurrencyPair,