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 { 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> { 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 { 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 { return this.getRate("USD", "ETB"); } /** Convenience alias for `getRate('ETB', 'USD')`. */ getEtbToUsdRate(): Promise { return this.getRate("ETB", "USD"); } }