From b061f49ca7e1bc37de12bd8dfc5e57c1f7f4a095 Mon Sep 17 00:00:00 2001 From: "a.bouhuolia" Date: Thu, 9 Sep 2021 21:06:16 +0200 Subject: [PATCH] fix: lock mutate base currency once organization has transactions. --- server/package.json | 1 + server/src/api/controllers/Organization.ts | 25 ++- .../api/controllers/OrganizationDashboard.ts | 40 +++++ server/src/api/index.ts | 2 + .../core/20200810121809_seed_settings.js | 58 +++---- server/src/interfaces/Setup.ts | 5 +- .../OrganizationBaseCurrencyLocking.ts | 142 ++++++++++++++++++ server/src/services/Organization/index.ts | 74 ++++++++- 8 files changed, 308 insertions(+), 39 deletions(-) create mode 100644 server/src/api/controllers/OrganizationDashboard.ts create mode 100644 server/src/services/Organization/OrganizationBaseCurrencyLocking.ts diff --git a/server/package.json b/server/package.json index cbcebcb30..0b62f8911 100644 --- a/server/package.json +++ b/server/package.json @@ -29,6 +29,7 @@ "async": "^3.2.0", "axios": "^0.20.0", "bcryptjs": "^2.4.3", + "bluebird": "^3.7.2", "compression": "^1.7.4", "country-codes-list": "^1.6.8", "cpy": "^8.1.2", diff --git a/server/src/api/controllers/Organization.ts b/server/src/api/controllers/Organization.ts index 71a3bd208..edc04c4c4 100644 --- a/server/src/api/controllers/Organization.ts +++ b/server/src/api/controllers/Organization.ts @@ -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); } diff --git a/server/src/api/controllers/OrganizationDashboard.ts b/server/src/api/controllers/OrganizationDashboard.ts new file mode 100644 index 000000000..9e408d06d --- /dev/null +++ b/server/src/api/controllers/OrganizationDashboard.ts @@ -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); + } + } +} diff --git a/server/src/api/index.ts b/server/src/api/index.ts index a3b4cea7c..750f82d70 100644 --- a/server/src/api/index.ts +++ b/server/src/api/index.ts @@ -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()); diff --git a/server/src/database/seeds/core/20200810121809_seed_settings.js b/server/src/database/seeds/core/20200810121809_seed_settings.js index 9baca5664..d72998dff 100644 --- a/server/src/database/seeds/core/20200810121809_seed_settings.js +++ b/server/src/database/seeds/core/20200810121809_seed_settings.js @@ -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) => {}; diff --git a/server/src/interfaces/Setup.ts b/server/src/interfaces/Setup.ts index 561348b1c..67742bf4b 100644 --- a/server/src/interfaces/Setup.ts +++ b/server/src/interfaces/Setup.ts @@ -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; diff --git a/server/src/services/Organization/OrganizationBaseCurrencyLocking.ts b/server/src/services/Organization/OrganizationBaseCurrencyLocking.ts new file mode 100644 index 000000000..971854632 --- /dev/null +++ b/server/src/services/Organization/OrganizationBaseCurrencyLocking.ts @@ -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 { + 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); + } +} diff --git a/server/src/services/Organization/index.ts b/server/src/services/Organization/index.ts index e9a364abf..92cb75660 100644 --- a/server/src/services/Organization/index.ts +++ b/server/src/services/Organization/index.ts @@ -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 { - 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