Files
bigcapital/server/src/http/controllers/Accounts.js
Ahmed Bouhuolia 9d9c7c1568 - fix: store children accounts with Redux store.
- fix: store expense payment date with transactions.
- fix: Total assets, liabilities and equity on balance sheet.
- tweaks: dashboard content and sidebar style.
- fix: reset form with contact list on journal entry form.
- feat: Add hints to filter accounts in financial statements.
2020-07-12 12:31:12 +02:00

670 lines
19 KiB
JavaScript

import express from 'express';
import { check, validationResult, param, query } from 'express-validator';
import { difference } from 'lodash';
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import JournalPoster from '@/services/Accounting/JournalPoster';
import {
mapViewRolesToConditionals,
mapFilterRolesToDynamicFilter,
} from '@/lib/ViewRolesBuilder';
import {
DynamicFilter,
DynamicFilterSortBy,
DynamicFilterViews,
DynamicFilterFilterRoles,
} from '@/lib/DynamicFilter';
export default {
/**
* Router constructor method.
*/
router() {
const router = express.Router();
router.post(
'/',
this.newAccount.validation,
asyncMiddleware(this.newAccount.handler)
);
router.post(
'/:id',
this.editAccount.validation,
asyncMiddleware(this.editAccount.handler)
);
router.get(
'/:id',
this.getAccount.validation,
asyncMiddleware(this.getAccount.handler)
);
router.get(
'/',
this.getAccountsList.validation,
asyncMiddleware(this.getAccountsList.handler)
);
router.delete(
'/',
this.deleteBulkAccounts.validation,
asyncMiddleware(this.deleteBulkAccounts.handler)
);
router.delete(
'/:id',
this.deleteAccount.validation,
asyncMiddleware(this.deleteAccount.handler)
);
router.post(
'/:id/active',
this.activeAccount.validation,
asyncMiddleware(this.activeAccount.handler)
);
router.post(
'/:id/inactive',
this.inactiveAccount.validation,
asyncMiddleware(this.inactiveAccount.handler)
);
router.post(
'/:id/recalculate-balance',
this.recalcualteBalanace.validation,
asyncMiddleware(this.recalcualteBalanace.handler)
);
router.post(
'/:id/transfer_account/:toAccount',
this.transferToAnotherAccount.validation,
asyncMiddleware(this.transferToAnotherAccount.handler)
);
router.post(
'/bulk/:type(activate|inactivate)',
this.bulkInactivateAccounts.validation,
asyncMiddleware(this.bulkInactivateAccounts.handler)
);
return router;
},
/**
* Creates a new account.
*/
newAccount: {
validation: [
check('name').exists().isLength({ min: 3, max: 255 }).trim().escape(),
check('code').optional().isLength({ min: 3, max: 6 }).trim().escape(),
check('account_type_id').exists().isNumeric().toInt(),
check('description').optional().isLength({ max: 512 }).trim().escape(),
check('parent_account_id')
.optional({ nullable: true })
.isNumeric()
.toInt(),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error',
...validationErrors,
});
}
const form = { ...req.body };
const { AccountType, Account } = req.models;
const foundAccountCodePromise = form.code
? Account.query().where('code', form.code)
: null;
const foundAccountTypePromise = AccountType.query().findById(
form.account_type_id
);
const [foundAccountCode, foundAccountType] = await Promise.all([
foundAccountCodePromise,
foundAccountTypePromise,
]);
if (foundAccountCodePromise && foundAccountCode.length > 0) {
return res.boom.badRequest(null, {
errors: [{ type: 'NOT_UNIQUE_CODE', code: 100 }],
});
}
if (!foundAccountType) {
return res.boom.badRequest(null, {
errors: [{ type: 'NOT_EXIST_ACCOUNT_TYPE', code: 200 }],
});
}
if (form.parent_account_id) {
const parentAccount = await Account.query()
.where('id', form.parent_account_id)
.first();
if (!parentAccount) {
return res.boom.badRequest(null, {
errors: [{ type: 'PARENT_ACCOUNT_NOT_FOUND', code: 300 }],
});
}
if (parentAccount.accountTypeId !== form.parent_account_id) {
return res.boom.badRequest(null, {
errors: [
{ type: 'PARENT.ACCOUNT.HAS.DIFFERENT.ACCOUNT.TYPE', code: 400 },
],
});
}
}
const insertedAccount = await Account.query().insertAndFetch({ ...form });
return res.status(200).send({ account: { ...insertedAccount } });
},
},
/**
* Edit the given account details.
*/
editAccount: {
validation: [
param('id').exists().toInt(),
check('name').exists().isLength({ min: 3, max: 255 }).trim().escape(),
check('code').optional().isLength({ min: 3, max: 6 }).trim().escape(),
check('account_type_id').exists().isNumeric().toInt(),
check('description').optional().isLength({ max: 512 }).trim().escape(),
],
async handler(req, res) {
const { id } = req.params;
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error',
...validationErrors,
});
}
const { Account, AccountType } = req.models;
const form = { ...req.body };
const account = await Account.query().findById(id);
if (!account) {
return res.boom.notFound();
}
const errorReasons = [];
// Validate the account type is not changed.
if (account.account_type_id != form.accountTypeId) {
errorReasons.push({
type: 'NOT.ALLOWED.TO.CHANGE.ACCOUNT.TYPE',
code: 100,
});
}
// Validate the account code not exists on the storage.
if (form.code && form.code !== account.code) {
const foundAccountCode = await Account.query()
.where('code', form.code)
.whereNot('id', account.id);
if (foundAccountCode.length > 0) {
errorReasons.push({ type: 'NOT_UNIQUE_CODE', code: 200 });
}
}
if (form.parent_account_id) {
const parentAccount = await Account.query()
.where('id', form.parent_account_id)
.whereNot('id', account.id)
.first();
if (!parentAccount) {
errorReasons.push({
type: 'PARENT_ACCOUNT_NOT_FOUND',
code: 300,
});
}
if (parentAccount.accountTypeId !== account.parentAccountId) {
return res.boom.badRequest(null, {
errors: [
{ type: 'PARENT.ACCOUNT.HAS.DIFFERENT.ACCOUNT.TYPE', code: 400 },
],
});
}
}
if (errorReasons.length > 0) {
return res.status(400).send({ errors: errorReasons });
}
// Update the account on the storage.
const updatedAccount = await Account.query().patchAndFetchById(
account.id,
{ ...form }
);
return res.status(200).send({ account: { ...updatedAccount } });
},
},
/**
* Get details of the given account.
*/
getAccount: {
validation: [param('id').toInt()],
async handler(req, res) {
const { id } = req.params;
const { Account } = req.models;
const account = await Account.query().where('id', id).first();
if (!account) {
return res.boom.notFound();
}
return res.status(200).send({ account });
},
},
/**
* Delete the given account.
*/
deleteAccount: {
validation: [param('id').toInt()],
async handler(req, res) {
const { id } = req.params;
const { Account, AccountTransaction } = req.models;
const account = await Account.query().findById(id);
if (!account) {
return res.boom.notFound();
}
if (account.predefined) {
return res.boom.badRequest(null, {
errors: [{ type: 'ACCOUNT.PREDEFINED', code: 200 }],
});
}
// Validate the account has no child accounts.
const childAccounts = await Account.query().where(
'parent_account_id',
account.id
);
if (childAccounts.length > 0) {
return res.boom.badRequest(null, {
errors: [{ type: 'ACCOUNT.HAS.CHILD.ACCOUNTS', code: 300 }],
});
}
const accountTransactions = await AccountTransaction.query().where(
'account_id',
account.id
);
if (accountTransactions.length > 0) {
return res.boom.badRequest(null, {
errors: [{ type: 'ACCOUNT.HAS.ASSOCIATED.TRANSACTIONS', code: 100 }],
});
}
await Account.query().deleteById(account.id);
return res.status(200).send();
},
},
/**
* Retrieve accounts list.
*/
getAccountsList: {
validation: [
query('display_type').optional().isIn(['tree', 'flat']),
query('account_types').optional().isArray(),
query('account_types.*').optional().isNumeric().toInt(),
query('custom_view_id').optional().isNumeric().toInt(),
query('stringified_filter_roles').optional().isJSON(),
query('column_sort_by').optional(),
query('sort_order').optional().isIn(['desc', 'asc']),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error',
...validationErrors,
});
}
const filter = {
display_type: 'flat',
account_types: [],
filter_roles: [],
sort_order: 'asc',
...req.query,
};
if (filter.stringified_filter_roles) {
filter.filter_roles = JSON.parse(filter.stringified_filter_roles);
}
const { Resource, Account, View } = req.models;
const errorReasons = [];
const accountsResource = await Resource.query()
.remember()
.where('name', 'accounts')
.withGraphFetched('fields')
.first();
if (!accountsResource) {
return res.status(400).send({
errors: [{ type: 'ACCOUNTS_RESOURCE_NOT_FOUND', code: 200 }],
});
}
const resourceFieldsKeys = accountsResource.fields.map((c) => c.key);
const view = await View.query().onBuild((builder) => {
if (filter.custom_view_id) {
builder.where('id', filter.custom_view_id);
} else {
builder.where('favourite', true);
}
// builder.where('resource_id', accountsResource.id);
builder.withGraphFetched('roles.field');
builder.withGraphFetched('columns');
builder.first();
builder.remember();
});
const dynamicFilter = new DynamicFilter(Account.tableName);
if (filter.column_sort_by) {
if (resourceFieldsKeys.indexOf(filter.column_sort_by) === -1) {
errorReasons.push({ type: 'COLUMN.SORT.ORDER.NOT.FOUND', code: 300 });
}
const sortByFilter = new DynamicFilterSortBy(
filter.column_sort_by,
filter.sort_order
);
dynamicFilter.setFilter(sortByFilter);
}
// View roles.
if (view && view.roles.length > 0) {
const viewFilter = new DynamicFilterViews(
mapViewRolesToConditionals(view.roles),
view.rolesLogicExpression
);
if (!viewFilter.validateFilterRoles()) {
errorReasons.push({
type: 'VIEW.LOGIC.EXPRESSION.INVALID',
code: 400,
});
}
dynamicFilter.setFilter(viewFilter);
}
// Filter roles.
if (filter.filter_roles.length > 0) {
// Validate the accounts resource fields.
const filterRoles = new DynamicFilterFilterRoles(
mapFilterRolesToDynamicFilter(filter.filter_roles),
accountsResource.fields
);
dynamicFilter.setFilter(filterRoles);
if (filterRoles.validateFilterRoles().length > 0) {
errorReasons.push({
type: 'ACCOUNTS.RESOURCE.HAS.NO.GIVEN.FIELDS',
code: 500,
});
}
}
if (errorReasons.length > 0) {
return res.status(400).send({ errors: errorReasons });
}
const accounts = await Account.query().onBuild((builder) => {
builder.modify('filterAccountTypes', filter.account_types);
builder.withGraphFetched('type');
builder.withGraphFetched('balance');
dynamicFilter.buildQuery()(builder);
});
return res.status(200).send({
accounts:
filter.display_type === 'tree'
? Account.toNestedArray(accounts)
: accounts,
...(view
? {
customViewId: view.id,
}
: {}),
});
},
},
/**
* Re-calculates balance of the given account.
*/
recalcualteBalanace: {
validation: [param('id').isNumeric().toInt()],
async handler(req, res) {
const { id } = req.params;
const { Account, AccountTransaction, AccountBalance } = req.models;
const account = await Account.findById(id);
if (!account) {
return res.status(400).send({
errors: [{ type: 'ACCOUNT.NOT.FOUND', code: 100 }],
});
}
const accountTransactions = AccountTransaction.query().where(
'account_id',
account.id
);
const journalEntries = new JournalPoster();
journalEntries.loadFromCollection(accountTransactions);
// Delete the balance of the given account id.
await AccountBalance.query().where('account_id', account.id).delete();
// Save calcualted account balance.
await journalEntries.saveBalance();
return res.status(200).send();
},
},
/**
* Active the given account.
*/
activeAccount: {
validation: [param('id').exists().isNumeric().toInt()],
async handler(req, res) {
const { id } = req.params;
const { Account } = req.models;
const account = await Account.query().findById(id);
if (!account) {
return res.status(400).send({
errors: [{ type: 'ACCOUNT.NOT.FOUND', code: 100 }],
});
}
await Account.query().where('id', id).patch({ active: true });
return res.status(200).send({ id: account.id });
},
},
/**
* Inactive the given account.
*/
inactiveAccount: {
validation: [param('id').exists().isNumeric().toInt()],
async handler(req, res) {
const { id } = req.params;
const { Account } = req.models;
const account = await Account.query().findById(id);
if (!account) {
return res.status(400).send({
errors: [{ type: 'ACCOUNT.NOT.FOUND', code: 100 }],
});
}
await Account.query().where('id', id).patch({ active: false });
return res.status(200).send({ id: account.id });
},
},
/**
* Transfer all journal entries of the given account to another account.
*/
transferToAnotherAccount: {
validation: [
param('id').exists().isNumeric().toInt(),
param('toAccount').exists().isNumeric().toInt(),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error',
...validationErrors,
});
}
// const { id, toAccount: toAccountId } = req.params;
// const [fromAccount, toAccount] = await Promise.all([
// Account.query().findById(id),
// Account.query().findById(toAccountId),
// ]);
// const fromAccountTransactions = await AccountTransaction.query()
// .where('account_id', fromAccount);
// return res.status(200).send();
},
},
deleteBulkAccounts: {
validation: [
query('ids').isArray({ min: 1 }),
query('ids.*').isNumeric().toInt(),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error',
...validationErrors,
});
}
const filter = { ids: [], ...req.query };
const { Account, AccountTransaction } = req.models;
const accounts = await Account.query().onBuild((builder) => {
if (filter.ids.length) {
builder.whereIn('id', filter.ids);
}
});
const accountsIds = accounts.map((a) => a.id);
const notFoundAccounts = difference(filter.ids, accountsIds);
const predefinedAccounts = accounts.filter(
(account) => account.predefined
);
const errorReasons = [];
if (notFoundAccounts.length > 0) {
return res.status(404).send({
errors: [
{
type: 'ACCOUNTS.IDS.NOT.FOUND',
code: 200,
ids: notFoundAccounts,
},
],
});
}
if (predefinedAccounts.length > 0) {
errorReasons.push({
type: 'ACCOUNT.PREDEFINED',
code: 200,
ids: predefinedAccounts.map((a) => a.id),
});
}
const accountsTransactions = await AccountTransaction.query()
.whereIn('account_id', accountsIds)
.count('id as transactions_count')
.groupBy('account_id')
.select('account_id');
const accountsHasTransactions = [];
accountsTransactions.forEach((transaction) => {
if (transaction.transactionsCount > 0) {
accountsHasTransactions.push(transaction.accountId);
}
});
if (accountsHasTransactions.length > 0) {
errorReasons.push({
type: 'ACCOUNT.HAS.ASSOCIATED.TRANSACTIONS',
code: 300,
ids: accountsHasTransactions,
});
}
if (errorReasons.length > 0) {
return res.status(400).send({ errors: errorReasons });
}
await Account.query()
.whereIn(
'id',
accounts.map((a) => a.id)
)
.delete();
return res.status(200).send();
},
},
/**
* Bulk acvtivate/inactivate the given accounts.
*/
bulkInactivateAccounts: {
validation: [
query('ids').isArray({ min: 1 }),
query('ids.*').isNumeric().toInt(),
param('type').exists().isIn(['activate', 'inactivate']),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error',
...validationErrors,
});
}
const filter = {
ids: [],
...req.query,
};
const { Account } = req.models;
const { type } = req.params;
const storedAccounts = await Account.query().whereIn('id', filter.ids);
const storedAccountsIds = storedAccounts.map((account) => account.id);
const notFoundAccounts = difference(filter.ids, storedAccountsIds);
if (notFoundAccounts.length > 0) {
return res.status(400).send({
errors: [{ type: 'ACCOUNTS.NOT.FOUND', code: 200 }],
});
}
const updatedAccounts = await Account.query()
.whereIn('id', storedAccountsIds)
.patch({
active: type === 'activate' ? 1 : 0,
});
return res.status(200).send({ ids: storedAccountsIds });
},
},
};