feat: Financial statements dependency graph calculate.

This commit is contained in:
Ahmed Bouhuolia
2020-05-23 06:47:00 +02:00
parent b2c9ac54f4
commit 17c1b6ad51
26 changed files with 2041 additions and 900 deletions

View File

@@ -0,0 +1,172 @@
import express from 'express';
import { query, oneOf, validationResult } from 'express-validator';
import moment from 'moment';
import { pick, difference, groupBy } from 'lodash';
import JournalPoster from '@/services/Accounting/JournalPoster';
import { dateRangeCollection } from '@/utils';
import DependencyGraph from '@/lib/DependencyGraph';
import asyncMiddleware from '@/http/middleware/asyncMiddleware'
import { formatNumberClosure } from './FinancialStatementMixin';
export default {
/**
* Router constructor.
*/
router() {
const router = express.Router();
router.get('/',
this.balanceSheet.validation,
asyncMiddleware(this.balanceSheet.handler));
return router;
},
/**
* Retrieve the balance sheet.
*/
balanceSheet: {
validation: [
query('accounting_method').optional().isIn(['cash', 'accural']),
query('from_date').optional(),
query('to_date').optional(),
query('display_columns_type').optional().isIn(['date_periods', 'total']),
query('display_columns_by').optional({ nullable: true, checkFalsy: true })
.isIn(['year', 'month', 'week', 'day', 'quarter']),
query('number_format.no_cents').optional().isBoolean().toBoolean(),
query('number_format.divide_1000').optional().isBoolean().toBoolean(),
query('account_ids').isArray().optional(),
query('account_ids.*').isNumeric().toInt(),
query('none_zero').optional().isBoolean().toBoolean(),
],
async handler(req, res) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
const { Account, AccountType } = req.models;
const filter = {
display_columns_type: 'total',
display_columns_by: '',
from_date: moment().startOf('year').format('YYYY-MM-DD'),
to_date: moment().endOf('year').format('YYYY-MM-DD'),
number_format: {
no_cents: false,
divide_1000: false,
},
none_zero: false,
basis: 'cash',
account_ids: [],
...req.query,
};
if (!Array.isArray(filter.account_ids)) {
filter.account_ids = [filter.account_ids];
}
// Account balance formmatter based on the given query.
const balanceFormatter = formatNumberClosure(filter.number_format);
const comparatorDateType = filter.display_columns_type === 'total' ? 'day' : filter.display_columns_by;
const balanceSheetTypes = await AccountType.query().where('balance_sheet', true);
// Fetch all balance sheet accounts from the storage.
const accounts = await Account.query()
// .remember('balance_sheet_accounts')
.whereIn('account_type_id', balanceSheetTypes.map((a) => a.id))
.modify('filterAccounts', filter.account_ids)
.withGraphFetched('type')
.withGraphFetched('transactions')
.modifyGraph('transactions', (builder) => {
builder.modify('filterDateRange', null, filter.to_date);
});
// Accounts dependency graph.
const accountsGraph = DependencyGraph.fromArray(
accounts, { itemId: 'id', parentItemId: 'parentAccountId' }
);
// Load all entries that associated to the given accounts.
const journalEntriesCollected = Account.collectJournalEntries(accounts);
const journalEntries = new JournalPoster(accountsGraph);
journalEntries.loadEntries(journalEntriesCollected);
// Date range collection.
const dateRangeSet = (filter.display_columns_type === 'date_periods')
? dateRangeCollection(
filter.from_date, filter.to_date, comparatorDateType,
) : [];
// Gets the date range set from start to end date.
const totalPeriods = (account) => ({
total_periods: dateRangeSet.map((date) => {
const amount = journalEntries.getAccountBalance(account.id, date, comparatorDateType);
return {
amount,
formatted_amount: balanceFormatter(amount),
date,
};
}),
});
const accountsMapperToResponse = (account) => {
// Calculates the closing balance to the given date.
const closingBalance = journalEntries.getAccountBalance(account.id, filter.to_date);
return {
...pick(account, ['id', 'index', 'name', 'code', 'parentAccountId']),
// Date periods when display columns.
...(filter.display_columns_type === 'date_periods') && totalPeriods(account),
total: {
amount: closingBalance,
formatted_amount: balanceFormatter(closingBalance),
date: filter.to_date,
},
};
};
// Retrieve all assets accounts.
const assetsAccounts = accounts.filter((account) => (
account.type.normal === 'debit'
&& (account.transactions.length > 0 || !filter.none_zero)))
.map(accountsMapperToResponse);
// Retrieve all liability accounts.
const liabilitiesAccounts = accounts.filter((account) => (
account.type.normal === 'credit'
&& (account.transactions.length > 0 || !filter.none_zero)))
.map(accountsMapperToResponse);
// Retrieve the asset balance sheet.
const assetsAccountsResponse = Account.toNestedArray(assetsAccounts);
// Retrieve liabilities and equity balance sheet.
const liabilitiesEquityResponse = Account.toNestedArray(liabilitiesAccounts);
// Response.
return res.status(200).send({
query: { ...filter },
columns: { ...dateRangeSet },
accounts: [
{
name: 'Assets',
type: 'assets',
children: [...assetsAccountsResponse],
},
{
name: 'Liabilities & Equity',
type: 'liabilities_equity',
children: [...liabilitiesEquityResponse],
},
],
});
},
},
}

View File

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

View File

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

View File

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

View File

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

View File

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