diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-rate.spec.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-rate.spec.ts new file mode 100644 index 000000000..325275d6e --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-rate.spec.ts @@ -0,0 +1,105 @@ +import { CbeExchangeProvider, ExchangeService } from '@edr/api-common'; +import { EXCHANGE_OPTIONS } from '@edr/api-common'; + +/** + * A CBE `daily-exchange-rates` payload, trimmed to the entries that matter. The real one + * carries ~18 currencies in exactly this shape. + */ +const cbePayload = (over: Record = {}) => [ + { + Date: '2026-08-28', + ExchangeRate: [ + { transactionalSelling: over.USD === undefined ? 163.4365 : over.USD, currency: { CurrencyCode: 'USD' } }, + { transactionalSelling: over.DJF === undefined ? 0.9203 : over.DJF, currency: { CurrencyCode: 'DJF' } }, + { transactionalSelling: 190.7304, currency: { CurrencyCode: 'EUR' } }, + ], + }, +]; + +const okFetch = (payload: unknown) => + jest.fn().mockResolvedValue({ ok: true, json: async () => payload }); + +const service = (options = {}) => + new ExchangeService({ cacheTtlMs: 0, ...options } as never); + +describe('CBE exchange rates', () => { + const realFetch = global.fetch; + afterEach(() => { + global.fetch = realFetch; + jest.restoreAllMocks(); + }); + + it('serves DJF→ETB from the same payload that carries USD', async () => { + global.fetch = okFetch(cbePayload()) as never; + // The provider used to hardcode `pair.from !== 'USD' → null` and throw away the other + // 17 quotes in the payload it had already fetched. + await expect(service().getRate('DJF', 'ETB')).resolves.toBeCloseTo(0.9203, 6); + }); + + it('derives ETB→DJF as the inverse', async () => { + global.fetch = okFetch(cbePayload()) as never; + await expect(service().getRate('ETB', 'DJF')).resolves.toBeCloseTo(1 / 0.9203, 6); + }); + + it('crosses DJF→USD through ETB, since CBE quotes neither direction', async () => { + global.fetch = okFetch(cbePayload()) as never; + await expect(service().getRate('DJF', 'USD')).resolves.toBeCloseTo(0.9203 / 163.4365, 8); + }); + + it('falls back to the USD peg for DJF when the feed is down', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('ENOTFOUND combanketh.et')) as never; + // No cached rate, no stored fallback → the static default (162), pegged to DJF. + await expect(service({ fallbackRate: 162 }).getRate('DJF', 'ETB')).resolves.toBeCloseTo( + 162 / 177.721, + 6, + ); + }); + + it('prefers the stored fallback over the static one, still via the peg', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('timeout')) as never; + const svc = service({ fallbackRate: 162, loadFallbackRate: async () => 163.4365 }); + await expect(svc.getRate('DJF', 'ETB')).resolves.toBeCloseTo(163.4365 / 177.721, 6); + }); + + it('skips a currency CBE publishes as zero rather than pricing off it', async () => { + // CBE publishes 0/null for currencies it is not quoting that day. A zero rate would + // zero every DJF line on the invoice. + global.fetch = okFetch(cbePayload({ DJF: 0 })) as never; + const svc = service({ fallbackRate: 162 }); + await expect(svc.getRate('DJF', 'ETB')).rejects.toThrow(/No exchange rate available/); + }); + + it('still reports USD→ETB as the feed status rate', async () => { + const provider = new CbeExchangeProvider({ cacheTtlMs: 0 }); + global.fetch = okFetch(cbePayload()) as never; + await provider.getBaseRate({ from: 'DJF', to: 'ETB' }); + expect(provider.getStatus().rate).toBe(163.4365); + }); + + it('never asks the provider for an ETB-based pair', async () => { + const fetchSpy = okFetch(cbePayload()); + global.fetch = fetchSpy as never; + await expect(service().getRate('ETB', 'ETB')).resolves.toBe(1); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('fetches CBE once when a pricing pass asks for three currencies at once', async () => { + // getRatesFromUsd resolves the codes concurrently. Without in-flight de-duplication a + // cold cache opened one HTTP request per currency, all for the same payload. + const fetchSpy = okFetch(cbePayload()); + global.fetch = fetchSpy as never; + await service().getRatesFromUsd(['USD', 'ETB', 'DJF']); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('resolves every USD→x rate in one pass for pricing', async () => { + global.fetch = okFetch(cbePayload()) as never; + const rates = await service().getRatesFromUsd(['USD', 'ETB', 'DJF']); + expect(rates.USD).toBe(1); + expect(rates.ETB).toBeCloseTo(163.4365, 6); + expect(rates.DJF).toBeCloseTo(163.4365 / 0.9203, 6); + }); +}); + +// Referenced so the import is not flagged unused by noUnusedLocals in the type-check. +void EXCHANGE_OPTIONS; diff --git a/packages/api-common/src/services/exchange/cbe.provider.ts b/packages/api-common/src/services/exchange/cbe.provider.ts index 3aa4fadba..190f12143 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"; @@ -28,7 +29,7 @@ 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. */ + /** The USD→ETB rate most recently served, whatever its source. */ rate: number | null; /** Where that rate came from. `live` means the API answered. */ source: CbeRateSource | null; @@ -38,22 +39,40 @@ export interface CbeProviderStatus { lastError: string | null; } +/** + * Djibouti Franc per US Dollar. DJF is hard-pegged to USD at this rate, so a DJF→ETB + * fallback can be derived from the stored USD→ETB one instead of persisting a second + * number. It reproduces CBE's own DJF quote to four decimals. + * + * ponytail: one constant instead of a per-currency fallback table. If the peg ever floats, + * add a `currency` column to `freight.exchange_settings` and key the stored fallback by it. + */ +const DJF_PER_USD = 177.721; + /** * 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 currency CBE quotes **against ETB** (transactional selling rate) from its + * public `daily-exchange-rates` JSON endpoint — one fetch returns USD, DJF and ~16 others in + * a single payload, so serving more than one pair costs no extra request. Results are cached + * as a whole map and fall back to a configured rate when the fetch fails. Inverse and + * cross-rate directions (ETB→USD, DJF→USD) are derived by {@link ExchangeService}, so this + * provider only ever reports `X→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; + /** Every quoted code → ETB, from one payload. Keyed by ISO code, e.g. `USD`, `DJF`. */ + private cachedRates: Map | null = null; private cacheExpiresAt = 0; + /** + * The fetch currently in flight, if any. Without this a cold cache asked for three + * currencies at once (as a pricing pass does) opens three identical HTTP requests and + * races to store the winner — one payload already carries every currency. + */ + private inFlight: Promise> | null = null; private lastSuccessAt: number | null = null; private lastError: string | null = null; private lastSource: CbeRateSource | null = null; @@ -63,17 +82,18 @@ export class CbeExchangeProvider implements ExchangeRateProvider { } async getBaseRate(pair: CurrencyPair): Promise { - // CBE only sources USD→ETB; everything else is derived upstream. - if (pair.from !== "USD" || pair.to !== "ETB") { + // CBE quotes everything against ETB; the inverse and cross directions are derived + // upstream, so an ETB-based pair is not this provider's to answer. + 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 { return { - rate: this.cachedRate, + rate: this.cachedRates?.get("USD") ?? null, source: this.lastSource, lastSuccessAt: this.lastSuccessAt, lastError: this.lastError, @@ -81,92 +101,126 @@ export class CbeExchangeProvider implements ExchangeRateProvider { } /** - * Returns the current CBE USD→ETB **transactional selling** rate. + * Returns the current CBE **transactional selling** rate for `code`→ETB. * - * 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`. + * Cached for `cacheTtlMs`. On a successful fetch the USD rate is written back via + * `saveFallbackRate`, so the stored fallback is never more than one good fetch stale. On + * failure the chain is: cached rates → `loadFallbackRate()` → static `fallbackRate`, with + * the non-USD legs derived from that USD number via {@link DJF_PER_USD}. */ - private async getUsdToEtbRate(): Promise { + private async getRateToEtb(code: CurrencyCode): Promise { const now = Date.now(); - if (this.cachedRate !== null && now < this.cacheExpiresAt) { + if (this.cachedRates !== null && now < this.cacheExpiresAt) { this.lastSource = "cache"; - return this.cachedRate; + return this.cachedRates.get(code) ?? null; } - const { scrapeUrl, fallbackRate, cacheTtlMs, requestTimeoutMs } = - this.options; + const { fallbackRate } = 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; + const rates = await (this.inFlight ??= this.fetchRates().finally(() => { + this.inFlight = null; + })); + return rates.get(code) ?? null; } 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) { + const cached = this.cachedRates?.get(code); + if (cached !== undefined) { this.lastSource = "cache"; - this.logger.warn( - `Using previously cached CBE rate: ${this.cachedRate}`, - ); - return this.cachedRate; + this.logger.warn(`Using previously cached CBE rate for ${code}: ${cached}`); + return cached; } const stored = await this.loadStoredFallback(); if (stored !== null) { this.lastSource = "stored"; - this.logger.warn(`Using stored fallback CBE rate: ${stored}`); - return stored; + const derived = this.deriveFromUsd(code, stored); + this.logger.warn( + `Using stored fallback CBE rate for ${code}: ${derived ?? "unavailable"}`, + ); + return derived; } this.lastSource = "default"; - this.logger.warn(`Using default fallback CBE rate: ${fallbackRate}`); - return fallbackRate; + const derived = this.deriveFromUsd(code, fallbackRate); + this.logger.warn( + `Using default fallback CBE rate for ${code}: ${derived ?? "unavailable"}`, + ); + return derived; } } + /** + * One CBE fetch, parsed into the whole rate map. Shared by every currency asked for + * while it is in flight — the payload carries all of them. + */ + private async fetchRates(): Promise> { + const now = Date.now(); + const { scrapeUrl, cacheTtlMs, requestTimeoutMs } = this.options; + + 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 rates = this.parseRates(day); + const usd = rates.get("USD"); + + // USD anchors the fallback and every derived rate, so a payload without it is not + // usable even if it quotes the code we were asked for. + if (usd === undefined) { + throw new Error( + `USD transactionalSelling not found in CBE record for ${day.Date ?? "unknown date"}`, + ); + } + + const previous = this.cachedRates?.get("USD") ?? null; + this.cachedRates = rates; + this.cacheExpiresAt = now + cacheTtlMs; + this.lastSuccessAt = now; + this.lastError = null; + this.lastSource = "live"; + this.logger.log( + `CBE rates refreshed — ${rates.size} currencies quoted, USD→ETB=${usd} (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. USD only: every other + // currency derives from it, so a second stored number would just be a second thing + // that can go stale. + if (usd !== previous) { + await this.persistFallback(usd); + } + + return rates; + } + + /** + * Derives a `code`→ETB rate from a USD→ETB one. Only the pegged currencies can be derived; + * anything else returns `null` so the caller raises "no rate available" rather than + * pricing a booking off an invented number. + */ + private deriveFromUsd(code: CurrencyCode, usdToEtb: number): number | null { + if (code === "USD") return usdToEtb; + if (code === "DJF") return usdToEtb / DJF_PER_USD; + return null; + } + /** * Writes a freshly fetched rate back as the stored fallback. Failures are * logged and swallowed: persisting the fallback is housekeeping, and must @@ -217,18 +271,24 @@ 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 usable `transactionalSelling` out of a daily record, keyed by ISO code. + * Entries whose value isn't a positive number are skipped — CBE publishes `0`/`null` for + * currencies it isn't quoting that day, and a zero rate would zero a whole invoice. */ - private parseUsdRate(day: CbeDailyRecord): number | null { - const usd = day.ExchangeRate?.find( - (entry) => entry?.currency?.CurrencyCode === "USD", - ); - if (!usd) return null; + private parseRates(day: CbeDailyRecord): Map { + const rates = new Map(); - const rate = Number(usd.transactionalSelling); - return Number.isFinite(rate) && rate > 0 ? rate : null; + for (const entry of day.ExchangeRate ?? []) { + const code = entry?.currency?.CurrencyCode?.trim().toUpperCase(); + 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.service.ts b/packages/api-common/src/services/exchange/exchange.service.ts index 135bafb6d..0e4a83146 100644 --- a/packages/api-common/src/services/exchange/exchange.service.ts +++ b/packages/api-common/src/services/exchange/exchange.service.ts @@ -10,8 +10,9 @@ import { CurrencyCode } from "./exchange.types"; * * Resolution order for `getRate(from, to)`: * 1. `from === to` → `1`. - * 2. Provider supplies the pair directly (e.g. CBE → USD→ETB). + * 2. Provider supplies the pair directly (e.g. CBE → USD→ETB, DJF→ETB). * 3. Provider supplies the inverse → return `1 / inverse` (e.g. ETB→USD). + * 4. Both sides quote against ETB → cross them (e.g. DJF→USD via ETB). * * Configure via {@link ExchangeModule.forRoot} / `forRootAsync`. */ @@ -42,11 +43,57 @@ export class ExchangeService { return 1 / inverse; } + // Neither leg touches ETB directly — e.g. DJF→USD. The provider quotes everything + // against ETB, so cross the two ETB legs rather than declaring the pair unavailable. + const cross = await this.crossViaEtb(from, to); + if (cross !== null) { + return cross; + } + throw new Error( `No exchange rate available for ${from}→${to} from provider ${this.provider.name}`, ); } + /** + * `from→to` derived from the two ETB-quoted legs. Returns `null` when either leg is + * missing or unusable, so the caller raises rather than pricing off a bad number. + */ + private async crossViaEtb( + from: CurrencyCode, + to: CurrencyCode, + ): Promise { + const [fromEtb, toEtb] = await Promise.all([ + this.provider.getBaseRate({ from, to: "ETB" }), + this.provider.getBaseRate({ from: to, to: "ETB" }), + ]); + + if (fromEtb === null || toEtb === null || toEtb <= 0 || fromEtb <= 0) { + return null; + } + + return fromEtb / toEtb; + } + + /** + * Every `USD→code` rate in one object, so a pricing pass resolves its conversions once up + * front instead of awaiting inside each line builder. `USD` is always `1`. + * + * The provider caches a whole CBE payload, so the extra codes cost no extra HTTP request. + */ + async getRatesFromUsd( + codes: readonly CurrencyCode[], + ): Promise> { + const unique = Array.from(new Set(codes)); + const rates = await Promise.all( + unique.map((code) => this.getRate("USD", code)), + ); + + return Object.fromEntries( + unique.map((code, i) => [code, rates[i]]), + ) as Record; + } + /** * Health of the underlying rate feed — what was served last and whether it * is currently failing. For operator-facing status displays. diff --git a/packages/api-common/src/services/exchange/exchange.types.ts b/packages/api-common/src/services/exchange/exchange.types.ts index 384ad93b3..bd163743d 100644 --- a/packages/api-common/src/services/exchange/exchange.types.ts +++ b/packages/api-common/src/services/exchange/exchange.types.ts @@ -1,8 +1,13 @@ +import type { PaymentCurrency } from "@edr/types"; + /** * ISO-4217 currency codes the exchange service can handle. - * Extend this union as new currencies are supported. + * + * Aliases the platform-wide `PaymentCurrency` rather than keeping its own union: this list + * and the one the DTOs validate against must never disagree, and they did — the DTOs gained + * currencies this type had not heard of. */ -export type CurrencyCode = "USD" | "ETB"; +export type CurrencyCode = PaymentCurrency; /** A directional currency pair, e.g. `{ from: 'USD', to: 'ETB' }`. */ export interface CurrencyPair {