mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-04-19 10:44:06 +00:00
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:
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface ExchangeRateLatestDTO {
|
||||
fromCurrency?: string;
|
||||
toCurrency?: string;
|
||||
}
|
||||
|
||||
export interface EchangeRateLatestPOJO {
|
||||
baseCurrency: string;
|
||||
toCurrency: string;
|
||||
exchangeRate: number;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
7
packages/server/src/modules/ExchangeRates/index.ts
Normal file
7
packages/server/src/modules/ExchangeRates/index.ts
Normal 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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
17
packages/server/src/modules/ExchangeRates/lib/types.ts
Normal file
17
packages/server/src/modules/ExchangeRates/lib/types.ts
Normal 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';
|
||||
Reference in New Issue
Block a user