- 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.
This commit is contained in:
Ahmed Bouhuolia
2020-07-12 12:31:12 +02:00
parent 4bd8f1628d
commit 9d9c7c1568
60 changed files with 1685 additions and 929 deletions

View File

@@ -3,7 +3,6 @@ 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 NestedSet from '@/collection/NestedSet';
import {
mapViewRolesToConditionals,
mapFilterRolesToDynamicFilter,
@@ -97,9 +96,13 @@ export default {
newAccount: {
validation: [
check('name').exists().isLength({ min: 3, max: 255 }).trim().escape(),
check('code').optional().isLength({ min: 3, max: 6, }).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);
@@ -125,7 +128,6 @@ export default {
foundAccountCodePromise,
foundAccountTypePromise,
]);
if (foundAccountCodePromise && foundAccountCode.length > 0) {
return res.boom.badRequest(null, {
errors: [{ type: 'NOT_UNIQUE_CODE', code: 100 }],
@@ -136,6 +138,24 @@ export default {
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 } });
@@ -148,8 +168,8 @@ export default {
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('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(),
],
@@ -189,7 +209,26 @@ export default {
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 });
}
@@ -238,11 +277,22 @@ export default {
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 }],
@@ -279,8 +329,8 @@ export default {
});
}
const filter = {
display_type: 'flat',
account_types: [],
display_type: 'tree',
filter_roles: [],
sort_order: 'asc',
...req.query,
@@ -364,38 +414,18 @@ export default {
return res.status(400).send({ errors: errorReasons });
}
const query = Account.query()
// .remember()
.onBuild((builder) => {
builder.modify('filterAccountTypes', filter.account_types);
builder.withGraphFetched('type');
builder.withGraphFetched('balance');
dynamicFilter.buildQuery()(builder);
// console.log(builder.toKnexQuery().toSQL());
})
.toKnexQuery()
.toSQL();
console.log(query);
const accounts = await Account.query()
// .remember()
.onBuild((builder) => {
builder.modify('filterAccountTypes', filter.account_types);
builder.withGraphFetched('type');
builder.withGraphFetched('balance');
dynamicFilter.buildQuery()(builder);
// console.log(builder.toKnexQuery().toSQL());
});
const nestedAccounts = Account.toNestedArray(accounts);
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: nestedAccounts,
accounts:
filter.display_type === 'tree'
? Account.toNestedArray(accounts)
: accounts,
...(view
? {
customViewId: view.id,

View File

@@ -181,6 +181,7 @@ export default {
const mixinEntry = {
referenceType: 'Expense',
referenceId: expenseTransaction.id,
date: moment(form.payment_date).format('YYYY-MM-DD'),
userId: user.id,
draft: !form.publish,
};

View File

@@ -1,13 +1,12 @@
import express from 'express';
import { query, oneOf, validationResult } from 'express-validator';
import { query, validationResult } from 'express-validator';
import moment from 'moment';
import { pick, difference, groupBy } from 'lodash';
import { pick, omit, sumBy } from 'lodash';
import JournalPoster from '@/services/Accounting/JournalPoster';
import { dateRangeCollection } from '@/utils';
import DependencyGraph from '@/lib/DependencyGraph';
import asyncMiddleware from '@/http/middleware/asyncMiddleware'
import { dateRangeCollection, itemsStartWith, getTotalDeep } from '@/utils';
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import { formatNumberClosure } from './FinancialStatementMixin';
import BalanceSheetStructure from '@/data/BalanceSheetStructure';
export default {
/**
@@ -16,13 +15,15 @@ export default {
router() {
const router = express.Router();
router.get('/',
router.get(
'/',
this.balanceSheet.validation,
asyncMiddleware(this.balanceSheet.handler));
asyncMiddleware(this.balanceSheet.handler)
);
return router;
},
/**
* Retrieve the balance sheet.
*/
@@ -32,7 +33,8 @@ export default {
query('from_date').optional(),
query('to_date').optional(),
query('display_columns_type').optional().isIn(['date_periods', 'total']),
query('display_columns_by').optional({ nullable: true, checkFalsy: true })
query('display_columns_by')
.optional({ nullable: true, checkFalsy: true })
.isIn(['year', 'month', 'week', 'day', 'quarter']),
query('number_format.no_cents').optional().isBoolean().toBoolean(),
query('number_format.divide_1000').optional().isBoolean().toBoolean(),
@@ -45,7 +47,8 @@ export default {
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
code: 'validation_error',
...validationErrors,
});
}
const { Account, AccountType } = req.models;
@@ -67,106 +70,168 @@ export default {
if (!Array.isArray(filter.account_ids)) {
filter.account_ids = [filter.account_ids];
}
// Account balance formmatter based on the given query.
const balanceFormatter = formatNumberClosure(filter.number_format);
const comparatorDateType = filter.display_columns_type === 'total' ? 'day' : filter.display_columns_by;
const balanceSheetTypes = await AccountType.query().where('balance_sheet', true);
const amountFormatter = formatNumberClosure(filter.number_format);
const comparatorDateType =
filter.display_columns_type === 'total'
? 'day'
: filter.display_columns_by;
const balanceSheetTypes = await AccountType.query().where(
'balance_sheet',
true
);
// Fetch all balance sheet accounts from the storage.
const accounts = await Account.query()
// .remember('balance_sheet_accounts')
.whereIn('account_type_id', balanceSheetTypes.map((a) => a.id))
.whereIn(
'account_type_id',
balanceSheetTypes.map((a) => a.id)
)
.modify('filterAccounts', filter.account_ids)
.withGraphFetched('type')
.withGraphFetched('transactions')
.modifyGraph('transactions', (builder) => {
builder.modify('filterDateRange', null, filter.to_date);
});
// Accounts dependency graph.
const accountsGraph = DependencyGraph.fromArray(
accounts, { itemId: 'id', parentItemId: 'parentAccountId' }
);
const accountsGraph = Account.toDependencyGraph(accounts);
// Load all entries that associated to the given accounts.
const journalEntriesCollected = Account.collectJournalEntries(accounts);
const journalEntries = new JournalPoster(accountsGraph);
journalEntries.loadEntries(journalEntriesCollected);
// Date range collection.
const dateRangeSet = (filter.display_columns_type === 'date_periods')
? dateRangeCollection(
filter.from_date, filter.to_date, comparatorDateType,
) : [];
// Date range collection.
const dateRangeSet =
filter.display_columns_type === 'date_periods'
? dateRangeCollection(
filter.from_date,
filter.to_date,
comparatorDateType
)
: [];
// Gets the date range set from start to end date.
const totalPeriods = (account) => ({
const getAccountTotalPeriods = (account) => ({
total_periods: dateRangeSet.map((date) => {
const amount = journalEntries.getAccountBalance(account.id, date, comparatorDateType);
const amount = journalEntries.getAccountBalance(
account.id,
date,
comparatorDateType
);
return {
amount,
formatted_amount: balanceFormatter(amount),
date,
formatted_amount: amountFormatter(amount),
};
}),
});
const accountsMapperToResponse = (account) => {
// Calculates the closing balance to the given date.
const closingBalance = journalEntries.getAccountBalance(account.id, filter.to_date);
// Retrieve accounts total periods.
const getAccountsTotalPeriods = (_accounts) =>
Object.values(
dateRangeSet.reduce((acc, date, index) => {
const amount = sumBy(_accounts, `total_periods[${index}].amount`);
acc[date] = {
date,
amount,
formatted_amount: amountFormatter(amount),
};
return acc;
}, {})
);
// Retrieve account total and total periods with account meta.
const getAccountTotal = (account) => {
const closingBalance = journalEntries.getAccountBalance(
account.id,
filter.to_date
);
const totalPeriods =
(filter.display_columns_type === 'date_periods' &&
getAccountTotalPeriods(account)) ||
null;
return {
...pick(account, ['id', 'index', 'name', 'code', 'parentAccountId']),
// Date periods when display columns.
...(filter.display_columns_type === 'date_periods') && totalPeriods(account),
...(totalPeriods && { totalPeriods }),
total: {
amount: closingBalance,
formatted_amount: balanceFormatter(closingBalance),
formatted_amount: amountFormatter(closingBalance),
date: filter.to_date,
},
};
};
// Get accounts total of the given structure section
const getAccountsSectionTotal = (_accounts) => {
const total = getTotalDeep(_accounts, 'children', 'total.amount');
return {
total: {
total,
formatted_amount: amountFormatter(total),
},
};
};
// Strcuture accounts related mapper.
const structureAccountsRelatedMapper = (accountsTypes) => {
const filteredAccounts = accounts
// Filter accounts that have no transaction when `none_zero` is on.
.filter(
(account) => account.transactions.length > 0 || !filter.none_zero
)
// Filter accounts that associated to the section accounts types.
.filter(
(account) => accountsTypes.indexOf(account.type.childType) !== -1
)
.map(getAccountTotal);
// Gets total amount of the given accounts.
const totalAmount = sumBy(filteredAccounts, 'total.amount');
// Retrieve all assets accounts.
const assetsAccounts = accounts.filter((account) => (
account.type.normal === 'debit'
&& (account.transactions.length > 0 || !filter.none_zero)))
.map(accountsMapperToResponse);
// Retrieve all liability accounts.
const liabilitiesAccounts = accounts.filter((account) => (
account.type.normal === 'credit'
&& (account.transactions.length > 0 || !filter.none_zero)))
.map(accountsMapperToResponse);
// Retrieve the asset balance sheet.
const assetsAccountsResponse = Account.toNestedArray(assetsAccounts);
// Retrieve liabilities and equity balance sheet.
const liabilitiesEquityResponse = Account.toNestedArray(liabilitiesAccounts);
return {
children: Account.toNestedArray(filteredAccounts),
total: {
amount: totalAmount,
formatted_amount: amountFormatter(totalAmount),
},
...(filter.display_columns_type === 'date_periods'
? {
total_periods: getAccountsTotalPeriods(filteredAccounts),
}
: {}),
};
};
// Structure section mapper.
const structureSectionMapper = (structure) => {
const result = {
...omit(structure, itemsStartWith(Object.keys(structure), '_')),
...(structure.children
? {
children: balanceSheetWalker(structure.children),
}
: {}),
...(structure._accounts_types_related
? {
...structureAccountsRelatedMapper(
structure._accounts_types_related
),
}
: {}),
};
return {
...result,
...(!structure._accounts_types_related
? getAccountsSectionTotal(result.children)
: {}),
};
};
const balanceSheetWalker = (reportStructure) =>
reportStructure.map(structureSectionMapper).filter(
// Filter the structure sections that have no children.
(structure) => structure.children.length > 0 || structure._forceShow
);
// Response.
return res.status(200).send({
query: { ...filter },
columns: { ...dateRangeSet },
accounts: [
{
name: 'Assets',
type: 'assets',
children: [...assetsAccountsResponse],
},
{
name: 'Liabilities & Equity',
type: 'liabilities_equity',
children: [...liabilitiesEquityResponse],
},
],
balance_sheet: [...balanceSheetWalker(BalanceSheetStructure)],
});
},
},
}
};

View File

@@ -1,12 +1,11 @@
import express from 'express';
import { query, oneOf, validationResult } from 'express-validator';
import moment from 'moment';
import { pick } from 'lodash';
import { pick, sumBy } from 'lodash';
import JournalPoster from '@/services/Accounting/JournalPoster';
import { dateRangeCollection } from '@/utils';
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import { formatNumberClosure } from './FinancialStatementMixin';
import DependencyGraph from '@/lib/DependencyGraph';
export default {
/**
@@ -15,10 +14,11 @@ export default {
router() {
const router = express.Router();
router.get('/',
router.get(
'/',
this.profitLossSheet.validation,
asyncMiddleware(this.profitLossSheet.handler));
asyncMiddleware(this.profitLossSheet.handler)
);
return router;
},
@@ -36,10 +36,9 @@ export default {
query('none_zero').optional().isBoolean().toBoolean(),
query('account_ids').isArray().optional(),
query('account_ids.*').isNumeric().toInt(),
query('display_columns_type').optional().isIn([
'total', 'date_periods',
]),
query('display_columns_by').optional({ nullable: true, checkFalsy: true })
query('display_columns_type').optional().isIn(['total', 'date_periods']),
query('display_columns_by')
.optional({ nullable: true, checkFalsy: true })
.isIn(['year', 'month', 'week', 'day', 'quarter']),
],
async handler(req, res) {
@@ -47,7 +46,8 @@ export default {
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
code: 'validation_error',
...validationErrors,
});
}
const { Account, AccountType } = req.models;
@@ -68,91 +68,110 @@ export default {
if (!Array.isArray(filter.account_ids)) {
filter.account_ids = [filter.account_ids];
}
const incomeStatementTypes = await AccountType.query().where('income_sheet', true);
const incomeStatementTypes = await AccountType.query().where(
'income_sheet',
true
);
// Fetch all income accounts from storage.
const accounts = await Account.query()
// .remember('profit_loss_accounts')
.modify('filterAccounts', filter.account_ids)
.whereIn('account_type_id', incomeStatementTypes.map((t) => t.id))
.whereIn(
'account_type_id',
incomeStatementTypes.map((t) => t.id)
)
.withGraphFetched('type')
.withGraphFetched('transactions');
// Accounts dependency graph.
const accountsGraph = DependencyGraph.fromArray(
accounts, { itemId: 'id', parentItemId: 'parentAccountId' }
);
const accountsGraph = Account.toDependencyGraph(accounts);
// Filter all none zero accounts if it was enabled.
const filteredAccounts = accounts.filter((account) => (
account.transactions.length > 0 || !filter.none_zero
));
const filteredAccounts = accounts.filter(
(account) => account.transactions.length > 0 || !filter.none_zero
);
const journalEntriesCollected = Account.collectJournalEntries(accounts);
const journalEntries = new JournalPoster(accountsGraph);
journalEntries.loadEntries(journalEntriesCollected);
// Account balance formmatter based on the given query.
const numberFormatter = formatNumberClosure(filter.number_format);
const comparatorDateType = filter.display_columns_type === 'total'
? 'day' : filter.display_columns_by;
const comparatorDateType =
filter.display_columns_type === 'total'
? 'day'
: filter.display_columns_by;
// Gets the date range set from start to end date.
const dateRangeSet = dateRangeCollection(
filter.from_date,
filter.to_date,
comparatorDateType,
comparatorDateType
);
const accountsMapper = (incomeExpenseAccounts) => (
const accountsMapper = (incomeExpenseAccounts) =>
incomeExpenseAccounts.map((account) => ({
...pick(account, ['id', 'index', 'name', 'code', 'parentAccountId']),
// Total closing balance of the account.
...(filter.display_columns_type === 'total') && {
...(filter.display_columns_type === 'total' && {
total: (() => {
const amount = journalEntries.getAccountBalance(account.id, filter.to_date);
return { amount, date: filter.to_date, formatted_amount: numberFormatter(amount) };
const amount = journalEntries.getAccountBalance(
account.id,
filter.to_date
);
return {
amount,
date: filter.to_date,
formatted_amount: numberFormatter(amount),
};
})(),
},
}),
// Date periods when display columns type `periods`.
...(filter.display_columns_type === 'date_periods') && {
...(filter.display_columns_type === 'date_periods' && {
periods: dateRangeSet.map((date) => {
const type = comparatorDateType;
const amount = journalEntries.getAccountBalance(account.id, date, type);
return { date, amount, formatted_amount: numberFormatter(amount) };
const amount = journalEntries.getAccountBalance(
account.id,
date,
type
);
return {
date,
amount,
formatted_amount: numberFormatter(amount),
};
}),
},
})));
}),
}));
const totalAccountsReducer = (incomeExpenseAccounts) => (
incomeExpenseAccounts.reduce((acc, account) => {
const amount = (account) ? account.total.amount : 0;
return amount + acc;
}, 0));
const accountsIncome = Account.toNestedArray(
accountsMapper(
filteredAccounts.filter((account) => account.type.normal === 'credit')
)
);
const accountsExpenses = Account.toNestedArray(
accountsMapper(
filteredAccounts.filter((account) => account.type.normal === 'debit')
)
);
const totalPeriodsMapper = (incomeExpenseAccounts) =>
Object.values(
dateRangeSet.reduce((acc, date, index) => {
let amount = sumBy(
incomeExpenseAccounts,
`periods[${index}].amount`
);
acc[date] = {
date,
amount,
formatted_amount: numberFormatter(amount),
};
return acc;
}, {})
);
const accountsIncome = Account.toNestedArray(accountsMapper(filteredAccounts
.filter((account) => account.type.normal === 'credit')));
const accountsExpenses = Account.toNestedArray(accountsMapper(filteredAccounts
.filter((account) => account.type.normal === 'debit')));
// @return {Array}
const totalPeriodsMapper = (incomeExpenseAccounts) => (
Object.values(dateRangeSet.reduce((acc, date, index) => {
let amount = 0;
incomeExpenseAccounts.forEach((account) => {
const currentDate = account.periods[index];
amount += currentDate.amount || 0;
});
acc[date] = { date, amount, formatted_amount: numberFormatter(amount) };
return acc;
}, {})));
// Total income(date) - Total expenses(date) = Net income(date)
// @return {Array}
const netIncomePeriodsMapper = (totalIncomeAcocunts, totalExpenseAccounts) => (
// Total income - Total expenses = Net income
const netIncomePeriodsMapper = (
totalIncomeAcocunts,
totalExpenseAccounts
) =>
dateRangeSet.map((date, index) => {
const totalIncome = totalIncomeAcocunts[index];
const totalExpenses = totalExpenseAccounts[index];
@@ -160,66 +179,71 @@ export default {
let amount = totalIncome.amount || 0;
amount -= totalExpenses.amount || 0;
return { date, amount, formatted_amount: numberFormatter(amount) };
}));
});
// @return {Object}
const netIncomeTotal = (totalIncome, totalExpenses) => {
const netIncomeAmount = totalIncome.amount - totalExpenses.amount;
return { amount: netIncomeAmount, formatted_amount: netIncomeAmount, date: filter.to_date };
return {
amount: netIncomeAmount,
formatted_amount: netIncomeAmount,
date: filter.to_date,
};
};
const incomeResponse = {
entry_normal: 'credit',
accounts: accountsIncome,
...(filter.display_columns_type === 'total') && (() => {
const totalIncomeAccounts = totalAccountsReducer(accountsIncome);
return {
total: {
amount: totalIncomeAccounts,
date: filter.to_date,
formatted_amount: numberFormatter(totalIncomeAccounts),
},
};
})(),
...(filter.display_columns_type === 'date_periods') && {
total_periods: [
...totalPeriodsMapper(accountsIncome),
],
},
...(filter.display_columns_type === 'total' &&
(() => {
const totalIncomeAccounts = sumBy(accountsIncome, 'total.amount');
return {
total: {
amount: totalIncomeAccounts,
date: filter.to_date,
formatted_amount: numberFormatter(totalIncomeAccounts),
},
};
})()),
...(filter.display_columns_type === 'date_periods' && {
total_periods: [...totalPeriodsMapper(accountsIncome)],
}),
};
const expenseResponse = {
entry_normal: 'debit',
accounts: accountsExpenses,
...(filter.display_columns_type === 'total') && (() => {
const totalExpensesAccounts = totalAccountsReducer(accountsExpenses);
return {
total: {
amount: totalExpensesAccounts,
date: filter.to_date,
formatted_amount: numberFormatter(totalExpensesAccounts),
},
};
})(),
...(filter.display_columns_type === 'date_periods') && {
total_periods: [
...totalPeriodsMapper(accountsExpenses),
],
},
...(filter.display_columns_type === 'total' &&
(() => {
const totalExpensesAccounts = sumBy(
accountsExpenses,
'total.amount'
);
return {
total: {
amount: totalExpensesAccounts,
date: filter.to_date,
formatted_amount: numberFormatter(totalExpensesAccounts),
},
};
})()),
...(filter.display_columns_type === 'date_periods' && {
total_periods: [...totalPeriodsMapper(accountsExpenses)],
}),
};
const netIncomeResponse = {
...(filter.display_columns_type === 'total') && {
...(filter.display_columns_type === 'total' && {
total: {
...netIncomeTotal(incomeResponse.total, expenseResponse.total),
},
},
...(filter.display_columns_type === 'date_periods') && {
}),
...(filter.display_columns_type === 'date_periods' && {
total_periods: [
...netIncomePeriodsMapper(
incomeResponse.total_periods,
expenseResponse.total_periods,
expenseResponse.total_periods
),
],
},
}),
};
return res.status(200).send({
query: { ...filter },
@@ -228,8 +252,8 @@ export default {
income: incomeResponse,
expenses: expenseResponse,
net_income: netIncomeResponse,
},
},
});
},
},
}
};