mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-17 21:30:31 +00:00
Merge branch 'master' of https://github.com/abouolia/Ratteb into feature/editItem
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
} : {},
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
172
server/src/http/controllers/FinancialStatements/BalanceSheet.js
Normal file
172
server/src/http/controllers/FinancialStatements/BalanceSheet.js
Normal 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],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
165
server/src/http/controllers/FinancialStatements/GeneralLedger.js
Normal file
165
server/src/http/controllers/FinancialStatements/GeneralLedger.js
Normal 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),
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
120
server/src/http/controllers/FinancialStatements/JournalSheet.js
Normal file
120
server/src/http/controllers/FinancialStatements/JournalSheet.js
Normal 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,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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) ],
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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());
|
||||
|
||||
349
server/src/lib/DependencyGraph/index.js
Normal file
349
server/src/lib/DependencyGraph/index.js
Normal 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;
|
||||
9
server/src/lib/NestedSet/NestedSetNode.js
Normal file
9
server/src/lib/NestedSet/NestedSetNode.js
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
|
||||
class NestedSetNode {
|
||||
|
||||
// Saves
|
||||
appendToNode($parent) {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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: {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
})
|
||||
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()', () => {
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user