From 22b2fd5918db880308b1fd30094bf6abf2f7bcdf Mon Sep 17 00:00:00 2001 From: "a.bouhuolia" Date: Thu, 7 Jan 2021 13:48:45 +0200 Subject: [PATCH] draft: AR and AP aging summary report. --- .../api/controllers/FinancialStatements.ts | 8 +- .../FinancialStatements/APAgingSummary.ts | 76 +++++++ .../FinancialStatements/ARAgingSummary.ts | 21 +- .../FinancialStatements/AgingReport.js | 106 --------- .../InventoryValuationSummary.js | 17 -- .../PayableAgingSummary.js | 187 ---------------- server/src/interfaces/APAgingSummaryReport.ts | 29 +++ server/src/interfaces/ARAgingSummaryReport.ts | 56 ++--- server/src/interfaces/AgingReport.ts | 12 + .../AgingSummary/APAgingSummaryService.ts | 75 ++++++- .../AgingSummary/APAgingSummarySheet.ts | 93 +++++++- .../AgingSummary/ARAgingSummaryService.ts | 52 ++--- .../AgingSummary/ARAgingSummarySheet.ts | 157 +++++-------- .../AgingSummary/AgingReport.ts | 54 +++++ .../AgingSummary/AgingSummary.ts | 207 ++++++++++++------ .../BalanceSheet/BalanceSheet.ts | 14 +- .../GeneralLedger/GeneralLedgerService.ts | 65 ++++-- 17 files changed, 639 insertions(+), 590 deletions(-) create mode 100644 server/src/api/controllers/FinancialStatements/APAgingSummary.ts delete mode 100644 server/src/api/controllers/FinancialStatements/AgingReport.js delete mode 100644 server/src/api/controllers/FinancialStatements/InventoryValuationSummary.js delete mode 100644 server/src/api/controllers/FinancialStatements/PayableAgingSummary.js create mode 100644 server/src/interfaces/APAgingSummaryReport.ts create mode 100644 server/src/interfaces/AgingReport.ts create mode 100644 server/src/services/FinancialStatements/AgingSummary/AgingReport.ts diff --git a/server/src/api/controllers/FinancialStatements.ts b/server/src/api/controllers/FinancialStatements.ts index f4e4650bb..eddad6615 100644 --- a/server/src/api/controllers/FinancialStatements.ts +++ b/server/src/api/controllers/FinancialStatements.ts @@ -6,8 +6,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/ARAgingSummary'; -// import PayableAgingSummary from './FinancialStatements/PayableAgingSummary'; +import ARAgingSummary from './FinancialStatements/ARAgingSummary'; +import APAgingSummary from './FinancialStatements/APAgingSummary'; @Service() export default class FinancialStatementsService { @@ -22,8 +22,8 @@ export default class FinancialStatementsService { 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()); + router.use('/receivable_aging_summary', Container.get(ARAgingSummary).router()); + router.use('/payable_aging_summary', Container.get(APAgingSummary).router()); return router; } diff --git a/server/src/api/controllers/FinancialStatements/APAgingSummary.ts b/server/src/api/controllers/FinancialStatements/APAgingSummary.ts new file mode 100644 index 000000000..a64f136fc --- /dev/null +++ b/server/src/api/controllers/FinancialStatements/APAgingSummary.ts @@ -0,0 +1,76 @@ +import { Router, Request, Response } from 'express'; +import { query } from 'express-validator'; +import { Inject } from 'typedi'; +import BaseController from 'api/controllers/BaseController'; +import asyncMiddleware from 'api/middleware/asyncMiddleware'; +import APAgingSummaryReportService from 'services/FinancialStatements/AgingSummary/APAgingSummaryService'; +import { findPhoneNumbersInText } from 'libphonenumber-js'; + +export default class APAgingSummaryReportController extends BaseController { + @Inject() + APAgingSummaryService: APAgingSummaryReportService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.get( + '/', + this.validationSchema, + asyncMiddleware(this.payableAgingSummary.bind(this)) + ); + return router; + } + + /** + * Validation schema. + */ + 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(), + query('vendors_ids.*').isNumeric().toInt(), + query('none_zero').optional().isBoolean().toBoolean(), + ]; + } + + /** + * Retrieve payable aging summary report. + */ + async payableAgingSummary(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.APAgingSummaryService.APAgingSummary(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); + } + } +} diff --git a/server/src/api/controllers/FinancialStatements/ARAgingSummary.ts b/server/src/api/controllers/FinancialStatements/ARAgingSummary.ts index d79bf8b68..d61352b77 100644 --- a/server/src/api/controllers/FinancialStatements/ARAgingSummary.ts +++ b/server/src/api/controllers/FinancialStatements/ARAgingSummary.ts @@ -30,8 +30,8 @@ export default class ARAgingSummaryReportController extends BaseController { get validationSchema() { return [ query('as_date').optional().isISO8601(), - query('aging_days_before').optional().isNumeric().toInt(), - query('aging_periods').optional().isNumeric().toInt(), + query('aging_days_before').optional().isInt({ max: 500 }).toInt(), + query('aging_periods').optional().isInt({ max: 12 }).toInt(), query('number_format.no_cents').optional().isBoolean().toBoolean(), query('number_format.1000_divide').optional().isBoolean().toBoolean(), oneOf( @@ -49,18 +49,31 @@ export default class ARAgingSummaryReportController extends BaseController { * Retrieve receivable aging summary report. */ async receivableAgingSummary(req: Request, res: Response) { - const { tenantId } = req; + 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 + columns, + query, } = await this.ARAgingSummaryService.ARAgingSummary(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); diff --git a/server/src/api/controllers/FinancialStatements/AgingReport.js b/server/src/api/controllers/FinancialStatements/AgingReport.js deleted file mode 100644 index 41cb37eab..000000000 --- a/server/src/api/controllers/FinancialStatements/AgingReport.js +++ /dev/null @@ -1,106 +0,0 @@ -import moment from 'moment'; -import { validationResult } from 'express-validator'; -import { omit, reverse } from 'lodash'; -import BaseController from 'api/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; - }; - } -} \ No newline at end of file diff --git a/server/src/api/controllers/FinancialStatements/InventoryValuationSummary.js b/server/src/api/controllers/FinancialStatements/InventoryValuationSummary.js deleted file mode 100644 index b75f71ae3..000000000 --- a/server/src/api/controllers/FinancialStatements/InventoryValuationSummary.js +++ /dev/null @@ -1,17 +0,0 @@ - - -export default class InventoryValuationSummary { - - static router() { - const router = express.Router(); - - router.get('/inventory_valuation_summary', - asyncMiddleware(this.inventoryValuationSummary), - ); - return router; - } - - static inventoryValuationSummary(req, res) { - - } -} \ No newline at end of file diff --git a/server/src/api/controllers/FinancialStatements/PayableAgingSummary.js b/server/src/api/controllers/FinancialStatements/PayableAgingSummary.js deleted file mode 100644 index be5142b93..000000000 --- a/server/src/api/controllers/FinancialStatements/PayableAgingSummary.js +++ /dev/null @@ -1,187 +0,0 @@ -import express from 'express'; -import { query } 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 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), - })), - ], - }, - }); - } -} diff --git a/server/src/interfaces/APAgingSummaryReport.ts b/server/src/interfaces/APAgingSummaryReport.ts new file mode 100644 index 000000000..1becb1d22 --- /dev/null +++ b/server/src/interfaces/APAgingSummaryReport.ts @@ -0,0 +1,29 @@ +import { + IAgingPeriod, + IAgingPeriodTotal +} from './AgingReport'; + +export interface IAPAgingSummaryQuery { + asDate: Date | string; + agingDaysBefore: number; + agingPeriods: number; + numberFormat: { + noCents: boolean; + divideOn1000: boolean; + }; + vendorsIds: number[]; + noneZero: boolean; +} + +export interface IAPAgingSummaryVendor { + vendorName: string, + aging: (IAgingPeriod & IAgingPeriodTotal)[], + total: IAgingPeriodTotal, +} + +export interface IAPAgingSummaryData { + vendors: IAPAgingSummaryVendor[], + total: (IAgingPeriod & IAgingPeriodTotal)[], +} + +export type IAPAgingSummaryColumns = IAgingPeriod[]; \ No newline at end of file diff --git a/server/src/interfaces/ARAgingSummaryReport.ts b/server/src/interfaces/ARAgingSummaryReport.ts index 3cadb1f19..4648391c9 100644 --- a/server/src/interfaces/ARAgingSummaryReport.ts +++ b/server/src/interfaces/ARAgingSummaryReport.ts @@ -1,45 +1,29 @@ - - +import { + IAgingPeriod, + IAgingPeriodTotal +} from './AgingReport'; export interface IARAgingSummaryQuery { - asDate: Date | string, - agingDaysBefore: number, - agingPeriods: number, + asDate: Date | string; + agingDaysBefore: number; + agingPeriods: number; numberFormat: { - noCents: number, - divideOn1000: number, - }, - customersIds: number[], - noneZero: boolean, + 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 IARAgingSummaryCustomer { + customerName: string; + aging: (IAgingPeriodTotal & IAgingPeriod)[]; + total: IAgingPeriodTotal; } -export interface ARAgingSummaryCustomerTotal { - amount: number, - formattedAmount: string, - currencyCode: string, +export interface IARAgingSummaryData { + customers: IARAgingSummaryCustomer[], + total: (IAgingPeriodTotal & IAgingPeriod)[] } -export interface ARAgingSummaryCustomer { - customerName: string, - aging: IAgingPeriodTotal[], - total: ARAgingSummaryCustomerTotal, -}; +export type IARAgingSummaryColumns = IAgingPeriod[]; \ No newline at end of file diff --git a/server/src/interfaces/AgingReport.ts b/server/src/interfaces/AgingReport.ts new file mode 100644 index 000000000..ca8d4bc01 --- /dev/null +++ b/server/src/interfaces/AgingReport.ts @@ -0,0 +1,12 @@ +export interface IAgingPeriodTotal { + total: number; + formattedTotal: string; + currencyCode: string; +} + +export interface IAgingPeriod { + fromPeriod: Date|string; + toPeriod: Date|string; + beforeDays: number; + toDays: number; +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryService.ts b/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryService.ts index 992fc2323..44852d1cc 100644 --- a/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryService.ts +++ b/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryService.ts @@ -1,6 +1,75 @@ +import moment from 'moment'; +import { Inject, Service } from 'typedi'; +import TenancyService from 'services/Tenancy/TenancyService'; +import APAgingSummarySheet from './APAgingSummarySheet'; - - +@Service() export default class PayableAgingSummaryService { - + @Inject() + tenancy: TenancyService; + + @Inject('logger') + logger: any; + + /** + * Default report query. + */ + get defaultQuery() { + return { + asDate: moment().format('YYYY-MM-DD'), + agingDaysBefore: 30, + agingPeriods: 3, + numberFormat: { + noCents: false, + divideOn1000: false, + }, + vendorsIds: [], + noneZero: false, + } + } + + /** + * + * @param {number} tenantId + * @param query + */ + async APAgingSummary(tenantId: number, query) { + const { + vendorRepository, + } = this.tenancy.repositories(tenantId); + const { Bill } = this.tenancy.models(tenantId); + + const filter = { + ...this.defaultQuery, + ...query, + }; + this.logger.info('[AR_Aging_Summary] trying to prepairing the report.', { + tenantId, filter, + }); + // Settings tenant service. + const settings = this.tenancy.settings(tenantId); + const baseCurrency = settings.get({ + group: 'organization', + key: 'base_currency', + }); + // Retrieve all vendors from the storage. + const vendors = await vendorRepository.all(); + + // Retrieve all unpaid vendors bills. + const unpaidBills = await Bill.query().modify('unpaid'); + + // A/P aging summary report instance. + const APAgingSummaryReport = new APAgingSummarySheet( + tenantId, + filter, + vendors, + unpaidBills, + baseCurrency, + ); + // A/P aging summary report data and columns. + const data = APAgingSummaryReport.reportData(); + const columns = APAgingSummaryReport.reportColumns(); + + return { data, columns, query: filter }; + } } \ No newline at end of file diff --git a/server/src/services/FinancialStatements/AgingSummary/APAgingSummarySheet.ts b/server/src/services/FinancialStatements/AgingSummary/APAgingSummarySheet.ts index da413b72e..49472c32f 100644 --- a/server/src/services/FinancialStatements/AgingSummary/APAgingSummarySheet.ts +++ b/server/src/services/FinancialStatements/AgingSummary/APAgingSummarySheet.ts @@ -1,12 +1,97 @@ -import FinancialSheet from "../FinancialSheet"; +import { groupBy, sumBy } from 'lodash'; +import AgingSummaryReport from './AgingSummary'; +import { + IAPAgingSummaryQuery, + IAgingPeriod, + IBill, + IVendor, + IAPAgingSummaryData, + IAPAgingSummaryVendor, + IAPAgingSummaryColumns +} from 'interfaces'; +import { Dictionary } from 'tsyringe/dist/typings/types'; +export default class APAgingSummarySheet extends AgingSummaryReport { + readonly tenantId: number; + readonly query: IAPAgingSummaryQuery; + readonly contacts: IVendor[]; + readonly unpaidBills: IBill[]; + readonly baseCurrency: string; + readonly unpaidInvoicesByContactId: Dictionary; + readonly agingPeriods: IAgingPeriod[]; + constructor( + tenantId: number, + query: IAPAgingSummaryQuery, + vendors: IVendor[], + unpaidBills: IBill[], + baseCurrency: string + ) { + super(); -export default class APAgingSummarySheet extends FinancialSheet { + this.tenantId = tenantId; + this.query = query; + this.numberFormat = this.query.numberFormat; + this.contacts = vendors; + this.unpaidBills = unpaidBills; + this.baseCurrency = baseCurrency; - + this.unpaidInvoicesByContactId = groupBy(unpaidBills, 'vendorId'); - reportData() { + // Initializes the aging periods. + this.agingPeriods = this.agingRangePeriods( + this.query.asDate, + this.query.agingDaysBefore, + this.query.agingPeriods + ); + this.initContactsAgingPeriods(); + this.calcUnpaidInvoicesAgingPeriods(); + } + /** + * Retrieve the vendor section data. + * @param {IVendor} vendor + * @return {IAPAgingSummaryData} + */ + protected vendorData(vendor: IVendor): IAPAgingSummaryVendor { + const agingPeriods = this.getContactAgingPeriods(vendor.id); + const amount = sumBy(agingPeriods, 'total'); + + return { + vendorName: vendor.displayName, + aging: agingPeriods, + total: this.formatTotalAmount(amount), + }; + } + + /** + * Retrieve vendors aging periods. + * @return {IAPAgingSummaryVendor[]} + */ + private vendorsWalker(): IAPAgingSummaryVendor[] { + return this.contacts + .map((vendor) => this.vendorData(vendor)) + .filter( + (vendor: IAPAgingSummaryVendor) => + !(vendor.total.total === 0 && this.query.noneZero) + ); + } + + /** + * Retrieve the A/P aging summary report data. + * @return {IAPAgingSummaryData} + */ + public reportData(): IAPAgingSummaryData { + return { + vendors: this.vendorsWalker(), + total: this.getTotalAgingPeriods(), + } + } + + /** + * Retrieve the A/P aging summary report columns. + */ + reportColumns(): IAPAgingSummaryColumns { + return this.agingPeriods; } } \ No newline at end of file diff --git a/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryService.ts b/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryService.ts index 24dbd74ce..3bdc3c231 100644 --- a/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryService.ts +++ b/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryService.ts @@ -2,7 +2,6 @@ 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() @@ -31,63 +30,48 @@ export default class ARAgingSummaryService { } /** - * Retreive th accounts receivable aging summary data and columns. - * @param {number} tenantId - * @param query + * + * @param {number} tenantId + * @param query */ async ARAgingSummary(tenantId: number, query: IARAgingSummaryQuery) { const { customerRepository, - accountRepository, - transactionsRepository, - accountTypeRepository + saleInvoiceRepository } = 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 }); - + 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(); - + const baseCurrency = settings.get({ + group: 'organization', + key: 'base_currency', + }); // Retrieve all customers from the storage. const customers = await customerRepository.all(); - // Retrieve AR account type. - const ARType = await accountTypeRepository.getByKey('accounts_receivable'); + // Retrieve all due sale invoices. + const dueSaleInvoices = await saleInvoiceRepository.dueInvoices(); - // 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. + // AR aging summary report instance. const ARAgingSummaryReport = new ARAgingSummarySheet( tenantId, filter, customers, - journal, - ARAccount, + dueSaleInvoices, baseCurrency ); // AR aging summary report data and columns. const data = ARAgingSummaryReport.reportData(); const columns = ARAgingSummaryReport.reportColumns(); - return { data, columns }; + return { data, columns, query: filter }; } -} \ No newline at end of file +} diff --git a/server/src/services/FinancialStatements/AgingSummary/ARAgingSummarySheet.ts b/server/src/services/FinancialStatements/AgingSummary/ARAgingSummarySheet.ts index 988032c7c..2302b6788 100644 --- a/server/src/services/FinancialStatements/AgingSummary/ARAgingSummarySheet.ts +++ b/server/src/services/FinancialStatements/AgingSummary/ARAgingSummarySheet.ts @@ -1,154 +1,107 @@ +import { groupBy, sumBy, defaultTo } from 'lodash'; import { ICustomer, IARAgingSummaryQuery, - ARAgingSummaryCustomer, - IAgingPeriodClosingBalance, + IARAgingSummaryCustomer, IAgingPeriodTotal, - IJournalPoster, - IAccount, - IAgingPeriod -} from "interfaces"; + IAgingPeriod, + ISaleInvoice, + IARAgingSummaryData, + IARAgingSummaryColumns, +} from 'interfaces'; import AgingSummaryReport from './AgingSummary'; - +import { Dictionary } from 'tsyringe/dist/typings/types'; export default class ARAgingSummarySheet extends AgingSummaryReport { - tenantId: number; - query: IARAgingSummaryQuery; - customers: ICustomer[]; - journal: IJournalPoster; - ARAccount: IAccount; - agingPeriods: IAgingPeriod[]; - baseCurrency: string; + readonly tenantId: number; + readonly query: IARAgingSummaryQuery; + readonly contacts: ICustomer[]; + readonly agingPeriods: IAgingPeriod[]; + readonly baseCurrency: string; + readonly dueInvoices: ISaleInvoice[]; + readonly unpaidInvoicesByContactId: Dictionary; /** * Constructor method. - * @param {number} tenantId - * @param {IARAgingSummaryQuery} query - * @param {ICustomer[]} customers - * @param {IJournalPoster} journal + * @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, + unpaidSaleInvoices: ISaleInvoice[], + baseCurrency: string ) { super(); this.tenantId = tenantId; - this.customers = customers; + this.contacts = customers; this.query = query; - this.numberFormat = this.query.numberFormat; - this.journal = journal; - this.ARAccount = ARAccount; this.baseCurrency = baseCurrency; + this.numberFormat = this.query.numberFormat; + this.unpaidInvoicesByContactId = groupBy(unpaidSaleInvoices, 'customerId'); + this.dueInvoices = unpaidSaleInvoices; + this.periodsByContactId = {}; - this.initAgingPeriod(); - } - - /** - * Initializes the aging periods. - */ - private initAgingPeriod() { + // Initializes the aging periods. 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; + this.initContactsAgingPeriods(); + this.calcUnpaidInvoicesAgingPeriods(); } /** * Mapping aging customer. * @param {ICustomer} customer - - * @return {ARAgingSummaryCustomer[]} + * @return {IARAgingSummaryCustomer[]} */ - 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; + private customerData(customer: ICustomer): IARAgingSummaryCustomer { + const agingPeriods = this.getContactAgingPeriods(customer.id); + const amount = sumBy(agingPeriods, 'total'); return { customerName: customer.displayName, - aging: this.getCustomerAging(customer, trialBalance.balance), - total: { - amount, - formattedAmount, - currencyCode, - }, + aging: agingPeriods, + total: this.formatTotalAmount(amount), }; } /** - * Retrieve customers walker. - * @param {ICustomer[]} customers - * @return {ARAgingSummaryCustomer[]} + * Retrieve customers report. + * @param {ICustomer[]} customers + * @return {IARAgingSummaryCustomer[]} */ - 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), + private customersWalker(): IARAgingSummaryCustomer[] { + return this.contacts + .map((customer) => this.customerData(customer)) + .filter( + (customer: IARAgingSummaryCustomer) => + !(customer.total.total === 0 && this.query.noneZero) ); } /** - * Retrieve AR. aging summary report data. + * Retrieve A/R aging summary report data. + * @return {IARAgingSummaryData} */ - public reportData() { - return this.customersWalker(this.customers); + public reportData(): IARAgingSummaryData { + return { + customers: this.customersWalker(), + total: this.getTotalAgingPeriods(), + }; } /** * Retrieve AR aging summary report columns. + * @return {IARAgingSummaryColumns} */ - reportColumns() { - return [] + public reportColumns(): IARAgingSummaryColumns { + return this.agingPeriods; } -} \ No newline at end of file +} diff --git a/server/src/services/FinancialStatements/AgingSummary/AgingReport.ts b/server/src/services/FinancialStatements/AgingSummary/AgingReport.ts new file mode 100644 index 000000000..197c4fbff --- /dev/null +++ b/server/src/services/FinancialStatements/AgingSummary/AgingReport.ts @@ -0,0 +1,54 @@ +import moment from 'moment'; +import { + IAgingPeriod, +} from 'interfaces'; +import FinancialSheet from "../FinancialSheet"; + + +export default abstract class AgingReport extends FinancialSheet{ + /** + * Retrieve the aging periods range. + * @param {string} asDay + * @param {number} agingDaysBefore + * @param {number} agingPeriodsFreq + */ + agingRangePeriods( + asDay: string, + agingDaysBefore: number, + agingPeriodsFreq: number + ): IAgingPeriod[] { + const totalAgingDays = agingDaysBefore * agingPeriodsFreq; + const startAging = moment(asDay).startOf('day'); + const endAging = startAging + .clone() + .subtract(totalAgingDays, 'days') + .endOf('day'); + + const agingPeriods: IAgingPeriod[] = []; + const startingAging = startAging.clone(); + + let beforeDays = 1; + let toDays = 0; + + while (startingAging > endAging) { + const currentAging = startingAging.clone(); + startingAging.subtract(agingDaysBefore, 'days').endOf('day'); + toDays += agingDaysBefore; + + agingPeriods.push({ + fromPeriod: moment(currentAging).format('YYYY-MM-DD'), + toPeriod: moment(startingAging).format('YYYY-MM-DD'), + beforeDays: beforeDays === 1 ? 0 : beforeDays, + toDays: toDays, + ...(startingAging.valueOf() === endAging.valueOf() + ? { + toPeriod: null, + toDays: null, + } + : {}), + }); + beforeDays += agingDaysBefore; + } + return agingPeriods; + } +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/AgingSummary/AgingSummary.ts b/server/src/services/FinancialStatements/AgingSummary/AgingSummary.ts index 0f0b7b91b..bc7eba449 100644 --- a/server/src/services/FinancialStatements/AgingSummary/AgingSummary.ts +++ b/server/src/services/FinancialStatements/AgingSummary/AgingSummary.ts @@ -1,75 +1,158 @@ import moment from 'moment'; -import { omit, reverse } from 'lodash'; -import { IAgingPeriod, IAgingPeriodClosingBalance, IAgingPeriodTotal } from 'interfaces'; -import FinancialSheet from '../FinancialSheet'; +import { defaultTo } from 'lodash'; +import { + IAgingPeriod, + ISaleInvoice, + IBill, + IAgingPeriodTotal, + IContact, +} from 'interfaces'; +import AgingReport from './AgingReport'; +import { Dictionary } from 'tsyringe/dist/typings/types'; -export default class AgingSummaryReport extends FinancialSheet{ +export default abstract class AgingSummaryReport extends AgingReport { + protected readonly contacts: IContact[]; + protected readonly agingPeriods: IAgingPeriod[] = []; + protected readonly baseCurrency: string; + protected readonly unpaidInvoices: (ISaleInvoice | IBill)[]; + readonly unpaidInvoicesByContactId: Dictionary< + (ISaleInvoice | IBill)[] + >; + protected periodsByContactId: { + [key: number]: (IAgingPeriod & IAgingPeriodTotal)[]; + } = {}; /** - * - * @param {Array} agingPeriods - * @param {Numeric} customerBalance + * Setes initial aging periods to the given customer id. + * @param {number} customerId - Customer id. */ - 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); + protected setInitialAgingPeriods(contactId: number): void { + this.periodsByContactId[contactId] = this.agingPeriods.map( + (agingPeriod) => ({ + ...agingPeriod, + ...this.formatTotalAmount(0), + }) + ); } /** - * - * @param {*} asDay - * @param {*} agingDaysBefore - * @param {*} agingPeriodsFreq + * Calculates the given contact aging periods. + * @param {ICustomer} customer + * @return {(IAgingPeriod & IAgingPeriodTotal)[]} */ - agingRangePeriods(asDay, agingDaysBefore, agingPeriodsFreq): IAgingPeriod[] { - const totalAgingDays = agingDaysBefore * agingPeriodsFreq; - const startAging = moment(asDay).startOf('day'); - const endAging = startAging.clone().subtract('days', totalAgingDays).endOf('day'); + protected getContactAgingPeriods( + contactId: number + ): (IAgingPeriod & IAgingPeriodTotal)[] { + return defaultTo(this.periodsByContactId[contactId], []); + } - 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; + /** + * Sets the customer aging due amount to the table. + * @param {number} customerId - Customer id. + * @param {number} dueAmount - Due amount. + * @param {number} overdueDays - Overdue days. + */ + protected setContactAgingDueAmount( + customerId: number, + dueAmount: number, + overdueDays: number + ): void { + if (!this.periodsByContactId[customerId]) { + this.setInitialAgingPeriods(customerId); } - return agingPeriods; + const agingPeriods = this.periodsByContactId[customerId]; + + const newAgingPeriods = agingPeriods.map((agingPeriod) => { + const isInAgingPeriod = + agingPeriod.beforeDays < overdueDays && + agingPeriod.toDays > overdueDays; + + return { + ...agingPeriod, + total: isInAgingPeriod + ? agingPeriod.total + dueAmount + : agingPeriod.total, + }; + }); + this.periodsByContactId[customerId] = newAgingPeriods; } -} \ No newline at end of file + /** + * Retrieve the aging period total object. + * @param {number} amount + * @return {IAgingPeriodTotal} + */ + protected formatTotalAmount(amount: number): IAgingPeriodTotal { + return { + total: amount, + formattedTotal: this.formatNumber(amount), + currencyCode: this.baseCurrency, + }; + } + + /** + * Calculates the total of the aging period by the given index. + * @param {number} index + * @return {number} + */ + protected getTotalAgingPeriodByIndex(index: number): number { + return this.contacts.reduce((acc, customer) => { + const periods = this.getContactAgingPeriods(customer.id); + const totalPeriod = periods[index] ? periods[index].total : 0; + + return acc + totalPeriod; + }, 0); + } + + /** + * Sets the initial aging periods to the all customers. + */ + protected initContactsAgingPeriods(): void { + this.contacts.forEach((contact) => { + this.setInitialAgingPeriods(contact.id); + }); + } + + /** + * Retrieve the due invoices by the given customer id. + * @param {number} customerId - + * @return {ISaleInvoice[]} + */ + protected getUnpaidInvoicesByContactId( + contactId: number + ): (ISaleInvoice | IBill)[] { + return defaultTo(this.unpaidInvoicesByContactId[contactId], []); + } + + /** + * Retrieve total aging periods of the report. + * @return {(IAgingPeriodTotal & IAgingPeriod)[]} + */ + protected getTotalAgingPeriods(): (IAgingPeriodTotal & IAgingPeriod)[] { + return this.agingPeriods.map((agingPeriod, index) => { + const total = this.getTotalAgingPeriodByIndex(index); + + return { + ...agingPeriod, + ...this.formatTotalAmount(total), + }; + }); + } + + /** + * Sets customers invoices to aging periods. + */ + protected calcUnpaidInvoicesAgingPeriods(): void { + this.contacts.forEach((contact) => { + const unpaidInvoices = this.getUnpaidInvoicesByContactId(contact.id); + + unpaidInvoices.forEach((unpaidInvoice) => { + this.setContactAgingDueAmount( + contact.id, + unpaidInvoice.dueAmount, + unpaidInvoice.overdueDays + ); + }); + }); + } +} diff --git a/server/src/services/FinancialStatements/BalanceSheet/BalanceSheet.ts b/server/src/services/FinancialStatements/BalanceSheet/BalanceSheet.ts index f87405be2..c1ebcc21f 100644 --- a/server/src/services/FinancialStatements/BalanceSheet/BalanceSheet.ts +++ b/server/src/services/FinancialStatements/BalanceSheet/BalanceSheet.ts @@ -14,13 +14,13 @@ 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; + readonly query: IBalanceSheetQuery; + readonly tenantId: number; + readonly accounts: IAccount & { type: IAccountType }[]; + readonly journalFinancial: IJournalPoster; + readonly comparatorDateType: string; + readonly dateRangeSet: string[]; + readonly baseCurrency: string; /** * Constructor method. diff --git a/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerService.ts b/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerService.ts index 5b74cc921..57a28f2e7 100644 --- a/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerService.ts +++ b/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerService.ts @@ -1,10 +1,10 @@ -import { Service, Inject } from "typedi"; +import { Service, Inject } from 'typedi'; import moment from 'moment'; -import { ServiceError } from "exceptions"; +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 Journal from 'services/Accounting/JournalPoster'; import GeneralLedgerSheet from 'services/FinancialStatements/GeneralLedger/GeneralLedger'; const ERRORS = { @@ -39,8 +39,8 @@ export default class GeneralLedgerService { /** * Validates accounts existance on the storage. - * @param {number} tenantId - * @param {number[]} accountsIds + * @param {number} tenantId + * @param {number[]} accountsIds */ async validateAccountsExistance(tenantId: number, accountsIds: number[]) { const { Account } = this.tenancy.models(tenantId); @@ -49,35 +49,42 @@ export default class GeneralLedgerService { const storedAccountsIds = storedAccounts.map((a) => a.id); if (difference(accountsIds, storedAccountsIds).length > 0) { - throw new ServiceError(ERRORS.ACCOUNTS_NOT_FOUND) + throw new ServiceError(ERRORS.ACCOUNTS_NOT_FOUND); } } /** * Retrieve general ledger report statement. * ---------- - * @param {number} tenantId - * @param {IGeneralLedgerSheetQuery} query + * @param {number} tenantId + * @param {IGeneralLedgerSheetQuery} query * @return {IGeneralLedgerStatement} */ - async generalLedger(tenantId: number, query: IGeneralLedgerSheetQuery): - Promise<{ - data: any, - query: IGeneralLedgerSheetQuery, - }> { + async generalLedger( + tenantId: number, + query: IGeneralLedgerSheetQuery + ): Promise<{ + data: any; + query: IGeneralLedgerSheetQuery; + }> { const { accountRepository, transactionsRepository, } = this.tenancy.repositories(tenantId); + const settings = this.tenancy.settings(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' }); + this.logger.info('[general_ledger] trying to calculate the report.', { + tenantId, + filter, + }); + const baseCurrency = settings.get({ + group: 'organization', + key: 'base_currency', + }); // Retrieve all accounts from the storage. const accounts = await accountRepository.all('type'); @@ -98,12 +105,22 @@ export default class GeneralLedgerService { 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); - + 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, @@ -120,6 +137,6 @@ export default class GeneralLedgerService { return { data: reportData, query: filter, - } + }; } -} \ No newline at end of file +}