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..3ea6d6d22 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( @@ -41,7 +41,7 @@ export default class ARAgingSummaryReportController extends BaseController { ], [query('customer_ids').optional().isNumeric().toInt()] ), - query('none_zero').optional().isBoolean().toBoolean(), + query('none_zero').default(true).isBoolean().toBoolean(), ]; } @@ -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/api/controllers/Sales/SalesInvoices.ts b/server/src/api/controllers/Sales/SalesInvoices.ts index 29c8cc91d..121cc1110 100644 --- a/server/src/api/controllers/Sales/SalesInvoices.ts +++ b/server/src/api/controllers/Sales/SalesInvoices.ts @@ -321,7 +321,6 @@ export default class SaleInvoicesController extends BaseController { tenantId, customerId ); - return res.status(200).send({ sales_invoices: this.transfromToResponse(salesInvoices), }); diff --git a/server/src/interfaces/APAgingSummaryReport.ts b/server/src/interfaces/APAgingSummaryReport.ts new file mode 100644 index 000000000..490403338 --- /dev/null +++ b/server/src/interfaces/APAgingSummaryReport.ts @@ -0,0 +1,35 @@ +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, + current: IAgingPeriodTotal, + aging: (IAgingPeriod & IAgingPeriodTotal)[], + total: IAgingPeriodTotal, +}; + +export interface IAPAgingSummaryTotal { + current: IAgingPeriodTotal, + aging: (IAgingPeriodTotal & IAgingPeriod)[], +}; + +export interface IAPAgingSummaryData { + vendors: IAPAgingSummaryVendor[], + total: IAPAgingSummaryTotal, +}; + +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..a7b3f45dd 100644 --- a/server/src/interfaces/ARAgingSummaryReport.ts +++ b/server/src/interfaces/ARAgingSummaryReport.ts @@ -1,45 +1,34 @@ - - +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: boolean; + divideOn1000: boolean; + }; + 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; + current: IAgingPeriodTotal, + aging: (IAgingPeriodTotal & IAgingPeriod)[]; + total: IAgingPeriodTotal; } -export interface ARAgingSummaryCustomerTotal { - amount: number, - formattedAmount: string, - currencyCode: string, +export interface IARAgingSummaryTotal { + current: IAgingPeriodTotal, + aging: (IAgingPeriodTotal & IAgingPeriod)[], +}; +export interface IARAgingSummaryData { + customers: IARAgingSummaryCustomer[], + total: IARAgingSummaryTotal, } -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/interfaces/Bill.ts b/server/src/interfaces/Bill.ts index dfe2b3acd..7c8c20ffa 100644 --- a/server/src/interfaces/Bill.ts +++ b/server/src/interfaces/Bill.ts @@ -42,6 +42,9 @@ export interface IBill { amount: number, paymentAmount: number, + dueAmount: number, + overdueDays: number, + invLotNumber: string, openedAt: Date | string, diff --git a/server/src/interfaces/SaleInvoice.ts b/server/src/interfaces/SaleInvoice.ts index 554f2cb34..3fd3e5507 100644 --- a/server/src/interfaces/SaleInvoice.ts +++ b/server/src/interfaces/SaleInvoice.ts @@ -7,6 +7,7 @@ export interface ISaleInvoice { invoiceDate: Date, dueDate: Date, dueAmount: number, + overdueDays: number, customerId: number, entries: IItemEntry[], deliveredAt: string | Date, diff --git a/server/src/interfaces/index.ts b/server/src/interfaces/index.ts index 48aecded5..0912db23b 100644 --- a/server/src/interfaces/index.ts +++ b/server/src/interfaces/index.ts @@ -35,5 +35,7 @@ export * from './TrialBalanceSheet'; export * from './GeneralLedgerSheet' export * from './ProfitLossSheet'; export * from './JournalReport'; +export * from './AgingReport'; export * from './ARAgingSummaryReport'; +export * from './APAgingSummaryReport'; export * from './Mailable'; \ No newline at end of file diff --git a/server/src/models/Bill.js b/server/src/models/Bill.js index 40074de3c..44164227e 100644 --- a/server/src/models/Bill.js +++ b/server/src/models/Bill.js @@ -48,6 +48,12 @@ export default class Bill extends TenantModel { overdue(query) { query.where('due_date', '<', moment().format('YYYY-MM-DD')); }, + /** + * Filters the not overdue invoices. + */ + notOverdue(query, asDate = moment().format('YYYY-MM-DD')) { + query.where('due_date', '>=', asDate); + }, /** * Filters the partially paid bills. */ @@ -61,7 +67,13 @@ export default class Bill extends TenantModel { paid(query) { query.where(raw('`PAYMENT_AMOUNT` = `AMOUNT`')); }, - } + /** + * Filters the bills from the given date. + */ + fromDate(query, fromDate) { + query.where('bill_date', '<=', fromDate) + } + }; } /** @@ -71,7 +83,7 @@ export default class Bill extends TenantModel { return ['createdAt', 'updatedAt']; } - /** + /** * Virtual attributes. */ static get virtualAttributes() { @@ -117,7 +129,7 @@ export default class Bill extends TenantModel { */ get isFullyPaid() { return this.dueAmount === 0; - } + } /** * Detarmines whether the bill paid fully or partially. @@ -133,7 +145,9 @@ export default class Bill extends TenantModel { */ get remainingDays() { // Can't continue in case due date not defined. - if (!this.dueDate) { return null; } + if (!this.dueDate) { + return null; + } const date = moment(); const dueDate = moment(this.dueDate); @@ -146,13 +160,7 @@ export default class Bill extends TenantModel { * @return {number|null} */ get overdueDays() { - // Can't continue in case due date not defined. - if (!this.dueDate) { return null; } - - const date = moment(); - const dueDate = moment(this.dueDate); - - return Math.max(date.diff(dueDate, 'days'), 0); + return this.getOverdueDays(); } /** @@ -163,6 +171,17 @@ export default class Bill extends TenantModel { return this.overdueDays > 0; } + getOverdueDays(asDate = moment().format('YYYY-MM-DD')) { + // Can't continue in case due date not defined. + if (!this.dueDate) { + return null; + } + const date = moment(asDate); + const dueDate = moment(this.dueDate); + + return Math.max(date.diff(dueDate, 'days'), 0); + } + /** * Relationship mapping. */ @@ -180,7 +199,7 @@ export default class Bill extends TenantModel { }, filter(query) { query.where('contact_service', 'vendor'); - } + }, }, entries: { @@ -199,26 +218,22 @@ export default class Bill extends TenantModel { /** * Retrieve the not found bills ids as array that associated to the given vendor. - * @param {Array} billsIds - * @param {number} vendorId - + * @param {Array} billsIds + * @param {number} vendorId - * @return {Array} */ static async getNotFoundBills(billsIds, vendorId) { - const storedBills = await this.query() - .onBuild((builder) => { - builder.whereIn('id', billsIds); + const storedBills = await this.query().onBuild((builder) => { + builder.whereIn('id', billsIds); + + if (vendorId) { + builder.where('vendor_id', vendorId); + } + }); - if (vendorId) { - builder.where('vendor_id', vendorId); - } - }); - const storedBillsIds = storedBills.map((t) => t.id); - const notFoundBillsIds = difference( - billsIds, - storedBillsIds, - ); + const notFoundBillsIds = difference(billsIds, storedBillsIds); return notFoundBillsIds; } @@ -263,19 +278,25 @@ export default class Bill extends TenantModel { label: 'Status', options: [], query: (query, role) => { - switch(role.value) { + switch (role.value) { case 'draft': - query.modify('draft'); break; + query.modify('draft'); + break; case 'opened': - query.modify('opened'); break; + query.modify('opened'); + break; case 'unpaid': - query.modify('unpaid'); break; + query.modify('unpaid'); + break; case 'overdue': - query.modify('overdue'); break; + query.modify('overdue'); + break; case 'partially-paid': - query.modify('partiallyPaid'); break; + query.modify('partiallyPaid'); + break; case 'paid': - query.modify('paid'); break; + query.modify('paid'); + break; } }, }, @@ -295,14 +316,12 @@ export default class Bill extends TenantModel { label: 'Note', column: 'note', }, - user: { - - }, + user: {}, created_at: { label: 'Created at', column: 'created_at', columnType: 'date', }, - } + }; } } diff --git a/server/src/models/SaleInvoice.js b/server/src/models/SaleInvoice.js index cf9fb4b3b..1cbea60e2 100644 --- a/server/src/models/SaleInvoice.js +++ b/server/src/models/SaleInvoice.js @@ -103,19 +103,27 @@ export default class SaleInvoice extends TenantModel { * @return {number|null} */ get overdueDays() { - // Can't continue in case due date not defined. - if (!this.dueDate) { return null; } - - const date = moment(); - const dueDate = moment(this.dueDate); - - return Math.max(date.diff(dueDate, 'days'), 0); + return this.getOverdueDays(); } static get resourceable() { return true; } + /** + * + * @param {*} asDate + */ + getOverdueDays(asDate = moment().format('YYYY-MM-DD')) { + // Can't continue in case due date not defined. + if (!this.dueDate) { return null; } + + const date = moment(asDate); + const dueDate = moment(this.dueDate); + + return Math.max(date.diff(dueDate, 'days'), 0); + } + /** * Model modifiers. */ @@ -163,8 +171,14 @@ export default class SaleInvoice extends TenantModel { /** * Filters the overdue invoices. */ - overdue(query) { - query.where('due_date', '<', moment().format('YYYY-MM-DD')); + overdue(query, asDate = moment().format('YYYY-MM-DD')) { + query.where('due_date', '<', asDate); + }, + /** + * Filters the not overdue invoices. + */ + notOverdue(query, asDate = moment().format('YYYY-MM-DD')) { + query.where('due_date', '>=', asDate); }, /** * Filters the partially invoices. @@ -178,6 +192,12 @@ export default class SaleInvoice extends TenantModel { */ paid(query) { query.where(raw('PAYMENT_AMOUNT = BALANCE')); + }, + /** + * Filters the sale invoices from the given date. + */ + fromDate(query, fromDate) { + query.where('invoice_date', '<=', fromDate) } }; } diff --git a/server/src/repositories/BillRepository.ts b/server/src/repositories/BillRepository.ts index fb3b2514d..d8b96fc2e 100644 --- a/server/src/repositories/BillRepository.ts +++ b/server/src/repositories/BillRepository.ts @@ -1,3 +1,4 @@ +import moment from 'moment'; import { Bill } from 'models'; import TenantRepository from 'repositories/TenantRepository'; @@ -8,4 +9,30 @@ export default class BillRepository extends TenantRepository { get model() { return Bill.bindKnex(this.knex); } + + dueBills(asDate = moment().format('YYYY-MM-DD'), withRelations) { + const cacheKey = this.getCacheKey('dueInvoices', asDate, withRelations); + + return this.cache.get(cacheKey, () => { + return this.model + .query() + .modify('dueBills') + .modify('notOverdue') + .modify('fromDate', asDate) + .withGraphFetched(withRelations); + }); + } + + overdueBills(asDate = moment().format('YYYY-MM-DD'), withRelations) { + const cacheKey = this.getCacheKey('overdueInvoices', asDate, withRelations); + + return this.cache.get(cacheKey, () => { + return this.model + .query() + .modify('dueBills') + .modify('overdue', asDate) + .modify('fromDate', asDate) + .withGraphFetched(withRelations); + }) + } } \ No newline at end of file diff --git a/server/src/repositories/SaleInvoiceRepository.ts b/server/src/repositories/SaleInvoiceRepository.ts index f1a0806b2..c5e962d39 100644 --- a/server/src/repositories/SaleInvoiceRepository.ts +++ b/server/src/repositories/SaleInvoiceRepository.ts @@ -1,3 +1,4 @@ +import moment from 'moment'; import { SaleInvoice } from 'models'; import TenantRepository from 'repositories/TenantRepository'; @@ -8,4 +9,30 @@ export default class SaleInvoiceRepository extends TenantRepository { get model() { return SaleInvoice.bindKnex(this.knex); } -} \ No newline at end of file + + dueInvoices(asDate = moment().format('YYYY-MM-DD'), withRelations) { + const cacheKey = this.getCacheKey('dueInvoices', asDate, withRelations); + + return this.cache.get(cacheKey, async () => { + return this.model + .query() + .modify('dueInvoices') + .modify('notOverdue', asDate) + .modify('fromDate', asDate) + .withGraphFetched(withRelations); + }); + } + + overdueInvoices(asDate = moment().format('YYYY-MM-DD'), withRelations) { + const cacheKey = this.getCacheKey('overdueInvoices', asDate, withRelations); + + return this.cache.get(cacheKey, () => { + return this.model + .query() + .modify('dueInvoices') + .modify('overdue', asDate) + .modify('fromDate', asDate) + .withGraphFetched(withRelations); + }); + } +} diff --git a/server/src/services/Accounting/JournalCommands.ts b/server/src/services/Accounting/JournalCommands.ts index 1fb128516..8b5d6e1b8 100644 --- a/server/src/services/Accounting/JournalCommands.ts +++ b/server/src/services/Accounting/JournalCommands.ts @@ -291,8 +291,6 @@ export default class JournalCommands { referenceType: ['SaleInvoice'], index: [3, 4], }); - console.log(transactions); - this.journal.fromTransactions(transactions); this.journal.removeEntries(); } diff --git a/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryService.ts b/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryService.ts index 992fc2323..cdb00ddbb 100644 --- a/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryService.ts +++ b/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryService.ts @@ -1,6 +1,80 @@ +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, + billRepository + } = 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 overdue vendors bills. + const overdueBills = await billRepository.overdueBills( + filter.asDate, + ); + const dueBills = await billRepository.dueBills(filter.asDate); + + // A/P aging summary report instance. + const APAgingSummaryReport = new APAgingSummarySheet( + tenantId, + filter, + vendors, + overdueBills, + dueBills, + 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..769be797c 100644 --- a/server/src/services/FinancialStatements/AgingSummary/APAgingSummarySheet.ts +++ b/server/src/services/FinancialStatements/AgingSummary/APAgingSummarySheet.ts @@ -1,12 +1,116 @@ -import FinancialSheet from "../FinancialSheet"; +import { groupBy, sum } 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 overdueInvoicesByContactId: Dictionary; + readonly currentInvoicesByContactId: Dictionary; + readonly agingPeriods: IAgingPeriod[]; -export default class APAgingSummarySheet extends FinancialSheet { + /** + * Constructor method. + * @param {number} tenantId - Tenant id. + * @param {IAPAgingSummaryQuery} query - Report query. + * @param {IVendor[]} vendors - Unpaid bills. + * @param {string} baseCurrency - Base currency of the organization. + */ + constructor( + tenantId: number, + query: IAPAgingSummaryQuery, + vendors: IVendor[], + overdueBills: IBill[], + unpaidBills: IBill[], + baseCurrency: string + ) { + super(); - + this.tenantId = tenantId; + this.query = query; + this.numberFormat = this.query.numberFormat; + this.contacts = vendors; + this.baseCurrency = baseCurrency; - reportData() { + this.overdueInvoicesByContactId = groupBy(overdueBills, 'vendorId'); + this.currentInvoicesByContactId = groupBy(unpaidBills, 'vendorId'); + // Initializes the aging periods. + this.agingPeriods = this.agingRangePeriods( + this.query.asDate, + this.query.agingDaysBefore, + this.query.agingPeriods + ); + } + + /** + * Retrieve the vendor section data. + * @param {IVendor} vendor + * @return {IAPAgingSummaryVendor} + */ + private vendorData(vendor: IVendor): IAPAgingSummaryVendor { + const agingPeriods = this.getContactAgingPeriods(vendor.id); + const currentTotal = this.getContactCurrentTotal(vendor.id); + const agingPeriodsTotal = this.getAgingPeriodsTotal(agingPeriods); + + const amount = sum([agingPeriodsTotal, currentTotal]); + + return { + vendorName: vendor.displayName, + current: this.formatTotalAmount(currentTotal), + aging: agingPeriods, + total: this.formatTotalAmount(amount), + }; + } + + /** + * Retrieve vendors aging periods. + * @return {IAPAgingSummaryVendor[]} + */ + private vendorsWalker(vendors: IVendor[]): IAPAgingSummaryVendor[] { + return vendors + .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 { + const vendorsAgingPeriods = this.vendorsWalker(this.contacts); + const totalAgingPeriods = this.getTotalAgingPeriods(vendorsAgingPeriods); + const totalCurrent = this.getTotalCurrent(vendorsAgingPeriods); + + return { + vendors: vendorsAgingPeriods, + total: { + current: this.formatTotalAmount(totalCurrent), + aging: totalAgingPeriods, + }, + } + } + + /** + * Retrieve the A/P aging summary report columns. + */ + public 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..9be5a8e22 100644 --- a/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryService.ts +++ b/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryService.ts @@ -2,8 +2,8 @@ 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'; +import SaleInvoiceRepository from 'repositories/SaleInvoiceRepository'; @Service() export default class ARAgingSummaryService { @@ -31,63 +31,54 @@ 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'); - - // 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. + // Retrieve all overdue sale invoices. + const overdueSaleInvoices = await saleInvoiceRepository.overdueInvoices( + filter.asDate + ); + // Retrieve all due sale invoices. + const currentInvoices = await saleInvoiceRepository.dueInvoices( + filter.asDate + ); + // AR aging summary report instance. const ARAgingSummaryReport = new ARAgingSummarySheet( tenantId, filter, customers, - journal, - ARAccount, + overdueSaleInvoices, + currentInvoices, 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..39f0d18fa 100644 --- a/server/src/services/FinancialStatements/AgingSummary/ARAgingSummarySheet.ts +++ b/server/src/services/FinancialStatements/AgingSummary/ARAgingSummarySheet.ts @@ -1,57 +1,53 @@ +import { groupBy, sum } from 'lodash'; import { ICustomer, IARAgingSummaryQuery, - ARAgingSummaryCustomer, - IAgingPeriodClosingBalance, - IAgingPeriodTotal, - IJournalPoster, - IAccount, - IAgingPeriod -} from "interfaces"; + IARAgingSummaryCustomer, + 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 overdueInvoicesByContactId: Dictionary; + readonly currentInvoicesByContactId: 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, + overdueSaleInvoices: ISaleInvoice[], + currentSaleInvoices: 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.initAgingPeriod(); - } + this.overdueInvoicesByContactId = groupBy(overdueSaleInvoices, 'customerId'); + this.currentInvoicesByContactId = groupBy(currentSaleInvoices, 'customerId'); - /** - * Initializes the aging periods. - */ - private initAgingPeriod() { + // Initializes the aging periods. this.agingPeriods = this.agingRangePeriods( this.query.asDate, this.query.agingDaysBefore, @@ -59,96 +55,62 @@ export default class ARAgingSummarySheet extends AgingSummaryReport { ); } - /** - * - * @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[]} + * @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 currentTotal = this.getContactCurrentTotal(customer.id); + const agingPeriodsTotal = this.getAgingPeriodsTotal(agingPeriods); + const amount = sum([agingPeriodsTotal, currentTotal]); return { customerName: customer.displayName, - aging: this.getCustomerAging(customer, trialBalance.balance), - total: { - amount, - formattedAmount, - currencyCode, - }, + current: this.formatTotalAmount(currentTotal), + 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[] { + private customersWalker(customers: ICustomer[]): IARAgingSummaryCustomer[] { 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), + .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 { + const customersAgingPeriods = this.customersWalker(this.contacts); + const totalAgingPeriods = this.getTotalAgingPeriods(customersAgingPeriods); + const totalCurrent = this.getTotalCurrent(customersAgingPeriods); + + return { + customers: customersAgingPeriods, + total: { + current: this.formatTotalAmount(totalCurrent), + aging: totalAgingPeriods, + } + }; } /** * 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..17bad96c5 --- /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: Date|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..b13ecaaa2 100644 --- a/server/src/services/FinancialStatements/AgingSummary/AgingSummary.ts +++ b/server/src/services/FinancialStatements/AgingSummary/AgingSummary.ts @@ -1,75 +1,185 @@ -import moment from 'moment'; -import { omit, reverse } from 'lodash'; -import { IAgingPeriod, IAgingPeriodClosingBalance, IAgingPeriodTotal } from 'interfaces'; -import FinancialSheet from '../FinancialSheet'; +import { defaultTo, sumBy, get } from 'lodash'; +import { + IAgingPeriod, + ISaleInvoice, + IBill, + IAgingPeriodTotal, + IARAgingSummaryCustomer, + IContact, + IARAgingSummaryQuery, +} 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 query: IARAgingSummaryQuery; + protected readonly overdueInvoicesByContactId: Dictionary< + (ISaleInvoice | IBill)[] + >; + protected readonly currentInvoicesByContactId: Dictionary< + (ISaleInvoice | IBill)[] + >; /** - * - * @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; + protected getInitialAgingPeriodsTotal() { + return this.agingPeriods.map((agingPeriod) => ({ + ...agingPeriod, + ...this.formatTotalAmount(0), + })); + } - const periods = reverse(agingPeriods).map((agingPeriod) => { - const agingAmount = (agingPeriod.closingBalance - prevAging); - const subtract = Math.min(diff, agingAmount); - diff -= Math.min(agingAmount, diff); + /** + * Calculates the given contact aging periods. + * @param {ICustomer} customer + * @return {(IAgingPeriod & IAgingPeriodTotal)[]} + */ + protected getContactAgingPeriods( + contactId: number + ): (IAgingPeriod & IAgingPeriodTotal)[] { + const unpaidInvoices = this.getUnpaidInvoicesByContactId(contactId); + const initialAgingPeriods = this.getInitialAgingPeriodsTotal(); - const total = Math.max(agingAmount - subtract, 0); + return unpaidInvoices.reduce((agingPeriods, unpaidInvoice) => { + const newAgingPeriods = this.getContactAgingDueAmount( + agingPeriods, + unpaidInvoice.dueAmount, + unpaidInvoice.getOverdueDays(this.query.asDate) + ); + return newAgingPeriods; + }, initialAgingPeriods); + } - const output = { - ...omit(agingPeriod, ['closingBalance']), - total, + /** + * Sets the customer aging due amount to the table. (Xx) + * @param {number} customerId - Customer id. + * @param {number} dueAmount - Due amount. + * @param {number} overdueDays - Overdue days. + */ + protected getContactAgingDueAmount( + agingPeriods: any, + dueAmount: number, + overdueDays: number + ): (IAgingPeriod & IAgingPeriodTotal)[] { + const newAgingPeriods = agingPeriods.map((agingPeriod) => { + const isInAgingPeriod = + agingPeriod.beforeDays <= overdueDays && + (agingPeriod.toDays > overdueDays || !agingPeriod.toDays); + + return { + ...agingPeriod, + total: isInAgingPeriod + ? agingPeriod.total + dueAmount + : agingPeriod.total, }; - prevAging = agingPeriod.closingBalance; - return output; }); - return reverse(periods); + return newAgingPeriods; } /** - * - * @param {*} asDay - * @param {*} agingDaysBefore - * @param {*} agingPeriodsFreq + * Retrieve the aging period total object. + * @param {number} amount + * @return {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'); - - 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; + protected formatTotalAmount(amount: number): IAgingPeriodTotal { + return { + total: amount, + formattedTotal: this.formatNumber(amount), + currencyCode: this.baseCurrency, + }; } -} \ No newline at end of file + /** + * Calculates the total of the aging period by the given index. + * @param {number} index + * @return {number} + */ + protected getTotalAgingPeriodByIndex( + contactsAgingPeriods: any, + index: number + ): number { + return this.contacts.reduce((acc, customer) => { + const totalPeriod = contactsAgingPeriods[index] + ? contactsAgingPeriods[index].total + : 0; + + return acc + totalPeriod; + }, 0); + } + + /** + * Retrieve the due invoices by the given customer id. + * @param {number} customerId - + * @return {ISaleInvoice[]} + */ + protected getUnpaidInvoicesByContactId( + contactId: number + ): (ISaleInvoice | IBill)[] { + return defaultTo(this.overdueInvoicesByContactId[contactId], []); + } + + /** + * Retrieve total aging periods of the report. + * @return {(IAgingPeriodTotal & IAgingPeriod)[]} + */ + protected getTotalAgingPeriods( + contactsAgingPeriods: IARAgingSummaryCustomer[] + ): (IAgingPeriodTotal & IAgingPeriod)[] { + return this.agingPeriods.map((agingPeriod, index) => { + const total = sumBy(contactsAgingPeriods, `aging[${index}].total`); + + return { + ...agingPeriod, + ...this.formatTotalAmount(total), + }; + }); + } + + /** + * Retrieve the current invoices by the given contact id. + * @param {number} contactId + * @return {(ISaleInvoice | IBill)[]} + */ + protected getCurrentInvoicesByContactId( + contactId: number + ): (ISaleInvoice | IBill)[] { + return get(this.currentInvoicesByContactId, contactId, []); + } + + /** + * Retrieve the contact total due amount. + * @param {number} contactId + * @return {number} + */ + protected getContactCurrentTotal(contactId: number): number { + const currentInvoices = this.getCurrentInvoicesByContactId(contactId); + return sumBy(currentInvoices, invoice => invoice.dueAmount); + } + + /** + * Retrieve to total sumation of the given customers sections. + * @param {IARAgingSummaryCustomer[]} contactsSections - + * @return {number} + */ + protected getTotalCurrent( + customersSummary: IARAgingSummaryCustomer[] + ): number { + return sumBy(customersSummary, summary => summary.current.total); + } + + /** + * Retrieve the total of the given aging periods. + * @param {IAgingPeriodTotal[]} agingPeriods + * @return {number} + */ + protected getAgingPeriodsTotal( + agingPeriods: IAgingPeriodTotal[], + ): number { + return sumBy(agingPeriods, 'total'); + } +} 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 +} diff --git a/server/src/services/Purchases/Bills.ts b/server/src/services/Purchases/Bills.ts index 94289cef1..beeef28f4 100644 --- a/server/src/services/Purchases/Bills.ts +++ b/server/src/services/Purchases/Bills.ts @@ -56,7 +56,7 @@ export default class BillsService extends SalesInvoicesCost { @Inject() tenancy: TenancyService; - + @EventDispatcher() eventDispatcher: EventDispatcherInterface; @@ -206,7 +206,7 @@ export default class BillsService extends SalesInvoicesCost { billDTO: IBillDTO, authorizedUser: ISystemUser ): Promise { - const { Bill } = this.tenancy.models(tenantId); + const { billRepository } = this.tenancy.repositories(tenantId); this.logger.info('[bill] trying to create a new bill', { tenantId, @@ -236,7 +236,7 @@ export default class BillsService extends SalesInvoicesCost { billDTO.entries ); // Inserts the bill graph object to the storage. - const bill = await Bill.query().insertGraph({ ...billObj }); + const bill = await billRepository.upsertGraph({ ...billObj }); // Triggers `onBillCreated` event. await this.eventDispatcher.dispatch(events.bill.onCreated, { @@ -275,7 +275,7 @@ export default class BillsService extends SalesInvoicesCost { billDTO: IBillEditDTO, authorizedUser: ISystemUser ): Promise { - const { Bill } = this.tenancy.models(tenantId); + const { billRepository } = this.tenancy.repositories(tenantId); this.logger.info('[bill] trying to edit bill.', { tenantId, billId }); const oldBill = await this.getBillOrThrowError(tenantId, billId); @@ -314,7 +314,7 @@ export default class BillsService extends SalesInvoicesCost { billDTO.entries ); // Update the bill transaction. - const bill = await Bill.query().upsertGraphAndFetch({ + const bill = await billRepository.upsertGraph({ id: billId, ...billObj, }); @@ -339,7 +339,8 @@ export default class BillsService extends SalesInvoicesCost { * @return {void} */ public async deleteBill(tenantId: number, billId: number) { - const { Bill, ItemEntry } = this.tenancy.models(tenantId); + const { ItemEntry } = this.tenancy.models(tenantId); + const { billRepository } = this.tenancy.repositories(tenantId); // Retrieve the given bill or throw not found error. const oldBill = await this.getBillOrThrowError(tenantId, billId); @@ -351,7 +352,7 @@ export default class BillsService extends SalesInvoicesCost { .delete(); // Delete the bill transaction. - const deleteBillOper = Bill.query().where('id', billId).delete(); + const deleteBillOper = billRepository.deleteById(billId); await Promise.all([deleteBillEntriesOper, deleteBillOper]);