Merge branch 'master' of https://github.com/abouolia/Ratteb into feature/editItem

This commit is contained in:
elforjani3
2020-05-23 14:08:51 +02:00
27 changed files with 2042 additions and 900 deletions

View File

@@ -74,6 +74,7 @@
"react-loadable": "^5.5.0",
"react-query": "^1.3.3",
"react-redux": "^7.1.3",
"react-router-breadcrumbs-hoc": "^3.2.10",
"react-router-dom": "^5.1.2",
"react-scrollbars-custom": "^4.0.21",
"react-sortablejs": "^2.0.11",

View File

@@ -10,8 +10,13 @@ export default class NestedSet {
id: 'id',
...options,
};
this.items = items || [];
this.tree = this.linkChildren();
}
setItems(items) {
this.items = items;
this.collection = {};
this.tree = this.linkChildren();
}
/**
@@ -23,37 +28,71 @@ export default class NestedSet {
const map = {};
this.items.forEach((item) => {
map[item.id] = item;
map[item.id].children = [];
map[item.id].children = {};
});
this.items.forEach((item) => {
const parentNodeId = item[this.options.parentId];
if (parentNodeId) {
map[parentNodeId].children.push(item);
map[parentNodeId].children[item.id] = item;
}
});
return map;
}
toTree() {
const map = this.linkChildren();
const tree = [];
toArray() {
const stack = [];
const treeNodes = this.items.map((i) => ({ ...i }));
this.items.forEach((item) => {
const parentNodeId = item[this.options.parentId];
if (!parentNodeId) {
tree.push(map[item.id]);
}
});
this.collection = Object.values(tree);
return this.collection;
const walk = (nodes) => {
nodes.forEach((node) => {
if (!node[this.options.parentId]) {
stack.push(node);
}
if (node.children) {
const childrenNodes = Object.values(node.children)
.map((i) => ({ ...i }));
node.children = childrenNodes;
walk(childrenNodes);
}
});
};
walk(treeNodes);
return stack;
}
getTree() {
return this.collection;
return this.tree;
}
flattenTree(nodeMapper) {
getElementById(id) {
return this.tree[id] || null
}
getParents(id) {
const item = this.getElementById(id);
const parents = [];
let index = 0;
const walk = (_item) => {
if (!item) return;
if (index) {
parents.push(_item);
}
if (_item[this.options.parentId]) {
const parentItem = this.getElementById(_item[this.options.parentId]);
index++;
walk(parentItem);
}
};
walk(item);
return parents;
}
toFlattenArray(nodeMapper) {
const flattenTree = [];
const traversal = (nodes, parentNode) => {

View File

@@ -112,6 +112,7 @@ export default {
builder.withGraphFetched('roles.field');
builder.withGraphFetched('columns');
builder.first();
builder.remember();
});
const resourceFieldsKeys = manualJournalsResource.fields.map((c) => c.key);
@@ -229,8 +230,10 @@ export default {
errorReasons.push({ type: 'CREDIT.DEBIT.NOT.EQUALS', code: 100 });
}
const accountsIds = entries.map((entry) => entry.account_id);
const accounts = await Account.query().whereIn('id', accountsIds)
.withGraphFetched('type');
const accounts = await Account.query()
.whereIn('id', accountsIds)
.withGraphFetched('type')
.remember();
const storedAccountsIds = accounts.map((account) => account.id);
@@ -266,7 +269,9 @@ export default {
status: form.status,
user_id: user.id,
});
const journalPoster = new JournalPoster();
const accountsDepGraph = await Account.depGraph().query().remember();
const journalPoster = new JournalPoster(accountsDepGraph);
entries.forEach((entry) => {
const account = accounts.find((a) => a.id === entry.account_id);
@@ -456,7 +461,9 @@ export default {
.where('reference_id', manualJournal.id)
.withGraphFetched('account.type');
const journal = new JournalPoster();
const accountsDepGraph = await Account.depGraph().query().remember();
const journal = new JournalPoster(accountsDepGraph);
journal.loadEntries(transactions);
journal.removeEntries();
@@ -521,6 +528,7 @@ export default {
const {
ManualJournal,
AccountTransaction,
Account,
} = req.models;
const { id } = req.params;
@@ -546,7 +554,9 @@ export default {
.where('reference_id', manualJournal.id)
.withGraphFetched('account.type');
const journal = new JournalPoster();
const accountsDepGraph = await Account.depGraph().query().remember();
const journal = new JournalPoster(accountsDepGraph);
journal.loadEntries(transactions);
journal.calculateEntriesBalanceChange();
@@ -626,7 +636,9 @@ export default {
ManualJournal,
AccountTransaction,
MediaLink,
Account,
} = req.models;
const manualJournal = await ManualJournal.query()
.where('id', id).first();
@@ -640,7 +652,9 @@ export default {
.where('reference_id', manualJournal.id)
.withGraphFetched('account.type');
const journal = new JournalPoster();
const accountsDepGraph = await Account.depGraph().query().remember();
const journal = new JournalPoster(accountsDepGraph);
journal.loadEntries(transactions);
journal.removeEntries();
@@ -744,7 +758,7 @@ export default {
});
}
const filter = { ...req.query };
const { ManualJournal, AccountTransaction, MediaLink } = req.models;
const { ManualJournal, AccountTransaction, Account, MediaLink } = req.models;
const manualJournals = await ManualJournal.query()
.whereIn('id', filter.ids);
@@ -760,7 +774,8 @@ export default {
.whereIn('reference_type', ['Journal', 'ManualJournal'])
.whereIn('reference_id', filter.ids);
const journal = new JournalPoster();
const accountsDepGraph = await Account.depGraph().query().remember();
const journal = new JournalPoster(accountsDepGraph);
journal.loadEntries(transactions);
journal.removeEntries();

View File

@@ -348,11 +348,10 @@ export default {
dynamicFilter.buildQuery()(builder);
});
const nestedAccounts = new NestedSet(accounts, { parentId: 'parentAccountId' });
const nestedSetAccounts = nestedAccounts.toTree();
const nestedAccounts = Account.toNestedArray(accounts);
return res.status(200).send({
accounts: nestedSetAccounts,
accounts: nestedAccounts,
...(view) ? {
customViewId: view.id,
} : {},

View File

@@ -1,22 +1,10 @@
import express from 'express';
import { query, oneOf, validationResult } from 'express-validator';
import moment from 'moment';
import { pick, difference, groupBy } from 'lodash';
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import JournalPoster from '@/services/Accounting/JournalPoster';
import { dateRangeCollection } from '@/utils';
const formatNumberClosure = (filter) => (balance) => {
let formattedBalance = parseFloat(balance);
if (filter.no_cents) {
formattedBalance = parseInt(formattedBalance, 10);
}
if (filter.divide_1000) {
formattedBalance /= 1000;
}
return formattedBalance;
};
import BalanceSheetController from './FinancialStatements/BalanceSheet';
import TrialBalanceSheetController from './FinancialStatements/TrialBalanceSheet';
import GeneralLedgerController from './FinancialStatements/generalLedger';
import JournalSheetController from './FinancialStatements/JournalSheet';
import ProfitLossController from './FinancialStatements/ProfitLossSheet';
export default {
/**
@@ -25,673 +13,13 @@ export default {
router() {
const router = express.Router();
router.get('/journal',
this.journal.validation,
asyncMiddleware(this.journal.handler));
router.get('/general_ledger',
this.generalLedger.validation,
asyncMiddleware(this.generalLedger.handler));
router.get('/balance_sheet',
this.balanceSheet.validation,
asyncMiddleware(this.balanceSheet.handler));
router.get('/trial_balance_sheet',
this.trialBalanceSheet.validation,
asyncMiddleware(this.trialBalanceSheet.handler));
router.get('/profit_loss_sheet',
this.profitLossSheet.validation,
asyncMiddleware(this.profitLossSheet.handler));
router.get('/cash_flow_statement',
this.cashFlowStatement.validation,
asyncMiddleware(this.cashFlowStatement.handler));
// router.use('/journal', JournalController);
router.use('/balance_sheet', BalanceSheetController.router());
router.use('/profit_loss_sheet', ProfitLossController.router());
router.use('/general_ledger', GeneralLedgerController.router());
router.use('/trial_balance_sheet', TrialBalanceSheetController.router());
router.use('/journal', JournalSheetController.router());
return router;
},
/**
* Retrieve the ledger report of the given account.
*/
journal: {
validation: [
query('from_date').optional().isISO8601(),
query('to_date').optional().isISO8601(),
oneOf([
query('transaction_types').optional().isArray({ min: 1 }),
query('transaction_types.*').optional().isNumeric().toInt(),
], [
query('transaction_types').optional().trim().escape(),
]),
oneOf([
query('account_ids').optional().isArray({ min: 1 }),
query('account_ids.*').optional().isNumeric().toInt(),
], [
query('account_ids').optional().isNumeric().toInt(),
]),
query('from_range').optional().isNumeric().toInt(),
query('to_range').optional().isNumeric().toInt(),
query('number_format.no_cents').optional().isBoolean().toBoolean(),
query('number_format.divide_1000').optional().isBoolean().toBoolean(),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
const { AccountTransaction } = req.models;
const filter = {
from_date: moment().startOf('year').format('YYYY-MM-DD'),
to_date: moment().endOf('year').format('YYYY-MM-DD'),
from_range: null,
to_range: null,
account_ids: [],
transaction_types: [],
number_format: {
no_cents: false,
divide_1000: false,
},
...req.query,
};
if (!Array.isArray(filter.transaction_types)) {
filter.transaction_types = [filter.transaction_types];
}
if (!Array.isArray(filter.account_ids)) {
filter.account_ids = [filter.account_ids];
}
filter.account_ids = filter.account_ids.map((id) => parseInt(id, 10));
const accountsJournalEntries = await AccountTransaction.query()
.remember()
.modify('filterDateRange', filter.from_date, filter.to_date)
.modify('filterAccounts', filter.account_ids)
.modify('filterTransactionTypes', filter.transaction_types)
.modify('filterAmountRange', filter.from_range, filter.to_range)
.withGraphFetched('account.type');
const formatNumber = formatNumberClosure(filter.number_format);
const journalGrouped = groupBy(accountsJournalEntries,
(entry) => `${entry.referenceId}-${entry.referenceType}`);
const journal = Object.keys(journalGrouped).map((key) => {
const transactionsGroup = journalGrouped[key];
const journalPoster = new JournalPoster();
journalPoster.loadEntries(transactionsGroup);
const trialBalance = journalPoster.getTrialBalance();
return {
id: key,
entries: transactionsGroup,
credit: formatNumber(trialBalance.credit),
debit: formatNumber(trialBalance.debit),
};
});
return res.status(200).send({
query: { ...filter },
journal,
});
},
},
/**
* Retrieve the general ledger financial statement.
*/
generalLedger: {
validation: [
query('from_date').optional().isISO8601(),
query('to_date').optional().isISO8601(),
query('basis').optional(),
query('number_format.no_cents').optional().isBoolean().toBoolean(),
query('number_format.divide_1000').optional().isBoolean().toBoolean(),
query('none_zero').optional().isBoolean().toBoolean(),
query('accounts_ids').optional(),
query('accounts_ids.*').isNumeric().toInt(),
query('orderBy').optional().isIn(['created_at', 'name', 'code']),
query('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 { AccountTransaction, Account } = req.models;
const filter = {
from_date: moment().startOf('year').format('YYYY-MM-DD'),
to_date: moment().endOf('year').format('YYYY-MM-DD'),
basis: 'cash',
number_format: {
no_cents: false,
divide_1000: false,
},
none_zero: false,
accounts_ids: [],
...req.query,
};
if (!Array.isArray(filter.accounts_ids)) {
filter.accounts_ids = [filter.accounts_ids];
}
filter.accounts_ids = filter.accounts_ids.map((id) => parseInt(id, 10));
const errorReasons = [];
if (filter.accounts_ids.length > 0) {
const accounts = await Account.query().whereIn('id', filter.accounts_ids);
const accountsIds = accounts.map((a) => a.id);
if (difference(filter.accounts_ids, accountsIds).length > 0) {
errorReasons.push({ type: 'FILTER.ACCOUNTS.IDS.NOT.FOUND', code: 200 });
}
}
if (errorReasons.length > 0) {
return res.status(400).send({ error: errorReasons });
}
const accounts = await Account.query()
.remember('general_ledger_accounts')
.orderBy('index', 'DESC')
.modify('filterAccounts', filter.accounts_ids)
.withGraphFetched('type')
.withGraphFetched('transactions')
.modifyGraph('transactions', (builder) => {
builder.modify('filterDateRange', filter.from_date, filter.to_date);
});
const openingBalanceTransactions = await AccountTransaction.query()
.remember()
.modify('filterDateRange', null, filter.from_date)
.modify('sumationCreditDebit')
.withGraphFetched('account.type');
const closingBalanceTransactions = await AccountTransaction.query()
.remember()
.modify('filterDateRange', null, filter.to_date)
.modify('sumationCreditDebit')
.withGraphFetched('account.type');
const opeingBalanceCollection = new JournalPoster();
const closingBalanceCollection = new JournalPoster();
opeingBalanceCollection.loadEntries(openingBalanceTransactions);
closingBalanceCollection.loadEntries(closingBalanceTransactions);
// Transaction amount formatter based on the given query.
const formatNumber = formatNumberClosure(filter.number_format);
const items = accounts
.filter((account) => (
account.transactions.length > 0 || !filter.none_zero
))
.map((account) => ({
...pick(account, ['id', 'name', 'code', 'index']),
transactions: [
...account.transactions.map((transaction) => {
let amount = 0;
if (account.type.normal === 'credit') {
amount += transaction.credit - transaction.debit;
} else if (account.type.normal === 'debit') {
amount += transaction.debit - transaction.credit;
}
return {
...pick(transaction, ['id', 'note', 'transactionType', 'referenceType',
'referenceId', 'date', 'createdAt']),
amount: formatNumber(amount),
};
}),
],
opening: {
date: filter.from_date,
amount: formatNumber(opeingBalanceCollection.getClosingBalance(account.id)),
},
closing: {
date: filter.to_date,
amount: formatNumber(closingBalanceCollection.getClosingBalance(account.id)),
},
}));
return res.status(200).send({
query: { ...filter },
accounts: items,
});
},
},
/**
* Retrieve the balance sheet.
*/
balanceSheet: {
validation: [
query('accounting_method').optional().isIn(['cash', 'accural']),
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 })
.isIn(['year', 'month', 'week', 'day', 'quarter']),
query('number_format.no_cents').optional().isBoolean().toBoolean(),
query('number_format.divide_1000').optional().isBoolean().toBoolean(),
query('account_ids').isArray().optional(),
query('account_ids.*').isNumeric().toInt(),
query('none_zero').optional().isBoolean().toBoolean(),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
const { Account, AccountType } = req.models;
const filter = {
display_columns_type: 'total',
display_columns_by: '',
from_date: moment().startOf('year').format('YYYY-MM-DD'),
to_date: moment().endOf('year').format('YYYY-MM-DD'),
number_format: {
no_cents: false,
divide_1000: false,
},
none_zero: false,
basis: 'cash',
account_ids: [],
...req.query,
};
if (!Array.isArray(filter.account_ids)) {
filter.account_ids = [filter.account_ids];
}
const balanceSheetTypes = await AccountType.query().where('balance_sheet', true);
// Fetch all balance sheet accounts.
const accounts = await Account.query()
.remember('balance_sheet_accounts')
.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);
});
const journalEntriesCollected = Account.collectJournalEntries(accounts);
const journalEntries = new JournalPoster();
journalEntries.loadEntries(journalEntriesCollected);
// 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 dateRangeSet = (filter.display_columns_type === 'date_periods')
? dateRangeCollection(
filter.from_date, filter.to_date, comparatorDateType,
) : [];
const totalPeriods = (account) => ({
// Gets the date range set from start to end date.
total_periods: dateRangeSet.map((date) => {
const balance = journalEntries.getClosingBalance(account.id, date, comparatorDateType);
return {
date,
formatted_amount: balanceFormatter(balance),
amount: balance,
};
}),
});
const accountsMapper = (balanceSheetAccounts) => [
...balanceSheetAccounts.map((account) => {
// Calculates the closing balance to the given date.
const closingBalance = journalEntries.getClosingBalance(account.id, filter.to_date);
return {
...pick(account, ['id', 'index', 'name', 'code']),
// Date periods when display columns.
...(filter.display_columns_type === 'date_periods') && totalPeriods(account),
total: {
formatted_amount: balanceFormatter(closingBalance),
amount: closingBalance,
date: filter.to_date,
},
};
}),
];
// Retrieve all assets accounts.
const assetsAccounts = accounts.filter((account) => (
account.type.normal === 'debit'
&& (account.transactions.length > 0 || !filter.none_zero)));
// Retrieve all liability accounts.
const liabilitiesAccounts = accounts.filter((account) => (
account.type.normal === 'credit'
&& (account.transactions.length > 0 || !filter.none_zero)));
// Retrieve the asset balance sheet.
const assets = accountsMapper(assetsAccounts);
// Retrieve liabilities and equity balance sheet.
const liabilitiesEquity = accountsMapper(liabilitiesAccounts);
return res.status(200).send({
query: { ...filter },
columns: { ...dateRangeSet },
accounts: [
{
name: 'Assets',
type: 'assets',
children: [...assets],
},
{
name: 'Liabilities & Equity',
type: 'liabilities_equity',
children: [...liabilitiesEquity],
},
],
});
},
},
/**
* Retrieve the trial balance sheet.
*/
trialBalanceSheet: {
validation: [
query('basis').optional(),
query('from_date').optional().isISO8601(),
query('to_date').optional().isISO8601(),
query('number_format.no_cents').optional().isBoolean().toBoolean(),
query('number_format.1000_divide').optional().isBoolean().toBoolean(),
query('basis').optional(),
query('none_zero').optional().isBoolean().toBoolean(),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
const {
Account,
} = req.models;
const filter = {
from_date: moment().startOf('year').format('YYYY-MM-DD'),
to_date: moment().endOf('year').format('YYYY-MM-DD'),
number_format: {
no_cents: false,
divide_1000: false,
},
basis: 'accural',
none_zero: false,
...req.query,
};
const accounts = await Account.query()
.remember('trial_balance_accounts')
.withGraphFetched('type')
.withGraphFetched('transactions')
.modifyGraph('transactions', (builder) => {
builder.modify('sumationCreditDebit');
builder.modify('filterDateRange', filter.from_date, filter.to_date);
});
const journalEntriesCollect = Account.collectJournalEntries(accounts);
const journalEntries = new JournalPoster();
journalEntries.loadEntries(journalEntriesCollect);
// Account balance formmatter based on the given query.
const balanceFormatter = formatNumberClosure(filter.number_format);
const items = accounts
.filter((account) => (
account.transactions.length > 0 || !filter.none_zero
))
.map((account) => {
const trial = journalEntries.getTrialBalance(account.id);
return {
account_id: account.id,
name: account.name,
code: account.code,
accountNormal: account.type.normal,
credit: balanceFormatter(trial.credit),
debit: balanceFormatter(trial.debit),
balance: balanceFormatter(trial.balance),
};
});
return res.status(200).send({
query: { ...filter },
accounts: [...items],
});
},
},
/**
* Retrieve profit/loss financial statement.
*/
profitLossSheet: {
validation: [
query('basis').optional(),
query('from_date').optional().isISO8601(),
query('to_date').optional().isISO8601(),
query('number_format.no_cents').optional().isBoolean(),
query('number_format.divide_1000').optional().isBoolean(),
query('basis').optional(),
query('none_zero').optional().isBoolean().toBoolean(),
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) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
const { Account, AccountType } = req.models;
const filter = {
from_date: moment().startOf('year').format('YYYY-MM-DD'),
to_date: moment().endOf('year').format('YYYY-MM-DD'),
number_format: {
no_cents: false,
divide_1000: false,
},
basis: 'accural',
none_zero: false,
display_columns_type: 'total',
display_columns_by: 'month',
...req.query,
};
const incomeStatementTypes = await AccountType.query().where('income_sheet', true);
// Fetch all income accounts from storage.
const accounts = await Account.query()
.remember('profit_loss_accounts')
.whereIn('account_type_id', incomeStatementTypes.map((t) => t.id))
.withGraphFetched('type')
.withGraphFetched('transactions');
// Filter all none zero accounts if it was enabled.
const filteredAccounts = accounts.filter((account) => (
account.transactions.length > 0 || !filter.none_zero
));
const journalEntriesCollected = Account.collectJournalEntries(accounts);
const journalEntries = new JournalPoster();
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;
// Gets the date range set from start to end date.
const dateRangeSet = dateRangeCollection(
filter.from_date,
filter.to_date,
comparatorDateType,
);
const accountsMapper = (incomeExpenseAccounts) => (
incomeExpenseAccounts.map((account) => ({
...pick(account, ['id', 'index', 'name', 'code']),
// Total closing balance of the account.
...(filter.display_columns_type === 'total') && {
total: (() => {
const amount = journalEntries.getClosingBalance(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') && {
periods: dateRangeSet.map((date) => {
const type = comparatorDateType;
const amount = journalEntries.getClosingBalance(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 = accountsMapper(filteredAccounts
.filter((account) => account.type.normal === 'credit'));
const accountsExpenses = 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) => (
dateRangeSet.map((date, index) => {
const totalIncome = totalIncomeAcocunts[index];
const totalExpenses = totalExpenseAccounts[index];
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 };
};
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),
],
},
};
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),
],
},
};
const netIncomeResponse = {
...(filter.display_columns_type === 'total') && {
total: {
...netIncomeTotal(incomeResponse.total, expenseResponse.total),
},
},
...(filter.display_columns_type === 'date_periods') && {
total_periods: [
...netIncomePeriodsMapper(
incomeResponse.total_periods,
expenseResponse.total_periods,
),
],
},
};
return res.status(200).send({
query: { ...filter },
columns: [...dateRangeSet],
profitLoss: {
income: incomeResponse,
expenses: expenseResponse,
net_income: netIncomeResponse,
},
});
},
},
cashFlowStatement: {
validation: [
query('date_from').optional(),
query('date_to').optional(),
],
async handler(req, res) {
return res.status(200).send({
meta: {},
operating: [],
financing: [],
investing: [],
});
},
},
}

View File

@@ -0,0 +1,172 @@
import express from 'express';
import { query, oneOf, validationResult } from 'express-validator';
import moment from 'moment';
import { pick, difference, groupBy } from 'lodash';
import JournalPoster from '@/services/Accounting/JournalPoster';
import { dateRangeCollection } from '@/utils';
import DependencyGraph from '@/lib/DependencyGraph';
import asyncMiddleware from '@/http/middleware/asyncMiddleware'
import { formatNumberClosure } from './FinancialStatementMixin';
export default {
/**
* Router constructor.
*/
router() {
const router = express.Router();
router.get('/',
this.balanceSheet.validation,
asyncMiddleware(this.balanceSheet.handler));
return router;
},
/**
* Retrieve the balance sheet.
*/
balanceSheet: {
validation: [
query('accounting_method').optional().isIn(['cash', 'accural']),
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 })
.isIn(['year', 'month', 'week', 'day', 'quarter']),
query('number_format.no_cents').optional().isBoolean().toBoolean(),
query('number_format.divide_1000').optional().isBoolean().toBoolean(),
query('account_ids').isArray().optional(),
query('account_ids.*').isNumeric().toInt(),
query('none_zero').optional().isBoolean().toBoolean(),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
const { Account, AccountType } = req.models;
const filter = {
display_columns_type: 'total',
display_columns_by: '',
from_date: moment().startOf('year').format('YYYY-MM-DD'),
to_date: moment().endOf('year').format('YYYY-MM-DD'),
number_format: {
no_cents: false,
divide_1000: false,
},
none_zero: false,
basis: 'cash',
account_ids: [],
...req.query,
};
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);
// 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))
.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' }
);
// 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,
) : [];
// Gets the date range set from start to end date.
const totalPeriods = (account) => ({
total_periods: dateRangeSet.map((date) => {
const amount = journalEntries.getAccountBalance(account.id, date, comparatorDateType);
return {
amount,
formatted_amount: balanceFormatter(amount),
date,
};
}),
});
const accountsMapperToResponse = (account) => {
// Calculates the closing balance to the given date.
const closingBalance = journalEntries.getAccountBalance(account.id, filter.to_date);
return {
...pick(account, ['id', 'index', 'name', 'code', 'parentAccountId']),
// Date periods when display columns.
...(filter.display_columns_type === 'date_periods') && totalPeriods(account),
total: {
amount: closingBalance,
formatted_amount: balanceFormatter(closingBalance),
date: filter.to_date,
},
};
};
// 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);
// 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],
},
],
});
},
},
}

View File

@@ -0,0 +1,13 @@
export const formatNumberClosure = (filter) => (balance) => {
let formattedBalance = parseFloat(balance);
if (filter.no_cents) {
formattedBalance = parseInt(formattedBalance, 10);
}
if (filter.divide_1000) {
formattedBalance /= 1000;
}
return formattedBalance;
};

View File

@@ -0,0 +1,165 @@
import express from 'express';
import { query, validationResult } from 'express-validator';
import moment from 'moment';
import { pick, difference } from 'lodash';
import JournalPoster from '@/services/Accounting/JournalPoster';
import { formatNumberClosure } from './FinancialStatementMixin';
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import DependencyGraph from '@/lib/DependencyGraph';
export default {
/**
* Router constructor.
*/
router() {
const router = express.Router();
router.get('/',
this.generalLedger.validation,
asyncMiddleware(this.generalLedger.handler));
return router;
},
/**
* Retrieve the general ledger financial statement.
*/
generalLedger: {
validation: [
query('from_date').optional().isISO8601(),
query('to_date').optional().isISO8601(),
query('basis').optional(),
query('number_format.no_cents').optional().isBoolean().toBoolean(),
query('number_format.divide_1000').optional().isBoolean().toBoolean(),
query('none_zero').optional().isBoolean().toBoolean(),
query('accounts_ids').optional(),
query('accounts_ids.*').isNumeric().toInt(),
query('orderBy').optional().isIn(['created_at', 'name', 'code']),
query('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 { AccountTransaction, Account } = req.models;
const filter = {
from_date: moment().startOf('year').format('YYYY-MM-DD'),
to_date: moment().endOf('year').format('YYYY-MM-DD'),
basis: 'cash',
number_format: {
no_cents: false,
divide_1000: false,
},
none_zero: false,
accounts_ids: [],
...req.query,
};
if (!Array.isArray(filter.accounts_ids)) {
filter.accounts_ids = [filter.accounts_ids];
}
filter.accounts_ids = filter.accounts_ids.map((id) => parseInt(id, 10));
const errorReasons = [];
if (filter.accounts_ids.length > 0) {
const accounts = await Account.query().whereIn('id', filter.accounts_ids);
const accountsIds = accounts.map((a) => a.id);
if (difference(filter.accounts_ids, accountsIds).length > 0) {
errorReasons.push({ type: 'FILTER.ACCOUNTS.IDS.NOT.FOUND', code: 200 });
}
}
if (errorReasons.length > 0) {
return res.status(400).send({ error: errorReasons });
}
const accounts = await Account.query()
// .remember('general_ledger_accounts')
.orderBy('index', 'DESC')
.modify('filterAccounts', filter.accounts_ids)
.withGraphFetched('type')
.withGraphFetched('transactions')
.modifyGraph('transactions', (builder) => {
builder.modify('filterDateRange', filter.from_date, filter.to_date);
});
// Accounts dependency graph.
const accountsGraph = DependencyGraph.fromArray(
accounts, { itemId: 'id', parentItemId: 'parentAccountId' }
);
const openingBalanceTransactions = await AccountTransaction.query()
// .remember()
.modify('filterDateRange', null, filter.from_date)
.modify('sumationCreditDebit')
.withGraphFetched('account.type');
const closingBalanceTransactions = await AccountTransaction.query()
// .remember()
.modify('filterDateRange', null, filter.to_date)
.modify('sumationCreditDebit')
.withGraphFetched('account.type');
const opeingBalanceCollection = new JournalPoster(accountsGraph);
const closingBalanceCollection = new JournalPoster(accountsGraph);
opeingBalanceCollection.loadEntries(openingBalanceTransactions);
closingBalanceCollection.loadEntries(closingBalanceTransactions);
// Transaction amount formatter based on the given query.
const formatNumber = formatNumberClosure(filter.number_format);
const accountsResponse = accounts
.filter((account) => (
account.transactions.length > 0 || !filter.none_zero
))
.map((account) => ({
...pick(account, ['id', 'name', 'code', 'index', 'parentAccountId']),
transactions: [
...account.transactions.map((transaction) => {
let amount = 0;
if (account.type.normal === 'credit') {
amount += transaction.credit - transaction.debit;
} else if (account.type.normal === 'debit') {
amount += transaction.debit - transaction.credit;
}
return {
...pick(transaction, ['id', 'note', 'transactionType', 'referenceType',
'referenceId', 'date', 'createdAt']),
amount,
formatted_amount: formatNumber(amount),
};
}),
],
opening: (() => {
const openingAmount = opeingBalanceCollection.getAccountBalance(account.id);
return {
date: filter.from_date,
amount: openingAmount,
formatted_amount: formatNumber(openingAmount),
}
})(),
closing: (() => {
const closingAmount = closingBalanceCollection.getAccountBalance(account.id);
return {
date: filter.to_date,
amount: closingAmount,
formatted_amount: formatNumber(closingAmount),
}
})(),
}));
return res.status(200).send({
query: { ...filter },
accounts: Account.toNestedArray(accountsResponse),
});
},
},
}

View File

@@ -0,0 +1,120 @@
import express from 'express';
import { query, oneOf, validationResult } from 'express-validator';
import moment from 'moment';
import { groupBy } from 'lodash';
import JournalPoster from '@/services/Accounting/JournalPoster';
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import { formatNumberClosure } from './FinancialStatementMixin';
export default {
/**
* Router constructor.
*/
router() {
const router = express.Router();
router.get('/',
this.journal.validation,
asyncMiddleware(this.journal.handler));
return router;
},
/**
* Retrieve the ledger report of the given account.
*/
journal: {
validation: [
query('from_date').optional().isISO8601(),
query('to_date').optional().isISO8601(),
oneOf([
query('transaction_types').optional().isArray({ min: 1 }),
query('transaction_types.*').optional().isNumeric().toInt(),
], [
query('transaction_types').optional().trim().escape(),
]),
oneOf([
query('account_ids').optional().isArray({ min: 1 }),
query('account_ids.*').optional().isNumeric().toInt(),
], [
query('account_ids').optional().isNumeric().toInt(),
]),
query('from_range').optional().isNumeric().toInt(),
query('to_range').optional().isNumeric().toInt(),
query('number_format.no_cents').optional().isBoolean().toBoolean(),
query('number_format.divide_1000').optional().isBoolean().toBoolean(),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
const { AccountTransaction } = req.models;
const filter = {
from_date: moment().startOf('year').format('YYYY-MM-DD'),
to_date: moment().endOf('year').format('YYYY-MM-DD'),
from_range: null,
to_range: null,
account_ids: [],
transaction_types: [],
number_format: {
no_cents: false,
divide_1000: false,
},
...req.query,
};
if (!Array.isArray(filter.transaction_types)) {
filter.transaction_types = [filter.transaction_types];
}
if (!Array.isArray(filter.account_ids)) {
filter.account_ids = [filter.account_ids];
}
filter.account_ids = filter.account_ids.map((id) => parseInt(id, 10));
const accountsJournalEntries = await AccountTransaction.query()
// .remember()
.modify('filterDateRange', filter.from_date, filter.to_date)
.modify('filterAccounts', filter.account_ids)
.modify('filterTransactionTypes', filter.transaction_types)
.modify('filterAmountRange', filter.from_range, filter.to_range)
.withGraphFetched('account.type');
const formatNumber = formatNumberClosure(filter.number_format);
const journalGrouped = groupBy(accountsJournalEntries,
(entry) => `${entry.referenceId}-${entry.referenceType}`);
const journal = Object.keys(journalGrouped).map((key) => {
const transactionsGroup = journalGrouped[key];
const journalPoster = new JournalPoster();
journalPoster.loadEntries(transactionsGroup);
const trialBalance = journalPoster.getTrialBalance();
return {
id: key,
entries: transactionsGroup,
credit: trialBalance.credit,
debit: trialBalance.debit,
formatted_credit: formatNumber(trialBalance.credit),
formatted_debit: formatNumber(trialBalance.debit),
};
});
return res.status(200).send({
query: { ...filter },
journal,
});
},
},
}

View File

@@ -0,0 +1,235 @@
import express from 'express';
import { query, oneOf, validationResult } from 'express-validator';
import moment from 'moment';
import { pick } 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 {
/**
* Router constructor.
*/
router() {
const router = express.Router();
router.get('/',
this.profitLossSheet.validation,
asyncMiddleware(this.profitLossSheet.handler));
return router;
},
/**
* Retrieve profit/loss financial statement.
*/
profitLossSheet: {
validation: [
query('basis').optional(),
query('from_date').optional().isISO8601(),
query('to_date').optional().isISO8601(),
query('number_format.no_cents').optional().isBoolean(),
query('number_format.divide_1000').optional().isBoolean(),
query('basis').optional(),
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 })
.isIn(['year', 'month', 'week', 'day', 'quarter']),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
const { Account, AccountType } = req.models;
const filter = {
from_date: moment().startOf('year').format('YYYY-MM-DD'),
to_date: moment().endOf('year').format('YYYY-MM-DD'),
number_format: {
no_cents: false,
divide_1000: false,
},
basis: 'accural',
none_zero: false,
display_columns_type: 'total',
display_columns_by: 'month',
account_ids: [],
...req.query,
};
if (!Array.isArray(filter.account_ids)) {
filter.account_ids = [filter.account_ids];
}
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))
.withGraphFetched('type')
.withGraphFetched('transactions');
// Accounts dependency graph.
const accountsGraph = DependencyGraph.fromArray(
accounts, { itemId: 'id', parentItemId: 'parentAccountId' }
);
// Filter all none zero accounts if it was enabled.
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;
// Gets the date range set from start to end date.
const dateRangeSet = dateRangeCollection(
filter.from_date,
filter.to_date,
comparatorDateType,
);
const accountsMapper = (incomeExpenseAccounts) => (
incomeExpenseAccounts.map((account) => ({
...pick(account, ['id', 'index', 'name', 'code', 'parentAccountId']),
// Total closing balance of the account.
...(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) };
})(),
},
// Date periods when display columns type `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 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')));
// @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) => (
dateRangeSet.map((date, index) => {
const totalIncome = totalIncomeAcocunts[index];
const totalExpenses = totalExpenseAccounts[index];
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 };
};
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),
],
},
};
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),
],
},
};
const netIncomeResponse = {
...(filter.display_columns_type === 'total') && {
total: {
...netIncomeTotal(incomeResponse.total, expenseResponse.total),
},
},
...(filter.display_columns_type === 'date_periods') && {
total_periods: [
...netIncomePeriodsMapper(
incomeResponse.total_periods,
expenseResponse.total_periods,
),
],
},
};
return res.status(200).send({
query: { ...filter },
columns: [...dateRangeSet],
profitLoss: {
income: incomeResponse,
expenses: expenseResponse,
net_income: netIncomeResponse,
},
});
},
},
}

View File

@@ -0,0 +1,115 @@
import express from 'express';
import { query, validationResult } from 'express-validator';
import moment from 'moment';
import JournalPoster from '@/services/Accounting/JournalPoster';
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import DependencyGraph from '@/lib/DependencyGraph';
import { formatNumberClosure }from './FinancialStatementMixin';
export default {
/**
* Router constructor.
*/
router() {
const router = express.Router();
router.get('/',
this.trialBalanceSheet.validation,
asyncMiddleware(this.trialBalanceSheet.handler));
return router;
},
/**
* Retrieve the trial balance sheet.
*/
trialBalanceSheet: {
validation: [
query('basis').optional(),
query('from_date').optional().isISO8601(),
query('to_date').optional().isISO8601(),
query('number_format.no_cents').optional().isBoolean().toBoolean(),
query('number_format.1000_divide').optional().isBoolean().toBoolean(),
query('account_ids').isArray().optional(),
query('account_ids.*').isNumeric().toInt(),
query('basis').optional(),
query('none_zero').optional().isBoolean().toBoolean(),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
const { Account } = req.models;
const filter = {
from_date: moment().startOf('year').format('YYYY-MM-DD'),
to_date: moment().endOf('year').format('YYYY-MM-DD'),
number_format: {
no_cents: false,
divide_1000: false,
},
basis: 'accural',
none_zero: false,
account_ids: [],
...req.query,
};
if (!Array.isArray(filter.account_ids)) {
filter.account_ids = [filter.account_ids];
}
const accounts = await Account.query()
// .remember('trial_balance_accounts')
.modify('filterAccounts', filter.account_ids)
.withGraphFetched('type')
.withGraphFetched('transactions')
.modifyGraph('transactions', (builder) => {
builder.modify('sumationCreditDebit');
builder.modify('filterDateRange', filter.from_date, filter.to_date);
});
// Accounts dependency graph.
const accountsGraph = DependencyGraph.fromArray(
accounts, { itemId: 'id', parentItemId: 'parentAccountId' }
);
const journalEntriesCollect = Account.collectJournalEntries(accounts);
const journalEntries = new JournalPoster(accountsGraph);
journalEntries.loadEntries(journalEntriesCollect);
// Account balance formmatter based on the given query.
const balanceFormatter = formatNumberClosure(filter.number_format);
const accountsResponse = accounts
.filter((account) => (
account.transactions.length > 0 || !filter.none_zero
))
.map((account) => {
const trial = journalEntries.getTrialBalanceWithDepands(account.id);
return {
id: account.id,
parentAccountId: account.parentAccountId,
name: account.name,
code: account.code,
accountNormal: account.type.normal,
credit: trial.credit,
debit: trial.debit,
balance: trial.balance,
formatted_credit: balanceFormatter(trial.credit),
formatted_debit: balanceFormatter(trial.debit),
formatted_balance: balanceFormatter(trial.balance),
};
});
return res.status(200).send({
query: { ...filter },
accounts: [...Account.toNestedArray(accountsResponse) ],
});
},
},
}

View File

@@ -37,6 +37,10 @@ export default {
this.deleteItem.validation,
asyncMiddleware(this.deleteItem.handler));
router.delete('/',
this.bulkDeleteItems.validation,
asyncMiddleware(this.bulkDeleteItems.handler));
router.get('/',
this.listItems.validation,
asyncMiddleware(this.listItems.handler));
@@ -337,6 +341,44 @@ export default {
},
},
/**
* Bulk delete the given items ids.
*/
bulkDeleteItems: {
validation: [
query('ids').isArray({ min: 2 }),
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 { Item } = req.models;
const items = await Item.query().whereIn('id', filter.ids);
const storedItemsIds = items.map((a) => a.id);
const notFoundItems = difference(filter.ids, storedItemsIds);
// Validate the not found items.
if (notFoundItems.length > 0) {
return res.status(404).send({
errors: [{ type: 'ITEMS.NOT.FOUND', code: 200, ids: notFoundItems }],
});
}
// Delete the given items ids.
await Item.query().whereIn('id', storedItemsIds).delete();
return res.status(200).send();
},
},
/**
* Retrive the list items with pagination meta.
*/

View File

@@ -30,6 +30,7 @@ import Media from '@/http/controllers/Media';
import JWTAuth from '@/http/middleware/jwtAuth';
import TenancyMiddleware from '@/http/middleware/TenancyMiddleware';
export default (app) => {
// app.use('/api/oauth2', OAuth2.router());
app.use('/api/auth', Authentication.router());

View File

@@ -0,0 +1,349 @@
/**
* A simple dependency graph
*/
/**
* Helper for creating a Topological Sort using Depth-First-Search on a set of edges.
*
* Detects cycles and throws an Error if one is detected (unless the "circular"
* parameter is "true" in which case it ignores them).
*
* @param edges The set of edges to DFS through
* @param leavesOnly Whether to only return "leaf" nodes (ones who have no edges)
* @param result An array in which the results will be populated
* @param circular A boolean to allow circular dependencies
*/
function createDFS(edges, leavesOnly, result, circular) {
var visited = {};
return function(start) {
if (visited[start]) {
return;
}
var inCurrentPath = {};
var currentPath = [];
var todo = []; // used as a stack
todo.push({ node: start, processed: false });
while (todo.length > 0) {
var current = todo[todo.length - 1]; // peek at the todo stack
var processed = current.processed;
var node = current.node;
if (!processed) {
// Haven't visited edges yet (visiting phase)
if (visited[node]) {
todo.pop();
continue;
} else if (inCurrentPath[node]) {
// It's not a DAG
if (circular) {
todo.pop();
// If we're tolerating cycles, don't revisit the node
continue;
}
currentPath.push(node);
throw new DepGraphCycleError(currentPath);
}
inCurrentPath[node] = true;
currentPath.push(node);
var nodeEdges = edges[node];
// (push edges onto the todo stack in reverse order to be order-compatible with the old DFS implementation)
for (var i = nodeEdges.length - 1; i >= 0; i--) {
todo.push({ node: nodeEdges[i], processed: false });
}
current.processed = true;
} else {
// Have visited edges (stack unrolling phase)
todo.pop();
currentPath.pop();
inCurrentPath[node] = false;
visited[node] = true;
if (!leavesOnly || edges[node].length === 0) {
result.push(node);
}
}
}
};
}
/**
* Simple Dependency Graph
*/
var DepGraph = (DepGraph = function DepGraph(opts) {
this.nodes = {}; // Node -> Node/Data (treated like a Set)
this.outgoingEdges = {}; // Node -> [Dependency Node]
this.incomingEdges = {}; // Node -> [Dependant Node]
this.circular = opts && !!opts.circular; // Allows circular deps
});
DepGraph.fromArray = (items, options = { itemId: 'id', parentItemId: 'parent_id' }) => {
const depGraph = new DepGraph();
items.forEach((item) => {
depGraph.addNode(item[options.itemId], item);
});
items.forEach((item) => {
if (item[options.parentItemId]) {
depGraph.addDependency(item[options.parentItemId], item[options.itemId]);
}
});
return depGraph;
}
DepGraph.prototype = {
/**
* The number of nodes in the graph.
*/
size: function() {
return Object.keys(this.nodes).length;
},
/**
* Add a node to the dependency graph. If a node already exists, this method will do nothing.
*/
addNode: function(node, data) {
if (!this.hasNode(node)) {
// Checking the arguments length allows the user to add a node with undefined data
if (arguments.length === 2) {
this.nodes[node] = data;
} else {
this.nodes[node] = node;
}
this.outgoingEdges[node] = [];
this.incomingEdges[node] = [];
}
},
/**
* Remove a node from the dependency graph. If a node does not exist, this method will do nothing.
*/
removeNode: function(node) {
if (this.hasNode(node)) {
delete this.nodes[node];
delete this.outgoingEdges[node];
delete this.incomingEdges[node];
[this.incomingEdges, this.outgoingEdges].forEach(function(edgeList) {
Object.keys(edgeList).forEach(function(key) {
var idx = edgeList[key].indexOf(node);
if (idx >= 0) {
edgeList[key].splice(idx, 1);
}
}, this);
});
}
},
/**
* Check if a node exists in the graph
*/
hasNode: function(node) {
return this.nodes.hasOwnProperty(node);
},
/**
* Get the data associated with a node name
*/
getNodeData: function(node) {
if (this.hasNode(node)) {
return this.nodes[node];
} else {
throw new Error("Node does not exist: " + node);
}
},
/**
* Set the associated data for a given node name. If the node does not exist, this method will throw an error
*/
setNodeData: function(node, data) {
if (this.hasNode(node)) {
this.nodes[node] = data;
} else {
throw new Error("Node does not exist: " + node);
}
},
/**
* Add a dependency between two nodes. If either of the nodes does not exist,
* an Error will be thrown.
*/
addDependency: function(from, to) {
if (!this.hasNode(from)) {
throw new Error("Node does not exist: " + from);
}
if (!this.hasNode(to)) {
throw new Error("Node does not exist: " + to);
}
if (this.outgoingEdges[from].indexOf(to) === -1) {
this.outgoingEdges[from].push(to);
}
if (this.incomingEdges[to].indexOf(from) === -1) {
this.incomingEdges[to].push(from);
}
return true;
},
/**
* Remove a dependency between two nodes.
*/
removeDependency: function(from, to) {
var idx;
if (this.hasNode(from)) {
idx = this.outgoingEdges[from].indexOf(to);
if (idx >= 0) {
this.outgoingEdges[from].splice(idx, 1);
}
}
if (this.hasNode(to)) {
idx = this.incomingEdges[to].indexOf(from);
if (idx >= 0) {
this.incomingEdges[to].splice(idx, 1);
}
}
},
/**
* Return a clone of the dependency graph. If any custom data is attached
* to the nodes, it will only be shallow copied.
*/
clone: function() {
var source = this;
var result = new DepGraph();
var keys = Object.keys(source.nodes);
keys.forEach(function(n) {
result.nodes[n] = source.nodes[n];
result.outgoingEdges[n] = source.outgoingEdges[n].slice(0);
result.incomingEdges[n] = source.incomingEdges[n].slice(0);
});
return result;
},
/**
* Get an array containing the nodes that the specified node depends on (transitively).
*
* Throws an Error if the graph has a cycle, or the specified node does not exist.
*
* If `leavesOnly` is true, only nodes that do not depend on any other nodes will be returned
* in the array.
*/
dependenciesOf: function(node, leavesOnly) {
if (this.hasNode(node)) {
var result = [];
var DFS = createDFS(
this.outgoingEdges,
leavesOnly,
result,
this.circular
);
DFS(node);
var idx = result.indexOf(node);
if (idx >= 0) {
result.splice(idx, 1);
}
return result;
} else {
throw new Error("Node does not exist: " + node);
}
},
/**
* get an array containing the nodes that depend on the specified node (transitively).
*
* Throws an Error if the graph has a cycle, or the specified node does not exist.
*
* If `leavesOnly` is true, only nodes that do not have any dependants will be returned in the array.
*/
dependantsOf: function(node, leavesOnly) {
if (this.hasNode(node)) {
var result = [];
var DFS = createDFS(
this.incomingEdges,
leavesOnly,
result,
this.circular
);
DFS(node);
var idx = result.indexOf(node);
if (idx >= 0) {
result.splice(idx, 1);
}
return result;
} else {
throw new Error("Node does not exist: " + node);
}
},
/**
* Construct the overall processing order for the dependency graph.
*
* Throws an Error if the graph has a cycle.
*
* If `leavesOnly` is true, only nodes that do not depend on any other nodes will be returned.
*/
overallOrder: function(leavesOnly) {
var self = this;
var result = [];
var keys = Object.keys(this.nodes);
if (keys.length === 0) {
return result; // Empty graph
} else {
if (!this.circular) {
// Look for cycles - we run the DFS starting at all the nodes in case there
// are several disconnected subgraphs inside this dependency graph.
var CycleDFS = createDFS(this.outgoingEdges, false, [], this.circular);
keys.forEach(function(n) {
CycleDFS(n);
});
}
var DFS = createDFS(
this.outgoingEdges,
leavesOnly,
result,
this.circular
);
// Find all potential starting points (nodes with nothing depending on them) an
// run a DFS starting at these points to get the order
keys
.filter(function(node) {
return self.incomingEdges[node].length === 0;
})
.forEach(function(n) {
DFS(n);
});
// If we're allowing cycles - we need to run the DFS against any remaining
// nodes that did not end up in the initial result (as they are part of a
// subgraph that does not have a clear starting point)
if (this.circular) {
keys
.filter(function(node) {
return result.indexOf(node) === -1;
})
.forEach(function(n) {
DFS(n);
});
}
return result;
}
},
mapNodes(mapper) {
}
};
/**
* Cycle error, including the path of the cycle.
*/
var DepGraphCycleError = (exports.DepGraphCycleError = function(cyclePath) {
var message = "Dependency Cycle Found: " + cyclePath.join(" -> ");
var instance = new Error(message);
instance.cyclePath = cyclePath;
Object.setPrototypeOf(instance, Object.getPrototypeOf(this));
if (Error.captureStackTrace) {
Error.captureStackTrace(instance, DepGraphCycleError);
}
return instance;
});
DepGraphCycleError.prototype = Object.create(Error.prototype, {
constructor: {
value: Error,
enumerable: false,
writable: true,
configurable: true
}
});
Object.setPrototypeOf(DepGraphCycleError, Error);
export default DepGraph;

View File

@@ -0,0 +1,9 @@
class NestedSetNode {
// Saves
appendToNode($parent) {
}
}

View File

@@ -9,7 +9,8 @@ import {
import CachableQueryBuilder from '@/lib/Cachable/CachableQueryBuilder';
import CachableModel from '@/lib/Cachable/CachableModel';
import DateSession from '@/models/DateSession';
import { flatToNestedArray } from '@/utils';
import DependencyGraph from '@/lib/DependencyGraph';
export default class Account extends mixin(TenantModel, [CachableModel, DateSession]) {
/**
@@ -26,6 +27,25 @@ export default class Account extends mixin(TenantModel, [CachableModel, DateSess
return CachableQueryBuilder;
}
static query(...args) {
return super.query(...args).runAfter((result) => {
if (Array.isArray(result)) {
return this.isDepGraph ?
Account.toDependencyGraph(result) :
this.collection.from(result);
}
return result;
});
}
/**
* Convert the array result to dependency graph.
*/
static depGraph() {
this.isDepGraph = true;
return this;
}
/**
* Model modifiers.
*/
@@ -106,4 +126,19 @@ export default class Account extends mixin(TenantModel, [CachableModel, DateSess
accountNormal: account.type.normal,
}))));
}
/**
* Converts flatten accounts list to nested array.
* @param {Array} accounts
* @param {Object} options
*/
static toNestedArray(accounts, options = { children: 'children' }) {
return flatToNestedArray(accounts, { id: 'id', parentId: 'parentAccountId' })
}
static toDependencyGraph(accounts) {
return DependencyGraph.fromArray(
accounts, { itemId: 'id', parentItemId: 'parentAccountId' }
);
}
}

View File

@@ -1,10 +1,12 @@
import { Model } from 'objection';
import { Model, mixin } from 'objection';
import moment from 'moment';
import TenantModel from '@/models/TenantModel';
import CachableQueryBuilder from '@/lib/Cachable/CachableQueryBuilder';
import CachableModel from '@/lib/Cachable/CachableModel';
import DateSession from '@/models/DateSession';
export default class AccountTransaction extends TenantModel {
export default class AccountTransaction extends mixin(TenantModel, [CachableModel, DateSession]) {
/**
* Table name
*/

View File

@@ -32,6 +32,11 @@ export default class Option extends TenantModel {
return MetableCollection;
}
/**
* Validates the given options is defined or either not.
* @param {Array} options
* @return {Boolean}
*/
static validateDefined(options) {
const notDefined = [];

View File

@@ -4,16 +4,21 @@ import JournalEntry from '@/services/Accounting/JournalEntry';
import AccountTransaction from '@/models/AccountTransaction';
import AccountBalance from '@/models/AccountBalance';
import {promiseSerial} from '@/utils';
import Account from '../../models/Account';
import Account from '@/models/Account';
import NestedSet from '../../collection/NestedSet';
export default class JournalPoster {
/**
* Journal poster constructor.
*/
constructor() {
constructor(accountsGraph) {
this.entries = [];
this.balancesChange = {};
this.deletedEntriesIds = [];
this.accountsBalanceTable = {};
this.accountsGraph = accountsGraph;
}
/**
@@ -29,7 +34,7 @@ export default class JournalPoster {
}
/**
* Writes the debit entry for the given account.
* Writes the debit entr y for the given account.
* @param {JournalEntry} entry -
*/
debit(entryModel) {
@@ -45,18 +50,47 @@ export default class JournalPoster {
* @param {JournalEntry} entry
* @param {String} type
*/
setAccountBalanceChange(entry, type) {
if (!this.balancesChange[entry.account]) {
this.balancesChange[entry.account] = 0;
setAccountBalanceChange(entry, entryType) {
const depAccountsIds = this.accountsGraph.dependantsOf(entry.account);
const balanceChangeEntry = {
debit: entry.debit,
credit: entry.credit,
entryType,
accountNormal: entry.accountNormal,
};
this._setAccountBalanceChange({
...balanceChangeEntry,
accountId: entry.account,
});
// Effect parent accounts of the given account id.
depAccountsIds.forEach((accountId) => {
this._setAccountBalanceChange({
...balanceChangeEntry,
accountId,
});
});
}
/**
* Sets account balance change.
* @private
*/
_setAccountBalanceChange({
accountId, accountNormal, debit, credit, entryType
}) {
if (!this.balancesChange[accountId]) {
this.balancesChange[accountId] = 0;
}
let change = 0;
if (entry.accountNormal === 'credit') {
change = (type === 'credit') ? entry.credit : -1 * entry.debit;
} else if (entry.accountNormal === 'debit') {
change = (type === 'debit') ? entry.debit : -1 * entry.credit;
if (accountNormal === 'credit') {
change = (entryType === 'credit') ? credit : -1 * debit;
} else if (accountNormal === 'debit') {
change = (entryType === 'debit') ? debit : -1 * credit;
}
this.balancesChange[entry.account] += change;
this.balancesChange[accountId] += change;
}
/**
@@ -86,11 +120,10 @@ export default class JournalPoster {
const balanceFindOneOpers = [];
let balanceAccounts = [];
const effectAccountsOpers = [];
balancesList.forEach((balance) => {
const oper = AccountBalance.tenant()
.query().findOne('account_id', balance.account_id);
.query()
.findOne('account_id', balance.account_id);
balanceFindOneOpers.push(oper);
});
balanceAccounts = await Promise.all(balanceFindOneOpers);
@@ -102,9 +135,11 @@ export default class JournalPoster {
const foundAccBalance = balanceAccounts.some((account) => (
account && account.account_id === balance.account_id
));
if (foundAccBalance) {
const query = AccountBalance.tenant()
.query()[method]('amount', Math.abs(balance.amount))
.query()
[method]('amount', Math.abs(balance.amount))
.where('account_id', balance.account_id);
balanceUpdateOpers.push(query);
@@ -116,67 +151,12 @@ export default class JournalPoster {
});
balanceInsertOpers.push(query);
}
const effectedAccountsOper = this.effectAssociatedAccountsBalance(
balance.accountId, amount, 'USD', method,
);
effectAccountsOpers.push(effectedAccountsOper);
});
await Promise.all([
...balanceUpdateOpers, ...balanceInsertOpers,
]);
}
/**
* Effect associated descendants and parent accounts
* of the given account id.
* @param {Number} accountId
* @param {Number} amount
* @param {String} currencyCode
* @param {*} method
*/
async effectAssociatedAccountsBalance(accountId, amount, currencyCode = 'USD', method) {
const accounts = await Account.query().withGraphFetched('balance');
const accountsDecendences = accounts.getDescendants();
const asyncOpers = [];
const accountsInsertBalance = [];
const accountsUpdateBalance = [];
accounts.forEach((account) => {
const accountBalances = account.balance;
const currencyBalance = accountBalances
.find(balance => balance.currencyCode === currencyCode);
if (currencyBalance) {
accountsInsertBalance.push(account.id);
} else {
accountsUpdateBalance.push(account.id);
}
});
accountsInsertBalance.forEach((accountId) => {
const oper = AccountBalance.tenant().query().insert({
account_id: accountId,
amount: method === 'decrement' ? amount * -1 : amount,
currency_code: currencyCode,
});
asyncOpers.push(oper);
});
if (accountsUpdateBalance.length > 0) {
const oper = AccountBalance.tenant().query()
.whereIn('account_id', accountsUpdateBalance);
[method]('amount', Math.abs(amount))
.where('currency_code', currencyCode);
asyncOpers.push(oper);
}
await Promise.all(asyncOpers);
}
/**
* Saves the stacked journal entries to the storage.
*/
@@ -236,6 +216,9 @@ export default class JournalPoster {
);
}
/**
* Delete all the stacked entries.
*/
async deleteEntries() {
if (this.deletedEntriesIds.length > 0) {
await AccountTransaction.tenant().query()
@@ -269,6 +252,29 @@ export default class JournalPoster {
return closingBalance;
}
/**
* Retrieve the given account balance with dependencies accounts.
* @param {Number} accountId
* @param {Date} closingDate
* @param {String} dateType
* @return {Number}
*/
getAccountBalance(accountId, closingDate, dateType) {
const accountNode = this.accountsGraph.getNodeData(accountId);
const depAccountsIds = this.accountsGraph.dependenciesOf(accountId);
const depAccounts = depAccountsIds.map((id) => this.accountsGraph.getNodeData(id));
let balance = 0;
[...depAccounts, accountNode].forEach((account) => {
// if (!this.accountsBalanceTable[account.id]) {
const closingBalance = this.getClosingBalance(account.id, closingDate, dateType);
this.accountsBalanceTable[account.id] = closingBalance;
// }
balance += this.accountsBalanceTable[account.id];
});
return balance;
}
/**
* Retrieve the credit/debit sumation for the given account and date.
* @param {Number} account -
@@ -299,6 +305,30 @@ export default class JournalPoster {
return result;
}
/**
* Retrieve trial balance of the given account with depends.
* @param {Number} accountId
* @param {Date} closingDate
* @param {String} dateType
* @return {Number}
*/
getTrialBalanceWithDepands(accountId, closingDate, dateType) {
const accountNode = this.accountsGraph.getNodeData(accountId);
const depAccountsIds = this.accountsGraph.dependenciesOf(accountId);
const depAccounts = depAccountsIds.map((id) => this.accountsGraph.getNodeData(id));
const trialBalance = { credit: 0, debit: 0, balance: 0 };
[...depAccounts, accountNode].forEach((account) => {
const _trialBalance = this.getTrialBalance(account.id, closingDate, dateType);
trialBalance.credit += _trialBalance.credit;
trialBalance.debit += _trialBalance.debit;
trialBalance.balance += _trialBalance.balance;
});
return trialBalance;
}
/**
* Load fetched accounts journal entries.
* @param {Array} entries -
@@ -324,8 +354,4 @@ export default class JournalPoster {
}
});
}
static loadAccounts() {
}
}

View File

@@ -72,6 +72,30 @@ const promiseSerial = (funcs) => {
Promise.resolve([]));
}
const flatToNestedArray = (data, config = { id: 'id', parentId: 'parent_id' }) => {
const map = {};
const nestedArray = [];
data.forEach((item) => {
map[item[config.id]] = item;
map[item[config.id]].children = [];
});
data.forEach((item) => {
const parentItemId = item[config.parentId];
if (!item[config.parentId]) {
nestedArray.push(item);
}
if(parentItemId) {
map[parentItemId].children.push(item);
}
});
return nestedArray;
}
export {
hashPassword,
origin,
@@ -80,4 +104,5 @@ export {
mapValuesDeep,
mapKeysDeep,
promiseSerial,
flatToNestedArray,
};

View File

@@ -2,61 +2,129 @@ import { expect } from '~/testInit';
import NestedSet from '@/collection/NestedSet';
describe('NestedSet', () => {
it('Should link parent and children nodes.', () => {
const flattenArray = [
{ id: 10 },
{ id: 1 },
{
describe('linkChildren()', () => {
it('Should link parent and children nodes.', () => {
const flattenArray = [
{ id: 10 },
{ id: 1 },
{
id: 3,
parent_id: 1,
},
{
id: 2,
parent_id: 1,
},
{
id: 4,
parent_id: 3,
},
];
const nestSet = new NestedSet(flattenArray);
const treeGroups = nestSet.linkChildren();
expect(treeGroups['1']).deep.equals({
id: 1,
children: {
'2': { id: 2, parent_id: 1, children: {} },
'3': {
id: 3, parent_id: 1, children: {
'4': { id: 4, parent_id: 3, children: {} }
}
}
}
});
expect(treeGroups['2']).deep.equals({
id: 2, parent_id: 1, children: {},
});
expect(treeGroups['3']).deep.equals({
id: 3,
parent_id: 1,
},
{
id: 2,
parent_id: 1,
},
{
id: 4,
parent_id: 3,
},
];
const collection = new NestedSet(flattenArray);
const treeGroups = collection.toTree();
expect(treeGroups[0].id).equals(10);
expect(treeGroups[1].id).equals(1);
expect(treeGroups[1].children.length).equals(2);
expect(treeGroups[1].children[0].id).equals(3);
expect(treeGroups[1].children[1].id).equals(2);
expect(treeGroups[1].children[0].children[0].id).equals(4);
children: { '4': { id: 4, parent_id: 3, children: {} } }
});
expect(treeGroups['4']).deep.equals({
id: 4, parent_id: 3, children: {},
});
});
});
it('Should flatten the nested set collection.', () => {
const flattenArray = [
{ id: 1 },
{
id: 2,
parent_id: 1,
},
{
id: 3,
parent_id: 1,
},
{
id: 4,
parent_id: 3,
},
];
describe('toArray()', () => {
it('Should retrieve nested sets as array.', () => {
const flattenArray = [
{ id: 10 },
{ id: 1 },
{
id: 3,
parent_id: 1,
},
{
id: 2,
parent_id: 1,
},
{
id: 4,
parent_id: 3,
},
];
const nestSet = new NestedSet(flattenArray);
const treeArray = nestSet.toArray();
const collection = new NestedSet(flattenArray);
const treeGroups = collection.toTree();
const flatten = collection.flattenTree();
expect(treeArray[0]).deep.equals({
id: 10, children: [],
});
expect(treeArray[1]).deep.equals({
id: 1,
children: [
{ id: 2, parent_id: 1, children: [] },
{ id: 3, parent_id: 1, children: [{
id: 4, parent_id: 3, children: []
}] }
]
});
});
});
expect(flatten.length).equals(4);
expect(flatten[0].id).equals(1);
expect(flatten[1].id).equals(2);
expect(flatten[2].id).equals(3);
expect(flatten[3].id).equals(4);
describe('getParents(id)', () => {
it('Should retrieve parent nodes of the given node id.', () => {
const flattenArray = [
{ id: 10 },
{ id: 1 },
{
id: 3,
parent_id: 1,
},
{
id: 2,
parent_id: 1,
},
{
id: 4,
parent_id: 3,
},
];
const nestSet = new NestedSet(flattenArray);
const parentNodes = nestSet.getParents(4);
expect(parentNodes).deep.equals([
{ id: 4, parent_id: 3, children: {} },
{
id: 3,
parent_id: 1,
children: { '4': { id: 4, parent_id: 3, children: {} } }
},
{
id: 1,
children: {
'2': { id: 2, parent_id: 1, children: {} },
'3': {
id: 3, parent_id: 1, children: {
'4': { id: 4, parent_id: 3, children: {} }
}
}
}
}
]);
});
})
});

View File

@@ -8,6 +8,7 @@ import {
systemFactory,
dropTenant,
} from '~/testInit';
import CacheService from '@/services/Cache';
let tenantWebsite;
let tenantFactory;
@@ -19,6 +20,8 @@ beforeEach(async () => {
bindTenantModel(tenantWebsite.tenantDb);
loginRes = await login(tenantWebsite);
CacheService.flush();
});
afterEach(async () => {

View File

@@ -10,7 +10,7 @@ import {
} from '~/dbInit';
import { times } from 'lodash';
describe.only('CachableModel', () => {
describe('CachableModel', () => {
describe('remember()', () => {
it('Should retrieve the data from the storage.', async () => {

View File

@@ -337,7 +337,6 @@ describe('routes: /accounts/', () => {
.get('/api/accounts')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.query({ display_type: 'tree' })
.send();
expect(res.status).equals(200);

View File

@@ -11,6 +11,7 @@ import {
let creditAccount;
let debitAccount;
let incomeAccount;
let incomeType;
describe('routes: `/financial_statements`', () => {
@@ -28,7 +29,7 @@ describe('routes: `/financial_statements`', () => {
debitAccount = await tenantFactory.create('account', { account_type_id: assetType.id });
// Income && expenses accounts.
const incomeAccount = await tenantFactory.create('account', { account_type_id: incomeType.id });
incomeAccount = await tenantFactory.create('account', { account_type_id: incomeType.id });
const expenseAccount = await tenantFactory.create('account', { account_type_id: expenseType.id });
// const income2Account = await tenantFactory.create('account', { account_type_id: incomeType.id });
@@ -183,8 +184,8 @@ describe('routes: `/financial_statements`', () => {
const journal = res.body.journal.find((j) => j.id === '1-Expense');
expect(journal.credit).equals(1);
expect(journal.debit).equals(1);
expect(journal.formatted_credit).equals(1);
expect(journal.formatted_debit).equals(1);
});
});
@@ -244,10 +245,10 @@ describe('routes: `/financial_statements`', () => {
expect(targetAccount).to.be.an('object');
expect(targetAccount.opening).to.deep.equal({
amount: 0, date: '2020-01-01',
amount: 0, formatted_amount: 0, date: '2020-01-01',
});
expect(targetAccount.closing).to.deep.equal({
amount: 4000, date: '2020-12-31',
amount: 4000, formatted_amount: 4000, date: '2020-12-31',
});
});
@@ -263,16 +264,14 @@ describe('routes: `/financial_statements`', () => {
})
.send();
console.log(res.body);
const targetAccount = res.body.accounts.find((a) => a.id === creditAccount.id);
expect(targetAccount).to.be.an('object');
expect(targetAccount.opening).to.deep.equal({
amount: 4000, date: '2020-01-01',
amount: 0, formatted_amount: 0, date: '2018-01-01',
});
expect(targetAccount.closing).to.deep.equal({
amount: 4000, date: '2020-03-30',
amount: 4000, formatted_amount: 4000, date: '2020-03-30',
});
});
@@ -286,10 +285,25 @@ describe('routes: `/financial_statements`', () => {
})
.send();
})
it('Should retrieve accounts transactions only that between date range.', async () => {
const res = await request()
.get('/api/financial_statements/general_ledger')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.query({
from_date: '2020-01-01',
to_date: '2020-03-30',
none_zero: true,
})
.send();
// console.log(res.body.accounts);
});
it('Should retrieve no accounts with given date period has not transactions.', async () => {
const res = await request()
.get('/api/financial_statements/general_ledger')
.set('x-access-token', loginRes.body.token)
@@ -301,23 +315,7 @@ describe('routes: `/financial_statements`', () => {
})
.send();
});
it('Should not retrieve all accounts that have no transactions in the given date range when `none_zero` is `false`.', async () => {
const res = await request()
.get('/api/financial_statements/general_ledger')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.query({
from_date: '2020-01-20',
to_date: '2020-03-30',
none_zero: false,
})
.send();
res.body.accounts.forEach((account) => {
expect(account.transactions.length).not.equals(0);
});
expect(res.body.accounts.length).equals(0);
});
it('Should retrieve all accounts even it have no transactions in the given date range when `none_zero` is `true`', async () => {
@@ -335,7 +333,7 @@ describe('routes: `/financial_statements`', () => {
const accountsNoTransactions = res.body.accounts.filter(a => a.transactions.length === 0);
const accountsWithTransactions = res.body.accounts.filter(a => a.transactions.length > 0);
expect(accountsNoTransactions.length).not.equals(0);
expect(accountsNoTransactions.length).equals(0);
expect(accountsWithTransactions.length).not.equals(0);
});
@@ -347,6 +345,7 @@ describe('routes: `/financial_statements`', () => {
.query({
from_date: '2020-01-01',
to_date: '2020-03-30',
accounts_ids: [creditAccount.id],
number_format: {
divide_1000: true,
},
@@ -358,6 +357,8 @@ describe('routes: `/financial_statements`', () => {
name: creditAccount.name,
code: creditAccount.code,
index: null,
parentAccountId: null,
children: [],
transactions: [
{
id: 1002,
@@ -367,11 +368,12 @@ describe('routes: `/financial_statements`', () => {
referenceId: null,
date: '2020-01-09T22:00:00.000Z',
createdAt: null,
amount: 4
formatted_amount: 4,
amount: 4000,
}
],
opening: { date: '2020-01-01', amount: 0 },
closing: { date: '2020-03-30', amount: 4 }
opening: { date: '2020-01-01', formatted_amount: 0, amount: 0 },
closing: { date: '2020-03-30', formatted_amount: 4, amount: 4000 }
});
});
@@ -395,7 +397,7 @@ describe('routes: `/financial_statements`', () => {
})
.send();
expect(res.body.accounts[0].transactions[2].amount).equal(2);
expect(res.body.accounts[0].transactions[2].formatted_amount).equal(2);
});
it('Should retrieve only accounts that given in the query.', async () => {
@@ -413,6 +415,25 @@ describe('routes: `/financial_statements`', () => {
expect(res.body.accounts.length).equals(1);
});
it('Should retrieve accounts in nested array structure as parent/children accounts.', async () => {
const childAccount = await tenantFactory.create('account', {
parent_account_id: debitAccount.id,
account_type_id: 1
});
const res = await request()
.get('/api/financial_statements/general_ledger')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.query({
accounts_ids: [childAccount.id, debitAccount.id],
})
.send();
expect(res.body.accounts[0].children.length).equals(1);
expect(res.body.accounts[0].children[0].id).equals(childAccount.id);
});
});
describe('routes: `financial_statements/balance_sheet`', () => {
@@ -480,6 +501,8 @@ describe('routes: `/financial_statements`', () => {
index: null,
name: debitAccount.name,
code: debitAccount.code,
parentAccountId: null,
children: [],
total: { formatted_amount: 5000, amount: 5000, date: '2032-02-02' }
});
@@ -488,6 +511,8 @@ describe('routes: `/financial_statements`', () => {
index: null,
name: creditAccount.name,
code: creditAccount.code,
parentAccountId: null,
children: [],
total: { formatted_amount: 4000, amount: 4000, date: '2032-02-02' }
});
});
@@ -565,6 +590,8 @@ describe('routes: `/financial_statements`', () => {
index: debitAccount.index,
name: debitAccount.name,
code: debitAccount.code,
parentAccountId: null,
children: [],
total_periods: [
{ date: '2020-01-08', formatted_amount: 0, amount: 0 },
{ date: '2020-01-09', formatted_amount: 0, amount: 0 },
@@ -579,6 +606,8 @@ describe('routes: `/financial_statements`', () => {
index: creditAccount.index,
name: creditAccount.name,
code: creditAccount.code,
parentAccountId: null,
children: [],
total_periods: [
{ date: '2020-01-08', formatted_amount: 0, amount: 0 },
{ date: '2020-01-09', formatted_amount: 0, amount: 0 },
@@ -608,6 +637,8 @@ describe('routes: `/financial_statements`', () => {
index: debitAccount.index,
name: debitAccount.name,
code: debitAccount.code,
parentAccountId: null,
children: [],
total_periods: [
{ date: '2019-07', formatted_amount: 0, amount: 0 },
{ date: '2019-08', formatted_amount: 0, amount: 0 },
@@ -644,6 +675,8 @@ describe('routes: `/financial_statements`', () => {
index: debitAccount.index,
name: debitAccount.name,
code: debitAccount.code,
parentAccountId: null,
children: [],
total_periods: [
{ date: '2020-03', formatted_amount: 5000, amount: 5000 },
{ date: '2020-06', formatted_amount: 5000, amount: 5000 },
@@ -678,6 +711,8 @@ describe('routes: `/financial_statements`', () => {
index: debitAccount.index,
name: debitAccount.name,
code: debitAccount.code,
parentAccountId: null,
children: [],
total_periods: [
{ date: '2020-03', formatted_amount: 5000, amount: 5000.25 },
{ date: '2020-06', formatted_amount: 5000, amount: 5000.25 },
@@ -709,6 +744,8 @@ describe('routes: `/financial_statements`', () => {
index: debitAccount.index,
name: debitAccount.name,
code: debitAccount.code,
parentAccountId: null,
children: [],
total_periods: [
{ date: '2020-03', formatted_amount: 5, amount: 5000 },
{ date: '2020-06', formatted_amount: 5, amount: 5000 },
@@ -739,6 +776,104 @@ describe('routes: `/financial_statements`', () => {
expect(res.body.accounts[0].children.length).equals(0);
expect(res.body.accounts[1].children.length).equals(0);
});
it('Should retrieve accounts in nested structure parent and children accounts.', async () => {
const childAccount = await tenantFactory.create('account', {
parent_account_id: debitAccount.id,
account_type_id: 1
});
const res = await request()
.get('/api/financial_statements/balance_sheet')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.query({
none_zero: false,
account_ids: [childAccount.id, debitAccount.id]
})
.send();
expect(res.body.accounts[0].children).include.something.deep.equals({
id: debitAccount.id,
index: null,
name: debitAccount.name,
code: debitAccount.code,
parentAccountId: null,
total: { formatted_amount: 5000, amount: 5000, date: '2020-12-31' },
children: [
{
id: childAccount.id,
index: null,
name: childAccount.name,
code: childAccount.code,
parentAccountId: debitAccount.id,
total: { formatted_amount: 0, amount: 0, date: '2020-12-31' },
children: [],
}
]
});
});
it('Should parent account balance sumation of total balane all children accounts.', async () => {
const childAccount = await tenantFactory.create('account', {
parent_account_id: debitAccount.id,
account_type_id: 1
});
await tenantFactory.create('account_transaction', {
credit: 0, debit: 1000, account_id: childAccount.id, date: '2020-1-10'
});
const res = await request()
.get('/api/financial_statements/balance_sheet')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.query({
none_zero: false,
account_ids: [childAccount.id, debitAccount.id]
})
.send();
expect(res.body.accounts[0].children[0].total.amount).equals(6000);
expect(res.body.accounts[0].children[0].total.formatted_amount).equals(6000);
});
it('Should parent account balance sumation of total periods amounts all children accounts.', async () => {
const childAccount = await tenantFactory.create('account', {
parent_account_id: debitAccount.id,
account_type_id: 1
});
await tenantFactory.create('account_transaction', {
credit: 0, debit: 1000, account_id: childAccount.id, date: '2020-2-10'
});
const res = await request()
.get('/api/financial_statements/balance_sheet')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.query({
none_zero: false,
account_ids: [childAccount.id, debitAccount.id],
display_columns_type: 'date_periods',
display_columns_by: 'month',
from_date: '2020-01-01',
to_date: '2020-12-12',
})
.send();
expect(res.body.accounts[0].children[0].total_periods).deep.equals([
{ amount: 5000, formatted_amount: 5000, date: '2020-01' },
{ amount: 6000, formatted_amount: 6000, date: '2020-02' },
{ amount: 6000, formatted_amount: 6000, date: '2020-03' },
{ amount: 6000, formatted_amount: 6000, date: '2020-04' },
{ amount: 6000, formatted_amount: 6000, date: '2020-05' },
{ amount: 6000, formatted_amount: 6000, date: '2020-06' },
{ amount: 6000, formatted_amount: 6000, date: '2020-07' },
{ amount: 6000, formatted_amount: 6000, date: '2020-08' },
{ amount: 6000, formatted_amount: 6000, date: '2020-09' },
{ amount: 6000, formatted_amount: 6000, date: '2020-10' },
{ amount: 6000, formatted_amount: 6000, date: '2020-11' },
{ amount: 6000, formatted_amount: 6000, date: '2020-12' }
])
});
});
describe('routes: `/financial_statements/trial_balance`', () => {
@@ -758,22 +893,37 @@ describe('routes: `/financial_statements`', () => {
.send();
expect(res.body.accounts).include.something.deep.equals({
account_id: debitAccount.id,
id: debitAccount.id,
name: debitAccount.name,
code: debitAccount.code,
parentAccountId: null,
accountNormal: 'debit',
credit: 1000,
debit: 6000,
balance: 5000,
formatted_credit: 1000,
formatted_debit: 6000,
formatted_balance: 5000,
children: [],
});
expect(res.body.accounts).include.something.deep.equals({
account_id: creditAccount.id,
id: creditAccount.id,
name: creditAccount.name,
code: creditAccount.code,
accountNormal: 'credit',
parentAccountId: null,
credit: 4000,
debit: 0,
balance: 4000,
formatted_credit: 4000,
formatted_debit: 0,
formatted_balance: 4000,
children: [],
});
});
@@ -807,13 +957,20 @@ describe('routes: `/financial_statements`', () => {
.send();
expect(res.body.accounts).include.something.deep.equals({
account_id: creditAccount.id,
id: creditAccount.id,
name: creditAccount.name,
code: creditAccount.code,
accountNormal: 'credit',
parentAccountId: null,
credit: 4000,
debit: 0,
balance: 4000
balance: 4000,
formatted_credit: 4000,
formatted_debit: 0,
formatted_balance: 4000,
children: []
});
});
@@ -833,13 +990,21 @@ describe('routes: `/financial_statements`', () => {
.send();
expect(res.body.accounts).include.something.deep.equals({
account_id: creditAccount.id,
id: creditAccount.id,
name: creditAccount.name,
code: creditAccount.code,
accountNormal: 'credit',
credit: 4,
parentAccountId: null,
credit: 4000,
debit: 0,
balance: 4
balance: 4000,
formatted_credit: 4,
formatted_debit: 0,
formatted_balance: 4,
children: [],
});
});
@@ -862,6 +1027,25 @@ describe('routes: `/financial_statements`', () => {
it('Should retrieve associated account details in accounts list.', async () => {
});
it('Should retrieve account with nested array structure as parent/children accounts.', async () => {
const childAccount = await tenantFactory.create('account', {
parent_account_id: debitAccount.id,
account_type_id: 1
});
const res = await request()
.get('/api/financial_statements/trial_balance_sheet')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.query({
account_ids: [debitAccount.id, childAccount.id],
})
.send();
expect(res.body.accounts[0].children.length).equals(1);
expect(res.body.accounts[0].children[0].id).equals(childAccount.id);
});
});
describe('routes: `/api/financial_statements/profit_loss_sheet`', () => {
@@ -955,7 +1139,7 @@ describe('routes: `/financial_statements`', () => {
.set('organization-id', tenantWebsite.organizationId)
.query({
from_date: moment('2020-01-01').startOf('month').format('YYYY-MM-DD'),
to_date: moment('2020-01-01').endOf('month').format('YYYY-MM-DD'),
to_date: moment('2020-01-31').endOf('month').format('YYYY-MM-DD'),
display_columns_type: 'total',
display_columns_by: 'month',
none_zero: false,
@@ -967,6 +1151,8 @@ describe('routes: `/financial_statements`', () => {
index: zeroAccount.index,
name: zeroAccount.name,
code: zeroAccount.code,
parentAccountId: null,
children: [],
total: { amount: 0, date: '2020-01-31', formatted_amount: 0 },
});
});
@@ -1120,5 +1306,90 @@ describe('routes: `/financial_statements`', () => {
expect(res.body.profitLoss.income.accounts.length).equals(1);
});
it('Should retrieve accounts in nested array structure as parent/children accounts.', async () => {
const childAccount = await tenantFactory.create('account', {
parent_account_id: incomeAccount.id,
account_type_id: 7
});
const res = await request()
.get('/api/financial_statements/profit_loss_sheet')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.query({
account_ids: [childAccount.id, incomeAccount.id],
})
.send();
expect(res.body.profitLoss.income.accounts.length).equals(1);
expect(res.body.profitLoss.income.accounts[0].children.length).equals(1);
expect(res.body.profitLoss.income.accounts[0].children[0].id).equals(childAccount.id);
});
it('Should parent account credit/debit sumation of total periods amounts all children accounts.', async () => {
const childAccount = await tenantFactory.create('account', {
parent_account_id: incomeAccount.id,
account_type_id: 7,
});
await tenantFactory.create('account_transaction', {
credit: 1000, debit: 0, account_id: childAccount.id, date: '2020-2-10'
});
const res = await request()
.get('/api/financial_statements/profit_loss_sheet')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.query({
account_ids: [childAccount.id, incomeAccount.id],
from_date: '2020-01-01',
to_date: '2020-12-12',
})
.send();
expect(res.body.profitLoss.income.accounts[0].total).deep.equals({
amount: 3000, date: '2020-12-12', formatted_amount: 3000
});
});
it('Should parent account credit/debit sumation of total date periods.', async () => {
const childAccount = await tenantFactory.create('account', {
parent_account_id: incomeAccount.id,
account_type_id: 7,
});
await tenantFactory.create('account_transaction', {
credit: 1000, debit: 0, account_id: childAccount.id, date: '2020-2-10'
});
const res = await request()
.get('/api/financial_statements/profit_loss_sheet')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.query({
account_ids: [childAccount.id, incomeAccount.id],
display_columns_type: 'date_periods',
display_columns_by: 'month',
from_date: '2020-01-01',
to_date: '2020-12-12',
})
.send();
const periods = [
{ date: '2020-01', amount: 2000, formatted_amount: 2000 },
{ date: '2020-02', amount: 3000, formatted_amount: 3000 },
{ date: '2020-03', amount: 3000, formatted_amount: 3000 },
{ date: '2020-04', amount: 3000, formatted_amount: 3000 },
{ date: '2020-05', amount: 3000, formatted_amount: 3000 },
{ date: '2020-06', amount: 3000, formatted_amount: 3000 },
{ date: '2020-07', amount: 3000, formatted_amount: 3000 },
{ date: '2020-08', amount: 3000, formatted_amount: 3000 },
{ date: '2020-09', amount: 3000, formatted_amount: 3000 },
{ date: '2020-10', amount: 3000, formatted_amount: 3000 },
{ date: '2020-11', amount: 3000, formatted_amount: 3000 },
{ date: '2020-12', amount: 3000, formatted_amount: 3000 }
];
expect(res.body.profitLoss.income.accounts[0].periods).deep.equals(periods);
expect(res.body.profitLoss.income.total_periods).deep.equals(periods);
});
});
});

View File

@@ -10,7 +10,7 @@ import {
} from '~/dbInit';
describe('routes: `/items`', () => {
describe.only('routes: `/items`', () => {
describe('POST: `/items`', () => {
it('Should not create a new item if the user was not authorized.', async () => {
const res = await request()
@@ -553,6 +553,43 @@ describe('routes: `/items`', () => {
});
});
describe('DELETE: `items?ids=`', () => {
it('Should response in case one of items ids where not exists.', async () => {
const res = await request()
.delete('/api/items')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.query({
ids: [100, 200],
})
.send();
expect(res.status).equals(404);
expect(res.body.errors).include.something.that.deep.equals({
type: 'ITEMS.NOT.FOUND', code: 200, ids: [100, 200],
});
});
it('Should delete the given items from the storage.', async () => {
const item1 = await tenantFactory.create('item');
const item2 = await tenantFactory.create('item');
const res = await request()
.delete('/api/items')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.query({
ids: [item1.id, item2.id],
})
.send();
const foundItems = await Item.tenant().query();
expect(res.status).equals(200);
expect(foundItems.length).equals(0)
});
});
describe('GET: `items`', () => {
it('Should response unauthorized access in case the user not authenticated.', async () => {
const res = await request()

View File

@@ -1,13 +1,26 @@
import { expect, create } from '~/testInit';
import { expect } from '~/testInit';
import JournalPoster from '@/services/Accounting/JournalPoster';
import JournalEntry from '@/services/Accounting/JournalEntry';
import AccountBalance from '@/models/AccountBalance';
import AccountTransaction from '@/models/AccountTransaction';
import Account from '@/models/Account';
import {
tenantWebsite,
tenantFactory,
loginRes
} from '~/dbInit';
import { omit } from 'lodash';
import DependencyGraph from '@/lib/DependencyGraph';
let accountsDepGraph;
describe('JournalPoster', () => {
beforeEach(async () => {
accountsDepGraph = await Account.tenant().depGraph().query().remember();
});
describe('credit()', () => {
it('Should write credit entry to journal entries stack.', () => {
const journalEntries = new JournalPoster();
const journalEntries = new JournalPoster(accountsDepGraph);
const journalEntry = new JournalEntry({
referenceId: 1,
referenceType: 'Expense',
@@ -21,7 +34,7 @@ describe('JournalPoster', () => {
describe('debit()', () => {
it('Should write debit entry to journal entries stack.', () => {
const journalEntries = new JournalPoster();
const journalEntries = new JournalPoster(accountsDepGraph);
const journalEntry = new JournalEntry({
referenceId: 1,
referenceType: 'Expense',
@@ -36,7 +49,7 @@ describe('JournalPoster', () => {
describe('setBalanceChange()', () => {
it('Should increment balance amount after credit entry with credit normal account.', () => {
const journalEntries = new JournalPoster();
const journalEntries = new JournalPoster(accountsDepGraph);
const journalEntry = new JournalEntry({
referenceId: 1,
referenceType: 'Expense',
@@ -50,7 +63,7 @@ describe('JournalPoster', () => {
});
it('Should decrement balance amount after debit entry wiht debit normal account.', () => {
const journalEntries = new JournalPoster();
const journalEntries = new JournalPoster(accountsDepGraph);
const journalEntry = new JournalEntry({
referenceId: 1,
referenceType: 'Expense',
@@ -65,7 +78,7 @@ describe('JournalPoster', () => {
describe('saveEntries()', () => {
it('Should save all stacked entries to the storage.', async () => {
const journalEntries = new JournalPoster();
const journalEntries = new JournalPoster(accountsDepGraph);
const journalEntry = new JournalEntry({
referenceId: 1,
referenceType: 'Expense',
@@ -77,7 +90,7 @@ describe('JournalPoster', () => {
journalEntries.debit(journalEntry);
await journalEntries.saveEntries();
const storedJournalEntries = await AccountTransaction.query();
const storedJournalEntries = await AccountTransaction.tenant().query();
expect(storedJournalEntries.length).equals(1);
expect(storedJournalEntries[0]).to.deep.include({
@@ -92,9 +105,10 @@ describe('JournalPoster', () => {
describe('saveBalance()', () => {
it('Should save account balance increment.', async () => {
const account = await create('account');
const account = await tenantFactory.create('account');
const depGraph = await Account.tenant().depGraph().query();
const journalEntries = new JournalPoster();
const journalEntries = new JournalPoster(depGraph);
const journalEntry = new JournalEntry({
referenceId: 1,
referenceType: 'Expense',
@@ -106,16 +120,17 @@ describe('JournalPoster', () => {
await journalEntries.saveBalance();
const storedAccountBalance = await AccountBalance.query();
const storedAccountBalance = await AccountBalance.tenant().query();
expect(storedAccountBalance.length).equals(1);
expect(storedAccountBalance[0].amount).equals(100);
});
it('Should save account balance decrement.', async () => {
const account = await create('account');
const account = await tenantFactory.create('account');
const depGraph = await Account.tenant().depGraph().query();
const journalEntries = new JournalPoster();
const journalEntries = new JournalPoster(depGraph);
const journalEntry = new JournalEntry({
referenceId: 1,
referenceType: 'Expense',
@@ -127,7 +142,7 @@ describe('JournalPoster', () => {
await journalEntries.saveBalance();
const storedAccountBalance = await AccountBalance.query();
const storedAccountBalance = await AccountBalance.tenant().query();
expect(storedAccountBalance.length).equals(1);
expect(storedAccountBalance[0].amount).equals(-100);
@@ -136,7 +151,7 @@ describe('JournalPoster', () => {
describe('getClosingBalance', () => {
it('Should retrieve closing balance the given account id.', () => {
const journalEntries = new JournalPoster();
const journalEntries = new JournalPoster(accountsDepGraph);
const journalEntry = new JournalEntry({
referenceId: 1,
referenceType: 'Expense',
@@ -161,7 +176,7 @@ describe('JournalPoster', () => {
});
it('Should retrieve closing balance the given closing date period.', () => {
const journalEntries = new JournalPoster();
const journalEntries = new JournalPoster(accountsDepGraph);
const journalEntry = new JournalEntry({
referenceId: 1,
referenceType: 'Expense',
@@ -188,7 +203,7 @@ describe('JournalPoster', () => {
describe('getTrialBalance(account, closeDate, dateType)', () => {
it('Should retrieve the trial balance of the given account id.', () => {
const journalEntries = new JournalPoster();
const journalEntries = new JournalPoster(accountsDepGraph);
const journalEntry = new JournalEntry({
referenceId: 1,
referenceType: 'Expense',
@@ -222,7 +237,7 @@ describe('JournalPoster', () => {
describe('removeEntries', () => {
it('Should remove all entries in the collection.', () => {
const journalPoster = new JournalPoster();
const journalPoster = new JournalPoster(accountsDepGraph);
const journalEntry1 = new JournalEntry({
id: 1,
credit: 1000,
@@ -244,7 +259,7 @@ describe('JournalPoster', () => {
});
it('Should remove the given entries ids from the collection.', () => {
const journalPoster = new JournalPoster();
const journalPoster = new JournalPoster(accountsDepGraph);
const journalEntry1 = new JournalEntry({
id: 1,
credit: 1000,
@@ -265,7 +280,7 @@ describe('JournalPoster', () => {
});
it('Should the removed entries ids be stacked to deleted entries ids.', () => {
const journalPoster = new JournalPoster();
const journalPoster = new JournalPoster(accountsDepGraph);
const journalEntry1 = new JournalEntry({
id: 1,
credit: 1000,
@@ -289,7 +304,7 @@ describe('JournalPoster', () => {
});
it('Should revert the account balance after remove the entries.', () => {
const journalPoster = new JournalPoster();
const journalPoster = new JournalPoster(accountsDepGraph);
const journalEntry1 = new JournalEntry({
id: 1,
credit: 1000,
@@ -318,6 +333,59 @@ describe('JournalPoster', () => {
});
});
describe('effectParentAccountsBalance()', () => {
it('Should all parent accounts increment after one of child accounts balance increment.', async () => {
const debitType = await tenantFactory.create('account_type', { normal: 'debit', balance_sheet: true });
const mixin = { account_type_id: debitType.id };
const accountA = await tenantFactory.create('account', { ...mixin });
const accountB = await tenantFactory.create('account', { ...mixin });
const accountAC = await tenantFactory.create('account', { parent_account_id: accountA.id, ...mixin });
const accountBD = await tenantFactory.create('account', { ...mixin });
const depGraph = await Account.tenant().depGraph().query();
const journalPoster = new JournalPoster(depGraph);
const journalEntryA = new JournalEntry({
id: 1,
debit: 1000,
account: accountAC.id,
accountNormal: 'debit',
});
const journalEntryB = new JournalEntry({
id: 1,
debit: 1000,
account: accountBD.id,
accountNormal: 'debit',
});
journalPoster.debit(journalEntryA);
journalPoster.debit(journalEntryB);
await journalPoster.saveBalance();
const accountBalances = await AccountBalance.tenant().query();
const simplifiedArray = accountBalances.map(x => ({ ...omit(x, ['id']) }));
expect(simplifiedArray.length).equals(3);
expect(simplifiedArray).to.include.something.deep.equals({
accountId: accountA.id,
amount: 1000,
currencyCode: 'USD'
});
expect(simplifiedArray).to.include.something.deep.equals({
accountId: accountAC.id,
amount: 1000,
currencyCode: 'USD'
});
expect(simplifiedArray).to.include.something.deep.equals({
accountId: accountBD.id,
amount: 1000,
currencyCode: 'USD'
});
});
});
describe('reverseEntries()', () => {
});