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());

View File

@@ -1,62 +0,0 @@
export default [
{
name: 'Assets',
section_type: 'assets',
type: 'section',
children: [
{
name: 'Current Asset',
type: 'section',
_accounts_types_related: ['current_asset'],
},
{
name: 'Fixed Asset',
type: 'section',
_accounts_types_related: ['fixed_asset'],
},
{
name: 'Other Asset',
type: 'section',
_accounts_types_related: ['other_asset'],
},
],
_forceShow: true,
},
{
name: 'Liabilities and Equity',
section_type: 'liabilities_equity',
type: 'section',
children: [
{
name: 'Liabilities',
section_type: 'liability',
type: 'section',
children: [
{
name: 'Current Liability',
type: 'section',
_accounts_types_related: ['current_liability'],
},
{
name: 'Long Term Liability',
type: 'section',
_accounts_types_related: ['long_term_liability'],
},
{
name: 'Other Liability',
type: 'section',
_accounts_types_related: ['other_liability'],
},
],
},
{
name: 'Equity',
section_type: 'equity',
type: 'section',
_accounts_types_related: ['equity'],
},
],
_forceShow: true,
},
];

View File

@@ -0,0 +1,65 @@
import { IBalanceSheetStructureSection } from 'interfaces';
const balanceSheetStructure: IBalanceSheetStructureSection[] = [
{
name: 'Assets',
sectionType: 'assets',
type: 'section',
children: [
{
name: 'Current Asset',
type: 'accounts_section',
_accountsTypesRelated: ['current_asset'],
},
{
name: 'Fixed Asset',
type: 'accounts_section',
_accountsTypesRelated: ['fixed_asset'],
},
{
name: 'Other Asset',
type: 'accounts_section',
_accountsTypesRelated: ['other_asset'],
},
],
_forceShow: true,
},
{
name: 'Liabilities and Equity',
sectionType: 'liabilities_equity',
type: 'section',
children: [
{
name: 'Liabilities',
sectionType: 'liability',
type: 'section',
children: [
{
name: 'Current Liability',
type: 'accounts_section',
_accountsTypesRelated: ['current_liability'],
},
{
name: 'Long Term Liability',
type: 'accounts_section',
_accountsTypesRelated: ['long_term_liability'],
},
{
name: 'Other Liability',
type: 'accounts_section',
_accountsTypesRelated: ['other_liability'],
},
],
},
{
name: 'Equity',
sectionType: 'equity',
type: 'accounts_section',
_accountsTypesRelated: ['equity'],
},
],
_forceShow: true,
},
];
export default balanceSheetStructure;

View File

@@ -111,8 +111,8 @@ exports.up = function (knex) {
normal: 'debit',
root_type: 'expenses',
child_type: 'expenses',
balance_sheet: true,
income_sheet: false,
balance_sheet: false,
income_sheet: true,
},
{
id: 8,

View File

@@ -0,0 +1,45 @@
export interface IARAgingSummaryQuery {
asDate: Date | string,
agingDaysBefore: number,
agingPeriods: number,
numberFormat: {
noCents: number,
divideOn1000: number,
},
customersIds: number[],
noneZero: boolean,
}
export interface IAgingPeriod {
fromPeriod: Date,
toPeriod: Date,
beforeDays: number,
toDays: number,
};
export interface IAgingPeriodClosingBalance extends IAgingPeriod {
closingBalance: number,
};
export interface IAgingPeriodTotal extends IAgingPeriod {
total: number,
};
export interface ARAgingSummaryCustomerPeriod {
}
export interface ARAgingSummaryCustomerTotal {
amount: number,
formattedAmount: string,
currencyCode: string,
}
export interface ARAgingSummaryCustomer {
customerName: string,
aging: IAgingPeriodTotal[],
total: ARAgingSummaryCustomerTotal,
};

View File

@@ -10,9 +10,11 @@ export interface IAccountDTO {
};
export interface IAccount {
id: number,
name: string,
slug: string,
code: string,
index: number,
description: string,
accountTypeId: number,
parentAccountId: number,
@@ -20,6 +22,8 @@ export interface IAccount {
predefined: boolean,
amount: number,
currencyCode: string,
transactions?: any[],
type?: any[],
};
export interface IAccountsFilter extends IDynamicListFilterDTO {

View File

@@ -0,0 +1,74 @@
export interface IBalanceSheetQuery{
displayColumnsType: 'total' | 'date_periods',
displayColumnsBy: string,
fromDate: Date|string,
toDate: Date|string,
numberFormat: {
noCents: boolean,
divideOn1000: boolean,
},
noneZero: boolean,
noneTransactions: boolean,
basis: 'cash' | 'accural',
accountIds: number[],
}
export interface IBalanceSheetStatementService {
balanceSheet(tenantId: number, query: IBalanceSheetQuery): Promise<IBalanceSheetStatement>;
}
export interface IBalanceSheetStatementColumns {
}
export interface IBalanceSheetStatementData {
}
export interface IBalanceSheetStatement {
query: IBalanceSheetQuery,
columns: IBalanceSheetStatementColumns,
data: IBalanceSheetStatementData,
}
export interface IBalanceSheetStructureSection {
name: string,
sectionType?: string,
type: 'section' | 'accounts_section',
children?: IBalanceSheetStructureSection[],
_accountsTypesRelated?: string[],
_forceShow?: boolean,
}
export interface IBalanceSheetAccountTotal {
amount: number,
formattedAmount: string,
currencyCode: string,
date?: string|Date,
}
export interface IBalanceSheetAccount {
id: number,
index: number,
name: string,
code: string,
parentAccountId: number,
type: 'account',
hasTransactions: boolean,
children?: IBalanceSheetAccount[],
total: IBalanceSheetAccountTotal,
totalPeriods?: IBalanceSheetAccountTotal[],
}
export interface IBalanceSheetSection {
name: string,
sectionType?: string,
type: 'section' | 'accounts_section',
children: IBalanceSheetAccount[] | IBalanceSheetSection[],
total: IBalanceSheetAccountTotal,
totalPeriods?: IBalanceSheetAccountTotal[];
_accountsTypesRelated?: string[],
_forceShow?: boolean,
}

View File

@@ -43,6 +43,7 @@ export interface IContactAddressDTO {
shippingAddressState?: string,
};
export interface IContact extends IContactAddress{
id?: number,
contactService: 'customer' | 'vendor',
contactType: string,

View File

@@ -0,0 +1,2 @@

View File

@@ -0,0 +1,61 @@
export interface IGeneralLedgerSheetQuery {
fromDate: Date | string,
toDate: Date | string,
basis: string,
numberFormat: {
noCents: boolean,
divideOn1000: boolean,
},
noneTransactions: boolean,
accountsIds: number[],
};
export interface IGeneralLedgerSheetAccountTransaction {
id: number,
amount: number,
formattedAmount: string,
currencyCode: string,
note?: string,
transactionType?: string,
referenceId?: number,
referenceType?: string,
date: Date|string,
};
export interface IGeneralLedgerSheetAccountBalance {
date: Date|string,
amount: number,
formattedAmount: string,
currencyCode: string,
}
export interface IGeneralLedgerSheetAccount {
id: number,
name: string,
code: string,
index: number,
parentAccountId: number,
transactions: IGeneralLedgerSheetAccountTransaction[],
opening: IGeneralLedgerSheetAccountBalance,
closing: IGeneralLedgerSheetAccountBalance,
}
export interface IAccountTransaction {
id: number,
index: number,
draft: boolean,
note: string,
accountId: number,
transactionType: string,
referenceType: string,
referenceId: number,
contactId: number,
contactType: string,
credit: number,
debit: number,
date: string|Date,
createdAt: string|Date,
updatedAt: string|Date,
}

View File

@@ -1,6 +1,7 @@
export interface IJournalEntry {
id: number,
index?: number,
date: Date,
@@ -18,6 +19,8 @@ export interface IJournalEntry {
};
export interface IJournalPoster {
entries: IJournalEntry[],
credit(entry: IJournalEntry): void;
debit(entry: IJournalEntry): void;
@@ -26,6 +29,9 @@ export interface IJournalPoster {
saveEntries(): void;
saveBalance(): void;
deleteEntries(): void;
getAccountBalance(accountId: number, closingDate?: Date | string, dateType?: string): number;
getAccountEntries(accountId: number): IJournalEntry[];
}
export type TEntryType = 'credit' | 'debit';

View File

@@ -0,0 +1,28 @@
import { IJournalEntry } from './Journal';
export interface IJournalReportQuery {
fromDate: Date | string,
toDate: Date | string,
numberFormat: {
noCents: boolean,
divideOn1000: boolean,
},
transactionTypes: string | string[],
accountsIds: number | number[],
fromRange: number,
toRange: number,
}
export interface IJournalReportEntriesGroup {
id: string,
entries: IJournalEntry[],
currencyCode: string,
credit: number,
debit: number,
formattedCredit: string,
formattedDebit: string,
}
export interface IJournalReport {
entries: IJournalReportEntriesGroup[],
}

View File

@@ -0,0 +1,58 @@
export interface IProfitLossSheetQuery {
basis: string,
fromDate: Date | string,
toDate: Date | string,
numberFormat: {
noCents: boolean,
divideOn1000: boolean,
},
noneZero: boolean,
noneTransactions: boolean,
accountsIds: number[],
displayColumnsType: 'total' | 'date_periods',
displayColumnsBy: string,
};
export interface IProfitLossSheetTotal {
amount: number,
formattedAmount: string,
currencyCode: string,
date?: Date|string,
};
export interface IProfitLossSheetAccount {
id: number,
index: number,
name: string,
code: string,
parentAccountId: number,
hasTransactions: boolean,
total: IProfitLossSheetTotal,
totalPeriods: IProfitLossSheetTotal[],
};
export interface IProfitLossSheetAccountsSection {
sectionTitle: string,
entryNormal: 'credit',
accounts: IProfitLossSheetAccount[],
total: IProfitLossSheetTotal,
totalPeriods?: IProfitLossSheetTotal[],
};
export interface IProfitLossSheetTotalSection {
total: IProfitLossSheetTotal,
totalPeriods?: IProfitLossSheetTotal[],
};
export interface IProfitLossSheetStatement {
income: IProfitLossSheetAccountsSection,
costOfSales: IProfitLossSheetAccountsSection,
expenses: IProfitLossSheetAccountsSection,
otherExpenses: IProfitLossSheetAccountsSection,
netIncome: IProfitLossSheetTotalSection;
operatingProfit: IProfitLossSheetTotalSection;
grossProfit: IProfitLossSheetTotalSection;
};

View File

@@ -0,0 +1,37 @@
export interface ITrialBalanceSheetQuery {
fromDate: Date|string,
toDate: Date|string,
numberFormat: {
noCents: boolean,
divideOn1000: boolean,
},
basis: 'cash' | 'accural',
noneZero: boolean,
noneTransactions: boolean,
accountIds: number[],
}
export interface ITrialBalanceAccount {
id: number,
parentAccountId: number,
name: string,
code: string,
accountNormal: string,
hasTransactions: boolean,
credit: number,
debit: number,
balance: number,
formattedCredit: string,
formattedDebit: string,
formattedBalance: string,
}
export type ITrialBalanceSheetData = IBalanceSheetSection[];
export interface ITrialBalanceStatement {
data: ITrialBalanceSheetData,
query: ITrialBalanceSheetQuery,
}

View File

@@ -28,4 +28,12 @@ export * from './ManualJournal';
export * from './Currency';
export * from './ExchangeRate';
export * from './Media';
export * from './SaleEstimate';
export * from './SaleEstimate';
export * from './FinancialStatements';
export * from './BalanceSheet';
export * from './TrialBalanceSheet';
export * from './GeneralLedgerSheet'
export * from './ProfitLossSheet';
export * from './JournalReport';
export * from './ARAgingSummaryReport';

View File

@@ -6,10 +6,12 @@ import ExpenseRepository from 'repositories/ExpenseRepository';
import ViewRepository from 'repositories/ViewRepository';
import ViewRoleRepository from 'repositories/ViewRoleRepository';
import ContactRepository from 'repositories/ContactRepository';
import AccountTransactionsRepository from 'repositories/AccountTransactionRepository';
export default (tenantId: number) => {
return {
accountRepository: new AccountRepository(tenantId),
transactionsRepository: new AccountTransactionsRepository(tenantId),
accountTypeRepository: new AccountTypeRepository(tenantId),
customerRepository: new CustomerRepository(tenantId),
vendorRepository: new VendorRepository(tenantId),

View File

@@ -28,7 +28,7 @@ export default class AccountTransaction extends TenantModel {
* @param {number[]} accountsIds
*/
filterAccounts(query, accountsIds) {
if (accountsIds.length > 0) {
if (Array.isArray(accountsIds) && accountsIds.length > 0) {
query.whereIn('account_id', accountsIds);
}
},
@@ -63,9 +63,11 @@ export default class AccountTransaction extends TenantModel {
q.where('credit', '<=', toAmount);
q.orWhere('debit', '<=', toAmount);
});
}
}
},
sumationCreditDebit(query) {
query.select(['accountId']);
query.sum('credit as credit');
query.sum('debit as debit');
query.groupBy('account_id');
@@ -76,6 +78,16 @@ export default class AccountTransaction extends TenantModel {
filterContactIds(query, contactIds) {
query.whereIn('contact_id', contactIds);
},
openingBalance(query, fromDate) {
query.modify('filterDateRange', null, fromDate)
query.modify('sumationCreditDebit')
},
closingBalance(query, toDate) {
query.modify('filterDateRange', null, toDate)
query.modify('sumationCreditDebit')
},
};
}

View File

@@ -1,25 +1,7 @@
import TenantRepository from 'repositories/TenantRepository';
import { IAccount } from 'interfaces';
import { Account } from 'models';
export default class AccountRepository extends TenantRepository {
models: any;
repositories: any;
cache: any;
/**
* Constructor method.
* @param {number} tenantId - The given tenant id.
*/
constructor(
tenantId: number,
) {
super(tenantId);
this.models = this.tenancy.models(tenantId);
this.cache = this.tenancy.cache(tenantId);
}
/**
* Retrieve accounts dependency graph.
* @returns {}
@@ -27,8 +9,9 @@ export default class AccountRepository extends TenantRepository {
async getDependencyGraph() {
const { Account } = this.models;
const accounts = await this.allAccounts();
const cacheKey = this.getCacheKey('accounts.depGraph');
return this.cache.get('accounts.depGraph', async () => {
return this.cache.get(cacheKey, async () => {
return Account.toDependencyGraph(accounts);
});
}
@@ -37,10 +20,13 @@ export default class AccountRepository extends TenantRepository {
* Retrieve all accounts on the storage.
* @return {IAccount[]}
*/
allAccounts(): IAccount[] {
allAccounts(withRelations?: string|string[]): IAccount[] {
const { Account } = this.models;
return this.cache.get('accounts', async () => {
return Account.query();
const cacheKey = this.getCacheKey('accounts.depGraph', withRelations);
return this.cache.get(cacheKey, async () => {
return Account.query()
.withGraphFetched(withRelations);
});
}
@@ -51,7 +37,9 @@ export default class AccountRepository extends TenantRepository {
*/
getBySlug(slug: string): IAccount {
const { Account } = this.models;
return this.cache.get(`accounts.slug.${slug}`, () => {
const cacheKey = this.getCacheKey('accounts.slug', slug);
return this.cache.get(cacheKey, () => {
return Account.query().findOne('slug', slug);
});
}
@@ -63,7 +51,9 @@ export default class AccountRepository extends TenantRepository {
*/
findById(id: number): IAccount {
const { Account } = this.models;
return this.cache.get(`accounts.id.${id}`, () => {
const cacheKey = this.getCacheKey('accounts.id', id);
return this.cache.get(cacheKey, () => {
return Account.query().findById(id);
});
}
@@ -75,7 +65,11 @@ export default class AccountRepository extends TenantRepository {
*/
findByIds(accountsIds: number[]) {
const { Account } = this.models;
return Account.query().whereIn('id', accountsIds);
const cacheKey = this.getCacheKey('accounts.id', accountsIds);
return this.cache.get(cacheKey, () => {
return Account.query().whereIn('id', accountsIds);
});
}
/**
@@ -143,6 +137,6 @@ export default class AccountRepository extends TenantRepository {
* Flush repository cache.
*/
flushCache(): void {
this.cache.delStartWith('accounts');
this.cache.delStartWith(this.repositoryName);
}
}

View File

@@ -0,0 +1,60 @@
import { QueryBuilder } from 'knex';
import { AccountTransaction } from 'models';
import hashObject from 'object-hash';
import TenantRepository from 'repositories/TenantRepository';
interface IJournalTransactionsFilter {
fromDate: string | Date,
toDate: string | Date,
accountsIds: number[],
sumationCreditDebit: boolean,
fromAmount: number,
toAmount: number,
contactsIds?: number[],
contactType?: string,
};
export default class AccountTransactionsRepository extends TenantRepository {
journal(filter: IJournalTransactionsFilter) {
const { AccountTransaction } = this.models;
const cacheKey = this.getCacheKey('transactions.journal', filter);
return this.cache.get(cacheKey, () => {
return AccountTransaction.query()
.modify('filterAccounts', filter.accountsIds)
.modify('filterDateRange', filter.fromDate, filter.toDate)
.withGraphFetched('account.type')
.onBuild((query) => {
if (filter.sumationCreditDebit) {
query.modify('sumationCreditDebit');
}
if (filter.fromAmount || filter.toAmount) {
query.modify('filterAmountRange', filter.fromAmount, filter.toAmount);
}
if (filter.contactsIds) {
query.modify('filterContactIds', filter.contactsIds);
}
if (filter.contactType) {
query.where('contact_type', filter.contactType);
}
});
});
}
openingBalance(fromDate) {
return this.cache.get('transaction.openingBalance', () => {
return AccountTransaction.query()
.modify('openingBalance', fromDate);
})
}
closingOpening(toDate) {
return this.cache.get('transaction.closingBalance', () => {
return AccountTransaction.query()
.modify('closingBalance', toDate);
});
}
}

View File

@@ -2,22 +2,6 @@ import TenantRepository from 'repositories/TenantRepository';
import { IAccountType } from 'interfaces';
export default class AccountTypeRepository extends TenantRepository {
cache: any;
models: any;
/**
* Constructor method.
* @param {number} tenantId - The given tenant id.
*/
constructor(
tenantId: number,
) {
super(tenantId);
this.models = this.tenancy.models(tenantId);
this.cache = this.tenancy.cache(tenantId);
}
/**
* Retrieve all accounts types.
* @return {IAccountType[]}

View File

@@ -0,0 +1,19 @@
import hashObject from 'object-hash';
export default class CachableRepository {
repositoryName: string;
/**
* Retrieve the cache key of the method name and arguments.
* @param {string} method
* @param {...any} args
* @return {string}
*/
getCacheKey(method, ...args) {
const hashArgs = hashObject({ ...args });
const repositoryName = this.repositoryName;
return `${repositoryName}-${method}-${hashArgs}`;
}
}

View File

@@ -2,22 +2,6 @@ import TenantRepository from 'repositories/TenantRepository';
import { IContact } from 'interfaces';
export default class ContactRepository extends TenantRepository {
cache: any;
models: any;
/**
* Constructor method.
* @param {number} tenantId - The given tenant id.
*/
constructor(
tenantId: number,
) {
super(tenantId);
this.models = this.tenancy.models(tenantId);
this.cache = this.tenancy.cache(tenantId);
}
/**
* Retrieve the given contact model.
* @param {number} contactId

View File

@@ -1,18 +1,12 @@
import TenantRepository from "./TenantRepository";
export default class CustomerRepository extends TenantRepository {
models: any;
cache: any;
all() {
const { Contact } = this.models;
/**
* Constructor method.
* @param {number} tenantId
*/
constructor(tenantId: number) {
super(tenantId);
this.models = this.tenancy.models(tenantId);
this.cache = this.tenancy.cache(tenantId);
return this.cache.get('customers', () => {
return Contact.query().modify('customer');
});
}
/**

View File

@@ -3,21 +3,6 @@ import { IExpense } from 'interfaces';
import moment from "moment";
export default class ExpenseRepository extends TenantRepository {
models: any;
repositories: any;
cache: any;
/**
* Constructor method.
* @param {number} tenantId
*/
constructor(tenantId: number) {
super(tenantId);
this.models = this.tenancy.models(tenantId);
this.cache = this.tenancy.cache(tenantId);
}
/**
* Retrieve the given expense by id.
* @param {number} expenseId

View File

@@ -0,0 +1,18 @@
import { IBalanceSheetQuery } from 'interfaces';
import TenantRepository from 'repositories/TenantRepository';
export default class JournalRepository extends TenantRepository {
balanceSheet(query: IBalanceSheetQuery) {
// Accounts dependency graph.
const accountsGraph = Account.toDependencyGraph(balanceSheetAccounts);
// Load all entries that associated to the given accounts.
const journalEntriesCollected = Account.collectJournalEntries(balanceSheetAccounts);
const journalEntries = new JournalPoster(accountsGraph);
journalEntries.loadEntries(journalEntriesCollected);
}
}

View File

@@ -1,16 +1,45 @@
import { Container } from 'typedi';
import TenancyService from 'services/Tenancy/TenancyService';
import CachableRepository from './CachableRepository';
export default class TenantRepository {
export default class TenantRepository extends CachableRepository {
repositoryName: string;
tenantId: number;
tenancy: TenancyService;
modelsInstance: any;
repositoriesInstance: any;
cacheInstance: any;
/**
* Constructor method.
* @param {number} tenantId
*/
constructor(tenantId: number) {
super();
this.tenantId = tenantId;
this.tenancy = Container.get(TenancyService);
this.repositoryName = this.constructor.name;
}
get models() {
if (!this.modelsInstance) {
this.modelsInstance = this.tenancy.models(this.tenantId);
}
return this.modelsInstance;
}
get repositories() {
if (!this.repositoriesInstance) {
this.repositoriesInstance = this.tenancy.repositories(this.tenantId);
}
return this.repositoriesInstance;
}
get cache() {
if (!this.cacheInstance) {
this.cacheInstance = this.tenancy.cache(this.tenantId);
}
return this.cacheInstance;
}
}

View File

@@ -3,19 +3,6 @@ import TenantRepository from "./TenantRepository";
export default class VendorRepository extends TenantRepository {
models: any;
cache: any;
/**
* Constructor method.
* @param {number} tenantId
*/
constructor(tenantId: number) {
super(tenantId);
this.models = this.tenancy.models(tenantId);
this.cache = this.tenancy.cache(tenantId);
}
/**
* Retrieve vendor details of the given id.
@@ -68,7 +55,6 @@ export default class VendorRepository extends TenantRepository {
[changeMethod]('balance', Math.abs(amount));
}
async changeDiffBalance(
vendorId: number,
amount: number,

View File

@@ -2,22 +2,6 @@ import { IView } from 'interfaces';
import TenantRepository from 'repositories/TenantRepository';
export default class ViewRepository extends TenantRepository {
models: any;
cache: any;
repositories: any;
/**
* Constructor method.
* @param {number} tenantId - The given tenant id.
*/
constructor(
tenantId: number,
) {
super(tenantId);
this.models = this.tenancy.models(tenantId);
this.cache = this.tenancy.cache(tenantId);
}
/**
* Retrieve view model by the given id.

View File

@@ -2,23 +2,6 @@ import { omit } from 'lodash';
import TenantRepository from 'repositories/TenantRepository';
export default class ViewRoleRepository extends TenantRepository {
models: any;
cache: any;
repositories: any;
/**
* Constructor method.
* @param {number} tenantId - The given tenant id.
*/
constructor(
tenantId: number,
) {
super(tenantId);
this.models = this.tenancy.models(tenantId);
this.cache = this.tenancy.cache(tenantId);
this.repositories = this.tenancy.cache(tenantId);
}
allByView(viewId: number) {
const { ViewRole } = this.models;

View File

@@ -1,207 +1,19 @@
import moment from 'moment';
import { IJournalPoster } from 'interfaces';
export default class JournalFinancial {
accountsBalanceTable: { [key: number]: number; } = {};
journal: IJournalPoster;
accountsDepGraph: any;
/**
* Retrieve the closing balance for the given account and closing date.
* @param {Number} accountId -
* @param {Date} closingDate -
* @param {string} dataType? -
* @return {number}
* Journal poster.
* @param {IJournalPoster} journal
*/
getClosingBalance(
accountId: number,
closingDate: Date|string,
dateType: string = 'day'
): number {
let closingBalance = 0;
const momentClosingDate = moment(closingDate);
this.entries.forEach((entry) => {
// Can not continue if not before or event same closing date.
if (
(!momentClosingDate.isAfter(entry.date, dateType) &&
!momentClosingDate.isSame(entry.date, dateType)) ||
(entry.account !== accountId && accountId)
) {
return;
}
if (entry.accountNormal === 'credit') {
closingBalance += entry.credit ? entry.credit : -1 * entry.debit;
} else if (entry.accountNormal === 'debit') {
closingBalance += entry.debit ? entry.debit : -1 * entry.credit;
}
});
return closingBalance;
constructor(journal: IJournalPoster) {
this.journal = journal;
this.accountsDepGraph = this.journal.accountsDepGraph;
}
/**
* Retrieve the given account balance with dependencies accounts.
* @param {Number} accountId
* @param {Date} closingDate
* @param {String} dateType
* @return {Number}
*/
getAccountBalance(accountId: number, closingDate: Date|string, dateType: string) {
const accountNode = this.accountsDepGraph.getNodeData(accountId);
const depAccountsIds = this.accountsDepGraph.dependenciesOf(accountId);
const depAccounts = depAccountsIds
.map((id) => this.accountsDepGraph.getNodeData(id));
let balance: number = 0;
[...depAccounts, accountNode].forEach((account) => {
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 -
* @param {Date|String} closingDate -
*/
getTrialBalance(accountId, closingDate, dateType) {
const momentClosingDate = moment(closingDate);
const result = {
credit: 0,
debit: 0,
balance: 0,
};
this.entries.forEach((entry) => {
if (
(!momentClosingDate.isAfter(entry.date, dateType) &&
!momentClosingDate.isSame(entry.date, dateType)) ||
(entry.account !== accountId && accountId)
) {
return;
}
result.credit += entry.credit;
result.debit += entry.debit;
if (entry.accountNormal === 'credit') {
result.balance += entry.credit - entry.debit;
} else if (entry.accountNormal === 'debit') {
result.balance += entry.debit - entry.credit;
}
});
return result;
}
/**
* Retrieve trial balance of the given account with depends.
* @param {Number} accountId
* @param {Date} closingDate
* @param {String} dateType
* @return {Number}
*/
getTrialBalanceWithDepands(accountId: number, closingDate: Date, dateType: string) {
const accountNode = this.accountsDepGraph.getNodeData(accountId);
const depAccountsIds = this.accountsDepGraph.dependenciesOf(accountId);
const depAccounts = depAccountsIds.map((id) =>
this.accountsDepGraph.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;
}
getContactTrialBalance(
accountId: number,
contactId: number,
contactType: string,
closingDate: Date|string,
openingDate: Date|string,
) {
const momentClosingDate = moment(closingDate);
const momentOpeningDate = moment(openingDate);
const trial = {
credit: 0,
debit: 0,
balance: 0,
};
this.entries.forEach((entry) => {
if (
(closingDate &&
!momentClosingDate.isAfter(entry.date, 'day') &&
!momentClosingDate.isSame(entry.date, 'day')) ||
(openingDate &&
!momentOpeningDate.isBefore(entry.date, 'day') &&
!momentOpeningDate.isSame(entry.date)) ||
(accountId && entry.account !== accountId) ||
(contactId && entry.contactId !== contactId) ||
entry.contactType !== contactType
) {
return;
}
if (entry.credit) {
trial.balance -= entry.credit;
trial.credit += entry.credit;
}
if (entry.debit) {
trial.balance += entry.debit;
trial.debit += entry.debit;
}
});
return trial;
}
/**
* Retrieve total balnace of the given customer/vendor contact.
* @param {Number} accountId
* @param {Number} contactId
* @param {String} contactType
* @param {Date} closingDate
*/
getContactBalance(
accountId: number,
contactId: number,
contactType: string,
closingDate: Date,
openingDate: Date,
) {
const momentClosingDate = moment(closingDate);
let balance = 0;
this.entries.forEach((entry) => {
if (
(closingDate &&
!momentClosingDate.isAfter(entry.date, 'day') &&
!momentClosingDate.isSame(entry.date, 'day')) ||
(entry.account !== accountId && accountId) ||
(contactId && entry.contactId !== contactId) ||
entry.contactType !== contactType
) {
return;
}
if (entry.credit) {
balance -= entry.credit;
}
if (entry.debit) {
balance += entry.debit;
}
});
return balance;
}
}

View File

@@ -1,4 +1,5 @@
import { omit } from 'lodash';
import { omit, get } from 'lodash';
import moment from 'moment';
import { Container } from 'typedi';
import JournalEntry from 'services/Accounting/JournalEntry';
import TenancyService from 'services/Tenancy/TenancyService';
@@ -22,18 +23,25 @@ export default class JournalPoster implements IJournalPoster {
balancesChange: IAccountsChange = {};
accountsDepGraph: IAccountsChange = {};
accountsBalanceTable: { [key: number]: number; } = {};
/**
* Journal poster constructor.
* @param {number} tenantId -
*/
constructor(
tenantId: number,
accountsGraph?: any,
) {
this.initTenancy();
this.tenantId = tenantId;
this.models = this.tenancy.models(tenantId);
this.repositories = this.tenancy.repositories(tenantId);
if (accountsGraph) {
this.accountsDepGraph = accountsGraph;
}
}
/**
@@ -54,10 +62,13 @@ export default class JournalPoster implements IJournalPoster {
* @private
* @returns {Promise<void>}
*/
private async initializeAccountsDepGraph(): Promise<void> {
public async initAccountsDepGraph(): Promise<void> {
const { accountRepository } = this.repositories;
const accountsDepGraph = await accountRepository.getDependencyGraph();
this.accountsDepGraph = accountsDepGraph;
if (!this.accountsDepGraph) {
const accountsDepGraph = await accountRepository.getDependencyGraph();
this.accountsDepGraph = accountsDepGraph;
}
}
/**
@@ -176,7 +187,7 @@ export default class JournalPoster implements IJournalPoster {
* @returns {Promise<void>}
*/
public async saveBalance() {
await this.initializeAccountsDepGraph();
await this.initAccountsDepGraph();
const { Account } = this.models;
const accountsChange = this.balanceChangeWithDepends(this.balancesChange);
@@ -311,15 +322,17 @@ export default class JournalPoster implements IJournalPoster {
* Load fetched accounts journal entries.
* @param {IJournalEntry[]} entries -
*/
loadEntries(entries: IJournalEntry[]): void {
entries.forEach((entry: IJournalEntry) => {
fromTransactions(transactions) {
transactions.forEach((transaction) => {
this.entries.push({
...entry,
account: entry.account ? entry.account.id : entry.accountId,
...transaction,
account: transaction.accountId,
accountNormal: get(transaction, 'account.type.normal'),
});
});
}
/**
* Calculates the entries balance change.
* @public
@@ -334,4 +347,216 @@ export default class JournalPoster implements IJournalPoster {
}
});
}
static fromTransactions(entries, ...args: [number, ...any]) {
const journal = new this(...args);
journal.fromTransactions(entries);
return journal;
}
/**
* Retrieve the closing balance for the given account and closing date.
* @param {Number} accountId -
* @param {Date} closingDate -
* @param {string} dataType? -
* @return {number}
*/
getClosingBalance(
accountId: number,
closingDate: Date|string,
dateType: string = 'day'
): number {
let closingBalance = 0;
const momentClosingDate = moment(closingDate);
this.entries.forEach((entry) => {
// Can not continue if not before or event same closing date.
if (
(!momentClosingDate.isAfter(entry.date, dateType) &&
!momentClosingDate.isSame(entry.date, dateType)) ||
(entry.account !== accountId && accountId)
) {
return;
}
if (entry.accountNormal === 'credit') {
closingBalance += entry.credit ? entry.credit : -1 * entry.debit;
} else if (entry.accountNormal === 'debit') {
closingBalance += entry.debit ? entry.debit : -1 * entry.credit;
}
});
return closingBalance;
}
/**
* Retrieve the given account balance with dependencies accounts.
* @param {Number} accountId -
* @param {Date} closingDate -
* @param {String} dateType -
* @return {Number}
*/
getAccountBalance(accountId: number, closingDate: Date|string, dateType: string) {
const accountNode = this.accountsDepGraph.getNodeData(accountId);
const depAccountsIds = this.accountsDepGraph.dependenciesOf(accountId);
const depAccounts = depAccountsIds
.map((id) => this.accountsDepGraph.getNodeData(id));
let balance: number = 0;
[...depAccounts, accountNode].forEach((account) => {
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 -
* @param {Date|String} closingDate -
*/
getTrialBalance(accountId, closingDate, dateType) {
const momentClosingDate = moment(closingDate);
const result = {
credit: 0,
debit: 0,
balance: 0,
};
this.entries.forEach((entry) => {
if (
(!momentClosingDate.isAfter(entry.date, dateType) &&
!momentClosingDate.isSame(entry.date, dateType)) ||
(entry.account !== accountId && accountId)
) {
return;
}
result.credit += entry.credit;
result.debit += entry.debit;
if (entry.accountNormal === 'credit') {
result.balance += entry.credit - entry.debit;
} else if (entry.accountNormal === 'debit') {
result.balance += entry.debit - entry.credit;
}
});
return result;
}
/**
* Retrieve trial balance of the given account with depends.
* @param {Number} accountId
* @param {Date} closingDate
* @param {String} dateType
* @return {Number}
*/
getTrialBalanceWithDepands(accountId: number, closingDate: Date, dateType: string) {
const accountNode = this.accountsDepGraph.getNodeData(accountId);
const depAccountsIds = this.accountsDepGraph.dependenciesOf(accountId);
const depAccounts = depAccountsIds.map((id) =>
this.accountsDepGraph.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;
}
getContactTrialBalance(
accountId: number,
contactId: number,
contactType: string,
closingDate: Date|string,
openingDate: Date|string,
) {
const momentClosingDate = moment(closingDate);
const momentOpeningDate = moment(openingDate);
const trial = {
credit: 0,
debit: 0,
balance: 0,
};
this.entries.forEach((entry) => {
if (
(closingDate &&
!momentClosingDate.isAfter(entry.date, 'day') &&
!momentClosingDate.isSame(entry.date, 'day')) ||
(openingDate &&
!momentOpeningDate.isBefore(entry.date, 'day') &&
!momentOpeningDate.isSame(entry.date)) ||
(accountId && entry.account !== accountId) ||
(contactId && entry.contactId !== contactId) ||
entry.contactType !== contactType
) {
return;
}
if (entry.credit) {
trial.balance -= entry.credit;
trial.credit += entry.credit;
}
if (entry.debit) {
trial.balance += entry.debit;
trial.debit += entry.debit;
}
});
return trial;
}
/**
* Retrieve total balnace of the given customer/vendor contact.
* @param {Number} accountId
* @param {Number} contactId
* @param {String} contactType
* @param {Date} closingDate
*/
getContactBalance(
accountId: number,
contactId: number,
contactType: string,
closingDate: Date,
openingDate: Date,
) {
const momentClosingDate = moment(closingDate);
let balance = 0;
this.entries.forEach((entry) => {
if (
(closingDate &&
!momentClosingDate.isAfter(entry.date, 'day') &&
!momentClosingDate.isSame(entry.date, 'day')) ||
(entry.account !== accountId && accountId) ||
(contactId && entry.contactId !== contactId) ||
entry.contactType !== contactType
) {
return;
}
if (entry.credit) {
balance -= entry.credit;
}
if (entry.debit) {
balance += entry.debit;
}
});
return balance;
}
getAccountEntries(accountId: number) {
return this.entries.filter((entry) => entry.account === accountId);
}
}

View File

@@ -340,7 +340,7 @@ export default class AccountsService {
* @param {number[]} accountsIds
* @return {IAccount[]}
*/
private async getAccountsOrThrowError(
public async getAccountsOrThrowError(
tenantId: number,
accountsIds: number[]
): Promise<IAccount[]> {
@@ -521,8 +521,7 @@ export default class AccountsService {
* -----------
* Precedures.
* -----------
* - Transfer the given account transactions to another account
* with the same root type.
* - Transfer the given account transactions to another account with the same root type.
* - Delete the given account.
* -------
* @param {number} tenantId -

View File

@@ -0,0 +1,6 @@
export default class PayableAgingSummaryService {
}

View File

@@ -0,0 +1,12 @@
import FinancialSheet from "../FinancialSheet";
export default class APAgingSummarySheet extends FinancialSheet {
reportData() {
}
}

View File

@@ -0,0 +1,93 @@
import moment from 'moment';
import { Inject, Service } from 'typedi';
import { IARAgingSummaryQuery } from 'interfaces';
import TenancyService from 'services/Tenancy/TenancyService';
import Journal from 'services/Accounting/JournalPoster';
import ARAgingSummarySheet from './ARAgingSummarySheet';
@Service()
export default class ARAgingSummaryService {
@Inject()
tenancy: TenancyService;
@Inject('logger')
logger: any;
/**
* Default report query.
*/
get defaultQuery() {
return {
asDate: moment().format('YYYY-MM-DD'),
agingDaysBefore: 30,
agingPeriods: 3,
numberFormat: {
no_cents: false,
divide_1000: false,
},
customersIds: [],
noneZero: false,
};
}
/**
* Retreive th accounts receivable aging summary data and columns.
* @param {number} tenantId
* @param query
*/
async ARAgingSummary(tenantId: number, query: IARAgingSummaryQuery) {
const {
customerRepository,
accountRepository,
transactionsRepository,
accountTypeRepository
} = this.tenancy.repositories(tenantId);
const { Account } = this.tenancy.models(tenantId);
const filter = {
...this.defaultQuery,
...query,
};
this.logger.info('[AR_Aging_Summary] try to calculate the report.', { tenantId, filter });
// Settings tenant service.
const settings = this.tenancy.settings(tenantId);
const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' });
// Retrieve all accounts graph on the storage.
const accountsGraph = await accountRepository.getDependencyGraph();
// Retrieve all customers from the storage.
const customers = await customerRepository.all();
// Retrieve AR account type.
const ARType = await accountTypeRepository.getByKey('accounts_receivable');
// Retreive AR account.
const ARAccount = await Account.query().findOne('account_type_id', ARType.id);
// Retrieve journal transactions based on the given query.
const transactions = await transactionsRepository.journal({
toDate: filter.asDate,
contactType: 'customer',
contactsIds: customers.map(customer => customer.id),
});
// Converts transactions array to journal collection.
const journal = Journal.fromTransactions(transactions, tenantId, accountsGraph);
// AR aging summary report instnace.
const ARAgingSummaryReport = new ARAgingSummarySheet(
tenantId,
filter,
customers,
journal,
ARAccount,
baseCurrency
);
// AR aging summary report data and columns.
const data = ARAgingSummaryReport.reportData();
const columns = ARAgingSummaryReport.reportColumns();
return { data, columns };
}
}

View File

@@ -0,0 +1,154 @@
import {
ICustomer,
IARAgingSummaryQuery,
ARAgingSummaryCustomer,
IAgingPeriodClosingBalance,
IAgingPeriodTotal,
IJournalPoster,
IAccount,
IAgingPeriod
} from "interfaces";
import AgingSummaryReport from './AgingSummary';
export default class ARAgingSummarySheet extends AgingSummaryReport {
tenantId: number;
query: IARAgingSummaryQuery;
customers: ICustomer[];
journal: IJournalPoster;
ARAccount: IAccount;
agingPeriods: IAgingPeriod[];
baseCurrency: string;
/**
* Constructor method.
* @param {number} tenantId
* @param {IARAgingSummaryQuery} query
* @param {ICustomer[]} customers
* @param {IJournalPoster} journal
*/
constructor(
tenantId: number,
query: IARAgingSummaryQuery,
customers: ICustomer[],
journal: IJournalPoster,
ARAccount: IAccount,
baseCurrency: string,
) {
super();
this.tenantId = tenantId;
this.customers = customers;
this.query = query;
this.numberFormat = this.query.numberFormat;
this.journal = journal;
this.ARAccount = ARAccount;
this.baseCurrency = baseCurrency;
this.initAgingPeriod();
}
/**
* Initializes the aging periods.
*/
private initAgingPeriod() {
this.agingPeriods = this.agingRangePeriods(
this.query.asDate,
this.query.agingDaysBefore,
this.query.agingPeriods
);
}
/**
*
* @param {ICustomer} customer
* @param {IAgingPeriod} agingPeriod
*/
private agingPeriodCloser(
customer: ICustomer,
agingPeriod: IAgingPeriod,
): IAgingPeriodClosingBalance {
// Calculate the trial balance between the given date period.
const agingTrialBalance = this.journal.getContactTrialBalance(
this.ARAccount.id,
customer.id,
'customer',
agingPeriod.fromPeriod,
);
return {
...agingPeriod,
closingBalance: agingTrialBalance.debit,
};
}
/**
*
* @param {ICustomer} customer
*/
private getCustomerAging(customer: ICustomer, totalReceivable: number): IAgingPeriodTotal[] {
const agingClosingBalance = this.agingPeriods
.map((agingPeriod: IAgingPeriod) => this.agingPeriodCloser(customer, agingPeriod));
const aging = this.contactAgingBalance(
agingClosingBalance,
totalReceivable
);
return aging;
}
/**
* Mapping aging customer.
* @param {ICustomer} customer -
* @return {ARAgingSummaryCustomer[]}
*/
private customerMapper(customer: ICustomer): ARAgingSummaryCustomer {
// Calculate the trial balance total of the given customer.
const trialBalance = this.journal.getContactTrialBalance(
this.ARAccount.id,
customer.id,
'customer'
);
const amount = trialBalance.balance;
const formattedAmount = this.formatNumber(amount);
const currencyCode = this.baseCurrency;
return {
customerName: customer.displayName,
aging: this.getCustomerAging(customer, trialBalance.balance),
total: {
amount,
formattedAmount,
currencyCode,
},
};
}
/**
* Retrieve customers walker.
* @param {ICustomer[]} customers
* @return {ARAgingSummaryCustomer[]}
*/
private customersWalker(customers: ICustomer[]): ARAgingSummaryCustomer[] {
return customers
.map((customer: ICustomer) => this.customerMapper(customer))
// Filter customers that have zero total amount when `noneZero` is on.
.filter((customer: ARAgingSummaryCustomer) =>
!(customer.total.amount === 0 && this.query.noneZero),
);
}
/**
* Retrieve AR. aging summary report data.
*/
public reportData() {
return this.customersWalker(this.customers);
}
/**
* Retrieve AR aging summary report columns.
*/
reportColumns() {
return []
}
}

View File

@@ -0,0 +1,75 @@
import moment from 'moment';
import { omit, reverse } from 'lodash';
import { IAgingPeriod, IAgingPeriodClosingBalance, IAgingPeriodTotal } from 'interfaces';
import FinancialSheet from '../FinancialSheet';
export default class AgingSummaryReport extends FinancialSheet{
/**
*
* @param {Array} agingPeriods
* @param {Numeric} customerBalance
*/
contactAgingBalance(
agingPeriods: IAgingPeriodClosingBalance[],
receivableTotalCredit: number,
): IAgingPeriodTotal[] {
let prevAging = 0;
let receivableCredit = receivableTotalCredit;
let diff = receivableCredit;
const periods = reverse(agingPeriods).map((agingPeriod) => {
const agingAmount = (agingPeriod.closingBalance - prevAging);
const subtract = Math.min(diff, agingAmount);
diff -= Math.min(agingAmount, diff);
const total = Math.max(agingAmount - subtract, 0);
const output = {
...omit(agingPeriod, ['closingBalance']),
total,
};
prevAging = agingPeriod.closingBalance;
return output;
});
return reverse(periods);
}
/**
*
* @param {*} asDay
* @param {*} agingDaysBefore
* @param {*} agingPeriodsFreq
*/
agingRangePeriods(asDay, agingDaysBefore, agingPeriodsFreq): IAgingPeriod[] {
const totalAgingDays = agingDaysBefore * agingPeriodsFreq;
const startAging = moment(asDay).startOf('day');
const endAging = startAging.clone().subtract('days', totalAgingDays).endOf('day');
const agingPeriods: IAgingPeriod[] = [];
const startingAging = startAging.clone();
let beforeDays = 1;
let toDays = 0;
while (startingAging > endAging) {
const currentAging = startingAging.clone();
startingAging.subtract('days', agingDaysBefore).endOf('day');
toDays += agingDaysBefore;
agingPeriods.push({
fromPeriod: moment(currentAging).toDate(),
toPeriod: moment(startingAging).toDate(),
beforeDays: beforeDays === 1 ? 0 : beforeDays,
toDays: toDays,
...(startingAging.valueOf() === endAging.valueOf()) ? {
toPeriod: null,
toDays: null,
} : {},
});
beforeDays += agingDaysBefore;
}
return agingPeriods;
}
}

View File

@@ -0,0 +1,284 @@
import { sumBy, pick } from 'lodash';
import {
IBalanceSheetQuery,
IBalanceSheetStructureSection,
IBalanceSheetAccountTotal,
IBalanceSheetAccount,
IBalanceSheetSection,
IAccount,
IJournalPoster,
IAccountType,
} from 'interfaces';
import {
dateRangeCollection,
flatToNestedArray,
} from 'utils';
import BalanceSheetStructure from 'data/BalanceSheetStructure';
import FinancialSheet from '../FinancialSheet';
export default class BalanceSheetStatement extends FinancialSheet {
query: IBalanceSheetQuery;
tenantId: number;
accounts: IAccount & { type: IAccountType }[];
journalFinancial: IJournalPoster;
comparatorDateType: string;
dateRangeSet: string[];
baseCurrency: string;
/**
* Constructor method.
* @param {number} tenantId -
* @param {IBalanceSheetQuery} query -
* @param {IAccount[]} accounts -
* @param {IJournalFinancial} journalFinancial -
*/
constructor(
tenantId: number,
query: IBalanceSheetQuery,
accounts: IAccount & { type: IAccountType }[],
journalFinancial: IJournalPoster,
baseCurrency: string,
) {
super();
this.tenantId = tenantId;
this.query = query;
this.numberFormat = this.query.numberFormat;
this.accounts = accounts;
this.journalFinancial = journalFinancial;
this.baseCurrency = baseCurrency;
this.comparatorDateType = query.displayColumnsType === 'total'
? 'day'
: query.displayColumnsBy;
this.initDateRangeCollection();
}
/**
* Initialize date range set.
*/
initDateRangeCollection() {
if (this.query.displayColumnsType === 'date_periods') {
this.dateRangeSet = dateRangeCollection(
this.query.fromDate,
this.query.toDate,
this.comparatorDateType
);
}
}
/**
* Calculates accounts total deeply of the given accounts graph.
* @param {IBalanceSheetSection[]} sections -
* @return {IBalanceSheetAccountTotal}
*/
private getSectionTotal(sections: IBalanceSheetSection[]): IBalanceSheetAccountTotal {
const amount = sumBy(sections, 'total.amount');
const formattedAmount = this.formatNumber(amount);
const currencyCode = this.baseCurrency;
return { amount, formattedAmount, currencyCode };
};
/**
* Retrieve accounts total periods.
* @param {IBalanceSheetAccount[]} sections -
* @return {IBalanceSheetAccountTotal[]}
*/
private getSectionTotalPeriods(sections: IBalanceSheetAccount[]): IBalanceSheetAccountTotal[] {
return this.dateRangeSet.map((date, index) => {
const amount = sumBy(sections, `totalPeriods[${index}].amount`);
const formattedAmount = this.formatNumber(amount);
const currencyCode = this.baseCurrency;
return { date, amount, formattedAmount, currencyCode };
});
}
/**
* Gets the date range set from start to end date.
* @param {IAccount} account
* @return {IBalanceSheetAccountTotal[]}
*/
private getAccountTotalPeriods (account: IAccount): IBalanceSheetAccountTotal[] {
return this.dateRangeSet.map((date) => {
const amount = this.journalFinancial.getAccountBalance(
account.id,
date,
this.comparatorDateType
);
const formattedAmount = this.formatNumber(amount);
const currencyCode = this.baseCurrency;
return { amount, formattedAmount, currencyCode, date };
});
}
/**
* Retrieve account total and total periods with account meta.
* @param {IAccount} account -
* @param {IBalanceSheetQuery} query -
* @return {IBalanceSheetAccount}
*/
private balanceSheetAccountMapper(account: IAccount): IBalanceSheetAccount {
// Calculates the closing balance of the given account in the specific date point.
const amount = this.journalFinancial.getAccountBalance(
account.id, this.query.toDate,
);
const formattedAmount = this.formatNumber(amount);
// Retrieve all entries that associated to the given account.
const entries = this.journalFinancial.getAccountEntries(account.id)
return {
...pick(account, ['id', 'index', 'name', 'code', 'parentAccountId']),
type: 'account',
hasTransactions: entries.length > 0,
// Total date periods.
...this.query.displayColumnsType === 'date_periods' && ({
totalPeriods: this.getAccountTotalPeriods(account),
}),
total: {
amount,
formattedAmount,
currencyCode: this.baseCurrency,
},
};
};
/**
* Strcuture accounts related mapper.
* @param {string[]} sectionAccountsTypes -
* @param {IAccount[]} accounts -
* @param {IBalanceSheetQuery} query -
*/
private structureRelatedAccountsMapper(
sectionAccountsTypes: string[],
accounts: IAccount & { type: IAccountType }[],
): {
children: IBalanceSheetAccount[],
total: IBalanceSheetAccountTotal,
} {
const filteredAccounts = accounts
// Filter accounts that associated to the section accounts types.
.filter(
(account) => sectionAccountsTypes.indexOf(account.type.childType) !== -1
)
.map((account) => this.balanceSheetAccountMapper(account))
// Filter accounts that have no transaction when `noneTransactions` is on.
.filter(
(section: IBalanceSheetAccount) =>
!(!section.hasTransactions && this.query.noneTransactions),
)
// Filter accounts that have zero total amount when `noneZero` is on.
.filter(
(section: IBalanceSheetAccount) =>
!(section.total.amount === 0 && this.query.noneZero)
);
// Gets total amount of the given accounts.
const totalAmount = sumBy(filteredAccounts, 'total.amount');
return {
children: flatToNestedArray(
filteredAccounts,
{ id: 'id', parentId: 'parentAccountId' }
),
total: {
amount: totalAmount,
formattedAmount: this.formatNumber(totalAmount),
currencyCode: this.baseCurrency,
},
...(this.query.displayColumnsType === 'date_periods'
? {
totalPeriods: this.getSectionTotalPeriods(filteredAccounts),
}
: {}),
};
};
/**
* Balance sheet structure mapper.
* @param {IBalanceSheetStructureSection} structure -
* @return {IBalanceSheetSection}
*/
private balanceSheetStructureMapper(
structure: IBalanceSheetStructureSection,
accounts: IAccount & { type: IAccountType }[],
): IBalanceSheetSection {
const result = {
name: structure.name,
sectionType: structure.sectionType,
type: structure.type,
...(structure.type === 'accounts_section'
? {
...this.structureRelatedAccountsMapper(
structure._accountsTypesRelated,
accounts,
),
}
: (() => {
const children = this.balanceSheetStructureWalker(
structure.children,
accounts,
);
return {
children,
total: this.getSectionTotal(children),
};
})()),
};
return result;
}
/**
* Balance sheet structure walker.
* @param {IBalanceSheetStructureSection[]} reportStructure -
* @return {IBalanceSheetSection}
*/
private balanceSheetStructureWalker(
reportStructure: IBalanceSheetStructureSection[],
balanceSheetAccounts: IAccount & { type: IAccountType }[],
): IBalanceSheetSection[] {
return reportStructure
.map((structure: IBalanceSheetStructureSection) =>
this.balanceSheetStructureMapper(structure, balanceSheetAccounts)
)
// Filter the structure sections that have no children.
.filter((structure: IBalanceSheetSection) =>
structure.children.length > 0 || structure._forceShow
);
}
/**
* Retrieve date range columns of the given query.
* @param {IBalanceSheetQuery} query
* @return {string[]}
*/
private dateRangeColumns(): string[] {
return this.dateRangeSet;
}
/**
* Retrieve balance sheet columns in different display columns types.
* @return {string[]}
*/
public reportColumns(): string[] {
// Date range collection.
return this.query.displayColumnsType === 'date_periods'
? this.dateRangeColumns()
: ['total'];
}
/**
* Retrieve balance sheet statement data.
* @return {IBalanceSheetSection[]}
*/
public reportData(): IBalanceSheetSection[] {
return this.balanceSheetStructureWalker(
BalanceSheetStructure,
this.accounts,
)
}
}

View File

@@ -0,0 +1,103 @@
import { Service, Inject } from 'typedi';
import moment from 'moment';
import {
IBalanceSheetStatementService,
IBalanceSheetQuery,
IBalanceSheetStatement,
} from 'interfaces';
import TenancyService from 'services/Tenancy/TenancyService';
import Journal from 'services/Accounting/JournalPoster';
import BalanceSheetStatement from './BalanceSheet';
@Service()
export default class BalanceSheetStatementService
implements IBalanceSheetStatementService {
@Inject()
tenancy: TenancyService;
@Inject('logger')
logger: any;
/**
* Defaults balance sheet filter query.
* @return {IBalanceSheetQuery}
*/
get defaultQuery(): IBalanceSheetQuery {
return {
displayColumnsType: 'total',
displayColumnsBy: 'day',
fromDate: moment().startOf('year').format('YYYY-MM-DD'),
toDate: moment().endOf('year').format('YYYY-MM-DD'),
numberFormat: {
noCents: false,
divideOn1000: false,
},
noneZero: false,
noneTransactions: false,
basis: 'cash',
accountIds: [],
};
}
/**
* Retrieve balance sheet statement.
* -------------
* @param {number} tenantId
* @param {IBalanceSheetQuery} query
*
* @return {IBalanceSheetStatement}
*/
public async balanceSheet(
tenantId: number,
query: IBalanceSheetQuery
): Promise<IBalanceSheetStatement> {
const {
accountRepository,
transactionsRepository,
} = this.tenancy.repositories(tenantId);
// Settings tenant service.
const settings = this.tenancy.settings(tenantId);
const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' });
const filter = {
...this.defaultQuery,
...query,
};
this.logger.info('[balance_sheet] trying to calculate the report.', { filter, tenantId });
// Retrieve all accounts on the storage.
const accounts = await accountRepository.allAccounts('type');
const accountsGraph = await accountRepository.getDependencyGraph();
// Retrieve all journal transactions based on the given query.
const transactions = await transactionsRepository.journal({
fromDate: query.toDate,
});
// Transform transactions to journal collection.
const transactionsJournal = Journal.fromTransactions(
transactions,
tenantId,
accountsGraph,
);
// Balance sheet report instance.
const balanceSheetInstanace = new BalanceSheetStatement(
tenantId,
filter,
accounts,
transactionsJournal,
baseCurrency
);
// Balance sheet data.
const balanceSheetData = balanceSheetInstanace.reportData();
// Retrieve balance sheet columns.
const balanceSheetColumns = balanceSheetInstanace.reportColumns();
return {
data: balanceSheetData,
columns: balanceSheetColumns,
query: filter,
};
}
}

View File

@@ -0,0 +1,16 @@
import {
formatNumber
} from 'utils';
export default class FinancialSheet {
numberFormat: { noCents: boolean, divideOn1000: boolean };
/**
* Formating amount based on the given report query.
* @param {number} number
* @return {string}
*/
protected formatNumber(number): string {
return formatNumber(number, this.numberFormat);
}
}

View File

@@ -0,0 +1,150 @@
import { pick } from 'lodash';
import {
IGeneralLedgerSheetQuery,
IGeneralLedgerSheetAccount,
IGeneralLedgerSheetAccountBalance,
IGeneralLedgerSheetAccountTransaction,
IAccount,
IJournalPoster,
IAccountType,
IJournalEntry
} from 'interfaces';
import FinancialSheet from "../FinancialSheet";
export default class GeneralLedgerSheet extends FinancialSheet {
tenantId: number;
accounts: IAccount[];
query: IGeneralLedgerSheetQuery;
openingBalancesJournal: IJournalPoster;
closingBalancesJournal: IJournalPoster;
transactions: IJournalPoster;
baseCurrency: string;
/**
* Constructor method.
* @param {number} tenantId -
* @param {IAccount[]} accounts -
* @param {IJournalPoster} transactions -
* @param {IJournalPoster} openingBalancesJournal -
* @param {IJournalPoster} closingBalancesJournal -
*/
constructor(
tenantId: number,
query: IGeneralLedgerSheetQuery,
accounts: IAccount[],
transactions: IJournalPoster,
openingBalancesJournal: IJournalPoster,
closingBalancesJournal: IJournalPoster,
baseCurrency: string,
) {
super();
this.tenantId = tenantId;
this.query = query;
this.numberFormat = this.query.numberFormat;
this.accounts = accounts;
this.transactions = transactions;
this.openingBalancesJournal = openingBalancesJournal;
this.closingBalancesJournal = closingBalancesJournal;
this.baseCurrency = baseCurrency;
}
/**
* Mapping the account transactions to general ledger transactions of the given account.
* @param {IAccount} account
* @return {IGeneralLedgerSheetAccountTransaction[]}
*/
private accountTransactionsMapper(
account: IAccount & { type: IAccountType }
): IGeneralLedgerSheetAccountTransaction[] {
const entries = this.transactions.getAccountEntries(account.id);
return entries.map((transaction: IJournalEntry): IGeneralLedgerSheetAccountTransaction => {
let amount = 0;
if (account.type.normal === 'credit') {
amount += transaction.credit - transaction.debit;
} else if (account.type.normal === 'debit') {
amount += transaction.debit - transaction.credit;
}
const formattedAmount = this.formatNumber(amount);
return {
...pick(transaction, ['id', 'note', 'transactionType', 'referenceType',
'referenceId', 'date']),
amount,
formattedAmount,
currencyCode: this.baseCurrency,
};
});
}
/**
* Retrieve account opening balance.
* @param {IAccount} account
* @return {IGeneralLedgerSheetAccountBalance}
*/
private accountOpeningBalance(account: IAccount): IGeneralLedgerSheetAccountBalance {
const amount = this.openingBalancesJournal.getAccountBalance(account.id);
const formattedAmount = this.formatNumber(amount);
const currencyCode = this.baseCurrency;
const date = this.query.fromDate;
return { amount, formattedAmount, currencyCode, date };
}
/**
* Retrieve account closing balance.
* @param {IAccount} account
* @return {IGeneralLedgerSheetAccountBalance}
*/
private accountClosingBalance(account: IAccount): IGeneralLedgerSheetAccountBalance {
const amount = this.closingBalancesJournal.getAccountBalance(account.id);
const formattedAmount = this.formatNumber(amount);
const currencyCode = this.baseCurrency;
const date = this.query.toDate;
return { amount, formattedAmount, currencyCode, date };
}
/**
* Retreive general ledger accounts sections.
* @param {IAccount} account
* @return {IGeneralLedgerSheetAccount}
*/
private accountMapper(
account: IAccount & { type: IAccountType },
): IGeneralLedgerSheetAccount {
return {
...pick(account, ['id', 'name', 'code', 'index', 'parentAccountId']),
opening: this.accountOpeningBalance(account),
transactions: this.accountTransactionsMapper(account),
closing: this.accountClosingBalance(account),
}
}
/**
* Retrieve mapped accounts with general ledger transactions and opeing/closing balance.
* @param {IAccount[]} accounts -
* @return {IGeneralLedgerSheetAccount[]}
*/
private accountsWalker(
accounts: IAccount & { type: IAccountType }[]
): IGeneralLedgerSheetAccount[] {
return accounts
.map((account: IAccount & { type: IAccountType }) => this.accountMapper(account))
// Filter general ledger accounts that have no transactions when `noneTransactions` is on.
.filter((generalLedgerAccount: IGeneralLedgerSheetAccount) => (
!(generalLedgerAccount.transactions.length === 0 && this.query.noneTransactions)
));
}
/**
* Retrieve general ledger report data.
* @return {IGeneralLedgerSheetAccount[]}
*/
public reportData(): IGeneralLedgerSheetAccount[] {
return this.accountsWalker(this.accounts);
}
}

View File

@@ -0,0 +1,125 @@
import { Service, Inject } from "typedi";
import moment from 'moment';
import { ServiceError } from "exceptions";
import { difference } from 'lodash';
import { IGeneralLedgerSheetQuery } from 'interfaces';
import TenancyService from 'services/Tenancy/TenancyService';
import Journal from "services/Accounting/JournalPoster";
import GeneralLedgerSheet from 'services/FinancialStatements/GeneralLedger/GeneralLedger';
const ERRORS = {
ACCOUNTS_NOT_FOUND: 'ACCOUNTS_NOT_FOUND',
};
@Service()
export default class GeneralLedgerService {
@Inject()
tenancy: TenancyService;
@Inject('logger')
logger: any;
/**
* Defaults general ledger report filter query.
* @return {IBalanceSheetQuery}
*/
get defaultQuery() {
return {
fromDate: moment().startOf('year').format('YYYY-MM-DD'),
toDate: moment().endOf('year').format('YYYY-MM-DD'),
basis: 'cash',
numberFormat: {
noCents: false,
divideOn1000: false,
},
noneZero: false,
accountsIds: [],
};
}
/**
* Validates accounts existance on the storage.
* @param {number} tenantId
* @param {number[]} accountsIds
*/
async validateAccountsExistance(tenantId: number, accountsIds: number[]) {
const { Account } = this.tenancy.models(tenantId);
const storedAccounts = await Account.query().whereIn('id', accountsIds);
const storedAccountsIds = storedAccounts.map((a) => a.id);
if (difference(accountsIds, storedAccountsIds).length > 0) {
throw new ServiceError(ERRORS.ACCOUNTS_NOT_FOUND)
}
}
/**
* Retrieve general ledger report statement.
* ----------
* @param {number} tenantId
* @param {IGeneralLedgerSheetQuery} query
* @return {IGeneralLedgerStatement}
*/
async generalLedger(tenantId: number, query: IGeneralLedgerSheetQuery):
Promise<{
data: any,
query: IGeneralLedgerSheetQuery,
}> {
const {
accountRepository,
transactionsRepository,
} = this.tenancy.repositories(tenantId);
const filter = {
...this.defaultQuery,
...query,
};
this.logger.info('[general_ledger] trying to calculate the report.', { tenantId, filter })
const settings = this.tenancy.settings(tenantId);
const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' });
// Retrieve all accounts from the storage.
const accounts = await accountRepository.allAccounts('type');
const accountsGraph = await accountRepository.getDependencyGraph();
// Retreive journal transactions from/to the given date.
const transactions = await transactionsRepository.journal({
fromDate: filter.fromDate,
toDate: filter.toDate,
});
// Retreive opening balance credit/debit sumation.
const openingBalanceTrans = await transactionsRepository.journal({
toDate: filter.fromDate,
sumationCreditDebit: true,
});
// Retreive closing balance credit/debit sumation.
const closingBalanceTrans = await transactionsRepository.journal({
toDate: filter.toDate,
sumationCreditDebit: true,
});
// Transform array transactions to journal collection.
const transactionsJournal = Journal.fromTransactions(transactions, tenantId, accountsGraph);
const openingTransJournal = Journal.fromTransactions(openingBalanceTrans, tenantId, accountsGraph);
const closingTransJournal = Journal.fromTransactions(closingBalanceTrans, tenantId, accountsGraph);
// General ledger report instance.
const generalLedgerInstance = new GeneralLedgerSheet(
tenantId,
filter,
accounts,
transactionsJournal,
openingTransJournal,
closingTransJournal,
baseCurrency
);
// Retrieve general ledger report data.
const reportData = generalLedgerInstance.reportData();
return {
data: reportData,
query: filter,
}
}
}

View File

@@ -0,0 +1,88 @@
import { sumBy, chain } from 'lodash';
import {
IJournalEntry,
IJournalPoster,
IJournalReportEntriesGroup,
IJournalReportQuery,
IJournalReport
} from "interfaces";
import FinancialSheet from "../FinancialSheet";
export default class JournalSheet extends FinancialSheet {
tenantId: number;
journal: IJournalPoster;
query: IJournalReportQuery;
baseCurrency: string;
/**
* Constructor method.
* @param {number} tenantId
* @param {IJournalPoster} journal
*/
constructor(
tenantId: number,
query: IJournalReportQuery,
journal: IJournalPoster,
baseCurrency: string,
) {
super();
this.tenantId = tenantId;
this.journal = journal;
this.query = query;
this.numberFormat = this.query.numberFormat;
this.baseCurrency = baseCurrency;
}
/**
* Mapping journal entries groups.
* @param {IJournalEntry[]} entriesGroup -
* @param {string} key -
* @return {IJournalReportEntriesGroup}
*/
entriesGroupMapper(
entriesGroup: IJournalEntry[],
key: string,
): IJournalReportEntriesGroup {
const totalCredit = sumBy(entriesGroup, 'credit');
const totalDebit = sumBy(entriesGroup, 'debit');
return {
id: key,
entries: entriesGroup,
currencyCode: this.baseCurrency,
credit: totalCredit,
debit: totalDebit,
formattedCredit: this.formatNumber(totalCredit),
formattedDebit: this.formatNumber(totalDebit),
};
}
/**
* Mapping the journal entries to entries groups.
* @param {IJournalEntry[]} entries
* @return {IJournalReportEntriesGroup[]}
*/
entriesWalker(entries: IJournalEntry[]): IJournalReportEntriesGroup[] {
return chain(entries)
.groupBy((entry) => `${entry.referenceId}-${entry.referenceType}`)
.map((
entriesGroup: IJournalEntry[],
key: string
) => this.entriesGroupMapper(entriesGroup, key))
.value();
}
/**
* Retrieve journal report.
* @return {IJournalReport}
*/
reportData(): IJournalReport {
return {
entries: this.entriesWalker(this.journal.entries),
};
}
}

View File

@@ -0,0 +1,85 @@
import { Service, Inject } from "typedi";
import { IJournalReportQuery } from 'interfaces';
import moment from 'moment';
import JournalSheet from "./JournalSheet";
import TenancyService from "services/Tenancy/TenancyService";
import Journal from "services/Accounting/JournalPoster";
@Service()
export default class JournalSheetService {
@Inject()
tenancy: TenancyService;
@Inject('logger')
logger: any;
/**
* Default journal sheet filter queyr.
*/
get defaultQuery() {
return {
fromDate: moment().startOf('year').format('YYYY-MM-DD'),
toDate: moment().endOf('year').format('YYYY-MM-DD'),
fromRange: null,
toRange: null,
accountsIds: [],
transactionTypes: [],
numberFormat: {
noCents: false,
divideOn1000: false,
},
};
}
/**
* Journal sheet.
* @param {number} tenantId
* @param {IJournalSheetFilterQuery} query
*/
async journalSheet(
tenantId: number,
query: IJournalReportQuery,
) {
const {
accountRepository,
transactionsRepository,
} = this.tenancy.repositories(tenantId);
const filter = {
...this.defaultQuery,
...query,
};
this.logger.info('[journal] trying to calculate the report.', { tenantId, filter });
// Settings service.
const settings = this.tenancy.settings(tenantId);
const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' });
// Retrieve all accounts on the storage.
const accountsGraph = await accountRepository.getDependencyGraph();
// Retrieve all journal transactions based on the given query.
const transactions = await transactionsRepository.journal({
fromDate: filter.fromDate,
toDate: filter.toDate,
transactionsTypes: filter.transactionTypes,
fromAmount: filter.fromRange,
toAmount: filter.toRange,
});
// Transform the transactions array to journal collection.
const transactionsJournal = Journal.fromTransactions(transactions, tenantId, accountsGraph);
// Journal report instance.
const journalSheetInstance = new JournalSheet(
tenantId,
filter,
transactionsJournal,
baseCurrency
);
// Retrieve journal report columns.
const journalSheetData = journalSheetInstance.reportData();
return journalSheetData;
}
}

View File

@@ -0,0 +1,369 @@
import { flatten, pick, sumBy } from 'lodash';
import { IProfitLossSheetQuery } from "interfaces/ProfitLossSheet";
import FinancialSheet from "../FinancialSheet";
import {
IAccount,
IAccountType,
IJournalPoster,
IProfitLossSheetAccount,
IProfitLossSheetTotal,
IProfitLossSheetStatement,
IProfitLossSheetAccountsSection,
IProfitLossSheetTotalSection,
} from 'interfaces';
import { flatToNestedArray, dateRangeCollection } from 'utils';
export default class ProfitLossSheet extends FinancialSheet {
tenantId: number;
query: IProfitLossSheetQuery;
accounts: IAccount & { type: IAccountType }[];
journal: IJournalPoster;
dateRangeSet: string[];
comparatorDateType: string;
baseCurrency: string;
/**
* Constructor method.
* @param {number} tenantId -
* @param {IProfitLossSheetQuery} query -
* @param {IAccount[]} accounts -
* @param {IJournalPoster} transactionsJournal -
*/
constructor(
tenantId: number,
query: IProfitLossSheetQuery,
accounts: IAccount & { type: IAccountType }[],
journal: IJournalPoster,
baseCurrency: string,
) {
super();
this.tenantId = tenantId;
this.query = query;
this.numberFormat = this.query.numberFormat;
this.accounts = accounts;
this.journal = journal;
this.baseCurrency = baseCurrency;
this.comparatorDateType = query.displayColumnsType === 'total'
? 'day'
: query.displayColumnsBy;
this.initDateRangeCollection();
}
/**
* Filtering income accounts.
* @return {IAccount & { type: IAccountType }[]}
*/
get incomeAccounts() {
return this.accounts.filter(a => a.type.key === 'income');
}
/**
* Filtering expenses accounts.
* @return {IAccount & { type: IAccountType }[]}
*/
get expensesAccounts() {
return this.accounts.filter(a => a.type.key === 'expense');
}
/**
* Filter other expenses accounts.
* @return {IAccount & { type: IAccountType }[]}}
*/
get otherExpensesAccounts() {
return this.accounts.filter(a => a.type.key === 'other_expense');
}
/**
* Filtering cost of sales accounts.
* @return {IAccount & { type: IAccountType }[]}
*/
get costOfSalesAccounts() {
return this.accounts.filter(a => a.type.key === 'cost_of_goods_sold');
}
/**
* Initialize date range set.
*/
initDateRangeCollection() {
if (this.query.displayColumnsType === 'date_periods') {
this.dateRangeSet = dateRangeCollection(
this.query.fromDate,
this.query.toDate,
this.comparatorDateType
);
}
}
/**
* Retrieve account total in the query date.
* @param {IAccount} account -
* @return {IProfitLossSheetTotal}
*/
private getAccountTotal(account: IAccount): IProfitLossSheetTotal {
const amount = this.journal.getAccountBalance(
account.id,
this.query.toDate,
this.comparatorDateType,
);
const formattedAmount = this.formatNumber(amount);
const currencyCode = this.baseCurrency;
return { amount, formattedAmount, currencyCode };
}
/**
* Retrieve account total periods.
* @param {IAccount} account -
* @return {IProfitLossSheetTotal[]}
*/
private getAccountTotalPeriods(account: IAccount): IProfitLossSheetTotal[] {
return this.dateRangeSet.map((date) => {
const amount = this.journal.getAccountBalance(
account.id,
date,
this.comparatorDateType,
);
const formattedAmount = this.formatNumber(amount);
const currencyCode = this.baseCurrency;
return { date, amount, formattedAmount, currencyCode };
})
}
/**
* Mapping the given account to total result with account metadata.
* @param {IAccount} account -
* @return {IProfitLossSheetAccount}
*/
private accountMapper(account: IAccount): IProfitLossSheetAccount {
const entries = this.journal.getAccountEntries(account.id);
return {
...pick(account, ['id', 'index', 'name', 'code', 'parentAccountId']),
hasTransactions: entries.length > 0,
total: this.getAccountTotal(account),
// Date periods when display columns type `periods`.
...(this.query.displayColumnsType === 'date_periods' && {
totalPeriods: this.getAccountTotalPeriods(account),
}),
};
}
/**
*
* @param {IAccount[]} accounts -
* @return {IProfitLossSheetAccount[]}
*/
private accountsWalker(accounts: IAccount & { type: IAccountType }[]): IProfitLossSheetAccount[] {
const flattenAccounts = accounts
.map(this.accountMapper.bind(this))
// Filter accounts that have no transaction when `noneTransactions` is on.
.filter((account: IProfitLossSheetAccount) =>
!(!account.hasTransactions && this.query.noneTransactions),
)
// Filter accounts that have zero total amount when `noneZero` is on.
.filter((account: IProfitLossSheetAccount) =>
!(account.total.amount === 0 && this.query.noneZero)
);
return flatToNestedArray(
flattenAccounts,
{ id: 'id', parentId: 'parentAccountId' },
);
}
/**
* Retreive the report total section.
* @param {IAccount[]} accounts -
* @return {IProfitLossSheetTotal}
*/
private gatTotalSection(accounts: IProfitLossSheetAccount[]): IProfitLossSheetTotal {
const amount = sumBy(accounts, 'total.amount');
const formattedAmount = this.formatNumber(amount);
const currencyCode = this.baseCurrency;
return { amount, formattedAmount, currencyCode };
}
/**
* Retrieve the report total section in periods display type.
* @param {IAccount} accounts -
* @return {IProfitLossSheetTotal[]}
*/
private getTotalPeriodsSection(accounts: IProfitLossSheetAccount[]): IProfitLossSheetTotal[] {
return this.dateRangeSet.map((date, index) => {
const amount = sumBy(accounts, `totalPeriods[${index}].amount`);
const formattedAmount = this.formatNumber(amount);
const currencyCode = this.baseCurrency;
return { amount, formattedAmount, currencyCode };
});
}
sectionMapper(sectionAccounts) {
const accounts = this.accountsWalker(sectionAccounts);
const total = this.gatTotalSection(accounts);
return {
accounts,
total,
...(this.query.displayColumnsType === 'date_periods' && {
totalPeriods: this.getTotalPeriodsSection(accounts),
}),
}
}
/**
* Retrieve income section.
* @return {IProfitLossSheetIncomeSection}
*/
private get incomeSection(): IProfitLossSheetAccountsSection {
return {
sectionTitle: 'Income accounts',
entryNormal: 'credit',
...this.sectionMapper(this.incomeAccounts),
};
}
/**
* Retreive expenses section.
* @return {IProfitLossSheetLossSection}
*/
private get expensesSection(): IProfitLossSheetAccountsSection {
return {
sectionTitle: 'Expense accounts',
entryNormal: 'debit',
...this.sectionMapper(this.expensesAccounts),
};
}
/**
* Retrieve other expenses section.
* @return {IProfitLossSheetAccountsSection}
*/
private get otherExpensesSection(): IProfitLossSheetAccountsSection {
return {
sectionTitle: 'Other expenses accounts',
entryNormal: 'debit',
...this.sectionMapper(this.otherExpensesAccounts),
};
}
/**
* Cost of sales section.
* @return {IProfitLossSheetAccountsSection}
*/
private get costOfSalesSection(): IProfitLossSheetAccountsSection {
return {
sectionTitle: 'Cost of sales',
entryNormal: 'debit',
...this.sectionMapper(this.costOfSalesAccounts),
};
}
private getSummarySectionDatePeriods(
positiveSections: IProfitLossSheetTotalSection[],
minesSections: IProfitLossSheetTotalSection[],
) {
return this.dateRangeSet.map((date, index: number) => {
const totalPositive = sumBy(positiveSections, `totalPeriods[${index}].amount`);
const totalMines = sumBy(minesSections, `totalPeriods[${index}].amount`);
const amount = totalPositive - totalMines;
const formattedAmount = this.formatNumber(amount);
const currencyCode = this.baseCurrency;
return { date, amount, formattedAmount, currencyCode };
});
};
private getSummarySectionTotal(
positiveSections: IProfitLossSheetTotalSection[],
minesSections: IProfitLossSheetTotalSection[],
) {
const totalPositiveSections = sumBy(positiveSections, 'total.amount');
const totalMinesSections = sumBy(minesSections, 'total.amount');
const amount = totalPositiveSections - totalMinesSections;
const formattedAmount = this.formatNumber(amount);
const currencyCode = this.baseCurrency;
return { amount, formattedAmount, currencyCode };
}
/**
* Retrieve the summary section
* @param
*/
private getSummarySection(
sections: IProfitLossSheetTotalSection|IProfitLossSheetTotalSection[],
subtractSections: IProfitLossSheetTotalSection|IProfitLossSheetTotalSection[]
): IProfitLossSheetTotalSection {
const positiveSections = Array.isArray(sections) ? sections : [sections];
const minesSections = Array.isArray(subtractSections) ? subtractSections : [subtractSections];
return {
total: this.getSummarySectionTotal(positiveSections, minesSections),
...(this.query.displayColumnsType === 'date_periods' && {
totalPeriods: [
...this.getSummarySectionDatePeriods(
positiveSections,
minesSections,
),
],
}),
}
}
/**
* Retrieve date range columns of the given query.
* @param {IBalanceSheetQuery} query
* @return {string[]}
*/
private dateRangeColumns(): string[] {
return this.dateRangeSet;
}
/**
* Retrieve profit/loss report data.
* @return {IProfitLossSheetStatement}
*/
public reportData(): IProfitLossSheetStatement {
const income = this.incomeSection;
const costOfSales = this.costOfSalesSection;
const expenses = this.expensesSection;
const otherExpenses = this.otherExpensesSection;
// - Gross profit = Total income - COGS.
const grossProfit = this.getSummarySection(income, costOfSales);
// - Operating profit = Gross profit - Expenses.
const operatingProfit = this.getSummarySection(grossProfit, [expenses, costOfSales]);
// - Net income = Operating profit - Other expenses.
const netIncome = this.getSummarySection(operatingProfit, otherExpenses);
return {
income,
costOfSales,
grossProfit,
expenses,
otherExpenses,
netIncome,
operatingProfit,
};
}
/**
* Retrieve profit/loss report columns.
*/
public reportColumns() {
// Date range collection.
return this.query.displayColumnsType === 'date_periods'
? this.dateRangeColumns()
: ['total'];
}
}

View File

@@ -0,0 +1,98 @@
import { Service, Inject } from 'typedi';
import moment from 'moment';
import Journal from 'services/Accounting/JournalPoster';
import { IProfitLossSheetQuery } from 'interfaces';
import ProfitLossSheet from './ProfitLossSheet';
import TenancyService from 'services/Tenancy/TenancyService';
import AccountsService from 'services/Accounts/AccountsService';
// Profit/Loss sheet service.
@Service()
export default class ProfitLossSheetService {
@Inject()
tenancy: TenancyService;
@Inject('logger')
logger: any;
@Inject()
accountsService: AccountsService;
/**
* Default sheet filter query.
* @return {IBalanceSheetQuery}
*/
get defaultQuery(): IProfitLossSheetQuery {
return {
fromDate: moment().startOf('year').format('YYYY-MM-DD'),
toDate: moment().endOf('year').format('YYYY-MM-DD'),
numberFormat: {
noCents: false,
divideOn1000: false,
},
basis: 'accural',
noneZero: false,
noneTransactions: false,
displayColumnsType: 'total',
displayColumnsBy: 'month',
accountsIds: [],
};
}
/**
* Retrieve profit/loss sheet statement.
* @param {number} tenantId
* @param {IProfitLossSheetQuery} query
* @return { }
*/
async profitLossSheet(tenantId: number, query: IProfitLossSheetQuery) {
const {
accountRepository,
transactionsRepository,
} = this.tenancy.repositories(tenantId);
const filter = {
...this.defaultQuery,
...query,
};
this.logger.info('[profit_loss_sheet] trying to calculate the report.', { tenantId, filter });
// Get the given accounts or throw not found service error.
if (filter.accountsIds.length > 0) {
await this.accountsService.getAccountsOrThrowError(tenantId, filter.accountsIds);
}
// Settings tenant service.
const settings = this.tenancy.settings(tenantId);
const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' });
// Retrieve all accounts on the storage.
const accounts = await accountRepository.allAccounts('type');
const accountsGraph = await accountRepository.getDependencyGraph();
// Retrieve all journal transactions based on the given query.
const transactions = await transactionsRepository.journal({
fromDate: query.fromDate,
toDate: query.toDate,
});
// Transform transactions to journal collection.
const transactionsJournal = Journal.fromTransactions(transactions, tenantId, accountsGraph);
// Profit/Loss report instance.
const profitLossInstance = new ProfitLossSheet(
tenantId,
filter,
accounts,
transactionsJournal,
baseCurrency
);
// Profit/loss report data and collumns.
const profitLossData = profitLossInstance.reportData();
const profitLossColumns = profitLossInstance.reportColumns();
return {
data: profitLossData,
columns: profitLossColumns,
query: filter,
};
}
}

View File

@@ -0,0 +1,101 @@
import {
ITrialBalanceSheetQuery,
ITrialBalanceAccount,
IAccount,
IAccountType,
} from 'interfaces';
import FinancialSheet from '../FinancialSheet';
import { flatToNestedArray } from 'utils';
export default class TrialBalanceSheet extends FinancialSheet{
tenantId: number;
query: ITrialBalanceSheetQuery;
accounts: IAccount & { type: IAccountType }[];
journalFinancial: any;
/**
* Constructor method.
* @param {number} tenantId
* @param {ITrialBalanceSheetQuery} query
* @param {IAccount[]} accounts
* @param journalFinancial
*/
constructor(
tenantId: number,
query: ITrialBalanceSheetQuery,
accounts: IAccount & { type: IAccountType }[],
journalFinancial: any
) {
super();
this.tenantId = tenantId;
this.query = query;
this.numberFormat = this.query.numberFormat;
this.accounts = accounts;
this.journalFinancial = journalFinancial;
this.numberFormat = this.query.numberFormat;
}
/**
* Account mapper.
* @param {IAccount} account
*/
private accountMapper(account: IAccount & { type: IAccountType }): ITrialBalanceAccount {
const trial = this.journalFinancial.getTrialBalanceWithDepands(account.id);
// Retrieve all entries that associated to the given account.
const entries = this.journalFinancial.getAccountEntries(account.id)
return {
id: account.id,
parentAccountId: account.parentAccountId,
name: account.name,
code: account.code,
accountNormal: account.type.normal,
hasTransactions: entries.length > 0,
credit: trial.credit,
debit: trial.debit,
balance: trial.balance,
formattedCredit: this.formatNumber(trial.credit),
formattedDebit: this.formatNumber(trial.debit),
formattedBalance: this.formatNumber(trial.balance),
};
}
/**
* Accounts walker.
* @param {IAccount[]} accounts
*/
private accountsWalker(
accounts: IAccount & { type: IAccountType }[]
): ITrialBalanceAccount[] {
const flattenAccounts = accounts
// Mapping the trial balance accounts sections.
.map((account: IAccount & { type: IAccountType }) => this.accountMapper(account))
// Filter accounts that have no transaction when `noneTransactions` is on.
.filter((trialAccount: ITrialBalanceAccount): boolean =>
!(!trialAccount.hasTransactions && this.query.noneTransactions),
)
// Filter accounts that have zero total amount when `noneZero` is on.
.filter(
(trialAccount: ITrialBalanceAccount): boolean =>
!(trialAccount.credit === 0 && trialAccount.debit === 0 && this.query.noneZero)
);
return flatToNestedArray(
flattenAccounts,
{ id: 'id', parentId: 'parentAccountId' },
);
}
/**
* Retrieve trial balance sheet statement data.
*/
public reportData() {
return this.accountsWalker(this.accounts);
}
}

View File

@@ -0,0 +1,88 @@
import { Service, Inject } from "typedi";
import moment from 'moment';
import TenancyService from 'services/Tenancy/TenancyService';
import { ITrialBalanceSheetQuery, ITrialBalanceStatement } from 'interfaces';
import TrialBalanceSheet from "./TrialBalanceSheet";
import Journal from 'services/Accounting/JournalPoster';
@Service()
export default class TrialBalanceSheetService {
@Inject()
tenancy: TenancyService;
@Inject('logger')
logger: any;
/**
* Defaults trial balance sheet filter query.
* @return {IBalanceSheetQuery}
*/
get defaultQuery(): ITrialBalanceSheetQuery {
return {
fromDate: moment().startOf('year').format('YYYY-MM-DD'),
toDate: moment().endOf('year').format('YYYY-MM-DD'),
numberFormat: {
noCents: false,
divideOn1000: false,
},
basis: 'accural',
noneZero: false,
noneTransactions: false,
accountIds: [],
};
}
/**
* Retrieve trial balance sheet statement.
* -------------
* @param {number} tenantId
* @param {IBalanceSheetQuery} query
*
* @return {IBalanceSheetStatement}
*/
public async trialBalanceSheet(
tenantId: number,
query: ITrialBalanceSheetQuery,
): Promise<ITrialBalanceStatement> {
const filter = {
...this.defaultQuery,
...query,
};
const {
accountRepository,
transactionsRepository,
} = this.tenancy.repositories(tenantId);
this.logger.info('[trial_balance_sheet] trying to calcualte the report.', { tenantId, filter });
// Retrieve all accounts on the storage.
const accounts = await accountRepository.allAccounts('type');
const accountsGraph = await accountRepository.getDependencyGraph();
// Retrieve all journal transactions based on the given query.
const transactions = await transactionsRepository.journal({
fromDate: query.fromDate,
toDate: query.toDate,
sumationCreditDebit: true,
});
// Transform transactions array to journal collection.
const transactionsJournal = Journal.fromTransactions(transactions, tenantId, accountsGraph);
// Trial balance report instance.
const trialBalanceInstance = new TrialBalanceSheet(
tenantId,
filter,
accounts,
transactionsJournal,
);
// Trial balance sheet data.
const trialBalanceSheetData = trialBalanceInstance.reportData();
return {
data: trialBalanceSheetData,
query: filter,
}
}
}

View File

@@ -0,0 +1,13 @@
export const formatNumber = (balance, { noCents, divideOn1000 }): string => {
let formattedBalance: number = parseFloat(balance);
if (noCents) {
formattedBalance = parseInt(formattedBalance, 10);
}
if (divideOn1000) {
formattedBalance /= 1000;
}
return formattedBalance;
};

View File

@@ -356,7 +356,7 @@ export default class ManualJournalsService implements IManualJournalsService {
// Triggers `onManualJournalCreated` event.
this.eventDispatcher.dispatch(events.manualJournals.onCreated, {
tenantId,
manualJournal,
manualJournal: { ...manualJournal, entries: manualJournalObj.entries },
});
this.logger.info(
'[manual_journal] the manual journal inserted successfully.',

View File

@@ -81,7 +81,7 @@ export default class HasTenancyService {
setI18nLocals(tenantId: number, locals: any) {
return this.singletonService(tenantId, 'i18n', () => {
return locals;
})
});
}
/**

View File

@@ -211,6 +211,18 @@ const convertEmptyStringToNull = (value) => {
: value;
};
const formatNumber = (balance, { noCents = false, divideOn1000 = false }) => {
let formattedBalance = parseFloat(balance);
if (noCents) {
formattedBalance = parseInt(formattedBalance, 10);
}
if (divideOn1000) {
formattedBalance /= 1000;
}
return formattedBalance + '';
};
export {
hashPassword,
origin,
@@ -229,4 +241,5 @@ export {
getDefinedOptions,
entriesAmountDiff,
convertEmptyStringToNull,
formatNumber
};