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 { 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 { 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> { 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; } /** * 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 { 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"); } }