refactor(server): migrate ExchangeRates module to NestJS

- Convert TypeDI services to NestJS @Injectable() pattern
- Replace Express router with NestJS @Controller() decorators
- Migrate express-validator to class-validator DTOs
- Add Swagger/OpenAPI documentation decorators
- Fix import paths for TenantMetadata and ServiceError
- Add ExchangeRatesModule to AppModule imports
This commit is contained in:
Ahmed Bouhuolia
2026-03-14 05:16:06 +02:00
parent 6515bd2a60
commit 3706e048b6
12 changed files with 351 additions and 0 deletions

View File

@@ -99,6 +99,7 @@ import { UsersModule } from '../UsersModule/Users.module';
import { ContactsModule } from '../Contacts/Contacts.module';
import { BankingPlaidModule } from '../BankingPlaid/BankingPlaid.module';
import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.module';
import { ExchangeRatesModule } from '../ExchangeRates/ExchangeRates.module';
import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module';
import { BillLandedCostsModule } from '../BillLandedCosts/BillLandedCosts.module';
import { SocketModule } from '../Socket/Socket.module';
@@ -256,6 +257,7 @@ import { AppThrottleModule } from './AppThrottle.module';
UsersModule,
ContactsModule,
SocketModule,
ExchangeRatesModule,
],
controllers: [AppController],
providers: [

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { ExchangeRatesService } from './ExchangeRates.service';
import { ExchangeRateLatestDTO, EchangeRateLatestPOJO } from './ExchangeRates.types';
@Injectable()
export class ExchangeRateApplication {
constructor(private readonly exchangeRateService: ExchangeRatesService) {}
/**
* Gets the latest exchange rate.
* @param {number} tenantId
* @param {ExchangeRateLatestDTO} exchangeRateLatestDTO
* @returns {Promise<EchangeRateLatestPOJO>}
*/
public latest(
tenantId: number,
exchangeRateLatestDTO: ExchangeRateLatestDTO,
): Promise<EchangeRateLatestPOJO> {
return this.exchangeRateService.latest(tenantId, exchangeRateLatestDTO);
}
}

View File

@@ -0,0 +1,73 @@
import {
Controller,
Get,
Query,
Req,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { Request } from 'express';
import {
ApiOperation,
ApiTags,
ApiResponse,
ApiQuery,
} from '@nestjs/swagger';
import { ExchangeRateApplication } from './ExchangeRates.application';
import { ExchangeRateLatestQueryDto } from './dtos/ExchangeRateLatestQuery.dto';
import { ExchangeRateLatestResponseDto } from './dtos/ExchangeRateLatestResponse.dto';
interface RequestWithTenantId extends Request {
tenantId: number;
}
@Controller('exchange-rates')
@ApiTags('Exchange Rates')
export class ExchangeRatesController {
constructor(private readonly exchangeRateApp: ExchangeRateApplication) {}
@Get('/latest')
@UsePipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
)
@ApiOperation({ summary: 'Get the latest exchange rate' })
@ApiQuery({
name: 'from_currency',
description: 'Source currency code (ISO 4217)',
required: false,
type: String,
example: 'USD',
})
@ApiQuery({
name: 'to_currency',
description: 'Target currency code (ISO 4217)',
required: false,
type: String,
example: 'EUR',
})
@ApiResponse({
status: 200,
description: 'Successfully retrieved exchange rate',
type: ExchangeRateLatestResponseDto,
})
@ApiResponse({
status: 400,
description: 'Invalid currency code or service error',
})
async getLatestExchangeRate(
@Query() query: ExchangeRateLatestQueryDto,
@Req() req: RequestWithTenantId,
): Promise<ExchangeRateLatestResponseDto> {
const tenantId = req.tenantId;
const exchangeRate = await this.exchangeRateApp.latest(tenantId, {
fromCurrency: query.from_currency,
toCurrency: query.to_currency,
});
return exchangeRate;
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ExchangeRatesController } from './ExchangeRates.controller';
import { ExchangeRatesService } from './ExchangeRates.service';
import { ExchangeRateApplication } from './ExchangeRates.application';
@Module({
providers: [ExchangeRatesService, ExchangeRateApplication],
controllers: [ExchangeRatesController],
exports: [ExchangeRatesService, ExchangeRateApplication],
})
export class ExchangeRatesModule {}

View File

@@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { ExchangeRate } from './lib/ExchangeRate';
import { ExchangeRateServiceType } from './lib/types';
import { TenantMetadata } from '@/modules/System/models/TenantMetadataModel';
import { ExchangeRateLatestDTO, EchangeRateLatestPOJO } from './ExchangeRates.types';
@Injectable()
export class ExchangeRatesService {
/**
* Gets the latest exchange rate.
* @param {number} tenantId
* @param {ExchangeRateLatestDTO} exchangeRateLatestDTO
* @returns {EchangeRateLatestPOJO}
*/
public async latest(
tenantId: number,
exchangeRateLatestDTO: ExchangeRateLatestDTO,
): Promise<EchangeRateLatestPOJO> {
const organization = await TenantMetadata.query().findOne({ tenantId });
// Assign the organization base currency as a default currency
// if no currency is provided
const fromCurrency =
exchangeRateLatestDTO.fromCurrency || organization.baseCurrency;
const toCurrency =
exchangeRateLatestDTO.toCurrency || organization.baseCurrency;
const exchange = new ExchangeRate(ExchangeRateServiceType.OpenExchangeRate);
const exchangeRate = await exchange.latest(fromCurrency, toCurrency);
return {
baseCurrency: fromCurrency,
toCurrency: exchangeRateLatestDTO.toCurrency || toCurrency,
exchangeRate,
};
}
}

View File

@@ -0,0 +1,10 @@
export interface ExchangeRateLatestDTO {
fromCurrency?: string;
toCurrency?: string;
}
export interface EchangeRateLatestPOJO {
baseCurrency: string;
toCurrency: string;
exchangeRate: number;
}

View File

@@ -0,0 +1,22 @@
import { IsOptional, IsString, Length } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class ExchangeRateLatestQueryDto {
@ApiPropertyOptional({
description: 'The source currency code (ISO 4217)',
example: 'USD',
})
@IsOptional()
@IsString()
@Length(3, 3, { message: 'Currency code must be 3 characters (ISO 4217)' })
from_currency?: string;
@ApiPropertyOptional({
description: 'The target currency code (ISO 4217)',
example: 'EUR',
})
@IsOptional()
@IsString()
@Length(3, 3, { message: 'Currency code must be 3 characters (ISO 4217)' })
to_currency?: string;
}

View File

@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
export class ExchangeRateLatestResponseDto {
@ApiProperty({
description: 'The base currency code',
example: 'USD',
})
baseCurrency: string;
@ApiProperty({
description: 'The target currency code',
example: 'EUR',
})
toCurrency: string;
@ApiProperty({
description: 'The exchange rate value',
example: 0.85,
})
exchangeRate: number;
}

View File

@@ -0,0 +1,7 @@
export * from './ExchangeRates.module';
export * from './ExchangeRates.controller';
export * from './ExchangeRates.service';
export * from './ExchangeRates.application';
export * from './dtos/ExchangeRateLatestQuery.dto';
export * from './dtos/ExchangeRateLatestResponse.dto';
export * from './lib/types';

View File

@@ -0,0 +1,45 @@
import { OpenExchangeRate } from './OpenExchangeRate';
import { ExchangeRateServiceType, IExchangeRateService } from './types';
export class ExchangeRate {
private exchangeRateService: IExchangeRateService;
private exchangeRateServiceType: ExchangeRateServiceType;
/**
* Constructor method.
* @param {ExchangeRateServiceType} service
*/
constructor(service: ExchangeRateServiceType) {
this.exchangeRateServiceType = service;
this.initService();
}
/**
* Initialize the exchange rate service based on the service type.
*/
private initService() {
if (
this.exchangeRateServiceType === ExchangeRateServiceType.OpenExchangeRate
) {
this.setExchangeRateService(new OpenExchangeRate());
}
}
/**
* Sets the exchange rate service.
* @param {IExchangeRateService} service
*/
private setExchangeRateService(service: IExchangeRateService) {
this.exchangeRateService = service;
}
/**
* Gets the latest exchange rate.
* @param {string} baseCurrency
* @param {string} toCurrency
* @returns {number}
*/
public latest(baseCurrency: string, toCurrency: string): Promise<number> {
return this.exchangeRateService.latest(baseCurrency, toCurrency);
}
}

View File

@@ -0,0 +1,85 @@
import Axios from 'axios';
import {
EchangeRateErrors,
IExchangeRateService,
OPEN_EXCHANGE_RATE_LATEST_URL,
} from './types';
import { ServiceError } from '@/modules/Items/ServiceError';
export class OpenExchangeRate implements IExchangeRateService {
private appId: string;
constructor(appId?: string) {
this.appId = appId || process.env.OPEN_EXCHANGE_RATE_APP_ID || '';
}
/**
* Gets the latest exchange rate.
* @param {string} baseCurrency
* @param {string} toCurrency
* @returns {Promise<number>}
*/
public async latest(
baseCurrency: string,
toCurrency: string
): Promise<number> {
// Validates the Open Exchange Rate api id early.
this.validateApiIdExistance();
try {
const result = await Axios.get(OPEN_EXCHANGE_RATE_LATEST_URL, {
params: {
app_id: this.appId,
base: baseCurrency,
symbols: toCurrency,
},
});
return result.data.rates[toCurrency] || (1 as number);
} catch (error) {
this.handleLatestErrors(error);
}
}
/**
* Validates the Open Exchange Rate api id.
* @throws {ServiceError}
*/
private validateApiIdExistance() {
if (!this.appId) {
throw new ServiceError(
EchangeRateErrors.EX_RATE_SERVICE_API_KEY_REQUIRED,
'Invalid App ID provided. Please sign up at https://openexchangerates.org/signup, or contact support@openexchangerates.org.'
);
}
}
/**
* Handles the latest errors.
* @param {any} error
* @throws {ServiceError}
*/
private handleLatestErrors(error: any) {
if (error.response?.data?.message === 'missing_app_id') {
throw new ServiceError(
EchangeRateErrors.EX_RATE_SERVICE_API_KEY_REQUIRED,
'Invalid App ID provided. Please sign up at https://openexchangerates.org/signup, or contact support@openexchangerates.org.'
);
} else if (error.response?.data?.message === 'invalid_app_id') {
throw new ServiceError(
EchangeRateErrors.EX_RATE_SERVICE_API_KEY_REQUIRED,
'Invalid App ID provided. Please sign up at https://openexchangerates.org/signup, or contact support@openexchangerates.org.'
);
} else if (error.response?.data?.message === 'not_allowed') {
throw new ServiceError(
EchangeRateErrors.EX_RATE_SERVICE_NOT_ALLOWED,
'Getting the exchange rate from the given base currency to the given currency is not allowed.'
);
} else if (error.response?.data?.message === 'invalid_base') {
throw new ServiceError(
EchangeRateErrors.EX_RATE_INVALID_BASE_CURRENCY,
'The given base currency is invalid.'
);
}
throw error;
}
}

View File

@@ -0,0 +1,17 @@
export interface IExchangeRateService {
latest(baseCurrency: string, toCurrency: string): Promise<number>;
}
export enum ExchangeRateServiceType {
OpenExchangeRate = 'OpenExchangeRate',
}
export enum EchangeRateErrors {
EX_RATE_SERVICE_NOT_ALLOWED = 'EX_RATE_SERVICE_NOT_ALLOWED',
EX_RATE_LIMIT_EXCEEDED = 'EX_RATE_LIMIT_EXCEEDED',
EX_RATE_SERVICE_API_KEY_REQUIRED = 'EX_RATE_SERVICE_API_KEY_REQUIRED',
EX_RATE_INVALID_BASE_CURRENCY = 'EX_RATE_INVALID_BASE_CURRENCY',
}
export const OPEN_EXCHANGE_RATE_LATEST_URL =
'https://openexchangerates.org/api/latest.json';