diff --git a/app/controllers/bill-runs.controller.js b/app/controllers/bill-runs.controller.js index 783feeb25d..386cf0297c 100644 --- a/app/controllers/bill-runs.controller.js +++ b/app/controllers/bill-runs.controller.js @@ -10,6 +10,7 @@ const Boom = require('@hapi/boom') const CreateBillRunValidator = require('../validators/create-bill-run.validator.js') const StartBillRunProcessService = require('../services/bill-runs/start-bill-run-process.service.js') const ViewBillRunService = require('../services/bill-runs/view-bill-run.service.js') +const ReviewBillRunService = require('../services/bill-runs/two-part-tariff/review-bill-run.service.js') async function create (request, h) { const validatedData = CreateBillRunValidator.go(request.payload) @@ -28,10 +29,15 @@ async function create (request, h) { } } -async function review (_request, h) { +async function review (request, h) { + const { id } = request.params + + const pageData = await ReviewBillRunService.go(id) + return h.view('bill-runs/review.njk', { - pageTitle: 'Review Two Part Tariff SROC', - activeNavBar: 'bill-runs' + pageTitle: 'Review licences', + activeNavBar: 'bill-runs', + ...pageData }) } diff --git a/app/models/licence.model.js b/app/models/licence.model.js index 4144fd0bd5..6d31e0b81c 100644 --- a/app/models/licence.model.js +++ b/app/models/licence.model.js @@ -64,6 +64,14 @@ class LicenceModel extends BaseModel { to: 'regions.id' } }, + reviewResults: { + relation: Model.HasManyRelation, + modelClass: 'review-result.model', + join: { + from: 'licences.id', + to: 'reviewResults.licenceId' + } + }, workflows: { relation: Model.HasManyRelation, modelClass: 'workflow.model', diff --git a/app/models/review-result.model.js b/app/models/review-result.model.js index ba18c215b9..845c7be022 100644 --- a/app/models/review-result.model.js +++ b/app/models/review-result.model.js @@ -31,6 +31,14 @@ class ReviewResultModel extends BaseModel { from: 'reviewResults.reviewReturnResultId', to: 'reviewReturnResults.id' } + }, + licence: { + relation: Model.BelongsToOneRelation, + modelClass: 'licence.model', + join: { + from: 'reviewResults.licenceId', + to: 'licences.id' + } } } } diff --git a/app/presenters/bill-runs/two-part-tariff/review-bill-run.presenter.js b/app/presenters/bill-runs/two-part-tariff/review-bill-run.presenter.js new file mode 100644 index 0000000000..959e9705ee --- /dev/null +++ b/app/presenters/bill-runs/two-part-tariff/review-bill-run.presenter.js @@ -0,0 +1,77 @@ +'use strict' + +/** + * Formats the two part tariff review data ready for presenting in the review page + * @module ReviewBillRunPresenter + */ + +const { formatLongDate } = require('../../base.presenter.js') + +/** + * Prepares and processes bill run and licence data for presentation + * + * @param {module:BillRunModel} billRun the data from the bill run + * @param {module:LicenceModel} licences the licences data asociated with the bill run + * + * @returns {Object} the prepared bill run and licence data to be passed to the review page + */ +function go (billRun, licences) { + const { licencesToReviewCount, preparedLicences } = _prepareLicences(licences) + + const preparedBillRun = _prepareBillRun(billRun, preparedLicences, licencesToReviewCount) + + return { ...preparedBillRun, preparedLicences } +} + +function _prepareLicences (licences) { + let licencesToReviewCount = 0 + const preparedLicences = [] + + for (const licence of licences) { + if (licence.status === 'review') { + licencesToReviewCount++ + } + + preparedLicences.push({ + licenceRef: licence.licenceRef, + licenceHolder: licence.licenceHolder, + status: licence.status, + issue: _getIssueOnLicence(licence.issues) + }) + } + + return { preparedLicences, licencesToReviewCount } +} + +function _prepareBillRun (billRun, billRunLicences, licencesToReviewCount) { + return { + region: billRun.region.displayName, + status: billRun.status, + dateCreated: formatLongDate(billRun.createdAt), + financialYear: _financialYear(billRun.toFinancialYearEnding), + billRunType: 'two-part tariff', + numberOfLicences: billRunLicences.length, + licencesToReviewCount + } +} + +function _financialYear (financialYearEnding) { + const startYear = financialYearEnding - 1 + const endYear = financialYearEnding + + return `${startYear} to ${endYear}` +} + +function _getIssueOnLicence (issues) { + if (issues.length > 1) { + return 'Multiple Issues' + } else if (issues.length === 1) { + return issues[0] + } else { + return '' + } +} + +module.exports = { + go +} diff --git a/app/services/bill-runs/two-part-tariff/determine-bill-run-issues.service.js b/app/services/bill-runs/two-part-tariff/determine-bill-run-issues.service.js new file mode 100644 index 0000000000..8db95ca5a5 --- /dev/null +++ b/app/services/bill-runs/two-part-tariff/determine-bill-run-issues.service.js @@ -0,0 +1,206 @@ +'use strict' + +/** + * Determines the issues on the licences for a two-part tariff bill run + * @module DetermineBillRunIssuesService + */ + +const FetchReviewResultsService = require('./fetch-review-results.service.js') + +// A list of issues that would put a licence into a status of 'review' +const REVIEW_STATUSES = [ + 'Aggregate factor', 'Checking query', 'Overlap of charge dates', 'Returns received but not processed', + 'Returns split over charge references', 'Unable to match returns' +] + +/** + * Fetches the review data on a licence and determines all the issues and licence status + * + * @param {module:LicenceModel} licences the two-part tariff licence included in the bill run + */ +async function go (licences) { + for (const licence of licences) { + const licenceReviewResults = await FetchReviewResultsService.go(licence.id) + const { issues, status } = _determineIssues(licenceReviewResults) + + licence.issues = issues + licence.status = status + } +} + +function _abstractionOutsidePeriod (issues, licenceReviewResults) { + const abstractionOutsidePeriod = licenceReviewResults.some((licenceReviewResult) => { + return licenceReviewResult.reviewReturnResults?.abstractionOutsidePeriod + }) + + if (abstractionOutsidePeriod) { + issues.push('Abstraction outside period') + } +} + +function _determineIssues (licenceReviewResults) { + const issues = [] + + _abstractionOutsidePeriod(issues, licenceReviewResults) + + _hasAggregate(issues, licenceReviewResults) + + _underQuery(issues, licenceReviewResults) + + _noReturnsReceived(issues, licenceReviewResults) + + _overAbstracted(issues, licenceReviewResults) + + _hasChargeDatesOverlap(issues, licenceReviewResults) + + _notProcessed(issues, licenceReviewResults) + + _receivedLate(issues, licenceReviewResults) + + _returnsSplitOverChargeReference(issues, licenceReviewResults) + + _returnsNotReceived(issues, licenceReviewResults) + + _matchingReturns(issues, licenceReviewResults) + + const status = _determineIssueStatus(issues) + + return { issues, status } +} + +function _determineIssueStatus (issues) { + const hasReviewIssue = issues.some((issue) => { + return REVIEW_STATUSES.includes(issue) + }) + + if (hasReviewIssue) { + return 'review' + } + + return 'ready' +} + +function _hasAggregate (issues, licenceReviewResults) { + const hasAggregate = licenceReviewResults.some((licenceReviewResult) => { + return licenceReviewResult.reviewChargeElementResults?.aggregate !== 1 + }) + + if (hasAggregate) { + issues.push('Aggregate factor') + } +} + +function _hasChargeDatesOverlap (issues, licenceReviewResults) { + const hasChargeDatesOverlap = licenceReviewResults.some((licenceReviewResult) => { + return licenceReviewResult.reviewChargeElementResults?.chargeDatesOverlap + }) + + if (hasChargeDatesOverlap) { + issues.push('Overlap of charge dates') + } +} + +function _matchingReturns (issues, licenceReviewResults) { + const matchingReturns = licenceReviewResults.some((licenceReviewResult) => { + return !(licenceReviewResult?.reviewChargeElementResultId && licenceReviewResult?.reviewReturnResultId) + }) + + if (matchingReturns) { + issues.push('Unable to match returns') + } +} + +function _notProcessed (issues, licenceReviewResults) { + const notProcessed = licenceReviewResults.some((licenceReviewResult) => { + return licenceReviewResult.reviewReturnResults?.status === 'received' + }) + + if (notProcessed) { + issues.push('Returns received but not processed') + } +} + +function _noReturnsReceived (issues, licenceReviewResults) { + const noReturnsReceived = licenceReviewResults.some((licenceReviewResult) => { + return licenceReviewResult.reviewReturnResults?.status === 'due' || licenceReviewResult.reviewReturnResults?.status === 'overdue' + }) + + if (noReturnsReceived) { + issues.push('No returns received') + } +} + +function _overAbstracted (issues, licenceReviewResults) { + const overAbstracted = licenceReviewResults.some((licenceReviewResult) => { + return licenceReviewResult.reviewReturnResults?.quantity > licenceReviewResult.reviewReturnResults?.allocated + }) + + if (overAbstracted) { + issues.push('Over abstraction') + } +} + +function _receivedLate (issues, licenceReviewResults) { + const receivedLate = licenceReviewResults.some((licenceReviewResult) => { + return licenceReviewResult.reviewReturnResults?.receivedDate > licenceReviewResult.reviewReturnResults?.dueDate + }) + + if (receivedLate) { + issues.push('Returns received late') + } +} + +function _underQuery (issues, licenceReviewResults) { + const underQuery = licenceReviewResults.some((licenceReviewResult) => { + return licenceReviewResult.reviewReturnResults?.underQuery + }) + + if (underQuery) { + issues.push('Checking query') + } +} + +/** + * Checks if any of the licence review results indicate that a charge element has not received its returns + */ +function _returnsNotReceived (issues, licenceReviewResults) { + const returnsNotReceived = licenceReviewResults.some((licenceReviewResult) => { + return (licenceReviewResult.reviewReturnResultId && + licenceReviewResult.reviewChargeElementResultId && + (licenceReviewResult.reviewReturnResults.status === 'due' || + licenceReviewResult.reviewReturnResults.status === 'overdue') + ) + }) + + if (returnsNotReceived) { + issues.push('Some returns not received') + } +} + +/** + * Determines if there are multiple charge references associated with a matched return + */ +function _returnsSplitOverChargeReference (issues, licenceReviewResults) { + const seenReviewReturnResults = {} + let returnsSplitOverChargeReference + + for (const result of licenceReviewResults) { + const { chargeReferenceId, reviewReturnResultId } = result + + if (seenReviewReturnResults[reviewReturnResultId]) { + if (seenReviewReturnResults[reviewReturnResultId] !== chargeReferenceId) { + returnsSplitOverChargeReference = true + } + } else { + seenReviewReturnResults[reviewReturnResultId] = chargeReferenceId + } + } + + if (returnsSplitOverChargeReference) { + issues.push('Returns split over charge references') + } +} + +module.exports = { + go +} diff --git a/app/services/bill-runs/two-part-tariff/fetch-bill-run-licences.service.js b/app/services/bill-runs/two-part-tariff/fetch-bill-run-licences.service.js new file mode 100644 index 0000000000..917e584ec2 --- /dev/null +++ b/app/services/bill-runs/two-part-tariff/fetch-bill-run-licences.service.js @@ -0,0 +1,67 @@ +'use strict' + +/** + * Fetches bill run and licences data for two-part-tariff billing review + * @module FetchBillRunLicencesService + */ + +const BillRunModel = require('../../../models/bill-run.model.js') +const LicenceModel = require('../../../models/licence.model.js') + +/** + * Takes the bill run ID and fetches all the data needed to review the bill run + * + * Fetches specifically the bill run data, a list of the licences in the bill run with the licence holder and licence + * ref. + * @param {String} id The UUID for the bill run + * + * @returns {Object} an object containing the billRun data and an array of licences for the bill run + */ +async function go (id) { + const billRun = await _fetchBillRun(id) + const licences = await _fetchLicences(id) + + return { billRun, licences } +} + +async function _fetchBillRun (id) { + const billRun = await BillRunModel.query() + .findById(id) + .select([ + 'id', + 'createdAt', + 'status', + 'toFinancialYearEnding', + 'batchType' + ]) + .withGraphFetched('region') + .modifyGraph('region', (builder) => { + builder.select([ + 'id', + 'displayName' + ]) + }) + + return billRun +} + +async function _fetchLicences (id) { + const licences = await LicenceModel.query() + .distinct([ + 'licences.id', + 'licenceRef' + ]) + .innerJoinRelated('reviewResults') + .where('billRunId', id) + .modify('licenceHolder') + + for (const licence of licences) { + licence.licenceHolder = licence.$licenceHolder() + } + + return licences +} + +module.exports = { + go +} diff --git a/app/services/bill-runs/two-part-tariff/fetch-review-results.service.js b/app/services/bill-runs/two-part-tariff/fetch-review-results.service.js new file mode 100644 index 0000000000..db81b431d4 --- /dev/null +++ b/app/services/bill-runs/two-part-tariff/fetch-review-results.service.js @@ -0,0 +1,56 @@ +'use strict' + +/** + * Fetches the review results data for a given licence along with their associated charge elements and return logs + * @module FetchReviewResultService + */ + +const ReviewResultModel = require('../../../models/review-result.model.js') + +/** + * Fetches the review results data for a given licence along with their associated charge elements and return logs + * + * @param {String} licenceId the id of the licence + * + * @returns {module:ReviewResultModel} the review result data associated with the given licence + */ +async function go (licenceId) { + const reviewResults = await _fetchReviewResults(licenceId) + + return reviewResults +} + +async function _fetchReviewResults (licenceId) { + return ReviewResultModel.query() + .where('licenceId', licenceId) + .select( + 'reviewChargeElementResultId', + 'chargeReferenceId', + 'reviewReturnResultId' + ) + .withGraphFetched('reviewChargeElementResults') + .modifyGraph('reviewChargeElementResults', (builder) => { + builder.select([ + 'id', + 'chargeDatesOverlap', + 'aggregate' + ]) + }) + .withGraphFetched('reviewReturnResults') + .modifyGraph('reviewReturnResults', (builder) => { + builder.select([ + 'id', + 'underQuery', + 'quantity', + 'allocated', + 'abstractionOutsidePeriod', + 'status', + 'dueDate', + 'receivedDate' + ]) + }) +} + +module.exports = { + go +} diff --git a/app/services/bill-runs/two-part-tariff/match-and-allocate.service.js b/app/services/bill-runs/two-part-tariff/match-and-allocate.service.js index 69ba8592b6..6187d18c38 100644 --- a/app/services/bill-runs/two-part-tariff/match-and-allocate.service.js +++ b/app/services/bill-runs/two-part-tariff/match-and-allocate.service.js @@ -64,7 +64,7 @@ async function _process (licences, billingPeriods, billRun) { }) }) - await PersistAllocatedLicenceToResultsService.go(billRun.billingBatchId, licence) + await PersistAllocatedLicenceToResultsService.go(billRun.id, licence) } } diff --git a/app/services/bill-runs/two-part-tariff/review-bill-run.service.js b/app/services/bill-runs/two-part-tariff/review-bill-run.service.js new file mode 100644 index 0000000000..844c9c4bd1 --- /dev/null +++ b/app/services/bill-runs/two-part-tariff/review-bill-run.service.js @@ -0,0 +1,32 @@ +'use strict' + +/** + * Orchestrates fetching and presenting the data needed for the review bill run page + * @module ReviewBillRunService + */ + +const DetermineBillRunIssuesService = require('./determine-bill-run-issues.service.js') +const FetchBillRunLicencesService = require('./fetch-bill-run-licences.service.js') +const ReviewBillRunPresenter = require('../../../presenters/bill-runs/two-part-tariff/review-bill-run.presenter.js') + +/** + * Orchestrates fetching and presenting the data needed for the review bill run page + * + * @param {string} id The UUID for the bill run to review + * + * @returns {Object} an object representing the `pageData` needed by the review bill run template. It contains details of + * the bill run and the licences linked to it. + */ +async function go (id) { + const { billRun, licences } = await FetchBillRunLicencesService.go(id) + + await DetermineBillRunIssuesService.go(licences) + + const pageData = ReviewBillRunPresenter.go(billRun, licences) + + return pageData +} + +module.exports = { + go +} diff --git a/app/services/bills/fetch-bill-service.js b/app/services/bills/fetch-bill-service.js index dbec2a16e4..98feb571a1 100644 --- a/app/services/bills/fetch-bill-service.js +++ b/app/services/bills/fetch-bill-service.js @@ -29,7 +29,7 @@ async function go (id) { } async function _fetchBill (id) { - const result = BillModel.query() + return BillModel.query() .findById(id) .select([ 'id', @@ -66,8 +66,6 @@ async function _fetchBill (id) { 'displayName' ]) }) - - return result } /** @@ -84,7 +82,7 @@ async function _fetchBill (id) { * Objection.js. */ async function _fetchLicenceSummaries (id) { - const results = await db + return db .distinct(['bil.id', 'bil.licenceRef']) .sum('bt.net_amount AS total') .from('billLicences AS bil') @@ -93,8 +91,6 @@ async function _fetchLicenceSummaries (id) { .where('bt.chargeType', 'standard') .groupBy('bil.id', 'bil.licenceRef') .orderBy('bil.licenceRef') - - return results } module.exports = { diff --git a/app/services/jobs/export/fetch-table-names.service.js b/app/services/jobs/export/fetch-table-names.service.js index 68403a396d..4e398a0f5c 100644 --- a/app/services/jobs/export/fetch-table-names.service.js +++ b/app/services/jobs/export/fetch-table-names.service.js @@ -35,7 +35,7 @@ async function _fetchTableNames (schemaName) { AND table_type = 'BASE TABLE'; ` - return await db.raw(query) + return db.raw(query) } function _pluckTableNames (tableData) { diff --git a/app/views/bill-runs/review.njk b/app/views/bill-runs/review.njk index 2d8c0e1756..b58fd9895c 100644 --- a/app/views/bill-runs/review.njk +++ b/app/views/bill-runs/review.njk @@ -1,8 +1,167 @@ +{% extends 'layout.njk' %} +{% from "govuk/components/back-link/macro.njk" import govukBackLink %} +{% from "govuk/components/tag/macro.njk" import govukTag %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/summary-list/macro.njk" import govukSummaryList %} +{% from "govuk/components/inset-text/macro.njk" import govukInsetText %} +{% from "govuk/components/input/macro.njk" import govukInput %} +{% from "govuk/components/radios/macro.njk" import govukRadios %} +{% from "govuk/components/details/macro.njk" import govukDetails %} +{% from "govuk/components/table/macro.njk" import govukTable %} + +{% from "macros/badge.njk" import badge %} + +{% block breadcrumbs %} + {# Back link #} + {{ + govukBackLink({ + text: 'Go back to bill runs', + href: '/billing/batch/list' + }) + }} +{% endblock %} + {% block content %} {# Main heading #} -
-

-

-

+
+
+ {{region}} {{billRunType}} bill run +

{{pageTitle}}

+ + {# Status badge #} + {% if status == 'review' %} + {% set colour = "govuk-tag--blue govuk-!-font-size-27" %} + {% else %} + {% set colour = "govuk-tag--green govuk-!-font-size-27" %} + {% endif %} + +

+ {{govukTag({ + text: status, + classes: colour + })}} +

+ + {# Bill run meta-data #} + {{ govukSummaryList({ + classes: 'govuk-summary-list--no-border', + rows: [ + { + key: { + text: 'Date created', + classes: "meta-data__label" + }, + value: { + text: dateCreated, + classes: "meta-data__value" + } + }, + { + key: { + text: 'Region', + classes: "meta-data__label" + }, + value: { + text: region, + classes: "meta-data__value" + } + }, + { + key: { + text: 'Bill run type', + classes: "meta-data__label" + }, + value: { + text: billRunType | capitalize, + classes: "meta-data__value" + } + }, + { + key: { + text: 'Charge scheme', + classes: "meta-data__label" + }, + value: { + text: 'Current', + classes: "meta-data__value" + } + }, + { + key: { + text: 'Financial year', + classes: "meta-data__label" + }, + value: { + text: financialYear, + classes: "meta-data__value" + } + } + ] + }) }} +
+ + {# Dynamic message either telling the user they have issues to deal with or that they can generate bills #} + {% if licencesToReviewCount > 0 %} +
+ {{ govukInsetText({ + text: 'You need to review ' + licencesToReviewCount + ' licences with returns data issues. You can then continue and send the bill run.' + }) }} +
+ {% else %} +
+

You have resolved all returns data issues. Continue to generate bills.

+
+ {% endif %} + + {# Generate the row data for the table #} + {% set tableRows = [] %} + {% if preparedLicences.length > 0 %} + {% for licence in preparedLicences %} + {% if licence.status == 'review' %} + {% set colour = "govuk-tag--blue" %} + {% else %} + {% set colour = "govuk-tag--green" %} + {% endif %} + + {% set statusTag %} + {{govukTag({ + text: licence.status, + classes: colour + })}} + {% endset %} + + {% set action = 'View licence matching for licence ' + licence.licenceRef + '' %} + {% set tableRow = [ + {html: action | safe}, + {text: licence.licenceHolder}, + {text: licence.issue }, + {html: statusTag, format: "numeric"} + ] %} + {% set tableRows = (tableRows.push(tableRow), tableRows) %} + {% endfor %} + {% endif %} + + {# Table displaying details of the licences in the bill run. These results take into account any filter that is set #} + {{ govukTable({ + caption: "Showing all " + numberOfLicences + " licences", + captionClasses: "govuk-table__caption--s govuk-!-margin-bottom-2", + firstCellIsHeader: false, + head: [ + { + text: 'Licence' + }, + { + text: 'Licence holder' + }, + { + text: 'Issue' + }, + { + text: 'Status', + format: "numeric" + } + ], + rows: tableRows + }) }} {% endblock %} diff --git a/test/controllers/bill-runs.controller.test.js b/test/controllers/bill-runs.controller.test.js index 500e20dde8..067fd9e3c7 100644 --- a/test/controllers/bill-runs.controller.test.js +++ b/test/controllers/bill-runs.controller.test.js @@ -10,6 +10,7 @@ const { expect } = Code // Things we need to stub const Boom = require('@hapi/boom') +const ReviewBillRunService = require('../../app/services/bill-runs/two-part-tariff/review-bill-run.service.js') const StartBillRunProcessService = require('../../app/services/bill-runs/start-bill-run-process.service.js') const ViewBillRunService = require('../../app/services/bill-runs/view-bill-run.service.js') @@ -166,10 +167,17 @@ describe('Bill Runs controller', () => { }) describe('when a request is valid', () => { + beforeEach(() => { + Sinon.stub(ReviewBillRunService, 'go').resolves(_reviewBillRunData()) + }) + it('returns a 200 response', async () => { const response = await server.inject(options) expect(response.statusCode).to.equal(200) + expect(response.payload).to.contain('two-part tariff') + expect(response.payload).to.contain('Southern (Test replica)') + expect(response.payload).to.contain('Showing all 1 licences') }) }) }) @@ -226,6 +234,28 @@ function _multiGroupBillRun () { } } +function _reviewBillRunData () { + return { + region: 'Southern (Test replica)', + status: 'review', + dateCreated: '6 November 2023', + financialYear: '2021 to 2022', + chargeScheme: 'Current', + billRunType: 'two-part tariff', + numberOfLicences: 1, + licencesToReviewCount: 1, + licences: [ + { + licenceId: 'cc4bbb18-0d6a-4254-ac2c-7409de814d7e', + licenceRef: '1/11/11/*1/1111', + licenceHolder: 'Big Farm Ltd', + licenceIssues: 'Multiple Issues', + licenceStatus: 'review' + } + ] + } +} + function _singleGroupBillRun () { return { billsCount: '1 Annual bill', diff --git a/test/models/licence.model.test.js b/test/models/licence.model.test.js index 7d45878ed6..8cfa33735d 100644 --- a/test/models/licence.model.test.js +++ b/test/models/licence.model.test.js @@ -27,6 +27,8 @@ const LicenceVersionModel = require('../../app/models/licence-version.model.js') const RegionHelper = require('../support/helpers/region.helper.js') const RegionModel = require('../../app/models/region.model.js') const RegisteredToAndLicenceNameSeeder = require('../support/seeders/registered-to-and-licence-name.seeder.js') +const ReviewResultHelper = require('../support/helpers/review-result.helper.js') +const ReviewResultModel = require('../../app/models/review-result.model.js') const WorkflowHelper = require('../support/helpers/workflow.helper.js') const WorkflowModel = require('../../app/models/workflow.model.js') @@ -247,6 +249,41 @@ describe('Licence model', () => { }) }) + describe('when linking to review results', () => { + let testReviewResults + + beforeEach(async () => { + const { id } = testRecord + + testReviewResults = [] + for (let i = 0; i < 2; i++) { + const reviewResult = await ReviewResultHelper.add({ licenceId: id }) + testReviewResults.push(reviewResult) + } + }) + + it('can successfully run a related query', async () => { + const query = await LicenceModel.query() + .innerJoinRelated('reviewResults') + + expect(query).to.exist() + }) + + it('can eager load the workflows', async () => { + const result = await LicenceModel.query() + .findById(testRecord.id) + .withGraphFetched('reviewResults') + + expect(result).to.be.instanceOf(LicenceModel) + expect(result.id).to.equal(testRecord.id) + + expect(result.reviewResults).to.be.an.array() + expect(result.reviewResults[0]).to.be.an.instanceOf(ReviewResultModel) + expect(result.reviewResults).to.include(testReviewResults[0]) + expect(result.reviewResults).to.include(testReviewResults[1]) + }) + }) + describe('when linking to workflows', () => { let testWorkflows diff --git a/test/models/review-result.model.test.js b/test/models/review-result.model.test.js index 75a2f7b9c2..b442f7c865 100644 --- a/test/models/review-result.model.test.js +++ b/test/models/review-result.model.test.js @@ -9,6 +9,8 @@ const { expect } = Code // Test helpers const DatabaseHelper = require('../support/helpers/database.helper.js') +const LicenceHelper = require('../support/helpers/licence.helper.js') +const LicenceModel = require('../../app/models/licence.model.js') const ReviewChargeElementResultHelper = require('../support/helpers/review-charge-element-result.helper.js') const ReviewChargeElementResultModel = require('../../app/models/review-charge-element-result.model.js') const ReviewResultHelper = require('../support/helpers/review-result.helper.js') @@ -95,5 +97,34 @@ describe('Review Result model', () => { expect(result.reviewReturnResults).to.equal(testReviewReturnResult) }) }) + + describe('when linking to licence', () => { + let testLicence + + beforeEach(async () => { + testLicence = await LicenceHelper.add() + + testRecord = await ReviewResultHelper.add({ licenceId: testLicence.id }) + }) + + it('can successfully run a related query', async () => { + const query = await ReviewResultModel.query() + .innerJoinRelated('licence') + + expect(query).to.exist() + }) + + it('can eager load the licence', async () => { + const result = await ReviewResultModel.query() + .findById(testRecord.id) + .withGraphFetched('licence') + + expect(result).to.be.instanceOf(ReviewResultModel) + expect(result.id).to.equal(testRecord.id) + + expect(result.licence).to.be.instanceOf(LicenceModel) + expect(result.licence).to.equal(testLicence) + }) + }) }) }) diff --git a/test/presenters/bill-runs/two-part-tariff/review-bill-run.presenter.test.js b/test/presenters/bill-runs/two-part-tariff/review-bill-run.presenter.test.js new file mode 100644 index 0000000000..8d67436848 --- /dev/null +++ b/test/presenters/bill-runs/two-part-tariff/review-bill-run.presenter.test.js @@ -0,0 +1,98 @@ +'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 ReviewBillRunPresenter = require('../../../../app/presenters/bill-runs/two-part-tariff/review-bill-run.presenter.js') + +describe('Review Bill Run presenter', () => { + describe('when there is data to be presented for review', () => { + const testBillRun = _testBillRun() + const testLicences = _testLicences() + + it('correctly presents the data', () => { + const result = ReviewBillRunPresenter.go(testBillRun, testLicences) + + expect(result).to.equal({ + region: 'Southern (Test replica)', + status: 'review', + dateCreated: '17 January 2024', + financialYear: '2022 to 2023', + billRunType: 'two-part tariff', + numberOfLicences: 3, + licencesToReviewCount: 1, + preparedLicences: [ + { + licenceRef: '1/11/11/*11/1111', + licenceHolder: 'Big Farm Ltd', + status: 'ready', + issue: '' + }, + { + licenceRef: '2/22/22/*S2/2222', + licenceHolder: 'Bob Bobbles', + status: 'ready', + issue: 'Abstraction outside period' + }, + { + licenceRef: '3/33/33/*3/3333', + licenceHolder: 'Farmer Palmer', + status: 'review', + issue: 'Multiple Issues' + } + ] + }) + }) + }) +}) + +function _testBillRun () { + return { + id: 'b21bd372-cd04-405d-824e-5180d854121c', + createdAt: new Date('2024-01-17'), + status: 'review', + toFinancialYearEnding: 2023, + batchType: 'two_part_tariff', + region: { + displayName: 'Southern (Test replica)' + } + } +} + +function _testLicences () { + return [ + // Licence with no issues + { + id: 'cc4bbb18-0d6a-4254-ac2c-7409de814d7e', + licenceRef: '1/11/11/*11/1111', + licenceHolder: 'Big Farm Ltd', + issues: [], + status: 'ready' + }, + // Licence with a single issue + { + id: '395bdc01-605b-44f5-9d90-5836cc013799', + licenceRef: '2/22/22/*S2/2222', + licenceHolder: 'Bob Bobbles', + issues: ['Abstraction outside period'], + status: 'ready' + }, + // Licence with multiple issues + { + id: 'fdae33da-9195-4b97-976a-9791bc4f6b66', + licenceRef: '3/33/33/*3/3333', + licenceHolder: 'Farmer Palmer', + issues: [ + 'Abstraction outside period', + 'Over abstraction', + 'Overlap of charge dates' + ], + status: 'review' + } + ] +} diff --git a/test/services/bill-runs/two-part-tariff/determine-bill-run-issues.service.test.js b/test/services/bill-runs/two-part-tariff/determine-bill-run-issues.service.test.js new file mode 100644 index 0000000000..30e669149b --- /dev/null +++ b/test/services/bill-runs/two-part-tariff/determine-bill-run-issues.service.test.js @@ -0,0 +1,163 @@ +'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 FetchReviewResultsService = require('../../../../app/services/bill-runs/two-part-tariff/fetch-review-results.service.js') + +// Thing under test +const DetermineBillRunIssuesService = require('../../../../app/services/bill-runs/two-part-tariff/determine-bill-run-issues.service.js') + +describe('Determine Bill Run Issues Service', () => { + afterEach(() => { + Sinon.restore() + }) + + describe('when given licences', () => { + const testLicences = _testLicences() + + beforeEach(() => { + Sinon.stub(FetchReviewResultsService, 'go') + .onFirstCall().resolves(_readyLicenceReviewResults()) + .onSecondCall().resolves(_reviewLicenceReviewResults()) + }) + + it('it fetches their review result records', async () => { + await DetermineBillRunIssuesService.go(testLicences) + + expect(FetchReviewResultsService.go.called).to.be.true() + }) + + describe('that have issues', () => { + it('adds the status of `review` or `ready` to the licence', async () => { + await DetermineBillRunIssuesService.go(testLicences) + + expect(testLicences[0].status).to.equal('ready') + expect(testLicences[1].status).to.equal('review') + }) + + it('adds the specific issues to the licence', async () => { + await DetermineBillRunIssuesService.go(testLicences) + + expect(testLicences[0].issues).to.equal([ + 'Abstraction outside period', + 'No returns received', + 'Over abstraction', + 'Returns received late', + 'Some returns not received' + ]) + + expect(testLicences[1].issues).to.equal([ + 'Aggregate factor', + 'Checking query', + 'Overlap of charge dates', + 'Returns received but not processed', + 'Returns split over charge references', + 'Unable to match returns' + ]) + }) + }) + }) +}) + +// The test data in _reviewLicenceReviewResults is set up to trigger all the issues with a status of review +function _reviewLicenceReviewResults () { + return [ + { + reviewChargeElementResultId: '9f60684c-89fc-48d6-8a12-27dd2beaad36', + chargeReferenceId: '8b44bdb9-3417-4d20-b9b2-0ca3438b6abb', + reviewReturnResultId: '90eceec8-18de-4497-8102-3a7c6259d41a', + reviewChargeElementResults: { + id: '9f60684c-89fc-48d6-8a12-27dd2beaad36', + chargeDatesOverlap: true, + aggregate: 0.5 + }, + reviewReturnResults: { + id: '90eceec8-18de-4497-8102-3a7c6259d41a', + underQuery: true, + quantity: 0, + allocated: 0, + abstractionOutsidePeriod: false, + status: 'received', + dueDate: new Date('2023-04-28'), + receivedDate: new Date('2023-04-28') + } + }, + { + reviewChargeElementResultId: '9f60684c-89fc-48d6-8a12-27dd2beaad36', + chargeReferenceId: '90eceec8-18de-4497-8102-3a7c6259d41a', + reviewReturnResultId: '90eceec8-18de-4497-8102-3a7c6259d41a', + reviewChargeElementResults: { + id: '9f60684c-89fc-48d6-8a12-27dd2beaad36', + chargeDatesOverlap: true, + aggregate: 0.5 + }, + reviewReturnResults: { + id: '90eceec8-18de-4497-8102-3a7c6259d41a', + underQuery: true, + quantity: 0, + allocated: 0, + abstractionOutsidePeriod: false, + status: 'received', + dueDate: new Date('2023-04-28'), + receivedDate: new Date('2023-04-28') + } + }, + { + reviewChargeElementResultId: '9f60684c-89fc-48d6-8a12-27dd2beaad36', + chargeReferenceId: '90eceec8-18de-4497-8102-3a7c6259d41a', + reviewReturnResultId: null, + reviewChargeElementResults: { + id: '9f60684c-89fc-48d6-8a12-27dd2beaad36', + chargeDatesOverlap: true, + aggregate: 0.5 + } + } + ] +} + +// The test data in _readyLicenceReviewResults is set up to trigger all the issues with a status of ready +function _readyLicenceReviewResults () { + return [ + { + reviewChargeElementResultId: '9f60684c-89fc-48d6-8a12-27dd2beaad36', + chargeReferenceId: '8b44bdb9-3417-4d20-b9b2-0ca3438b6abb', + reviewReturnResultId: '90eceec8-18de-4497-8102-3a7c6259d41a', + reviewChargeElementResults: { + id: '9f60684c-89fc-48d6-8a12-27dd2beaad36', + chargeDatesOverlap: false, + aggregate: 1 + }, + reviewReturnResults: { + id: '90eceec8-18de-4497-8102-3a7c6259d41a', + underQuery: false, + quantity: 1, + allocated: 0.5, + abstractionOutsidePeriod: true, + status: 'due', + dueDate: new Date('2023-04-28'), + receivedDate: new Date('2023-04-29') + } + } + ] +} + +function _testLicences () { + return [ + { + licenceId: 'cc4bbb18-0d6a-4254-ac2c-7409de814d7e', + licenceHolder: 'Big Farm Ltd', + licenceRef: '1/11/11/*11/1111' + }, { + licenceId: 'fdae33da-9195-4b97-976a-9791bc4f6b66', + licenceHolder: 'Bob Bobbles', + licenceRef: '2/22/22/*S2/2222' + } + ] +} diff --git a/test/services/bill-runs/two-part-tariff/fetch-bill-run-licences.service.test.js b/test/services/bill-runs/two-part-tariff/fetch-bill-run-licences.service.test.js new file mode 100644 index 0000000000..18a7ee0e6b --- /dev/null +++ b/test/services/bill-runs/two-part-tariff/fetch-bill-run-licences.service.test.js @@ -0,0 +1,70 @@ +'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 + +// Test helpers +const BillRunHelper = require('../../../support/helpers/bill-run.helper.js') +const DatabaseHelper = require('../../../support/helpers/database.helper.js') +const LicenceHelper = require('../../../support/helpers/licence.helper.js') +const LicenceHolderSeeder = require('../../../support/seeders/licence-holder.seeder.js') +const RegionHelper = require('../../../support/helpers/region.helper.js') +const ReviewResultHelper = require('../../../support/helpers/review-result.helper.js') + +// Thing under test +const FetchBillRunLicencesService = require('../../../../app/services/bill-runs/two-part-tariff/fetch-bill-run-licences.service.js') + +describe('Fetch Bill Run Licences service', () => { + beforeEach(async () => { + await DatabaseHelper.clean() + }) + + describe('when there is a valid bill run', () => { + let billRun + let region + + beforeEach(async () => { + region = await RegionHelper.add() + billRun = await BillRunHelper.add({ regionId: region.id, batchType: 'two_part_tariff' }) + }) + + describe('and there are licences in the bill run', () => { + let testLicence + + beforeEach(async () => { + testLicence = await LicenceHelper.add() + await LicenceHolderSeeder.seed(testLicence.licenceRef) + await ReviewResultHelper.add({ billRunId: billRun.id, licenceId: testLicence.id }) + }) + + it('returns details of the bill run and the licences in it', async () => { + const result = await FetchBillRunLicencesService.go(billRun.id) + + expect(result.billRun.id).to.equal(billRun.id) + expect(result.billRun.createdAt).to.equal(billRun.createdAt) + expect(result.billRun.status).to.equal(billRun.status) + expect(result.billRun.toFinancialYearEnding).to.equal(billRun.toFinancialYearEnding) + expect(result.billRun.batchType).to.equal(billRun.batchType) + expect(result.billRun.region.displayName).to.equal(region.displayName) + + expect(result.licences).to.have.length(1) + expect(result.licences[0].id).to.equal(testLicence.id) + expect(result.licences[0].licenceHolder).to.equal('Licence Holder Ltd') + expect(result.licences[0].licenceRef).to.equal(testLicence.licenceRef) + }) + }) + }) + + describe('when there is an invalid bill run id passed to the service', () => { + it('returns no results', async () => { + const result = await FetchBillRunLicencesService.go('56db85ed-767f-4c83-8174-5ad9c80fd00d') + + expect(result.billRun).to.be.undefined() + expect(result.licences).to.have.length(0) + }) + }) +}) diff --git a/test/services/bill-runs/two-part-tariff/fetch-review-results.service.test.js b/test/services/bill-runs/two-part-tariff/fetch-review-results.service.test.js new file mode 100644 index 0000000000..5461c91ca5 --- /dev/null +++ b/test/services/bill-runs/two-part-tariff/fetch-review-results.service.test.js @@ -0,0 +1,136 @@ +'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 + +// Test helpers +const DatabaseHelper = require('../../../support/helpers/database.helper.js') +const ReviewChargeElementsResultsHelper = require('../../..//support/helpers/review-charge-element-result.helper.js') +const ReviewResultsHelper = require('../../..//support/helpers/review-result.helper.js') +const ReviewReturnResultsHelper = require('../../../support/helpers/review-return-result.helper.js') + +// Thing under test +const FetchReviewResultsService = require('../../../../app/services/bill-runs/two-part-tariff/fetch-review-results.service.js') + +describe('Fetch Review Results Service', () => { + const licenceId = '83fe31e7-2f16-4be7-b557-bbada2323d92' + + beforeEach(async () => { + await DatabaseHelper.clean() + }) + + describe('when the licence has data for review', () => { + let reviewChargeElementsResults + let reviewReturnResults + let reviewResults + + describe('with related charge elements and returns', () => { + beforeEach(async () => { + reviewChargeElementsResults = await ReviewChargeElementsResultsHelper.add() + reviewReturnResults = await ReviewReturnResultsHelper.add() + reviewResults = await ReviewResultsHelper.add({ + licenceId, + reviewChargeElementResultId: reviewChargeElementsResults.id, + reviewReturnResultId: reviewReturnResults.id + }) + }) + + it('returns the review result with the related charge element and returns', async () => { + const result = await FetchReviewResultsService.go(licenceId) + + expect(result[0]).to.equal({ + reviewChargeElementResultId: reviewResults.reviewChargeElementResultId, + chargeReferenceId: reviewResults.chargeReferenceId, + reviewReturnResultId: reviewReturnResults.id, + reviewChargeElementResults: { + id: reviewChargeElementsResults.id, + chargeDatesOverlap: reviewChargeElementsResults.chargeDatesOverlap, + aggregate: reviewChargeElementsResults.aggregate + }, + reviewReturnResults: { + id: reviewReturnResults.id, + underQuery: reviewReturnResults.underQuery, + quantity: reviewReturnResults.quantity, + allocated: reviewReturnResults.allocated, + abstractionOutsidePeriod: reviewReturnResults.abstractionOutsidePeriod, + status: reviewReturnResults.status, + dueDate: reviewReturnResults.dueDate, + receivedDate: reviewReturnResults.receivedDate + } + }) + }) + }) + + describe('with related charge elements but no returns', () => { + beforeEach(async () => { + reviewChargeElementsResults = await ReviewChargeElementsResultsHelper.add() + reviewResults = await ReviewResultsHelper.add({ + licenceId, + reviewChargeElementResultId: reviewChargeElementsResults.id, + reviewReturnResultId: null + }) + }) + + it('returns the review results with the related charge elements', async () => { + const result = await FetchReviewResultsService.go(licenceId) + + expect(result[0]).to.equal({ + reviewChargeElementResultId: reviewResults.reviewChargeElementResultId, + chargeReferenceId: reviewResults.chargeReferenceId, + reviewReturnResultId: reviewResults.reviewReturnResultId, + reviewChargeElementResults: { + id: reviewChargeElementsResults.id, + chargeDatesOverlap: reviewChargeElementsResults.chargeDatesOverlap, + aggregate: reviewChargeElementsResults.aggregate + }, + reviewReturnResults: null + }) + }) + }) + + describe('with related returns but no charge elements', () => { + beforeEach(async () => { + reviewReturnResults = await ReviewReturnResultsHelper.add() + + reviewResults = await ReviewResultsHelper.add({ + licenceId, + reviewChargeElementResultId: null, + reviewReturnResultId: reviewReturnResults.id + }) + }) + + it('returns the review results with the related returns', async () => { + const result = await FetchReviewResultsService.go(licenceId) + + expect(result[0]).to.equal({ + reviewChargeElementResultId: reviewResults.reviewChargeElementResultId, + chargeReferenceId: reviewResults.chargeReferenceId, + reviewReturnResultId: reviewResults.reviewReturnResultId, + reviewChargeElementResults: null, + reviewReturnResults: { + id: reviewReturnResults.id, + underQuery: reviewReturnResults.underQuery, + quantity: reviewReturnResults.quantity, + allocated: reviewReturnResults.allocated, + abstractionOutsidePeriod: reviewReturnResults.abstractionOutsidePeriod, + status: reviewReturnResults.status, + dueDate: reviewReturnResults.dueDate, + receivedDate: reviewReturnResults.receivedDate + } + }) + }) + }) + }) + + describe('when there are no applicable review results', () => { + it('returns no records', async () => { + const result = await FetchReviewResultsService.go(licenceId) + + expect(result).to.be.empty() + }) + }) +}) diff --git a/test/services/bill-runs/two-part-tariff/match-and-allocate.service.test.js b/test/services/bill-runs/two-part-tariff/match-and-allocate.service.test.js index 2c4c9a6be9..41a88ca117 100644 --- a/test/services/bill-runs/two-part-tariff/match-and-allocate.service.test.js +++ b/test/services/bill-runs/two-part-tariff/match-and-allocate.service.test.js @@ -8,8 +8,6 @@ const Sinon = require('sinon') const { describe, it, beforeEach, afterEach } = exports.lab = Lab.script() const { expect } = Code -// Test helpers - // Things we need to stub const AllocateReturnsToChargeElementService = require('../../../../app/services/bill-runs/two-part-tariff/allocate-returns-to-charge-element.service.js') const FetchLicencesService = require('../../../../app/services/bill-runs/two-part-tariff/fetch-licences.service.js') diff --git a/test/services/bill-runs/two-part-tariff/review-bill-run.service.test.js b/test/services/bill-runs/two-part-tariff/review-bill-run.service.test.js new file mode 100644 index 0000000000..7679949c2b --- /dev/null +++ b/test/services/bill-runs/two-part-tariff/review-bill-run.service.test.js @@ -0,0 +1,49 @@ +'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 DetermineBillRunIssuesService = require('../../../../app/services/bill-runs/two-part-tariff/determine-bill-run-issues.service.js') +const FetchBillRunLicencesService = require('../../../../app/services/bill-runs/two-part-tariff/fetch-bill-run-licences.service.js') +const ReviewBillRunPresenter = require('../../../../app/presenters/bill-runs/two-part-tariff/review-bill-run.presenter.js') + +// Thing under test +const ReviewBillRunService = require('../../../../app/services/bill-runs/two-part-tariff/review-bill-run.service.js') + +describe('Review Bill Run Service', () => { + afterEach(() => { + Sinon.restore() + }) + + describe('when a bill run with a matching ID exists', () => { + const billRunId = '2c80bd22-a005-4cf4-a2a2-73812a9861de' + + beforeEach(() => { + Sinon.stub(FetchBillRunLicencesService, 'go').resolves({ billRun: 'bill data', billRunLicences: 'licence data' }) + Sinon.stub(DetermineBillRunIssuesService, 'go').resolves() + Sinon.stub(ReviewBillRunPresenter, 'go').returns('page data') + }) + + it('will fetch the bill run data for the review page and return it once formatted by the presenter', async () => { + const result = await ReviewBillRunService.go(billRunId) + + expect(result).to.equal('page data') + + expect(FetchBillRunLicencesService.go.called).to.be.true() + expect(DetermineBillRunIssuesService.go.called).to.be.true() + expect(ReviewBillRunPresenter.go.called).to.be.true() + }) + }) + + describe('when a bill run with a matching ID does not exist', () => { + it('throws an exception', async () => { + await expect(ReviewBillRunService.go('718c01a2-04bc-40f1-8e06-36c0ee50bb3a')).to.reject() + }) + }) +})