diff --git a/packages/server/src/modules/App/App.module.ts b/packages/server/src/modules/App/App.module.ts index 38852d9ec..8ae530926 100644 --- a/packages/server/src/modules/App/App.module.ts +++ b/packages/server/src/modules/App/App.module.ts @@ -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: [ diff --git a/packages/server/src/modules/ExchangeRates/ExchangeRates.application.ts b/packages/server/src/modules/ExchangeRates/ExchangeRates.application.ts new file mode 100644 index 000000000..ff12bca40 --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/ExchangeRates.application.ts @@ -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} + */ + public latest( + tenantId: number, + exchangeRateLatestDTO: ExchangeRateLatestDTO, + ): Promise { + return this.exchangeRateService.latest(tenantId, exchangeRateLatestDTO); + } +} diff --git a/packages/server/src/modules/ExchangeRates/ExchangeRates.controller.ts b/packages/server/src/modules/ExchangeRates/ExchangeRates.controller.ts new file mode 100644 index 000000000..00f33e011 --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/ExchangeRates.controller.ts @@ -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 { + const tenantId = req.tenantId; + + const exchangeRate = await this.exchangeRateApp.latest(tenantId, { + fromCurrency: query.from_currency, + toCurrency: query.to_currency, + }); + return exchangeRate; + } +} diff --git a/packages/server/src/modules/ExchangeRates/ExchangeRates.module.ts b/packages/server/src/modules/ExchangeRates/ExchangeRates.module.ts new file mode 100644 index 000000000..e9689199e --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/ExchangeRates.module.ts @@ -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 {} diff --git a/packages/server/src/modules/ExchangeRates/ExchangeRates.service.ts b/packages/server/src/modules/ExchangeRates/ExchangeRates.service.ts new file mode 100644 index 000000000..074f587b7 --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/ExchangeRates.service.ts @@ -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 { + 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, + }; + } +} diff --git a/packages/server/src/modules/ExchangeRates/ExchangeRates.types.ts b/packages/server/src/modules/ExchangeRates/ExchangeRates.types.ts new file mode 100644 index 000000000..2a5be04e0 --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/ExchangeRates.types.ts @@ -0,0 +1,10 @@ +export interface ExchangeRateLatestDTO { + fromCurrency?: string; + toCurrency?: string; +} + +export interface EchangeRateLatestPOJO { + baseCurrency: string; + toCurrency: string; + exchangeRate: number; +} diff --git a/packages/server/src/modules/ExchangeRates/dtos/ExchangeRateLatestQuery.dto.ts b/packages/server/src/modules/ExchangeRates/dtos/ExchangeRateLatestQuery.dto.ts new file mode 100644 index 000000000..0a9d12f1d --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/dtos/ExchangeRateLatestQuery.dto.ts @@ -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; +} diff --git a/packages/server/src/modules/ExchangeRates/dtos/ExchangeRateLatestResponse.dto.ts b/packages/server/src/modules/ExchangeRates/dtos/ExchangeRateLatestResponse.dto.ts new file mode 100644 index 000000000..9ff87d739 --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/dtos/ExchangeRateLatestResponse.dto.ts @@ -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; +} diff --git a/packages/server/src/modules/ExchangeRates/index.ts b/packages/server/src/modules/ExchangeRates/index.ts new file mode 100644 index 000000000..4281d9ece --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/index.ts @@ -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'; diff --git a/packages/server/src/modules/ExchangeRates/lib/ExchangeRate.ts b/packages/server/src/modules/ExchangeRates/lib/ExchangeRate.ts new file mode 100644 index 000000000..d0dcae12e --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/lib/ExchangeRate.ts @@ -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 { + return this.exchangeRateService.latest(baseCurrency, toCurrency); + } +} \ No newline at end of file diff --git a/packages/server/src/modules/ExchangeRates/lib/OpenExchangeRate.ts b/packages/server/src/modules/ExchangeRates/lib/OpenExchangeRate.ts new file mode 100644 index 000000000..be8d2da2d --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/lib/OpenExchangeRate.ts @@ -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} + */ + public async latest( + baseCurrency: string, + toCurrency: string + ): Promise { + // 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; + } +} diff --git a/packages/server/src/modules/ExchangeRates/lib/types.ts b/packages/server/src/modules/ExchangeRates/lib/types.ts new file mode 100644 index 000000000..aa1f45481 --- /dev/null +++ b/packages/server/src/modules/ExchangeRates/lib/types.ts @@ -0,0 +1,17 @@ +export interface IExchangeRateService { + latest(baseCurrency: string, toCurrency: string): Promise; +} + +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'; \ No newline at end of file diff --git a/shared/sdk-ts/src/exchange-rates.ts b/shared/sdk-ts/src/exchange-rates.ts new file mode 100644 index 000000000..9c1fac18c --- /dev/null +++ b/shared/sdk-ts/src/exchange-rates.ts @@ -0,0 +1,41 @@ +import type { ApiFetcher } from './fetch-utils'; + +export const EXCHANGE_RATES_ROUTES = { + LATEST: '/api/exchange-rates/latest', +} as const; + +/** Query params for GET /api/exchange-rates/latest */ +export interface ExchangeRateLatestQuery { + /** Source currency code (ISO 4217) */ + from_currency?: string; + /** Target currency code (ISO 4217) */ + to_currency?: string; +} + +/** Response for GET /api/exchange-rates/latest */ +export interface ExchangeRateLatestResponse { + /** The base currency code */ + baseCurrency: string; + /** The target currency code */ + toCurrency: string; + /** The exchange rate value */ + exchangeRate: number; +} + +/** + * Fetches the latest exchange rate for the given currency pair. + * @param fetcher - The API fetcher instance + * @param query - Query parameters containing from_currency and/or to_currency + * @returns The exchange rate response with baseCurrency, toCurrency, and exchangeRate + */ +export async function fetchLatestExchangeRate( + fetcher: ApiFetcher, + query?: ExchangeRateLatestQuery, +): Promise { + const get = fetcher + .path(EXCHANGE_RATES_ROUTES.LATEST as never) + .method('get') + .create(); + const { data } = await get((query ?? {}) as never); + return data as ExchangeRateLatestResponse; +} diff --git a/shared/sdk-ts/src/index.ts b/shared/sdk-ts/src/index.ts index 57aae06ea..c7e7a006d 100644 --- a/shared/sdk-ts/src/index.ts +++ b/shared/sdk-ts/src/index.ts @@ -14,6 +14,7 @@ export * from './bills'; export * from './items'; export * from './branches'; export * from './warehouses'; +export * from './exchange-rates'; export * from './expenses'; export * from './import'; export * from './manual-journals';