mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-18 13:50:31 +00:00
Merge branch 'master' of https://github.com/abouolia/Ratteb into feature/editItem
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user