refactoring: balance sheet report.

refactoring: trial balance sheet report.
refactoring: general ledger report.
refactoring: journal report.
refactoring: P&L report.
This commit is contained in:
Ahmed Bouhuolia
2020-12-10 13:04:49 +02:00
parent e8f329e29e
commit d49992a6d7
71 changed files with 3203 additions and 1571 deletions

View File

@@ -2,6 +2,7 @@ import { Response, Request, NextFunction } from 'express';
import { matchedData, validationResult } from "express-validator";
import { camelCase, snakeCase, omit } from "lodash";
import { mapKeysDeep } from 'utils'
import asyncMiddleware from 'api/middleware/asyncMiddleware';
export default class BaseController {
@@ -63,4 +64,8 @@ export default class BaseController {
transfromToResponse(data: any) {
return mapKeysDeep(data, (v, k) => snakeCase(k));
}
asyncMiddleware(callback) {
return asyncMiddleware(callback);
}
}

View File

@@ -1,28 +0,0 @@
import express from 'express';
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';
import ReceivableAgingSummary from './FinancialStatements/ReceivableAgingSummary';
import PayableAgingSummary from './FinancialStatements/PayableAgingSummary';
export default {
/**
* Router constructor.
*/
router() {
const router = express.Router();
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());
router.use('/receivable_aging_summary', ReceivableAgingSummary.router());
router.use('/payable_aging_summary', PayableAgingSummary.router());
return router;
},
};

View File

@@ -0,0 +1,30 @@
import { Router } from 'express';
import { Container, Service } from 'typedi';
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';
import ReceivableAgingSummary from './FinancialStatements/ARAgingSummary';
// import PayableAgingSummary from './FinancialStatements/PayableAgingSummary';
@Service()
export default class FinancialStatementsService {
/**
* Router constructor.
*/
router() {
const router = Router();
router.use('/balance_sheet', Container.get(BalanceSheetController).router());
router.use('/profit_loss_sheet', Container.get(ProfitLossController).router());
router.use('/general_ledger', Container.get(GeneralLedgerController).router());
router.use('/trial_balance_sheet', Container.get(TrialBalanceSheetController).router());
router.use('/journal', Container.get(JournalSheetController).router());
router.use('/receivable_aging_summary', Container.get(ReceivableAgingSummary).router());
// router.use('/payable_aging_summary', PayableAgingSummary.router());
return router;
}
};

View File

@@ -0,0 +1,69 @@
import { Service, Inject } from 'typedi';
import { Router, Request, Response } from 'express';
import { query, oneOf } from 'express-validator';
import BaseController from '../BaseController';
import ARAgingSummaryService from 'services/FinancialStatements/AgingSummary/ARAgingSummaryService';
@Service()
export default class ARAgingSummaryReportController extends BaseController {
@Inject()
ARAgingSummaryService: ARAgingSummaryService;
/**
* Router constructor.
*/
router() {
const router = Router();
router.get(
'/',
this.validationSchema,
this.validationResult,
this.asyncMiddleware(this.receivableAgingSummary.bind(this))
);
return router;
}
/**
* Receivable aging summary validation roles.
*/
get validationSchema() {
return [
query('as_date').optional().isISO8601(),
query('aging_days_before').optional().isNumeric().toInt(),
query('aging_periods').optional().isNumeric().toInt(),
query('number_format.no_cents').optional().isBoolean().toBoolean(),
query('number_format.1000_divide').optional().isBoolean().toBoolean(),
oneOf(
[
query('customer_ids').optional().isArray({ min: 1 }),
query('customer_ids.*').isNumeric().toInt(),
],
[query('customer_ids').optional().isNumeric().toInt()]
),
query('none_zero').optional().isBoolean().toBoolean(),
];
}
/**
* Retrieve receivable aging summary report.
*/
async receivableAgingSummary(req: Request, res: Response) {
const { tenantId } = req;
const filter = this.matchedQueryData(req);
try {
const {
data,
columns
} = await this.ARAgingSummaryService.ARAgingSummary(tenantId, filter);
return res.status(200).send({
data: this.transfromToResponse(data),
columns: this.transfromToResponse(columns),
});
} catch (error) {
console.log(error);
}
}
}

View File

@@ -1,237 +0,0 @@
import express from 'express';
import { query, validationResult } from 'express-validator';
import moment from 'moment';
import { pick, omit, sumBy } from 'lodash';
import JournalPoster from 'services/Accounting/JournalPoster';
import { dateRangeCollection, itemsStartWith, getTotalDeep } from 'utils';
import asyncMiddleware from 'api/middleware/asyncMiddleware';
import { formatNumberClosure } from './FinancialStatementMixin';
import BalanceSheetStructure from 'data/BalanceSheetStructure';
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 amountFormatter = 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()
.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 = Account.toDependencyGraph(accounts);
// 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 getAccountTotalPeriods = (account) => ({
total_periods: dateRangeSet.map((date) => {
const amount = journalEntries.getAccountBalance(
account.id,
date,
comparatorDateType
);
return {
amount,
date,
formatted_amount: amountFormatter(amount),
};
}),
});
// Retrieve accounts total periods.
const getAccountsTotalPeriods = (_accounts) =>
Object.values(
dateRangeSet.reduce((acc, date, index) => {
const amount = sumBy(_accounts, `total_periods[${index}].amount`);
acc[date] = {
date,
amount,
formatted_amount: amountFormatter(amount),
};
return acc;
}, {})
);
// Retrieve account total and total periods with account meta.
const getAccountTotal = (account) => {
const closingBalance = journalEntries.getAccountBalance(
account.id,
filter.to_date
);
const totalPeriods =
(filter.display_columns_type === 'date_periods' &&
getAccountTotalPeriods(account)) ||
null;
return {
...pick(account, ['id', 'index', 'name', 'code', 'parentAccountId']),
...(totalPeriods && { totalPeriods }),
total: {
amount: closingBalance,
formatted_amount: amountFormatter(closingBalance),
date: filter.to_date,
},
};
};
// Get accounts total of the given structure section
const getAccountsSectionTotal = (_accounts) => {
const total = getTotalDeep(_accounts, 'children', 'total.amount');
return {
total: {
total,
formatted_amount: amountFormatter(total),
},
};
};
// Strcuture accounts related mapper.
const structureAccountsRelatedMapper = (accountsTypes) => {
const filteredAccounts = accounts
// Filter accounts that have no transaction when `none_zero` is on.
.filter(
(account) => account.transactions.length > 0 || !filter.none_zero
)
// Filter accounts that associated to the section accounts types.
.filter(
(account) => accountsTypes.indexOf(account.type.childType) !== -1
)
.map(getAccountTotal);
// Gets total amount of the given accounts.
const totalAmount = sumBy(filteredAccounts, 'total.amount');
return {
children: Account.toNestedArray(filteredAccounts),
total: {
amount: totalAmount,
formatted_amount: amountFormatter(totalAmount),
},
...(filter.display_columns_type === 'date_periods'
? {
total_periods: getAccountsTotalPeriods(filteredAccounts),
}
: {}),
};
};
// Structure section mapper.
const structureSectionMapper = (structure) => {
const result = {
...omit(structure, itemsStartWith(Object.keys(structure), '_')),
...(structure.children
? {
children: balanceSheetWalker(structure.children),
}
: {}),
...(structure._accounts_types_related
? {
...structureAccountsRelatedMapper(
structure._accounts_types_related
),
}
: {}),
};
return {
...result,
...(!structure._accounts_types_related
? getAccountsSectionTotal(result.children)
: {}),
};
};
const balanceSheetWalker = (reportStructure) =>
reportStructure.map(structureSectionMapper).filter(
// Filter the structure sections that have no children.
(structure) => structure.children.length > 0 || structure._forceShow
);
// Response.
return res.status(200).send({
query: { ...filter },
columns: { ...dateRangeSet },
balance_sheet: [...balanceSheetWalker(BalanceSheetStructure)],
});
},
},
};

View File

@@ -0,0 +1,93 @@
import { Inject, Service } from 'typedi';
import { Router, Request, Response, NextFunction } from 'express';
import { query, ValidationChain } from 'express-validator';
import { castArray } from 'lodash';
import asyncMiddleware from 'api/middleware/asyncMiddleware';
import BaseController from '../BaseController';
import BalanceSheetStatementService from 'services/FinancialStatements/BalanceSheet/BalanceSheetService';
@Service()
export default class BalanceSheetStatementController extends BaseController{
@Inject()
balanceSheetService: BalanceSheetStatementService;
/**
* Router constructor.
*/
router() {
const router = Router();
router.get(
'/',
this.balanceSheetValidationSchema,
this.validationResult,
asyncMiddleware(this.balanceSheet.bind(this))
);
return router;
}
/**
* Balance sheet validation schecma.
* @returns {ValidationChain[]}
*/
get balanceSheetValidationSchema(): ValidationChain[] {
return [
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(),
query('none_transactions').optional().isBoolean().toBoolean(),
];
}
/**
* Retrieve the balance sheet.
*/
async balanceSheet(req: Request, res: Response, next: NextFunction) {
const { tenantId, settings } = req;
let filter = this.matchedQueryData(req);
filter = {
...filter,
accountsIds: castArray(filter.accountsIds),
};
const organizationName = settings.get({ group: 'organization', key: 'name' });
const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' });
try {
const {
data,
columns,
query
} = await this.balanceSheetService.balanceSheet(tenantId, filter);
return res.status(200).send({
organization_name: organizationName,
base_currency: baseCurrency,
data: this.transfromToResponse(data),
columns: this.transfromToResponse(columns),
query: this.transfromToResponse(query),
})
} catch (error) {
next(error);
}
}
};

View File

@@ -1,13 +0,0 @@
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

@@ -1,165 +0,0 @@
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 'api/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,74 @@
import { Router, Request, Response } from 'express';
import { query, ValidationChain } from 'express-validator';
import asyncMiddleware from 'api/middleware/asyncMiddleware';
import BaseController from '../BaseController';
import { Inject, Service } from 'typedi';
import GeneralLedgerService from 'services/FinancialStatements/GeneralLedger/GeneralLedgerService';
@Service()
export default class GeneralLedgerReportController extends BaseController{
@Inject()
generalLedgetService: GeneralLedgerService;
/**
* Router constructor.
*/
router() {
const router = Router();
router.get('/',
this.validationSchema,
this.validationResult,
asyncMiddleware(this.generalLedger.bind(this))
);
return router;
}
/**
* Validation schema.
*/
get validationSchema(): ValidationChain[] {
return [
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_transactions').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']),
];
}
/**
* Retrieve the general ledger financial statement.
* @param {Request} req -
* @param {Response} res -
*/
async generalLedger(req: Request, res: Response) {
const { tenantId, settings } = req;
const filter = this.matchedQueryData(req);
const organizationName = settings.get({ group: 'organization', key: 'name' });
const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' });
try {
const {
data,
query,
} = await this.generalLedgetService.generalLedger(tenantId, filter);
return res.status(200).send({
organization_name: organizationName,
base_currency: baseCurrency,
data: this.transfromToResponse(data),
query: this.transfromToResponse(query),
});
} catch (error) {
console.log(error);
}
}
}

View File

@@ -1,120 +0,0 @@
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 'api/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,82 @@
import { Inject, Service } from 'typedi';
import { Request, Response, Router } from 'express';
import { castArray } from 'lodash';
import { query, oneOf } from 'express-validator';
import JournalSheetService from 'services/FinancialStatements/JournalSheet/JournalSheetService';
import BaseController from '../BaseController';
@Service()
export default class JournalSheetController extends BaseController {
@Inject()
journalService: JournalSheetService;
/**
* Router constructor.
*/
router() {
const router = Router();
router.get('/',
this.journalValidationSchema,
this.validationResult,
this.asyncMiddleware(this.journal.bind(this))
);
return router;
}
/**
* Validation schema.
*/
get journalValidationSchema() {
return [
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(),
];
}
/**
* Retrieve the ledger report of the given account.
* @param {Request} req -
* @param {Response} res -
*/
async journal(req: Request, res: Response) {
const { tenantId, settings } = req;
let filter = this.matchedQueryData(req);
filter = {
...filter,
accountsIds: castArray(filter.accountsIds),
};
const organizationName = settings.get({ group: 'organization', key: 'name' });
const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' });
try {
const data = await this.journalService.journalSheet(tenantId, filter);
return res.status(200).send({
organization_name: organizationName,
base_currency: baseCurrency,
data: this.transfromToResponse(data),
query: this.transfromToResponse(query),
});
} catch (error) {
console.log(error);
}
}
}

View File

@@ -1,259 +0,0 @@
import express from 'express';
import { query, oneOf, validationResult } from 'express-validator';
import moment from 'moment';
import { pick, sumBy } from 'lodash';
import JournalPoster from 'services/Accounting/JournalPoster';
import { dateRangeCollection } from 'utils';
import asyncMiddleware from 'api/middleware/asyncMiddleware';
import { formatNumberClosure } from './FinancialStatementMixin';
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 = Account.toDependencyGraph(accounts);
// 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 accountsIncome = Account.toNestedArray(
accountsMapper(
filteredAccounts.filter((account) => account.type.normal === 'credit')
)
);
const accountsExpenses = Account.toNestedArray(
accountsMapper(
filteredAccounts.filter((account) => account.type.normal === 'debit')
)
);
const totalPeriodsMapper = (incomeExpenseAccounts) =>
Object.values(
dateRangeSet.reduce((acc, date, index) => {
let amount = sumBy(
incomeExpenseAccounts,
`periods[${index}].amount`
);
acc[date] = {
date,
amount,
formatted_amount: numberFormatter(amount),
};
return acc;
}, {})
);
// Total income - Total expenses = Net income
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 = sumBy(accountsIncome, 'total.amount');
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 = sumBy(
accountsExpenses,
'total.amount'
);
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,79 @@
import { Service, Inject } from 'typedi';
import { Router, Request, Response, NextFunction } from 'express';
import { query, ValidationChain } from 'express-validator';
import BaseController from '../BaseController';
import ProfitLossSheetService from 'services/FinancialStatements/ProfitLossSheet/ProfitLossSheetService';
@Service()
export default class ProfitLossSheetController extends BaseController {
@Inject()
profitLossSheetService: ProfitLossSheetService;
/**
* Router constructor.
*/
router() {
const router = Router();
router.get(
'/',
this.validationSchema,
this.validationResult,
this.asyncMiddleware(this.profitLossSheet.bind(this)),
);
return router;
}
/**
* Validation schema.
*/
get validationSchema(): ValidationChain[] {
return [
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('none_transactions').optional().isBoolean().toBoolean(),
query('accounts_ids').isArray().optional(),
query('accounts_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']),
];
}
/**
* Retrieve profit/loss financial statement.
* @param {Request} req -
* @param {Response} res -
*/
async profitLossSheet(req: Request, res: Response, next: NextFunction) {
const { tenantId, settings } = req;
const filter = this.matchedQueryData(req);
const organizationName = settings.get({ group: 'organization', key: 'name' });
const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' });
try {
const {
data,
columns,
query,
} = await this.profitLossSheetService.profitLossSheet(tenantId, filter);
return res.status(200).send({
organization_name: organizationName,
base_currency: baseCurrency,
data: this.transfromToResponse(data),
columns: this.transfromToResponse(columns),
query: this.transfromToResponse(query),
});
} catch (error) {
console.log(error);
}
}
}

View File

@@ -1,218 +0,0 @@
import express from 'express';
import { query, oneOf } from 'express-validator';
import { difference } from 'lodash';
import JournalPoster from 'services/Accounting/JournalPoster';
import asyncMiddleware from 'api/middleware/asyncMiddleware';
import AgingReport from 'api/controllers/FinancialStatements/AgingReport';
import moment from 'moment';
export default class ReceivableAgingSummary extends AgingReport {
/**
* Router constructor.
*/
static router() {
const router = express.Router();
router.get(
'/',
this.receivableAgingSummaryRoles,
this.validateResults,
asyncMiddleware(this.validateCustomersIds.bind(this)),
asyncMiddleware(this.receivableAgingSummary.bind(this))
);
return router;
}
/**
* Validates the report customers ids query.
*/
static async validateCustomersIds(req, res, next) {
const { Customer } = req.models;
const filter = {
customer_ids: [],
...req.query,
};
if (!Array.isArray(filter.customer_ids)) {
filter.customer_ids = [filter.customer_ids];
}
if (filter.customer_ids.length > 0) {
const storedCustomers = await Customer.query().whereIn(
'id',
filter.customer_ids
);
const storedCustomersIds = storedCustomers.map((c) => parseInt(c.id, 10));
const notStoredCustomersIds = difference(
filter.customer_ids.map(a => parseInt(a, 10)),
storedCustomersIds
);
if (notStoredCustomersIds.length) {
return res.status(400).send({
errors: [
{
type: 'CUSTOMERS.IDS.NOT.FOUND',
code: 300,
ids: notStoredCustomersIds,
},
],
});
}
}
next();
}
/**
* Receivable aging summary validation roles.
*/
static get receivableAgingSummaryRoles() {
return [
query('as_date').optional().isISO8601(),
query('aging_days_before').optional().isNumeric().toInt(),
query('aging_periods').optional().isNumeric().toInt(),
query('number_format.no_cents').optional().isBoolean().toBoolean(),
query('number_format.1000_divide').optional().isBoolean().toBoolean(),
oneOf(
[
query('customer_ids').optional().isArray({ min: 1 }),
query('customer_ids.*').isNumeric().toInt(),
],
[query('customer_ids').optional().isNumeric().toInt()]
),
query('none_zero').optional().isBoolean().toBoolean(),
];
}
/**
* Retrieve receivable aging summary report.
*/
static async receivableAgingSummary(req, res) {
const { Customer, Account, AccountTransaction, AccountType } = req.models;
const filter = {
as_date: moment().format('YYYY-MM-DD'),
aging_days_before: 30,
aging_periods: 3,
number_format: {
no_cents: false,
divide_1000: false,
},
customer_ids: [],
none_zero: false,
...req.query,
};
if (!Array.isArray(filter.customer_ids)) {
filter.customer_ids = [filter.customer_ids];
}
const storedCustomers = await Customer.query().onBuild((builder) => {
if (filter.customer_ids.length > 0) {
builder.modify('filterCustomerIds', filter.customer_ids);
}
return builder;
});
const accountsReceivableType = await AccountType.query()
.where('key', 'accounts_receivable')
.first();
const accountsReceivable = await Account.query()
.where('account_type_id', accountsReceivableType.id)
.remember()
.first();
const transactions = await AccountTransaction.query().onBuild((query) => {
query.modify('filterDateRange', null, filter.as_date)
query.where('account_id', accountsReceivable.id)
query.modify('filterContactType', 'customer');
if (filter.customer_ids.length> 0) {
query.modify('filterContactIds', filter.customer_ids)
}
query.remember();
return query;
});
const journalPoster = new JournalPoster();
journalPoster.loadEntries(transactions);
const agingPeriods = this.agingRangePeriods(
filter.as_date,
filter.aging_days_before,
filter.aging_periods
);
// Total amount formmatter based on the given query.
const totalFormatter = this.formatNumberClosure(filter.number_format);
const customers = storedCustomers.map((customer) => {
// Calculate the trial balance total of the given customer.
const customerBalance = journalPoster.getContactTrialBalance(
accountsReceivable.id,
customer.id,
'customer'
);
const agingClosingBalance = agingPeriods.map((agingPeriod) => {
// Calculate the trial balance between the given date period.
const agingTrialBalance = journalPoster.getContactTrialBalance(
accountsReceivable.id,
customer.id,
'customer',
agingPeriod.from_period
);
return {
...agingPeriod,
closingBalance: agingTrialBalance.debit,
};
});
const aging = this.contactAgingBalance(
agingClosingBalance,
customerBalance.credit
);
return {
customer_name: customer.displayName,
aging: aging.map((item) => ({
...item,
formatted_total: totalFormatter(item.total),
})),
total: customerBalance.balance,
formatted_total: totalFormatter(customerBalance.balance),
};
});
const agingClosingBalance = agingPeriods.map((agingPeriod) => {
const closingTrialBalance = journalPoster.getContactTrialBalance(
accountsReceivable.id,
null,
'customer',
agingPeriod.from_period
);
return {
...agingPeriod,
closingBalance: closingTrialBalance.balance,
};
});
const totalClosingBalance = journalPoster.getContactTrialBalance(
accountsReceivable.id,
null,
'customer'
);
const agingTotal = this.contactAgingBalance(
agingClosingBalance,
totalClosingBalance.credit
);
return res.status(200).send({
columns: [...agingPeriods],
aging: {
customers,
total: [
...agingTotal.map((item) => ({
...item,
formatted_total: totalFormatter(item.total),
})),
],
},
});
}
}

View File

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

View File

@@ -0,0 +1,74 @@
import { Inject, Service } from 'typedi';
import { Request, Response, Router, NextFunction } from 'express';
import { query, ValidationChain } from 'express-validator';
import asyncMiddleware from 'api/middleware/asyncMiddleware';
import BaseController from '../BaseController';
import TrialBalanceSheetService from 'services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetService';
import { castArray } from 'lodash';
@Service()
export default class TrialBalanceSheetController extends BaseController {
@Inject()
trialBalanceSheetService: TrialBalanceSheetService;
/**
* Router constructor.
*/
router() {
const router = Router();
router.get('/',
this.trialBalanceSheetValidationSchema,
this.validationResult,
asyncMiddleware(this.trialBalanceSheet.bind(this))
);
return router;
}
/**
* Validation schema.
* @return {ValidationChain[]}
*/
get trialBalanceSheetValidationSchema(): ValidationChain[] {
return [
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(),
];
}
/**
* Retrieve the trial balance sheet.
*/
public async trialBalanceSheet(req: Request, res: Response, next: NextFunction) {
const { tenantId, settings } = req;
let filter = this.matchedQueryData(req);
filter = {
...filter,
accountsIds: castArray(filter.accountsIds),
};
const organizationName = settings.get({ group: 'organization', key: 'name' });
const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' });
try {
const { data, query } = await this.trialBalanceSheetService
.trialBalanceSheet(tenantId, filter);
return res.status(200).send({
organization_name: organizationName,
base_currency: baseCurrency,
data: this.transfromToResponse(data),
query: this.transfromToResponse(query)
});
} catch (error) {
next(error);
}
}
}

View File

@@ -338,7 +338,11 @@ export default class ManualJournalsController extends BaseController {
filter.filterRoles = JSON.parse(filter.stringifiedFilterRoles);
}
try {
const { manualJournals, pagination, filterMeta } = await this.manualJournalsService.getManualJournals(tenantId, filter);
const {
manualJournals,
pagination,
filterMeta
} = await this.manualJournalsService.getManualJournals(tenantId, filter);
return res.status(200).send({
manual_journals: manualJournals,

View File

@@ -91,7 +91,7 @@ export default () => {
dashboard.use('/items', Container.get(Items).router());
dashboard.use('/item_categories', Container.get(ItemCategories).router());
dashboard.use('/expenses', Container.get(Expenses).router());
dashboard.use('/financial_statements', FinancialStatements.router());
dashboard.use('/financial_statements', Container.get(FinancialStatements).router());
dashboard.use('/customers', Container.get(Customers).router());
dashboard.use('/vendors', Container.get(Vendors).router());
dashboard.use('/sales', Container.get(Sales).router());