feat(exchange): serve every currency CBE quotes, not just USD

CbeExchangeProvider fetched the CBE daily-exchange-rates payload, read the
USD entry out of it and threw the other seventeen away — getBaseRate()
returned null for anything but USD to ETB. The feed already publishes DJF
in that same payload (0.9203 ETB per DJF today), so serving more than one
pair costs no extra request.

The provider now parses the whole record into a code to ETB map and caches
that, keyed by ISO code. Entries CBE publishes as 0 or null are skipped
rather than stored — a zero rate would silently zero an invoice line.

ExchangeService gains a fourth resolution step. DJF to USD is neither a
direct pair nor an inverse of one, because the provider only ever quotes
against ETB, so the two ETB legs are crossed instead of the pair being
declared unavailable.

Fallbacks stay a single stored number. DJF is hard-pegged to USD at
177.721, so the offline legs derive from the stored USD rate through the
peg — that reproduces CBE's own DJF quote to four decimals, and a second
persisted rate would only be a second thing that can go stale.

getRatesFromUsd() resolves a whole pricing pass's conversions up front so
line builders do not await inside a loop. Because it asks for several
codes concurrently, a cold cache would have opened one HTTP request per
currency for the same payload; an in-flight promise is now shared.

The spec lives in freight-api because api-common has no test setup of its
own, and adding one for a single file is not worth the framework.
This commit is contained in:
Nathnael
2026-08-29 08:12:33 +00:00
parent 427665f5b2
commit b3f89a41b9
4 changed files with 301 additions and 84 deletions

View File

@@ -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<string, number | null> = {}) => [
{
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;

View File

@@ -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<string, number> | 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<Map<string, number>> | 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<number | null> {
// 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<number> {
private async getRateToEtb(code: CurrencyCode): Promise<number | null> {
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<Map<string, number>> {
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<string, number> {
const rates = new Map<string, number>();
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;
}
}

View File

@@ -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<number | null> {
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<Record<CurrencyCode, number>> {
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<CurrencyCode, number>;
}
/**
* Health of the underlying rate feed — what was served last and whether it
* is currently failing. For operator-facing status displays.

View File

@@ -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 {