feat: refactor exchange rate handling by integrating new ExchangeService and removing CbeExchangeService

This commit is contained in:
Marshal
2026-06-20 09:22:21 +00:00
parent 54587a8c55
commit 4a0085ef57
12 changed files with 281 additions and 44 deletions

View File

@@ -51,7 +51,6 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.30" "typeorm": "^0.3.30"
}, },
"devDependencies": { "devDependencies": {
"@edr/api-common": "workspace:*", "@edr/api-common": "workspace:*",

View File

@@ -14,17 +14,13 @@ export default registerAs("app", () => ({
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760), maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53), maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
}, },
// Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts).
cbeExchange: { cbeExchange: {
/** ethio.forex CBET page — scraped for USD buying/selling rates. */ /** ethio.forex CBET page — scraped for USD buying/selling rates. */
scrapeUrl: scrapeUrl:
process.env.CBE_EXCHANGE_SCRAPE_URL ?? process.env.CBE_EXCHANGE_SCRAPE_URL ??
process.env.CBE_EXCHANGE_API_URL ?? process.env.CBE_EXCHANGE_API_URL ??
"https://ethio.forex/bank/CBET", "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), fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130),
cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000), cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000),
}, },

View File

@@ -28,15 +28,15 @@ describe('BookingPricingService — domestic corridor', () => {
let service: BookingPricingService; let service: BookingPricingService;
let bookingsRepository: { calculateWagonCount: jest.Mock }; let bookingsRepository: { calculateWagonCount: jest.Mock };
let ratesService: { findLiveRates: jest.Mock }; let ratesService: { findLiveRates: jest.Mock };
let cbeExchangeService: { getUsdToEtbRate: jest.Mock }; let exchangeService: { getRate: jest.Mock };
beforeEach(() => { beforeEach(() => {
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) }; bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
ratesService = { ratesService = {
findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]), findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]),
}; };
cbeExchangeService = { exchangeService = {
getUsdToEtbRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
}; };
service = new BookingPricingService( service = new BookingPricingService(
@@ -45,7 +45,7 @@ describe('BookingPricingService — domestic corridor', () => {
{} as never, {} as never,
ratesService as never, ratesService as never,
{} as never, {} as never,
cbeExchangeService as never, exchangeService as never,
); );
}); });

View File

@@ -4,7 +4,7 @@ import { ContainerTypesService } from '../rule-engine/services/container-types.s
import { RatesService } from '../rule-engine/services/rates.service'; import { RatesService } from '../rule-engine/services/rates.service';
import { ServiceTypesService } from '../rule-engine/services/service-types.service'; import { ServiceTypesService } from '../rule-engine/services/service-types.service';
import { Rate } from '../rule-engine/entities/rate.entity'; import { Rate } from '../rule-engine/entities/rate.entity';
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service'; import { ExchangeService } from '@edr/api-common';
import { import {
AppliedCargoModifier, AppliedCargoModifier,
BookingEvaluationInput, BookingEvaluationInput,
@@ -41,7 +41,7 @@ export class BookingPricingService {
private readonly containerTypesService: ContainerTypesService, private readonly containerTypesService: ContainerTypesService,
private readonly ratesService: RatesService, private readonly ratesService: RatesService,
private readonly serviceTypesService: ServiceTypesService, private readonly serviceTypesService: ServiceTypesService,
private readonly cbeExchangeService: CbeExchangeService, private readonly exchangeService: ExchangeService,
) {} ) {}
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> { async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
@@ -84,7 +84,7 @@ export class BookingPricingService {
const paymentCurrency = booking.paymentCurrency; const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB'; 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[] = []; const lineItems: PriceLineItemDto[] = [];
let total = 0; let total = 0;
@@ -285,7 +285,7 @@ export class BookingPricingService {
const liveRates = await this.ratesService.findLiveRates(); const liveRates = await this.ratesService.findLiveRates();
const paymentCurrency = booking.paymentCurrency; const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB'; 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 isBulk = booking.freightType === 'BULK';
const rateType = const rateType =

View File

@@ -1,5 +1,7 @@
import { Module, forwardRef } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
// import { CustomersModule } from '../customers/customers.module'; // import { CustomersModule } from '../customers/customers.module';
import { CompaniesModule } from '../companies/companies.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 { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { PaymentModule } from '../payment/payment.module'; import { PaymentModule } from '../payment/payment.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
@Module({ @Module({
imports: [ imports: [
@@ -52,6 +53,11 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
// CustomersModule, // CustomersModule,
RuleEngineModule, RuleEngineModule,
SignaturesModule, SignaturesModule,
ExchangeModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
}),
], ],
controllers: [BookingsController, PayController], controllers: [BookingsController, PayController],
providers: [ providers: [
@@ -68,7 +74,6 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
ContractPricingScheduleBuilder, ContractPricingScheduleBuilder,
ContractRendererService, ContractRendererService,
ContractPdfService, ContractPdfService,
CbeExchangeService,
], ],
exports: [BookingsService, BookingsRepository], exports: [BookingsService, BookingsRepository],
}) })

View File

@@ -17,3 +17,6 @@ export * from "./entities/base.entity";
// Repositories // Repositories
export * from "./repositories/base.repository"; export * from "./repositories/base.repository";
// Services
export * from "./services/exchange";

View File

@@ -1,41 +1,62 @@
import { Injectable, Logger } from '@nestjs/common'; import { Logger } from "@nestjs/common";
import { ConfigService } from '@nestjs/config';
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). */ /** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */
const USD_RATE_REGEX = const USD_RATE_REGEX =
/currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/; /currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/;
@Injectable() /**
export class CbeExchangeService { * Central Bank of Ethiopia (CBE) rate provider.
private readonly logger = new Logger(CbeExchangeService.name); *
* Sources a single canonical direction **USDETB** (selling rate) by
* scraping ethio.forex, caching the result, and falling back to a configured
* rate when the scrape fails. The inverse (ETBUSD) is derived by
* {@link ExchangeService}, so this provider only ever reports USDETB.
*/
export class CbeExchangeProvider implements ExchangeRateProvider {
readonly name = "CBE";
private readonly logger = new Logger(CbeExchangeProvider.name);
private readonly options: Required<ExchangeOptions>;
private cachedRate: number | null = null; private cachedRate: number | null = null;
private cacheExpiresAt = 0; private cacheExpiresAt = 0;
constructor(private readonly configService: ConfigService) {} constructor(options: ExchangeOptions) {
this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) };
}
async getBaseRate(pair: CurrencyPair): Promise<number | null> {
// 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 USDETB **selling** rate scraped from ethio.forex. * Returns the current CBE USDETB **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<number> { private async getUsdToEtbRate(): Promise<number> {
const now = Date.now(); const now = Date.now();
if (this.cachedRate !== null && now < this.cacheExpiresAt) { if (this.cachedRate !== null && now < this.cacheExpiresAt) {
return this.cachedRate; return this.cachedRate;
} }
const scrapeUrl = this.getScrapeUrl(); const { scrapeUrl, fallbackRate, cacheTtlMs, requestTimeoutMs } =
const fallbackRate = this.options;
this.configService.get<number>('app.cbeExchange.fallbackRate') ?? 130;
const cacheTtlMs =
this.configService.get<number>('app.cbeExchange.cacheTtlMs') ?? 3_600_000;
try { try {
const response = await fetch(scrapeUrl, { const response = await fetch(scrapeUrl, {
signal: AbortSignal.timeout(8_000), signal: AbortSignal.timeout(requestTimeoutMs),
headers: { 'User-Agent': 'Mozilla/5.0' }, headers: { "User-Agent": "Mozilla/5.0" },
}); });
if (!response.ok) { if (!response.ok) {
@@ -46,7 +67,7 @@ export class CbeExchangeService {
const rates = this.parseScrapedRates(html); const rates = this.parseScrapedRates(html);
if (!rates) { 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; const rate = rates.selling;
@@ -66,7 +87,9 @@ export class CbeExchangeService {
); );
if (this.cachedRate !== null) { 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; return this.cachedRate;
} }
@@ -74,13 +97,6 @@ export class CbeExchangeService {
} }
} }
private getScrapeUrl(): string {
const configured =
this.configService.get<string>('app.cbeExchange.scrapeUrl') ??
this.configService.get<string>('app.cbeExchange.apiUrl');
return configured?.trim() || DEFAULT_SCRAPE_URL;
}
private parseScrapedRates( private parseScrapedRates(
html: string, html: string,
): { buying: number; selling: number } | null { ): { buying: number; selling: number } | null {
@@ -99,8 +115,15 @@ export class CbeExchangeService {
return html return html
.replace(/&quot;/g, '"') .replace(/&quot;/g, '"')
.replace(/&#34;/g, '"') .replace(/&#34;/g, '"')
.replace(/&amp;/g, '&') .replace(/&amp;/g, "&")
.replace(/&lt;/g, '<') .replace(/&lt;/g, "<")
.replace(/&gt;/g, '>'); .replace(/&gt;/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),
);
}

View File

@@ -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],
};
}
}

View File

@@ -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<ExchangeOptions> = {
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<ExchangeOptions>;
}

View File

@@ -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<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;
}
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<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");
}
}

View File

@@ -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<number | null>;
}

View File

@@ -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";