diff --git a/app/services/bill-runs/tpt-supplementary/process-billing-period.service.js b/app/services/bill-runs/tpt-supplementary/process-billing-period.service.js index 56657b3fde..3928bd1a19 100644 --- a/app/services/bill-runs/tpt-supplementary/process-billing-period.service.js +++ b/app/services/bill-runs/tpt-supplementary/process-billing-period.service.js @@ -5,17 +5,290 @@ * @module ProcessBillingPeriodService */ +const BillRunError = require('../../../errors/bill-run.error.js') +const BillRunModel = require('../../../models/bill-run.model.js') +const BillModel = require('../../../models/bill.model.js') +const BillLicenceModel = require('../../../models/bill-licence.model.js') +const DetermineChargePeriodService = require('../determine-charge-period.service.js') +const DetermineMinimumChargeService = require('../determine-minimum-charge.service.js') +const { generateUUID } = require('../../../lib/general.lib.js') +const GenerateTwoPartTariffTransactionService = require('../generate-two-part-tariff-transaction.service.js') +const ProcessSupplementaryTransactionsService = require('../process-supplementary-transactions.service.js') +const SendTransactionsService = require('../send-transactions.service.js') +const TransactionModel = require('../../../models/transaction.model.js') + +const BillingConfig = require('../../../../config/billing.config.js') + /** * Process the billing accounts for a given billing period and creates their supplementary two-part tariff bills * - * @param {module:BillRunModel} _billRun - The two-part tariff supplementary bill run we need to process - * @param {object} _billingPeriod - An object representing the financial year the bills will be for - * @param {module:BillingAccountModel[]} _billingAccounts - The billing accounts to create bills for + * @param {module:BillRunModel} billRun - The two-part tariff supplementary bill run we need to process + * @param {object} billingPeriod - An object representing the financial year the bills will be for + * @param {module:BillingAccountModel[]} billingAccounts - The billing accounts to create bills for * * @returns {Promise} true if the bill run is not empty (there are transactions to bill) else false */ -async function go(_billRun, _billingPeriod, _billingAccounts) { - throw Error('Not implemented') +async function go(billRun, billingPeriod, billingAccounts) { + let billRunIsPopulated = false + + if (billingAccounts.length === 0) { + return billRunIsPopulated + } + + // We set the batch size and number of billing accounts here rather than determine them for every iteration of the + // loop. It's a very minor nod towards performance. + const batchSize = BillingConfig.annual.batchSize + const billingAccountsCount = billingAccounts.length + + // Loop through the billing accounts to be processed by the size of the batch. For example, if we have 100 billing + // accounts to process and the batch size is 10, we'll make 10 iterations of the loop + for (let i = 0; i < billingAccountsCount; i += batchSize) { + // Use slice(start, end) to extract the next batch of billing accounts to process. For example, if we have 100 + // billing accounts, a batch size of 10 then + // + // - 1st pass: slice(0, 10) will return billingAccounts[0] to billingAccounts[9] + // - 2nd pass: slice(10, 20) will return billingAccounts[10] to billingAccounts[19] + // + // Both the start and end params are zero-based indexes for the array being sliced. The bit that might confuse is + // end is not inclusive! + const accountsToProcess = billingAccounts.slice(i, i + batchSize) + + // NOTE: we purposefully loop through each billing account in the batch without awaiting them to be processed. This + // is for performance purposes. If our batch size is 10 we'll start processing one after the other. We then wait for + // all 10 to complete. The overall process time will only be that of the one that takes the longest. If we await + // instead the overall time will be the sum of the time to process each one. + const processes = accountsToProcess.map((accountToProcess) => { + return _processBillingAccount(accountToProcess, billRun, billingPeriod) + }) + + // _processBillingAccount() will return true (bill was created) else false (no bill created) for each billing + // account processed. Promise.all() will return these results as an array. Only one of them has to be true for us to + // mark the bill run as populated. The complication is we're not doing this once but multiple times as we iterate + // the billing accounts. As soon as we have flagged the bill run as populated we can stop checking. + const results = await Promise.all(processes) + + if (!billRunIsPopulated) { + billRunIsPopulated = results.some((result) => { + return result + }) + } + } + + return billRunIsPopulated +} + +/** + * Use to either find or create the bill licence for a given bill (billing account) and licence + * + * For each billing account being processed the engine will create a bill. But the transactions within a bill are + * grouped by licence because the details of the licence will effect the charge against the transactions. + * + * So, transactions are linked to a bill by a 'bill licence' record. Things are further complicated by the fact we don't + * directly work with the licences against a billing account. The link is via charge versions, and more than one charge + * version can link to the same licence. + * + * > The information needed for billing is held in charge versions and their associated records. A licence will have at + * > least one of these but may have more should changes have been made, for example, a change in the amount that can be + * > abstracted. It's the charge version that is directly linked to the billing account, not the licence. + * + * Because we're processing the charge versions for a billing account the same licence might appear more than once. This + * means we need to find the existing bill licence record or if one doesn't exist create it. + * + * @private + */ +function _findOrCreateBillLicence(billLicences, licence, billId) { + let billLicence = billLicences.find((existingBillLicence) => { + return existingBillLicence.licence.id === licence.id + }) + + if (!billLicence) { + billLicence = { + id: generateUUID(), + billId, + licence, + transactions: [] + } + + billLicences.push(billLicence) + } + + return billLicence +} + +/** + * Generate the bill licence and transaction records for a bill + * + * For each billing account we need to create a bill (1-to-1). Linked to the billing account will be multiple charge + * versions which is what we actually use to calculate a bill. A charge version can have multiple charge references and + * for each one we need to generate a transaction line. + * + * The complication is we group transactions by licence (via the bill licence), not charge version. So, as we iterate + * the charge versions we have to determine if its for a licence that we have already generated a bill licence for, or + * if we have to create a new one. + * + * @private + */ +async function _generateBillLicencesAndTransactions(billId, billingAccount, billingPeriod) { + const allBillLicences = [] + + for (const chargeVersion of billingAccount.chargeVersions) { + const billLicence = _findOrCreateBillLicence(allBillLicences, chargeVersion.licence, billId) + + const generatedTransactions = await _generateTransactions(billLicence.id, billingPeriod, chargeVersion) + + billLicence.transactions.push(...generatedTransactions) + } + + return allBillLicences +} + +/** + * Generate the transaction(s) for a charge version + * + * For each charge reference linked to the charge version we have to generate a transaction record. One of the things we + * need to know is if the charge version is the first charge on a new licence. This information needs to be passed to + * the Charging Module API as it affects the calculation. + * + * This function iterates the charge references generating transaction(s) for each one. + * + * @private + */ +async function _generateTransactions(billLicenceId, billingPeriod, chargeVersion) { + try { + const chargePeriod = DetermineChargePeriodService.go(chargeVersion, billingPeriod) + + // Guard clause against invalid charge periods, for example, a licence 'ends' before the charge version starts + if (!chargePeriod.startDate) { + return [] + } + + const firstChargeOnNewLicence = DetermineMinimumChargeService.go(chargeVersion, chargePeriod) + + const transactions = [] + + chargeVersion.chargeReferences.forEach((chargeReference) => { + const transaction = GenerateTwoPartTariffTransactionService.go( + billLicenceId, + chargeReference, + chargePeriod, + firstChargeOnNewLicence, + chargeVersion.licence.waterUndertaker + ) + + if (transaction) { + transactions.push(transaction) + } + }) + + return transactions + } catch (error) { + throw new BillRunError(error, BillRunModel.errorCodes.failedToPrepareTransactions) + } +} + +/** + * Processes the billing account + * + * We create a bill object that will eventually be persisted for the billing account. We then call + * `_generateBillLicencesAndTransactions()` which handles generating the candidate bill licences and their transactions. + * + * These are 'processed' by `_processBillLicences()` which returns only those which have something to bill. + * + * Those bill licences with transactions after processing are then passed to `_persistGeneratedData()`. It first + * calls the Charging Module API to get the charge value, before persisting the transactions, bill licences and the bill + * itself to the DB. + * + * @private + */ +async function _processBillingAccount(billingAccount, billRun, billingPeriod) { + const { id: billingAccountId, accountNumber } = billingAccount + const { id: billRunId, externalId: billRunExternalId } = billRun + + const bill = { + id: generateUUID(), + accountNumber, + address: {}, // Address is set to an empty object for SROC billing invoices + billingAccountId, + billRunId, + credit: false, + financialYearEnding: billingPeriod.endDate.getFullYear() + } + + const allBillLicences = await _generateBillLicencesAndTransactions(bill.id, billingAccount, billingPeriod) + const processedBillLicences = await _processBillLicences(allBillLicences, billingAccount.id, billingPeriod) + + if (processedBillLicences.length === 0) { + return false + } + + return _persistGeneratedData(bill, processedBillLicences, billRunExternalId, billingAccount.accountNumber) +} + +/** + * Process the bill licences + * + * We loop through each bill licence and check if any transactions were generated. If not we skip it. + * + * If there are transactions we call `ProcessSupplementaryTransactionsService.go()` which will pair up newly generated + * transactions with previously billed ones. If there are no differences (i.e. all transactions have been previously + * billed) we skip it. + * + * Finally, we return an array of the bill licences that have been processed and have transactions that require billing. + * + * @private + */ +async function _processBillLicences(billLicences, billingAccountId, billingPeriod) { + const financialYearEnding = billingPeriod.endDate.getFullYear() + const cleansedBillLicences = [] + + for (const billLicence of billLicences) { + if (billLicence.transactions.length === 0) { + continue + } + + const { id: billLicenceId, licence, transactions } = billLicence + const processedTransactions = await ProcessSupplementaryTransactionsService.go( + transactions, + billLicenceId, + billingAccountId, + licence.id, + financialYearEnding + ) + + if (processedTransactions.length === 0) { + continue + } + + billLicence.transactions = processedTransactions + + cleansedBillLicences.push(billLicence) + } + + return cleansedBillLicences +} + +async function _persistGeneratedData(bill, processedBillLicences, billRunExternalId, accountNumber) { + const billLicences = [] + const transactions = [] + + for (const processedBillLicence of processedBillLicences) { + const { billId, id, licence, transactions: processedTransactions } = processedBillLicence + const sentTransactions = await SendTransactionsService.go( + processedTransactions, + billRunExternalId, + accountNumber, + licence + ) + + billLicences.push({ billId, id, licenceId: licence.id, licenceRef: licence.licenceRef }) + transactions.push(...sentTransactions) + } + + await BillModel.query().insert(bill) + await BillLicenceModel.query().insert(billLicences) + await TransactionModel.query().insert(transactions) + + return true } module.exports = { diff --git a/test/fixtures/two-part-tariff.fixture.js b/test/fixtures/two-part-tariff.fixture.js new file mode 100644 index 0000000000..268b9443ad --- /dev/null +++ b/test/fixtures/two-part-tariff.fixture.js @@ -0,0 +1,162 @@ +'use strict' + +const { generateAccountNumber } = require('../support/helpers/billing-account.helper.js') +const { generateUUID } = require('../../app/lib/general.lib.js') +const { generateLicenceRef } = require('../support/helpers/licence.helper.js') + +/** + * Represents a bill run object with generated UUIDs for the ID and external ID + * + * @param {string} regionId - UUID of the region the bill run is for + * + * @returns {object} Bill run object with generated UUIDs + */ +function billRun(regionId) { + return { + id: generateUUID(), + externalId: generateUUID(), + regionId + } +} + +/** + * Represents a billing account object with a generated UUID for the ID and a generated account number + * + * @returns {object} Billing account object with generated UUID and account number + */ +function billingAccount() { + return { + id: generateUUID(), + accountNumber: generateAccountNumber() + } +} + +/** + * Generates a mock response object from the Charging Module API + * + * @param {string} transactionId - UUID of the transaction + * + * @returns {object} Mock response object from the Charging Module API + */ +function chargingModuleResponse(transactionId) { + return { + succeeded: true, + response: { + body: { transaction: { id: transactionId } } + } + } +} + +/** + * Represents a complete response from `FetchChargeVersionsService` + * + * We are faking an Objection model which comes with a toJSON() method that gets called as part of processing the + * billing account. + * + * @param {string} billingAccountId - UUID of the billing account + * @param {object} licence - Licence object with a generated UUID, licence reference and a region with a generated UUID + * and charge region ID + * + * @returns {object} Charge version object with generated UUID and licence + */ +function chargeVersion(billingAccountId, licence) { + // NOTE: We are faking an Objection model which comes with a toJSON() method that gets called as part + // of processing the billing account. + const toJSON = () => { + return '{}' + } + + return { + id: generateUUID(), + scheme: 'sroc', + startDate: new Date('2022-04-01'), + endDate: null, + billingAccountId, + status: 'current', + licence, + chargeReferences: [ + { + id: generateUUID(), + additionalCharges: { isSupplyPublicWater: false }, + adjustments: { + s126: null, + s127: false, + s130: false, + charge: null, + winter: false, + aggregate: '0.562114443' + }, + chargeCategory: { + id: 'b270718a-12c0-4fca-884b-3f8612dbe2f5', + reference: '4.4.5', + shortDescription: 'Low loss, non-tidal, restricted water, up to and including 5,000 ML/yr, Tier 1 model' + }, + chargeElements: [ + { + id: 'e6b98712-227a-40c2-b93a-c05e9047be8c', + abstractionPeriodStartDay: 1, + abstractionPeriodStartMonth: 4, + abstractionPeriodEndDay: 31, + abstractionPeriodEndMonth: 3, + reviewChargeElements: [{ id: '1d9050b2-09c8-4570-8173-7f55921437cc', amendedAllocated: 5 }], + toJSON + }, + { + id: '9e6f3f64-78d5-441b-80fc-e01711b2f766', + abstractionPeriodStartDay: 1, + abstractionPeriodStartMonth: 4, + abstractionPeriodEndDay: 31, + abstractionPeriodEndMonth: 3, + reviewChargeElements: [{ id: '17f0c41e-e894-41d2-8a68-69dd2b39e9f9', amendedAllocated: 10 }], + toJSON + } + ], + description: 'Lower Queenstown - Pittisham', + loss: 'low', + reviewChargeReferences: [ + { + id: '3dd04348-2c06-4559-9343-dd7dd76276ef', + amendedAggregate: 0.75, + amendedAuthorisedVolume: 20, + amendedChargeAdjustment: 0.6 + } + ], + source: 'non-tidal', + volume: 20 + } + ] + } +} + +/** + * Represents a licence object with generated UUIDs for the ID and a generated licence reference + * + * @param {object} region - Region object with generated UUIDs for the ID and charge region ID + * + * @returns {object} Licence object with generated UUID and licence reference + */ +function licence(region) { + return { + id: generateUUID(), + licenceRef: generateLicenceRef(), + waterUndertaker: true, + historicalAreaCode: 'SAAR', + regionalChargeArea: 'Southern', + startDate: new Date('2022-01-01'), + expiredDate: null, + lapsedDate: null, + revokedDate: null, + region: { + id: region.id, + chargeRegionId: region.chargeRegionId + } + } +} + +module.exports = { + billRun, + billingAccount, + chargingModuleResponse, + chargeVersion, + licence +} diff --git a/test/services/bill-runs/tpt-supplementary/process-billing-period.service.test.js b/test/services/bill-runs/tpt-supplementary/process-billing-period.service.test.js index c29424d04e..1295bff63d 100644 --- a/test/services/bill-runs/tpt-supplementary/process-billing-period.service.test.js +++ b/test/services/bill-runs/tpt-supplementary/process-billing-period.service.test.js @@ -3,17 +3,246 @@ // Test framework dependencies const Lab = require('@hapi/lab') const Code = require('@hapi/code') +const Sinon = require('sinon') -const { describe, it } = (exports.lab = Lab.script()) +const { describe, it, beforeEach, afterEach } = (exports.lab = Lab.script()) const { expect } = Code +// Test helpers +const RegionHelper = require('../../../support/helpers/region.helper.js') +const TwoPartTariffFixture = require('../../../fixtures/two-part-tariff.fixture.js') + +// Things we need to stub +const BillModel = require('../../../../app/models/bill.model.js') +const BillLicenceModel = require('../../../../app/models/bill-licence.model.js') +const BillRunError = require('../../../../app/errors/bill-run.error.js') +const BillRunModel = require('../../../../app/models/bill-run.model.js') +const ChargingModuleCreateTransactionRequest = require('../../../../app/requests/charging-module/create-transaction.request.js') +const GenerateTwoPartTariffTransactionService = require('../../../../app/services/bill-runs/generate-two-part-tariff-transaction.service.js') +const FetchPreviousTransactionsService = require('../../../../app/services/bill-runs/fetch-previous-transactions.service.js') +const ProcessSupplementaryTransactionsService = require('../../../../app/services/bill-runs/process-supplementary-transactions.service.js') +const TransactionModel = require('../../../../app/models/transaction.model.js') + // Thing under test const ProcessBillingPeriodService = require('../../../../app/services/bill-runs/tpt-supplementary/process-billing-period.service.js') describe('Bill Runs - Two-part Tariff - Process Billing Period service', () => { + const billingPeriod = { + startDate: new Date('2022-04-01'), + endDate: new Date('2023-03-31') + } + + let billInsertStub + let billLicenceInsertStub + let billRun + let billingAccount + let chargingModuleCreateTransactionRequestStub + let fetchPreviousTransactionsStub + let licence + let region + let transactionInsertStub + + beforeEach(async () => { + region = RegionHelper.select() + billRun = TwoPartTariffFixture.billRun() + billingAccount = TwoPartTariffFixture.billingAccount() + licence = TwoPartTariffFixture.licence(region) + + fetchPreviousTransactionsStub = Sinon.stub(FetchPreviousTransactionsService, 'go') + chargingModuleCreateTransactionRequestStub = Sinon.stub(ChargingModuleCreateTransactionRequest, 'send') + + billInsertStub = Sinon.stub() + billLicenceInsertStub = Sinon.stub() + transactionInsertStub = Sinon.stub() + + Sinon.stub(BillModel, 'query').returns({ insert: billInsertStub }) + Sinon.stub(BillLicenceModel, 'query').returns({ insert: billLicenceInsertStub }) + Sinon.stub(TransactionModel, 'query').returns({ insert: transactionInsertStub }) + }) + + afterEach(() => { + Sinon.restore() + }) + describe('when the service is called', () => { - it('throws an error', async () => { - await expect(ProcessBillingPeriodService.go()).to.reject() + describe('and there are no billing accounts to process', () => { + it('returns false (bill run is empty)', async () => { + const result = await ProcessBillingPeriodService.go(billRun, billingPeriod, []) + + expect(result).to.be.false() + }) + }) + + describe('and there are billing accounts to process', () => { + describe('and no previous billed transactions', () => { + beforeEach(() => { + // NOTE: Called by ProcessSupplementaryTransactions when checking if any previous billed transactions need to + // be considered/processed. Stubbing FetchPreviousTransactionsService means ProcessSupplementaryTransactions + // will just return what we passed in. + fetchPreviousTransactionsStub.resolves([]) + }) + + describe('and the billable volume is greater than 0', () => { + beforeEach(async () => { + // We want to ensure there is coverage of the functionality that finds an existing bill licence or creates a + // new one when processing a billing account. To to that we need a billing account with 2 charge versions + // linked to the same licence + billingAccount.chargeVersions = [ + TwoPartTariffFixture.chargeVersion(billingAccount.id, licence), + TwoPartTariffFixture.chargeVersion(billingAccount.id, licence) + ] + + chargingModuleCreateTransactionRequestStub.onFirstCall().resolves({ + ...TwoPartTariffFixture.chargingModuleResponse('7e752fa6-a19c-4779-b28c-6e536f028795') + }) + + chargingModuleCreateTransactionRequestStub.onSecondCall().resolves({ + ...TwoPartTariffFixture.chargingModuleResponse('a2086da4-e3b6-4b83-afe1-0e2e5255efaf') + }) + }) + + it('returns true (bill run is not empty) and persists the generated bills', async () => { + const result = await ProcessBillingPeriodService.go(billRun, billingPeriod, [billingAccount]) + + expect(result).to.be.true() + + // NOTE: We pass a single bill per billing account when persisting + const billInsertArgs = billInsertStub.args[0] + + expect(billInsertStub.calledOnce).to.be.true() + expect(billInsertArgs[0]).to.equal( + { + accountNumber: billingAccount.accountNumber, + address: {}, // Address is set to an empty object for SROC billing invoices + billingAccountId: billingAccount.id, + billRunId: billRun.id, + credit: false, + financialYearEnding: billingPeriod.endDate.getFullYear() + }, + { skip: ['id'] } + ) + + // NOTE: A bill may have multiple bill licences, so we always pass them as an array + const billLicenceInsertArgs = billLicenceInsertStub.args[0] + + expect(billLicenceInsertStub.calledOnce).to.be.true() + expect(billLicenceInsertArgs[0]).to.have.length(1) + expect(billLicenceInsertArgs[0][0]).to.equal( + { + billId: billInsertArgs[0].id, + licenceId: licence.id, + licenceRef: licence.licenceRef + }, + { skip: ['id'] } + ) + + // NOTE: And for performance reasons, we pass _all_ transactions for all bill licences at once + const transactionInsertArgs = transactionInsertStub.args[0] + + expect(transactionInsertStub.calledOnce).to.be.true() + expect(transactionInsertArgs[0]).to.have.length(2) + + // We just check that on of the transactions being persisted is linked to the records we expect + expect(transactionInsertArgs[0][0].billLicenceId).equal(billLicenceInsertArgs[0][0].id) + expect(transactionInsertArgs[0][0].externalId).equal('7e752fa6-a19c-4779-b28c-6e536f028795') + }) + }) + + describe('but the billable volume is 0', () => { + beforeEach(() => { + // This time we update the charge version so that nothing is allocated in the charge references. This means + // the service will not generate any transactions so no bill licences, leading to bills being empty + const unbillableChargeVersion = TwoPartTariffFixture.chargeVersion(billingAccount.id, licence) + + unbillableChargeVersion.chargeReferences[0].chargeElements[0].reviewChargeElements[0].amendedAllocated = 0 + unbillableChargeVersion.chargeReferences[0].chargeElements[1].reviewChargeElements[0].amendedAllocated = 0 + + billingAccount.chargeVersions = [unbillableChargeVersion] + }) + + it('returns false (bill run is empty) and persists nothing', async () => { + const result = await ProcessBillingPeriodService.go(billRun, billingPeriod, [billingAccount]) + + expect(result).to.be.false() + + expect(billInsertStub.called).to.be.false() + }) + }) + + describe('but the charge period is invalid (perhaps the licence has been ended)', () => { + beforeEach(() => { + licence.revokedDate = new Date('2022-03-31') + + billingAccount.chargeVersions = [TwoPartTariffFixture.chargeVersion(billingAccount.id, licence)] + }) + + it('returns false (bill run is empty) and persists nothing', async () => { + const result = await ProcessBillingPeriodService.go(billRun, billingPeriod, [billingAccount]) + + expect(result).to.be.false() + + expect(billInsertStub.called).to.be.false() + }) + }) + }) + + describe('with previous billed transactions', () => { + describe('that cancel out those generated in the bill run', () => { + beforeEach(() => { + // NOTE: If FetchPreviousTransactions finds existing transactions that 'cancel' out those generated as part + // of the current bill run, ProcessSupplementaryTransactionsService will return nothing, and the engine uses + // this to know not to create a bill. + Sinon.stub(ProcessSupplementaryTransactionsService, 'go').resolves([]) + + billingAccount.chargeVersions = [TwoPartTariffFixture.chargeVersion(billingAccount.id, licence)] + }) + + it('returns false (bill run is empty) and persists nothing', async () => { + const result = await ProcessBillingPeriodService.go(billRun, billingPeriod, [billingAccount]) + + expect(result).to.be.false() + + expect(billInsertStub.called).to.be.false() + }) + }) + }) + }) + }) + + describe('when the service errors', () => { + beforeEach(async () => { + billingAccount.chargeVersions = [TwoPartTariffFixture.chargeVersion(billingAccount.id, licence)] + }) + + describe('because generating the calculated transaction fails', () => { + beforeEach(async () => { + Sinon.stub(GenerateTwoPartTariffTransactionService, 'go').throws() + }) + + it('throws a BillRunError with the correct code', async () => { + const error = await expect(ProcessBillingPeriodService.go(billRun, billingPeriod, [billingAccount])).to.reject() + + expect(error).to.be.an.instanceOf(BillRunError) + expect(error.code).to.equal(BillRunModel.errorCodes.failedToPrepareTransactions) + }) + }) + + describe('because sending the transactions fails', () => { + beforeEach(async () => { + // NOTE: Called by ProcessSupplementaryTransactions when checking if any previous billed transactions need to + // be considered/processed. Stubbing FetchPreviousTransactionsService means ProcessSupplementaryTransactions + // will just return what we passed in, which is all we need make the engine attempt to get the charge from the + // Charging Module API. + fetchPreviousTransactionsStub.resolves([]) + chargingModuleCreateTransactionRequestStub.rejects() + }) + + it('throws a BillRunError with the correct code', async () => { + const error = await expect(ProcessBillingPeriodService.go(billRun, billingPeriod, [billingAccount])).to.reject() + + expect(error).to.be.an.instanceOf(BillRunError) + expect(error.code).to.equal(BillRunModel.errorCodes.failedToCreateCharge) + }) }) }) }) diff --git a/test/services/bill-runs/two-part-tariff/process-billing-period.service.test.js b/test/services/bill-runs/two-part-tariff/process-billing-period.service.test.js index 7fb6f8d6ad..c42b01baad 100644 --- a/test/services/bill-runs/two-part-tariff/process-billing-period.service.test.js +++ b/test/services/bill-runs/two-part-tariff/process-billing-period.service.test.js @@ -9,17 +9,17 @@ const { describe, it, beforeEach, afterEach } = (exports.lab = Lab.script()) const { expect } = Code // Test helpers -const BillModel = require('../../../../app/models/bill.model.js') -const { generateAccountNumber } = require('../../../support/helpers/billing-account.helper.js') -const { generateUUID } = require('../../../../app/lib/general.lib.js') -const { generateLicenceRef } = require('../../../support/helpers/licence.helper.js') const RegionHelper = require('../../../support/helpers/region.helper.js') +const TwoPartTariffFixture = require('../../../fixtures/two-part-tariff.fixture.js') // Things we need to stub +const BillModel = require('../../../../app/models/bill.model.js') +const BillLicenceModel = require('../../../../app/models/bill-licence.model.js') const BillRunError = require('../../../../app/errors/bill-run.error.js') const BillRunModel = require('../../../../app/models/bill-run.model.js') const ChargingModuleCreateTransactionRequest = require('../../../../app/requests/charging-module/create-transaction.request.js') const GenerateTwoPartTariffTransactionService = require('../../../../app/services/bill-runs/generate-two-part-tariff-transaction.service.js') +const TransactionModel = require('../../../../app/models/transaction.model.js') // Thing under test const ProcessBillingPeriodService = require('../../../../app/services/bill-runs/two-part-tariff/process-billing-period.service.js') @@ -30,22 +30,30 @@ describe('Bill Runs - Two-part Tariff - Process Billing Period service', () => { endDate: new Date('2023-03-31') } + let billInsertStub + let billLicenceInsertStub let billRun let billingAccount let chargingModuleCreateTransactionRequestStub let licence + let region + let transactionInsertStub beforeEach(async () => { - billRun = { - id: generateUUID(), - externalId: generateUUID() - } + region = RegionHelper.select() + billRun = TwoPartTariffFixture.billRun(region.id) + billingAccount = TwoPartTariffFixture.billingAccount() + licence = TwoPartTariffFixture.licence(region) - billingAccount = _billingAccount() + chargingModuleCreateTransactionRequestStub = Sinon.stub(ChargingModuleCreateTransactionRequest, 'send') - licence = _licence() + billInsertStub = Sinon.stub() + billLicenceInsertStub = Sinon.stub() + transactionInsertStub = Sinon.stub() - chargingModuleCreateTransactionRequestStub = Sinon.stub(ChargingModuleCreateTransactionRequest, 'send') + Sinon.stub(BillModel, 'query').returns({ insert: billInsertStub }) + Sinon.stub(BillLicenceModel, 'query').returns({ insert: billLicenceInsertStub }) + Sinon.stub(TransactionModel, 'query').returns({ insert: transactionInsertStub }) }) afterEach(() => { @@ -64,22 +72,22 @@ describe('Bill Runs - Two-part Tariff - Process Billing Period service', () => { describe('and there are billing accounts to process', () => { beforeEach(async () => { chargingModuleCreateTransactionRequestStub.onFirstCall().resolves({ - ..._chargingModuleResponse('7e752fa6-a19c-4779-b28c-6e536f028795') + ...TwoPartTariffFixture.chargingModuleResponse('7e752fa6-a19c-4779-b28c-6e536f028795') }) chargingModuleCreateTransactionRequestStub.onSecondCall().resolves({ - ..._chargingModuleResponse('a2086da4-e3b6-4b83-afe1-0e2e5255efaf') + ...TwoPartTariffFixture.chargingModuleResponse('a2086da4-e3b6-4b83-afe1-0e2e5255efaf') }) }) describe('and they are billable', () => { beforeEach(async () => { // We want to ensure there is coverage of the functionality that finds an existing bill licence or creates a - // new one when processing a billing account. To to that we need a billing account with 2 charge versions + // new one when processing a billing account. To do that we need a billing account with 2 charge versions // linked to the same licence billingAccount.chargeVersions = [ - _chargeVersion(billingAccount.id, licence), - _chargeVersion(billingAccount.id, licence) + TwoPartTariffFixture.chargeVersion(billingAccount.id, licence), + TwoPartTariffFixture.chargeVersion(billingAccount.id, licence) ] }) @@ -88,30 +96,45 @@ describe('Bill Runs - Two-part Tariff - Process Billing Period service', () => { expect(result).to.be.true() - const bills = await _fetchPersistedBill(billRun.id) + // NOTE: We pass a single bill per billing account when persisting + const billInsertArgs = billInsertStub.args[0] - expect(bills).to.have.length(1) - expect(bills[0]).to.equal( + expect(billInsertStub.calledOnce).to.be.true() + expect(billInsertArgs[0]).to.equal( { accountNumber: billingAccount.accountNumber, address: {}, // Address is set to an empty object for SROC billing invoices billingAccountId: billingAccount.id, + billRunId: billRun.id, credit: false, financialYearEnding: billingPeriod.endDate.getFullYear() }, - { skip: ['billLicences'] } + { skip: ['id'] } ) - expect(bills[0].billLicences).to.have.length(1) - expect(bills[0].billLicences[0]).to.equal( + // NOTE: A bill may have multiple bill licences, so we always pass them as an array + const billLicenceInsertArgs = billLicenceInsertStub.args[0] + + expect(billLicenceInsertStub.calledOnce).to.be.true() + expect(billLicenceInsertArgs[0]).to.have.length(1) + expect(billLicenceInsertArgs[0][0]).to.equal( { + billId: billInsertArgs[0].id, licenceId: licence.id, licenceRef: licence.licenceRef }, - { skip: ['transactions'] } + { skip: ['id'] } ) - expect(bills[0].billLicences[0].transactions).to.have.length(2) + // NOTE: And for performance reasons, we pass _all_ transactions for all bill licences at once + const transactionInsertArgs = transactionInsertStub.args[0] + + expect(transactionInsertStub.calledOnce).to.be.true() + expect(transactionInsertArgs[0]).to.have.length(2) + + // We just check that on of the transactions being persisted is linked to the records we expect + expect(transactionInsertArgs[0][0].billLicenceId).equal(billLicenceInsertArgs[0][0].id) + expect(transactionInsertArgs[0][0].externalId).equal('7e752fa6-a19c-4779-b28c-6e536f028795') }) }) @@ -121,15 +144,15 @@ describe('Bill Runs - Two-part Tariff - Process Billing Period service', () => { // that is also linked to our billing account. The engine will determine that the charge period for the charge // version is invalid so won't attempt to generate a transaction. If we did try, the Charging Module would // only reject it. - const unbillableLicence = _licence() + const unbillableLicence = TwoPartTariffFixture.licence(region) unbillableLicence.revokedDate = new Date('2019-01-01') - const unbillableChargeVersion = _chargeVersion(billingAccount.id, unbillableLicence) + const unbillableChargeVersion = TwoPartTariffFixture.chargeVersion(billingAccount.id, unbillableLicence) billingAccount.chargeVersions = [ - _chargeVersion(billingAccount.id, licence), - _chargeVersion(billingAccount.id, licence), + TwoPartTariffFixture.chargeVersion(billingAccount.id, licence), + TwoPartTariffFixture.chargeVersion(billingAccount.id, licence), unbillableChargeVersion ] }) @@ -139,53 +162,84 @@ describe('Bill Runs - Two-part Tariff - Process Billing Period service', () => { expect(result).to.be.true() - const bills = await _fetchPersistedBill(billRun.id) + // NOTE: We pass a single bill per billing account when persisting + const billInsertArgs = billInsertStub.args[0] - expect(bills).to.have.length(1) - expect(bills[0]).to.equal( + expect(billInsertStub.calledOnce).to.be.true() + expect(billInsertArgs[0]).to.equal( { accountNumber: billingAccount.accountNumber, address: {}, // Address is set to an empty object for SROC billing invoices billingAccountId: billingAccount.id, + billRunId: billRun.id, credit: false, financialYearEnding: billingPeriod.endDate.getFullYear() }, - { skip: ['billLicences'] } + { skip: ['id'] } ) - expect(bills[0].billLicences).to.have.length(1) - expect(bills[0].billLicences[0]).to.equal( + // NOTE: A bill may have multiple bill licences, so we always pass them as an array + const billLicenceInsertArgs = billLicenceInsertStub.args[0] + + expect(billLicenceInsertStub.calledOnce).to.be.true() + expect(billLicenceInsertArgs[0]).to.have.length(1) + expect(billLicenceInsertArgs[0][0]).to.equal( { + billId: billInsertArgs[0].id, licenceId: licence.id, licenceRef: licence.licenceRef }, - { skip: ['transactions'] } + { skip: ['id'] } ) - expect(bills[0].billLicences[0].transactions).to.have.length(2) + // NOTE: And for performance reasons, we pass _all_ transactions for all bill licences at once + const transactionInsertArgs = transactionInsertStub.args[0] + + expect(transactionInsertStub.calledOnce).to.be.true() + expect(transactionInsertArgs[0]).to.have.length(2) + + // We just check that on of the transactions being persisted is linked to the records we expect + expect(transactionInsertArgs[0][0].billLicenceId).equal(billLicenceInsertArgs[0][0].id) + expect(transactionInsertArgs[0][0].externalId).equal('7e752fa6-a19c-4779-b28c-6e536f028795') }) }) describe('but they are not billable', () => { - beforeEach(() => { - // This time we update the charge version so that nothing is allocated in the charge references. This means - // the service will not generate any transactions and therefore no bills leading to bills being empty - const unbillableChargeVersion = _chargeVersion(billingAccount.id, licence) + describe('because the billable volume is 0', () => { + beforeEach(() => { + // This time we update the charge version so that nothing is allocated in the charge references. This means + // the service will not generate any transactions and therefore no bills leading to bills being empty + const unbillableChargeVersion = TwoPartTariffFixture.chargeVersion(billingAccount.id, licence) + + unbillableChargeVersion.chargeReferences[0].chargeElements[0].reviewChargeElements[0].amendedAllocated = 0 + unbillableChargeVersion.chargeReferences[0].chargeElements[1].reviewChargeElements[0].amendedAllocated = 0 + + billingAccount.chargeVersions = [unbillableChargeVersion] + }) + + it('returns false (bill run is empty) and persists nothing', async () => { + const result = await ProcessBillingPeriodService.go(billRun, billingPeriod, [billingAccount]) - unbillableChargeVersion.chargeReferences[0].chargeElements[0].reviewChargeElements[0].amendedAllocated = 0 - unbillableChargeVersion.chargeReferences[0].chargeElements[1].reviewChargeElements[0].amendedAllocated = 0 + expect(result).to.be.false() - billingAccount.chargeVersions = [unbillableChargeVersion] + expect(billInsertStub.called).to.be.false() + }) }) - it('returns false (bill run is empty) and persists nothing', async () => { - const result = await ProcessBillingPeriodService.go(billRun, billingPeriod, [billingAccount]) + describe('because the charge period is invalid (perhaps the licence has been ended)', () => { + beforeEach(() => { + licence.revokedDate = new Date('2022-03-31') + + billingAccount.chargeVersions = [TwoPartTariffFixture.chargeVersion(billingAccount.id, licence)] + }) - expect(result).to.be.false() + it('returns false (bill run is empty) and persists nothing', async () => { + const result = await ProcessBillingPeriodService.go(billRun, billingPeriod, [billingAccount]) - const bills = await _fetchPersistedBill(billRun.id) + expect(result).to.be.false() - expect(bills).to.be.empty() + expect(billInsertStub.called).to.be.false() + }) }) }) }) @@ -193,7 +247,7 @@ describe('Bill Runs - Two-part Tariff - Process Billing Period service', () => { describe('when the service errors', () => { beforeEach(async () => { - billingAccount.chargeVersions = [_chargeVersion(billingAccount.id, licence)] + billingAccount.chargeVersions = [TwoPartTariffFixture.chargeVersion(billingAccount.id, licence)] }) describe('because generating the calculated transaction fails', () => { @@ -223,122 +277,3 @@ describe('Bill Runs - Two-part Tariff - Process Billing Period service', () => { }) }) }) - -function _billingAccount() { - return { - id: generateUUID(), - accountNumber: generateAccountNumber() - } -} - -function _chargingModuleResponse(transactionId) { - return { - succeeded: true, - response: { - body: { transaction: { id: transactionId } } - } - } -} - -function _chargeVersion(billingAccountId, licence) { - // NOTE: We are faking an Objection model which comes with a toJSON() method that gets called as part - // of processing the billing account. - const toJSON = () => { - return '{}' - } - - return { - id: generateUUID(), - scheme: 'sroc', - startDate: new Date('2022-04-01'), - endDate: null, - billingAccountId, - status: 'current', - licence, - chargeReferences: [ - { - id: generateUUID(), - additionalCharges: { isSupplyPublicWater: false }, - adjustments: { - s126: null, - s127: false, - s130: false, - charge: null, - winter: false, - aggregate: '0.562114443' - }, - chargeCategory: { - id: 'b270718a-12c0-4fca-884b-3f8612dbe2f5', - reference: '4.4.5', - shortDescription: 'Low loss, non-tidal, restricted water, up to and including 5,000 ML/yr, Tier 1 model' - }, - chargeElements: [ - { - id: 'e6b98712-227a-40c2-b93a-c05e9047be8c', - abstractionPeriodStartDay: 1, - abstractionPeriodStartMonth: 4, - abstractionPeriodEndDay: 31, - abstractionPeriodEndMonth: 3, - reviewChargeElements: [{ id: '1d9050b2-09c8-4570-8173-7f55921437cc', amendedAllocated: 5 }], - toJSON - }, - { - id: '9e6f3f64-78d5-441b-80fc-e01711b2f766', - abstractionPeriodStartDay: 1, - abstractionPeriodStartMonth: 4, - abstractionPeriodEndDay: 31, - abstractionPeriodEndMonth: 3, - reviewChargeElements: [{ id: '17f0c41e-e894-41d2-8a68-69dd2b39e9f9', amendedAllocated: 10 }], - toJSON - } - ], - description: 'Lower Queenstown - Pittisham', - loss: 'low', - reviewChargeReferences: [ - { - id: '3dd04348-2c06-4559-9343-dd7dd76276ef', - amendedAggregate: 0.75, - amendedAuthorisedVolume: 20, - amendedChargeAdjustment: 0.6 - } - ], - source: 'non-tidal', - volume: 20 - } - ] - } -} - -async function _fetchPersistedBill(billRunId) { - return BillModel.query() - .select(['accountNumber', 'address', 'billingAccountId', 'credit', 'financialYearEnding']) - .where('billRunId', billRunId) - .withGraphFetched('billLicences') - .modifyGraph('billLicences', (builder) => { - builder.select(['licenceId', 'licenceRef']) - }) - .withGraphFetched('billLicences.transactions') - .modifyGraph('billLicences.transactions', (builder) => { - builder.select(['id']) - }) -} - -function _licence() { - const region = RegionHelper.select() - - return { - id: generateUUID(), - licenceRef: generateLicenceRef(), - waterUndertaker: true, - historicalAreaCode: 'SAAR', - regionalChargeArea: 'Southern', - startDate: new Date('2022-01-01'), - expiredDate: null, - lapsedDate: null, - revokedDate: null, - region: { - id: region.id, - chargeRegionId: region.chargeRegionId - } - } -}