diff --git a/server/src/collection/NestedSet/index.js b/server/src/collection/NestedSet/index.js index ad636783c..e7480e900 100644 --- a/server/src/collection/NestedSet/index.js +++ b/server/src/collection/NestedSet/index.js @@ -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) => { diff --git a/server/src/http/controllers/Accounting.js b/server/src/http/controllers/Accounting.js index fbee86518..fbd6c3646 100644 --- a/server/src/http/controllers/Accounting.js +++ b/server/src/http/controllers/Accounting.js @@ -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(); diff --git a/server/src/http/controllers/Accounts.js b/server/src/http/controllers/Accounts.js index c5c325b05..a5776c614 100644 --- a/server/src/http/controllers/Accounts.js +++ b/server/src/http/controllers/Accounts.js @@ -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, } : {}, diff --git a/server/src/http/controllers/FinancialStatements.js b/server/src/http/controllers/FinancialStatements.js index 6b36360a6..e8340d00f 100644 --- a/server/src/http/controllers/FinancialStatements.js +++ b/server/src/http/controllers/FinancialStatements.js @@ -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: [], - }); - }, - }, } diff --git a/server/src/http/controllers/FinancialStatements/BalanceSheet.js b/server/src/http/controllers/FinancialStatements/BalanceSheet.js new file mode 100644 index 000000000..d8d0cb5d9 --- /dev/null +++ b/server/src/http/controllers/FinancialStatements/BalanceSheet.js @@ -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], + }, + ], + }); + }, + }, +} \ No newline at end of file diff --git a/server/src/http/controllers/FinancialStatements/FinancialStatementMixin.js b/server/src/http/controllers/FinancialStatements/FinancialStatementMixin.js new file mode 100644 index 000000000..623cb4210 --- /dev/null +++ b/server/src/http/controllers/FinancialStatements/FinancialStatementMixin.js @@ -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; +}; \ No newline at end of file diff --git a/server/src/http/controllers/FinancialStatements/GeneralLedger.js b/server/src/http/controllers/FinancialStatements/GeneralLedger.js new file mode 100644 index 000000000..0a3d7202f --- /dev/null +++ b/server/src/http/controllers/FinancialStatements/GeneralLedger.js @@ -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), + }); + }, + }, +} \ No newline at end of file diff --git a/server/src/http/controllers/FinancialStatements/JournalSheet.js b/server/src/http/controllers/FinancialStatements/JournalSheet.js new file mode 100644 index 000000000..1b2460784 --- /dev/null +++ b/server/src/http/controllers/FinancialStatements/JournalSheet.js @@ -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, + }); + }, + }, + + +} \ No newline at end of file diff --git a/server/src/http/controllers/FinancialStatements/ProfitLossSheet.js b/server/src/http/controllers/FinancialStatements/ProfitLossSheet.js new file mode 100644 index 000000000..1e85427d3 --- /dev/null +++ b/server/src/http/controllers/FinancialStatements/ProfitLossSheet.js @@ -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, + }, + }); + }, + }, +} \ No newline at end of file diff --git a/server/src/http/controllers/FinancialStatements/TrialBalanceSheet.js b/server/src/http/controllers/FinancialStatements/TrialBalanceSheet.js new file mode 100644 index 000000000..a83aa0986 --- /dev/null +++ b/server/src/http/controllers/FinancialStatements/TrialBalanceSheet.js @@ -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) ], + }); + }, + }, +} \ No newline at end of file diff --git a/server/src/http/controllers/Items.js b/server/src/http/controllers/Items.js index b8c6e1916..bd5747439 100644 --- a/server/src/http/controllers/Items.js +++ b/server/src/http/controllers/Items.js @@ -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. */ diff --git a/server/src/http/index.js b/server/src/http/index.js index 8dad4f4e6..a1a6ad0e6 100644 --- a/server/src/http/index.js +++ b/server/src/http/index.js @@ -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()); diff --git a/server/src/lib/DependencyGraph/index.js b/server/src/lib/DependencyGraph/index.js new file mode 100644 index 000000000..dcc19c7b7 --- /dev/null +++ b/server/src/lib/DependencyGraph/index.js @@ -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; \ No newline at end of file diff --git a/server/src/lib/NestedSet/NestedSetNode.js b/server/src/lib/NestedSet/NestedSetNode.js new file mode 100644 index 000000000..60655589f --- /dev/null +++ b/server/src/lib/NestedSet/NestedSetNode.js @@ -0,0 +1,9 @@ + + +class NestedSetNode { + + // Saves + appendToNode($parent) { + + } +} \ No newline at end of file diff --git a/server/src/models/Account.js b/server/src/models/Account.js index 4d85db60a..e8d21fb99 100644 --- a/server/src/models/Account.js +++ b/server/src/models/Account.js @@ -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' } + ); + } } diff --git a/server/src/models/AccountTransaction.js b/server/src/models/AccountTransaction.js index 360091bb0..42b396fe0 100644 --- a/server/src/models/AccountTransaction.js +++ b/server/src/models/AccountTransaction.js @@ -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 */ diff --git a/server/src/models/Option.js b/server/src/models/Option.js index 40ff8ae81..dedd921c8 100644 --- a/server/src/models/Option.js +++ b/server/src/models/Option.js @@ -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 = []; diff --git a/server/src/services/Accounting/JournalPoster.js b/server/src/services/Accounting/JournalPoster.js index ed6fdd36a..3f5077f9b 100644 --- a/server/src/services/Accounting/JournalPoster.js +++ b/server/src/services/Accounting/JournalPoster.js @@ -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() { - - } } diff --git a/server/src/utils/index.js b/server/src/utils/index.js index 6dd921b8f..de65713fa 100644 --- a/server/src/utils/index.js +++ b/server/src/utils/index.js @@ -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, }; diff --git a/server/tests/collection/NestedSet.test.js b/server/tests/collection/NestedSet.test.js index 32862bfde..7246259f8 100644 --- a/server/tests/collection/NestedSet.test.js +++ b/server/tests/collection/NestedSet.test.js @@ -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: {} } + } + } + } + } + ]); + }); }) + }); diff --git a/server/tests/dbInit.js b/server/tests/dbInit.js index 98456918b..8da9bb9c9 100644 --- a/server/tests/dbInit.js +++ b/server/tests/dbInit.js @@ -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 () => { diff --git a/server/tests/lib/CachableModel.test.js b/server/tests/lib/CachableModel.test.js index 796bda373..eff891505 100644 --- a/server/tests/lib/CachableModel.test.js +++ b/server/tests/lib/CachableModel.test.js @@ -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 () => { diff --git a/server/tests/routes/accounts.test.js b/server/tests/routes/accounts.test.js index e3bf051d4..6c9a9418c 100644 --- a/server/tests/routes/accounts.test.js +++ b/server/tests/routes/accounts.test.js @@ -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); diff --git a/server/tests/routes/financial_statements.test.js b/server/tests/routes/financial_statements.test.js index 3a75c898b..03139e438 100644 --- a/server/tests/routes/financial_statements.test.js +++ b/server/tests/routes/financial_statements.test.js @@ -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); + }); }); }); diff --git a/server/tests/routes/items.test.js b/server/tests/routes/items.test.js index c2bf550aa..f3773be25 100644 --- a/server/tests/routes/items.test.js +++ b/server/tests/routes/items.test.js @@ -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() diff --git a/common/services/JournalPoster.test.js b/server/tests/services/JournalPoster.test.js similarity index 70% rename from common/services/JournalPoster.test.js rename to server/tests/services/JournalPoster.test.js index 2da8f87cb..819dd6a03 100644 --- a/common/services/JournalPoster.test.js +++ b/server/tests/services/JournalPoster.test.js @@ -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()', () => { });