From ac7175d83b7d0109c39d89826a4b7f5dff32262b Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 28 Jan 2024 15:52:54 +0200 Subject: [PATCH] feat: get latest exchange rate from third party services --- .env.example | 8 +- .../src/api/controllers/ExchangeRates.ts | 214 +++++------------- packages/server/src/config/index.ts | 10 + .../src/lib/ExchangeRate/ExchangeRate.ts | 45 ++++ .../src/lib/ExchangeRate/OpenExchangeRate.ts | 62 +++++ packages/server/src/lib/ExchangeRate/types.ts | 17 ++ .../ExchangeRates/ExchangeRatesService.ts | 208 +++-------------- 7 files changed, 224 insertions(+), 340 deletions(-) create mode 100644 packages/server/src/lib/ExchangeRate/ExchangeRate.ts create mode 100644 packages/server/src/lib/ExchangeRate/OpenExchangeRate.ts create mode 100644 packages/server/src/lib/ExchangeRate/types.ts diff --git a/.env.example b/.env.example index 78945ab91..7af1c6061 100644 --- a/.env.example +++ b/.env.example @@ -57,4 +57,10 @@ GOTENBERG_DOCS_URL=http://server:3000/public/ # Gotenberg API - (development) # GOTENBERG_URL=http://localhost:9000 -# GOTENBERG_DOCS_URL=http://host.docker.internal:3000/public/ \ No newline at end of file +# GOTENBERG_DOCS_URL=http://host.docker.internal:3000/public/ + +# Exchange Rate Service +EXCHANGE_RATE_SERVICE=open-exchange-rate + +# Open Exchange Rate +OPEN_EXCHANGE_RATE_APP_I= \ No newline at end of file diff --git a/packages/server/src/api/controllers/ExchangeRates.ts b/packages/server/src/api/controllers/ExchangeRates.ts index 4b808e921..4ea59ad1b 100644 --- a/packages/server/src/api/controllers/ExchangeRates.ts +++ b/packages/server/src/api/controllers/ExchangeRates.ts @@ -4,16 +4,13 @@ import { check, param, query } from 'express-validator'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import BaseController from './BaseController'; import { ServiceError } from '@/exceptions'; -import ExchangeRatesService from '@/services/ExchangeRates/ExchangeRatesService'; -import DynamicListingService from '@/services/DynamicListing/DynamicListService'; +import { ExchangeRatesService } from '@/services/ExchangeRates/ExchangeRatesService'; +import { EchangeRateErrors } from '@/lib/ExchangeRate/types'; @Service() export default class ExchangeRatesController extends BaseController { @Inject() - exchangeRatesService: ExchangeRatesService; - - @Inject() - dynamicListService: DynamicListingService; + private exchangeRatesService: ExchangeRatesService; /** * Constructor method. @@ -22,164 +19,35 @@ export default class ExchangeRatesController extends BaseController { const router = Router(); router.get( - '/', - [...this.exchangeRatesListSchema], + '/latest', + [query('to_currency').exists().isString()], this.validationResult, - asyncMiddleware(this.exchangeRates.bind(this)), - this.dynamicListService.handlerErrorsToResponse, - this.handleServiceError, - ); - router.post( - '/', - [...this.exchangeRateDTOSchema], - this.validationResult, - asyncMiddleware(this.addExchangeRate.bind(this)), - this.handleServiceError - ); - router.post( - '/:id', - [...this.exchangeRateEditDTOSchema, ...this.exchangeRateIdSchema], - this.validationResult, - asyncMiddleware(this.editExchangeRate.bind(this)), - this.handleServiceError - ); - router.delete( - '/:id', - [...this.exchangeRateIdSchema], - this.validationResult, - asyncMiddleware(this.deleteExchangeRate.bind(this)), + asyncMiddleware(this.latestExchangeRate.bind(this)), this.handleServiceError ); return router; } - get exchangeRatesListSchema() { - return [ - query('page').optional().isNumeric().toInt(), - query('page_size').optional().isNumeric().toInt(), - - query('column_sort_by').optional(), - query('sort_order').optional().isIn(['desc', 'asc']), - ]; - } - - get exchangeRateDTOSchema() { - return [ - check('exchange_rate').exists().isNumeric().toFloat(), - check('currency_code').exists().trim().escape(), - check('date').exists().isISO8601(), - ]; - } - - get exchangeRateEditDTOSchema() { - return [check('exchange_rate').exists().isNumeric().toFloat()]; - } - - get exchangeRateIdSchema() { - return [param('id').isNumeric().toInt()]; - } - - get exchangeRatesIdsSchema() { - return [ - query('ids').isArray({ min: 2 }), - query('ids.*').isNumeric().toInt(), - ]; - } - /** * Retrieve exchange rates. * @param {Request} req * @param {Response} res * @param {NextFunction} next */ - async exchangeRates(req: Request, res: Response, next: NextFunction) { + private async latestExchangeRate( + req: Request, + res: Response, + next: NextFunction + ) { const { tenantId } = req; - const filter = { - page: 1, - pageSize: 12, - filterRoles: [], - columnSortBy: 'created_at', - sortOrder: 'asc', - ...this.matchedQueryData(req), - }; - if (filter.stringifiedFilterRoles) { - filter.filterRoles = JSON.parse(filter.stringifiedFilterRoles); - } - try { - const exchangeRates = await this.exchangeRatesService.listExchangeRates( - tenantId, - filter - ); - return res.status(200).send({ exchange_rates: exchangeRates }); - } catch (error) { - next(error); - } - } - - /** - * Adds a new exchange rate on the given date. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - async addExchangeRate(req: Request, res: Response, next: NextFunction) { - const { tenantId } = req; - const exchangeRateDTO = this.matchedBodyData(req); + const exchangeRateQuery = this.matchedQueryData(req); try { - const exchangeRate = await this.exchangeRatesService.newExchangeRate( + const exchangeRate = await this.exchangeRatesService.latest( tenantId, - exchangeRateDTO + exchangeRateQuery ); - return res.status(200).send({ id: exchangeRate.id }); - } catch (error) { - next(error); - } - } - - /** - * Edit the given exchange rate. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - async editExchangeRate(req: Request, res: Response, next: NextFunction) { - const { tenantId } = req; - const { id: exchangeRateId } = req.params; - const exchangeRateDTO = this.matchedBodyData(req); - - try { - const exchangeRate = await this.exchangeRatesService.editExchangeRate( - tenantId, - exchangeRateId, - exchangeRateDTO - ); - - return res.status(200).send({ - id: exchangeRateId, - message: 'The exchange rate has been edited successfully.', - }); - } catch (error) { - next(error); - } - } - - /** - * Delete the given exchange rate from the storage. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - async deleteExchangeRate(req: Request, res: Response, next: NextFunction) { - const { tenantId } = req; - const { id: exchangeRateId } = req.params; - - try { - await this.exchangeRatesService.deleteExchangeRate( - tenantId, - exchangeRateId - ); - return res.status(200).send({ id: exchangeRateId }); + return res.status(200).send(exchangeRate); } catch (error) { next(error); } @@ -192,26 +60,56 @@ export default class ExchangeRatesController extends BaseController { * @param {Response} res * @param {NextFunction} next */ - handleServiceError( + private handleServiceError( error: Error, req: Request, res: Response, next: NextFunction ) { if (error instanceof ServiceError) { - if (error.errorType === 'EXCHANGE_RATE_NOT_FOUND') { - return res.status(404).send({ - errors: [{ type: 'EXCHANGE.RATE.NOT.FOUND', code: 200 }], - }); - } - if (error.errorType === 'NOT_FOUND_EXCHANGE_RATES') { + if (EchangeRateErrors.EX_RATE_INVALID_BASE_CURRENCY === error.errorType) { return res.status(400).send({ - errors: [{ type: 'EXCHANGE.RATES.IS.NOT.FOUND', code: 100 }], + errors: [ + { + type: EchangeRateErrors.EX_RATE_INVALID_BASE_CURRENCY, + code: 100, + message: 'The given base currency is invalid.', + }, + ], }); - } - if (error.errorType === 'EXCHANGE_RATE_PERIOD_EXISTS') { + } else if ( + EchangeRateErrors.EX_RATE_SERVICE_NOT_ALLOWED === error.errorType + ) { return res.status(400).send({ - errors: [{ type: 'EXCHANGE.RATE.PERIOD.EXISTS', code: 300 }], + errors: [ + { + type: EchangeRateErrors.EX_RATE_SERVICE_NOT_ALLOWED, + code: 200, + message: 'The service is not allowed', + }, + ], + }); + } else if ( + EchangeRateErrors.EX_RATE_SERVICE_API_KEY_REQUIRED === error.errorType + ) { + return res.status(400).send({ + errors: [ + { + type: EchangeRateErrors.EX_RATE_SERVICE_API_KEY_REQUIRED, + code: 300, + message: 'The API key is required', + }, + ], + }); + } else if (EchangeRateErrors.EX_RATE_LIMIT_EXCEEDED === error.errorType) { + return res.status(400).send({ + errors: [ + { + type: EchangeRateErrors.EX_RATE_LIMIT_EXCEEDED, + code: 400, + message: 'The API rate limit has been exceeded', + }, + ], }); } } diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index 0dc9d9676..4d096875a 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -169,4 +169,14 @@ module.exports = { * to application detarmines to upgrade. */ databaseBatch: 4, + + /** + * Exchange rate. + */ + exchangeRate: { + service: 'open-exchange-rate', + openExchangeRate: { + appId: process.env.OPEN_EXCHANGE_RATE_APP_ID, + } + } }; diff --git a/packages/server/src/lib/ExchangeRate/ExchangeRate.ts b/packages/server/src/lib/ExchangeRate/ExchangeRate.ts new file mode 100644 index 000000000..5bb84cff3 --- /dev/null +++ b/packages/server/src/lib/ExchangeRate/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); + } +} diff --git a/packages/server/src/lib/ExchangeRate/OpenExchangeRate.ts b/packages/server/src/lib/ExchangeRate/OpenExchangeRate.ts new file mode 100644 index 000000000..5f0a9b15b --- /dev/null +++ b/packages/server/src/lib/ExchangeRate/OpenExchangeRate.ts @@ -0,0 +1,62 @@ +import Axios, { AxiosError } from 'axios'; +import { + EchangeRateErrors, + IExchangeRateService, + OPEN_EXCHANGE_RATE_LATEST_URL, +} from './types'; +import config from '@/config'; +import { ServiceError } from '@/exceptions'; + +export class OpenExchangeRate implements IExchangeRateService { + /** + * Gets the latest exchange rate. + * @param {string} baseCurrency + * @param {string} toCurrency + * @returns {Promise { + try { + const result = await Axios.get(OPEN_EXCHANGE_RATE_LATEST_URL, { + params: { + app_id: config.exchangeRate.openExchangeRate.appId, + base: baseCurrency, + symbols: toCurrency, + }, + }); + return result.data.rates[toCurrency] || (1 as number); + } catch (error) { + this.handleLatestErrors(error); + } + } + + /** + * Handles the latest errors. + * @param {any} error + */ + 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.' + ); + } + } +} diff --git a/packages/server/src/lib/ExchangeRate/types.ts b/packages/server/src/lib/ExchangeRate/types.ts new file mode 100644 index 000000000..8b40125cd --- /dev/null +++ b/packages/server/src/lib/ExchangeRate/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'; diff --git a/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts b/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts index 9bc63fbfd..bb2437abc 100644 --- a/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts +++ b/packages/server/src/services/ExchangeRates/ExchangeRatesService.ts @@ -1,193 +1,39 @@ -import moment from 'moment'; -import { difference } from 'lodash'; import { Service, Inject } from 'typedi'; -import { ServiceError } from '@/exceptions'; -import DynamicListingService from '@/services/DynamicListing/DynamicListService'; -import { - EventDispatcher, - EventDispatcherInterface, -} from 'decorators/eventDispatcher'; -import { - IExchangeRateDTO, - IExchangeRate, - IExchangeRatesService, - IExchangeRateEditDTO, - IExchangeRateFilter, -} from '@/interfaces'; -import TenancyService from '@/services/Tenancy/TenancyService'; +import { IExchangeRatesService } from '@/interfaces'; +import { ExchangeRate } from '@/lib/ExchangeRate/ExchangeRate'; +import { ExchangeRateServiceType } from '@/lib/ExchangeRate/types'; +interface ExchangeRateLatestDTO { + toCurrency: string; +} -const ERRORS = { - NOT_FOUND_EXCHANGE_RATES: 'NOT_FOUND_EXCHANGE_RATES', - EXCHANGE_RATE_PERIOD_EXISTS: 'EXCHANGE_RATE_PERIOD_EXISTS', - EXCHANGE_RATE_NOT_FOUND: 'EXCHANGE_RATE_NOT_FOUND', -}; +interface EchangeRateLatestPOJO { + baseCurrency: string; + toCurrency: string; + exchangeRate: number; +} @Service() -export default class ExchangeRatesService implements IExchangeRatesService { - @Inject('logger') - logger: any; - - @EventDispatcher() - eventDispatcher: EventDispatcherInterface; - - @Inject() - tenancy: TenancyService; - - @Inject() - dynamicListService: DynamicListingService; - +export class ExchangeRatesService { /** - * Creates a new exchange rate. + * Gets the latest exchange rate. * @param {number} tenantId - * @param {IExchangeRateDTO} exchangeRateDTO - * @returns {Promise} + * @param {number} exchangeRateLatestDTO + * @returns {EchangeRateLatestPOJO} */ - public async newExchangeRate( + public async latest( tenantId: number, - exchangeRateDTO: IExchangeRateDTO - ): Promise { - const { ExchangeRate } = this.tenancy.models(tenantId); - - this.logger.info('[exchange_rates] trying to insert new exchange rate.', { - tenantId, - exchangeRateDTO, - }); - await this.validateExchangeRatePeriodExistance(tenantId, exchangeRateDTO); - - const exchangeRate = await ExchangeRate.query().insertAndFetch({ - ...exchangeRateDTO, - date: moment(exchangeRateDTO.date).format('YYYY-MM-DD'), - }); - this.logger.info('[exchange_rates] inserted successfully.', { - tenantId, - exchangeRateDTO, - }); - return exchangeRate; - } - - /** - * Edits the exchange rate details. - * @param {number} tenantId - Tenant id. - * @param {number} exchangeRateId - Exchange rate id. - * @param {IExchangeRateEditDTO} editExRateDTO - Edit exchange rate DTO. - */ - public async editExchangeRate( - tenantId: number, - exchangeRateId: number, - editExRateDTO: IExchangeRateEditDTO - ): Promise { - const { ExchangeRate } = this.tenancy.models(tenantId); - - this.logger.info('[exchange_rates] trying to edit exchange rate.', { - tenantId, - exchangeRateId, - editExRateDTO, - }); - await this.validateExchangeRateExistance(tenantId, exchangeRateId); - - await ExchangeRate.query() - .where('id', exchangeRateId) - .update({ ...editExRateDTO }); - this.logger.info('[exchange_rates] exchange rate edited successfully.', { - tenantId, - exchangeRateId, - editExRateDTO, - }); - } - - /** - * Deletes the given exchange rate. - * @param {number} tenantId - Tenant id. - * @param {number} exchangeRateId - Exchange rate id. - */ - public async deleteExchangeRate( - tenantId: number, - exchangeRateId: number - ): Promise { - const { ExchangeRate } = this.tenancy.models(tenantId); - await this.validateExchangeRateExistance(tenantId, exchangeRateId); - - await ExchangeRate.query().findById(exchangeRateId).delete(); - } - - /** - * Listing exchange rates details. - * @param {number} tenantId - Tenant id. - * @param {IExchangeRateFilter} exchangeRateFilter - Exchange rates list filter. - */ - public async listExchangeRates( - tenantId: number, - exchangeRateFilter: IExchangeRateFilter - ): Promise { - const { ExchangeRate } = this.tenancy.models(tenantId); - const dynamicFilter = await this.dynamicListService.dynamicList( - tenantId, - ExchangeRate, - exchangeRateFilter - ); - // Retrieve exchange rates by the given query. - const exchangeRates = await ExchangeRate.query() - .onBuild((query) => { - dynamicFilter.buildQuery()(query); - }) - .pagination(exchangeRateFilter.page - 1, exchangeRateFilter.pageSize); - - return exchangeRates; - } - - /** - * Validates period of the exchange rate existance. - * @param {number} tenantId - Tenant id. - * @param {IExchangeRateDTO} exchangeRateDTO - Exchange rate DTO. - * @return {Promise} - */ - private async validateExchangeRatePeriodExistance( - tenantId: number, - exchangeRateDTO: IExchangeRateDTO - ): Promise { - const { ExchangeRate } = this.tenancy.models(tenantId); - - this.logger.info('[exchange_rates] trying to validate period existance.', { - tenantId, - }); - const foundExchangeRate = await ExchangeRate.query() - .where('currency_code', exchangeRateDTO.currencyCode) - .where('date', exchangeRateDTO.date); - - if (foundExchangeRate.length > 0) { - this.logger.info('[exchange_rates] given exchange rate period exists.', { - tenantId, - }); - throw new ServiceError(ERRORS.EXCHANGE_RATE_PERIOD_EXISTS); - } - } - - /** - * Validate the given echange rate id existance. - * @param {number} tenantId - Tenant id. - * @param {number} exchangeRateId - Exchange rate id. - * @returns {Promise} - */ - private async validateExchangeRateExistance( - tenantId: number, - exchangeRateId: number - ) { - const { ExchangeRate } = this.tenancy.models(tenantId); - - this.logger.info( - '[exchange_rates] trying to validate exchange rate id existance.', - { tenantId, exchangeRateId } - ); - const foundExchangeRate = await ExchangeRate.query().findById( - exchangeRateId + exchangeRateLatestDTO: ExchangeRateLatestDTO + ): Promise { + const exchange = new ExchangeRate(ExchangeRateServiceType.OpenExchangeRate); + const exchangeRate = await exchange.latest( + 'USD', + exchangeRateLatestDTO.toCurrency ); - if (!foundExchangeRate) { - this.logger.info('[exchange_rates] exchange rate not found.', { - tenantId, - exchangeRateId, - }); - throw new ServiceError(ERRORS.EXCHANGE_RATE_NOT_FOUND); - } + return { + baseCurrency: 'USD', + toCurrency: exchangeRateLatestDTO.toCurrency, + exchangeRate, + }; } }