From a101846913e00815a7217e1019486b7ae60b95c9 Mon Sep 17 00:00:00 2001 From: Stuart Adair <43574728+StuAA78@users.noreply.github.com> Date: Fri, 24 Mar 2023 10:02:39 +0000 Subject: [PATCH] Create Reverse Billing Batch Licences service (#167) https://eaflood.atlassian.net/browse/WATER-3936 When we generate a supplementary bill run we are required to reverse the debits from the previous one. Essentially, when a licence is flagged for supplementary billing, for whatever reason, we reverse any previous bill runs and generate a new one. With the exception of Annual billing, because we know any previous supplementary bill run will have reversed the debits on the one that came before, any new runs also just need to focus on the ones previous to them. This is complicated, however, by the fact we then need to ignore transactions that cancel each other out. For example, Customer **ABC1** was billed 120 days for licence **LIC001** on the previous bill run. Our supplementary process calculates 120 days for the new charge version. These cancel each other out so should not be included. So, for each charge version `FetchChargeVersionsService` returns we'll need to identify if they are linked to a previous `billing_batch`. From there we'll need to locate the transactions, determine if they cancel out what we've calculated and if not include them as credits in the supplementary bill run we're creating. > Note - This PR originated to create a service to solve a part of this problem. It then became our 'SPIKE' branch and has culminated in all the changes needed. We _will_ be doing further cleanup and documentation after this change has been merged. --- .../fetch-charge-versions.service.js | 4 + ...h-previous-billing-transactions.service.js | 85 +++++++ ...enerate-billing-invoice-licence.service.js | 28 +-- .../generate-billing-invoice.service.js | 25 +- .../process-billing-batch.service.js | 231 ++++++++---------- .../process-billing-transactions.service.js | 67 +++++ .../reverse-billing-transactions.service.js | 71 ++++++ ...te-billing-invoice-licence.service.test.js | 63 ++--- .../generate-billing-invoice.service.test.js | 60 ++--- ...ocess-billing-transactions.service.test.js | 193 +++++++++++++++ ...verse-billing-transactions.service.test.js | 46 ++++ 11 files changed, 643 insertions(+), 230 deletions(-) create mode 100644 app/services/supplementary-billing/fetch-previous-billing-transactions.service.js create mode 100644 app/services/supplementary-billing/process-billing-transactions.service.js create mode 100644 app/services/supplementary-billing/reverse-billing-transactions.service.js create mode 100644 test/services/supplementary-billing/process-billing-transactions.service.test.js create mode 100644 test/services/supplementary-billing/reverse-billing-transactions.service.test.js diff --git a/app/services/supplementary-billing/fetch-charge-versions.service.js b/app/services/supplementary-billing/fetch-charge-versions.service.js index 7173e216ae..ce5d6f378f 100644 --- a/app/services/supplementary-billing/fetch-charge-versions.service.js +++ b/app/services/supplementary-billing/fetch-charge-versions.service.js @@ -42,6 +42,10 @@ async function _fetch (regionId, billingPeriod) { .where('chargeVersions.status', 'current') .where('chargeVersions.startDate', '>=', billingPeriod.startDate) .where('chargeVersions.startDate', '<=', billingPeriod.endDate) + .orderBy([ + { column: 'chargeVersions.invoiceAccountId' }, + { column: 'chargeVersions.licenceId' } + ]) .withGraphFetched('licence') .modifyGraph('licence', builder => { builder.select([ diff --git a/app/services/supplementary-billing/fetch-previous-billing-transactions.service.js b/app/services/supplementary-billing/fetch-previous-billing-transactions.service.js new file mode 100644 index 0000000000..3f1990144c --- /dev/null +++ b/app/services/supplementary-billing/fetch-previous-billing-transactions.service.js @@ -0,0 +1,85 @@ +'use strict' + +/** + * Fetches the previously billed transactions that match the invoice, licence and year provided + * @module FetchPreviousBillingTransactionsService + */ + +const { db } = require('../../../db/db.js') + +async function go (billingInvoice, billingInvoiceLicence, financialYearEnding) { + const billingTransactions = await _fetch( + billingInvoiceLicence.licenceId, + billingInvoice.invoiceAccountId, + financialYearEnding + ) + + return billingTransactions +} + +async function _fetch (licenceId, invoiceAccountId, financialYearEnding) { + return db + .select( + 'bt.authorisedDays', + 'bt.billableDays', + 'bt.isWaterUndertaker', + 'bt.chargeElementId', + 'bt.startDate', + 'bt.endDate', + 'bt.source', + 'bt.season', + 'bt.loss', + 'bt.isCredit', + 'bt.chargeType', + 'bt.authorisedQuantity', + 'bt.billableQuantity', + 'bt.description', + 'bt.volume', + 'bt.section126Factor', + 'bt.section127Agreement', + 'bt.section130Agreement', + 'bt.isTwoPartSecondPartCharge', + 'bt.scheme', + 'bt.aggregateFactor', + 'bt.adjustmentFactor', + 'bt.chargeCategoryCode', + 'bt.chargeCategoryDescription', + 'bt.isSupportedSource', + 'bt.supportedSourceName', + 'bt.isWaterCompanyCharge', + 'bt.isWinterOnly', + 'bt.purposes', + 'validBillingInvoices.invoiceAccountId', + 'validBillingInvoices.invoiceAccountNumber' + ) + .from('water.billingTransactions as bt') + .innerJoin( + db + .select( + 'bil.billingInvoiceLicenceId', + 'bi.invoiceAccountId', + 'bi.invoiceAccountNumber' + ) + .max('bil.date_created as latest_date_created') + .from('water.billingInvoiceLicences as bil') + .innerJoin('water.billingInvoices as bi', 'bil.billingInvoiceId', 'bi.billingInvoiceId') + .innerJoin('water.billingBatches as bb', 'bi.billingBatchId', 'bb.billingBatchId') + .where({ + 'bil.licenceId': licenceId, + 'bi.invoiceAccountId': invoiceAccountId, + 'bi.financialYearEnding': financialYearEnding, + 'bb.status': 'sent', + 'bb.scheme': 'sroc' + }) + .groupBy('bil.billingInvoiceLicenceId', 'bi.invoiceAccountId', 'bi.invoiceAccountNumber') + .as('validBillingInvoices'), + 'bt.billingInvoiceLicenceId', 'validBillingInvoices.billingInvoiceLicenceId' + ) + .where({ + 'bt.isCredit': false + }) +} + +module.exports = { + go +} diff --git a/app/services/supplementary-billing/generate-billing-invoice-licence.service.js b/app/services/supplementary-billing/generate-billing-invoice-licence.service.js index 170cf65fa3..144d57701b 100644 --- a/app/services/supplementary-billing/generate-billing-invoice-licence.service.js +++ b/app/services/supplementary-billing/generate-billing-invoice-licence.service.js @@ -35,34 +35,22 @@ const { randomUUID } = require('crypto') * @returns {Object} A result object containing either the found or generated billing invoice licence object, and an * array of generated billing invoice licences which includes the one being returned */ -function go (generatedBillingInvoiceLicences, billingInvoiceId, licence) { - let billingInvoiceLicence = _existing(generatedBillingInvoiceLicences, billingInvoiceId, licence.licenceId) - - if (billingInvoiceLicence) { - return { - billingInvoiceLicence, - billingInvoiceLicences: generatedBillingInvoiceLicences - } +function go (currentBillingInvoiceLicence, billingInvoiceId, licence) { + if ( + currentBillingInvoiceLicence?.billingInvoiceId === billingInvoiceId && + currentBillingInvoiceLicence?.licenceId === licence.licenceId + ) { + return currentBillingInvoiceLicence } - billingInvoiceLicence = { + const billingInvoiceLicence = { billingInvoiceId, billingInvoiceLicenceId: randomUUID({ disableEntropyCache: true }), licenceRef: licence.licenceRef, licenceId: licence.licenceId } - const updatedBillingInvoiceLicences = [...generatedBillingInvoiceLicences, billingInvoiceLicence] - - return { - billingInvoiceLicence, - billingInvoiceLicences: updatedBillingInvoiceLicences - } -} -function _existing (generatedBillingInvoiceLicences, billingInvoiceId, licenceId) { - return generatedBillingInvoiceLicences.find((invoiceLicence) => { - return (billingInvoiceId === invoiceLicence.billingInvoiceId && licenceId === invoiceLicence.licenceId) - }) + return billingInvoiceLicence } module.exports = { diff --git a/app/services/supplementary-billing/generate-billing-invoice.service.js b/app/services/supplementary-billing/generate-billing-invoice.service.js index fa081b56e8..c74f6e1251 100644 --- a/app/services/supplementary-billing/generate-billing-invoice.service.js +++ b/app/services/supplementary-billing/generate-billing-invoice.service.js @@ -37,19 +37,14 @@ const InvoiceAccountModel = require('../../models/crm-v2/invoice-account.model.j * @returns {Object} A result object containing either the found or generated billing invoice object, and an array of * generated billing invoices which includes the one being returned */ -async function go (generatedBillingInvoices, invoiceAccountId, billingBatchId, financialYearEnding) { - let billingInvoice = _existing(generatedBillingInvoices, invoiceAccountId) - - if (billingInvoice) { - return { - billingInvoice, - billingInvoices: generatedBillingInvoices - } +async function go (currentBillingInvoice, invoiceAccountId, billingBatchId, financialYearEnding) { + if (currentBillingInvoice?.invoiceAccountId === invoiceAccountId) { + return currentBillingInvoice } const invoiceAccount = await InvoiceAccountModel.query().findById(invoiceAccountId) - billingInvoice = { + const billingInvoice = { billingBatchId, financialYearEnding, invoiceAccountId, @@ -58,18 +53,8 @@ async function go (generatedBillingInvoices, invoiceAccountId, billingBatchId, f invoiceAccountNumber: invoiceAccount.invoiceAccountNumber, isCredit: false } - const updatedBillingInvoices = [...generatedBillingInvoices, billingInvoice] - - return { - billingInvoice, - billingInvoices: updatedBillingInvoices - } -} -function _existing (generatedBillingInvoices, invoiceAccountId) { - return generatedBillingInvoices.find((invoice) => { - return invoiceAccountId === invoice.invoiceAccountId - }) + return billingInvoice } module.exports = { diff --git a/app/services/supplementary-billing/process-billing-batch.service.js b/app/services/supplementary-billing/process-billing-batch.service.js index 19ca05b29f..82ccff10e7 100644 --- a/app/services/supplementary-billing/process-billing-batch.service.js +++ b/app/services/supplementary-billing/process-billing-batch.service.js @@ -20,6 +20,7 @@ const GenerateBillingInvoiceService = require('./generate-billing-invoice.servic const GenerateBillingInvoiceLicenceService = require('./generate-billing-invoice-licence.service.js') const HandleErroredBillingBatchService = require('./handle-errored-billing-batch.service.js') const LegacyRequestLib = require('../../lib/legacy-request.lib.js') +const ProcessBillingTransactionsService = require('./process-billing-transactions.service.js') /** * Creates the invoices and transactions in both WRLS and the Charging Module API @@ -32,14 +33,14 @@ const LegacyRequestLib = require('../../lib/legacy-request.lib.js') async function go (billingBatch, billingPeriod) { const { billingBatchId } = billingBatch - // NOTE: This is where we store generated data that _might_ be persisted when we finalise the billing batch. We need - // to generate invoice and invoice licence information so we can persist those transactions we want to bill. When we - // persist a transaction we flag the accompanying invoice and invoice licence as needing persisting. - // This means a number of our methods are mutating this information as the billing batch is processed. - const generatedData = { - invoices: [], - invoiceLicences: [] + const currentBillingData = { + isEmpty: true, + licence: null, + billingInvoice: null, + billingInvoiceLicence: null, + calculatedTransactions: [] } + try { // Mark the start time for later logging const startTime = process.hrtime.bigint() @@ -49,26 +50,35 @@ async function go (billingBatch, billingPeriod) { const chargeVersions = await _fetchChargeVersions(billingBatch, billingPeriod) for (const chargeVersion of chargeVersions) { - const { licence } = chargeVersion - - const billingInvoice = await _generateBillingInvoice(generatedData, chargeVersion, billingBatchId, billingPeriod) - const billingInvoiceLicence = _generateBillingInvoiceLicence(generatedData, billingInvoice, licence) - - const transactionLines = _generateTransactionLines(billingPeriod, chargeVersion, billingBatchId) - - await _createTransactionLines( - transactionLines, - billingPeriod, - billingInvoice, - billingInvoiceLicence, + const { billingInvoice, billingInvoiceLicence } = await _generateInvoiceData( + currentBillingData, + billingBatch, chargeVersion, - billingBatch + billingPeriod ) + + // We need to deal with the very first iteration when currentBillingData is all nulls! So, we check both there is + // a billingInvoiceLicence and that its ID is different + if ( + currentBillingData.billingInvoiceLicence && + currentBillingData.billingInvoiceLicence.billingInvoiceLicenceId !== billingInvoiceLicence.billingInvoiceLicenceId + ) { + await _finaliseCurrentInvoiceLicence(currentBillingData, billingPeriod, billingBatch) + currentBillingData.calculatedTransactions = [] + } + + currentBillingData.licence = chargeVersion.licence + currentBillingData.billingInvoice = billingInvoice + currentBillingData.billingInvoiceLicence = billingInvoiceLicence + + const calculatedTransactions = _generateCalculatedTransactions(billingPeriod, chargeVersion, billingBatchId, billingInvoiceLicence) + currentBillingData.calculatedTransactions.push(...calculatedTransactions) } + await _finaliseCurrentInvoiceLicence(currentBillingData, billingPeriod, billingBatch) - await _finaliseBillingBatch(generatedData, billingBatch) + await _finaliseBillingBatch(billingBatch, currentBillingData.isEmpty) - // Log how log the process took + // Log how long the process took _calculateAndLogTime(billingBatchId, startTime) } catch (error) { global.GlobalNotifier.omfg('Billing Batch process errored', { billingBatch, error }) @@ -93,36 +103,48 @@ function _calculateAndLogTime (billingBatchId, startTime) { global.GlobalNotifier.omg(`Time taken to process billing batch ${billingBatchId}: ${timeTakenMs}ms`) } -async function _generateBillingInvoice (generatedData, chargeVersion, billingBatchId, billingPeriod) { +async function _createBillingInvoiceLicence (currentBillingData, billingBatch) { + const { billingInvoice, billingInvoiceLicence } = currentBillingData + try { - const billingInvoiceData = await GenerateBillingInvoiceService.go( - generatedData.invoices, - chargeVersion.invoiceAccountId, - billingBatchId, - billingPeriod.endDate.getFullYear() - ) - generatedData.invoices = billingInvoiceData.billingInvoices + if (!billingInvoice.persisted) { + await BillingInvoiceModel.query().insert(billingInvoice) + billingInvoice.persisted = true + } - return billingInvoiceData.billingInvoice + await BillingInvoiceLicenceModel.query().insert(billingInvoiceLicence) } catch (error) { - HandleErroredBillingBatchService.go(billingBatchId) + HandleErroredBillingBatchService.go(billingBatch.billingBatchId) throw error } } -function _generateBillingInvoiceLicence (generatedData, billingInvoice, licence) { +async function _createBillingTransactions (currentBillingData, billingBatch, billingTransactions, billingPeriod) { + const { licence, billingInvoice, billingInvoiceLicence } = currentBillingData + try { - const billingInvoiceLicenceData = GenerateBillingInvoiceLicenceService.go( - generatedData.invoiceLicences, - billingInvoice.billingInvoiceId, - licence - ) - generatedData.invoiceLicences = billingInvoiceLicenceData.billingInvoiceLicences + for (const transaction of billingTransactions) { + const chargingModuleRequest = ChargingModuleCreateTransactionPresenter.go( + transaction, + billingPeriod, + billingInvoice.invoiceAccountNumber, + licence + ) + + const chargingModuleResponse = await ChargingModuleCreateTransactionService.go(billingBatch.externalId, chargingModuleRequest) + + transaction.status = 'charge_created' + transaction.externalId = chargingModuleResponse.response.body.transaction.id + transaction.billingInvoiceLicenceId = billingInvoiceLicence.billingInvoiceLicenceId - return billingInvoiceLicenceData.billingInvoiceLicence + await CreateBillingTransactionService.go(transaction) + } } catch (error) { - HandleErroredBillingBatchService.go(billingInvoice.billingBatchId) + HandleErroredBillingBatchService.go( + billingBatch.billingBatchId, + BillingBatchModel.errorCodes.failedToCreateCharge + ) throw error } @@ -145,91 +167,77 @@ async function _fetchChargeVersions (billingBatch, billingPeriod) { } } -async function _createTransactionLines ( - transactionLines, - billingPeriod, - billingInvoice, - billingInvoiceLicence, - chargeVersion, - billingBatch -) { - if (transactionLines.length === 0) { - return - } - +async function _finaliseBillingBatch (billingBatch, isEmpty) { try { - for (const transaction of transactionLines) { - const chargingModuleRequest = ChargingModuleCreateTransactionPresenter.go( - transaction, - billingPeriod, - billingInvoice.invoiceAccountNumber, - chargeVersion.licence - ) - - const chargingModuleResponse = await ChargingModuleCreateTransactionService.go(billingBatch.externalId, chargingModuleRequest) - - transaction.status = 'charge_created' - transaction.externalId = chargingModuleResponse.response.body.transaction.id - transaction.billingInvoiceLicenceId = billingInvoiceLicence.billingInvoiceLicenceId + // The bill run is considered empty. We just need to set the status to indicate this in the UI + if (isEmpty) { + await _updateStatus(billingBatch.billingBatchId, 'empty') - await CreateBillingTransactionService.go(transaction) + return } - billingInvoice.persist = true - billingInvoiceLicence.persist = true + // We then need to tell the Charging Module to run its generate process. This is where the Charging module finalises + // the debit and credit amounts, and adds any additional transactions needed, for example, minimum charge + await ChargingModuleGenerateService.go(billingBatch.externalId) + + await LegacyRequestLib.post('water', `billing/batches/${billingBatch.billingBatchId}/refresh`) } catch (error) { - HandleErroredBillingBatchService.go( - billingBatch.billingBatchId, - BillingBatchModel.errorCodes.failedToCreateCharge - ) + HandleErroredBillingBatchService.go(billingBatch.billingBatchId) throw error } } -async function _finaliseBillingBatch (generatedData, billingBatch) { - const { invoices, invoiceLicences } = generatedData - const { billingBatchId, externalId } = billingBatch - +async function _finaliseCurrentInvoiceLicence (currentBillingData, billingPeriod, billingBatch) { try { - const billingInvoicesToInsert = invoices.filter((billingInvoice) => billingInvoice.persist) + const cleansedTransactions = await ProcessBillingTransactionsService.go( + currentBillingData.calculatedTransactions, + currentBillingData.billingInvoice, + currentBillingData.billingInvoiceLicence, + billingPeriod + ) - // The bill run is considered empty. We just need to set the status to indicate this in the UI - if (billingInvoicesToInsert.length === 0) { - await _updateStatus(billingBatchId, 'empty') + if (cleansedTransactions.length > 0) { + currentBillingData.isEmpty = false - return + await _createBillingTransactions(currentBillingData, billingBatch, cleansedTransactions, billingPeriod) + await _createBillingInvoiceLicence(currentBillingData, billingBatch) } + } catch (error) { + HandleErroredBillingBatchService.go(billingBatch.billingBatchId) - // We need to persist the billing invoice and billing invoice licence records - const billingInvoiceLicencesToInsert = invoiceLicences.filter((invoiceLicence) => invoiceLicence.persist) + throw error + } +} - await _persistBillingInvoices(billingInvoicesToInsert) - await _persistBillingInvoiceLicences(billingInvoiceLicencesToInsert) +async function _generateInvoiceData (currentBillingData, billingBatch, chargeVersion, billingPeriod) { + try { + const { invoiceAccountId, licence } = chargeVersion + const { billingBatchId } = billingBatch + const financialYearEnding = billingPeriod.endDate.getFullYear() - // We then need to tell the Charging Module to run its generate process. This is where the Charging module finalises - // the debit and credit amounts, and adds any additional transactions needed, for example, minimum charge - await ChargingModuleGenerateService.go(externalId) + const billingInvoice = await GenerateBillingInvoiceService.go(currentBillingData.billingInvoice, invoiceAccountId, billingBatchId, financialYearEnding) + const billingInvoiceLicence = GenerateBillingInvoiceLicenceService.go(currentBillingData.billingInvoiceLicence, billingInvoice.billingInvoiceId, licence) - // We then tell our legacy service to queue up its refresh totals job. This requests the finalised bill run and - // invoice detail from the Charging Module and updates our data with it. The good news is the legacy code handles - // all that within this job. We just need to queue it up 😁 - await LegacyRequestLib.post('water', `billing/batches/${billingBatchId}/refresh`) + return { + billingInvoice, + billingInvoiceLicence + } } catch (error) { - HandleErroredBillingBatchService.go(billingBatchId) + HandleErroredBillingBatchService.go(billingBatch.billingBatchId) throw error } } -function _generateTransactionLines (billingPeriod, chargeVersion, billingBatchId) { +function _generateCalculatedTransactions (billingPeriod, chargeVersion, billingBatchId, billingInvoiceLicence) { try { const financialYearEnding = billingPeriod.endDate.getFullYear() const chargePeriod = DetermineChargePeriodService.go(chargeVersion, financialYearEnding) const isNewLicence = DetermineMinimumChargeService.go(chargeVersion, financialYearEnding) const isWaterUndertaker = chargeVersion.licence.isWaterUndertaker - const transactionLines = [] + const transactions = [] for (const chargeElement of chargeVersion.chargeElements) { const result = GenerateBillingTransactionsService.go( chargeElement, @@ -238,10 +246,11 @@ function _generateTransactionLines (billingPeriod, chargeVersion, billingBatchId isNewLicence, isWaterUndertaker ) - transactionLines.push(...result) + result.billingInvoiceLicenceId = billingInvoiceLicence.billingInvoiceLicenceId + transactions.push(...result) } - return transactionLines + return transactions } catch (error) { HandleErroredBillingBatchService.go( billingBatchId, @@ -252,32 +261,6 @@ function _generateTransactionLines (billingPeriod, chargeVersion, billingBatchId } } -async function _persistBillingInvoiceLicences (billingInvoiceLicences) { - // We have to remove the .persist flag we added else the SQL query Objection will generate will fail. So, we use - // object destructuring assignment to assign the `persist:` property to one var, and the rest to 'another' (which we - // name `propertiesToPersist`). We then have map() just return `propertiesToPersist` leaving 'persist' behind! - // Credit: https://stackoverflow.com/a/46839399/6117745 - const insertData = billingInvoiceLicences.map((billingInvoiceLicence) => { - const { persist, ...propertiesToPersist } = billingInvoiceLicence - - return propertiesToPersist - }) - - await BillingInvoiceLicenceModel.query() - .insert(insertData) -} - -async function _persistBillingInvoices (billingInvoices) { - const insertData = billingInvoices.map((billingInvoice) => { - const { persist, ...propertiesToPersist } = billingInvoice - - return propertiesToPersist - }) - - await BillingInvoiceModel.query() - .insert(insertData) -} - async function _updateStatus (billingBatchId, status) { try { await BillingBatchModel.query() diff --git a/app/services/supplementary-billing/process-billing-transactions.service.js b/app/services/supplementary-billing/process-billing-transactions.service.js new file mode 100644 index 0000000000..e193431ade --- /dev/null +++ b/app/services/supplementary-billing/process-billing-transactions.service.js @@ -0,0 +1,67 @@ +'use strict' + +const FetchPreviousBillingTransactionsService = require('./fetch-previous-billing-transactions.service.js') +const ReverseBillingTransactionsService = require('./reverse-billing-transactions.service.js') + +async function go (calculatedTransactions, billingInvoice, billingInvoiceLicence, billingPeriod) { + const previousTransactions = await _fetchPreviousTransactions(billingInvoice, billingInvoiceLicence, billingPeriod) + + if (previousTransactions.length === 0) { + return calculatedTransactions + } + + const reversedTransactions = ReverseBillingTransactionsService.go(previousTransactions, billingInvoiceLicence) + + return _cleanseTransactions(calculatedTransactions, reversedTransactions) +} + +function _cancelCalculatedTransaction (calculatedTransaction, reversedTransactions) { + const result = reversedTransactions.findIndex((reversedTransaction) => { + // Example of the things we are comparing + // - chargeType - standard or compensation + // - chargeCategory - 4.10.1 + // - billableDays - 215 + return reversedTransaction.chargeType === calculatedTransaction.chargeType && + reversedTransaction.chargeCategoryCode === calculatedTransaction.chargeCategoryCode && + reversedTransaction.billableDays === calculatedTransaction.billableDays + }) + + if (result === -1) { + return false + } + + reversedTransactions.splice(result, 1) + + return true +} + +/** + * Remove any "cancelling pairs" of transaction lines. We define a "cancelling pair" as a pair of transactions belonging + * to the same billing invoice licence which would send the same data to the Charging Module (and therefore return the + * same values) but with opposing credit flags -- in other words, a credit and a debit which cancel each other out. + */ +function _cleanseTransactions (calculatedTransactions, reverseTransactions) { + const cleansedTransactionLines = [] + + for (const calculatedTransactionLine of calculatedTransactions) { + if (!_cancelCalculatedTransaction(calculatedTransactionLine, reverseTransactions)) { + cleansedTransactionLines.push(calculatedTransactionLine) + } + } + + cleansedTransactionLines.push(...reverseTransactions) + + return cleansedTransactionLines +} + +async function _fetchPreviousTransactions (billingInvoice, billingInvoiceLicence, billingPeriod) { + const financialYearEnding = billingPeriod.endDate.getFullYear() + + const transactions = await FetchPreviousBillingTransactionsService.go(billingInvoice, billingInvoiceLicence, financialYearEnding) + + return transactions +} + +module.exports = { + go +} diff --git a/app/services/supplementary-billing/reverse-billing-transactions.service.js b/app/services/supplementary-billing/reverse-billing-transactions.service.js new file mode 100644 index 0000000000..4f9d9c807c --- /dev/null +++ b/app/services/supplementary-billing/reverse-billing-transactions.service.js @@ -0,0 +1,71 @@ +'use strict' + +/** + * Takes previously billed transactions and returns reversed and cleansed versions of them + * @module ReverseBillingTransactionsService + */ + +const { randomUUID } = require('crypto') + +/** + * Takes an array of transactions and returns an array of transactions which will reverse them. + * + * In some situations we need to "reverse" transactions; this is done by issuing new transactions which cancel them out. + * This service takes an array of transactions and a billing invoice licence, and returns an array of transactions which + * will reverse the original transactions, with their billing invoice licence id set to the id of the supplied billing + * invoice licence. + * + * @param {Array[module:BillingTransactionModel]} transactions Array of transactions to be reversed + * @param {module:BillingInvoiceLicenceModel} billingInvoiceLicence The billing invoice licence these transactions are + * intended to be added to + * + * @returns {Array[Object]} Array of reversing transactions with `billingInvoiceLicenceId` set to the id of the supplied + * `billingInvoiceLicence` + */ +function go (transactions, billingInvoiceLicence) { + return _reverseTransactions(transactions, billingInvoiceLicence) +} + +/** + * Receives an array of debit transactions and returns transactions that will reverse them. These transactions are + * identical except the `isCredit` flag is set to 'true', the status is set to `candidate`, the + * `billingInvoiceLicenceId` is set to the id of the supplied billing invoice licence, and a new `billingTransactionId` + * is generated. + */ +function _reverseTransactions (transactions, billingInvoiceLicence) { + return transactions.map((transaction) => { + // TODO: The FetchBillingTransactionsService which we use to get the transactions to reverse adds the invoice + // account ID and number to each transaction returned. This is a performance measure to avoid an extra query to the + // DB. But if we don't strip them from the result when we try to persist our reversed versions, they fail because + // the billing_transactions table doesn't have these fields. We do the stripping here to avoid iterating through + // the collection multiple times. Ideally, we'd look to return a result from FetchBillingTransactionsService that + // avoids us having to do this. + const { invoiceAccountId, invoiceAccountNumber, ...propertiesToKeep } = transaction + + return { + ...propertiesToKeep, + billingTransactionId: _generateUuid(), + billingInvoiceLicenceId: billingInvoiceLicence.billingInvoiceLicenceId, + isCredit: true, + status: 'candidate', + // TODO: Our query result seems to return the transaction's `purposes:` property as [Object]. Clearly, we need + // to re-jig something or give Knex some more instructions on dealing with this JSONB field. But just to prove + // the process is working we use this service to deal with the issue and extract the JSON from the array we've + // been provided. + purposes: transaction.purposes[0] + } + }) +} + +function _generateUuid () { + // We set `disableEntropyCache` to `false` as normally, for performance reasons node caches enough random data to + // generate up to 128 UUIDs. We disable this as we may need to generate more than this and the performance hit in + // disabling this cache is a rounding error in comparison to the rest of the process. + // + // https://nodejs.org/api/crypto.html#cryptorandomuuidoptions + return randomUUID({ disableEntropyCache: true }) +} + +module.exports = { + go +} diff --git a/test/services/supplementary-billing/generate-billing-invoice-licence.service.test.js b/test/services/supplementary-billing/generate-billing-invoice-licence.service.test.js index bec426347a..bcc37bb558 100644 --- a/test/services/supplementary-billing/generate-billing-invoice-licence.service.test.js +++ b/test/services/supplementary-billing/generate-billing-invoice-licence.service.test.js @@ -18,55 +18,58 @@ describe('Generate billing invoice licence service', () => { const billingInvoiceId = 'f4fb6257-c50f-46ea-80b0-7533423d6efd' - let generatedBillingInvoiceLicences + let currentBillingInvoiceLicence let expectedResult - describe('when `generatedBillingInvoiceLicences` is empty', () => { + describe('when `currentBillingInvoiceLicence` is null', () => { beforeEach(() => { - generatedBillingInvoiceLicences = [] + currentBillingInvoiceLicence = null expectedResult = _billingInvoiceLicenceGenerator(billingInvoiceId, licence) }) - it('returns a new billing invoice licence and an array populated with just it', () => { - const result = GenerateBillingInvoiceLicenceService.go(generatedBillingInvoiceLicences, billingInvoiceId, licence) + it('returns a new billing invoice licence with the provided values', () => { + const result = GenerateBillingInvoiceLicenceService.go(currentBillingInvoiceLicence, billingInvoiceId, licence) - expect(result.billingInvoiceLicence).to.equal(expectedResult, { skip: 'billingInvoiceLicenceId' }) - expect(result.billingInvoiceLicences).to.equal([result.billingInvoiceLicence]) + expect(result).to.equal(expectedResult, { skip: 'billingInvoiceLicenceId' }) }) }) - describe('when `generatedBillingInvoiceLicences` is populated', () => { - describe('and a matching billing invoice licence exists', () => { - let existingBillingInvoiceLicence - - beforeEach(() => { - existingBillingInvoiceLicence = _billingInvoiceLicenceGenerator(billingInvoiceId, licence) - generatedBillingInvoiceLicences = [existingBillingInvoiceLicence] - }) + describe('when `currentBillingInvoiceLicence` is set', () => { + beforeEach(() => { + currentBillingInvoiceLicence = _billingInvoiceLicenceGenerator(billingInvoiceId, licence) + }) - it('returns the existing billing invoice licence object and the existing array', () => { - const result = GenerateBillingInvoiceLicenceService.go(generatedBillingInvoiceLicences, billingInvoiceId, licence) + describe('and the billing invoice id matches', () => { + describe('as well as the licence', () => { + it('returns the `currentBillingInvoiceLicence`', async () => { + const result = GenerateBillingInvoiceLicenceService.go(currentBillingInvoiceLicence, billingInvoiceId, licence) - expect(result.billingInvoiceLicence).to.equal(existingBillingInvoiceLicence) - expect(result.billingInvoiceLicences).to.equal(generatedBillingInvoiceLicences) + expect(result).to.equal(currentBillingInvoiceLicence) + }) }) - }) - describe('and a matching billing invoice licence does not exist', () => { - let existingBillingInvoiceLicence + describe('but the licence does not', () => { + const otherLicence = { licenceId: 'e6c07787-b367-408d-a2f8-a5313cfcef32', licenceRef: 'ABC1' } - beforeEach(() => { - existingBillingInvoiceLicence = _billingInvoiceLicenceGenerator('d0761e82-9c96-4304-9b4c-3c5d4c1af8bb', licence) - generatedBillingInvoiceLicences = [existingBillingInvoiceLicence] + it('returns a new billing invoice licence with the provided values', () => { + const result = GenerateBillingInvoiceLicenceService.go(currentBillingInvoiceLicence, billingInvoiceId, otherLicence) - expectedResult = _billingInvoiceLicenceGenerator(billingInvoiceId, licence) + expect(result).not.to.equal(currentBillingInvoiceLicence) + expect(result.billingInvoiceId).to.equal(billingInvoiceId) + expect(result.licenceId).to.equal(otherLicence.licenceId) + }) }) + }) + + describe('but the billing invoice id does not match', () => { + const otherBillingInvoiceId = 'fc9c4a2f-819d-4b31-9bcc-39c795660602' - it('returns a new billing invoice licence object and the existing array with the new object included', () => { - const result = GenerateBillingInvoiceLicenceService.go(generatedBillingInvoiceLicences, billingInvoiceId, licence) + it('returns a new billing invoice licence with the provided values', () => { + const result = GenerateBillingInvoiceLicenceService.go(currentBillingInvoiceLicence, otherBillingInvoiceId, licence) - expect(result.billingInvoiceLicence).to.equal(expectedResult, { skip: 'billingInvoiceLicenceId' }) - expect(result.billingInvoiceLicences).to.equal([...generatedBillingInvoiceLicences, result.billingInvoiceLicence]) + expect(result).not.to.equal(currentBillingInvoiceLicence) + expect(result.billingInvoiceId).to.equal(otherBillingInvoiceId) + expect(result.licenceId).to.equal(licence.licenceId) }) }) }) diff --git a/test/services/supplementary-billing/generate-billing-invoice.service.test.js b/test/services/supplementary-billing/generate-billing-invoice.service.test.js index e81f7e91d4..7d5d45c79a 100644 --- a/test/services/supplementary-billing/generate-billing-invoice.service.test.js +++ b/test/services/supplementary-billing/generate-billing-invoice.service.test.js @@ -18,7 +18,7 @@ describe('Generate billing invoice service', () => { const billingBatchId = 'f4fb6257-c50f-46ea-80b0-7533423d6efd' const financialYearEnding = 2023 - let generatedBillingInvoices + let currentBillingInvoice let expectedResult let invoiceAccount @@ -28,71 +28,59 @@ describe('Generate billing invoice service', () => { invoiceAccount = await InvoiceAccountHelper.add() }) - describe('when `generatedBillingInvoices` is empty', () => { + describe('when `currentBillingInvoice` is null', () => { beforeEach(async () => { - generatedBillingInvoices = [] + currentBillingInvoice = null expectedResult = _billingInvoiceGenerator(invoiceAccount, billingBatchId, financialYearEnding) }) - it('returns a new billing invoice and an array populated with just it', async () => { + it('returns a new billing invoice with the provided values', async () => { const result = await GenerateBillingInvoiceService.go( - generatedBillingInvoices, + currentBillingInvoice, invoiceAccount.invoiceAccountId, billingBatchId, financialYearEnding ) - expect(result.billingInvoice).to.equal(expectedResult, { skip: 'billingInvoiceId' }) - expect(result.billingInvoices).to.equal([result.billingInvoice]) + expect(result).to.equal(expectedResult, { skip: 'billingInvoiceId' }) }) }) - describe('when `generatedBillingInvoices` is populated', () => { - describe('and a matching billing invoice exists', () => { - let existingBillingInvoice - - beforeEach(async () => { - existingBillingInvoice = _billingInvoiceGenerator(invoiceAccount, billingBatchId, financialYearEnding) - generatedBillingInvoices = [existingBillingInvoice] - }) + describe('when `currentBillingInvoice` is set', () => { + beforeEach(async () => { + currentBillingInvoice = _billingInvoiceGenerator(invoiceAccount, billingBatchId, financialYearEnding) + }) - it('returns the existing billing invoice object and the existing array', async () => { + describe('and the invoice account ID matches', () => { + it('returns the `currentBillingInvoice`', async () => { const result = await GenerateBillingInvoiceService.go( - generatedBillingInvoices, - invoiceAccount.invoiceAccountId, + currentBillingInvoice, + currentBillingInvoice.invoiceAccountId, billingBatchId, financialYearEnding ) - expect(result.billingInvoice).to.equal(existingBillingInvoice) - expect(result.billingInvoices).to.equal(generatedBillingInvoices) + expect(result).to.equal(currentBillingInvoice) }) }) - describe('and a matching billing invoice does not exist', () => { - let existingBillingInvoice + describe('and the invoice account ID does not match', () => { + let otherInvoiceAccount - beforeEach(() => { - existingBillingInvoice = _billingInvoiceGenerator( - { invoiceAccountId: '813b8bb3-f871-49c3-ac2c-0636e636d9f6', invoiceAccountNumber: 'ABC123' }, - billingBatchId, - financialYearEnding - ) - generatedBillingInvoices = [existingBillingInvoice] - - expectedResult = _billingInvoiceGenerator(invoiceAccount, billingBatchId, financialYearEnding) + beforeEach(async () => { + otherInvoiceAccount = await InvoiceAccountHelper.add() }) - it('returns a new billing invoice object and the existing array with the new object included', async () => { + it('returns a new billing invoice with the provided values', async () => { const result = await GenerateBillingInvoiceService.go( - generatedBillingInvoices, - invoiceAccount.invoiceAccountId, + currentBillingInvoice, + otherInvoiceAccount.invoiceAccountId, billingBatchId, financialYearEnding ) - expect(result.billingInvoice).to.equal(expectedResult, { skip: 'billingInvoiceId' }) - expect(result.billingInvoices).to.equal([...generatedBillingInvoices, result.billingInvoice]) + expect(result).not.to.equal(currentBillingInvoice) + expect(result.invoiceAccountId).to.equal(otherInvoiceAccount.invoiceAccountId) }) }) }) diff --git a/test/services/supplementary-billing/process-billing-transactions.service.test.js b/test/services/supplementary-billing/process-billing-transactions.service.test.js new file mode 100644 index 0000000000..2a96285e40 --- /dev/null +++ b/test/services/supplementary-billing/process-billing-transactions.service.test.js @@ -0,0 +1,193 @@ +'use strict' + +// Test framework dependencies +const Lab = require('@hapi/lab') +const Code = require('@hapi/code') +const Sinon = require('sinon') + +const { describe, it, beforeEach, afterEach } = exports.lab = Lab.script() +const { expect } = Code + +// Things we need to stub +const FetchPreviousBillingTransactionsService = require('../../../app/services/supplementary-billing/fetch-previous-billing-transactions.service.js') + +// Thing under test +const ProcessBillingTransactionsService = require('../../../app/services/supplementary-billing/process-billing-transactions.service.js') + +describe('Process billing batch service', () => { + const billingInvoice = { billingInvoiceId: 'a56ef6d9-370a-4224-b6ec-0fca8bfa4d1f' } + const billingInvoiceLicence = { billingInvoiceLicenceId: '110ab2e2-6076-4d5a-a56f-b17a048eb269' } + + const billingPeriod = { + startDate: new Date('2022-04-01'), + endDate: new Date('2023-03-31') + } + + const calculatedTransactions = [ + { + billingTransactionId: '61abdc15-7859-4783-9622-6cb8de7f2461', + billingInvoiceLicenceId: billingInvoiceLicence.billingInvoiceLicenceId, + isCredit: false, + status: 'candidate', + chargeType: 'standard', + chargeCategoryCode: '4.10.1', + billableDays: 365, + purposes: 'CALCULATED_TRANSACTION_1' + }, + { + billingTransactionId: 'a903cdd3-1804-4237-aeb9-70ef9008469d', + billingInvoiceLicenceId: billingInvoiceLicence.billingInvoiceLicenceId, + isCredit: false, + status: 'candidate', + chargeType: 'standard', + chargeCategoryCode: '5.11.2', + billableDays: 265, + purposes: 'CALCULATED_TRANSACTION_2' + }, + { + billingTransactionId: '34453414-0ecb-49ce-8442-619d22c882f0', + billingInvoiceLicenceId: billingInvoiceLicence.billingInvoiceLicenceId, + isCredit: false, + status: 'candidate', + chargeType: 'standard', + chargeCategoryCode: '6.12.3', + billableDays: 100, + purposes: 'CALCULATED_TRANSACTION_3' + } + ] + + const allPreviousTransactions = [ + { + billingTransactionId: '8d68eb26-d054-47a7-aee8-cd93a24fa860', + billingInvoiceLicenceId: '110ab2e2-6076-4d5a-a56f-b17a048eb269', + isCredit: false, + status: 'candidate', + chargeType: 'standard', + chargeCategoryCode: '4.10.1', + billableDays: 365, + purposes: ['I_WILL_BE_REMOVED_1'] + }, + { + billingTransactionId: '63742bee-cb1b-44f1-86f6-c7d546f59c88', + billingInvoiceLicenceId: '110ab2e2-6076-4d5a-a56f-b17a048eb269', + isCredit: false, + status: 'candidate', + chargeType: 'standard', + chargeCategoryCode: '5.11.2', + billableDays: 265, + purposes: ['I_WILL_BE_REMOVED_2'] + }, + { + billingTransactionId: '4f254a70-d36e-46eb-9e4b-59503c3d5994', + billingInvoiceLicenceId: '110ab2e2-6076-4d5a-a56f-b17a048eb269', + isCredit: false, + status: 'candidate', + chargeType: 'standard', + chargeCategoryCode: '6.12.3', + billableDays: 100, + purposes: ['I_WILL_BE_REMOVED_3'] + }, + { + billingTransactionId: '733bdb55-5386-4fdb-a581-1e98f9a35bed', + billingInvoiceLicenceId: '110ab2e2-6076-4d5a-a56f-b17a048eb269', + isCredit: false, + status: 'candidate', + chargeType: 'standard', + chargeCategoryCode: '9.9.9', + billableDays: 180, + purposes: ['I_WILL_NOT_BE_REMOVED'] + } + ] + + afterEach(() => { + Sinon.restore() + }) + + describe('when the billing invoice, licence and period', () => { + describe('match to transactions on a previous billing batch', () => { + describe('and the calculated transactions provided', () => { + let previousTransactions + + describe('completely cancel out the previous transactions from the last billing batch', () => { + beforeEach(() => { + previousTransactions = [allPreviousTransactions[0], allPreviousTransactions[1]] + + Sinon.stub(FetchPreviousBillingTransactionsService, 'go').resolves(previousTransactions) + }) + + it('returns the uncanceled calculated transactions', async () => { + const result = await ProcessBillingTransactionsService.go( + calculatedTransactions, + billingInvoice, + billingInvoiceLicence, + billingPeriod + ) + + expect(result.length).to.equal(1) + expect(result[0]).to.equal(calculatedTransactions[2]) + }) + }) + + describe('partially cancel out the previous transactions from the last billing batch', () => { + beforeEach(() => { + previousTransactions = [allPreviousTransactions[0], allPreviousTransactions[1], allPreviousTransactions[3]] + + Sinon.stub(FetchPreviousBillingTransactionsService, 'go').resolves(previousTransactions) + }) + + it('returns the uncanceled calculated and reversed transactions', async () => { + const result = await ProcessBillingTransactionsService.go( + calculatedTransactions, + billingInvoice, + billingInvoiceLicence, + billingPeriod + ) + + expect(result.length).to.equal(2) + expect(result[0].purposes).to.equal('CALCULATED_TRANSACTION_3') + expect(result[1].purposes).to.equal('I_WILL_NOT_BE_REMOVED') + }) + }) + + describe('are cancelled out by the previous transactions from the last billing batch', () => { + beforeEach(() => { + previousTransactions = [allPreviousTransactions[0], allPreviousTransactions[1], allPreviousTransactions[2]] + + Sinon.stub(FetchPreviousBillingTransactionsService, 'go').resolves(previousTransactions) + }) + + it('returns no transactions', async () => { + const result = await ProcessBillingTransactionsService.go( + calculatedTransactions, + billingInvoice, + billingInvoiceLicence, + billingPeriod + ) + + expect(result.length).to.equal(0) + }) + }) + }) + }) + + describe('do not match to transactions on a previous billing batch', () => { + beforeEach(() => { + Sinon.stub(FetchPreviousBillingTransactionsService, 'go').resolves([]) + }) + + it('returns the calculated transactions unchanged', async () => { + const result = await ProcessBillingTransactionsService.go( + calculatedTransactions, + billingInvoice, + billingInvoiceLicence, + billingPeriod + ) + + expect(result.length).to.equal(3) + expect(result[0].purposes).to.equal('CALCULATED_TRANSACTION_1') + expect(result[1].purposes).to.equal('CALCULATED_TRANSACTION_2') + expect(result[2].purposes).to.equal('CALCULATED_TRANSACTION_3') + }) + }) + }) +}) diff --git a/test/services/supplementary-billing/reverse-billing-transactions.service.test.js b/test/services/supplementary-billing/reverse-billing-transactions.service.test.js new file mode 100644 index 0000000000..998e1dd31b --- /dev/null +++ b/test/services/supplementary-billing/reverse-billing-transactions.service.test.js @@ -0,0 +1,46 @@ +'use strict' + +// Test framework dependencies +const Lab = require('@hapi/lab') +const Code = require('@hapi/code') + +const { describe, it } = exports.lab = Lab.script() +const { expect } = Code + +// Thing under test +const ReverseBillingTransactionsService = require('../../../app/services/supplementary-billing/reverse-billing-transactions.service.js') + +describe('Reverse Billing Transactions service', () => { + const transactions = [ + { + invoiceAccountId: '7190937e-e176-4d50-ae4f-c00c5e76938a', + invoiceAccountNumber: 'B12345678A', + name: 'DEBIT', + isCredit: false, + status: 'TO_BE_OVERWRITTEN', + purposes: ['foo'] + } + ] + + const billingInvoiceLicence = { + billingInvoiceLicenceId: '8affaa71-c185-4b6c-9814-4c615c235611' + } + + describe('when the service is called', () => { + it('returns reversing transactions', () => { + const result = ReverseBillingTransactionsService.go(transactions, billingInvoiceLicence) + + expect(result.length).to.equal(transactions.length) + + expect(result[0].invoiceAccountId).not.to.exist() + expect(result[0].invoiceAccountNumber).not.to.exist() + + expect(result[0].name).to.equal('DEBIT') + expect(result[0].isCredit).to.be.true() + expect(result[0].status).to.equal('candidate') + expect(result[0].billingInvoiceLicenceId).to.equal(billingInvoiceLicence.billingInvoiceLicenceId) + expect(result[0].billingTransactionId).to.exist().and.to.be.a.string() + expect(result[0].purposes).to.equal('foo') + }) + }) +})