mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 04:33:40 +00:00
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.
128 lines
4.0 KiB
TypeScript
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");
|
|
}
|
|
}
|