diff --git a/app/services/bill-runs/determine-abstraction-periods.service.js b/app/services/bill-runs/determine-abstraction-periods.service.js new file mode 100644 index 0000000000..f98b784005 --- /dev/null +++ b/app/services/bill-runs/determine-abstraction-periods.service.js @@ -0,0 +1,121 @@ +'use strict' + +/** + * Determine real abstraction periods with years based on a reference period + * @module DetermineAbstractionPeriodService + */ + +/** + * Determine real abstraction periods with years based on a reference period + * + * Abstraction periods on both charge elements and returns are held as just days and months, for example, '1st May to 31 + * Oct'. When we come to calculate billable days or attempt to match a return line to an element, we need to translate + * them into real dates to do so. + * + * We use a reference period, typically a billing or charge period to determine what years to use. For example if the + * `referencePeriod` is a billing period, it will be '1 April 2023 to 31 March 2024'. This service will then apply 2023 + * and 2024 to the provided abstraction period depending on whether it is an "in-year" or "out-year" abstraction period. + * + * ## In-year + * + * An "in-year" abstraction period is one that starts and ends in the same year. It can be identified by the start day/ + * month being before the end day/month. For example, 10-Oct to 31-Dec. + * + * ## Out-year + * + * An "out-year" abstraction period is one that starts in one year and ends in the next. It can be identified by the + * start day/month being *after* the end day/month. For example, 01-Nov to 31-Mar. + * + * To arrive at actual dates for an abstraction period, we start by creating dates based on the reference period's start + * year. So if the reference period starts in 2022, our first period for the above examples would be: + * + * - **In-year abstraction period** 10-Oct-2022 to 31-Dec-2022 + * - **Out-year abstraction period** 01-Nov-2022 to 31-Mar-2023 + * + * To ensure we cover all possible abstraction periods which the reference period could overlap, we then create + * additional abstraction periods for the year before and the year after our first period: + * + * - **In-year abstraction periods** 10-Oct-2021 to 31-Dec-2021 and 10-Oct-2023 to 31-Oct-2023 + * - **Out-year abstraction periods** 01-Nov-2021 to 31-Mar-2022 and 01-Nov-2023 to 31-Mar-2024 + * + * Finally, we filter out any of these abstraction periods which don't overlap with the reference period, and return the + * results. + * + * @param {Object} referencePeriod either the billing period or charge period + * @param {Number} startDay the abstraction start day value + * @param {Number} startMonth the abstraction start month value + * @param {Number} endDay the abstraction end day value + * @param {Number} endMonth the abstraction end month value + * + * @returns {Object[]} An array of abstraction periods each containing a start and end date + */ +function go (referencePeriod, startDay, startMonth, endDay, endMonth) { + const abstractionPeriodsWithYears = _determineYears(referencePeriod, startDay, startMonth, endDay, endMonth) + + return abstractionPeriodsWithYears.map((abstractionPeriod) => { + return _calculateAbstractionOverlapPeriod(referencePeriod, abstractionPeriod) + }) +} + +function _addOneYear (date) { + return new Date(date.getFullYear() + 1, date.getMonth(), date.getDate()) +} + +function _calculateAbstractionOverlapPeriod (referencePeriod, abstractionPeriod) { + const latestStartDateTimestamp = Math.max(abstractionPeriod.startDate, referencePeriod.startDate) + const earliestEndDateTimestamp = Math.min(abstractionPeriod.endDate, referencePeriod.endDate) + + return { + startDate: new Date(latestStartDateTimestamp), + endDate: new Date(earliestEndDateTimestamp) + } +} + +function _determineYears (referencePeriod, startDay, startMonth, endDay, endMonth) { + const periodStartYear = referencePeriod.startDate.getFullYear() + + // Reminder! Because of the unique qualities of Javascript, Year and Day are literal values, month is an index! So, + // January is actually 0, February is 1 etc. This is why we are always deducting 1 from the months. + const firstPeriod = { + startDate: new Date(periodStartYear, startMonth - 1, startDay), + endDate: new Date(periodStartYear, endMonth - 1, endDay) + } + + // Determine if this is an out-year abstraction period by checking whether the end date is before the start date. If + // it is then adjust the end date to be in the next year. + if (firstPeriod.endDate < firstPeriod.startDate) { + firstPeriod.endDate = _addOneYear(firstPeriod.endDate) + } + + // Create periods for the previous year and the following year, covering all possible abstraction periods that our + // reference period could overlap + const previousPeriod = { + startDate: _subtractOneYear(firstPeriod.startDate), + endDate: _subtractOneYear(firstPeriod.endDate) + } + const nextPeriod = { + startDate: _addOneYear(firstPeriod.startDate), + endDate: _addOneYear(firstPeriod.endDate) + } + + // Filter out any periods which don't overlap our reference period and return the ones which do + return [previousPeriod, firstPeriod, nextPeriod].filter((abstractionPeriod) => { + return _isPeriodValid(referencePeriod, abstractionPeriod) + }) +} + +function _isPeriodValid (referencePeriod, abstractionPeriod) { + // If either period starts after the other ends then there is no intersection and `false` is returned + return !( + abstractionPeriod.startDate > referencePeriod.endDate || + referencePeriod.startDate > abstractionPeriod.endDate + ) +} + +function _subtractOneYear (date) { + return new Date(date.getFullYear() - 1, date.getMonth(), date.getDate()) +} + +module.exports = { + go +} diff --git a/app/services/bill-runs/supplementary/calculate-authorised-and-billable-days.service.js b/app/services/bill-runs/supplementary/calculate-authorised-and-billable-days.service.js index 5681b51630..5386c60939 100644 --- a/app/services/bill-runs/supplementary/calculate-authorised-and-billable-days.service.js +++ b/app/services/bill-runs/supplementary/calculate-authorised-and-billable-days.service.js @@ -6,6 +6,7 @@ */ const ConsolidateDateRangesService = require('./consolidate-date-ranges.service.js') +const DetermineAbstractionPeriodService = require('../determine-abstraction-periods.service.js') const ONE_DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000 @@ -62,8 +63,19 @@ function go (chargePeriod, billingPeriod, chargeReference) { const billableAbstractionPeriods = [] chargeElements.forEach((chargeElement) => { - authorisedAbstractionPeriods.push(..._abstractionPeriods(billingPeriod, chargeElement)) - billableAbstractionPeriods.push(..._abstractionPeriods(chargePeriod, chargeElement)) + const { + abstractionPeriodStartDay: startDay, + abstractionPeriodStartMonth: startMonth, + abstractionPeriodEndDay: endDay, + abstractionPeriodEndMonth: endMonth + } = chargeElement + + authorisedAbstractionPeriods.push( + ...DetermineAbstractionPeriodService.go(billingPeriod, startDay, startMonth, endDay, endMonth) + ) + billableAbstractionPeriods.push( + ...DetermineAbstractionPeriodService.go(chargePeriod, startDay, startMonth, endDay, endMonth) + ) }) return { @@ -72,81 +84,6 @@ function go (chargePeriod, billingPeriod, chargeReference) { } } -/** - * Calculate from a charge element's abstraction data the relevant abstraction periods - * - * Before we can calculate the days and whether a period should be considered, we have to assign actual years to the - * charge element's abstraction start and end values. - * - * ## In-year - * - * An "in-year" abstraction period is one that starts and ends in the same year. It can be identified by the start day/ - * month being before the end day/month. For example, 10-Oct to 31-Dec. - * - * ## Out-year - * - * An "out-year" abstraction period is one that starts in one year and ends in the next. It can be identified by the - * start day/month being *after* the end day/month. For example, 01-Nov to 31-Mar. - * - * To arrive at actual dates for an abstraction period, we start by creating dates based on the reference period's start - * year. So if the reference period starts in 2022, our first period for the above examples would be: - * - * - **In-year abstraction period** 10-Oct-2022 to 31-Dec-2022 - * - **Out-year abstraction period** 01-Nov-2022 to 31-Mar-2023 - * - * To ensure we cover all possible abstraction periods which the reference period could overlap, we then create - * additional abstraction periods for the year before and the year after our first period: - * - * - **In-year abstraction periods** 10-Oct-2021 to 31-Dec-2021 and 10-Oct-2023 to 31-Oct-2023 - * - **Out-year abstraction periods** 01-Nov-2021 to 31-Mar-2022 and 01-Nov-2023 to 31-Mar-2024 - * - * Finally, we filter out any of these abstraction periods which don't overlap with the reference period, and return the - * results. - * - * @param {Object} referencePeriod either the billing period or charge period - * @param {module:ChargeElementModel} chargeElement holds the abstraction start and end day and month values - * - * @returns {Object[]} An array of abstraction periods each containing a start and end date - */ -function _abstractionPeriods (referencePeriod, chargeElement) { - const periodStartYear = referencePeriod.startDate.getFullYear() - const { - abstractionPeriodStartDay: startDay, - abstractionPeriodStartMonth: startMonth, - abstractionPeriodEndDay: endDay, - abstractionPeriodEndMonth: endMonth - } = chargeElement - - // Reminder! Because of the unique qualities of Javascript, Year and Day are literal values, month is an index! So, - // January is actually 0, February is 1 etc. This is why we are always deducting 1 from the months. - const firstPeriod = { - startDate: new Date(periodStartYear, startMonth - 1, startDay), - endDate: new Date(periodStartYear, endMonth - 1, endDay) - } - - // Determine if this is an out-year abstraction period by checking whether the end date is before the start date. If - // it is then adjust the end date to be in the next year. - if (firstPeriod.endDate < firstPeriod.startDate) { - firstPeriod.endDate = _addOneYear(firstPeriod.endDate) - } - - // Create periods for the previous year and the following year, covering all possible abstraction periods that our - // reference period could overlap - const previousPeriod = { - startDate: _subtractOneYear(firstPeriod.startDate), - endDate: _subtractOneYear(firstPeriod.endDate) - } - const nextPeriod = { - startDate: _addOneYear(firstPeriod.startDate), - endDate: _addOneYear(firstPeriod.endDate) - } - - // Filter out any periods which don't overlap our reference period to return the ones which do - return [previousPeriod, firstPeriod, nextPeriod].filter((period) => { - return _isPeriodValid(referencePeriod, period) - }) -} - /** * Calculates the numbers of days in an abstraction overlap period (inclusive) * @@ -215,40 +152,6 @@ function _consolidateAndCalculate (referencePeriod, abstractionsPeriods) { return totalDays } -/** - * Checks each abstraction period to see whether it has days that should be counted - * - * This is determined by calculating whether the abstraction period intersects with the reference period. For example, - * if the reference period is 2022-04-01 to 2023-03-31 and the abstraction period is 01-Nov to 31-Mar: - * - * - 01-Nov-2022 to 31-Mar-2023 = `true` - * - 01-Nov-2021 to 31-Mar-2022 = `false` - * - * @param {Object} referencePeriod Object that has a `startDate` and `endDate` that defines the reference period - * @param {Object} abstractionPeriod Object that has a `startDate` and `endDate` that defines the abstraction period - * - * @returns {Boolean} true if the abstraction period intersects the reference period - */ -function _isPeriodValid (referencePeriod, abstractionPeriod) { - // If one period starts after the other ends then there is no intersection - if ( - abstractionPeriod.startDate > referencePeriod.endDate || - referencePeriod.startDate > abstractionPeriod.endDate - ) { - return false - } - - return true -} - -function _addOneYear (date) { - return new Date(date.getFullYear() + 1, date.getMonth(), date.getDate()) -} - -function _subtractOneYear (date) { - return new Date(date.getFullYear() - 1, date.getMonth(), date.getDate()) -} - module.exports = { go } diff --git a/test/services/bill-runs/determine-abstraction-periods.service.test.js b/test/services/bill-runs/determine-abstraction-periods.service.test.js new file mode 100644 index 0000000000..e8da64f461 --- /dev/null +++ b/test/services/bill-runs/determine-abstraction-periods.service.test.js @@ -0,0 +1,273 @@ +'use strict' + +// Test framework dependencies +const Lab = require('@hapi/lab') +const Code = require('@hapi/code') + +const { describe, it, beforeEach } = exports.lab = Lab.script() +const { expect } = Code + +// Thing under test +const DetermineAbstractionPeriodService = require('../../../app/services/bill-runs/determine-abstraction-periods.service.js') + +// NOTE: You might find it helpful to refresh your understanding of abstraction periods and what the service is trying +// to fathom when referencing them to the billing and charge periods. See the documentation in the service. Also, a +// a reminder of what in-year and out-year means. +// +// - In-year: If the abstraction period end month is _after_ the start month, for example 01-Jan to 31-May, then we +// assign the reference period's end year to both the abstraction start and end dates. +// - Out-year: If the abstraction period end month is _before_ the start month, for example 01-Nov to 31-Mar, then we +// assign the reference period's end year to the end date, and start year to the start date. + +describe('Determine Abstraction Periods service', () => { + let endDay + let endMonth + let referencePeriod + let startDay + let startMonth + + describe('when the abstraction period is 01-JAN to 31-DEC (in-year)', () => { + beforeEach(() => { + startDay = 1 + startMonth = 1 + endDay = 31 + endMonth = 12 + }) + + describe('and the reference period is 01-APR-2022 to 31-MAR-2023 (starts first year, ends second year)', () => { + beforeEach(() => { + referencePeriod = { + startDate: new Date('2022-04-01'), + endDate: new Date('2023-03-31') + } + }) + + it('returns the expected abstraction periods', () => { + const result = DetermineAbstractionPeriodService.go(referencePeriod, startDay, startMonth, endDay, endMonth) + + expect(result).to.equal([ + { + startDate: new Date('2022-04-01'), + endDate: new Date('2022-12-31') + }, + { + startDate: new Date('2023-01-01'), + endDate: new Date('2023-03-31') + } + ]) + }) + }) + + describe('and the reference period is 01-NOV-2022 to 31-DEC-2022 (starts and ends first year)', () => { + beforeEach(() => { + referencePeriod = { + startDate: new Date('2022-11-01'), + endDate: new Date('2022-12-31') + } + }) + + it('returns the expected abstraction period', () => { + const result = DetermineAbstractionPeriodService.go(referencePeriod, startDay, startMonth, endDay, endMonth) + + expect(result).to.equal([ + { + startDate: new Date('2022-11-01'), + endDate: new Date('2022-12-31') + } + ]) + }) + }) + + describe('and the reference period is 01-DEC-2022 to 31-JAN-2023 (starts first year, ends second year)', () => { + beforeEach(() => { + referencePeriod = { + startDate: new Date('2022-12-01'), + endDate: new Date('2023-01-31') + } + }) + + it('returns the expected abstraction periods', () => { + const result = DetermineAbstractionPeriodService.go(referencePeriod, startDay, startMonth, endDay, endMonth) + + expect(result).to.equal([ + { + startDate: new Date('2022-12-01'), + endDate: new Date('2022-12-31') + }, + { + startDate: new Date('2023-01-01'), + endDate: new Date('2023-01-31') + } + ]) + }) + }) + + describe('and the reference period is 01-JAN-2023 to 28-FEB-2023 (starts and ends second year)', () => { + beforeEach(() => { + referencePeriod = { + startDate: new Date('2023-01-01'), + endDate: new Date('2023-02-28') + } + }) + + it('returns the expected abstraction period', () => { + const result = DetermineAbstractionPeriodService.go(referencePeriod, startDay, startMonth, endDay, endMonth) + + expect(result).to.equal([ + { + startDate: new Date('2023-01-01'), + endDate: new Date('2023-02-28') + } + ]) + }) + }) + }) + + describe('and the abstraction period is 01-JAN to 30-JUN (in-year)', () => { + beforeEach(() => { + startDay = 1 + startMonth = 1 + endDay = 30 + endMonth = 6 + }) + + describe('and the reference period is 01-NOV-2022 to 31-DEC-2022 (starts and ends first year)', () => { + beforeEach(() => { + referencePeriod = { + startDate: new Date('2022-11-01'), + endDate: new Date('2022-12-31') + } + }) + + it('returns no abstraction periods', () => { + const result = DetermineAbstractionPeriodService.go(referencePeriod, startDay, startMonth, endDay, endMonth) + + expect(result).to.equal([]) + }) + }) + + describe('and the reference period is 01-DEC-2022 to 31-JAN-2023 (starts first year, ends second year)', () => { + beforeEach(() => { + referencePeriod = { + startDate: new Date('2022-12-01'), + endDate: new Date('2023-01-31') + } + }) + + it('returns the expected abstraction period', () => { + const result = DetermineAbstractionPeriodService.go(referencePeriod, startDay, startMonth, endDay, endMonth) + + expect(result).to.equal([ + { + startDate: new Date('2023-01-01'), + endDate: new Date('2023-01-31') + } + ]) + }) + }) + + describe('and the reference period is 01-JAN-2023 to 28-FEB-2023 (starts and ends second year)', () => { + beforeEach(() => { + referencePeriod = { + startDate: new Date('2023-01-01'), + endDate: new Date('2023-02-28') + } + }) + + it('returns the expected abstraction period', () => { + const result = DetermineAbstractionPeriodService.go(referencePeriod, startDay, startMonth, endDay, endMonth) + + expect(result).to.equal([ + { + startDate: new Date('2023-01-01'), + endDate: new Date('2023-02-28') + } + ]) + }) + }) + }) + + describe('and the abstraction period is 01-OCT to 31-MAR (out-year)', () => { + beforeEach(() => { + startDay = 1 + startMonth = 10 + endDay = 31 + endMonth = 3 + }) + + describe('and the reference period is 01-NOV-2022 to 31-DEC-2022 (starts and ends first year)', () => { + beforeEach(() => { + referencePeriod = { + startDate: new Date('2022-11-01'), + endDate: new Date('2022-12-31') + } + }) + + it('returns the expected abstraction period', () => { + const result = DetermineAbstractionPeriodService.go(referencePeriod, startDay, startMonth, endDay, endMonth) + + expect(result).to.equal([ + { + startDate: new Date('2022-11-01'), + endDate: new Date('2022-12-31') + } + ]) + }) + }) + + describe('and the reference period is 01-DEC-2022 to 31-JAN-2023 (starts first year, ends second year)', () => { + beforeEach(() => { + referencePeriod = { + startDate: new Date('2022-12-01'), + endDate: new Date('2023-01-31') + } + }) + + it('returns the expected abstraction period', () => { + const result = DetermineAbstractionPeriodService.go(referencePeriod, startDay, startMonth, endDay, endMonth) + + expect(result).to.equal([ + { + startDate: new Date('2022-12-01'), + endDate: new Date('2023-01-31') + } + ]) + }) + }) + + describe('and the reference period is 01-JAN-2023 to 28-FEB-2023 (starts and ends second year)', () => { + beforeEach(() => { + referencePeriod = { + startDate: new Date('2023-01-01'), + endDate: new Date('2023-02-28') + } + }) + + it('returns the expected abstraction period', () => { + const result = DetermineAbstractionPeriodService.go(referencePeriod, startDay, startMonth, endDay, endMonth) + + expect(result).to.equal([ + { + startDate: new Date('2023-01-01'), + endDate: new Date('2023-02-28') + } + ]) + }) + }) + + describe('and the reference period is 01-AUG-2023 to 30-SEP-2023', () => { + beforeEach(() => { + referencePeriod = { + startDate: new Date('2023-08-01'), + endDate: new Date('2023-09-30') + } + }) + + it('returns no abstraction periods', () => { + const result = DetermineAbstractionPeriodService.go(referencePeriod, startDay, startMonth, endDay, endMonth) + + expect(result).to.equal([]) + }) + }) + }) +})