fix: lock mutate base currency once organization has transactions.

This commit is contained in:
a.bouhuolia
2021-09-09 21:06:16 +02:00
parent 772c24e3ef
commit b061f49ca7
8 changed files with 308 additions and 39 deletions

View File

@@ -19,6 +19,8 @@ import { DATE_FORMATS } from 'services/Miscellaneous/DateFormats/constants';
import { ServiceError } from 'exceptions';
import BaseController from 'api/controllers/BaseController';
const ACCEPTED_LOCATIONS = ['libya'];
@Service()
export default class OrganizationController extends BaseController {
@Inject()
@@ -39,14 +41,14 @@ export default class OrganizationController extends BaseController {
router.use('/build', SubscriptionMiddleware('main'));
router.post(
'/build',
this.buildValidationSchema,
this.organizationValidationSchema,
this.validationResult,
asyncMiddleware(this.build.bind(this)),
this.handleServiceErrors.bind(this)
);
router.put(
'/',
this.buildValidationSchema,
this.organizationValidationSchema,
this.validationResult,
this.asyncMiddleware(this.updateOrganization.bind(this)),
this.handleServiceErrors.bind(this)
@@ -61,15 +63,17 @@ export default class OrganizationController extends BaseController {
/**
* Organization setup schema.
* @return {ValidationChain[]}
*/
private get buildValidationSchema(): ValidationChain[] {
private get organizationValidationSchema(): ValidationChain[] {
return [
check('name').exists().trim(),
check('industry').optional().isString(),
check('location').exists().isString().isIn(ACCEPTED_LOCATIONS),
check('base_currency').exists().isIn(ACCEPTED_CURRENCIES),
check('timezone').exists().isIn(moment.tz.names()),
check('fiscal_year').exists().isIn(MONTHS),
check('industry').optional().isString(),
check('language').optional().isString().isIn(ACCEPTED_LOCALES),
check('language').exists().isString().isIn(ACCEPTED_LOCALES),
check('date_format').optional().isIn(DATE_FORMATS),
];
}
@@ -117,7 +121,9 @@ export default class OrganizationController extends BaseController {
const organization = await this.organizationService.currentOrganization(
tenantId
);
return res.status(200).send({ organization });
return res.status(200).send({
organization: this.transfromToResponse(organization),
});
} catch (error) {
next(error);
}
@@ -139,7 +145,7 @@ export default class OrganizationController extends BaseController {
const tenantDTO = this.matchedBodyData(req);
try {
const organization = await this.organizationService.updateOrganization(
await this.organizationService.updateOrganization(
tenantId,
tenantDTO
);
@@ -183,6 +189,11 @@ export default class OrganizationController extends BaseController {
errors: [{ type: 'TENANT_IS_BUILDING', code: 300 }],
});
}
if (error.errorType === 'BASE_CURRENCY_MUTATE_LOCKED') {
return res.status(400).send({
errors: [{ type: 'BASE_CURRENCY_MUTATE_LOCKED', code: 400 }],
});
}
}
next(error);
}

View File

@@ -0,0 +1,40 @@
import { Inject, Service } from 'typedi';
import { Request, Response, Router } from 'express';
import BaseController from 'api/controllers/BaseController';
import OrganizationService from 'services/Organization';
@Service()
export default class OrganizationDashboardController extends BaseController {
@Inject()
organizationService: OrganizationService;
/**
* Router constructor.
*/
router() {
const router = Router();
router.get(
'/base_currency_mutate',
this.baseCurrencyMutateAbility.bind(this)
);
return router;
}
private async baseCurrencyMutateAbility(
req: Request,
res: Response,
next: Function
) {
const { tenantId } = req;
try {
const abilities =
await this.organizationService.mutateBaseCurrencyAbility(tenantId);
return res.status(200).send({ abilities });
} catch (error) {
next(error);
}
}
}

View File

@@ -43,6 +43,7 @@ import InventoryAdjustments from 'api/controllers/Inventory/InventoryAdjustments
import asyncRenderMiddleware from './middleware/AsyncRenderMiddleware';
import Jobs from './controllers/Jobs';
import Miscellaneous from 'api/controllers/Miscellaneous';
import OrganizationDashboard from 'api/controllers/OrganizationDashboard';
export default () => {
const app = Router();
@@ -75,6 +76,7 @@ export default () => {
dashboard.use(I18nAuthenticatedMiddlware);
dashboard.use(EnsureTenantIsSeeded);
dashboard.use('/organization', Container.get(OrganizationDashboard).router());
dashboard.use('/invite', Container.get(InviteUsers).authRouter());
dashboard.use('/currencies', Container.get(Currencies).router());
dashboard.use('/settings', Container.get(Settings).router());

View File

@@ -1,41 +1,41 @@
import Container from 'typedi';
import TenancyService from 'services/Tenancy/TenancyService';
exports.up = (knex) => {
// const tenancyService = Container.get(TenancyService);
// const settings = tenancyService.settings(knex.userParams.tenantId);
const settings = [
// Orgnization settings.
{ group: 'organization', key: 'accounting_basis', value: 'accural' },
// // Orgnization settings.
// settings.set({ group: 'organization', key: 'accounting_basis', value: 'accural' });
// Accounts settings.
{ group: 'accounts', key: 'account_code_unique', value: true },
// // Accounts settings.
// settings.set({ group: 'accounts', key: 'account_code_unique', value: true });
// Manual journals settings.
{ group: 'manual_journals', key: 'next_number', value: '00001' },
{ group: 'manual_journals', key: 'auto_increment', value: true },
// // Manual journals settings.
// settings.set({ group: 'manual_journals', key: 'next_number', value: '00001' });
// settings.set({ group: 'manual_journals', key: 'auto_increment', value: true });
// Sale invoices settings.
{ group: 'sales_invoices', key: 'next_number', value: '00001' },
{ group: 'sales_invoices', key: 'number_prefix', value: 'INV-' },
{ group: 'sales_invoices', key: 'auto_increment', value: true },
// // Sale invoices settings.
// settings.set({ group: 'sales_invoices', key: 'next_number', value: '00001' });
// settings.set({ group: 'sales_invoices', key: 'number_prefix', value: 'INV-' });
// settings.set({ group: 'sales_invoices', key: 'auto_increment', value: true });
{ group: 'sales_invoices', key: 'next_number', value: '00001' },
{ group: 'sales_invoices', key: 'number_prefix', value: 'INV-' },
{ group: 'sales_invoices', key: 'auto_increment', value: true },
// // Sale receipts settings.
// settings.set({ group: 'sales_receipts', key: 'next_number', value: '00001' });
// settings.set({ group: 'sales_receipts', key: 'number_prefix', value: 'REC-' });
// settings.set({ group: 'sales_receipts', key: 'auto_increment', value: true });
// Sale receipts settings.
{ group: 'sales_receipts', key: 'next_number', value: '00001' },
{ group: 'sales_receipts', key: 'number_prefix', value: 'REC-' },
{ group: 'sales_receipts', key: 'auto_increment', value: true },
// // Sale estimates settings.
// settings.set({ group: 'sales_estimates', key: 'next_number', value: '00001' });
// settings.set({ group: 'sales_estimates', key: 'number_prefix', value: 'EST-' });
// settings.set({ group: 'sales_estimates', key: 'auto_increment', value: true });
// Sale estimates settings.
{ group: 'sales_estimates', key: 'next_number', value: '00001' },
{ group: 'sales_estimates', key: 'number_prefix', value: 'EST-' },
{ group: 'sales_estimates', key: 'auto_increment', value: true },
// // Payment receives settings.
// settings.set({ group: 'payment_receives', key: 'number_prefix', value: 'PAY-' });
// settings.set({ group: 'payment_receives', key: 'next_number', value: '00001' });
// settings.set({ group: 'payment_receives', key: 'auto_increment', value: true });
// return settings.save();
// Payment receives settings.
{ group: 'payment_receives', key: 'number_prefix', value: 'PAY-' },
{ group: 'payment_receives', key: 'next_number', value: '00001' },
{ group: 'payment_receives', key: 'auto_increment', value: true },
];
return knex('settings').insert(settings);
};
exports.down = (knex) => {};

View File

@@ -11,14 +11,17 @@ export interface IOrganizationSetupDTO{
export interface IOrganizationBuildDTO {
name: string;
industry: string;
location: string;
baseCurrency: string,
timezone: string;
fiscalYear: string;
industry: string;
dateFormat?: string;
}
export interface IOrganizationUpdateDTO {
name: string;
location: string;
baseCurrency: string,
timezone: string;
fiscalYear: string;

View File

@@ -0,0 +1,142 @@
import Bluebird from 'bluebird';
import { Inject, Service } from 'typedi';
import HasTenancyService from 'services/Tenancy/TenancyService';
import { TimeoutSettings } from 'puppeteer';
@Service()
export default class OrganizationBaseCurrencyLocking {
@Inject()
tenancy: HasTenancyService;
async shouldHasAlteastOne(Model) {
const model = await Model.query().limit(1);
return model.length > 0;
}
/**
* Validate the invoice has atleast once transaction.
* @param tenantId
*/
async validateInvoiceTransaction(tenantId: number) {
const { SaleInvoice } = this.tenancy.models(tenantId);
return this.shouldHasAlteastOne(SaleInvoice);
}
/**
* Validate the invoice has atleast once transaction.
* @param tenantId
*/
async validateEstimateTransaction(tenantId: number) {
const { SaleEstimate } = this.tenancy.models(tenantId);
return this.shouldHasAlteastOne(SaleEstimate);
}
/**
* Validate the invoice has atleast once transaction.
* @param tenantId
*/
async validateReceiptTransaction(tenantId: number) {
const { SaleReceipt } = this.tenancy.models(tenantId);
return this.shouldHasAlteastOne(SaleReceipt);
}
/**
* Validate the invoice has atleast once transaction.
* @param tenantId
*/
async validatePaymentReceiveTransaction(tenantId: number) {
const { PaymentReceive } = this.tenancy.models(tenantId);
return this.shouldHasAlteastOne(PaymentReceive);
}
/**
* Validate the invoice has atleast once transaction.
* @param tenantId
*/
async validatePaymentMadeTransaction(tenantId: number) {
const { BillPayment } = this.tenancy.models(tenantId);
return this.shouldHasAlteastOne(BillPayment);
}
/**
* Validate the invoice has atleast once transaction.
* @param tenantId
*/
async validateBillTransaction(tenantId: number) {
const { Bill } = this.tenancy.models(tenantId);
return this.shouldHasAlteastOne(Bill);
}
async validateJournalTransaction(tenantId: number) {
const { ManualJournal } = this.tenancy.models(tenantId);
return this.shouldHasAlteastOne(ManualJournal);
}
async validateAccountTransaction(tenantId: number) {
const { AccountTransaction } = this.tenancy.models(tenantId);
return this.shouldHasAlteastOne(AccountTransaction);
}
/**
*
* @param serviceName
* @param callback
* @returns
*/
validateService(serviceName, callback) {
return callback.then((result) => (result ? serviceName : false));
}
getValidators(tenantId: number) {
return [
{
serviceName: 'invoice',
validator: this.validateInvoiceTransaction(tenantId),
},
{
serviceName: 'receipt',
validator: this.validateReceiptTransaction(tenantId),
},
{
serviceName: 'bill',
validator: this.validateBillTransaction(tenantId),
},
{
serviceName: 'estimate',
validator: this.validateEstimateTransaction(tenantId),
},
{
serviceName: 'payment-receive',
validator: this.validatePaymentReceiveTransaction(tenantId),
},
{
serviceName: 'payment-made',
validator: this.validatePaymentMadeTransaction(tenantId),
},
{
serviceName: 'manual-journal',
validator: this.validateJournalTransaction(tenantId),
},
{
service: 'transaction',
validator: this.validateAccountTransaction(tenantId),
},
];
}
/**
*
* @param tenantId
* @returns
*/
async isBaseCurrencyMutateLocked(tenantId: number): Promise<string[]> {
const validators = this.getValidators(tenantId);
const asyncValidators = validators.map((validator) =>
this.validateService(validator.serviceName, validator.validator)
);
const results = await Bluebird.all(asyncValidators);
return results.filter((result) => result);
}
}

View File

@@ -1,5 +1,6 @@
import { Service, Inject } from 'typedi';
import { ObjectId } from 'mongodb';
import { defaultTo } from 'lodash';
import { ServiceError } from 'exceptions';
import {
IOrganizationBuildDTO,
@@ -13,6 +14,7 @@ import {
import events from 'subscribers/events';
import TenantsManager from 'services/Tenancy/TenantsManager';
import { Tenant } from 'system/models';
import OrganizationBaseCurrencyLocking from './OrganizationBaseCurrencyLocking';
const ERRORS = {
TENANT_NOT_FOUND: 'tenant_not_found',
@@ -20,6 +22,7 @@ const ERRORS = {
TENANT_ALREADY_SEEDED: 'tenant_already_seeded',
TENANT_DB_NOT_BUILT: 'tenant_db_not_built',
TENANT_IS_BUILDING: 'TENANT_IS_BUILDING',
BASE_CURRENCY_MUTATE_LOCKED: 'BASE_CURRENCY_MUTATE_LOCKED',
};
@Service()
@@ -39,6 +42,9 @@ export default class OrganizationService {
@Inject('agenda')
agenda: any;
@Inject()
baseCurrencyMutateLocking: OrganizationBaseCurrencyLocking;
/**
* Builds the database schema and seed data of the given organization id.
* @param {srting} organizationId
@@ -90,8 +96,11 @@ export default class OrganizationService {
// Throw error if tenant is currently building.
this.throwIfTenantIsBuilding(tenant);
// Transformes build DTO object.
const transformedBuildDTO = this.transformBuildDTO(buildDTO);
// Saves the tenant metadata.
await tenant.saveMetadata(buildDTO);
await tenant.saveMetadata(transformedBuildDTO);
// Send welcome mail to the user.
const jobMeta = await this.agenda.now('organization-setup', {
@@ -135,6 +144,15 @@ export default class OrganizationService {
return tenant;
}
/**
* Retrieve organization ability of mutate base currency
* @param {number} tenantId
* @returns
*/
public mutateBaseCurrencyAbility(tenantId: number) {
return this.baseCurrencyMutateLocking.isBaseCurrencyMutateLocked(tenantId);
}
/**
* Updates organization information.
* @param {ITenant} tenantId
@@ -144,14 +162,66 @@ export default class OrganizationService {
tenantId: number,
organizationDTO: IOrganizationUpdateDTO
): Promise<void> {
const tenant = await Tenant.query().findById(tenantId);
const tenant = await Tenant.query()
.findById(tenantId)
.withGraphFetched('metadata');
// Throw error if the tenant not exists.
this.throwIfTenantNotExists(tenant);
// Validate organization transactions before mutate base currency.
await this.validateMutateBaseCurrency(
tenant,
organizationDTO.baseCurrency,
tenant.metadata?.baseCurrency
);
await tenant.saveMetadata(organizationDTO);
}
/**
* Transformes build DTO object.
* @param {IOrganizationBuildDTO} buildDTO
* @returns {IOrganizationBuildDTO}
*/
private transformBuildDTO(
buildDTO: IOrganizationBuildDTO
): IOrganizationBuildDTO {
return {
...buildDTO,
dateFormat: defaultTo(buildDTO.dateFormat, 'DD/MM/yyyy'),
};
}
/**
* Throw base currency mutate locked error.
*/
private throwBaseCurrencyMutateLocked() {
throw new ServiceError(ERRORS.BASE_CURRENCY_MUTATE_LOCKED);
}
/**
* Validate mutate base currency ability.
* @param {Tenant} tenant -
* @param {string} newBaseCurrency -
* @param {string} oldBaseCurrency -
*/
private async validateMutateBaseCurrency(
tenant: Tenant,
newBaseCurrency: string,
oldBaseCurrency: string
) {
if (tenant.isReady && newBaseCurrency !== oldBaseCurrency) {
const isBaseCurrencyMutateLocked =
await this.baseCurrencyMutateLocking.isBaseCurrencyMutateLocked(
tenant.id
);
if (isBaseCurrencyMutateLocked.length > 0) {
this.throwBaseCurrencyMutateLocked();
}
}
}
/**
* Throws error in case the given tenant is undefined.
* @param {ITenant} tenant