diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 074f227d9..506ec86f0 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -51,7 +51,6 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.30" - }, "devDependencies": { "@edr/api-common": "workspace:*", diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index fa8644945..e493cc393 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -14,17 +14,13 @@ export default registerAs("app", () => ({ maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760), maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53), }, + // Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts). cbeExchange: { /** ethio.forex CBET page — scraped for USD buying/selling rates. */ scrapeUrl: process.env.CBE_EXCHANGE_SCRAPE_URL ?? process.env.CBE_EXCHANGE_API_URL ?? "https://ethio.forex/bank/CBET", - /** @deprecated use scrapeUrl — kept for backward-compatible config reads */ - apiUrl: - process.env.CBE_EXCHANGE_SCRAPE_URL ?? - process.env.CBE_EXCHANGE_API_URL ?? - "https://ethio.forex/bank/CBET", fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130), cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000), }, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 4ba93626f..471fcb6f2 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -28,15 +28,15 @@ describe('BookingPricingService — domestic corridor', () => { let service: BookingPricingService; let bookingsRepository: { calculateWagonCount: jest.Mock }; let ratesService: { findLiveRates: jest.Mock }; - let cbeExchangeService: { getUsdToEtbRate: jest.Mock }; + let exchangeService: { getRate: jest.Mock }; beforeEach(() => { bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) }; ratesService = { findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]), }; - cbeExchangeService = { - getUsdToEtbRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), + exchangeService = { + getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), }; service = new BookingPricingService( @@ -45,7 +45,7 @@ describe('BookingPricingService — domestic corridor', () => { {} as never, ratesService as never, {} as never, - cbeExchangeService as never, + exchangeService as never, ); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 14d8a8dbe..fbafaa911 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -4,7 +4,7 @@ import { ContainerTypesService } from '../rule-engine/services/container-types.s import { RatesService } from '../rule-engine/services/rates.service'; import { ServiceTypesService } from '../rule-engine/services/service-types.service'; import { Rate } from '../rule-engine/entities/rate.entity'; -import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service'; +import { ExchangeService } from '@edr/api-common'; import { AppliedCargoModifier, BookingEvaluationInput, @@ -41,7 +41,7 @@ export class BookingPricingService { private readonly containerTypesService: ContainerTypesService, private readonly ratesService: RatesService, private readonly serviceTypesService: ServiceTypesService, - private readonly cbeExchangeService: CbeExchangeService, + private readonly exchangeService: ExchangeService, ) {} async generatePrice(bookingId: string): Promise { @@ -84,7 +84,7 @@ export class BookingPricingService { const paymentCurrency = booking.paymentCurrency; const isEtbBooking = paymentCurrency === 'ETB'; - const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1; + const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; const lineItems: PriceLineItemDto[] = []; let total = 0; @@ -285,7 +285,7 @@ export class BookingPricingService { const liveRates = await this.ratesService.findLiveRates(); const paymentCurrency = booking.paymentCurrency; const isEtbBooking = paymentCurrency === 'ETB'; - const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1; + const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; const isBulk = booking.freightType === 'BULK'; const rateType = diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index f55a5a0f8..c318c9b40 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,5 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; // import { CustomersModule } from '../customers/customers.module'; import { CompaniesModule } from '../companies/companies.module'; @@ -31,7 +33,6 @@ import { ContractTemplateResolver } from '../../contracts/contract-template.reso import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; import { PaymentModule } from '../payment/payment.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; -import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service'; @Module({ imports: [ @@ -52,6 +53,11 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service'; // CustomersModule, RuleEngineModule, SignaturesModule, + ExchangeModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService): ExchangeOptions => + config.get('app.cbeExchange') ?? {}, + }), ], controllers: [BookingsController, PayController], providers: [ @@ -68,7 +74,6 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service'; ContractPricingScheduleBuilder, ContractRendererService, ContractPdfService, - CbeExchangeService, ], exports: [BookingsService, BookingsRepository], }) diff --git a/packages/api-common/src/index.ts b/packages/api-common/src/index.ts index 55ac1c99d..f0a25158d 100644 --- a/packages/api-common/src/index.ts +++ b/packages/api-common/src/index.ts @@ -17,3 +17,6 @@ export * from "./entities/base.entity"; // Repositories export * from "./repositories/base.repository"; + +// Services +export * from "./services/exchange"; diff --git a/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts b/packages/api-common/src/services/exchange/cbe.provider.ts similarity index 50% rename from apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts rename to packages/api-common/src/services/exchange/cbe.provider.ts index 27e89896b..489b1a4e3 100644 --- a/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts +++ b/packages/api-common/src/services/exchange/cbe.provider.ts @@ -1,41 +1,62 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Logger } from "@nestjs/common"; -const DEFAULT_SCRAPE_URL = 'https://ethio.forex/bank/CBET'; +import { EXCHANGE_DEFAULTS, ExchangeOptions } from "./exchange.options"; +import { + CurrencyPair, + ExchangeRateProvider, +} from "./exchange.types"; /** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */ const USD_RATE_REGEX = /currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/; -@Injectable() -export class CbeExchangeService { - private readonly logger = new Logger(CbeExchangeService.name); +/** + * Central Bank of Ethiopia (CBE) rate provider. + * + * Sources a single canonical direction — **USD→ETB** (selling rate) — by + * scraping ethio.forex, caching the result, and falling back to a configured + * rate when the scrape fails. The inverse (ETB→USD) is derived by + * {@link ExchangeService}, so this provider only ever reports USD→ETB. + */ +export class CbeExchangeProvider implements ExchangeRateProvider { + readonly name = "CBE"; + + private readonly logger = new Logger(CbeExchangeProvider.name); + private readonly options: Required; private cachedRate: number | null = null; private cacheExpiresAt = 0; - constructor(private readonly configService: ConfigService) {} + constructor(options: ExchangeOptions) { + this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) }; + } + + async getBaseRate(pair: CurrencyPair): Promise { + // CBE only sources USD→ETB; everything else is derived upstream. + if (pair.from !== "USD" || pair.to !== "ETB") { + return null; + } + return this.getUsdToEtbRate(); + } /** * Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex. - * Cached for CBE_EXCHANGE_CACHE_TTL_MS; falls back to CBE_EXCHANGE_FALLBACK_RATE on failure. + * Cached for `cacheTtlMs`; on failure reuses the last cached rate, else + * returns `fallbackRate`. */ - async getUsdToEtbRate(): Promise { + private async getUsdToEtbRate(): Promise { const now = Date.now(); if (this.cachedRate !== null && now < this.cacheExpiresAt) { return this.cachedRate; } - const scrapeUrl = this.getScrapeUrl(); - const fallbackRate = - this.configService.get('app.cbeExchange.fallbackRate') ?? 130; - const cacheTtlMs = - this.configService.get('app.cbeExchange.cacheTtlMs') ?? 3_600_000; + const { scrapeUrl, fallbackRate, cacheTtlMs, requestTimeoutMs } = + this.options; try { const response = await fetch(scrapeUrl, { - signal: AbortSignal.timeout(8_000), - headers: { 'User-Agent': 'Mozilla/5.0' }, + signal: AbortSignal.timeout(requestTimeoutMs), + headers: { "User-Agent": "Mozilla/5.0" }, }); if (!response.ok) { @@ -46,7 +67,7 @@ export class CbeExchangeService { const rates = this.parseScrapedRates(html); if (!rates) { - throw new Error('USD rate not found in ethio.forex page HTML'); + throw new Error("USD rate not found in ethio.forex page HTML"); } const rate = rates.selling; @@ -66,7 +87,9 @@ export class CbeExchangeService { ); if (this.cachedRate !== null) { - this.logger.warn(`Using previously cached CBE rate: ${this.cachedRate}`); + this.logger.warn( + `Using previously cached CBE rate: ${this.cachedRate}`, + ); return this.cachedRate; } @@ -74,13 +97,6 @@ export class CbeExchangeService { } } - private getScrapeUrl(): string { - const configured = - this.configService.get('app.cbeExchange.scrapeUrl') ?? - this.configService.get('app.cbeExchange.apiUrl'); - return configured?.trim() || DEFAULT_SCRAPE_URL; - } - private parseScrapedRates( html: string, ): { buying: number; selling: number } | null { @@ -99,8 +115,15 @@ export class CbeExchangeService { return html .replace(/"/g, '"') .replace(/"/g, '"') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>'); + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">"); } } + +/** Drops keys whose value is `undefined` so they don't override defaults via spread. */ +function stripUndefined(options: ExchangeOptions): ExchangeOptions { + return Object.fromEntries( + Object.entries(options).filter(([, value]) => value !== undefined), + ); +} diff --git a/packages/api-common/src/services/exchange/exchange.module.ts b/packages/api-common/src/services/exchange/exchange.module.ts new file mode 100644 index 000000000..b9c8e80e1 --- /dev/null +++ b/packages/api-common/src/services/exchange/exchange.module.ts @@ -0,0 +1,52 @@ +import { DynamicModule, Module, Provider } from "@nestjs/common"; + +import { + EXCHANGE_OPTIONS, + ExchangeAsyncOptions, + ExchangeOptions, +} from "./exchange.options"; +import { ExchangeService } from "./exchange.service"; + +/** + * Provides {@link ExchangeService} (currency conversion, currently CBE-backed). + * + * Register once in the app root, then inject `ExchangeService` anywhere: + * + * ```ts + * // static config + * ExchangeModule.forRoot({ fallbackRate: 135 }) + * + * // config resolved from ConfigService + * ExchangeModule.forRootAsync({ + * inject: [ConfigService], + * useFactory: (config: ConfigService) => config.get('app.exchange'), + * }) + * ``` + */ +@Module({}) +export class ExchangeModule { + static forRoot(options: ExchangeOptions = {}): DynamicModule { + return { + module: ExchangeModule, + providers: [ + { provide: EXCHANGE_OPTIONS, useValue: options }, + ExchangeService, + ], + exports: [ExchangeService], + }; + } + + static forRootAsync(options: ExchangeAsyncOptions): DynamicModule { + const optionsProvider: Provider = { + provide: EXCHANGE_OPTIONS, + useFactory: options.useFactory, + inject: (options.inject ?? []) as never[], + }; + + return { + module: ExchangeModule, + providers: [optionsProvider, ExchangeService], + exports: [ExchangeService], + }; + } +} diff --git a/packages/api-common/src/services/exchange/exchange.options.ts b/packages/api-common/src/services/exchange/exchange.options.ts new file mode 100644 index 000000000..e009b5f99 --- /dev/null +++ b/packages/api-common/src/services/exchange/exchange.options.ts @@ -0,0 +1,46 @@ +/** Injection token carrying the resolved {@link ExchangeOptions}. */ +export const EXCHANGE_OPTIONS = Symbol("EXCHANGE_OPTIONS"); + +/** Configuration for the {@link ExchangeService} and its CBE provider. */ +export interface ExchangeOptions { + /** + * ethio.forex CBET page scraped for USD buying/selling rates. + * @default 'https://ethio.forex/bank/CBET' + */ + scrapeUrl?: string; + + /** + * Base USD→ETB rate used when scraping fails and no previously cached rate + * exists. The ETB→USD direction is derived as its inverse. + * @default 130 + */ + fallbackRate?: number; + + /** + * How long a successfully fetched rate is cached, in milliseconds. + * @default 3_600_000 (1 hour) + */ + cacheTtlMs?: number; + + /** + * Timeout for the scrape HTTP request, in milliseconds. + * @default 8_000 + */ + requestTimeoutMs?: number; +} + +/** Defaults applied to any unset {@link ExchangeOptions} field. */ +export const EXCHANGE_DEFAULTS: Required = { + scrapeUrl: "https://ethio.forex/bank/CBET", + fallbackRate: 130, + cacheTtlMs: 3_600_000, + requestTimeoutMs: 8_000, +}; + +/** Factory contract for {@link ExchangeModule.forRootAsync}. */ +export interface ExchangeAsyncOptions { + /** Providers to inject into {@link useFactory} (e.g. `[ConfigService]`). */ + inject?: unknown[]; + /** Returns the options, possibly async. */ + useFactory: (...args: never[]) => ExchangeOptions | Promise; +} diff --git a/packages/api-common/src/services/exchange/exchange.service.ts b/packages/api-common/src/services/exchange/exchange.service.ts new file mode 100644 index 000000000..c1efdd923 --- /dev/null +++ b/packages/api-common/src/services/exchange/exchange.service.ts @@ -0,0 +1,72 @@ +import { Inject, Injectable } from "@nestjs/common"; + +import { CbeExchangeProvider } 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). + * 3. Provider supplies the inverse → return `1 / inverse` (e.g. ETB→USD). + * + * 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; + } + + throw new Error( + `No exchange rate available for ${from}→${to} from provider ${this.provider.name}`, + ); + } + + /** 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"); + } +} diff --git a/packages/api-common/src/services/exchange/exchange.types.ts b/packages/api-common/src/services/exchange/exchange.types.ts new file mode 100644 index 000000000..384ad93b3 --- /dev/null +++ b/packages/api-common/src/services/exchange/exchange.types.ts @@ -0,0 +1,31 @@ +/** + * ISO-4217 currency codes the exchange service can handle. + * Extend this union as new currencies are supported. + */ +export type CurrencyCode = "USD" | "ETB"; + +/** A directional currency pair, e.g. `{ from: 'USD', to: 'ETB' }`. */ +export interface CurrencyPair { + from: CurrencyCode; + to: CurrencyCode; +} + +/** + * A source of base exchange rates. Implementations fetch (scrape/API) the rate + * for a single canonical direction; the {@link ExchangeService} derives the + * inverse and same-currency (1:1) cases on top. + * + * Today the only implementation is the CBE (Central Bank of Ethiopia) provider, + * which sources USD→ETB. New providers (other banks, other base pairs) can be + * added without touching consumers. + */ +export interface ExchangeRateProvider { + /** Human-readable provider name, used in logs (e.g. `'CBE'`). */ + readonly name: string; + + /** + * Returns the rate for `pair` (units of `pair.to` per 1 unit of `pair.from`), + * or `null` if this provider cannot supply that pair directly. + */ + getBaseRate(pair: CurrencyPair): Promise; +} diff --git a/packages/api-common/src/services/exchange/index.ts b/packages/api-common/src/services/exchange/index.ts new file mode 100644 index 000000000..c5e9c960a --- /dev/null +++ b/packages/api-common/src/services/exchange/index.ts @@ -0,0 +1,10 @@ +export { ExchangeService } from "./exchange.service"; +export { ExchangeModule } from "./exchange.module"; +export { CbeExchangeProvider } from "./cbe.provider"; +export { EXCHANGE_OPTIONS, EXCHANGE_DEFAULTS } from "./exchange.options"; +export type { ExchangeOptions, ExchangeAsyncOptions } from "./exchange.options"; +export type { + CurrencyCode, + CurrencyPair, + ExchangeRateProvider, +} from "./exchange.types";