Files
edr-platform/packages/api-common/src/services/exchange/exchange.service.ts
ghost2023 17deb434ae feat(exchange): support n-currency conversion, not just USD/ETB
CbeExchangeProvider now parses every currency CBE quotes (USD, DJF, ...)
from the single existing daily-rates fetch instead of hardcoding USD
only, and ExchangeService.getRate gains a pivot step so a pair neither
quoted directly nor as its inverse (e.g. USD->DJF) is derived by
triangulating through the provider's base currency (ETB). Fallback
rates and the load/save callbacks become per-currency instead of a
single USD->ETB scalar. ExchangeService.getRateTable resolves a whole
currency->target rate table in one call for pricing loops.

No behavior change for existing USD/ETB callers.

Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
2026-09-04 11:52:05 +03:00

110 lines
3.7 KiB
TypeScript

import { Inject, Injectable } from "@nestjs/common";
import { CbeExchangeProvider, CbeProviderStatus } from "./cbe.provider";
import { EXCHANGE_OPTIONS, ExchangeOptions } from "./exchange.options";
import { CURRENCY_CODES, 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).
* 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`.
*/
@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 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<Record<string, number>> {
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(code: CurrencyCode = "USD"): CbeProviderStatus {
return this.provider.getStatus(code);
}
/** 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");
}
}