Files
edr-platform/packages/api-common/src/services/exchange/exchange.service.ts
Nathnael b3f89a41b9 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.
2026-08-29 08:12:33 +00:00

128 lines
4.0 KiB
TypeScript

import { Inject, Injectable } from "@nestjs/common";
import { CbeExchangeProvider, CbeProviderStatus } from "./cbe.provider";
import { EXCHANGE_OPTIONS, ExchangeOptions } from "./exchange.options";
import { CurrencyCode } from "./exchange.types";
/**
* Currency exchange service. Resolves the rate between any supported currency
* pair and converts amounts, backed by a rate provider (currently CBE).
*
* Resolution order for `getRate(from, to)`:
* 1. `from === to` → `1`.
* 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`.
*/
@Injectable()
export class ExchangeService {
private readonly provider: CbeExchangeProvider;
constructor(@Inject(EXCHANGE_OPTIONS) options: ExchangeOptions) {
this.provider = new CbeExchangeProvider(options);
}
/**
* Returns the rate to convert 1 unit of `from` into `to`
* (i.e. `amountInTo = amountInFrom * getRate(from, to)`).
*/
async getRate(from: CurrencyCode, to: CurrencyCode): Promise<number> {
if (from === to) {
return 1;
}
const direct = await this.provider.getBaseRate({ from, to });
if (direct !== null) {
return direct;
}
const inverse = await this.provider.getBaseRate({ from: to, to: from });
if (inverse !== null && inverse > 0) {
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.
*/
getProviderStatus(): CbeProviderStatus {
return this.provider.getStatus();
}
/** Converts `amount` from one currency to another using {@link getRate}. */
async convert(
amount: number,
from: CurrencyCode,
to: CurrencyCode,
): Promise<number> {
const rate = await this.getRate(from, to);
return amount * rate;
}
/**
* Convenience alias for `getRate('USD', 'ETB')`.
* @deprecated Prefer {@link getRate}; kept for existing callers.
*/
getUsdToEtbRate(): Promise<number> {
return this.getRate("USD", "ETB");
}
/** Convenience alias for `getRate('ETB', 'USD')`. */
getEtbToUsdRate(): Promise<number> {
return this.getRate("ETB", "USD");
}
}