mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: refactor exchange rate handling by integrating new ExchangeService and removing CbeExchangeService
This commit is contained in:
@@ -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:*",
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 — **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<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 USD→ETB **selling** rate scraped from ethio.forex.
|
* 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<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(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.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),
|
||||||
|
);
|
||||||
|
}
|
||||||
52
packages/api-common/src/services/exchange/exchange.module.ts
Normal file
52
packages/api-common/src/services/exchange/exchange.module.ts
Normal 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],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
31
packages/api-common/src/services/exchange/exchange.types.ts
Normal file
31
packages/api-common/src/services/exchange/exchange.types.ts
Normal 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>;
|
||||||
|
}
|
||||||
10
packages/api-common/src/services/exchange/index.ts
Normal file
10
packages/api-common/src/services/exchange/index.ts
Normal 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";
|
||||||
Reference in New Issue
Block a user