feat: Receivable and payable aging summary financial statement.

This commit is contained in:
Ahmed Bouhuolia
2020-06-11 22:05:34 +02:00
parent 55a4319827
commit 4d1dd14f8d
36 changed files with 1435 additions and 195 deletions

View File

@@ -33,6 +33,8 @@ export default {
asyncMiddleware(this.manualJournals.handler));
router.post('/make-journal-entries',
this.validateMediaIds,
this.validateContactEntries,
this.makeJournalEntries.validation,
asyncMiddleware(this.makeJournalEntries.handler));
@@ -41,6 +43,8 @@ export default {
asyncMiddleware(this.publishManualJournal.handler));
router.post('/manual-journals/:id',
this.validateMediaIds,
this.validateContactEntries,
this.editManualJournal.validation,
asyncMiddleware(this.editManualJournal.handler));
@@ -168,6 +172,114 @@ export default {
},
},
/**
* Validate media ids.
* @param {Request} req -
* @param {Response} res -
* @param {Function} next -
*/
async validateMediaIds(req, res, next) {
const form = { media_ids: [], ...req.body };
const { Media } = req.models;
const errorReasons = [];
// Validate if media ids was not already exists on the storage.
if (form.media_ids.length > 0) {
const storedMedia = await Media.query().whereIn('id', form.media_ids);
const notFoundMedia = difference(form.media_ids, storedMedia.map((m) => m.id));
if (notFoundMedia.length > 0) {
errorReasons.push({ type: 'MEDIA.IDS.NOT.FOUND', code: 400, ids: notFoundMedia });
}
}
req.errorReasons = Array.isArray(req.errorReasons) && req.errorReasons.length
? req.errorReasons.push(...errorReasons) : errorReasons;
next();
},
/**
* Validate form entries with contact customers and vendors.
* @param {Request} req
* @param {Response} res
* @param {Function} next
*/
async validateContactEntries(req, res, next) {
const form = { entries: [], ...req.body };
const { AccountType, Vendor, Customer } = req.models;
const errorReasons = [];
// Validate the entries contact type and ids.
const customersContacts = form.entries.filter(e => e.contact_type === 'customer');
const vendorsContacts = form.entries.filter(e => e.contact_type === 'vendor');
const accountsTypes = await AccountType.query();
const payableAccountsType = accountsTypes.find(t => t.key === 'accounts_payable');;
const receivableAccountsType = accountsTypes.find(t => t.key === 'accounts_receivable');
// Validate customers contacts.
if (customersContacts.length > 0) {
const customersContactsIds = customersContacts.map(c => c.contact_id);
const storedContacts = await Customer.query().whereIn('id', customersContactsIds);
const storedContactsIds = storedContacts.map(c => c.id);
const formEntriesCustomersIds = form.entries.filter(e => e.contact_type === 'customer');
const notFoundContactsIds = difference(
formEntriesCustomersIds.map(c => c.contact_id),
storedContactsIds,
);
if (notFoundContactsIds.length > 0) {
errorReasons.push({ type: 'CUSTOMERS.CONTACTS.NOT.FOUND', code: 500, ids: notFoundContactsIds });
}
const notReceivableAccounts = formEntriesCustomersIds.filter(
c => receivableAccountsType && c.contact_id !== receivableAccountsType.id);
if (notReceivableAccounts.length > 0) {
errorReasons.push({
type: 'CUSTOMERS.ACCOUNTS.NOT.RECEIVABLE.TYPE',
code: 700,
indexes: notReceivableAccounts.map(a => a.index),
});
}
}
// Validate vendors contacts.
if (vendorsContacts.length > 0) {
const vendorsContactsIds = vendorsContacts.map(c => c.contact_id);
const storedContacts = await Vendor.query().where('id', vendorsContactsIds);
const storedContactsIds = storedContacts.map(c => c.id);
const formEntriesVendorsIds = form.entries.filter(e => e.contact_type === 'vendor');
const notFoundContactsIds = difference(
formEntriesVendorsIds.map(v => v.contact_id),
storedContactsIds,
);
if (notFoundContactsIds.length > 0) {
errorReasons.push({
type: 'VENDORS.CONTACTS.NOT.FOUND', code: 600, ids: notFoundContactsIds,
});
}
const notPayableAccounts = formEntriesVendorsIds.filter(
v => payableAccountsType && v.contact_id === payableAccountsType.id
);
if (notPayableAccounts.length > 0) {
errorReasons.push({
type: 'VENDORS.ACCOUNTS.NOT.PAYABLE.TYPE',
code: 800,
indexes: notPayableAccounts.map(a => a.index),
});
}
}
req.errorReasons = Array.isArray(req.errorReasons) && req.errorReasons.length
? req.errorReasons.push(...errorReasons) : errorReasons;
next();
},
/**
* Make journal entrires.
*/
@@ -180,10 +292,13 @@ export default {
check('description').optional().trim().escape(),
check('status').optional().isBoolean().toBoolean(),
check('entries').isArray({ min: 2 }),
check('entries.*.index').exists().isNumeric().toInt(),
check('entries.*.credit').optional({ nullable: true }).isNumeric().toInt(),
check('entries.*.debit').optional({ nullable: true }).isNumeric().toInt(),
check('entries.*.account_id').isNumeric().toInt(),
check('entries.*.note').optional(),
check('entries.*.contact_id').optional().isNumeric().toInt(),
check('entries.*.contact_type').optional().isIn(['vendor', 'customer']),
check('media_ids').optional().isArray(),
check('media_ids.*').exists().isNumeric().toInt(),
],
@@ -202,13 +317,17 @@ export default {
media_ids: [],
...req.body,
};
const { ManualJournal, Account, Media, MediaLink } = req.models;
const {
ManualJournal,
Account,
MediaLink,
} = req.models;
let totalCredit = 0;
let totalDebit = 0;
const { user } = req;
const errorReasons = [];
const errorReasons = [...(req.errorReasons || [])];
const entries = form.entries.filter((entry) => (entry.credit || entry.debit));
const formattedDate = moment(form.date).format('YYYY-MM-DD');
@@ -229,23 +348,18 @@ export default {
if (totalCredit !== totalDebit) {
errorReasons.push({ type: 'CREDIT.DEBIT.NOT.EQUALS', code: 100 });
}
const accountsIds = entries.map((entry) => entry.account_id);
const formEntriesAccountsIds = entries.map((entry) => entry.account_id);
const formEntriesContactsIds = entries.map((entry) => entry.contact_id);
const accounts = await Account.query()
.whereIn('id', accountsIds)
.whereIn('id', formEntriesAccountsIds)
.withGraphFetched('type')
.remember();
const storedAccountsIds = accounts.map((account) => account.id);
if (form.media_ids.length > 0) {
const storedMedia = await Media.query().whereIn('id', form.media_ids);
const notFoundMedia = difference(form.media_ids, storedMedia.map((m) => m.id));
if (notFoundMedia.length > 0) {
errorReasons.push({ type: 'MEDIA.IDS.NOT.FOUND', code: 400, ids: notFoundMedia });
}
}
if (difference(accountsIds, storedAccountsIds).length > 0) {
if (difference(formEntriesAccountsIds, storedAccountsIds).length > 0) {
errorReasons.push({ type: 'ACCOUNTS.IDS.NOT.FOUND', code: 200 });
}
@@ -255,10 +369,11 @@ export default {
if (journalNumber.length > 0) {
errorReasons.push({ type: 'JOURNAL.NUMBER.ALREADY.EXISTS', code: 300 });
}
if (errorReasons.length > 0) {
return res.status(400).send({ errors: errorReasons });
}
// Save manual journal transaction.
// Save manual journal tansaction.
const manualJournal = await ManualJournal.query().insert({
reference: form.reference,
transaction_type: 'Journal',
@@ -282,6 +397,8 @@ export default {
referenceType: 'Journal',
referenceId: manualJournal.id,
accountNormal: account.type.normal,
contactType: entry.contact_type,
contactId: entry.contact_id,
note: entry.note,
date: formattedDate,
userId: user.id,
@@ -356,6 +473,8 @@ export default {
check('entries.*.credit').optional({ nullable: true }).isNumeric().toInt(),
check('entries.*.debit').optional({ nullable: true }).isNumeric().toInt(),
check('entries.*.account_id').isNumeric().toInt(),
check('entries.*.contact_id').optional().isNumeric().toInt(),
check('entries.*.contact_type').optional().isIn(['vendor', 'customer']).isNumeric().toInt(),
check('entries.*.note').optional(),
check('media_ids').optional().isArray(),
check('media_ids.*').isNumeric().toInt(),
@@ -393,7 +512,7 @@ export default {
let totalDebit = 0;
const { user } = req;
const errorReasons = [];
const errorReasons = [...(req.errorReasons || [])];
const entries = form.entries.filter((entry) => (entry.credit || entry.debit));
const formattedDate = moment(form.date).format('YYYY-MM-DD');
@@ -431,16 +550,6 @@ export default {
if (difference(accountsIds, storedAccountsIds).length > 0) {
errorReasons.push({ type: 'ACCOUNTS.IDS.NOT.FOUND', code: 200 });
}
// Validate if media ids was not already exists on the storage.
if (form.media_ids.length > 0) {
const storedMedia = await Media.query().whereIn('id', form.media_ids);
const notFoundMedia = difference(form.media_ids, storedMedia.map((m) => m.id));
if (notFoundMedia.length > 0) {
errorReasons.push({ type: 'MEDIA.IDS.NOT.FOUND', code: 400, ids: notFoundMedia });
}
}
if (errorReasons.length > 0) {
return res.status(400).send({ errors: errorReasons });
}

View File

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

View File

@@ -5,6 +5,8 @@ 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 {
/**
@@ -18,6 +20,8 @@ export default {
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,106 @@
import moment from 'moment';
import { validationResult } from 'express-validator';
import { omit, reverse } from 'lodash';
import BaseController from '@/http/controllers/BaseController';
export default class AgingReport extends BaseController{
/**
* Express validator middleware.
* @param {Request} req
* @param {Response} res
* @param {Function} next
*/
static validateResults(req, res, next) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
next();
}
/**
*
* @param {Array} agingPeriods
* @param {Numeric} customerBalance
*/
static contactAgingBalance(agingPeriods, receivableTotalCredit) {
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
*/
static agingRangePeriods(asDay, agingDaysBefore, agingPeriodsFreq) {
const totalAgingDays = agingDaysBefore * agingPeriodsFreq;
const startAging = moment(asDay).startOf('day');
const endAging = startAging.clone().subtract('days', totalAgingDays).endOf('day');
const agingPeriods = [];
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({
from_period: moment(currentAging).toDate(),
to_period: moment(startingAging).toDate(),
before_days: beforeDays === 1 ? 0 : beforeDays,
to_days: toDays,
...(startingAging.valueOf() === endAging.valueOf()) ? {
to_period: null,
to_days: null,
} : {},
});
beforeDays += agingDaysBefore;
}
return agingPeriods;
}
/**
*
* @param {*} filter
*/
static formatNumberClosure(filter) {
return (balance) => {
let formattedBalance = parseFloat(balance);
if (filter.no_cents) {
formattedBalance = parseInt(formattedBalance, 10);
}
if (filter.divide_1000) {
formattedBalance /= 1000;
}
return formattedBalance;
};
}
}

View File

@@ -0,0 +1,188 @@
import express from 'express';
import { query } from 'express-validator';
import { difference } from 'lodash';
import JournalPoster from '@/services/Accounting/JournalPoster';
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import AgingReport from '@/http/controllers/FinancialStatements/AgingReport';
import moment from 'moment';
export default class PayableAgingSummary extends AgingReport {
/**
* Router constructor.
*/
static router() {
const router = express.Router();
router.get(
'/',
this.payableAgingSummaryRoles(),
this.validateResults,
asyncMiddleware(this.validateVendorsIds.bind(this)),
asyncMiddleware(this.payableAgingSummary.bind(this))
);
return router;
}
/**
* Validates the report vendors ids query.
*/
static async validateVendorsIds(req, res, next) {
const { Vendor } = req.models;
const filter = {
vendors_ids: [],
...req.query,
};
if (!Array.isArray(filter.vendors_ids)) {
filter.vendors_ids = [filter.vendors_ids];
}
if (filter.vendors_ids.length > 0) {
const storedCustomers = await Vendor.query().whereIn(
'id',
filter.vendors_ids
);
const storedCustomersIds = storedCustomers.map((c) => c.id);
const notStoredCustomersIds = difference(
storedCustomersIds,
filter,
vendors_ids
);
if (notStoredCustomersIds.length) {
return res.status(400).send({
errors: [{ type: 'VENDORS.IDS.NOT.FOUND', code: 300 }],
});
}
}
next();
}
/**
* Receivable aging summary validation roles.
*/
static payableAgingSummaryRoles() {
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(),
query('vendors_ids.*').isNumeric().toInt(),
query('none_zero').optional().isBoolean().toBoolean(),
];
}
/**
* Retrieve payable aging summary report.
*/
static async payableAgingSummary(req, res) {
const { Customer, Account, AccountTransaction, AccountType } = req.models;
const storedVendors = await Customer.query();
const filter = {
as_date: moment().format('YYYY-MM-DD'),
aging_days_before: 30,
aging_periods: 3,
number_format: {
no_cents: false,
divide_1000: false,
},
...req.query,
};
const accountsReceivableType = await AccountType.query()
.where('key', 'accounts_payable')
.first();
const accountsReceivable = await Account.query()
.where('account_type_id', accountsReceivableType.id)
.remember()
.first();
const transactions = await AccountTransaction.query()
.modify('filterDateRange', null, filter.as_date)
.where('account_id', accountsReceivable.id)
.remember();
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 = formatNumberClosure(filter.number_format);
const vendors = storedVendors.map((vendor) => {
// Calculate the trial balance total of the given vendor.
const vendorBalance = journalPoster.getContactTrialBalance(
accountsReceivable.id,
vendor.id,
'vendor'
);
const agingClosingBalance = agingPeriods.map((agingPeriod) => {
// Calculate the trial balance between the given date period.
const agingTrialBalance = journalPoster.getContactTrialBalance(
accountsReceivable.id,
vendor.id,
'vendor',
agingPeriod.from_period
);
return {
...agingPeriod,
closingBalance: agingTrialBalance.debit,
};
});
const aging = this.contactAgingBalance(
agingClosingBalance,
vendorBalance.credit
);
return {
vendor_name: vendor.displayName,
aging: aging.map((item) => ({
...item,
formatted_total: totalFormatter(item.total),
})),
total: vendorBalance.balance,
formatted_total: totalFormatted(vendorBalance.balance),
};
});
const agingClosingBalance = agingPeriods.map((agingPeriod) => {
const closingTrialBalance = journalPoster.getContactTrialBalance(
accountsReceivable.id,
null,
'vendor',
agingPeriod.from_period
);
return {
...agingPeriod,
closingBalance: closingTrialBalance.balance,
};
});
const totalClosingBalance = journalPoster.getContactTrialBalance(
accountsReceivable.id,
null,
'vendor'
);
const agingTotal = this.contactAgingBalance(
agingClosingBalance,
totalClosingBalance.credit
);
return res.status(200).send({
columns: [ ...agingPeriods ],
aging: {
vendors,
total: [
...agingTotal.map((item) => ({
...item,
formatted_total: totalFormatter(item.total),
})),
],
},
});
}
}

View File

@@ -0,0 +1,218 @@
import express from 'express';
import { query, oneOf } from 'express-validator';
import { difference } from 'lodash';
import JournalPoster from '@/services/Accounting/JournalPoster';
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import AgingReport from '@/http/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;
console.log(req.query);
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: [],
...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) {
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),
})),
],
},
});
}
}