mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-18 22:00:31 +00:00
Merge pull request #340 from bigcapitalhq/big-65-get-realtime-exchange-rate-from-third-party-service
feat: get latest exchange rate from third party services
This commit is contained in:
@@ -57,4 +57,10 @@ GOTENBERG_DOCS_URL=http://server:3000/public/
|
|||||||
|
|
||||||
# Gotenberg API - (development)
|
# Gotenberg API - (development)
|
||||||
# GOTENBERG_URL=http://localhost:9000
|
# GOTENBERG_URL=http://localhost:9000
|
||||||
# GOTENBERG_DOCS_URL=http://host.docker.internal:3000/public/
|
# 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_ID=
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
import { Service, Inject } from 'typedi';
|
import { Service, Inject } from 'typedi';
|
||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { check, param, query } from 'express-validator';
|
import { query, oneOf } from 'express-validator';
|
||||||
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
|
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
|
||||||
import BaseController from './BaseController';
|
import BaseController from './BaseController';
|
||||||
import { ServiceError } from '@/exceptions';
|
import { ServiceError } from '@/exceptions';
|
||||||
import ExchangeRatesService from '@/services/ExchangeRates/ExchangeRatesService';
|
import { EchangeRateErrors } from '@/lib/ExchangeRate/types';
|
||||||
import DynamicListingService from '@/services/DynamicListing/DynamicListService';
|
import { ExchangeRateApplication } from '@/services/ExchangeRates/ExchangeRateApplication';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class ExchangeRatesController extends BaseController {
|
export default class ExchangeRatesController extends BaseController {
|
||||||
@Inject()
|
@Inject()
|
||||||
exchangeRatesService: ExchangeRatesService;
|
private exchangeRatesApp: ExchangeRateApplication;
|
||||||
|
|
||||||
@Inject()
|
|
||||||
dynamicListService: DynamicListingService;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor method.
|
* Constructor method.
|
||||||
@@ -22,164 +19,40 @@ export default class ExchangeRatesController extends BaseController {
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/latest',
|
||||||
[...this.exchangeRatesListSchema],
|
[
|
||||||
|
oneOf([
|
||||||
|
query('to_currency').exists().isString().isISO4217(),
|
||||||
|
query('from_currency').exists().isString().isISO4217(),
|
||||||
|
]),
|
||||||
|
],
|
||||||
this.validationResult,
|
this.validationResult,
|
||||||
asyncMiddleware(this.exchangeRates.bind(this)),
|
asyncMiddleware(this.latestExchangeRate.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)),
|
|
||||||
this.handleServiceError
|
this.handleServiceError
|
||||||
);
|
);
|
||||||
return router;
|
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.
|
* Retrieve exchange rates.
|
||||||
* @param {Request} req
|
* @param {Request} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
* @param {NextFunction} next
|
* @param {NextFunction} next
|
||||||
*/
|
*/
|
||||||
async exchangeRates(req: Request, res: Response, next: NextFunction) {
|
private async latestExchangeRate(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
const { tenantId } = req;
|
const { tenantId } = req;
|
||||||
const filter = {
|
const exchangeRateQuery = this.matchedQueryData(req);
|
||||||
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);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const exchangeRate = await this.exchangeRatesService.newExchangeRate(
|
const exchangeRate = await this.exchangeRatesApp.latest(
|
||||||
tenantId,
|
tenantId,
|
||||||
exchangeRateDTO
|
exchangeRateQuery
|
||||||
);
|
);
|
||||||
return res.status(200).send({ id: exchangeRate.id });
|
return res.status(200).send(exchangeRate);
|
||||||
} 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 });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -192,26 +65,56 @@ export default class ExchangeRatesController extends BaseController {
|
|||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
* @param {NextFunction} next
|
* @param {NextFunction} next
|
||||||
*/
|
*/
|
||||||
handleServiceError(
|
private handleServiceError(
|
||||||
error: Error,
|
error: Error,
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
if (error instanceof ServiceError) {
|
if (error instanceof ServiceError) {
|
||||||
if (error.errorType === 'EXCHANGE_RATE_NOT_FOUND') {
|
if (EchangeRateErrors.EX_RATE_INVALID_BASE_CURRENCY === error.errorType) {
|
||||||
return res.status(404).send({
|
|
||||||
errors: [{ type: 'EXCHANGE.RATE.NOT.FOUND', code: 200 }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (error.errorType === 'NOT_FOUND_EXCHANGE_RATES') {
|
|
||||||
return res.status(400).send({
|
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.',
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
} else if (
|
||||||
if (error.errorType === 'EXCHANGE_RATE_PERIOD_EXISTS') {
|
EchangeRateErrors.EX_RATE_SERVICE_NOT_ALLOWED === error.errorType
|
||||||
|
) {
|
||||||
return res.status(400).send({
|
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,4 +169,14 @@ module.exports = {
|
|||||||
* to application detarmines to upgrade.
|
* to application detarmines to upgrade.
|
||||||
*/
|
*/
|
||||||
databaseBatch: 4,
|
databaseBatch: 4,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange rate.
|
||||||
|
*/
|
||||||
|
exchangeRate: {
|
||||||
|
service: 'open-exchange-rate',
|
||||||
|
openExchangeRate: {
|
||||||
|
appId: process.env.OPEN_EXCHANGE_RATE_APP_ID,
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,36 +1,10 @@
|
|||||||
import { IFilterRole } from './DynamicFilter';
|
export interface ExchangeRateLatestDTO {
|
||||||
|
toCurrency: string;
|
||||||
|
fromCurrency: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IExchangeRate {
|
export interface EchangeRateLatestPOJO {
|
||||||
id: number,
|
baseCurrency: string;
|
||||||
currencyCode: string,
|
toCurrency: string;
|
||||||
exchangeRate: number,
|
exchangeRate: number;
|
||||||
date: Date,
|
}
|
||||||
createdAt: Date,
|
|
||||||
updatedAt: Date,
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface IExchangeRateDTO {
|
|
||||||
currencyCode: string,
|
|
||||||
exchangeRate: number,
|
|
||||||
date: Date,
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface IExchangeRateEditDTO {
|
|
||||||
exchangeRate: number,
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface IExchangeRateFilter {
|
|
||||||
page: number,
|
|
||||||
pageSize: number,
|
|
||||||
filterRoles?: IFilterRole[];
|
|
||||||
columnSortBy: string;
|
|
||||||
sortOrder: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface IExchangeRatesService {
|
|
||||||
newExchangeRate(tenantId: number, exchangeRateDTO: IExchangeRateDTO): Promise<IExchangeRate>;
|
|
||||||
editExchangeRate(tenantId: number, exchangeRateId: number, editExRateDTO: IExchangeRateEditDTO): Promise<void>;
|
|
||||||
|
|
||||||
deleteExchangeRate(tenantId: number, exchangeRateId: number): Promise<void>;
|
|
||||||
listExchangeRates(tenantId: number, exchangeRateFilter: IExchangeRateFilter): Promise<void>;
|
|
||||||
};
|
|
||||||
|
|||||||
45
packages/server/src/lib/ExchangeRate/ExchangeRate.ts
Normal file
45
packages/server/src/lib/ExchangeRate/ExchangeRate.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
packages/server/src/lib/ExchangeRate/OpenExchangeRate.ts
Normal file
81
packages/server/src/lib/ExchangeRate/OpenExchangeRate.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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<number}
|
||||||
|
*/
|
||||||
|
public async latest(
|
||||||
|
baseCurrency: string,
|
||||||
|
toCurrency: string
|
||||||
|
): Promise<number> {
|
||||||
|
// Vaclidates the Open Exchange Rate api id early.
|
||||||
|
this.validateApiIdExistance();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the Open Exchange Rate api id.
|
||||||
|
* @throws {ServiceError}
|
||||||
|
*/
|
||||||
|
private validateApiIdExistance() {
|
||||||
|
const apiId = config.exchangeRate.openExchangeRate.appId;
|
||||||
|
|
||||||
|
if (!apiId) {
|
||||||
|
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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/server/src/lib/ExchangeRate/types.ts
Normal file
17
packages/server/src/lib/ExchangeRate/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';
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Inject } from 'typedi';
|
||||||
|
import { ExchangeRatesService } from './ExchangeRatesService';
|
||||||
|
import { EchangeRateLatestPOJO, ExchangeRateLatestDTO } from '@/interfaces';
|
||||||
|
|
||||||
|
export class ExchangeRateApplication {
|
||||||
|
@Inject()
|
||||||
|
private 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,193 +1,37 @@
|
|||||||
import moment from 'moment';
|
import { Service } from 'typedi';
|
||||||
import { difference } from 'lodash';
|
import { ExchangeRate } from '@/lib/ExchangeRate/ExchangeRate';
|
||||||
import { Service, Inject } from 'typedi';
|
import { ExchangeRateServiceType } from '@/lib/ExchangeRate/types';
|
||||||
import { ServiceError } from '@/exceptions';
|
import { EchangeRateLatestPOJO, ExchangeRateLatestDTO } from '@/interfaces';
|
||||||
import DynamicListingService from '@/services/DynamicListing/DynamicListService';
|
import { TenantMetadata } from '@/system/models';
|
||||||
import {
|
|
||||||
EventDispatcher,
|
|
||||||
EventDispatcherInterface,
|
|
||||||
} from 'decorators/eventDispatcher';
|
|
||||||
import {
|
|
||||||
IExchangeRateDTO,
|
|
||||||
IExchangeRate,
|
|
||||||
IExchangeRatesService,
|
|
||||||
IExchangeRateEditDTO,
|
|
||||||
IExchangeRateFilter,
|
|
||||||
} from '@/interfaces';
|
|
||||||
import TenancyService from '@/services/Tenancy/TenancyService';
|
|
||||||
|
|
||||||
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',
|
|
||||||
};
|
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export default class ExchangeRatesService implements IExchangeRatesService {
|
export class ExchangeRatesService {
|
||||||
@Inject('logger')
|
|
||||||
logger: any;
|
|
||||||
|
|
||||||
@EventDispatcher()
|
|
||||||
eventDispatcher: EventDispatcherInterface;
|
|
||||||
|
|
||||||
@Inject()
|
|
||||||
tenancy: TenancyService;
|
|
||||||
|
|
||||||
@Inject()
|
|
||||||
dynamicListService: DynamicListingService;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new exchange rate.
|
* Gets the latest exchange rate.
|
||||||
* @param {number} tenantId
|
* @param {number} tenantId
|
||||||
* @param {IExchangeRateDTO} exchangeRateDTO
|
* @param {number} exchangeRateLatestDTO
|
||||||
* @returns {Promise<IExchangeRate>}
|
* @returns {EchangeRateLatestPOJO}
|
||||||
*/
|
*/
|
||||||
public async newExchangeRate(
|
public async latest(
|
||||||
tenantId: number,
|
tenantId: number,
|
||||||
exchangeRateDTO: IExchangeRateDTO
|
exchangeRateLatestDTO: ExchangeRateLatestDTO
|
||||||
): Promise<IExchangeRate> {
|
): Promise<EchangeRateLatestPOJO> {
|
||||||
const { ExchangeRate } = this.tenancy.models(tenantId);
|
const organization = await TenantMetadata.query().findOne({ tenantId });
|
||||||
|
|
||||||
this.logger.info('[exchange_rates] trying to insert new exchange rate.', {
|
// Assign the organization base currency as a default currency
|
||||||
tenantId,
|
// if no currency is provided
|
||||||
exchangeRateDTO,
|
const fromCurrency =
|
||||||
});
|
exchangeRateLatestDTO.fromCurrency || organization.baseCurrency;
|
||||||
await this.validateExchangeRatePeriodExistance(tenantId, exchangeRateDTO);
|
const toCurrency =
|
||||||
|
exchangeRateLatestDTO.toCurrency || organization.baseCurrency;
|
||||||
|
|
||||||
const exchangeRate = await ExchangeRate.query().insertAndFetch({
|
const exchange = new ExchangeRate(ExchangeRateServiceType.OpenExchangeRate);
|
||||||
...exchangeRateDTO,
|
const exchangeRate = await exchange.latest(fromCurrency, toCurrency);
|
||||||
date: moment(exchangeRateDTO.date).format('YYYY-MM-DD'),
|
|
||||||
});
|
|
||||||
this.logger.info('[exchange_rates] inserted successfully.', {
|
|
||||||
tenantId,
|
|
||||||
exchangeRateDTO,
|
|
||||||
});
|
|
||||||
return exchangeRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
return {
|
||||||
* Edits the exchange rate details.
|
baseCurrency: fromCurrency,
|
||||||
* @param {number} tenantId - Tenant id.
|
toCurrency: exchangeRateLatestDTO.toCurrency,
|
||||||
* @param {number} exchangeRateId - Exchange rate id.
|
exchangeRate,
|
||||||
* @param {IExchangeRateEditDTO} editExRateDTO - Edit exchange rate DTO.
|
};
|
||||||
*/
|
|
||||||
public async editExchangeRate(
|
|
||||||
tenantId: number,
|
|
||||||
exchangeRateId: number,
|
|
||||||
editExRateDTO: IExchangeRateEditDTO
|
|
||||||
): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void>}
|
|
||||||
*/
|
|
||||||
private async validateExchangeRatePeriodExistance(
|
|
||||||
tenantId: number,
|
|
||||||
exchangeRateDTO: IExchangeRateDTO
|
|
||||||
): Promise<void> {
|
|
||||||
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<void>}
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!foundExchangeRate) {
|
|
||||||
this.logger.info('[exchange_rates] exchange rate not found.', {
|
|
||||||
tenantId,
|
|
||||||
exchangeRateId,
|
|
||||||
});
|
|
||||||
throw new ServiceError(ERRORS.EXCHANGE_RATE_NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import BaseModel from 'models/Model';
|
import BaseModel from 'models/Model';
|
||||||
|
|
||||||
export default class TenantMetadata extends BaseModel {
|
export default class TenantMetadata extends BaseModel {
|
||||||
|
baseCurrency: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Table name.
|
* Table name.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useExchangeRate } from '@/hooks/query';
|
|
||||||
import { useCurrentOrganization } from '@/hooks/state';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useLatestExchangeRate } from '@/hooks/query';
|
||||||
|
|
||||||
interface AutoExchangeRateProviderProps {
|
interface AutoExchangeRateProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -18,16 +17,19 @@ const AutoExchangeRateContext = React.createContext(
|
|||||||
function AutoExchangeRateProvider({ children }: AutoExchangeRateProviderProps) {
|
function AutoExchangeRateProvider({ children }: AutoExchangeRateProviderProps) {
|
||||||
const [autoExRateCurrency, setAutoExRateCurrency] =
|
const [autoExRateCurrency, setAutoExRateCurrency] =
|
||||||
React.useState<string>('');
|
React.useState<string>('');
|
||||||
const currentOrganization = useCurrentOrganization();
|
|
||||||
|
|
||||||
// Retrieves the exchange rate.
|
// Retrieves the exchange rate.
|
||||||
const { data: autoExchangeRate, isLoading: isAutoExchangeRateLoading } =
|
const { data: autoExchangeRate, isLoading: isAutoExchangeRateLoading } =
|
||||||
useExchangeRate(autoExRateCurrency, currentOrganization.base_currency, {
|
useLatestExchangeRate(
|
||||||
enabled: Boolean(currentOrganization.base_currency && autoExRateCurrency),
|
{ fromCurrency: autoExRateCurrency },
|
||||||
refetchOnWindowFocus: false,
|
{
|
||||||
staleTime: 0,
|
enabled: Boolean(autoExRateCurrency),
|
||||||
cacheTime: 0,
|
refetchOnWindowFocus: false,
|
||||||
});
|
staleTime: 0,
|
||||||
|
cacheTime: 0,
|
||||||
|
retry: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
autoExRateCurrency,
|
autoExRateCurrency,
|
||||||
|
|||||||
@@ -98,24 +98,30 @@ interface UseSyncExRateToFormProps {
|
|||||||
*/
|
*/
|
||||||
export const useSyncExRateToForm = ({ onSynced }: UseSyncExRateToFormProps) => {
|
export const useSyncExRateToForm = ({ onSynced }: UseSyncExRateToFormProps) => {
|
||||||
const { setFieldValue, values } = useFormikContext();
|
const { setFieldValue, values } = useFormikContext();
|
||||||
const { autoExRateCurrency, autoExchangeRate } = useAutoExRateContext();
|
const { autoExRateCurrency, autoExchangeRate, isAutoExchangeRateLoading } =
|
||||||
|
useAutoExRateContext();
|
||||||
const updateEntriesOnExChange = useUpdateEntriesOnExchangeRateChange();
|
const updateEntriesOnExChange = useUpdateEntriesOnExchangeRateChange();
|
||||||
|
|
||||||
// Sync the fetched real-time exchanage rate to the form.
|
// Sync the fetched real-time exchanage rate to the form.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoExchangeRate?.exchange_rate && autoExRateCurrency) {
|
if (!isAutoExchangeRateLoading && autoExRateCurrency) {
|
||||||
setFieldValue('exchange_rate', autoExchangeRate?.exchange_rate + '');
|
// Sets a default ex. rate to 1 in case the exchange rate service wasn't configured.
|
||||||
|
// or returned an error from the server-side.
|
||||||
|
const exchangeRate = autoExchangeRate?.exchange_rate || 1;
|
||||||
|
|
||||||
|
setFieldValue('exchange_rate', exchangeRate + '');
|
||||||
setFieldValue(
|
setFieldValue(
|
||||||
'entries',
|
'entries',
|
||||||
updateEntriesOnExChange(
|
updateEntriesOnExChange(values.exchange_rate, exchangeRate),
|
||||||
values.exchange_rate,
|
|
||||||
autoExchangeRate?.exchange_rate,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
onSynced?.();
|
onSynced?.();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [autoExchangeRate?.exchange_rate, autoExRateCurrency]);
|
}, [
|
||||||
|
autoExchangeRate?.exchange_rate,
|
||||||
|
autoExRateCurrency,
|
||||||
|
isAutoExchangeRateLoading,
|
||||||
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,34 +1,36 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { useQuery } from 'react-query';
|
import { useQuery } from 'react-query';
|
||||||
import QUERY_TYPES from './types';
|
import QUERY_TYPES from './types';
|
||||||
|
import useApiRequest from '../useRequest';
|
||||||
|
|
||||||
function getRandomItemFromArray(arr) {
|
interface LatestExchangeRateQuery {
|
||||||
const randomIndex = Math.floor(Math.random() * arr.length);
|
fromCurrency?: string;
|
||||||
return arr[randomIndex];
|
toCurrency?: string;
|
||||||
}
|
|
||||||
function delay(t, val) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, t, val));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves tax rates.
|
* Retrieves latest exchange rate.
|
||||||
* @param {number} customerId - Customer id.
|
* @param {number} customerId - Customer id.
|
||||||
*/
|
*/
|
||||||
export function useExchangeRate(
|
export function useLatestExchangeRate(
|
||||||
fromCurrency: string,
|
{ toCurrency, fromCurrency }: LatestExchangeRateQuery,
|
||||||
toCurrency: string,
|
|
||||||
props,
|
props,
|
||||||
) {
|
) {
|
||||||
return useQuery(
|
const apiRequest = useApiRequest();
|
||||||
[QUERY_TYPES.EXCHANGE_RATE, fromCurrency, toCurrency],
|
|
||||||
async () => {
|
|
||||||
await delay(100);
|
|
||||||
|
|
||||||
return {
|
return useQuery(
|
||||||
from_currency: fromCurrency,
|
[QUERY_TYPES.EXCHANGE_RATE, toCurrency, fromCurrency],
|
||||||
to_currency: toCurrency,
|
() =>
|
||||||
exchange_rate: 1.00,
|
apiRequest
|
||||||
};
|
.http({
|
||||||
},
|
url: `/api/exchange_rates/latest`,
|
||||||
|
method: 'get',
|
||||||
|
params: {
|
||||||
|
to_currency: toCurrency,
|
||||||
|
from_currency: fromCurrency,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((res) => res.data),
|
||||||
props,
|
props,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user