diff --git a/dtfs-central-api/api-tests/v1/utilisation-reports/fee-record-corrections/get-completed-fee-record-corrections.api-test.ts b/dtfs-central-api/api-tests/v1/utilisation-reports/fee-record-corrections/get-completed-fee-record-corrections.api-test.ts new file mode 100644 index 0000000000..e04d6ffe02 --- /dev/null +++ b/dtfs-central-api/api-tests/v1/utilisation-reports/fee-record-corrections/get-completed-fee-record-corrections.api-test.ts @@ -0,0 +1,109 @@ +import { HttpStatusCode } from 'axios'; +import { + Bank, + FEE_RECORD_STATUS, + FeeRecordCorrectionEntityMockBuilder, + FeeRecordEntityMockBuilder, + RECONCILIATION_IN_PROGRESS, + RECORD_CORRECTION_REASON, + UtilisationReportEntityMockBuilder, +} from '@ukef/dtfs2-common'; +import { testApi } from '../../../test-api'; +import { SqlDbHelper } from '../../../sql-db-helper'; +import { aBank } from '../../../../test-helpers'; +import { mongoDbClient } from '../../../../src/drivers/db-client'; +import { wipe } from '../../../wipeDB'; +import { replaceUrlParameterPlaceholders } from '../../../../test-helpers/replace-url-parameter-placeholders'; + +console.error = jest.fn(); + +const BASE_URL = '/v1/bank/:bankId/utilisation-reports/completed-corrections'; + +describe(`GET ${BASE_URL}`, () => { + const bankId = '123'; + const bank: Bank = { + ...aBank(), + id: bankId, + }; + + const report = UtilisationReportEntityMockBuilder.forStatus(RECONCILIATION_IN_PROGRESS).withBankId(bankId).build(); + const feeRecord = FeeRecordEntityMockBuilder.forReport(report).withExporter('An exporter').withStatus(FEE_RECORD_STATUS.TO_DO_AMENDED).build(); + report.feeRecords = [feeRecord]; + + beforeAll(async () => { + await SqlDbHelper.initialize(); + await SqlDbHelper.deleteAllEntries('UtilisationReport'); + await wipe(['banks']); + + const banksCollection = await mongoDbClient.getCollection('banks'); + await banksCollection.insertOne(bank); + + await SqlDbHelper.saveNewEntries('UtilisationReport', [report]); + }); + + afterEach(async () => { + await SqlDbHelper.deleteAllEntries('FeeRecordCorrection'); + }); + + afterAll(async () => { + await SqlDbHelper.deleteAllEntries('UtilisationReport'); + await wipe(['banks']); + }); + + it(`should return '${HttpStatusCode.Ok}' and the completed fee record corrections response`, async () => { + // Arrange + const dateCorrectionReceived = new Date('2024-01-01'); + const oldFacilityId = feeRecord.facilityId; + const newFacilityId = '12345678'; + + const feeRecordCorrection = FeeRecordCorrectionEntityMockBuilder.forFeeRecordAndIsCompleted(feeRecord, true) + .withId(1) + .withDateReceived(dateCorrectionReceived) + .withReasons([RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT, RECORD_CORRECTION_REASON.OTHER]) + .withPreviousValues({ + facilityId: oldFacilityId, + }) + .withCorrectedValues({ + facilityId: newFacilityId, + }) + .withBankCommentary('Some bank commentary') + .build(); + + await SqlDbHelper.saveNewEntry('FeeRecordCorrection', feeRecordCorrection); + + // Act + const response = await testApi.get(replaceUrlParameterPlaceholders(BASE_URL, { bankId })); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Ok); + + const expectedMappedCompletedCorrections = [ + { + id: feeRecordCorrection.id, + dateSent: dateCorrectionReceived.toISOString(), + exporter: feeRecord.exporter, + formattedReasons: 'Facility ID is incorrect, Other', + formattedPreviousValues: `${oldFacilityId}, -`, + formattedCorrectedValues: `${newFacilityId}, -`, + bankCommentary: feeRecordCorrection.bankCommentary, + }, + ]; + + expect(response.body).toEqual(expectedMappedCompletedCorrections); + }); + + it(`should return '${HttpStatusCode.Ok}' and an empty array when no completed fee record corrections with the supplied bank id can be found`, async () => { + // Arrange + const feeRecordCorrection = FeeRecordCorrectionEntityMockBuilder.forFeeRecordAndIsCompleted(feeRecord, true).build(); + + await SqlDbHelper.saveNewEntry('FeeRecordCorrection', feeRecordCorrection); + + // Act + const response = await testApi.get(replaceUrlParameterPlaceholders(BASE_URL, { bankId: `${bankId}123` })); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Ok); + + expect(response.body).toEqual([]); + }); +}); diff --git a/dtfs-central-api/api-tests/v1/utilisation-reports/fee-record-corrections/put-fee-record-correction.api-test.ts b/dtfs-central-api/api-tests/v1/utilisation-reports/fee-record-corrections/put-fee-record-correction.api-test.ts index 2c28098ba3..b1cbf05299 100644 --- a/dtfs-central-api/api-tests/v1/utilisation-reports/fee-record-corrections/put-fee-record-correction.api-test.ts +++ b/dtfs-central-api/api-tests/v1/utilisation-reports/fee-record-corrections/put-fee-record-correction.api-test.ts @@ -6,6 +6,7 @@ import { FeeRecordCorrectionEntityMockBuilder, FeeRecordCorrectionTransientFormDataEntity, FeeRecordCorrectionTransientFormDataEntityMockBuilder, + FeeRecordEntity, FeeRecordEntityMockBuilder, RECONCILIATION_IN_PROGRESS, RECORD_CORRECTION_REASON, @@ -165,6 +166,19 @@ describe(`PUT ${BASE_URL}`, () => { expect(status).toEqual(HttpStatusCode.Ok); }); + it(`should update the fee record with corrected values and set status to ${FEE_RECORD_STATUS.TO_DO_AMENDED}`, async () => { + // Arrange + const requestBody = aValidRequestBody(); + + // Act + await testApi.put(requestBody).to(replaceUrlParameterPlaceholders(BASE_URL, { bankId, correctionId })); + + // Assert + const feeRecord = await SqlDbHelper.manager.findOneBy(FeeRecordEntity, { id: feeRecordId }); + expect(feeRecord?.facilityId).toEqual(correctFacilityId); + expect(feeRecord?.status).toEqual(FEE_RECORD_STATUS.TO_DO_AMENDED); + }); + it('should save the previous and corrected values on the correction and complete it', async () => { // Arrange const requestBody = aValidRequestBody(); diff --git a/dtfs-central-api/src/helpers/index.ts b/dtfs-central-api/src/helpers/index.ts index 76231f06ed..cbfa204673 100644 --- a/dtfs-central-api/src/helpers/index.ts +++ b/dtfs-central-api/src/helpers/index.ts @@ -12,4 +12,5 @@ export { calculateUkefShareOfUtilisation } from './calculate-ukef-share-of-utili export { getKeyingSheetCalculationFacilityValues } from './get-keying-sheet-calculation-facility-values'; export { getCorrectionPreviousValuesFromFeeRecord } from './get-correction-previous-values-from-fee-record'; export { getCorrectionCorrectedValuesFromFormData } from './get-correction-corrected-values-from-form-data'; +export { validateRequiredCorrectionField } from './validate-required-correction-field'; export * from './amendments'; diff --git a/dtfs-central-api/src/helpers/map-correction-reasons-and-values-to-formatted-values.test.ts b/dtfs-central-api/src/helpers/map-correction-reasons-and-values-to-formatted-values.test.ts new file mode 100644 index 0000000000..3e8a0dff39 --- /dev/null +++ b/dtfs-central-api/src/helpers/map-correction-reasons-and-values-to-formatted-values.test.ts @@ -0,0 +1,225 @@ +import { RECORD_CORRECTION_REASON, CURRENCY, getFormattedMonetaryValue, RecordCorrectionReason, aRecordCorrectionValues } from '@ukef/dtfs2-common'; +import { difference } from 'lodash'; +import { + getFormattedCorrectionValueForCorrectionReason, + mapCorrectionReasonsAndValuesToFormattedValues, +} from './map-correction-reasons-and-values-to-formatted-values'; + +console.error = jest.fn(); + +describe('get-completed-fee-record-corrections.controller map-reasons-and-values-to-formatted-values helpers', () => { + describe('getFormattedCorrectionValueForCorrectionReason', () => { + const reasonsExcludingOther = difference(Object.values(RECORD_CORRECTION_REASON), [RECORD_CORRECTION_REASON.OTHER]); + + it(`should return the correction values "facilityId" value for reason "${RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT}"`, () => { + // Arrange + const reason = RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT; + const facilityId = 'some-value'; + const correctionValues = { + ...aRecordCorrectionValues(), + facilityId, + }; + + // Act + const formattedValue = getFormattedCorrectionValueForCorrectionReason(correctionValues, reason); + + // Assert + expect(formattedValue).toEqual(facilityId); + }); + + it(`should return the correction values "feesPaidToUkefForThePeriodCurrency" value for reason "${RECORD_CORRECTION_REASON.REPORTED_CURRENCY_INCORRECT}"`, () => { + // Arrange + const reason = RECORD_CORRECTION_REASON.REPORTED_CURRENCY_INCORRECT; + const feesPaidToUkefForThePeriodCurrency = CURRENCY.GBP; + const correctionValues = { + ...aRecordCorrectionValues(), + feesPaidToUkefForThePeriodCurrency, + }; + + // Act + const formattedValue = getFormattedCorrectionValueForCorrectionReason(correctionValues, reason); + + // Assert + expect(formattedValue).toEqual(feesPaidToUkefForThePeriodCurrency); + }); + + it(`should map correction values "feesPaidToUkefForThePeriod" value to formatted monetary amount for reason "${RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT}"`, () => { + // Arrange + const reason = RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT; + const feesPaidToUkefForThePeriod = 123.45; + const correctionValues = { + ...aRecordCorrectionValues(), + feesPaidToUkefForThePeriod, + }; + + // Act + const formattedValue = getFormattedCorrectionValueForCorrectionReason(correctionValues, reason); + + // Assert + expect(formattedValue).toEqual(getFormattedMonetaryValue(feesPaidToUkefForThePeriod)); + }); + + it(`should map correction values "feesPaidToUkefForThePeriod" value of 0 to formatted monetary amount for reason "${RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT}"`, () => { + // Arrange + const reason = RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT; + const feesPaidToUkefForThePeriod = 0; + const correctionValues = { + ...aRecordCorrectionValues(), + feesPaidToUkefForThePeriod, + }; + + // Act + const formattedValue = getFormattedCorrectionValueForCorrectionReason(correctionValues, reason); + + // Assert + expect(formattedValue).toEqual(getFormattedMonetaryValue(feesPaidToUkefForThePeriod)); + }); + + it(`should map correction values "facilityUtilisation" value to formatted monetary amount for reason "${RECORD_CORRECTION_REASON.UTILISATION_INCORRECT}"`, () => { + // Arrange + const reason = RECORD_CORRECTION_REASON.UTILISATION_INCORRECT; + const facilityUtilisation = 10000.23; + const correctionValues = { + ...aRecordCorrectionValues(), + facilityUtilisation, + }; + + // Act + const formattedValue = getFormattedCorrectionValueForCorrectionReason(correctionValues, reason); + + // Assert + expect(formattedValue).toEqual(getFormattedMonetaryValue(facilityUtilisation)); + }); + + it(`should map correction values "facilityUtilisation" value of 0 to formatted monetary amount for reason "${RECORD_CORRECTION_REASON.UTILISATION_INCORRECT}"`, () => { + // Arrange + const reason = RECORD_CORRECTION_REASON.UTILISATION_INCORRECT; + const facilityUtilisation = 0; + const correctionValues = { + ...aRecordCorrectionValues(), + facilityUtilisation, + }; + + // Act + const formattedValue = getFormattedCorrectionValueForCorrectionReason(correctionValues, reason); + + // Assert + expect(formattedValue).toEqual(getFormattedMonetaryValue(facilityUtilisation)); + }); + + it(`should map reason "${RECORD_CORRECTION_REASON.OTHER}" to a hyphen character`, () => { + // Arrange + const reason = RECORD_CORRECTION_REASON.OTHER; + const correctionValues = aRecordCorrectionValues(); + + // Act + const formattedValue = getFormattedCorrectionValueForCorrectionReason(correctionValues, reason); + + // Assert + expect(formattedValue).toEqual('-'); + }); + + it.each(reasonsExcludingOther)('should throw error when required value for reason "%s" is set to "null" in the correction values', (reason) => { + // Arrange + const correctionValues = { + facilityId: null, + facilityUtilisation: null, + feesPaidToUkefForThePeriod: null, + feesPaidToUkefForThePeriodCurrency: null, + }; + + // Act & Assert + expect(() => getFormattedCorrectionValueForCorrectionReason(correctionValues, reason)).toThrow(); + }); + }); + + describe('mapCorrectionReasonsAndValuesToFormattedValues', () => { + it('should return an empty array if no reasons are provided', () => { + // Arrange + const reasons: RecordCorrectionReason[] = []; + const correctionValues = aRecordCorrectionValues(); + + // Act + const formattedValues = mapCorrectionReasonsAndValuesToFormattedValues(reasons, correctionValues); + + // Assert + expect(formattedValues).toEqual([]); + }); + + it(`should return the expected array of formatted form data values when only one reason is provided`, () => { + // Arrange + const reasons = [RECORD_CORRECTION_REASON.UTILISATION_INCORRECT]; + + const facilityUtilisation = 10000.23; + + const correctionValues = { + ...aRecordCorrectionValues(), + facilityUtilisation, + }; + + // Act + const formattedValues = mapCorrectionReasonsAndValuesToFormattedValues(reasons, correctionValues); + + // Assert + const expectedFormattedValues = [getFormattedMonetaryValue(facilityUtilisation)]; + + expect(formattedValues).toHaveLength(1); + expect(formattedValues).toEqual(expectedFormattedValues); + }); + + it(`should return the expected array of formatted form data values when some reasons are provided`, () => { + // Arrange + const reasons = [RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT, RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT, RECORD_CORRECTION_REASON.OTHER]; + + const feesPaidToUkefForThePeriod = 10000.23; + const facilityId = '12345678'; + + const correctionValues = { + ...aRecordCorrectionValues(), + feesPaidToUkefForThePeriod, + facilityId, + }; + + // Act + const formattedValues = mapCorrectionReasonsAndValuesToFormattedValues(reasons, correctionValues); + + // Assert + const expectedFormattedValues = [getFormattedMonetaryValue(feesPaidToUkefForThePeriod), facilityId, '-']; + + expect(formattedValues).toHaveLength(3); + expect(formattedValues).toEqual(expectedFormattedValues); + }); + + it(`should return the expected array of formatted form data values when all reasons are provided`, () => { + // Arrange + const reasons = Object.values(RECORD_CORRECTION_REASON); + + const facilityId = '12345678'; + const feesPaidToUkefForThePeriodCurrency = CURRENCY.EUR; + const feesPaidToUkefForThePeriod = 123.45; + const facilityUtilisation = 100000; + + const correctionValues = { + facilityId, + feesPaidToUkefForThePeriodCurrency, + feesPaidToUkefForThePeriod, + facilityUtilisation, + }; + + // Act + const formattedValues = mapCorrectionReasonsAndValuesToFormattedValues(reasons, correctionValues); + + // Assert + const expectedFormattedValues = [ + facilityId, + feesPaidToUkefForThePeriodCurrency, + getFormattedMonetaryValue(feesPaidToUkefForThePeriod), + getFormattedMonetaryValue(facilityUtilisation), + '-', + ]; + + expect(formattedValues).toHaveLength(5); + expect(formattedValues).toEqual(expectedFormattedValues); + }); + }); +}); diff --git a/dtfs-central-api/src/helpers/map-correction-reasons-and-values-to-formatted-values.ts b/dtfs-central-api/src/helpers/map-correction-reasons-and-values-to-formatted-values.ts new file mode 100644 index 0000000000..24ebc939b8 --- /dev/null +++ b/dtfs-central-api/src/helpers/map-correction-reasons-and-values-to-formatted-values.ts @@ -0,0 +1,53 @@ +import { getFormattedMonetaryValue, RECORD_CORRECTION_REASON, RecordCorrectionReason, RecordCorrectionValues } from '@ukef/dtfs2-common'; +import { validateRequiredCorrectionField } from '.'; + +/** + * Gets a formatted string value for a given correction reason from the correction values. + * + * @remarks + * + * No validation is performed for the {@link RECORD_CORRECTION_REASON.OTHER} + * reason as this value is not stored on the entity object. However, it still + * needs to be included in the formatted string output with a '-' placeholder + * so that the {@link RECORD_CORRECTION_REASON.OTHER} reference in the + * 'reasons' column has a corresponding value. + * + * @param correctionValues - The correction values entity. + * @param reason - The correction reason to get the formatted value for. + * @returns The formatted value for the correction reason. + * @throws Error if the correction reason is unknown. + * @throws Error if a required correction value is missing for the given reason. + */ +export const getFormattedCorrectionValueForCorrectionReason = (correctionValues: RecordCorrectionValues, reason: RecordCorrectionReason): string => { + switch (reason) { + case RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT: + validateRequiredCorrectionField(correctionValues.facilityId, reason); + + return correctionValues.facilityId; + case RECORD_CORRECTION_REASON.REPORTED_CURRENCY_INCORRECT: + validateRequiredCorrectionField(correctionValues.feesPaidToUkefForThePeriodCurrency, reason); + + return correctionValues.feesPaidToUkefForThePeriodCurrency; + case RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT: + validateRequiredCorrectionField(correctionValues.feesPaidToUkefForThePeriod, reason); + + return getFormattedMonetaryValue(correctionValues.feesPaidToUkefForThePeriod); + case RECORD_CORRECTION_REASON.UTILISATION_INCORRECT: + validateRequiredCorrectionField(correctionValues.facilityUtilisation, reason); + + return getFormattedMonetaryValue(correctionValues.facilityUtilisation); + case RECORD_CORRECTION_REASON.OTHER: + return '-'; + default: + throw new Error('Unknown correction reason'); + } +}; + +/** + * Maps an array of correction reasons and their corresponding values to an array of formatted string values. + * @param reasons - Array of correction reasons to map. + * @param correctionValues - The correction values. + * @returns An array of formatted correction values. + */ +export const mapCorrectionReasonsAndValuesToFormattedValues = (reasons: RecordCorrectionReason[], correctionValues: RecordCorrectionValues): string[] => + reasons.map((reason) => getFormattedCorrectionValueForCorrectionReason(correctionValues, reason)); diff --git a/dtfs-central-api/src/helpers/map-correction-reasons-to-formatted-old-fee-record-values.test.ts b/dtfs-central-api/src/helpers/map-correction-reasons-to-formatted-old-fee-record-values.test.ts index 03fde778c6..b91a82fc09 100644 --- a/dtfs-central-api/src/helpers/map-correction-reasons-to-formatted-old-fee-record-values.test.ts +++ b/dtfs-central-api/src/helpers/map-correction-reasons-to-formatted-old-fee-record-values.test.ts @@ -4,7 +4,7 @@ import { getFormattedOldValueForCorrectionReason, } from './map-correction-reasons-to-formatted-old-fee-record-values'; -describe('map-correction-reasons-to-formatted-values', () => { +describe('map-correction-reasons-to-formatted-old-fee-record-values', () => { const feeRecord = new FeeRecordEntityMockBuilder().build(); describe('getFormattedOldValueForCorrectionReason', () => { diff --git a/dtfs-central-api/src/helpers/map-correction-reasons-to-formatted-values.test.ts b/dtfs-central-api/src/helpers/map-correction-reasons-to-formatted-values.test.ts deleted file mode 100644 index 09cd035e37..0000000000 --- a/dtfs-central-api/src/helpers/map-correction-reasons-to-formatted-values.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { FeeRecordEntityMockBuilder, RECORD_CORRECTION_REASON, getFormattedMonetaryValue, FeeRecordCorrectionEntityMockBuilder } from '@ukef/dtfs2-common'; -import { - getFormattedValueForCorrectionReason, - mapCorrectionReasonsToFormattedCorrectValues, - mapCorrectionReasonsToFormattedPreviousValues, -} from './map-correction-reasons-to-formatted-values'; - -describe('map-correction-reasons-to-formatted-values', () => { - const correctionId = 3; - - const feeRecord = new FeeRecordEntityMockBuilder().build(); - - const correctionEntity = FeeRecordCorrectionEntityMockBuilder.forFeeRecordAndIsCompleted(feeRecord, true) - .withId(correctionId) - .withReasons([ - RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT, - RECORD_CORRECTION_REASON.REPORTED_CURRENCY_INCORRECT, - RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT, - RECORD_CORRECTION_REASON.UTILISATION_INCORRECT, - RECORD_CORRECTION_REASON.OTHER, - ]) - .withPreviousValues({ - facilityUtilisation: 600000, - feesPaidToUkefForThePeriod: 12345, - feesPaidToUkefForThePeriodCurrency: 'GBP', - facilityId: '123456', - }) - .withCorrectedValues({ - facilityUtilisation: 100000, - feesPaidToUkefForThePeriod: 1111, - feesPaidToUkefForThePeriodCurrency: 'JPY', - facilityId: '654321', - }) - .build(); - - describe('getFormattedValueForCorrectionReason', () => { - describe('previousValues', () => { - describe(`when the reason is ${RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT}`, () => { - it('should return the facility id of the previousValues', () => { - const result = getFormattedValueForCorrectionReason(correctionEntity.previousValues, RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT); - const expected = correctionEntity.previousValues.facilityId; - expect(result).toEqual(expected); - }); - }); - - describe(`when the reason is ${RECORD_CORRECTION_REASON.REPORTED_CURRENCY_INCORRECT}`, () => { - it('should return feesPaidToUkefForThePeriodCurrency from the previousValues', () => { - const result = getFormattedValueForCorrectionReason(correctionEntity.previousValues, RECORD_CORRECTION_REASON.REPORTED_CURRENCY_INCORRECT); - - const expected = correctionEntity.previousValues.feesPaidToUkefForThePeriodCurrency; - - expect(result).toEqual(expected); - }); - }); - - describe(`when the reason is ${RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT}`, () => { - it('should return feesPaidToUkefForThePeriod from the previousValues', () => { - const result = getFormattedValueForCorrectionReason(correctionEntity.previousValues, RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT); - - const expected = getFormattedMonetaryValue(Number(correctionEntity.previousValues.feesPaidToUkefForThePeriod)); - - expect(result).toEqual(expected); - }); - }); - - describe(`when the reason is ${RECORD_CORRECTION_REASON.UTILISATION_INCORRECT}`, () => { - it('should return facilityUtilisation from the previousValues', () => { - const result = getFormattedValueForCorrectionReason(correctionEntity.previousValues, RECORD_CORRECTION_REASON.UTILISATION_INCORRECT); - - const expected = getFormattedMonetaryValue(Number(correctionEntity.previousValues.facilityUtilisation)); - - expect(result).toEqual(expected); - }); - }); - - describe(`when the reason is ${RECORD_CORRECTION_REASON.OTHER}`, () => { - it('should return a dash', () => { - const result = getFormattedValueForCorrectionReason(correctionEntity.previousValues, RECORD_CORRECTION_REASON.OTHER); - - expect(result).toEqual('-'); - }); - }); - }); - - describe('correctValues', () => { - describe(`when the reason is ${RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT}`, () => { - it('should return the facility id of the correctedValues', () => { - const result = getFormattedValueForCorrectionReason(correctionEntity.correctedValues, RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT); - const expected = correctionEntity.correctedValues.facilityId; - expect(result).toEqual(expected); - }); - }); - - describe(`when the reason is ${RECORD_CORRECTION_REASON.REPORTED_CURRENCY_INCORRECT}`, () => { - it('should return feesPaidToUkefForThePeriodCurrency from the correctedValues', () => { - const result = getFormattedValueForCorrectionReason(correctionEntity.correctedValues, RECORD_CORRECTION_REASON.REPORTED_CURRENCY_INCORRECT); - - const expected = correctionEntity.correctedValues.feesPaidToUkefForThePeriodCurrency; - - expect(result).toEqual(expected); - }); - }); - - describe(`when the reason is ${RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT}`, () => { - it('should return feesPaidToUkefForThePeriod from the correctedValues', () => { - const result = getFormattedValueForCorrectionReason(correctionEntity.correctedValues, RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT); - - const expected = getFormattedMonetaryValue(Number(correctionEntity.correctedValues.feesPaidToUkefForThePeriod)); - - expect(result).toEqual(expected); - }); - }); - - describe(`when the reason is ${RECORD_CORRECTION_REASON.UTILISATION_INCORRECT}`, () => { - it('should return facilityUtilisation from the correctedValues', () => { - const result = getFormattedValueForCorrectionReason(correctionEntity.correctedValues, RECORD_CORRECTION_REASON.UTILISATION_INCORRECT); - - const expected = getFormattedMonetaryValue(Number(correctionEntity.correctedValues.facilityUtilisation)); - - expect(result).toEqual(expected); - }); - }); - - describe(`when the reason is ${RECORD_CORRECTION_REASON.OTHER}`, () => { - it('should return a dash', () => { - const result = getFormattedValueForCorrectionReason(correctionEntity.correctedValues, RECORD_CORRECTION_REASON.OTHER); - - expect(result).toEqual('-'); - }); - }); - }); - }); - - describe('mapCorrectionReasonsToFormattedCorrectValues', () => { - describe('when the reasons array contains all the reasons', () => { - it('should return an array of the values of each correctedValue that corresponds to each reason', () => { - const reasons = [ - RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT, - RECORD_CORRECTION_REASON.REPORTED_CURRENCY_INCORRECT, - RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT, - RECORD_CORRECTION_REASON.UTILISATION_INCORRECT, - RECORD_CORRECTION_REASON.OTHER, - ]; - - const result = mapCorrectionReasonsToFormattedCorrectValues(correctionEntity, reasons); - - const expected = [ - correctionEntity.correctedValues.facilityId, - correctionEntity.correctedValues.feesPaidToUkefForThePeriodCurrency, - getFormattedMonetaryValue(Number(correctionEntity.correctedValues.feesPaidToUkefForThePeriod)), - getFormattedMonetaryValue(Number(correctionEntity.correctedValues.facilityUtilisation)), - '-', - ]; - - expect(result).toEqual(expected); - }); - }); - - describe('when the reasons array contains one reason', () => { - it('should return an array of the value of correctedValues property that corresponds to the reason', () => { - const reasons = [RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT]; - - const result = mapCorrectionReasonsToFormattedCorrectValues(correctionEntity, reasons); - - const expected = [correctionEntity.correctedValues.facilityId]; - - expect(result).toEqual(expected); - }); - }); - - describe(`when the reasons array only contains ${RECORD_CORRECTION_REASON.OTHER}`, () => { - it('should return an array with a string "-"', () => { - const reasons = [RECORD_CORRECTION_REASON.OTHER]; - - const result = mapCorrectionReasonsToFormattedCorrectValues(correctionEntity, reasons); - - const expected = ['-']; - - expect(result).toEqual(expected); - }); - }); - }); - - describe('mapCorrectionReasonsToFormattedPreviousValues', () => { - describe('when the reasons array contains all the reasons', () => { - it('should return an array of the values of each previousValue that corresponds to each reason', () => { - const reasons = [ - RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT, - RECORD_CORRECTION_REASON.REPORTED_CURRENCY_INCORRECT, - RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT, - RECORD_CORRECTION_REASON.UTILISATION_INCORRECT, - RECORD_CORRECTION_REASON.OTHER, - ]; - - const result = mapCorrectionReasonsToFormattedPreviousValues(correctionEntity, reasons); - - const expected = [ - correctionEntity.previousValues.facilityId, - correctionEntity.previousValues.feesPaidToUkefForThePeriodCurrency, - getFormattedMonetaryValue(Number(correctionEntity.previousValues.feesPaidToUkefForThePeriod)), - getFormattedMonetaryValue(Number(correctionEntity.previousValues.facilityUtilisation)), - '-', - ]; - - expect(result).toEqual(expected); - }); - }); - - describe('when the reasons array contains one reason', () => { - it('should return an array of the value of previousValues that corresponds to the reason', () => { - const reasons = [RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT]; - - const result = mapCorrectionReasonsToFormattedPreviousValues(correctionEntity, reasons); - - const expected = [correctionEntity.previousValues.facilityId]; - - expect(result).toEqual(expected); - }); - }); - - describe(`when the reasons array only contains ${RECORD_CORRECTION_REASON.OTHER}`, () => { - it('should return an array with a string "-"', () => { - const reasons = [RECORD_CORRECTION_REASON.OTHER]; - - const result = mapCorrectionReasonsToFormattedPreviousValues(correctionEntity, reasons); - - const expected = ['-']; - - expect(result).toEqual(expected); - }); - }); - }); -}); diff --git a/dtfs-central-api/src/helpers/map-correction-reasons-to-formatted-values.ts b/dtfs-central-api/src/helpers/map-correction-reasons-to-formatted-values.ts deleted file mode 100644 index 90734403d8..0000000000 --- a/dtfs-central-api/src/helpers/map-correction-reasons-to-formatted-values.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - FeeRecordCorrectionEntity, - RecordCorrectionValues, - RECORD_CORRECTION_REASON, - RecordCorrectionReason, - getFormattedMonetaryValue, -} from '@ukef/dtfs2-common'; - -/** - * returns the value of the provided correction values that corresponds to the reason - * or '-' if the reason is RECORD_CORRECTION_REASON.OTHER - * @param correctionValues - the previousValues or correctedValues - * @param reason - the reason for the correction - * @returns - string with the value of the correction value that corresponds to the reason - */ -export const getFormattedValueForCorrectionReason = (correctionValues: RecordCorrectionValues, reason: RecordCorrectionReason) => { - switch (reason) { - case RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT: - return correctionValues.facilityId; - case RECORD_CORRECTION_REASON.REPORTED_CURRENCY_INCORRECT: - return correctionValues.feesPaidToUkefForThePeriodCurrency; - case RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT: - return getFormattedMonetaryValue(Number(correctionValues.feesPaidToUkefForThePeriod)); - case RECORD_CORRECTION_REASON.UTILISATION_INCORRECT: - return getFormattedMonetaryValue(Number(correctionValues.facilityUtilisation)); - default: - return '-'; - } -}; - -/** - * maps through an array of reasons for the correct values - * returns an array of the values of each correctedValues property that corresponds to each reason - * in the same order as the reasons array - * @param correction - the correctedValues - * @param reasons - the reasons for the correction - * @returns an array of the values of each correctedValues property that corresponds to each reason - */ -export const mapCorrectionReasonsToFormattedCorrectValues = (correction: FeeRecordCorrectionEntity, reasons: RecordCorrectionReason[]) => { - return reasons.map((reason) => getFormattedValueForCorrectionReason(correction.correctedValues, reason)); -}; - -/** - * maps through an array of reasons for the previous values - * returns an array of the values of each previousValues property that corresponds to each reason - * in the same order as the reasons array - * @param correction - the previousValues - * @param reasons - the reasons for the correction - * @returns an array of the values of each previousValues property that corresponds to each reason - */ -export const mapCorrectionReasonsToFormattedPreviousValues = (correction: FeeRecordCorrectionEntity, reasons: RecordCorrectionReason[]) => { - return reasons.map((reason) => getFormattedValueForCorrectionReason(correction.previousValues, reason)); -}; diff --git a/dtfs-central-api/src/helpers/validate-required-correction-field.test.ts b/dtfs-central-api/src/helpers/validate-required-correction-field.test.ts new file mode 100644 index 0000000000..24c70d813e --- /dev/null +++ b/dtfs-central-api/src/helpers/validate-required-correction-field.test.ts @@ -0,0 +1,55 @@ +import { RECORD_CORRECTION_REASON } from '@ukef/dtfs2-common'; +import { validateRequiredCorrectionField } from './validate-required-correction-field'; + +console.error = jest.fn(); + +describe('validate-required-correction-field helpers', () => { + describe('validateRequiredCorrectionField', () => { + it('should not throw error when value is defined and not null', () => { + // Arrange + const value = 'test'; + const reason = RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT; + + // Act & Assert + expect(() => validateRequiredCorrectionField(value, reason)).not.toThrow(); + }); + + it('should not throw error when value is zero', () => { + // Arrange + const value = 0; + const reason = RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT; + + // Act & Assert + expect(() => validateRequiredCorrectionField(value, reason)).not.toThrow(); + }); + + it('should throw error when value is undefined', () => { + // Arrange + const value = undefined; + const reason = RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT; + const expectedError = `Required field is missing value for correction reason: ${reason}`; + + // Act & Assert + expect(() => validateRequiredCorrectionField(value, reason)).toThrow(expectedError); + }); + + it('should not throw error when value is false', () => { + // Arrange + const value = false; + const reason = RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT; + + // Act & Assert + expect(() => validateRequiredCorrectionField(value, reason)).not.toThrow(); + }); + + it('should throw error when value is null', () => { + // Arrange + const value = null; + const reason = RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT; + const expectedError = `Required field is missing value for correction reason: ${reason}`; + + // Act & Assert + expect(() => validateRequiredCorrectionField(value, reason)).toThrow(expectedError); + }); + }); +}); diff --git a/dtfs-central-api/src/helpers/validate-required-correction-field.ts b/dtfs-central-api/src/helpers/validate-required-correction-field.ts new file mode 100644 index 0000000000..41f45df54b --- /dev/null +++ b/dtfs-central-api/src/helpers/validate-required-correction-field.ts @@ -0,0 +1,14 @@ +import { RecordCorrectionReason } from '@ukef/dtfs2-common'; + +/** + * Validates that a required fee record correction field has a value. + * Zero (0) is considered a valid value and will not throw an error. + * @param value - The value to validate + * @param reason - The correction reason associated with the field + * @throws Error if the value is undefined or null + */ +export function validateRequiredCorrectionField(value: T | undefined | null, reason: RecordCorrectionReason): asserts value is NonNullable { + if (value === undefined || value === null) { + throw new Error(`Required field is missing value for correction reason: ${reason}`); + } +} diff --git a/dtfs-central-api/src/repositories/fee-record-correction-repo/fee-record-correction.repo.ts b/dtfs-central-api/src/repositories/fee-record-correction-repo/fee-record-correction.repo.ts index b07de52628..6cdcc55869 100644 --- a/dtfs-central-api/src/repositories/fee-record-correction-repo/fee-record-correction.repo.ts +++ b/dtfs-central-api/src/repositories/fee-record-correction-repo/fee-record-correction.repo.ts @@ -62,6 +62,16 @@ export const FeeRecordCorrectionRepo = SqlDbDataSource.getRepository(FeeRecordCo return await this.findOneBy({ id: correctionId, feeRecord: { report: { bankId } } }); }, + /** + * Finds completed fee record corrections with the given bank id, with + * each of their fee records attached. + * @param bankId - The id of the bank. + * @returns The found fee record corrections with each of their fee records attached. + */ + async findCompletedCorrectionsByBankIdWithFeeRecord(bankId: string): Promise { + return await this.find({ where: { isCompleted: true, feeRecord: { report: { bankId } } }, relations: { feeRecord: true } }); + }, + withTransaction(transactionEntityManager: EntityManager) { const transactionRepository = transactionEntityManager.getRepository(FeeRecordCorrectionEntity); diff --git a/dtfs-central-api/src/services/state-machines/fee-record/event-handlers/correction-requested/correction-requested.event-handler.test.ts b/dtfs-central-api/src/services/state-machines/fee-record/event-handlers/correction-requested/correction-requested.event-handler.test.ts index 7582f9f80e..977bcd4f85 100644 --- a/dtfs-central-api/src/services/state-machines/fee-record/event-handlers/correction-requested/correction-requested.event-handler.test.ts +++ b/dtfs-central-api/src/services/state-machines/fee-record/event-handlers/correction-requested/correction-requested.event-handler.test.ts @@ -23,6 +23,9 @@ describe('handleFeeRecordCorrectionRequestedEvent', () => { lastName: 'User', }; + const bankTeamName = 'Payment Officer Team'; + const bankTeamEmails = ['test@ukexportfinance.gov.uk']; + const aCorrectionRequestedEventPayload = (): FeeRecordCorrectionRequestedEvent['payload'] => ({ transactionEntityManager: mockEntityManager, reasons: [RECORD_CORRECTION_REASON.REPORTED_CURRENCY_INCORRECT], @@ -31,6 +34,8 @@ describe('handleFeeRecordCorrectionRequestedEvent', () => { ...requestedByUser, }, requestSource: aDbRequestSource(), + bankTeamName, + bankTeamEmails, }); afterEach(() => { @@ -104,6 +109,8 @@ describe('handleFeeRecordCorrectionRequestedEvent', () => { additionalInfo, requestedByUser, requestSource, + bankTeamName, + bankTeamEmails, }); // Assert @@ -113,6 +120,8 @@ describe('handleFeeRecordCorrectionRequestedEvent', () => { reasons, additionalInfo, requestSource, + bankTeamName, + bankTeamEmails, }); expect(mockSave).toHaveBeenCalledWith(FeeRecordCorrectionEntity, newCorrection); }); diff --git a/dtfs-central-api/src/services/state-machines/fee-record/event-handlers/correction-requested/correction-requested.event-handler.ts b/dtfs-central-api/src/services/state-machines/fee-record/event-handlers/correction-requested/correction-requested.event-handler.ts index 61f10cf6a4..b1f78cbeb1 100644 --- a/dtfs-central-api/src/services/state-machines/fee-record/event-handlers/correction-requested/correction-requested.event-handler.ts +++ b/dtfs-central-api/src/services/state-machines/fee-record/event-handlers/correction-requested/correction-requested.event-handler.ts @@ -8,6 +8,8 @@ type CorrectionRequestedEventPayload = { requestedByUser: RequestedByUser; reasons: RecordCorrectionReason[]; additionalInfo: string; + bankTeamName: string; + bankTeamEmails: string[]; }; export type FeeRecordCorrectionRequestedEvent = BaseFeeRecordEvent<'CORRECTION_REQUESTED', CorrectionRequestedEventPayload>; @@ -25,7 +27,7 @@ export type FeeRecordCorrectionRequestedEvent = BaseFeeRecordEvent<'CORRECTION_R */ export const handleFeeRecordCorrectionRequestedEvent = async ( feeRecord: FeeRecordEntity, - { transactionEntityManager, requestSource, requestedByUser, reasons, additionalInfo }: CorrectionRequestedEventPayload, + { transactionEntityManager, requestSource, requestedByUser, reasons, additionalInfo, bankTeamName, bankTeamEmails }: CorrectionRequestedEventPayload, ): Promise => { const correction = FeeRecordCorrectionEntity.createRequestedCorrection({ feeRecord, @@ -33,6 +35,8 @@ export const handleFeeRecordCorrectionRequestedEvent = async ( reasons, additionalInfo, requestSource, + bankTeamName, + bankTeamEmails, }); await transactionEntityManager.save(FeeRecordCorrectionEntity, correction); diff --git a/dtfs-central-api/src/services/state-machines/fee-record/fee-record.state-machine.test.ts b/dtfs-central-api/src/services/state-machines/fee-record/fee-record.state-machine.test.ts index c06c4d46b2..056171d4ac 100644 --- a/dtfs-central-api/src/services/state-machines/fee-record/fee-record.state-machine.test.ts +++ b/dtfs-central-api/src/services/state-machines/fee-record/fee-record.state-machine.test.ts @@ -93,6 +93,8 @@ describe('FeeRecordStateMachine', () => { reasons: [RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT], additionalInfo: 'some additional information', requestSource: { platform: REQUEST_PLATFORM_TYPE.TFM, userId: 'abc123' }, + bankTeamName: 'Payment Officer Team', + bankTeamEmails: ['test@ukexportfinance.gov.uk'], }, }); diff --git a/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-completed-fee-record-corrections.controller/helpers/index.ts b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-completed-fee-record-corrections.controller/helpers/index.ts new file mode 100644 index 0000000000..ca9fa6b038 --- /dev/null +++ b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-completed-fee-record-corrections.controller/helpers/index.ts @@ -0,0 +1 @@ +export { mapCompletedFeeRecordCorrectionsToResponse } from './map-completed-corrections-to-response'; diff --git a/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-completed-fee-record-corrections.controller/helpers/map-completed-corrections-to-response.test.ts b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-completed-fee-record-corrections.controller/helpers/map-completed-corrections-to-response.test.ts new file mode 100644 index 0000000000..cf2821c1d0 --- /dev/null +++ b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-completed-fee-record-corrections.controller/helpers/map-completed-corrections-to-response.test.ts @@ -0,0 +1,112 @@ +import { + CURRENCY, + FeeRecordCorrectionEntity, + FeeRecordCorrectionEntityMockBuilder, + FeeRecordEntityMockBuilder, + mapReasonToDisplayValue, + RECORD_CORRECTION_REASON, +} from '@ukef/dtfs2-common'; +import { mapCompletedFeeRecordCorrectionsToResponse } from './map-completed-corrections-to-response'; + +console.error = jest.fn(); + +describe('get-completed-fee-record-corrections.controller map-completed-corrections-to-response helpers', () => { + describe('mapCompletedFeeRecordCorrectionsToResponse', () => { + it('should return an empty array if no completed corrections are provided', () => { + // Arrange + const completedCorrections: FeeRecordCorrectionEntity[] = []; + + // Act + const response = mapCompletedFeeRecordCorrectionsToResponse(completedCorrections); + + // Assert + expect(response).toEqual([]); + }); + + it('should map completed fee record corrections to response format', () => { + // Arrange + const exporter = 'An exporter'; + + const feeRecordEntity = new FeeRecordEntityMockBuilder().withExporter(exporter).build(); + + const correctionId = 123; + const dateReceived = new Date(); + const reasons = [RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT, RECORD_CORRECTION_REASON.REPORTED_CURRENCY_INCORRECT]; + + const previousValues = { + feesPaidToUkefForThePeriod: 123.45, + feesPaidToUkefForThePeriodCurrency: CURRENCY.USD, + }; + + const correctedValues = { + feesPaidToUkefForThePeriod: 987.65, + feesPaidToUkefForThePeriodCurrency: CURRENCY.EUR, + }; + + const bankCommentary = 'Some bank commentary'; + + const feeRecordCorrectionEntity = FeeRecordCorrectionEntityMockBuilder.forFeeRecordAndIsCompleted(feeRecordEntity, true) + .withId(correctionId) + .withReasons(reasons) + .withDateReceived(dateReceived) + .withPreviousValues(previousValues) + .withCorrectedValues(correctedValues) + .withBankCommentary(bankCommentary) + .build(); + + const completedCorrections = [feeRecordCorrectionEntity]; + + // Act + const response = mapCompletedFeeRecordCorrectionsToResponse(completedCorrections); + + // Assert + const expectedFormattedReasons = `${mapReasonToDisplayValue(reasons[0])}, ${mapReasonToDisplayValue(reasons[1])}`; + const expectedFormattedPreviousValues = `${previousValues.feesPaidToUkefForThePeriod}, ${previousValues.feesPaidToUkefForThePeriodCurrency}`; + const expectedFormattedCorrectedValues = `${correctedValues.feesPaidToUkefForThePeriod}, ${correctedValues.feesPaidToUkefForThePeriodCurrency}`; + + const expectedResponse = [ + { + id: correctionId, + dateSent: dateReceived, + exporter, + formattedReasons: expectedFormattedReasons, + formattedPreviousValues: expectedFormattedPreviousValues, + formattedCorrectedValues: expectedFormattedCorrectedValues, + bankCommentary, + }, + ]; + + expect(response).toEqual(expectedResponse); + }); + + it('should set the mapped corrections "bankCommentary" attribute to undefined when a corrections bank commentary is null', () => { + // Arrange + const feeRecordEntity = new FeeRecordEntityMockBuilder().build(); + + const feeRecordCorrectionEntity = FeeRecordCorrectionEntityMockBuilder.forFeeRecordAndIsCompleted(feeRecordEntity, true).withBankCommentary(null).build(); + + const completedCorrections = [feeRecordCorrectionEntity]; + + // Act + const response = mapCompletedFeeRecordCorrectionsToResponse(completedCorrections); + + // Assert + expect(response).toHaveLength(1); + expect(response[0].bankCommentary).toBeUndefined(); + }); + + it('should throw error when "dateReceived" is null', () => { + // Arrange + const feeRecordEntity = new FeeRecordEntityMockBuilder().build(); + + const feeRecordCorrectionEntity = FeeRecordCorrectionEntityMockBuilder.forFeeRecordAndIsCompleted(feeRecordEntity, true).withDateReceived(null).build(); + + const completedCorrections = [feeRecordCorrectionEntity]; + + // Act & Assert + expect(() => mapCompletedFeeRecordCorrectionsToResponse(completedCorrections)).toThrow( + 'Invalid state: "dateReceived" is null but correction is marked as completed.', + ); + }); + }); +}); diff --git a/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-completed-fee-record-corrections.controller/helpers/map-completed-corrections-to-response.ts b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-completed-fee-record-corrections.controller/helpers/map-completed-corrections-to-response.ts new file mode 100644 index 0000000000..6687a772a3 --- /dev/null +++ b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-completed-fee-record-corrections.controller/helpers/map-completed-corrections-to-response.ts @@ -0,0 +1,38 @@ +import { FeeRecordCorrectionEntity, mapReasonsToDisplayValues } from '@ukef/dtfs2-common'; +import { mapCorrectionReasonsAndValuesToFormattedValues } from '../../../../../../helpers/map-correction-reasons-and-values-to-formatted-values'; +import { GetCompletedFeeRecordCorrectionsResponseBody } from '..'; + +/** + * Maps an array of completed fee record corrections to a response format. + * @param completedCorrections - Array of fee record correction entities to be mapped + * @returns Array of mapped fee record corrections containing formatted values + * @throws Error if a completed correction has a null dateReceived value + */ +export const mapCompletedFeeRecordCorrectionsToResponse = (completedCorrections: FeeRecordCorrectionEntity[]): GetCompletedFeeRecordCorrectionsResponseBody => + completedCorrections.map((feeRecordCorrection) => { + const { id, feeRecord, dateReceived, reasons, previousValues, correctedValues, bankCommentary } = feeRecordCorrection; + + if (dateReceived === null) { + throw new Error('Invalid state: "dateReceived" is null but correction is marked as completed.'); + } + + const { exporter } = feeRecord; + + const formattedReasons = mapReasonsToDisplayValues(reasons).join(', '); + + const formattedPreviousValues = mapCorrectionReasonsAndValuesToFormattedValues(reasons, previousValues).join(', '); + + const formattedCorrectedValues = mapCorrectionReasonsAndValuesToFormattedValues(reasons, correctedValues).join(', '); + + const mappedBankCommentary = bankCommentary ?? undefined; + + return { + id, + dateSent: dateReceived, + exporter, + formattedReasons, + formattedPreviousValues, + formattedCorrectedValues, + bankCommentary: mappedBankCommentary, + }; + }); diff --git a/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-completed-fee-record-corrections.controller/index.test.ts b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-completed-fee-record-corrections.controller/index.test.ts new file mode 100644 index 0000000000..95681e0ba7 --- /dev/null +++ b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-completed-fee-record-corrections.controller/index.test.ts @@ -0,0 +1,131 @@ +import httpMocks, { MockResponse } from 'node-mocks-http'; +import { FeeRecordCorrectionEntityMockBuilder, TestApiError } from '@ukef/dtfs2-common'; +import { HttpStatusCode } from 'axios'; +import { FeeRecordCorrectionRepo } from '../../../../../repositories/fee-record-correction-repo'; +import { + getCompletedFeeRecordCorrections, + GetCompletedFeeRecordCorrectionsRequest, + GetCompletedFeeRecordCorrectionsResponse, + GetCompletedFeeRecordCorrectionsResponseBody, +} from '.'; +import { mapCompletedFeeRecordCorrectionsToResponse } from './helpers'; + +jest.mock('../../../../../repositories/fee-record-correction-repo'); + +console.error = jest.fn(); + +describe('get-completed-fee-record-corrections.controller', () => { + describe('getCompletedFeeRecordCorrections', () => { + const bankId = '123'; + + const aValidRequestParams = () => ({ bankId }); + + const mockCompletedCorrectionsFind = jest.fn(); + + let req: GetCompletedFeeRecordCorrectionsRequest; + let res: MockResponse; + + beforeEach(() => { + FeeRecordCorrectionRepo.findCompletedCorrectionsByBankIdWithFeeRecord = mockCompletedCorrectionsFind; + + req = httpMocks.createRequest({ + params: aValidRequestParams(), + }); + res = httpMocks.createResponse(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it(`should respond with a '${HttpStatusCode.Ok}' and the mapped completed fee record corrections if completed correction entities exist`, async () => { + // Arrange + const completedCorrections = [ + FeeRecordCorrectionEntityMockBuilder.forIsCompleted(true).withId(1).build(), + FeeRecordCorrectionEntityMockBuilder.forIsCompleted(true).withId(2).build(), + ]; + + mockCompletedCorrectionsFind.mockResolvedValue(completedCorrections); + + const expectedMappedCompletedCorrections = mapCompletedFeeRecordCorrectionsToResponse(completedCorrections); + + // Act + await getCompletedFeeRecordCorrections(req, res); + + // Assert + expect(res._getStatusCode()).toEqual(HttpStatusCode.Ok); + + const responseBody = res._getData() as GetCompletedFeeRecordCorrectionsResponseBody; + expect(responseBody).toEqual(expectedMappedCompletedCorrections); + }); + + it(`should respond with a '${HttpStatusCode.Ok}' and an empty array if no completed fee record corrections with the provided bank id are found`, async () => { + // Arrange + mockCompletedCorrectionsFind.mockResolvedValue([]); + + // Act + await getCompletedFeeRecordCorrections(req, res); + + // Assert + expect(res._getStatusCode()).toEqual(HttpStatusCode.Ok); + + const responseBody = res._getData() as GetCompletedFeeRecordCorrectionsResponseBody; + expect(responseBody).toEqual([]); + }); + + it('should call "completed fee record corrections" find method once with the correct parameters', async () => { + // Act + await getCompletedFeeRecordCorrections(req, res); + + // Assert + expect(mockCompletedCorrectionsFind).toHaveBeenCalledTimes(1); + expect(mockCompletedCorrectionsFind).toHaveBeenCalledWith(bankId); + }); + + it("should respond with the specific error status if retrieving the completed fee record corrections throws an 'ApiError'", async () => { + // Arrange + const errorStatus = HttpStatusCode.BadRequest; + mockCompletedCorrectionsFind.mockRejectedValue(new TestApiError({ status: errorStatus })); + + // Act + await getCompletedFeeRecordCorrections(req, res); + + // Assert + expect(res._getStatusCode()).toEqual(errorStatus); + }); + + it("should respond with the specific error message if retrieving the completed fee record corrections throws an 'ApiError'", async () => { + // Arrange + const errorMessage = 'Some error message'; + mockCompletedCorrectionsFind.mockRejectedValue(new TestApiError({ message: errorMessage })); + + // Act + await getCompletedFeeRecordCorrections(req, res); + + // Assert + expect(res._getData()).toEqual(`Failed to get completed fee record corrections: ${errorMessage}`); + }); + + it(`should respond with a '${HttpStatusCode.InternalServerError}' if an unknown error occurs`, async () => { + // Arrange + mockCompletedCorrectionsFind.mockRejectedValue(new Error('Some error')); + + // Act + await getCompletedFeeRecordCorrections(req, res); + + // Assert + expect(res._getStatusCode()).toEqual(HttpStatusCode.InternalServerError); + }); + + it('should respond with a generic error message if an unknown error occurs', async () => { + // Arrange + mockCompletedCorrectionsFind.mockRejectedValue(new Error('Some error')); + + // Act + await getCompletedFeeRecordCorrections(req, res); + + // Assert + expect(res._getData()).toEqual(`Failed to get completed fee record corrections`); + }); + }); +}); diff --git a/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-completed-fee-record-corrections.controller/index.ts b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-completed-fee-record-corrections.controller/index.ts new file mode 100644 index 0000000000..ca65a5d0f4 --- /dev/null +++ b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-completed-fee-record-corrections.controller/index.ts @@ -0,0 +1,60 @@ +import { ApiError, CustomExpressRequest } from '@ukef/dtfs2-common'; +import { Response } from 'express'; +import { HttpStatusCode } from 'axios'; +import { FeeRecordCorrectionRepo } from '../../../../../repositories/fee-record-correction-repo'; +import { mapCompletedFeeRecordCorrectionsToResponse } from './helpers'; + +/** + * Request type for the GET completed fee record corrections by bank id endpoint. + */ +export type GetCompletedFeeRecordCorrectionsRequest = CustomExpressRequest<{ + params: { + bankId: string; + }; +}>; + +/** + * Response body type for the GET completed fee record corrections by bank id endpoint. + */ +export type GetCompletedFeeRecordCorrectionsResponseBody = { + id: number; + dateSent: Date; + exporter: string; + formattedReasons: string; + formattedPreviousValues: string; + formattedCorrectedValues: string; + bankCommentary?: string; +}[]; + +/** + * Response type for the GET completed fee record corrections by bank id endpoint. + */ +export type GetCompletedFeeRecordCorrectionsResponse = Response; + +/** + * Controller for the GET completed fee record corrections by bank id route. + * @param req - The request object. + * @param res - The response object. + * @returns A promise that resolves to the response containing the completed fee record corrections for the bank. + */ +export const getCompletedFeeRecordCorrections = async (req: GetCompletedFeeRecordCorrectionsRequest, res: GetCompletedFeeRecordCorrectionsResponse) => { + const { bankId } = req.params; + + try { + const completedCorrections = await FeeRecordCorrectionRepo.findCompletedCorrectionsByBankIdWithFeeRecord(bankId); + + const mappedCompletedCorrections = mapCompletedFeeRecordCorrectionsToResponse(completedCorrections); + + return res.status(HttpStatusCode.Ok).send(mappedCompletedCorrections); + } catch (error) { + const errorMessage = `Failed to get completed fee record corrections`; + + console.error('%s %o', errorMessage, error); + + if (error instanceof ApiError) { + return res.status(error.status).send(`${errorMessage}: ${error.message}`); + } + + return res.status(HttpStatusCode.InternalServerError).send(errorMessage); + } +}; diff --git a/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-fee-record-correction-review.controller/helpers/format-form-data-value-for-reason.test.ts b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-fee-record-correction-review.controller/helpers/format-form-data-value-for-reason.test.ts index c8313c0bda..8e1b3d3ba0 100644 --- a/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-fee-record-correction-review.controller/helpers/format-form-data-value-for-reason.test.ts +++ b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-fee-record-correction-review.controller/helpers/format-form-data-value-for-reason.test.ts @@ -1,59 +1,10 @@ import { CURRENCY, getFormattedMonetaryValue, RECORD_CORRECTION_REASON } from '@ukef/dtfs2-common'; import { difference } from 'lodash'; -import { getFormattedFormDataValueForCorrectionReason, validateRequiredFormDataField } from './format-form-data-value-for-reason'; +import { getFormattedFormDataValueForCorrectionReason } from './format-form-data-value-for-reason'; console.error = jest.fn(); describe('get-fee-record-correction-review.controller format-form-data-value-for-reason helpers', () => { - describe('validateRequiredFormDataField', () => { - it('should not throw error when value is defined and not null', () => { - // Arrange - const value = 'test'; - const reason = RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT; - - // Act & Assert - expect(() => validateRequiredFormDataField(value, reason)).not.toThrow(); - }); - - it('should not throw error when value is zero', () => { - // Arrange - const value = 0; - const reason = RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT; - - // Act & Assert - expect(() => validateRequiredFormDataField(value, reason)).not.toThrow(); - }); - - it('should throw error when value is undefined', () => { - // Arrange - const value = undefined; - const reason = RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT; - const expectedError = `Required field is missing from transient form data for correction reason: ${reason}`; - - // Act & Assert - expect(() => validateRequiredFormDataField(value, reason)).toThrow(expectedError); - }); - - it('should not throw error when value is false', () => { - // Arrange - const value = false; - const reason = RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT; - - // Act & Assert - expect(() => validateRequiredFormDataField(value, reason)).not.toThrow(); - }); - - it('should throw error when value is null', () => { - // Arrange - const value = null; - const reason = RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT; - const expectedError = `Required field is missing from transient form data for correction reason: ${reason}`; - - // Act & Assert - expect(() => validateRequiredFormDataField(value, reason)).toThrow(expectedError); - }); - }); - describe('getFormattedFormDataValueForCorrectionReason', () => { const reasonsExcludingOther = difference(Object.values(RECORD_CORRECTION_REASON), [RECORD_CORRECTION_REASON.OTHER]); diff --git a/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-fee-record-correction-review.controller/helpers/format-form-data-value-for-reason.ts b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-fee-record-correction-review.controller/helpers/format-form-data-value-for-reason.ts index 455c9915ca..b3ed4fb795 100644 --- a/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-fee-record-correction-review.controller/helpers/format-form-data-value-for-reason.ts +++ b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/get-fee-record-correction-review.controller/helpers/format-form-data-value-for-reason.ts @@ -1,17 +1,5 @@ import { getFormattedMonetaryValue, RECORD_CORRECTION_REASON, RecordCorrectionReason, RecordCorrectionTransientFormData } from '@ukef/dtfs2-common'; - -/** - * Validates that a required form data field has a value. - * Zero (0) is considered a valid value and will not throw an error. - * @param value - The value to validate - * @param reason - The correction reason associated with the field - * @throws Error if the value is undefined or null - */ -export function validateRequiredFormDataField(value: T | undefined | null, reason: RecordCorrectionReason): asserts value is NonNullable { - if (value === undefined || value === null) { - throw new Error(`Required field is missing from transient form data for correction reason: ${reason}`); - } -} +import { validateRequiredCorrectionField } from '../../../../../../helpers'; /** * Gets the formatted value from form data for a specific correction reason. @@ -23,23 +11,23 @@ export function validateRequiredFormDataField(value: T | undefined | null, re export const getFormattedFormDataValueForCorrectionReason = (formData: RecordCorrectionTransientFormData, reason: RecordCorrectionReason): string => { switch (reason) { case RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT: - validateRequiredFormDataField(formData.facilityId, reason); + validateRequiredCorrectionField(formData.facilityId, reason); return formData.facilityId; case RECORD_CORRECTION_REASON.REPORTED_CURRENCY_INCORRECT: - validateRequiredFormDataField(formData.reportedCurrency, reason); + validateRequiredCorrectionField(formData.reportedCurrency, reason); return formData.reportedCurrency; case RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT: - validateRequiredFormDataField(formData.reportedFee, reason); + validateRequiredCorrectionField(formData.reportedFee, reason); return getFormattedMonetaryValue(formData.reportedFee); case RECORD_CORRECTION_REASON.UTILISATION_INCORRECT: - validateRequiredFormDataField(formData.utilisation, reason); + validateRequiredCorrectionField(formData.utilisation, reason); return getFormattedMonetaryValue(formData.utilisation); case RECORD_CORRECTION_REASON.OTHER: - validateRequiredFormDataField(formData.additionalComments, reason); + validateRequiredCorrectionField(formData.additionalComments, reason); return '-'; default: diff --git a/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/post-fee-record-correction.controller/helpers.test.ts b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/post-fee-record-correction.controller/helpers.test.ts index 03555d16f5..b2e468696a 100644 --- a/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/post-fee-record-correction.controller/helpers.test.ts +++ b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/post-fee-record-correction.controller/helpers.test.ts @@ -1,8 +1,6 @@ import { getFormattedReportPeriodWithLongMonth, mapReasonToDisplayValue, RECORD_CORRECTION_REASON, RecordCorrectionReason } from '@ukef/dtfs2-common'; import { formatReasonsAsBulletedListForEmail, generateFeeRecordCorrectionRequestEmailParameters, sendFeeRecordCorrectionRequestEmails } from './helpers'; -import { aBank, aReportPeriod } from '../../../../../../test-helpers'; -import { getBankById } from '../../../../../repositories/banks-repo'; -import { NotFoundError } from '../../../../../errors'; +import { aReportPeriod } from '../../../../../../test-helpers'; import externalApi from '../../../../../external-api/api'; import EMAIL_TEMPLATE_IDS from '../../../../../constants/email-template-ids'; @@ -19,14 +17,7 @@ describe('post-fee-record-correction.controller helpers', () => { const firstPaymentOfficerEmail = 'officer-1@example.com'; const secondPaymentOfficerEmail = 'officer-2@example.com'; const teamName = 'Payment Officer Team'; - - const bank = { - ...aBank(), - paymentOfficerTeam: { - teamName, - emails: [firstPaymentOfficerEmail, secondPaymentOfficerEmail], - }, - }; + const teamEmails = [firstPaymentOfficerEmail, secondPaymentOfficerEmail]; describe('formatReasonsAsBulletedListForEmail', () => { it('should format reasons as a bulleted list when there is a single reasons', () => { @@ -61,15 +52,11 @@ describe('post-fee-record-correction.controller helpers', () => { const reasons: RecordCorrectionReason[] = [RECORD_CORRECTION_REASON.UTILISATION_INCORRECT, RECORD_CORRECTION_REASON.REPORTED_CURRENCY_INCORRECT]; const reportPeriod = aReportPeriod(); const exporter = 'Test Exporter'; - const bankId = '123'; const requestedByUserEmail = 'user@example.com'; - it('should generate email parameters', async () => { - // Arrange - jest.mocked(getBankById).mockResolvedValue(bank); - + it('should generate email parameters', () => { // Act - const result = await generateFeeRecordCorrectionRequestEmailParameters(reasons, reportPeriod, exporter, bankId, requestedByUserEmail); + const result = generateFeeRecordCorrectionRequestEmailParameters(reasons, reportPeriod, exporter, requestedByUserEmail, teamName, teamEmails); // Assert expect(result).toEqual({ @@ -82,17 +69,6 @@ describe('post-fee-record-correction.controller helpers', () => { }, }); }); - - it('should throw a NotFoundError if the bank is not found', async () => { - // Arrange - jest.mocked(getBankById).mockResolvedValue(null); - - // Act & Assert - await expect(generateFeeRecordCorrectionRequestEmailParameters(reasons, reportPeriod, exporter, bankId, requestedByUserEmail)).rejects.toThrow( - new NotFoundError(`Bank not found: ${bankId}`), - ); - expect(console.error).toHaveBeenCalledWith('Bank not found: %s', bankId); - }); }); describe('sendFeeRecordCorrectionRequestEmails', () => { @@ -101,16 +77,13 @@ describe('post-fee-record-correction.controller helpers', () => { const reasons: RecordCorrectionReason[] = [RECORD_CORRECTION_REASON.UTILISATION_INCORRECT, RECORD_CORRECTION_REASON.REPORTED_CURRENCY_INCORRECT]; const reportPeriod = aReportPeriod(); const exporter = 'Potato exporter'; - const bankId = '567'; const requestedByUserEmail = 'tfm-user@email.com'; - jest.mocked(getBankById).mockResolvedValue(bank); - // Act - await sendFeeRecordCorrectionRequestEmails(reasons, reportPeriod, exporter, bankId, requestedByUserEmail); + await sendFeeRecordCorrectionRequestEmails(reasons, reportPeriod, exporter, requestedByUserEmail, teamName, teamEmails); // Assert - const { variables } = await generateFeeRecordCorrectionRequestEmailParameters(reasons, reportPeriod, exporter, bankId, requestedByUserEmail); + const { variables } = generateFeeRecordCorrectionRequestEmailParameters(reasons, reportPeriod, exporter, requestedByUserEmail, teamName, teamEmails); expect(externalApi.sendEmail).toHaveBeenCalledTimes(3); expect(externalApi.sendEmail).toHaveBeenCalledWith(EMAIL_TEMPLATE_IDS.FEE_RECORD_CORRECTION_REQUEST, firstPaymentOfficerEmail, variables); expect(externalApi.sendEmail).toHaveBeenCalledWith(EMAIL_TEMPLATE_IDS.FEE_RECORD_CORRECTION_REQUEST, secondPaymentOfficerEmail, variables); @@ -122,49 +95,23 @@ describe('post-fee-record-correction.controller helpers', () => { const reasons: RecordCorrectionReason[] = [RECORD_CORRECTION_REASON.UTILISATION_INCORRECT, RECORD_CORRECTION_REASON.REPORTED_CURRENCY_INCORRECT]; const reportPeriod = aReportPeriod(); const exporter = 'Potato exporter'; - const bankId = '567'; const requestedByUserEmail = 'tfm-user@email.com'; - jest.mocked(getBankById).mockResolvedValue(bank); - // Act - const response = await sendFeeRecordCorrectionRequestEmails(reasons, reportPeriod, exporter, bankId, requestedByUserEmail); + const response = await sendFeeRecordCorrectionRequestEmails(reasons, reportPeriod, exporter, requestedByUserEmail, teamName, teamEmails); // Assert - const { emails } = await generateFeeRecordCorrectionRequestEmailParameters(reasons, reportPeriod, exporter, bankId, requestedByUserEmail); + const { emails } = generateFeeRecordCorrectionRequestEmailParameters(reasons, reportPeriod, exporter, requestedByUserEmail, teamName, teamEmails); expect(response).toEqual({ emails }); }); - it('should throw NotFoundError error if the bank cannot be found', async () => { - // Arrange - const bankId = '123'; - jest.mocked(getBankById).mockResolvedValue(null); - - const expectedError = new NotFoundError(`Bank not found: ${bankId}`); - - // Act + Assert - await expect(sendFeeRecordCorrectionRequestEmails([], aReportPeriod(), 'test exporter', bankId, 'test@test.com')).rejects.toThrow(expectedError); - expect(console.error).toHaveBeenCalledTimes(1); - expect(console.error).toHaveBeenCalledWith('Bank not found: %s', bankId); - }); - it('should log and rethrow error if sending an email fails', async () => { - // Arrange - const bankId = '123'; - jest.mocked(getBankById).mockResolvedValue({ - ...aBank(), - paymentOfficerTeam: { - teamName, - emails: ['test1@test.com'], - }, - }); - const error = new Error('Failed to send second email'); jest.mocked(externalApi.sendEmail).mockResolvedValueOnce().mockRejectedValueOnce(error); // Act + Assert - await expect(sendFeeRecordCorrectionRequestEmails([], aReportPeriod(), 'test exporter', bankId, 'test2@test.com')).rejects.toThrow(error); + await expect(sendFeeRecordCorrectionRequestEmails([], aReportPeriod(), 'test exporter', 'test2@test.com', teamName, teamEmails)).rejects.toThrow(error); expect(console.error).toHaveBeenCalledWith('Error sending fee record correction request email: %o', error); }); }); diff --git a/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/post-fee-record-correction.controller/helpers.ts b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/post-fee-record-correction.controller/helpers.ts index f21b807c10..adefc9abe3 100644 --- a/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/post-fee-record-correction.controller/helpers.ts +++ b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/post-fee-record-correction.controller/helpers.ts @@ -2,8 +2,6 @@ import { getFormattedReportPeriodWithLongMonth, mapReasonToDisplayValue, RecordC import externalApi from '../../../../../external-api/api'; import EMAIL_TEMPLATE_IDS from '../../../../../constants/email-template-ids'; import { FeeRecordCorrectionRequestEmails, FeeRecordCorrectionRequestEmailAddresses } from '../../../../../types/utilisation-reports'; -import { getBankById } from '../../../../../repositories/banks-repo'; -import { NotFoundError } from '../../../../../errors'; /** * Formats the reasons for record correction into a bulleted list. @@ -27,28 +25,20 @@ export const formatReasonsAsBulletedListForEmail = (reasons: RecordCorrectionRea * @param report * @returns emails and variables for the email */ -export const generateFeeRecordCorrectionRequestEmailParameters = async ( +export const generateFeeRecordCorrectionRequestEmailParameters = ( reasons: RecordCorrectionReason[], reportPeriod: ReportPeriod, exporter: string, - bankId: string, requestedByUserEmail: string, -): Promise => { - const bank = await getBankById(bankId); - - if (!bank) { - console.error('Bank not found: %s', bankId); - throw new NotFoundError(`Bank not found: ${bankId}`); - } - - const { teamName, emails } = bank.paymentOfficerTeam; - + paymentOfficerTeamName: string, + paymentOfficerTeamEmails: string[], +): FeeRecordCorrectionRequestEmails => { const reportPeriodString = getFormattedReportPeriodWithLongMonth(reportPeriod); return { - emails: [...emails, requestedByUserEmail], + emails: [...paymentOfficerTeamEmails, requestedByUserEmail], variables: { - recipient: teamName, + recipient: paymentOfficerTeamName, reportPeriod: reportPeriodString, exporterName: exporter, reasonsList: formatReasonsAsBulletedListForEmail(reasons), @@ -63,9 +53,9 @@ export const generateFeeRecordCorrectionRequestEmailParameters = async ( * @param reasons - The reasons for the record correction request * @param reportPeriod - The report period of the fee's report * @param exporter - The exporter of the fee record - * @param bankId - The id of the bank - * @param requestedByUserEmail - The email of the TFM user who is - * requesting the correction + * @param requestedByUserEmail - The email of the TFM user who is requesting the correction + * @param paymentOfficerTeamName - The name of the bank payment officer team + * @param paymentOfficerTeamEmails - The email addresses of the bank payment officer team * @returns A promise that resolves to an object containing the email addresses * that were notified. */ @@ -73,10 +63,18 @@ export const sendFeeRecordCorrectionRequestEmails = async ( reasons: RecordCorrectionReason[], reportPeriod: ReportPeriod, exporter: string, - bankId: string, requestedByUserEmail: string, + paymentOfficerTeamName: string, + paymentOfficerTeamEmails: string[], ): Promise => { - const { emails, variables } = await generateFeeRecordCorrectionRequestEmailParameters(reasons, reportPeriod, exporter, bankId, requestedByUserEmail); + const { emails, variables } = generateFeeRecordCorrectionRequestEmailParameters( + reasons, + reportPeriod, + exporter, + requestedByUserEmail, + paymentOfficerTeamName, + paymentOfficerTeamEmails, + ); try { await Promise.all(emails.map((email) => externalApi.sendEmail(EMAIL_TEMPLATE_IDS.FEE_RECORD_CORRECTION_REQUEST, email, variables))); diff --git a/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/post-fee-record-correction.controller/index.test.ts b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/post-fee-record-correction.controller/index.test.ts index 923b084149..c01bc9be39 100644 --- a/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/post-fee-record-correction.controller/index.test.ts +++ b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/post-fee-record-correction.controller/index.test.ts @@ -19,12 +19,14 @@ import { FEE_RECORD_EVENT_TYPE } from '../../../../../services/state-machines/fe import { FeeRecordRepo } from '../../../../../repositories/fee-record-repo'; import { sendFeeRecordCorrectionRequestEmails } from './helpers'; import { FeeRecordCorrectionRequestEmailAddresses } from '../../../../../types/utilisation-reports'; +import { getBankPaymentOfficerTeamDetails } from '../../helpers/get-bank-payment-officer-team-details'; jest.mock('../../../../../helpers'); jest.mock('../../../../../services/state-machines/fee-record/fee-record.state-machine'); jest.mock('../../../../../repositories/fee-record-correction-request-transient-form-data-repo'); jest.mock('../../../../../repositories/fee-record-repo'); jest.mock('./helpers'); +jest.mock('../../helpers/get-bank-payment-officer-team-details'); console.error = jest.fn(); @@ -40,6 +42,14 @@ describe('post-fee-record-correction.controller', () => { const mockHandleEvent = jest.fn(); const mockForFeeRecordStateMachineConstructor = jest.fn(); + const mockTeamName = 'Mock team name'; + const mockEmails = ['test1@ukexportfinance.gov.uk', 'test2@ukexportfinance.gov.uk']; + + const getBankPaymentOfficerTeamDetailsResponse = { + teamName: mockTeamName, + emails: mockEmails, + }; + beforeEach(() => { jest.mocked(executeWithSqlTransaction).mockImplementation(async (functionToExecute) => { return await functionToExecute(mockEntityManager); @@ -54,6 +64,7 @@ describe('post-fee-record-correction.controller', () => { deleteByUserIdAndFeeRecordId: mockDeleteTransientFormData, }); jest.spyOn(FeeRecordRepo, 'withTransaction').mockReturnValue({ findOneByIdAndReportIdWithReport: mockFindFeeRecordWithReport }); + jest.mocked(getBankPaymentOfficerTeamDetails).mockResolvedValue(getBankPaymentOfficerTeamDetailsResponse); }); afterEach(() => { @@ -149,8 +160,9 @@ describe('post-fee-record-correction.controller', () => { reasons, mockReport.reportPeriod, mockFeeRecord.exporter, - mockReport.bankId, user.email, + mockTeamName, + mockEmails, ); }); @@ -171,6 +183,8 @@ describe('post-fee-record-correction.controller', () => { platform: REQUEST_PLATFORM_TYPE.TFM, userId, }, + bankTeamName: mockTeamName, + bankTeamEmails: mockEmails, }; // Act diff --git a/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/post-fee-record-correction.controller/index.ts b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/post-fee-record-correction.controller/index.ts index 0bc49bbed9..f698c42ad8 100644 --- a/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/post-fee-record-correction.controller/index.ts +++ b/dtfs-central-api/src/v1/controllers/utilisation-report-service/fee-record-correction/post-fee-record-correction.controller/index.ts @@ -11,6 +11,7 @@ import { PostFeeRecordCorrectionPayload } from '../../../../routes/middleware/pa import { FeeRecordRepo } from '../../../../../repositories/fee-record-repo'; import { sendFeeRecordCorrectionRequestEmails } from './helpers'; import { FeeRecordCorrectionRequestEmailAddresses } from '../../../../../types/utilisation-reports'; +import { getBankPaymentOfficerTeamDetails } from '../../helpers/get-bank-payment-officer-team-details'; export type PostFeeRecordCorrectionRequest = CustomExpressRequest<{ params: { @@ -64,6 +65,8 @@ export const postFeeRecordCorrection = async (req: PostFeeRecordCorrectionReques throw new NotFoundError(`Failed to find a fee record with id ${feeRecordId} and report id ${reportId}`); } + const { teamName, emails } = await getBankPaymentOfficerTeamDetails(feeRecord.report.bankId); + const stateMachine = FeeRecordStateMachine.forFeeRecord(feeRecord); await stateMachine.handleEvent({ @@ -81,6 +84,8 @@ export const postFeeRecordCorrection = async (req: PostFeeRecordCorrectionReques platform: REQUEST_PLATFORM_TYPE.TFM, userId, }, + bankTeamName: teamName, + bankTeamEmails: emails, }, }); @@ -90,8 +95,9 @@ export const postFeeRecordCorrection = async (req: PostFeeRecordCorrectionReques reasons, feeRecord.report.reportPeriod, feeRecord.exporter, - feeRecord.report.bankId, user.email, + teamName, + emails, ); return notifiedEmails; diff --git a/dtfs-central-api/src/v1/controllers/utilisation-report-service/get-utilisation-report-reconciliation-details-by-id.controller/helpers/get-formatted-old-and-correct-values.test.ts b/dtfs-central-api/src/v1/controllers/utilisation-report-service/get-utilisation-report-reconciliation-details-by-id.controller/helpers/get-formatted-old-and-correct-values.test.ts index 35487edbc6..fc92285c7b 100644 --- a/dtfs-central-api/src/v1/controllers/utilisation-report-service/get-utilisation-report-reconciliation-details-by-id.controller/helpers/get-formatted-old-and-correct-values.test.ts +++ b/dtfs-central-api/src/v1/controllers/utilisation-report-service/get-utilisation-report-reconciliation-details-by-id.controller/helpers/get-formatted-old-and-correct-values.test.ts @@ -1,10 +1,7 @@ import { FeeRecordEntityMockBuilder, FeeRecordCorrectionEntityMockBuilder, RECORD_CORRECTION_REASON } from '@ukef/dtfs2-common'; import { getFormattedOldAndCorrectValues } from './get-formatted-old-and-correct-values'; -import { - mapCorrectionReasonsToFormattedCorrectValues, - mapCorrectionReasonsToFormattedPreviousValues, -} from '../../../../../helpers/map-correction-reasons-to-formatted-values'; import { mapCorrectionReasonsToFormattedOldFeeRecordValues } from '../../../../../helpers/map-correction-reasons-to-formatted-old-fee-record-values'; +import { mapCorrectionReasonsAndValuesToFormattedValues } from '../../../../../helpers/map-correction-reasons-and-values-to-formatted-values'; describe('get-formatted-old-and-correct-values', () => { const feeRecordId = 11; @@ -34,15 +31,22 @@ describe('get-formatted-old-and-correct-values', () => { const feeRecord = new FeeRecordEntityMockBuilder().withId(feeRecordId).build(); const correction = FeeRecordCorrectionEntityMockBuilder.forFeeRecordAndIsCompleted(feeRecord, true) .withReasons([RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT, RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT]) - .withDateRequested(new Date()) + .withPreviousValues({ + facilityId: '11111111', + feesPaidToUkefForThePeriod: 123.45, + }) + .withCorrectedValues({ + facilityId: '22222222', + feesPaidToUkefForThePeriod: 987.65, + }) .build(); - it('should return the joined result of mapCorrectionReasonsToFormattedPreviousValues for formattedOldRecords and mapCorrectionReasonsToFormattedCorrectValues for formattedCorrectRecords', () => { + it('should return the joined result of mapCorrectionReasonsAndValuesToFormattedValues for both formattedOldRecords and formattedCorrectRecords', () => { const result = getFormattedOldAndCorrectValues(correction, feeRecord); - const previousRecords = mapCorrectionReasonsToFormattedPreviousValues(correction, correction.reasons); + const previousRecords = mapCorrectionReasonsAndValuesToFormattedValues(correction.reasons, correction.previousValues); - const correctRecords = mapCorrectionReasonsToFormattedCorrectValues(correction, correction.reasons); + const correctRecords = mapCorrectionReasonsAndValuesToFormattedValues(correction.reasons, correction.correctedValues); const expected = { formattedOldRecords: previousRecords.join(', '), diff --git a/dtfs-central-api/src/v1/controllers/utilisation-report-service/get-utilisation-report-reconciliation-details-by-id.controller/helpers/get-formatted-old-and-correct-values.ts b/dtfs-central-api/src/v1/controllers/utilisation-report-service/get-utilisation-report-reconciliation-details-by-id.controller/helpers/get-formatted-old-and-correct-values.ts index f0b53f9132..086df45332 100644 --- a/dtfs-central-api/src/v1/controllers/utilisation-report-service/get-utilisation-report-reconciliation-details-by-id.controller/helpers/get-formatted-old-and-correct-values.ts +++ b/dtfs-central-api/src/v1/controllers/utilisation-report-service/get-utilisation-report-reconciliation-details-by-id.controller/helpers/get-formatted-old-and-correct-values.ts @@ -1,9 +1,6 @@ import { FeeRecordEntity, FeeRecordCorrectionEntity } from '@ukef/dtfs2-common'; -import { - mapCorrectionReasonsToFormattedCorrectValues, - mapCorrectionReasonsToFormattedPreviousValues, -} from '../../../../../helpers/map-correction-reasons-to-formatted-values'; import { mapCorrectionReasonsToFormattedOldFeeRecordValues } from '../../../../../helpers/map-correction-reasons-to-formatted-old-fee-record-values'; +import { mapCorrectionReasonsAndValuesToFormattedValues } from '../../../../../helpers/map-correction-reasons-and-values-to-formatted-values'; /** * generates formattedOldRecords and formattedCorrectRecords from correction and feeRecord @@ -22,9 +19,9 @@ export const getFormattedOldAndCorrectValues = ( * will have been stored against the correction. */ if (correction.isCompleted) { - const previousRecords = mapCorrectionReasonsToFormattedPreviousValues(correction, correction.reasons); + const previousRecords = mapCorrectionReasonsAndValuesToFormattedValues(correction.reasons, correction.previousValues); - const correctRecords = mapCorrectionReasonsToFormattedCorrectValues(correction, correction.reasons); + const correctRecords = mapCorrectionReasonsAndValuesToFormattedValues(correction.reasons, correction.correctedValues); return { formattedOldRecords: previousRecords.join(', '), diff --git a/dtfs-central-api/src/v1/controllers/utilisation-report-service/get-utilisation-report-reconciliation-details-by-id.controller/helpers/get-record-correction-details.test.ts b/dtfs-central-api/src/v1/controllers/utilisation-report-service/get-utilisation-report-reconciliation-details-by-id.controller/helpers/get-record-correction-details.test.ts index 7ea9ac07a2..6acd7fd6d8 100644 --- a/dtfs-central-api/src/v1/controllers/utilisation-report-service/get-utilisation-report-reconciliation-details-by-id.controller/helpers/get-record-correction-details.test.ts +++ b/dtfs-central-api/src/v1/controllers/utilisation-report-service/get-utilisation-report-reconciliation-details-by-id.controller/helpers/get-record-correction-details.test.ts @@ -287,29 +287,42 @@ describe('get-record-correction-details', () => { }); describe('when a record correction is completed', () => { + const previousFeeRecordValues = { + facilityUtilisation: 123.45, + }; + const correctedFeeRecordValues = { + facilityUtilisation: 987.65, + }; + const feeRecord1 = new FeeRecordEntityMockBuilder().withId(feeRecordId).withStatus(FEE_RECORD_STATUS.TO_DO_AMENDED).build(); const feeRecord2 = new FeeRecordEntityMockBuilder().withId(feeRecordId2).withStatus(FEE_RECORD_STATUS.TO_DO_AMENDED).build(); const feeRecord3 = new FeeRecordEntityMockBuilder().withId(feeRecordId3).withStatus(FEE_RECORD_STATUS.TO_DO_AMENDED).withCorrections([]).build(); const correction1 = FeeRecordCorrectionEntityMockBuilder.forFeeRecordAndIsCompleted(feeRecord1, true) - .withReasons([RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT]) + .withReasons([RECORD_CORRECTION_REASON.UTILISATION_INCORRECT]) .withDateRequested(new Date()) + .withPreviousValues(previousFeeRecordValues) + .withCorrectedValues(correctedFeeRecordValues) .build(); const correction2 = FeeRecordCorrectionEntityMockBuilder.forFeeRecordAndIsCompleted(feeRecord1, true) .withReasons([RECORD_CORRECTION_REASON.UTILISATION_INCORRECT]) .withDateRequested(new Date()) + .withPreviousValues(previousFeeRecordValues) + .withCorrectedValues(correctedFeeRecordValues) .build(); const correction3 = FeeRecordCorrectionEntityMockBuilder.forFeeRecordAndIsCompleted(feeRecord2, true) .withReasons([RECORD_CORRECTION_REASON.UTILISATION_INCORRECT]) .withDateRequested(new Date()) + .withPreviousValues(previousFeeRecordValues) + .withCorrectedValues(correctedFeeRecordValues) .build(); feeRecord1.corrections = [correction1, correction2]; feeRecord2.corrections = [correction3]; - it('should populate formattedOldRecords and formattedCorrectRecords from mapCorrectionReasonsToFormattedPreviousValues and mapCorrectionReasonsToFormattedCorrectValues', () => { + it('should populate formattedOldRecords and formattedCorrectRecords from mapCorrectionReasonsAndValuesToFormattedValues', () => { const result = getRecordCorrectionDetails([feeRecord1, feeRecord2, feeRecord3]); const reasonsArray1 = mapReasonsToDisplayValues(feeRecord1.corrections[0].reasons); @@ -369,18 +382,29 @@ describe('get-record-correction-details', () => { }); describe('when provided with corrections which are complete and not complete', () => { + const previousFeeRecordValues = { + facilityUtilisation: 123.45, + }; + const correctedFeeRecordValues = { + facilityUtilisation: 987.65, + }; + const feeRecord1 = new FeeRecordEntityMockBuilder().withId(feeRecordId).withStatus(FEE_RECORD_STATUS.TO_DO_AMENDED).build(); const feeRecord2 = new FeeRecordEntityMockBuilder().withId(feeRecordId2).withStatus(FEE_RECORD_STATUS.PENDING_CORRECTION).build(); const feeRecord3 = new FeeRecordEntityMockBuilder().withId(feeRecordId3).withStatus(FEE_RECORD_STATUS.TO_DO_AMENDED).withCorrections([]).build(); const correction1 = FeeRecordCorrectionEntityMockBuilder.forFeeRecordAndIsCompleted(feeRecord1, true) - .withReasons([RECORD_CORRECTION_REASON.REPORTED_FEE_INCORRECT]) + .withReasons([RECORD_CORRECTION_REASON.UTILISATION_INCORRECT]) .withDateRequested(new Date()) + .withPreviousValues(previousFeeRecordValues) + .withCorrectedValues(correctedFeeRecordValues) .build(); const correction2 = FeeRecordCorrectionEntityMockBuilder.forFeeRecordAndIsCompleted(feeRecord1, true) .withReasons([RECORD_CORRECTION_REASON.UTILISATION_INCORRECT]) .withDateRequested(new Date()) + .withPreviousValues(previousFeeRecordValues) + .withCorrectedValues(correctedFeeRecordValues) .build(); const correction3 = FeeRecordCorrectionEntityMockBuilder.forFeeRecordAndIsCompleted(feeRecord2, false) diff --git a/dtfs-central-api/src/v1/controllers/utilisation-report-service/get-utilisation-report-reconciliation-details-by-id.controller/helpers/get-record-correction-details.ts b/dtfs-central-api/src/v1/controllers/utilisation-report-service/get-utilisation-report-reconciliation-details-by-id.controller/helpers/get-record-correction-details.ts index 2ab391c201..e3d69632ca 100644 --- a/dtfs-central-api/src/v1/controllers/utilisation-report-service/get-utilisation-report-reconciliation-details-by-id.controller/helpers/get-record-correction-details.ts +++ b/dtfs-central-api/src/v1/controllers/utilisation-report-service/get-utilisation-report-reconciliation-details-by-id.controller/helpers/get-record-correction-details.ts @@ -1,5 +1,5 @@ import { format } from 'date-fns'; -import { FeeRecordEntity, FeeRecordCorrectionSummary, mapReasonsToDisplayValues } from '@ukef/dtfs2-common'; +import { FeeRecordEntity, FeeRecordCorrectionSummary, mapReasonsToDisplayValues, DATE_FORMATS } from '@ukef/dtfs2-common'; import { getFormattedOldAndCorrectValues } from './get-formatted-old-and-correct-values'; /** @@ -39,7 +39,7 @@ export const getRecordCorrectionDetails = (feeRecords: FeeRecordEntity[]): FeeRe feeRecordId, exporter, formattedReasons, - formattedDateSent: format(dateRequested, 'dd MMM yyyy'), + formattedDateSent: format(dateRequested, DATE_FORMATS.DD_MMM_YYYY), formattedOldRecords, formattedCorrectRecords, isCompleted, diff --git a/dtfs-central-api/src/v1/controllers/utilisation-report-service/helpers/get-bank-payment-officer-team-details.test.ts b/dtfs-central-api/src/v1/controllers/utilisation-report-service/helpers/get-bank-payment-officer-team-details.test.ts new file mode 100644 index 0000000000..68ce8a81a4 --- /dev/null +++ b/dtfs-central-api/src/v1/controllers/utilisation-report-service/helpers/get-bank-payment-officer-team-details.test.ts @@ -0,0 +1,54 @@ +import { getBankPaymentOfficerTeamDetails } from './get-bank-payment-officer-team-details'; +import { aBank } from '../../../../../test-helpers'; +import { getBankById } from '../../../../repositories/banks-repo'; +import { NotFoundError } from '../../../../errors'; + +jest.mock('../../../../repositories/banks-repo'); + +console.error = jest.fn(); + +describe('get-bank-payment-officer-team-details', () => { + const firstPaymentOfficerEmail = 'officer-1@example.com'; + const secondPaymentOfficerEmail = 'officer-2@example.com'; + const teamName = 'Payment Officer Team'; + + const bankId = '123'; + + const bank = { + ...aBank(), + paymentOfficerTeam: { + teamName, + emails: [firstPaymentOfficerEmail, secondPaymentOfficerEmail], + }, + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('when a bank is found', () => { + it('should return the bank payment officer team details', async () => { + jest.mocked(getBankById).mockResolvedValue(bank); + + const result = await getBankPaymentOfficerTeamDetails(bankId); + + const expected = { + emails: [firstPaymentOfficerEmail, secondPaymentOfficerEmail], + teamName, + }; + + expect(result).toEqual(expected); + }); + }); + + describe('when a bank is not found', () => { + it('should throw a NotFoundError', async () => { + // Arrange + jest.mocked(getBankById).mockResolvedValue(null); + + // Act & Assert + await expect(getBankPaymentOfficerTeamDetails(bankId)).rejects.toThrow(new NotFoundError(`Bank not found: ${bankId}`)); + expect(console.error).toHaveBeenCalledWith('Bank not found: %s', bankId); + }); + }); +}); diff --git a/dtfs-central-api/src/v1/controllers/utilisation-report-service/helpers/get-bank-payment-officer-team-details.ts b/dtfs-central-api/src/v1/controllers/utilisation-report-service/helpers/get-bank-payment-officer-team-details.ts new file mode 100644 index 0000000000..266a795cc9 --- /dev/null +++ b/dtfs-central-api/src/v1/controllers/utilisation-report-service/helpers/get-bank-payment-officer-team-details.ts @@ -0,0 +1,18 @@ +import { getBankById } from '../../../../repositories/banks-repo'; +import { NotFoundError } from '../../../../errors'; + +export const getBankPaymentOfficerTeamDetails = async (bankId: string) => { + const bank = await getBankById(bankId); + + if (!bank) { + console.error('Bank not found: %s', bankId); + throw new NotFoundError(`Bank not found: ${bankId}`); + } + + const { teamName, emails } = bank.paymentOfficerTeam; + + return { + emails, + teamName, + }; +}; diff --git a/dtfs-central-api/src/v1/routes/bank-routes.js b/dtfs-central-api/src/v1/routes/bank-routes.js index de7b348282..a1c3221868 100644 --- a/dtfs-central-api/src/v1/routes/bank-routes.js +++ b/dtfs-central-api/src/v1/routes/bank-routes.js @@ -18,6 +18,9 @@ const { const { getUtilisationReportSummariesByBankIdAndYear, } = require('../controllers/utilisation-report-service/get-utilisation-reports-reconciliation-summary.controller'); +const { + getCompletedFeeRecordCorrections, +} = require('../controllers/utilisation-report-service/fee-record-correction/get-completed-fee-record-corrections.controller'); const validation = require('../validation/route-validators/route-validators'); const handleExpressValidatorResult = require('../validation/route-validators/express-validator-result-handler'); @@ -352,4 +355,39 @@ bankRouter .route('/:bankId/utilisation-reports/pending-corrections') .get(validation.bankIdValidation, handleExpressValidatorResult, getUtilisationReportPendingCorrectionsByBankId); +/** + * @openapi + * /bank/:bankId/utilisation-reports/completed-corrections: + * get: + * summary: Gets completed fee record corrections by bank id + * tags: [Utilisation Report] + * description: Gets completed fee record corrections by bank id + * parameters: + * - in: path + * name: bankId + * schema: + * type: string + * required: true + * description: the id for the bank + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/definitions/CompletedFeeRecordCorrection' + * 400: + * description: Bad request + * 404: + * description: Not found + * 500: + * description: Internal Server Error + */ +bankRouter + .route('/:bankId/utilisation-reports/completed-corrections') + .all(validation.bankIdValidation, handleExpressValidatorResult) + .get(getCompletedFeeRecordCorrections); + module.exports = bankRouter; diff --git a/dtfs-central-api/src/v1/swagger-definitions/portal/portal-amendments.js b/dtfs-central-api/src/v1/swagger-definitions/portal/portal-amendments.js index 2d6613d605..2d131a5393 100644 --- a/dtfs-central-api/src/v1/swagger-definitions/portal/portal-amendments.js +++ b/dtfs-central-api/src/v1/swagger-definitions/portal/portal-amendments.js @@ -32,18 +32,6 @@ * - IN_PROGRESS * - COMPLETED * description: The current status of the amendment. - * createdBy: - * type: object - * properties: - * username: - * type: string - * description: The username of the creator. - * name: - * type: string - * description: The name of the creator. - * email: - * type: string - * description: The email of the creator. * eligibilityCriteria: * type: object * properties: diff --git a/dtfs-central-api/src/v1/swagger-definitions/utilisation-reports/completed-fee-record-correction.js b/dtfs-central-api/src/v1/swagger-definitions/utilisation-reports/completed-fee-record-correction.js new file mode 100644 index 0000000000..d1a436c1e3 --- /dev/null +++ b/dtfs-central-api/src/v1/swagger-definitions/utilisation-reports/completed-fee-record-correction.js @@ -0,0 +1,23 @@ +/** + * @openapi + * definitions: + * CompletedFeeRecordCorrection: + * type: object + * properties: + * id: + * type: number + * dateSent: + * type: string + * format: date + * exporter: + * type: string + * formattedReasons: + * type: string + * formattedPreviousValues: + * type: string + * formattedCorrectedValues: + * type: string + * bankCommentary: + * type: string + * nullable: true + */ diff --git a/e2e-tests/e2e-fixtures/dateConstants.js b/e2e-tests/e2e-fixtures/dateConstants.js index 6a3dd6b7a3..0f20e359a3 100644 --- a/e2e-tests/e2e-fixtures/dateConstants.js +++ b/e2e-tests/e2e-fixtures/dateConstants.js @@ -59,6 +59,8 @@ export const twelveMonthsOneDayAgo = getFormattedValues(sub(todayDate, { months: export const oneYearAgo = getFormattedValues(sub(todayDate, { years: 1 })); export const twoYearsAgo = getFormattedValues(sub(todayDate, { years: 2 })); +export const addDays = (days) => getFormattedValues(add(todayDate, { days })); + // Dates calculated from other values /** * This constant is used for calculating the deadline for issuing a facility with submission date three days ago. diff --git a/e2e-tests/gef/cypress/e2e/facility/review-ukef-approval/review-ukef-approval-with-issued-and-unissued-facilities-MIA-and-resubmit-to-ukef.spec.js b/e2e-tests/gef/cypress/e2e/facility/review-ukef-approval/review-ukef-approval-with-issued-and-unissued-facilities-MIA-and-resubmit-to-ukef.spec.js index 9436ea778b..8934e41254 100644 --- a/e2e-tests/gef/cypress/e2e/facility/review-ukef-approval/review-ukef-approval-with-issued-and-unissued-facilities-MIA-and-resubmit-to-ukef.spec.js +++ b/e2e-tests/gef/cypress/e2e/facility/review-ukef-approval/review-ukef-approval-with-issued-and-unissued-facilities-MIA-and-resubmit-to-ukef.spec.js @@ -1,7 +1,7 @@ import { PORTAL_ACTIVITY_LABEL } from '@ukef/dtfs2-common'; import relative from '../../relativeURL'; import CONSTANTS from '../../../fixtures/constants'; -import { threeDaysAgo, threeMonthsOneDay, twoMonths, threeMonths } from '../../../../../e2e-fixtures/dateConstants'; +import { threeDaysAgo, addDays, twoMonths, threeMonths } from '../../../../../e2e-fixtures/dateConstants'; import { MOCK_APPLICATION_MIA, MOCK_APPLICATION_MIA_DRAFT, UKEF_DECISION, underwriterManagersDecision } from '../../../fixtures/mocks/mock-deals'; import { BANK1_MAKER1, BANK1_CHECKER1, BANK1_CHECKER1_WITH_MOCK_ID } from '../../../../../e2e-fixtures/portal-users.fixture'; import { anIssuedCashFacilityWithCoverDateConfirmed, multipleMockGefFacilities } from '../../../../../e2e-fixtures/mock-gef-facilities'; @@ -205,7 +205,13 @@ context('Review UKEF decision MIA -> confirm coverStartDate and issue unissued f coverStartDate.coverStartDateNo().click(); - cy.completeDateFormFields({ idPrefix: 'ukef-cover-start-date', date: threeMonthsOneDay.date }); + /** + * 3 months in the future in codebase is calculated as 90 days from today + * hence to get date over 3 months in the future to cause an error to be displayed + * add 91 days to today + */ + const days = 91; + cy.completeDateFormFields({ idPrefix: 'ukef-cover-start-date', date: addDays(days).date }); cy.clickContinueButton(); diff --git a/e2e-tests/gef/cypress/e2e/facility/review-ukef-approval/review-ukef-approval-without-issuing-facilities-MIA-and-resubmit-to-ukef.spec.js b/e2e-tests/gef/cypress/e2e/facility/review-ukef-approval/review-ukef-approval-without-issuing-facilities-MIA-and-resubmit-to-ukef.spec.js index 33637c1489..2f1aeca172 100644 --- a/e2e-tests/gef/cypress/e2e/facility/review-ukef-approval/review-ukef-approval-without-issuing-facilities-MIA-and-resubmit-to-ukef.spec.js +++ b/e2e-tests/gef/cypress/e2e/facility/review-ukef-approval/review-ukef-approval-without-issuing-facilities-MIA-and-resubmit-to-ukef.spec.js @@ -1,7 +1,7 @@ import { PORTAL_ACTIVITY_LABEL } from '@ukef/dtfs2-common'; import relative from '../../relativeURL'; import CONSTANTS from '../../../fixtures/constants'; -import { tomorrow, threeDaysAgo, threeMonthsOneDay } from '../../../../../e2e-fixtures/dateConstants'; +import { tomorrow, threeDaysAgo, addDays } from '../../../../../e2e-fixtures/dateConstants'; import { MOCK_APPLICATION_MIA, MOCK_APPLICATION_MIA_DRAFT, UKEF_DECISION, underwriterManagersDecision } from '../../../fixtures/mocks/mock-deals'; import { BANK1_MAKER1, BANK1_CHECKER1, BANK1_CHECKER1_WITH_MOCK_ID } from '../../../../../e2e-fixtures/portal-users.fixture'; import { anIssuedCashFacilityWithCoverDateConfirmed, multipleMockGefFacilities } from '../../../../../e2e-fixtures/mock-gef-facilities'; @@ -190,7 +190,13 @@ context('Review UKEF decision MIA -> confirm coverStartDate without issuing faci coverStartDate.coverStartDateNo().click(); - cy.completeDateFormFields({ idPrefix: 'ukef-cover-start-date', date: threeMonthsOneDay.date }); + /** + * 3 months in the future in codebase is calculated as 90 days from today + * hence to get date over 3 months in the future to cause an error to be displayed + * add 91 days to today + */ + const days = 91; + cy.completeDateFormFields({ idPrefix: 'ukef-cover-start-date', date: addDays(days).date }); cy.clickContinueButton(); diff --git a/e2e-tests/gef/cypress/e2e/pages/amendments/eligibility.js b/e2e-tests/gef/cypress/e2e/pages/amendments/eligibility.js index ad5eea3537..ed35fccfce 100644 --- a/e2e-tests/gef/cypress/e2e/pages/amendments/eligibility.js +++ b/e2e-tests/gef/cypress/e2e/pages/amendments/eligibility.js @@ -1,8 +1,8 @@ const eligibility = { errorSummary: () => cy.get('[data-cy="error-summary"]'), criterionInlineError: (id) => cy.get(`[data-cy="inline-error-criterion-${id}"]`), - allTrueRadioButtons: () => cy.get('*[data-cy^="true-radio-criterion-"]'), - allFalseRadioButtons: () => cy.get('*[data-cy^="true-radio-criterion-"]'), + allTrueRadioButtons: () => cy.get('[data-cy^="true-radio-criterion-"]'), + allFalseRadioButtons: () => cy.get('[data-cy^="false-radio-criterion-"]'), criterionTrueRadioButton: (id) => cy.get(`[data-cy="true-radio-criterion-${id}"]`), criterionFalseRadioButton: (id) => cy.get(`[data-cy="false-radio-criterion-${id}"]`), criterionRadiosText: (id) => cy.get(`[data-cy="radio-wrapper-${id}"]`), diff --git a/e2e-tests/gef/cypress/e2e/pages/amendments/manual-approval-needed.js b/e2e-tests/gef/cypress/e2e/pages/amendments/manual-approval-needed.js new file mode 100644 index 0000000000..31e15d1bcf --- /dev/null +++ b/e2e-tests/gef/cypress/e2e/pages/amendments/manual-approval-needed.js @@ -0,0 +1,8 @@ +const manualApprovalNeeded = { + pageHeading: () => cy.get('[data-cy="page-heading"]'), + backLink: () => cy.get('[data-cy="back-link"]'), + emailLink: () => cy.get('[data-cy="form-email-link"]'), + returnLink: () => cy.get('[data-cy="return-link"]'), +}; + +module.exports = manualApprovalNeeded; diff --git a/e2e-tests/portal/cypress/e2e/pages/utilisation-report-service/record-corrections/correctionLog.js b/e2e-tests/portal/cypress/e2e/pages/utilisation-report-service/record-corrections/correctionLog.js new file mode 100644 index 0000000000..b4ada0843e --- /dev/null +++ b/e2e-tests/portal/cypress/e2e/pages/utilisation-report-service/record-corrections/correctionLog.js @@ -0,0 +1,26 @@ +const page = { + table: () => cy.get('[data-cy="correction-log-table"]'), + tableHeaders: { + dateSent: () => cy.get(`[data-cy="correction-log-table"] [data-cy="correction-log-header--date-sent"]`), + exporter: () => cy.get(`[data-cy="correction-log-table"] [data-cy="correction-log-header--exporter"]`), + reasons: () => cy.get(`[data-cy="correction-log-table"] [data-cy="correction-log-header--formatted-reasons"]`), + correctRecord: () => cy.get(`[data-cy="correction-log-table"] [data-cy="correction-log-header--formatted-corrected-values"]`), + oldRecord: () => cy.get(`[data-cy="correction-log-table"] [data-cy="correction-log-header--formatted-previous-values"]`), + correctionNotes: () => cy.get(`[data-cy="correction-log-table"] [data-cy="correction-log-header--bank-commentary"]`), + }, + rows: () => cy.get(`[data-cy="correction-log-table"] tr`), + row: (index) => ({ + dateSent: () => cy.get(`[data-cy="correction-log-table"] tr:nth-child(${index}) [data-cy="correction-log-row--date-sent"]`), + exporter: () => cy.get(`[data-cy="correction-log-table"] tr:nth-child(${index}) [data-cy="correction-log-row--exporter"]`), + reasons: () => cy.get(`[data-cy="correction-log-table"] tr:nth-child(${index}) [data-cy="correction-log-row--formatted-reasons"]`), + correctRecord: () => cy.get(`[data-cy="correction-log-table"] tr:nth-child(${index}) [data-cy="correction-log-row--formatted-corrected-values"]`), + oldRecord: () => cy.get(`[data-cy="correction-log-table"] tr:nth-child(${index}) [data-cy="correction-log-row--formatted-previous-values"]`), + correctionNotes: () => cy.get(`[data-cy="correction-log-table"] tr:nth-child(${index}) [data-cy="correction-log-row--bank-commentary"]`), + }), + correctionsTextLine1: () => cy.get('[data-cy="corrections-text-line-1"]'), + correctionsTextLine2: () => cy.get('[data-cy="corrections-text-line-2"]'), + noCorrectionsTextLine1: () => cy.get('[data-cy="no-corrections-text-line-1"]'), + noCorrectionsTextLine2: () => cy.get('[data-cy="no-corrections-text-line-2"]'), +}; + +module.exports = page; diff --git a/e2e-tests/ukef/cypress/e2e/journeys/portal/amendments/pages/eligibility.spec.js b/e2e-tests/ukef/cypress/e2e/journeys/portal/amendments/pages/eligibility.spec.js index 80f886ac9d..9f8be9e6ba 100644 --- a/e2e-tests/ukef/cypress/e2e/journeys/portal/amendments/pages/eligibility.spec.js +++ b/e2e-tests/ukef/cypress/e2e/journeys/portal/amendments/pages/eligibility.spec.js @@ -120,4 +120,27 @@ context('Amendments - Eligibility - page tests', () => { cy.url().should('eq', relative(`/gef/application-details/${dealId}/facilities/${facilityId}/amendments/${amendmentId}/cancel`)); }); + + it('should navigate to the manual approval information page if "false" is selected for all criteria', () => { + eligibility.allFalseRadioButtons().click({ multiple: true }); + cy.clickContinueButton(); + + cy.url().should('eq', relative(`/gef/application-details/${dealId}/facilities/${facilityId}/amendments/${amendmentId}/manual-approval-needed`)); + }); + + it('should navigate to the manual approval information page if "false" is selected for all criteria', () => { + eligibility.allTrueRadioButtons().click({ multiple: true }); + eligibility.criterionFalseRadioButton(2).click(); + + cy.clickContinueButton(); + + cy.url().should('eq', relative(`/gef/application-details/${dealId}/facilities/${facilityId}/amendments/${amendmentId}/manual-approval-needed`)); + }); + + it('should navigate to the effective from page if "true" is selected for all criteria', () => { + eligibility.allTrueRadioButtons().click({ multiple: true }); + cy.clickContinueButton(); + + cy.url().should('eq', relative(`/gef/application-details/${dealId}/facilities/${facilityId}/amendments/${amendmentId}/effective-date`)); + }); }); diff --git a/e2e-tests/ukef/cypress/e2e/journeys/portal/amendments/pages/manual-approval-needed.spec.js b/e2e-tests/ukef/cypress/e2e/journeys/portal/amendments/pages/manual-approval-needed.spec.js new file mode 100644 index 0000000000..cb29b81075 --- /dev/null +++ b/e2e-tests/ukef/cypress/e2e/journeys/portal/amendments/pages/manual-approval-needed.spec.js @@ -0,0 +1,93 @@ +import relative from '../../../../relativeURL'; +import MOCK_USERS from '../../../../../../../e2e-fixtures/portal-users.fixture'; +import { MOCK_APPLICATION_AIN_DRAFT } from '../../../../../../../e2e-fixtures/gef/mocks/mock-deals'; +import { anIssuedCashFacility } from '../../../../../../../e2e-fixtures/mock-gef-facilities'; +import { applicationPreview } from '../../../../../../../gef/cypress/e2e/pages'; +import whatDoYouNeedToChange from '../../../../../../../gef/cypress/e2e/pages/amendments/what-do-you-need-to-change'; +import facilityValue from '../../../../../../../gef/cypress/e2e/pages/amendments/facility-value'; +import eligibility from '../../../../../../../gef/cypress/e2e/pages/amendments/eligibility'; +import manualApprovalNeeded from '../../../../../../../gef/cypress/e2e/pages/amendments/manual-approval-needed'; + +const { BANK1_MAKER1 } = MOCK_USERS; + +context('Amendments - Eligibility - page tests', () => { + /** + * @type {string} + */ + let dealId; + + /** + * @type {string} + */ + let facilityId; + /** + * @type {string} + */ + let amendmentId; + + before(() => { + cy.insertOneGefDeal(MOCK_APPLICATION_AIN_DRAFT, BANK1_MAKER1).then((insertedDeal) => { + dealId = insertedDeal._id; + + cy.updateGefDeal(dealId, MOCK_APPLICATION_AIN_DRAFT, BANK1_MAKER1); + + cy.createGefFacilities(dealId, [anIssuedCashFacility({ facilityEndDateEnabled: true })], BANK1_MAKER1).then((createdFacility) => { + facilityId = createdFacility.details._id; + + cy.makerLoginSubmitGefDealForReview(insertedDeal); + cy.checkerLoginSubmitGefDealToUkef(insertedDeal); + + cy.clearSessionCookies(); + cy.login(BANK1_MAKER1); + cy.saveSession(); + cy.visit(relative(`/gef/application-details/${dealId}`)); + + applicationPreview.makeAChangeButton(facilityId).click(); + + cy.url().then((url) => { + const urlSplit = url.split('/'); + + amendmentId = urlSplit[9]; + }); + + whatDoYouNeedToChange.facilityValueCheckbox().click(); + cy.clickContinueButton(); + cy.keyboardInput(facilityValue.facilityValue(), '10000'); + cy.clickContinueButton(); + eligibility.allFalseRadioButtons().click({ multiple: true }); + cy.clickContinueButton(); + }); + }); + }); + + after(() => { + cy.clearCookies(); + cy.clearSessionCookies(); + }); + + beforeEach(() => { + cy.clearSessionCookies(); + cy.login(BANK1_MAKER1); + cy.visit(relative(`/gef/application-details/${dealId}/facilities/${facilityId}/amendments/${amendmentId}/manual-approval-needed`)); + }); + + it('should render key features of the page', () => { + manualApprovalNeeded.pageHeading().contains('This amendment cannot be automatically approved'); + manualApprovalNeeded.returnLink(); + manualApprovalNeeded.emailLink(); + manualApprovalNeeded.backLink(); + }); + + it('should navigate to the deals overview page when the return link is clicked', () => { + manualApprovalNeeded.returnLink().click(); + + cy.url().should('eq', relative(`/dashboard/deals/0`)); + }); + + it('should navigate to the eligibility page with pre-filled data when "back" is clicked', () => { + manualApprovalNeeded.backLink().click(); + + cy.url().should('eq', relative(`/gef/application-details/${dealId}/facilities/${facilityId}/amendments/${amendmentId}/eligibility`)); + eligibility.allFalseRadioButtons().should('be.checked'); + }); +}); diff --git a/gef-ui/api-tests/amendments/manual-approval-needed.get.api-test.ts b/gef-ui/api-tests/amendments/manual-approval-needed.get.api-test.ts new file mode 100644 index 0000000000..cb2bf596ee --- /dev/null +++ b/gef-ui/api-tests/amendments/manual-approval-needed.get.api-test.ts @@ -0,0 +1,236 @@ +import { Headers } from 'node-mocks-http'; +import { NextFunction, Request, Response } from 'express'; +import { DEAL_STATUS, DEAL_SUBMISSION_TYPE, ROLES } from '@ukef/dtfs2-common'; +import { HttpStatusCode } from 'axios'; +import { withRoleValidationApiTests } from '../common-tests/role-validation-api-tests'; +import app from '../../server/createApp'; +import { createApi } from '../create-api'; +import api from '../../server/services/api'; +import * as storage from '../test-helpers/storage/storage'; +import { PortalFacilityAmendmentWithUkefIdMockBuilder } from '../../test-helpers/mock-amendment'; +import { PORTAL_AMENDMENT_PAGES } from '../../server/constants/amendments'; +import { MOCK_BASIC_DEAL } from '../../server/utils/mocks/mock-applications'; +import { MOCK_ISSUED_FACILITY, MOCK_UNISSUED_FACILITY } from '../../server/utils/mocks/mock-facilities'; +import { getAmendmentsUrl } from '../../server/controllers/amendments/helpers/navigation.helper.ts'; + +const originalEnv = { ...process.env }; + +const { get } = createApi(app); + +jest.mock('csurf', () => () => (_req: Request, _res: Response, next: NextFunction) => next()); +jest.mock('../../server/middleware/csrf', () => ({ + csrfToken: () => (_req: Request, _res: Response, next: NextFunction) => next(), +})); + +const mockGetFacility = jest.fn(); +const mockGetApplication = jest.fn(); +const mockGetAmendment = jest.fn(); + +const dealId = '123'; +const facilityId = '111'; +const amendmentId = '111'; + +const mockDeal = { ...MOCK_BASIC_DEAL, submissionType: DEAL_SUBMISSION_TYPE.AIN, status: DEAL_STATUS.UKEF_ACKNOWLEDGED }; + +const url = `/application-details/${dealId}/facilities/${facilityId}/amendments/${amendmentId}/${PORTAL_AMENDMENT_PAGES.MANUAL_APPROVAL_NEEDED}`; + +describe(`GET ${url}`, () => { + let sessionCookie: string; + + beforeEach(async () => { + await storage.flush(); + jest.resetAllMocks(); + + ({ sessionCookie } = await storage.saveUserSession([ROLES.MAKER])); + jest.spyOn(api, 'getFacility').mockImplementation(mockGetFacility); + jest.spyOn(api, 'getApplication').mockImplementation(mockGetApplication); + jest.spyOn(api, 'getAmendment').mockImplementation(mockGetAmendment); + + mockGetFacility.mockResolvedValue(MOCK_ISSUED_FACILITY); + mockGetApplication.mockResolvedValue(mockDeal); + mockGetAmendment.mockResolvedValue( + new PortalFacilityAmendmentWithUkefIdMockBuilder() + .withDealId(dealId) + .withFacilityId(facilityId) + .withAmendmentId(amendmentId) + .withCriteria([ + { id: 1, text: 'Criterion 1', answer: false }, + { id: 2, text: 'Criterion 2', answer: true }, + ]) + .build(), + ); + }); + + afterAll(async () => { + jest.resetAllMocks(); + await storage.flush(); + process.env = originalEnv; + }); + + describe('when FF_PORTAL_FACILITY_AMENDMENTS_ENABLED is disabled', () => { + beforeEach(() => { + process.env.FF_PORTAL_FACILITY_AMENDMENTS_ENABLED = 'false'; + }); + + it('should redirect to /not-found', async () => { + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Found); + expect(response.headers.location).toEqual('/not-found'); + }); + }); + + describe('when FF_PORTAL_FACILITY_AMENDMENTS_ENABLED feature flag is not set', () => { + beforeEach(() => { + delete process.env.FF_PORTAL_FACILITY_AMENDMENTS_ENABLED; + }); + + it('should redirect to /not-found', async () => { + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Found); + expect(response.headers.location).toEqual('/not-found'); + }); + }); + + describe('when FF_PORTAL_FACILITY_AMENDMENTS_ENABLED is enabled', () => { + beforeEach(() => { + process.env.FF_PORTAL_FACILITY_AMENDMENTS_ENABLED = 'true'; + }); + + withRoleValidationApiTests({ + makeRequestWithHeaders: (headers: Headers) => get(url, {}, headers), + whitelistedRoles: [ROLES.MAKER], + successCode: HttpStatusCode.Ok, + }); + + it('should render manual approval needed page', async () => { + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Ok); + expect(response.text).toContain('This amendment cannot be automatically approved'); + }); + + it('should redirect to /not-found when facility not found', async () => { + // Arrange + mockGetFacility.mockResolvedValue({ details: undefined }); + + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Found); + expect(response.headers.location).toEqual('/not-found'); + }); + + it('should redirect to /not-found when deal not found', async () => { + // Arrange + mockGetApplication.mockResolvedValue(undefined); + + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Found); + expect(response.headers.location).toEqual('/not-found'); + }); + + it('should redirect to /not-found when amendment not found', async () => { + // Arrange + mockGetAmendment.mockResolvedValue(undefined); + + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Found); + expect(response.headers.location).toEqual('/not-found'); + }); + + it('should redirect to deal summary page when facility cannot be amended', async () => { + // Arrange + mockGetApplication.mockResolvedValue(MOCK_UNISSUED_FACILITY); + + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Found); + expect(response.headers.location).toEqual(`/gef/application-details/${dealId}`); + }); + + it('should redirect to the eligibility page if there are no false responses to the criteria', async () => { + // Arrange + mockGetAmendment.mockResolvedValueOnce( + new PortalFacilityAmendmentWithUkefIdMockBuilder() + .withDealId(dealId) + .withFacilityId(facilityId) + .withAmendmentId(amendmentId) + .withCriteria([ + { id: 1, text: 'Criterion 1', answer: true }, + { id: 2, text: 'Criterion 2', answer: true }, + ]) + .build(), + ); + + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Found); + expect(response.headers.location).toEqual(getAmendmentsUrl({ dealId, facilityId, amendmentId, page: PORTAL_AMENDMENT_PAGES.ELIGIBILITY })); + }); + + it('should render `problem with service` if getApplication throws an error', async () => { + // Arrange + mockGetApplication.mockRejectedValue(new Error('test error')); + + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Ok); + expect(response.text).toContain('Problem with the service'); + }); + + it('should render `problem with service` if getFacility throws an error', async () => { + // Arrange + mockGetFacility.mockRejectedValue(new Error('test error')); + + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Ok); + expect(response.text).toContain('Problem with the service'); + }); + + it('should render `problem with service` if getAmendment throws an error', async () => { + // Arrange + mockGetAmendment.mockRejectedValue(new Error('test error')); + + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Ok); + expect(response.text).toContain('Problem with the service'); + }); + }); +}); + +function getWithSessionCookie(sessionCookie: string) { + return get( + url, + {}, + { + Cookie: [`dtfs-session=${encodeURIComponent(sessionCookie)}`], + }, + ); +} diff --git a/gef-ui/component-tests/partials/amendments/manual-approval-needed.component-test.ts b/gef-ui/component-tests/partials/amendments/manual-approval-needed.component-test.ts new file mode 100644 index 0000000000..3357eb913e --- /dev/null +++ b/gef-ui/component-tests/partials/amendments/manual-approval-needed.component-test.ts @@ -0,0 +1,52 @@ +import { FACILITY_TYPE } from '@ukef/dtfs2-common'; +import pageRenderer from '../../pageRenderer'; +import { ManualApprovalNeededViewModel } from '../../../server/types/view-models/amendments/ManualApprovalNeededViewModel.ts'; + +const page = 'partials/amendments/manual-approval-needed.njk'; +const render = pageRenderer(page); + +describe(page, () => { + const exporterName = 'exporterName'; + const previousPage = 'previousPage'; + const amendmentFormEmail = 'test@email.com'; + const returnLink = '/dashboard/deals'; + const facilityType = FACILITY_TYPE.CASH; + + const params: ManualApprovalNeededViewModel = { + exporterName, + previousPage, + facilityType, + amendmentFormEmail, + returnLink, + }; + + it('should render the page heading', () => { + const wrapper = render(params); + + wrapper.expectText('[data-cy="page-heading"]').toContain('This amendment cannot be automatically approved'); + }); + + it(`should render the 'Back' link`, () => { + const wrapper = render(params); + + wrapper.expectLink('[data-cy="back-link"]').toLinkTo(previousPage, 'Back'); + }); + + it('should render the exporter name and facility type in the heading caption', () => { + const wrapper = render(params); + + wrapper.expectText('[data-cy="heading-caption"]').toRead(`${exporterName}, ${facilityType} facility`); + }); + + it(`should render the return link`, () => { + const wrapper = render(params); + + wrapper.expectLink('[data-cy="return-link"]').toLinkTo(returnLink, 'Return to all applications and notices'); + }); + + it(`should render the form email address`, () => { + const wrapper = render(params); + + wrapper.expectLink('[data-cy="form-email-link"]').toLinkTo(`mailTo:${amendmentFormEmail}`, amendmentFormEmail); + }); +}); diff --git a/gef-ui/server/constants/amendments.ts b/gef-ui/server/constants/amendments.ts index 79defa2071..b25e9e2488 100644 --- a/gef-ui/server/constants/amendments.ts +++ b/gef-ui/server/constants/amendments.ts @@ -8,5 +8,6 @@ export const PORTAL_AMENDMENT_PAGES = { ELIGIBILITY: 'eligibility', EFFECTIVE_DATE: 'effective-date', CHECK_YOUR_ANSWERS: 'check-your-answers', + MANUAL_APPROVAL_NEEDED: 'manual-approval-needed', CANCEL: 'cancel', } as const; diff --git a/gef-ui/server/controllers/amendments/helpers/navigation.helper.getNextPage.test.ts b/gef-ui/server/controllers/amendments/helpers/navigation.helper.getNextPage.test.ts index b7bf02e617..85cd39d9f6 100644 --- a/gef-ui/server/controllers/amendments/helpers/navigation.helper.getNextPage.test.ts +++ b/gef-ui/server/controllers/amendments/helpers/navigation.helper.getNextPage.test.ts @@ -14,6 +14,7 @@ const { ELIGIBILITY, EFFECTIVE_DATE, CHECK_YOUR_ANSWERS, + MANUAL_APPROVAL_NEEDED, } = PORTAL_AMENDMENT_PAGES; describe('getNextPage', () => { @@ -161,10 +162,58 @@ describe('getNextPage', () => { currentPage: ELIGIBILITY, successTestCases: [ { - description: '', + description: 'when all eligibility criteria answers are "true"', expectedNextPage: EFFECTIVE_DATE, + amendment: new PortalFacilityAmendmentWithUkefIdMockBuilder() + .withCriteria([ + { id: 1, text: 'Criterion 1', answer: true }, + { id: 2, text: 'Criterion 2', answer: true }, + { id: 3, text: 'Criterion 3', answer: true }, + ]) + .build(), + }, + { + description: 'when all eligibility criteria answers are "false"', + expectedNextPage: MANUAL_APPROVAL_NEEDED, + amendment: new PortalFacilityAmendmentWithUkefIdMockBuilder() + .withCriteria([ + { id: 1, text: 'Criterion 1', answer: false }, + { id: 2, text: 'Criterion 2', answer: false }, + { id: 3, text: 'Criterion 3', answer: false }, + ]) + .build(), + }, + { + description: 'when some eligibility criteria answers are "false"', + expectedNextPage: MANUAL_APPROVAL_NEEDED, + amendment: new PortalFacilityAmendmentWithUkefIdMockBuilder() + .withCriteria([ + { id: 1, text: 'Criterion 1', answer: true }, + { id: 2, text: 'Criterion 2', answer: false }, + { id: 3, text: 'Criterion 3', answer: true }, + ]) + .build(), + }, + ], + }); + + withNextPageTests({ + currentPage: MANUAL_APPROVAL_NEEDED, + errorTestCases: [ + { + description: 'when all eligibility criteria answers are all valid', amendment: new PortalFacilityAmendmentWithUkefIdMockBuilder().build(), }, + { + description: 'when any eligibility criteria answers are all "false"', + amendment: new PortalFacilityAmendmentWithUkefIdMockBuilder() + .withCriteria([ + { id: 1, text: 'Criterion 1', answer: false }, + { id: 2, text: 'Criterion 2', answer: false }, + { id: 3, text: 'Criterion 3', answer: false }, + ]) + .build(), + }, ], }); diff --git a/gef-ui/server/controllers/amendments/helpers/navigation.helper.getPreviousPage.test.ts b/gef-ui/server/controllers/amendments/helpers/navigation.helper.getPreviousPage.test.ts index d34e5555d2..53f3e0aedc 100644 --- a/gef-ui/server/controllers/amendments/helpers/navigation.helper.getPreviousPage.test.ts +++ b/gef-ui/server/controllers/amendments/helpers/navigation.helper.getPreviousPage.test.ts @@ -14,6 +14,7 @@ const { ELIGIBILITY, EFFECTIVE_DATE, CHECK_YOUR_ANSWERS, + MANUAL_APPROVAL_NEEDED, } = PORTAL_AMENDMENT_PAGES; describe('getPreviousPage', () => { @@ -172,6 +173,35 @@ describe('getPreviousPage', () => { ], }); + withPreviousPageTests({ + currentPage: MANUAL_APPROVAL_NEEDED, + successTestCases: [ + { + description: 'when eligibility criteria have at least one "false" answer', + expectedPreviousPage: ELIGIBILITY, + amendment: new PortalFacilityAmendmentWithUkefIdMockBuilder() + .withCriteria([ + { id: 1, text: 'Criterion 1', answer: true }, + { id: 2, text: 'Criterion 2', answer: false }, + { id: 3, text: 'Criterion 3', answer: true }, + ]) + .build(), + }, + ], + errorTestCases: [ + { + description: 'when eligibility criteria all have "true" answers', + amendment: new PortalFacilityAmendmentWithUkefIdMockBuilder() + .withCriteria([ + { id: 1, text: 'Criterion 1', answer: true }, + { id: 2, text: 'Criterion 2', answer: true }, + { id: 3, text: 'Criterion 3', answer: true }, + ]) + .build(), + }, + ], + }); + withPreviousPageTests({ currentPage: EFFECTIVE_DATE, successTestCases: [ diff --git a/gef-ui/server/controllers/amendments/helpers/navigation.helper.ts b/gef-ui/server/controllers/amendments/helpers/navigation.helper.ts index 4f7a8dcead..041b229de1 100644 --- a/gef-ui/server/controllers/amendments/helpers/navigation.helper.ts +++ b/gef-ui/server/controllers/amendments/helpers/navigation.helper.ts @@ -12,10 +12,11 @@ const { ELIGIBILITY, EFFECTIVE_DATE, CHECK_YOUR_ANSWERS, + MANUAL_APPROVAL_NEEDED, } = PORTAL_AMENDMENT_PAGES; const startPages = [WHAT_DO_YOU_NEED_TO_CHANGE] as const; -const endPages = [ELIGIBILITY, EFFECTIVE_DATE, CHECK_YOUR_ANSWERS] as const; +const endPages = [EFFECTIVE_DATE, CHECK_YOUR_ANSWERS] as const; const coverEndDatePages = [COVER_END_DATE, DO_YOU_HAVE_A_FACILITY_END_DATE] as const; /** @@ -62,7 +63,13 @@ const getJourneyForAmendment = (amendment: PortalFacilityAmendmentWithUkefId): P pages.push(FACILITY_VALUE); } - pages.push(...endPages); + pages.push(ELIGIBILITY); + + if (amendment.eligibilityCriteria.criteria.some((criterion) => criterion.answer === false)) { + pages.push(MANUAL_APPROVAL_NEEDED); + } else { + pages.push(...endPages); + } return pages; }; diff --git a/gef-ui/server/controllers/amendments/manual-approval-needed/get-manual-approval-needed.test.ts b/gef-ui/server/controllers/amendments/manual-approval-needed/get-manual-approval-needed.test.ts new file mode 100644 index 0000000000..99b346742b --- /dev/null +++ b/gef-ui/server/controllers/amendments/manual-approval-needed/get-manual-approval-needed.test.ts @@ -0,0 +1,254 @@ +/* eslint-disable import/first */ +const getApplicationMock = jest.fn(); +const getFacilityMock = jest.fn(); +const getAmendmentMock = jest.fn(); + +import * as dtfsCommon from '@ukef/dtfs2-common'; +import { + aPortalSessionUser, + CURRENCY, + DEAL_STATUS, + DEAL_SUBMISSION_TYPE, + Facility, + PORTAL_LOGIN_STATUS, + ROLES, + PortalFacilityAmendmentWithUkefId, + FACILITY_TYPE, +} from '@ukef/dtfs2-common'; +import { HttpStatusCode } from 'axios'; +import { createMocks } from 'node-mocks-http'; +import { STB_PIM_EMAIL } from '../../../constants/emails.ts'; + +import { getManualApprovalNeeded, GetManualApprovalNeededRequest } from './get-manual-approval-needed.ts'; +import { Deal } from '../../../types/deal'; +import { PortalFacilityAmendmentWithUkefIdMockBuilder } from '../../../../test-helpers/mock-amendment'; +import { PORTAL_AMENDMENT_PAGES } from '../../../constants/amendments'; +import { getAmendmentsUrl, getPreviousPage } from '../helpers/navigation.helper'; +import { ManualApprovalNeededViewModel } from '../../../types/view-models/amendments/ManualApprovalNeededViewModel.ts'; + +jest.mock('../../../services/api', () => ({ + getApplication: getApplicationMock, + getFacility: getFacilityMock, + getAmendment: getAmendmentMock, +})); + +const dealId = 'dealId'; +const facilityId = 'facilityId'; +const amendmentId = 'amendmentId'; + +const companyName = 'company name ltd'; +const amendmentFormEmail = STB_PIM_EMAIL; +const returnLink = '/dashboard/deals'; + +const getHttpMocks = () => + createMocks({ + params: { dealId, facilityId, amendmentId }, + session: { + user: { ...aPortalSessionUser(), roles: [ROLES.MAKER] }, + userToken: 'testToken', + loginStatus: PORTAL_LOGIN_STATUS.VALID_2FA, + }, + }); + +const mockDeal = { exporter: { companyName }, submissionType: DEAL_SUBMISSION_TYPE.AIN, status: DEAL_STATUS.UKEF_ACKNOWLEDGED } as Deal; + +const mockFacility = { + currency: { + id: CURRENCY.GBP, + }, + type: FACILITY_TYPE.CASH, + hasBeenIssued: true, +} as Facility; + +describe('getManualApprovalNeeded', () => { + let amendment: PortalFacilityAmendmentWithUkefId; + + beforeEach(() => { + jest.resetAllMocks(); + jest.spyOn(dtfsCommon, 'isPortalFacilityAmendmentsFeatureFlagEnabled').mockReturnValue(true); + + amendment = new PortalFacilityAmendmentWithUkefIdMockBuilder() + .withDealId(dealId) + .withFacilityId(facilityId) + .withAmendmentId(amendmentId) + .withCriteria([ + { id: 1, text: 'Criterion 1', answer: true }, + { id: 2, text: 'Criterion 2', answer: false }, + ]) + .build(); + + getApplicationMock.mockResolvedValue(mockDeal); + getFacilityMock.mockResolvedValue({ details: mockFacility }); + getAmendmentMock.mockResolvedValue(amendment); + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + it('should call getApplication with the correct dealId and userToken', async () => { + // Arrange + const { req, res } = getHttpMocks(); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(getApplicationMock).toHaveBeenCalledTimes(1); + expect(getApplicationMock).toHaveBeenCalledWith({ dealId, userToken: req.session.userToken }); + }); + + it('should call getFacility with the correct facilityId and userToken', async () => { + // Arrange + const { req, res } = getHttpMocks(); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(getFacilityMock).toHaveBeenCalledTimes(1); + expect(getFacilityMock).toHaveBeenCalledWith({ facilityId, userToken: req.session.userToken }); + }); + + it('should call getAmendment with the correct facilityId, amendmentId and userToken', async () => { + // Arrange + const { req, res } = getHttpMocks(); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(getAmendmentMock).toHaveBeenCalledTimes(1); + expect(getAmendmentMock).toHaveBeenCalledWith({ facilityId, amendmentId, userToken: req.session.userToken }); + }); + + it('should render the manual approval needed template', async () => { + // Arrange + const { req, res } = getHttpMocks(); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + const expectedRenderData: ManualApprovalNeededViewModel = { + exporterName: companyName, + facilityType: mockFacility.type, + previousPage: getPreviousPage(PORTAL_AMENDMENT_PAGES.MANUAL_APPROVAL_NEEDED, amendment), + amendmentFormEmail, + returnLink, + }; + + expect(res._getStatusCode()).toEqual(HttpStatusCode.Ok); + expect(res._getRenderView()).toEqual('partials/amendments/manual-approval-needed.njk'); + expect(res._getRenderData()).toEqual(expectedRenderData); + }); + + it('should redirect if the facility is not found', async () => { + // Arrange + const { req, res } = getHttpMocks(); + getFacilityMock.mockResolvedValue({ details: undefined }); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(res._getStatusCode()).toEqual(HttpStatusCode.Found); + expect(res._getRedirectUrl()).toEqual(`/not-found`); + }); + + it('should redirect if the deal is not found', async () => { + // Arrange + const { req, res } = getHttpMocks(); + getApplicationMock.mockResolvedValue(undefined); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(res._getStatusCode()).toEqual(HttpStatusCode.Found); + expect(res._getRedirectUrl()).toEqual(`/not-found`); + }); + + it('should redirect if the amendment is not found', async () => { + // Arrange + const { req, res } = getHttpMocks(); + getAmendmentMock.mockResolvedValue(undefined); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(res._getStatusCode()).toEqual(HttpStatusCode.Found); + expect(res._getRedirectUrl()).toEqual(`/not-found`); + }); + + it('should redirect if the facility cannot be amended', async () => { + // Arrange + const { req, res } = getHttpMocks(); + getFacilityMock.mockResolvedValue({ details: { ...mockFacility, hasBeenIssued: false } }); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(res._getStatusCode()).toEqual(HttpStatusCode.Found); + expect(res._getRedirectUrl()).toEqual(`/gef/application-details/${dealId}`); + }); + + it('should redirect to the eligibility page if the responses to the criteria are all true', async () => { + // Arrange + const { req, res } = getHttpMocks(); + getAmendmentMock.mockResolvedValue({ + eligibilityCriteria: { + version: 1, + criteria: [ + { id: 1, text: 'Criterion 1', answer: true }, + { id: 2, text: 'Criterion 2', answer: true }, + ], + }, + }); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(res._getStatusCode()).toEqual(HttpStatusCode.Found); + expect(res._getRedirectUrl()).toEqual(getAmendmentsUrl({ dealId, facilityId, amendmentId, page: PORTAL_AMENDMENT_PAGES.ELIGIBILITY })); + }); + + it('should render `problem with service` if getApplication throws an error', async () => { + // Arrange + getApplicationMock.mockRejectedValue(new Error('test error')); + const { req, res } = getHttpMocks(); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(res._getRenderView()).toEqual('partials/problem-with-service.njk'); + }); + + it('should render `problem with service` if getFacility throws an error', async () => { + // Arrange + getFacilityMock.mockRejectedValue(new Error('test error')); + const { req, res } = getHttpMocks(); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(res._getRenderView()).toEqual('partials/problem-with-service.njk'); + }); + + it('should render `problem with service` if getAmendment throws an error', async () => { + // Arrange + getAmendmentMock.mockRejectedValue(new Error('test error')); + const { req, res } = getHttpMocks(); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(res._getRenderView()).toEqual('partials/problem-with-service.njk'); + }); +}); diff --git a/gef-ui/server/controllers/amendments/manual-approval-needed/get-manual-approval-needed.ts b/gef-ui/server/controllers/amendments/manual-approval-needed/get-manual-approval-needed.ts new file mode 100644 index 0000000000..5fd476ff41 --- /dev/null +++ b/gef-ui/server/controllers/amendments/manual-approval-needed/get-manual-approval-needed.ts @@ -0,0 +1,64 @@ +import { CustomExpressRequest } from '@ukef/dtfs2-common'; +import { Response } from 'express'; +import * as api from '../../../services/api'; +import { asLoggedInUserSession } from '../../../utils/express-session'; +import { userCanAmendFacility } from '../../../utils/facility-amendments.helper'; +import { getAmendmentsUrl, getPreviousPage } from '../helpers/navigation.helper.ts'; +import { PORTAL_AMENDMENT_PAGES } from '../../../constants/amendments.ts'; +import { ManualApprovalNeededViewModel } from '../../../types/view-models/amendments/ManualApprovalNeededViewModel.ts'; +import { STB_PIM_EMAIL } from '../../../constants/emails.ts'; + +export type GetManualApprovalNeededRequest = CustomExpressRequest<{ + params: { dealId: string; facilityId: string; amendmentId: string }; +}>; + +/** + * controller to get the manual approval needed amendment page + * + * @param req - The express request + * @param res - The express response + */ +export const getManualApprovalNeeded = async (req: GetManualApprovalNeededRequest, res: Response) => { + try { + const { dealId, facilityId, amendmentId } = req.params; + const { userToken, user } = asLoggedInUserSession(req.session); + + const deal = await api.getApplication({ dealId, userToken }); + const { details: facility } = await api.getFacility({ facilityId, userToken }); + + if (!deal || !facility) { + console.error('Deal %s or Facility %s was not found', dealId, facilityId); + return res.redirect('/not-found'); + } + + if (!userCanAmendFacility(facility, deal, user.roles)) { + console.error('User cannot amend facility %s on deal %s', facilityId, dealId); + return res.redirect(`/gef/application-details/${dealId}`); + } + + const amendment = await api.getAmendment({ facilityId, amendmentId, userToken }); + + if (!amendment) { + console.error('Amendment %s not found on facility %s', amendmentId, facilityId); + return res.redirect('/not-found'); + } + + if (!amendment.eligibilityCriteria.criteria.some((criterion) => criterion.answer === false)) { + console.error('There are no false answers to the eligibility criteria, manual approval not necessarily required', amendmentId, facilityId); + return res.redirect(getAmendmentsUrl({ dealId, facilityId, amendmentId, page: PORTAL_AMENDMENT_PAGES.ELIGIBILITY })); + } + + const viewModel: ManualApprovalNeededViewModel = { + exporterName: deal.exporter.companyName, + facilityType: facility.type, + previousPage: getPreviousPage(PORTAL_AMENDMENT_PAGES.MANUAL_APPROVAL_NEEDED, amendment), + amendmentFormEmail: STB_PIM_EMAIL, + returnLink: '/dashboard/deals', + }; + + return res.render('partials/amendments/manual-approval-needed.njk', viewModel); + } catch (error) { + console.error('Error getting manual approval needed amendment page %o', error); + return res.render('partials/problem-with-service.njk'); + } +}; diff --git a/gef-ui/server/routes/facilities/amendments/index.ts b/gef-ui/server/routes/facilities/amendments/index.ts index b80a5292d8..e61623e287 100644 --- a/gef-ui/server/routes/facilities/amendments/index.ts +++ b/gef-ui/server/routes/facilities/amendments/index.ts @@ -22,6 +22,7 @@ import { getEligibility } from '../../../controllers/amendments/eligibility-crit import { postEligibility } from '../../../controllers/amendments/eligibility-criteria/post-eligibility.ts'; import { getEffectiveDate } from '../../../controllers/amendments/effective-date/get-effective-date.ts'; import { postEffectiveDate } from '../../../controllers/amendments/effective-date/post-effective-date.ts'; +import { getManualApprovalNeeded } from '../../../controllers/amendments/manual-approval-needed/get-manual-approval-needed.ts'; const { WHAT_DO_YOU_NEED_TO_CHANGE, @@ -31,6 +32,7 @@ const { FACILITY_END_DATE, BANK_REVIEW_DATE, ELIGIBILITY, + MANUAL_APPROVAL_NEEDED, EFFECTIVE_DATE, CANCEL, } = PORTAL_AMENDMENT_PAGES; @@ -89,6 +91,11 @@ router .get(getEligibility) .post(postEligibility); +router + .route(`/application-details/:dealId/facilities/:facilityId/amendments/:amendmentId/${MANUAL_APPROVAL_NEEDED}`) + .all([validatePortalFacilityAmendmentsEnabled, validateToken, validateBank, validateRole({ role: [MAKER] })]) + .get(getManualApprovalNeeded); + router .route(`/application-details/:dealId/facilities/:facilityId/amendments/:amendmentId/${EFFECTIVE_DATE}`) .all([validatePortalFacilityAmendmentsEnabled, validateToken, validateBank, validateRole({ role: [MAKER] })]) diff --git a/gef-ui/server/types/view-models/amendments/ManualApprovalNeededViewModel.ts b/gef-ui/server/types/view-models/amendments/ManualApprovalNeededViewModel.ts new file mode 100644 index 0000000000..a6dce9bc2f --- /dev/null +++ b/gef-ui/server/types/view-models/amendments/ManualApprovalNeededViewModel.ts @@ -0,0 +1,9 @@ +import { FacilityType } from '@ukef/dtfs2-common'; + +export type ManualApprovalNeededViewModel = { + exporterName: string; + facilityType: FacilityType; + previousPage: string; + amendmentFormEmail: string; + returnLink: string; +}; diff --git a/gef-ui/templates/partials/amendments/manual-approval-needed.njk b/gef-ui/templates/partials/amendments/manual-approval-needed.njk new file mode 100644 index 0000000000..13cd12583d --- /dev/null +++ b/gef-ui/templates/partials/amendments/manual-approval-needed.njk @@ -0,0 +1,36 @@ +{% extends "index.njk" %} +{% from "govuk/components/back-link/macro.njk" import govukBackLink %} +{% from "govuk/components/button/macro.njk" import govukButton %} + +{% block pageTitle -%} + This amendment cannot be automatically approved +{%- endblock %} + +{% block content %} + {{ govukBackLink({ + text: "Back", + href: previousPage, + attributes: { + 'data-cy': 'back-link' + } + }) + }} + +
+
+

+ + {{ exporterName }}, {{ facilityType }} facility + + This amendment cannot be automatically approved +

+
+

Complete the Schedule 8 form (Amendment and Notification Request) and email to {{ amendmentFormEmail }}.

+

Include any supporting information and documents needed for UKEF to review and process the amendments.

+

Return to all applications and notices

+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/libs/common/package.json b/libs/common/package.json index 2e627ef589..72ddfcfacc 100644 --- a/libs/common/package.json +++ b/libs/common/package.json @@ -47,6 +47,7 @@ "unit-test-ff": "jest --coverage --verbose --config=unit.ff.jest.config.js --passWithNoTests" }, "dependencies": { + "@types/lodash": "^4.17.15", "axios": "1.7.8", "big.js": "^6.2.2", "date-fns": "^3.3.1", @@ -54,7 +55,7 @@ "dotenv": "^16.4.5", "express": "^4.21.2", "html-entities": "^2.5.2", - "lodash.difference": "^4.5.0", + "lodash": "^4.17.21", "mongodb": "^4.17.2", "mssql": "^11.0.1", "node-cron": "^3.0.3", diff --git a/libs/common/src/change-stream/validate-audit-details.test.ts b/libs/common/src/change-stream/validate-audit-details.test.ts index 822f4d6a53..75e32059b0 100644 --- a/libs/common/src/change-stream/validate-audit-details.test.ts +++ b/libs/common/src/change-stream/validate-audit-details.test.ts @@ -1,5 +1,5 @@ import { ObjectId } from 'mongodb'; -import difference from 'lodash.difference'; +import { difference } from 'lodash'; import { validateAuditDetails, validateAuditDetailsAndUserType } from './validate-audit-details'; import { AUDIT_USER_TYPES_AS_ARRAY, AUDIT_USER_TYPES_NOT_REQUIRING_ID, AUDIT_USER_TYPES_REQUIRING_ID } from '../constants'; import { InvalidAuditDetailsError } from '../errors'; diff --git a/libs/common/src/constants/date-formats.ts b/libs/common/src/constants/date-formats.ts index fa4642eb1e..42d13ea412 100644 --- a/libs/common/src/constants/date-formats.ts +++ b/libs/common/src/constants/date-formats.ts @@ -8,4 +8,5 @@ export const DATE_FORMATS = { DO_MMMM_YYYY: 'do MMMM yyyy', D_MMMM_YYYY: 'd MMMM yyyy', H_MMAAA: 'H:mmaaa', // e.g 12:34am + DD_MMM_YYYY: 'dd MMM yyyy', // e.g. '01 Feb 2024' }; diff --git a/libs/common/src/helpers/date.test.ts b/libs/common/src/helpers/date.test.ts index a76fbeb334..6b67d606e7 100644 --- a/libs/common/src/helpers/date.test.ts +++ b/libs/common/src/helpers/date.test.ts @@ -69,7 +69,7 @@ describe('date helpers', () => { const result = getISO8601(); const year = now().getFullYear(); const month = (now().getMonth() + 1).toString().padStart(2, '0'); - const date = now().getDate(); + const date = now().getDate().toString().padStart(2, '0'); // Assert expect(result).toContain(`${year}-${month}-${date}`); @@ -82,7 +82,7 @@ describe('date helpers', () => { const result = getISO8601(); const year = now().getFullYear(); const month = (now().getMonth() + 1).toString().padStart(2, '0'); - const date = now().getDate(); + const date = now().getDate().toString().padStart(2, '0'); // Assert expect(result).toContain(`${year}-${month}-${date}`); diff --git a/libs/common/src/helpers/index.ts b/libs/common/src/helpers/index.ts index 271b6c5670..7d1299c74f 100644 --- a/libs/common/src/helpers/index.ts +++ b/libs/common/src/helpers/index.ts @@ -22,4 +22,5 @@ export * from './is-ukef-email'; export * from './fee-record-corrections'; export * from './decode-html-entities'; export * from './map-currencies-to-radio-items'; +export * from './table-data-sort'; export * from './utilisation-report-emails'; diff --git a/trade-finance-manager-ui/server/controllers/utilisation-reports/helpers/get-key-to-date-sort-value-map-helper.test.ts b/libs/common/src/helpers/table-data-sort/get-key-to-date-sort-value-map-helper.test.ts similarity index 96% rename from trade-finance-manager-ui/server/controllers/utilisation-reports/helpers/get-key-to-date-sort-value-map-helper.test.ts rename to libs/common/src/helpers/table-data-sort/get-key-to-date-sort-value-map-helper.test.ts index 6a6ceae695..fc92e9f862 100644 --- a/trade-finance-manager-ui/server/controllers/utilisation-reports/helpers/get-key-to-date-sort-value-map-helper.test.ts +++ b/libs/common/src/helpers/table-data-sort/get-key-to-date-sort-value-map-helper.test.ts @@ -1,4 +1,4 @@ -import { getKeyToDateSortValueMap, GetKeyToDateSortValueMapItem } from './get-key-to-date-sort-value-map-helper'; +import { GetKeyToDateSortValueMapItem, getKeyToDateSortValueMap } from './get-key-to-date-sort-value-map-helper'; describe('get-key-to-currency-and-amount-sort-value-map-helper', () => { describe('getKeyToCurrencyAndAmountSortValueMap', () => { diff --git a/trade-finance-manager-ui/server/controllers/utilisation-reports/helpers/get-key-to-date-sort-value-map-helper.ts b/libs/common/src/helpers/table-data-sort/get-key-to-date-sort-value-map-helper.ts similarity index 91% rename from trade-finance-manager-ui/server/controllers/utilisation-reports/helpers/get-key-to-date-sort-value-map-helper.ts rename to libs/common/src/helpers/table-data-sort/get-key-to-date-sort-value-map-helper.ts index 0a656b81c8..1cf8cefe40 100644 --- a/trade-finance-manager-ui/server/controllers/utilisation-reports/helpers/get-key-to-date-sort-value-map-helper.ts +++ b/libs/common/src/helpers/table-data-sort/get-key-to-date-sort-value-map-helper.ts @@ -1,6 +1,6 @@ -import orderBy from 'lodash.orderby'; +import { orderBy } from 'lodash'; import { parseISO } from 'date-fns'; -import { IsoDateTimeStamp } from '@ukef/dtfs2-common'; +import { IsoDateTimeStamp } from '../../types'; export type GetKeyToDateSortValueMapItem = { date?: IsoDateTimeStamp; diff --git a/libs/common/src/helpers/table-data-sort/index.ts b/libs/common/src/helpers/table-data-sort/index.ts new file mode 100644 index 0000000000..1bd749f4f9 --- /dev/null +++ b/libs/common/src/helpers/table-data-sort/index.ts @@ -0,0 +1 @@ +export * from './get-key-to-date-sort-value-map-helper'; diff --git a/libs/common/src/sql-db-connection/migrations/1738320805938-addFeeRecordCorrectionBankTeamNameAndEmailsColumns.ts b/libs/common/src/sql-db-connection/migrations/1738320805938-addFeeRecordCorrectionBankTeamNameAndEmailsColumns.ts new file mode 100644 index 0000000000..1ffdfdd348 --- /dev/null +++ b/libs/common/src/sql-db-connection/migrations/1738320805938-addFeeRecordCorrectionBankTeamNameAndEmailsColumns.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddFeeRecordCorrectionBankTeamNameAndEmailsColumns1738320805938 implements MigrationInterface { + name = 'AddFeeRecordCorrectionBankTeamNameAndEmailsColumns1738320805938'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "FeeRecordCorrection" + ADD "bankTeamName" nvarchar(500) NOT NULL + `); + await queryRunner.query(` + ALTER TABLE "FeeRecordCorrection" + ADD "bankTeamEmails" nvarchar(1000) NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "FeeRecordCorrection" DROP COLUMN "bankTeamEmails" + `); + await queryRunner.query(` + ALTER TABLE "FeeRecordCorrection" DROP COLUMN "bankTeamName" + `); + } +} diff --git a/libs/common/src/sql-db-entities/fee-record-correction/fee-record-correction.entity.test.ts b/libs/common/src/sql-db-entities/fee-record-correction/fee-record-correction.entity.test.ts index c269a42f3d..81e5350fe6 100644 --- a/libs/common/src/sql-db-entities/fee-record-correction/fee-record-correction.entity.test.ts +++ b/libs/common/src/sql-db-entities/fee-record-correction/fee-record-correction.entity.test.ts @@ -21,14 +21,27 @@ describe('FeeRecordEntity', () => { platform: REQUEST_PLATFORM_TYPE.TFM, }; + const bankTeamName = 'Payment Officer Team'; + const bankTeamEmails = ['test@ukexportfinance.gov.uk', 'test2@ukexportfinance.gov.uk']; + // Act - const correctionEntity = FeeRecordCorrectionEntity.createRequestedCorrection({ feeRecord, requestedByUser, reasons, additionalInfo, requestSource }); + const correctionEntity = FeeRecordCorrectionEntity.createRequestedCorrection({ + feeRecord, + requestedByUser, + reasons, + additionalInfo, + requestSource, + bankTeamName, + bankTeamEmails, + }); // Assert expect(correctionEntity.reasons).toEqual(reasons); expect(correctionEntity.additionalInfo).toEqual(additionalInfo); expect(correctionEntity.requestedByUser).toEqual(requestedByUser); expect(correctionEntity.isCompleted).toEqual(false); + expect(correctionEntity.bankTeamName).toEqual(bankTeamName); + expect(correctionEntity.bankTeamEmails).toEqual('test@ukexportfinance.gov.uk,test2@ukexportfinance.gov.uk'); }); }); diff --git a/libs/common/src/sql-db-entities/fee-record-correction/fee-record-correction.entity.ts b/libs/common/src/sql-db-entities/fee-record-correction/fee-record-correction.entity.ts index 07b3376256..409da4012e 100644 --- a/libs/common/src/sql-db-entities/fee-record-correction/fee-record-correction.entity.ts +++ b/libs/common/src/sql-db-entities/fee-record-correction/fee-record-correction.entity.ts @@ -61,6 +61,18 @@ export class FeeRecordCorrectionEntity extends AuditableBaseEntity { @Column({ type: 'nvarchar', length: '500', nullable: true }) bankCommentary!: string | null; + /** + * Bank team name + */ + @Column({ type: 'nvarchar', length: '500' }) + bankTeamName!: string; + + /** + * Bank team emails + */ + @Column({ type: 'nvarchar', length: '1000' }) + bankTeamEmails!: string; + /** * The previous values of the fields of the fee record that the * correction is correcting @@ -97,6 +109,8 @@ export class FeeRecordCorrectionEntity extends AuditableBaseEntity { * @param param.reasons - The reasons for the correction * @param param.additionalInfo - The user provided additional information * @param param.requestSource - The request source + * @param param.bankTeamName - The bank payment officer team name + * @param param.bankTeamEmails - The bank payment officer team email address array * @returns The fee record correction */ static createRequestedCorrection({ @@ -105,6 +119,8 @@ export class FeeRecordCorrectionEntity extends AuditableBaseEntity { reasons, additionalInfo, requestSource, + bankTeamName, + bankTeamEmails, }: CreateFeeRecordCorrectionParams): FeeRecordCorrectionEntity { const recordCorrection = new FeeRecordCorrectionEntity(); recordCorrection.feeRecord = feeRecord; @@ -113,6 +129,8 @@ export class FeeRecordCorrectionEntity extends AuditableBaseEntity { recordCorrection.additionalInfo = additionalInfo; recordCorrection.isCompleted = false; recordCorrection.updateLastUpdatedBy(requestSource); + recordCorrection.bankTeamName = bankTeamName; + recordCorrection.bankTeamEmails = bankTeamEmails.join(','); return recordCorrection; } diff --git a/libs/common/src/sql-db-entities/fee-record-correction/fee-record-correction.types.ts b/libs/common/src/sql-db-entities/fee-record-correction/fee-record-correction.types.ts index 8749f1c8cb..64e3433bb8 100644 --- a/libs/common/src/sql-db-entities/fee-record-correction/fee-record-correction.types.ts +++ b/libs/common/src/sql-db-entities/fee-record-correction/fee-record-correction.types.ts @@ -8,6 +8,8 @@ export type CreateFeeRecordCorrectionParams = { reasons: RecordCorrectionReason[]; additionalInfo: string; requestSource: DbRequestSource; + bankTeamName: string; + bankTeamEmails: string[]; }; export type CompleteCorrectionParams = { diff --git a/libs/common/src/sql-db-entities/fee-record/fee-record.entity.test.ts b/libs/common/src/sql-db-entities/fee-record/fee-record.entity.test.ts index d4e56120ad..52b2c8d7f3 100644 --- a/libs/common/src/sql-db-entities/fee-record/fee-record.entity.test.ts +++ b/libs/common/src/sql-db-entities/fee-record/fee-record.entity.test.ts @@ -1,5 +1,11 @@ import { CURRENCY, FEE_RECORD_STATUS, REQUEST_PLATFORM_TYPE } from '../../constants'; -import { aRecordCorrectionValues, FeeRecordEntityMockBuilder, PaymentEntityMockBuilder, UtilisationReportEntityMockBuilder } from '../../test-helpers'; +import { + aRecordCorrectionValues, + FacilityUtilisationDataEntityMockBuilder, + FeeRecordEntityMockBuilder, + PaymentEntityMockBuilder, + UtilisationReportEntityMockBuilder, +} from '../../test-helpers'; import { Currency } from '../../types'; describe('FeeRecordEntity', () => { @@ -337,6 +343,33 @@ describe('FeeRecordEntity', () => { expect(feeRecord.facilityId).toEqual(correctedValues.facilityId); }); + it('should update facilityUtilisationData id to new facilityId when facilityId is corrected', () => { + // Arrange + const correctedValues = { + facilityUtilisation: null, + feesPaidToUkefForThePeriod: null, + feesPaidToUkefForThePeriodCurrency: null, + facilityId: '77777777', + }; + + const oldFacilityId = '11111111'; + + const feeRecord = FeeRecordEntityMockBuilder.forReport(utilisationReport) + .withFacilityUtilisationData(FacilityUtilisationDataEntityMockBuilder.forId(oldFacilityId).build()) + .withFacilityId(oldFacilityId) + .withStatus(FEE_RECORD_STATUS.PENDING_CORRECTION) + .build(); + + // Act + feeRecord.updateWithCorrection({ + correctedValues, + requestSource: { platform: REQUEST_PLATFORM_TYPE.PORTAL, userId: 'abc123' }, + }); + + // Assert + expect(feeRecord.facilityUtilisationData.id).toEqual(correctedValues.facilityId); + }); + describe('when the fee record payment currency is the same as the fees paid to ukef for the period currency', () => { const originalFeesPaidToUkefForThePeriodCurrency = CURRENCY.GBP; const originalPaymentCurrency = originalFeesPaidToUkefForThePeriodCurrency; diff --git a/libs/common/src/sql-db-entities/fee-record/fee-record.entity.ts b/libs/common/src/sql-db-entities/fee-record/fee-record.entity.ts index 7ea8f61975..5636a87fd8 100644 --- a/libs/common/src/sql-db-entities/fee-record/fee-record.entity.ts +++ b/libs/common/src/sql-db-entities/fee-record/fee-record.entity.ts @@ -271,6 +271,34 @@ export class FeeRecordEntity extends AuditableBaseEntity { if (correctedValues.facilityId !== null) { this.facilityId = correctedValues.facilityId; + + /** + * If we don't manually update this reference then the + * change to facilityId gets ignored but the transaction + * gets committed without an error. + * + * If there is an existing facilityUtilisationData with the + * new facilityId, then only the reference changes and it will + * now be linked to the existing facilityUtilisationDate entity. + * + * If no facilityUtilisationData with the new facilityId + * exists then typeorm creates a new entry with all the other + * fields the same as the previous data. + * + * This second behaviour could lead to incorrect utilisation + * adjustments since the initial utilisation will have been + * calculated using the values of an incorrect facility. + * However, utilisation adjustments are not currently + * being used so this is an accepted behaviour for now with + * FN-3813 having been raised to address this. + * + * TODO FN-3813: Remove this line. If we are not removing utilisation + * adjustments, we need to recalculate initial utilisation + * for any new facilityId without existing facilityUtilisationData + * rather than copying the values from the pre-correction data + * for the reasons explained in the above paragraph. + */ + this.facilityUtilisationData.id = this.facilityId; } this.updateLastUpdatedBy(requestSource); diff --git a/libs/common/src/test-helpers/mock-data/fee-record-correction.entity.mock-builder.ts b/libs/common/src/test-helpers/mock-data/fee-record-correction.entity.mock-builder.ts index c7b9d06f1a..a46e16bbe4 100644 --- a/libs/common/src/test-helpers/mock-data/fee-record-correction.entity.mock-builder.ts +++ b/libs/common/src/test-helpers/mock-data/fee-record-correction.entity.mock-builder.ts @@ -31,6 +31,8 @@ export class FeeRecordCorrectionEntityMockBuilder { }; data.additionalInfo = 'some info'; data.reasons = [RECORD_CORRECTION_REASON.UTILISATION_INCORRECT]; + data.bankTeamName = 'some team'; + data.bankTeamEmails = 'test1@ukexportfinance.gov.uk, test2@ukexportfinance.gov.uk'; if (isCompleted) { data.isCompleted = true; diff --git a/package-lock.json b/package-lock.json index c6b2dc47d1..9f2bed84e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -875,6 +875,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@types/lodash": "^4.17.15", "axios": "1.7.8", "big.js": "^6.2.2", "date-fns": "^3.3.1", @@ -882,7 +883,7 @@ "dotenv": "^16.4.5", "express": "^4.21.2", "html-entities": "^2.5.2", - "lodash.difference": "^4.5.0", + "lodash": "^4.17.21", "mongodb": "^4.17.2", "mssql": "^11.0.1", "node-cron": "^3.0.3", @@ -7917,9 +7918,9 @@ } }, "node_modules/@types/lodash": { - "version": "4.17.14", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.14.tgz", - "integrity": "sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==", + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==", "license": "MIT" }, "node_modules/@types/lodash.difference": { diff --git a/portal-api/api-tests/v1/utilisation-report-service/fee-record-corrections/get-completed-fee-record-corrections.api-test.js b/portal-api/api-tests/v1/utilisation-report-service/fee-record-corrections/get-completed-fee-record-corrections.api-test.js new file mode 100644 index 0000000000..b6fec1a2f1 --- /dev/null +++ b/portal-api/api-tests/v1/utilisation-report-service/fee-record-corrections/get-completed-fee-record-corrections.api-test.js @@ -0,0 +1,110 @@ +const { HttpStatusCode } = require('axios'); +const { + UtilisationReportEntityMockBuilder, + FeeRecordCorrectionEntityMockBuilder, + FeeRecordEntityMockBuilder, + RECORD_CORRECTION_REASON, + FEE_RECORD_STATUS, +} = require('@ukef/dtfs2-common'); +const { SqlDbHelper } = require('../../../sql-db-helper.ts'); +const app = require('../../../../src/createApp.js'); +const { as, get } = require('../../../api.js')(app); +const testUserCache = require('../../../api-test-users.js'); +const { withClientAuthenticationTests } = require('../../../common-tests/client-authentication-tests.js'); +const { withRoleAuthorisationTests } = require('../../../common-tests/role-authorisation-tests.js'); +const { PAYMENT_REPORT_OFFICER } = require('../../../../src/v1/roles/roles.js'); + +console.error = jest.fn(); + +describe('GET /v1/banks/:bankId/utilisation-reports/completed-corrections', () => { + const getUrl = ({ bankId }) => `/v1/banks/${bankId}/utilisation-reports/completed-corrections`; + + let testUsers; + let aBarclaysPaymentReportOfficer; + let barclaysBank; + + const exporter = 'An exporter'; + const correctionId = 7; + const dateCorrectionReceived = new Date('2024-01-01'); + const oldFacilityId = '11111111'; + const newFacilityId = '22222222'; + const bankCommentary = 'Some bank commentary'; + + beforeAll(async () => { + await SqlDbHelper.initialize(); + await SqlDbHelper.deleteAllEntries('UtilisationReport'); + + testUsers = await testUserCache.initialise(app); + aBarclaysPaymentReportOfficer = testUsers().withRole(PAYMENT_REPORT_OFFICER).withBankName('Barclays Bank').one(); + barclaysBank = aBarclaysPaymentReportOfficer.bank; + + const aUtilisationReport = new UtilisationReportEntityMockBuilder().withBankId(barclaysBank.id).build(); + + const aFeeRecord = FeeRecordEntityMockBuilder.forReport(aUtilisationReport) + .withExporter(exporter) + .withFacilityId(oldFacilityId) + .withStatus(FEE_RECORD_STATUS.TO_DO_AMENDED) + .build(); + aUtilisationReport.feeRecords = [aFeeRecord]; + + const aFeeRecordCorrection = FeeRecordCorrectionEntityMockBuilder.forFeeRecordAndIsCompleted(aFeeRecord, true) + .withId(correctionId) + .withDateReceived(dateCorrectionReceived) + .withReasons([RECORD_CORRECTION_REASON.FACILITY_ID_INCORRECT, RECORD_CORRECTION_REASON.OTHER]) + .withPreviousValues({ + facilityId: oldFacilityId, + }) + .withCorrectedValues({ + facilityId: newFacilityId, + }) + .withBankCommentary(bankCommentary) + .build(); + + await SqlDbHelper.saveNewEntries('UtilisationReport', [aUtilisationReport]); + await SqlDbHelper.saveNewEntries('FeeRecordCorrection', [aFeeRecordCorrection]); + }); + + withClientAuthenticationTests({ + makeRequestWithoutAuthHeader: () => get(getUrl({ bankId: barclaysBank.id })), + makeRequestWithAuthHeader: (authHeader) => get(getUrl({ bankId: barclaysBank.id }), { headers: { Authorization: authHeader } }), + }); + + withRoleAuthorisationTests({ + allowedRoles: [PAYMENT_REPORT_OFFICER], + getUserWithRole: (role) => testUsers().withRole(role).withBankName(barclaysBank.name).one(), + makeRequestAsUser: (user) => as(user).get(getUrl({ bankId: barclaysBank.id })), + successStatusCode: HttpStatusCode.Ok, + }); + + it(`should return a '${HttpStatusCode.Ok}' status code and the completed fee record corrections`, async () => { + const response = await as(aBarclaysPaymentReportOfficer).get(getUrl({ bankId: barclaysBank.id })); + + expect(response.status).toEqual(HttpStatusCode.Ok); + + const expectedResponseBody = [ + { + id: correctionId, + dateSent: dateCorrectionReceived.toISOString(), + exporter, + formattedReasons: 'Facility ID is incorrect, Other', + formattedPreviousValues: `${oldFacilityId}, -`, + formattedCorrectedValues: `${newFacilityId}, -`, + bankCommentary, + }, + ]; + + expect(response.body).toEqual(expectedResponseBody); + }); + + it(`should respond with a ${HttpStatusCode.BadRequest} to requests that do not have a valid bank id`, async () => { + const { status } = await as(aBarclaysPaymentReportOfficer).get(getUrl({ bankId: 'invalid-bank-id' })); + + expect(status).toEqual(HttpStatusCode.BadRequest); + }); + + it(`should respond with a ${HttpStatusCode.Unauthorized} if user's bank does not match request bank`, async () => { + const { status } = await as(aBarclaysPaymentReportOfficer).get(getUrl({ bankId: barclaysBank.id - 1 })); + + expect(status).toEqual(HttpStatusCode.Unauthorized); + }); +}); diff --git a/portal-api/src/v1/api-response-types/get-completed-fee-record-corrections-response-body.ts b/portal-api/src/v1/api-response-types/get-completed-fee-record-corrections-response-body.ts new file mode 100644 index 0000000000..c7d6f65b8b --- /dev/null +++ b/portal-api/src/v1/api-response-types/get-completed-fee-record-corrections-response-body.ts @@ -0,0 +1,11 @@ +import { IsoDateTimeStamp } from '@ukef/dtfs2-common'; + +export type GetCompletedFeeRecordCorrectionsResponseBody = { + id: number; + dateSent: IsoDateTimeStamp; + exporter: string; + formattedReasons: string; + formattedPreviousValues: string; + formattedCorrectedValues: string; + bankCommentary?: string; +}[]; diff --git a/portal-api/src/v1/api-response-types/index.ts b/portal-api/src/v1/api-response-types/index.ts index 27b375107d..4a194bbce2 100644 --- a/portal-api/src/v1/api-response-types/index.ts +++ b/portal-api/src/v1/api-response-types/index.ts @@ -7,3 +7,4 @@ export * from './get-fee-record-correction-response-body'; export * from './get-fee-record-correction-transient-form-data-response-body'; export * from './save-fee-record-correction-response-body'; export * from './put-fee-record-correction-response-body'; +export * from './get-completed-fee-record-corrections-response-body'; diff --git a/portal-api/src/v1/api.js b/portal-api/src/v1/api.js index 91328617d4..9ef53ddfc1 100644 --- a/portal-api/src/v1/api.js +++ b/portal-api/src/v1/api.js @@ -489,6 +489,24 @@ const getFeeRecordCorrectionById = async (correctionId) => { } }; +/** + * Gets completed fee record corrections by bank id. + * @param {number} bankId - The ID of the bank + * @returns {Promise} response of API call or wrapped error response + */ +const getCompletedFeeRecordCorrections = async (bankId) => { + try { + const response = await axios.get(`${DTFS_CENTRAL_API_URL}/v1/bank/${bankId}/utilisation-reports/completed-corrections`, { + headers: headers.central, + }); + + return response.data; + } catch (error) { + console.error('Unable to get completed fee record corrections for bank with id %s: %o', bankId, error); + throw error; + } +}; + /** * Saves a fee record correction. * @@ -737,4 +755,5 @@ module.exports = { getFeeRecordCorrectionReview, patchPortalFacilityAmendment, putFeeRecordCorrectionTransientFormData, + getCompletedFeeRecordCorrections, }; diff --git a/portal-api/src/v1/controllers/utilisation-report-service/fee-record-corrections/completed-fee-record-corrections.controller.test.ts b/portal-api/src/v1/controllers/utilisation-report-service/fee-record-corrections/completed-fee-record-corrections.controller.test.ts new file mode 100644 index 0000000000..744206876b --- /dev/null +++ b/portal-api/src/v1/controllers/utilisation-report-service/fee-record-corrections/completed-fee-record-corrections.controller.test.ts @@ -0,0 +1,113 @@ +import httpMocks, { MockResponse } from 'node-mocks-http'; +import { AxiosResponse, HttpStatusCode, AxiosError } from 'axios'; +import { Response } from 'express'; +import { ObjectId } from 'mongodb'; +import api from '../../../api'; +import { CompletedFeeRecordCorrectionsRequest, getCompletedFeeRecordCorrections } from './completed-fee-record-corrections.controller'; +import { aGetCompletedFeeRecordCorrectionsResponseBody } from '../../../../../test-helpers/test-data/get-completed-fee-record-corrections-response-body'; + +jest.mock('../../../api'); + +console.error = jest.fn(); + +describe('completed-fee-record-corrections.controller', () => { + const bankId = 123; + const portalUserId = new ObjectId().toString(); + + const aValidRequestParams = () => ({ bankId: bankId.toString() }); + + let req: CompletedFeeRecordCorrectionsRequest; + let res: MockResponse; + + beforeEach(() => { + req = httpMocks.createRequest({ + params: aValidRequestParams(), + user: { _id: portalUserId }, + }); + res = httpMocks.createResponse(); + }); + + describe('getCompletedFeeRecordCorrections', () => { + beforeEach(() => { + jest.mocked(api.getCompletedFeeRecordCorrections).mockResolvedValue(aGetCompletedFeeRecordCorrectionsResponseBody()); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it(`should return a ${HttpStatusCode.Ok} status code if the api request is successful`, async () => { + // Arrange + const mockedResponse = aGetCompletedFeeRecordCorrectionsResponseBody(); + + jest.mocked(api.getCompletedFeeRecordCorrections).mockResolvedValue(mockedResponse); + + // Act + await getCompletedFeeRecordCorrections(req, res); + + // Assert + expect(res._getStatusCode()).toEqual(HttpStatusCode.Ok); + }); + + it(`should return the completed fee record corrections in the response body if the api request is successful`, async () => { + // Arrange + const expectedResponse = aGetCompletedFeeRecordCorrectionsResponseBody(); + + jest.mocked(api.getCompletedFeeRecordCorrections).mockResolvedValue(expectedResponse); + + // Act + await getCompletedFeeRecordCorrections(req, res); + + // Assert + expect(res._getData()).toEqual(expectedResponse); + }); + + it('should call the "get completed fee record corrections" api endpoint once with the correct parameters', async () => { + // Act + await getCompletedFeeRecordCorrections(req, res); + + // Assert + expect(api.getCompletedFeeRecordCorrections).toHaveBeenCalledTimes(1); + expect(api.getCompletedFeeRecordCorrections).toHaveBeenCalledWith(bankId); + }); + + it(`should return a ${HttpStatusCode.InternalServerError} status code if an unknown error occurs`, async () => { + // Arrange + jest.mocked(api.getCompletedFeeRecordCorrections).mockRejectedValue(new Error('Some error')); + + // Act + await getCompletedFeeRecordCorrections(req, res); + + // Assert + expect(res._getStatusCode()).toEqual(HttpStatusCode.InternalServerError); + expect(res._isEndCalled()).toEqual(true); + }); + + it('should return a specific error code if an axios error is thrown', async () => { + // Arrange + const errorStatus = HttpStatusCode.BadRequest; + const axiosError = new AxiosError(undefined, undefined, undefined, undefined, { status: errorStatus } as AxiosResponse); + + jest.mocked(api.getCompletedFeeRecordCorrections).mockRejectedValue(axiosError); + + // Act + await getCompletedFeeRecordCorrections(req, res); + + // Assert + expect(res._getStatusCode()).toEqual(errorStatus); + expect(res._isEndCalled()).toEqual(true); + }); + + it('should return an error message if an error occurs', async () => { + // Arrange + jest.mocked(api.getCompletedFeeRecordCorrections).mockRejectedValue(new Error('Some error')); + + // Act + await getCompletedFeeRecordCorrections(req, res); + + // Assert + expect(res._getData()).toEqual('Failed to get completed fee record corrections'); + expect(res._isEndCalled()).toEqual(true); + }); + }); +}); diff --git a/portal-api/src/v1/controllers/utilisation-report-service/fee-record-corrections/completed-fee-record-corrections.controller.ts b/portal-api/src/v1/controllers/utilisation-report-service/fee-record-corrections/completed-fee-record-corrections.controller.ts new file mode 100644 index 0000000000..d9da6f5028 --- /dev/null +++ b/portal-api/src/v1/controllers/utilisation-report-service/fee-record-corrections/completed-fee-record-corrections.controller.ts @@ -0,0 +1,36 @@ +import { CustomExpressRequest } from '@ukef/dtfs2-common'; +import { Response } from 'express'; +import { HttpStatusCode, isAxiosError } from 'axios'; +import api from '../../../api'; + +/** + * Request type for the GET completed fee record corrections endpoint. + */ +export type CompletedFeeRecordCorrectionsRequest = CustomExpressRequest<{ + params: { + bankId: string; + }; +}>; + +/** + * Calls the DTFS Central API to get completed fee record corrections for a given bank id. + * @param req - The request object containing information about the HTTP request. + * @param res - The response object used to send the HTTP response. + */ +export const getCompletedFeeRecordCorrections = async (req: CompletedFeeRecordCorrectionsRequest, res: Response) => { + try { + const { bankId } = req.params; + + const completedCorrections = await api.getCompletedFeeRecordCorrections(Number(bankId)); + + return res.status(HttpStatusCode.Ok).send(completedCorrections); + } catch (error) { + const errorMessage = 'Failed to get completed fee record corrections'; + + console.error('%s %o', errorMessage, error); + + const errorStatus = (isAxiosError(error) && error.response?.status) || HttpStatusCode.InternalServerError; + + return res.status(errorStatus).send(errorMessage); + } +}; diff --git a/portal-api/src/v1/controllers/utilisation-report-service/fee-record-corrections/index.ts b/portal-api/src/v1/controllers/utilisation-report-service/fee-record-corrections/index.ts index c103ff3e72..7eef540861 100644 --- a/portal-api/src/v1/controllers/utilisation-report-service/fee-record-corrections/index.ts +++ b/portal-api/src/v1/controllers/utilisation-report-service/fee-record-corrections/index.ts @@ -2,3 +2,4 @@ export * from './pending-corrections.controller'; export * from './fee-record-correction-transient-form-data.controller'; export * from './fee-record-correction-review.controller'; export * from './fee-record-correction.controller'; +export * from './completed-fee-record-corrections.controller'; diff --git a/portal-api/src/v1/routes.js b/portal-api/src/v1/routes.js index 769f1e98bd..2408a660b7 100644 --- a/portal-api/src/v1/routes.js +++ b/portal-api/src/v1/routes.js @@ -373,4 +373,14 @@ authRouter utilisationReportControllers.getFeeRecordCorrectionReview, ); +authRouter + .route('/banks/:bankId/utilisation-reports/completed-corrections') + .get( + validateUserHasAtLeastOneAllowedRole({ allowedRoles: [PAYMENT_REPORT_OFFICER] }), + bankIdValidation, + handleExpressValidatorResult, + validateUserAndBankIdMatch, + utilisationReportControllers.getCompletedFeeRecordCorrections, + ); + module.exports = { openRouter, authRouter }; diff --git a/portal-api/test-helpers/test-data/get-completed-fee-record-corrections-response-body.ts b/portal-api/test-helpers/test-data/get-completed-fee-record-corrections-response-body.ts new file mode 100644 index 0000000000..d92047e862 --- /dev/null +++ b/portal-api/test-helpers/test-data/get-completed-fee-record-corrections-response-body.ts @@ -0,0 +1,13 @@ +import { GetCompletedFeeRecordCorrectionsResponseBody } from '../../src/v1/api-response-types'; + +export const aGetCompletedFeeRecordCorrectionsResponseBody = (): GetCompletedFeeRecordCorrectionsResponseBody => [ + { + id: 7, + dateSent: '2024-01-01T12:00:00Z', + exporter: 'A sample exporter', + formattedReasons: 'Reason 1, Reason 2', + formattedPreviousValues: 'previousValue1, previousValue2', + formattedCorrectedValues: 'newValue1, newValue2', + bankCommentary: 'Some bank commentary', + }, +]; diff --git a/portal/api-tests/utilisation-report-routes.api-test.js b/portal/api-tests/utilisation-report-routes.api-test.js index 6936d8633b..1bbec29833 100644 --- a/portal/api-tests/utilisation-report-routes.api-test.js +++ b/portal/api-tests/utilisation-report-routes.api-test.js @@ -171,4 +171,44 @@ describe('utilisation-report routes', () => { }); }); }); + + describe('GET /utilisation-reports/correction-log', () => { + const originalProcessEnv = { ...process.env }; + const url = '/utilisation-reports/correction-log'; + + describe('when FF_FEE_RECORD_CORRECTION_ENABLED is set to `true`', () => { + beforeAll(() => { + process.env.FF_FEE_RECORD_CORRECTION_ENABLED = 'true'; + }); + + afterAll(() => { + process.env = { ...originalProcessEnv }; + }); + + withRoleValidationApiTests({ + makeRequestWithHeaders: (headers) => get(url, {}, headers), + whitelistedRoles: [ROLES.PAYMENT_REPORT_OFFICER], + successCode: HttpStatusCode.Ok, + disableHappyPath: true, + }); + }); + + describe('when FF_FEE_RECORD_CORRECTION_ENABLED is set to `false`', () => { + beforeAll(() => { + process.env.FF_FEE_RECORD_CORRECTION_ENABLED = 'false'; + }); + + afterAll(() => { + process.env = { ...originalProcessEnv }; + }); + + it(`should redirect to "/not-found"`, async () => { + // Act + const response = await get(url); + + // Assert + expect(response.headers.location).toEqual('/not-found'); + }); + }); + }); }); diff --git a/portal/component-tests/assertions.js b/portal/component-tests/assertions.js index fb237ee152..e6cf494dce 100644 --- a/portal/component-tests/assertions.js +++ b/portal/component-tests/assertions.js @@ -83,6 +83,9 @@ const assertions = (wrapper, html) => ({ toHaveAttribute: (attribute, value) => { expect(wrapper(selector).attr(attribute)).toEqual(value); }, + notToHaveAttribute: (attribute) => { + expect(wrapper(selector).attr(attribute)).toBeUndefined(); + }, lengthToEqual: (expectedLength) => { const expected = expectedLength + 1; // cheerio html() assertion automatically adds 1. expect(wrapper(selector).html().length).toEqual(expected); diff --git a/portal/component-tests/partials/primary-navigation.component-test.js b/portal/component-tests/partials/primary-navigation.component-test.js index bb30f281ef..9a01264c23 100644 --- a/portal/component-tests/partials/primary-navigation.component-test.js +++ b/portal/component-tests/partials/primary-navigation.component-test.js @@ -17,6 +17,7 @@ describe(page, () => { itDoesNotRenderAUtilisationReportUploadLink(); itDoesNotRenderAPreviousReportsLink(); + itDoesNotRenderARecordCorrectionLogLink(); itDoesNotRenderAUsersLink(); }); @@ -30,6 +31,7 @@ describe(page, () => { itDoesNotRenderAUtilisationReportUploadLink(); itDoesNotRenderAPreviousReportsLink(); + itDoesNotRenderARecordCorrectionLogLink(); itDoesNotRenderAUsersLink(); }); @@ -44,6 +46,7 @@ describe(page, () => { itDoesNotRenderAUtilisationReportUploadLink(); itDoesNotRenderAPreviousReportsLink(); + itDoesNotRenderARecordCorrectionLogLink(); }); describe(`viewed by role '${ROLES.READ_ONLY}'`, () => { @@ -56,16 +59,46 @@ describe(page, () => { itDoesNotRenderAReportsLink(); itDoesNotRenderAUtilisationReportUploadLink(); itDoesNotRenderAPreviousReportsLink(); + itDoesNotRenderARecordCorrectionLogLink(); itDoesNotRenderAUsersLink(); }); - describe(`viewed by role '${ROLES.PAYMENT_REPORT_OFFICER}'`, () => { + describe(`viewed by role '${ROLES.PAYMENT_REPORT_OFFICER}' and FF_FEE_RECORD_CORRECTION_ENABLED is set to "true"`, () => { + const originalProcessEnv = { ...process.env }; + + beforeAll(() => { + process.env.FF_FEE_RECORD_CORRECTION_ENABLED = 'true'; + wrapper = render({ user: { roles: [ROLES.PAYMENT_REPORT_OFFICER] } }); + }); + + afterAll(() => { + process.env = originalProcessEnv; + }); + + itRendersAUtilisationReportUploadLink(); + itRendersAPreviousReportsLink(); + itRendersARecordCorrectionLogLink(); + + itDoesNotRenderAHomeLink(); + itDoesNotRenderAReportsLink(); + itDoesNotRenderAUsersLink(); + }); + + describe(`viewed by role '${ROLES.PAYMENT_REPORT_OFFICER}' and FF_FEE_RECORD_CORRECTION_ENABLED is set to "false"`, () => { + const originalProcessEnv = { ...process.env }; + beforeAll(() => { + process.env.FF_FEE_RECORD_CORRECTION_ENABLED = 'false'; wrapper = render({ user: { roles: [ROLES.PAYMENT_REPORT_OFFICER] } }); }); + afterAll(() => { + process.env = originalProcessEnv; + }); + itRendersAUtilisationReportUploadLink(); itRendersAPreviousReportsLink(); + itDoesNotRenderARecordCorrectionLogLink(); itDoesNotRenderAHomeLink(); itDoesNotRenderAReportsLink(); @@ -81,6 +114,7 @@ describe(page, () => { itDoesNotRenderAReportsLink(); itDoesNotRenderAUtilisationReportUploadLink(); itDoesNotRenderAPreviousReportsLink(); + itDoesNotRenderARecordCorrectionLogLink(); itDoesNotRenderAUsersLink(); }); @@ -132,6 +166,18 @@ describe(page, () => { }); } + function itRendersARecordCorrectionLogLink() { + it('renders a record correction log link', () => { + wrapper.expectLink('[data-cy="record_correction_log"]').toLinkTo('/utilisation-reports/correction-log', 'Record correction log'); + }); + } + + function itDoesNotRenderARecordCorrectionLogLink() { + it('does not render a record correction log link', () => { + wrapper.expectLink('[data-cy="record_correction_log"]').notToExist(); + }); + } + function itRendersAUsersLink() { it('renders a users link', () => { wrapper.expectLink('[data-cy="users"]').toLinkTo('/admin/users', 'Users'); diff --git a/portal/component-tests/utilisation-report-service/record-correction/_macros/correction-log-table.component-test.js b/portal/component-tests/utilisation-report-service/record-correction/_macros/correction-log-table.component-test.js new file mode 100644 index 0000000000..004d0d24a2 --- /dev/null +++ b/portal/component-tests/utilisation-report-service/record-correction/_macros/correction-log-table.component-test.js @@ -0,0 +1,148 @@ +const componentRenderer = require('../../../componentRenderer'); + +const component = 'utilisation-report-service/record-correction/_macros/correction-log-table.njk'; +const render = componentRenderer(component, true); + +describe(component, () => { + const firstCompletedCorrection = { + dateSent: { + formattedDateSent: '01 Jan 2024', + dataSortValue: 0, + }, + exporter: 'Some exporter', + formattedReasons: 'Facility ID is incorrect, Other', + formattedPreviousValues: '11111111, -', + formattedCorrectedValues: '22222222, -', + formattedBankCommentary: 'Some bank commentary', + }; + + const secondCompletedCorrection = { + dateSent: { + formattedDateSent: '02 Jan 2024', + dataSortValue: 1, + }, + exporter: 'Another exporter', + formattedReasons: 'Facility ID is incorrect, Utilisation is incorrect', + formattedPreviousValues: '33333333, 123.45', + formattedCorrectedValues: '44444444, 987.65', + formattedBankCommentary: 'Some other bank commentary', + }; + + const firstRowSelector = 'tr:nth-of-type(1)'; + const secondRowSelector = 'tr:nth-of-type(2)'; + + const viewModel = { + completedCorrections: [firstCompletedCorrection, secondCompletedCorrection], + }; + + const tableHeaderSelector = (text) => `thead th:contains("${text}")`; + + it('renders 6 table headings', () => { + // Act + const wrapper = render(viewModel); + + // Assert + wrapper.expectElement('table thead tr').toHaveCount(1); + wrapper.expectElement('table thead th').toHaveCount(6); + }); + + it('renders the "date sent" heading with the aria-sort attribute set to descending', () => { + // Act + const wrapper = render(viewModel); + + // Assert + wrapper.expectElement(tableHeaderSelector('Date sent')).toExist(); + wrapper.expectElement(tableHeaderSelector('Date sent')).toHaveAttribute('aria-sort', 'descending'); + }); + + it('renders the "exporter" heading with the aria-sort attribute set to ascending', () => { + // Act + const wrapper = render(viewModel); + + // Assert + wrapper.expectElement(tableHeaderSelector('Exporter')).toExist(); + wrapper.expectElement(tableHeaderSelector('Exporter')).toHaveAttribute('aria-sort', 'ascending'); + }); + + it('renders the "reasons for correction", "correct record", "old record", and "correction notes" headings as not sortable', () => { + // Act + const wrapper = render(viewModel); + + // Assert + wrapper.expectElement(tableHeaderSelector('Reason(s) for correction')).toExist(); + wrapper.expectElement(tableHeaderSelector('Reason(s) for correction')).notToHaveAttribute('aria-sort'); + + wrapper.expectElement(tableHeaderSelector('Correct record')).toExist(); + wrapper.expectElement(tableHeaderSelector('Correct record')).notToHaveAttribute('aria-sort'); + + wrapper.expectElement(tableHeaderSelector('Old record')).toExist(); + wrapper.expectElement(tableHeaderSelector('Old record')).notToHaveAttribute('aria-sort'); + + wrapper.expectElement(tableHeaderSelector('Correction notes')).toExist(); + wrapper.expectElement(tableHeaderSelector('Correction notes')).notToHaveAttribute('aria-sort'); + }); + + it('should render the corrections as rows within the table', () => { + // Act + const wrapper = render(viewModel); + + // Assert + wrapper.expectElement('tr').toHaveCount(3); // 2 completed corrections + 1 header row + + wrapper.expectText(`${firstRowSelector} [data-cy="correction-log-row--date-sent"]`).toRead(firstCompletedCorrection.dateSent.formattedDateSent); + wrapper.expectText(`${firstRowSelector} [data-cy="correction-log-row--exporter"]`).toRead(firstCompletedCorrection.exporter); + wrapper.expectText(`${firstRowSelector} [data-cy="correction-log-row--formatted-reasons"]`).toRead(firstCompletedCorrection.formattedReasons); + wrapper + .expectText(`${firstRowSelector} [data-cy="correction-log-row--formatted-corrected-values"]`) + .toRead(firstCompletedCorrection.formattedCorrectedValues); + wrapper + .expectText(`${firstRowSelector} [data-cy="correction-log-row--formatted-previous-values"]`) + .toRead(firstCompletedCorrection.formattedPreviousValues); + wrapper.expectText(`${firstRowSelector} [data-cy="correction-log-row--bank-commentary"]`).toRead(firstCompletedCorrection.formattedBankCommentary); + + wrapper.expectText(`${secondRowSelector} [data-cy="correction-log-row--date-sent"]`).toRead(secondCompletedCorrection.dateSent.formattedDateSent); + wrapper.expectText(`${secondRowSelector} [data-cy="correction-log-row--exporter"]`).toRead(secondCompletedCorrection.exporter); + wrapper.expectText(`${secondRowSelector} [data-cy="correction-log-row--formatted-reasons"]`).toRead(secondCompletedCorrection.formattedReasons); + wrapper + .expectText(`${secondRowSelector} [data-cy="correction-log-row--formatted-corrected-values"]`) + .toRead(secondCompletedCorrection.formattedCorrectedValues); + wrapper + .expectText(`${secondRowSelector} [data-cy="correction-log-row--formatted-previous-values"]`) + .toRead(secondCompletedCorrection.formattedPreviousValues); + wrapper.expectText(`${secondRowSelector} [data-cy="correction-log-row--bank-commentary"]`).toRead(secondCompletedCorrection.formattedBankCommentary); + }); + + it('should render the corrections "date sent" with the "data-sort-value" attribute', () => { + // Arrange + const completedCorrections = [ + { + ...firstCompletedCorrection, + dateSent: { + formattedDateSent: '01 Jan 2024', + dataSortValue: 0, + }, + }, + { + ...secondCompletedCorrection, + dateSent: { + formattedDateSent: '02 Jan 2024', + dataSortValue: 1, + }, + }, + ]; + + // Act + const wrapper = render({ completedCorrections }); + + // Assert + const firstCorrectionDateSentSelector = `${firstRowSelector} td:contains("${firstCompletedCorrection.dateSent.formattedDateSent}")`; + + wrapper.expectElement(firstCorrectionDateSentSelector).toExist(); + wrapper.expectElement(firstCorrectionDateSentSelector).toHaveAttribute('data-sort-value', firstCompletedCorrection.dateSent.dataSortValue.toString()); + + const secondCorrectionDateSentSelector = `${secondRowSelector} td:contains("${secondCompletedCorrection.dateSent.formattedDateSent}")`; + + wrapper.expectElement(secondCorrectionDateSentSelector).toExist(); + wrapper.expectElement(secondCorrectionDateSentSelector).toHaveAttribute('data-sort-value', secondCompletedCorrection.dateSent.dataSortValue.toString()); + }); +}); diff --git a/portal/component-tests/utilisation-report-service/record-correction/correction-log.component-test.js b/portal/component-tests/utilisation-report-service/record-correction/correction-log.component-test.js new file mode 100644 index 0000000000..12e1ab0324 --- /dev/null +++ b/portal/component-tests/utilisation-report-service/record-correction/correction-log.component-test.js @@ -0,0 +1,75 @@ +const pageRenderer = require('../../pageRenderer'); +const { aPendingCorrectionsViewModel } = require('../../../test-helpers/test-data/view-models'); + +const page = 'utilisation-report-service/record-correction/correction-log.njk'; +const render = pageRenderer(page); + +describe(page, () => { + it('should render the main heading', () => { + // Act + const wrapper = render(aPendingCorrectionsViewModel()); + + // Assert + wrapper.expectText('[data-cy="main-heading"]').toRead('Record correction log'); + }); + + describe('when there are no completed corrections', () => { + const viewModel = { + completedCorrections: [], + }; + + it('should render the "no corrections" text', () => { + // Act + const wrapper = render(viewModel); + + // Assert + wrapper.expectText('[data-cy="no-corrections-text-line-1"]').toRead('There are no previous record correction requests.'); + wrapper + .expectText('[data-cy="no-corrections-text-line-2"]') + .toRead('Records will be automatically added to this page once they have been sent back to UKEF.'); + }); + + it('should not render the correction log table', () => { + // Act + const wrapper = render(viewModel); + + // Assert + wrapper.expectElement('[data-cy="correction-log-table"]').notToExist(); + }); + }); + + describe('when there are completed corrections', () => { + const viewModel = { + completedCorrections: [ + { + dateSent: { + formattedDateSent: '01 Jan 2024', + dataSortValue: 0, + }, + exporter: 'Some exporter', + formattedReasons: 'Facility ID is incorrect, Other', + formattedPreviousValues: '11111111, -', + formattedCorrectedValues: '22222222, -', + formattedBankCommentary: 'Some bank commentary', + }, + ], + }; + + it('should render the "corrections" text', () => { + // Act + const wrapper = render(viewModel); + + // Assert + wrapper.expectText('[data-cy="corrections-text-line-1"]').toRead('Previous record correction requests are shown below.'); + wrapper.expectText('[data-cy="corrections-text-line-2"]').toRead('Records are automatically added to this page once they have been sent back to UKEF.'); + }); + + it('should render the correction log table', () => { + // Act + const wrapper = render(viewModel); + + // Assert + wrapper.expectElement('[data-cy="correction-log-table"]').toExist(); + }); + }); +}); diff --git a/portal/server/api-response-types/get-completed-fee-record-corrections-response-body.ts b/portal/server/api-response-types/get-completed-fee-record-corrections-response-body.ts new file mode 100644 index 0000000000..c7d6f65b8b --- /dev/null +++ b/portal/server/api-response-types/get-completed-fee-record-corrections-response-body.ts @@ -0,0 +1,11 @@ +import { IsoDateTimeStamp } from '@ukef/dtfs2-common'; + +export type GetCompletedFeeRecordCorrectionsResponseBody = { + id: number; + dateSent: IsoDateTimeStamp; + exporter: string; + formattedReasons: string; + formattedPreviousValues: string; + formattedCorrectedValues: string; + bankCommentary?: string; +}[]; diff --git a/portal/server/api-response-types/index.ts b/portal/server/api-response-types/index.ts index 07b17da8ba..f8488eed54 100644 --- a/portal/server/api-response-types/index.ts +++ b/portal/server/api-response-types/index.ts @@ -5,3 +5,4 @@ export { GetFeeRecordCorrectionResponseBody } from './get-fee-record-correction- export { GetFeeRecordCorrectionTransientFormDataResponseBody } from './get-fee-record-correction-transient-form-data-response-body'; export { SaveFeeRecordCorrectionResponseBody } from './save-fee-record-correction-response-body'; export { PutFeeRecordCorrectionResponseBody } from './put-fee-record-correction-response-body'; +export { GetCompletedFeeRecordCorrectionsResponseBody } from './get-completed-fee-record-corrections-response-body'; diff --git a/portal/server/api.js b/portal/server/api.js index 5e5f944a16..6e7457396d 100644 --- a/portal/server/api.js +++ b/portal/server/api.js @@ -1146,6 +1146,23 @@ const saveFeeRecordCorrection = async (token, bankId, id) => { return data; }; +/** + * Gets all completed fee record corrections by bank id. + * @param {string} token - The user token + * @param {string} bankId - The bank id + * @returns {Promise} Returns a promise that resolves to the completed fee record corrections data + */ +const getCompletedFeeRecordCorrections = async (token, bankId) => { + const { data } = await axios.get(`${PORTAL_API_URL}/v1/banks/${bankId}/utilisation-reports/completed-corrections`, { + headers: { + Authorization: token, + [HEADERS.CONTENT_TYPE.KEY]: HEADERS.CONTENT_TYPE.VALUES.JSON, + }, + }); + + return data; +}; + module.exports = { allDeals, allFacilities, @@ -1206,4 +1223,5 @@ module.exports = { getFeeRecordCorrectionReview, deleteFeeRecordCorrectionTransientFormData, saveFeeRecordCorrection, + getCompletedFeeRecordCorrections, }; diff --git a/portal/server/constants/primary-nav-key.ts b/portal/server/constants/primary-nav-key.ts index 51ea5c3c30..8dfb6b2cd4 100644 --- a/portal/server/constants/primary-nav-key.ts +++ b/portal/server/constants/primary-nav-key.ts @@ -4,4 +4,5 @@ export const PRIMARY_NAV_KEY = { REPORTS: 'reports', USERS: 'users', UTILISATION_REPORT_UPLOAD: 'utilisation_report_upload', + RECORD_CORRECTION_LOG: 'record_correction_log', } as const; diff --git a/portal/server/controllers/utilisation-report-service/record-correction/index.ts b/portal/server/controllers/utilisation-report-service/record-correction/index.ts index 3e984c9d08..7880c8cf11 100644 --- a/portal/server/controllers/utilisation-report-service/record-correction/index.ts +++ b/portal/server/controllers/utilisation-report-service/record-correction/index.ts @@ -2,3 +2,4 @@ export { getProvideUtilisationReportCorrection, postProvideUtilisationReportCorr export { getUtilisationReportCorrectionReview, postUtilisationReportCorrectionReview } from './check-the-information'; export { getRecordCorrectionSent } from './record-correction-sent'; export { cancelUtilisationReportCorrection } from './cancel-correction'; +export { getRecordCorrectionLog } from './record-correction-log'; diff --git a/portal/server/controllers/utilisation-report-service/record-correction/record-correction-log/helpers.test.ts b/portal/server/controllers/utilisation-report-service/record-correction/record-correction-log/helpers.test.ts new file mode 100644 index 0000000000..265c1b7550 --- /dev/null +++ b/portal/server/controllers/utilisation-report-service/record-correction/record-correction-log/helpers.test.ts @@ -0,0 +1,157 @@ +import { GetCompletedFeeRecordCorrectionsResponseBody } from '../../../../api-response-types'; +import { CompletedCorrectionViewModel } from '../../../../types/view-models/record-correction/record-correction-log'; +import { getFormattedDateSent, mapCompletedCorrectionsToViewModel } from './helpers'; + +describe('record-correction-log helpers', () => { + describe('getFormattedDateSent', () => { + describe('when a valid iso date string is provided', () => { + it('should format the date in the correct format', () => { + // Arrange + const dateSent = '2024-02-01T12:30:00.000'; + + // Act + const formattedDate = getFormattedDateSent(dateSent); + + // Assert + expect(formattedDate).toBe('01 Feb 2024'); + }); + }); + + describe('when an invalid date is provided', () => { + it('should throw an error', () => { + // Arrange + const dateSent = 'invalid-date'; + + // Act & Assert + expect(() => getFormattedDateSent(dateSent)).toThrow(); + }); + }); + }); + + describe('mapCompletedCorrectionsToViewModel', () => { + it('should return an empty array if no completed corrections are provided', () => { + // Arrange + const completedCorrections: GetCompletedFeeRecordCorrectionsResponseBody = []; + + // Act + const viewModel = mapCompletedCorrectionsToViewModel(completedCorrections); + + // Assert + expect(viewModel).toEqual([]); + }); + + it('should map completed corrections to view model', () => { + // Arrange + const firstCompletedCorrection = { + id: 1, + dateSent: new Date('2024-02-01').toISOString(), + exporter: 'Exporter A', + formattedReasons: 'Facility ID is incorrect', + formattedPreviousValues: '11111111', + formattedCorrectedValues: '22222222', + bankCommentary: 'Bank commentary A', + }; + + const secondCompletedCorrection = { + id: 2, + dateSent: new Date('2024-03-17').toISOString(), + exporter: 'Exporter B', + formattedReasons: 'Utilisation is incorrect', + formattedPreviousValues: '123.45', + formattedCorrectedValues: '987.65', + }; + + const completedCorrections: GetCompletedFeeRecordCorrectionsResponseBody = [firstCompletedCorrection, secondCompletedCorrection]; + + // Act + const viewModel = mapCompletedCorrectionsToViewModel(completedCorrections); + + // Assert + const expectedViewModel: CompletedCorrectionViewModel[] = [ + { + dateSent: { + formattedDateSent: '01 Feb 2024', + dataSortValue: 0, + }, + exporter: firstCompletedCorrection.exporter, + formattedReasons: firstCompletedCorrection.formattedReasons, + formattedPreviousValues: firstCompletedCorrection.formattedPreviousValues, + formattedCorrectedValues: firstCompletedCorrection.formattedCorrectedValues, + formattedBankCommentary: firstCompletedCorrection.bankCommentary, + }, + { + dateSent: { + formattedDateSent: '17 Mar 2024', + dataSortValue: 1, + }, + exporter: secondCompletedCorrection.exporter, + formattedReasons: secondCompletedCorrection.formattedReasons, + formattedPreviousValues: secondCompletedCorrection.formattedPreviousValues, + formattedCorrectedValues: secondCompletedCorrection.formattedCorrectedValues, + formattedBankCommentary: '-', + }, + ]; + + expect(viewModel).toEqual(expectedViewModel); + }); + + it('should map the completed correction "date sent" to a formatted date sent sorted by date ascending', () => { + // Arrange + const aCompletedCorrection = () => ({ + id: 1, + dateSent: new Date('2024-02-01').toISOString(), + exporter: 'An exporter', + formattedReasons: 'Other', + formattedPreviousValues: '-', + formattedCorrectedValues: '-', + formattedBankCommentary: 'Some bank commentary', + }); + + const completedCorrections: GetCompletedFeeRecordCorrectionsResponseBody = [ + { + ...aCompletedCorrection(), + id: 1, + dateSent: new Date('2024-06-01').toISOString(), // '01 Jun 2024', dataSortValue = 3 + }, + { + ...aCompletedCorrection(), + id: 2, + dateSent: new Date('2024-07-28').toISOString(), // '28 Jul 2024', dataSortValue = 5 + }, + { + ...aCompletedCorrection(), + id: 3, + dateSent: new Date('2024-03-14').toISOString(), // '14 Mar 2024', dataSortValue = 0 + }, + { + ...aCompletedCorrection(), + id: 4, + dateSent: new Date('2024-05-07').toISOString(), // '07 May 2024', dataSortValue = 2 + }, + { + ...aCompletedCorrection(), + id: 5, + dateSent: new Date('2024-04-01').toISOString(), // '01 Apr 2024', dataSortValue = 1 + }, + { + ...aCompletedCorrection(), + id: 6, + dateSent: new Date('2024-06-21').toISOString(), // '21 Jun 2024', dataSortValue = 4 + }, + ]; + + // Act + const viewModel = mapCompletedCorrectionsToViewModel(completedCorrections); + + // Assert + expect(viewModel).toHaveLength(6); + + expect(viewModel[0].dateSent).toEqual({ formattedDateSent: '01 Jun 2024', dataSortValue: 3 }); + expect(viewModel[1].dateSent).toEqual({ formattedDateSent: '28 Jul 2024', dataSortValue: 5 }); + expect(viewModel[2].dateSent).toEqual({ formattedDateSent: '14 Mar 2024', dataSortValue: 0 }); + expect(viewModel[3].dateSent).toEqual({ formattedDateSent: '07 May 2024', dataSortValue: 2 }); + expect(viewModel[4].dateSent).toEqual({ formattedDateSent: '01 Apr 2024', dataSortValue: 1 }); + expect(viewModel[5].dateSent).toEqual({ formattedDateSent: '21 Jun 2024', dataSortValue: 4 }); + }); + }); +}); diff --git a/portal/server/controllers/utilisation-report-service/record-correction/record-correction-log/helpers.ts b/portal/server/controllers/utilisation-report-service/record-correction/record-correction-log/helpers.ts new file mode 100644 index 0000000000..e39645fefe --- /dev/null +++ b/portal/server/controllers/utilisation-report-service/record-correction/record-correction-log/helpers.ts @@ -0,0 +1,45 @@ +import { DATE_FORMATS, getKeyToDateSortValueMap, IsoDateTimeStamp } from '@ukef/dtfs2-common'; +import { format, parseISO } from 'date-fns'; +import { CompletedCorrectionViewModel } from '../../../../types/view-models/record-correction/record-correction-log'; +import { GetCompletedFeeRecordCorrectionsResponseBody } from '../../../../api-response-types'; + +/** + * Formats the date sent + * @param dateSent - The date sent + * @returns The formatted date + * @example + * getFormattedDateSent('2024-01-01T12:30:00.000'); // '01 Jan 2024' + */ +export const getFormattedDateSent = (dateSent: IsoDateTimeStamp): string => format(parseISO(dateSent), DATE_FORMATS.DD_MMM_YYYY); + +/** + * Maps completed corrections to a view model. + * + * Formats the "date sent" timestamp for each completed correction. + * Generates a sort value map for the completed corrections "date sent" + * timestamp and uses this to determine a data sort value for each correction. + * This sort value is passed through the view model to be used for sorting. + * @param completedCorrections - The completed corrections response body. + * @returns An array of completed correction view models. + */ +export const mapCompletedCorrectionsToViewModel = (completedCorrections: GetCompletedFeeRecordCorrectionsResponseBody): CompletedCorrectionViewModel[] => { + const correctionIdToDateSentDataSortValueMap = getKeyToDateSortValueMap(completedCorrections.map(({ id, dateSent }) => ({ key: id, date: dateSent }))); + + return completedCorrections.map((correction) => { + const { exporter, formattedReasons, formattedPreviousValues, formattedCorrectedValues, bankCommentary } = correction; + + const formattedBankCommentary = bankCommentary ?? '-'; + + return { + dateSent: { + formattedDateSent: getFormattedDateSent(correction.dateSent), + dataSortValue: correctionIdToDateSentDataSortValueMap[correction.id], + }, + exporter, + formattedReasons, + formattedPreviousValues, + formattedCorrectedValues, + formattedBankCommentary, + }; + }); +}; diff --git a/portal/server/controllers/utilisation-report-service/record-correction/record-correction-log/index.test.ts b/portal/server/controllers/utilisation-report-service/record-correction/record-correction-log/index.test.ts new file mode 100644 index 0000000000..0d8c30cf5e --- /dev/null +++ b/portal/server/controllers/utilisation-report-service/record-correction/record-correction-log/index.test.ts @@ -0,0 +1,117 @@ +import httpMocks, { MockResponse } from 'node-mocks-http'; +import { Request, Response } from 'express'; +import { aPortalSessionBank, aPortalSessionUser, PORTAL_LOGIN_STATUS } from '@ukef/dtfs2-common'; +import { PRIMARY_NAV_KEY } from '../../../../constants'; +import api from '../../../../api'; +import { getRecordCorrectionLog } from '.'; +import { GetCompletedFeeRecordCorrectionsResponseBody } from '../../../../api-response-types'; +import { mapCompletedCorrectionsToViewModel } from './helpers'; +import { RecordCorrectionLogViewModel } from '../../../../types/view-models/record-correction/record-correction-log'; + +jest.mock('../../../../api'); + +console.error = jest.fn(); + +describe('controllers/utilisation-reports/record-corrections/record-correction-log', () => { + const bankId = '123'; + const userId = 'user-id'; + const mockUser = { + ...aPortalSessionUser(), + _id: userId, + bank: { + ...aPortalSessionBank(), + id: bankId, + }, + }; + + const userToken = 'token'; + const aRequestSession = () => ({ + user: mockUser, + userToken, + loginStatus: PORTAL_LOGIN_STATUS.VALID_2FA, + }); + + const getHttpMocks = () => + httpMocks.createMocks({ + session: aRequestSession(), + }); + + let req: Request; + let res: MockResponse; + + beforeEach(() => { + ({ req, res } = getHttpMocks()); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('getRecordCorrectionLog', () => { + it('should render the "record correction log" page', async () => { + // Arrange + const firstCompletedCorrection = { + id: 1, + dateSent: new Date('2024-02-01').toISOString(), + exporter: 'Exporter A', + formattedReasons: 'Facility ID is incorrect', + formattedPreviousValues: '11111111', + formattedCorrectedValues: '22222222', + bankCommentary: 'Bank commentary A', + }; + + const secondCompletedCorrection = { + id: 2, + dateSent: new Date('2024-03-17').toISOString(), + exporter: 'Exporter B', + formattedReasons: 'Utilisation is incorrect', + formattedPreviousValues: '123.45', + formattedCorrectedValues: '987.65', + }; + + const completedCorrectionsResponseBody: GetCompletedFeeRecordCorrectionsResponseBody = [firstCompletedCorrection, secondCompletedCorrection]; + + jest.mocked(api.getCompletedFeeRecordCorrections).mockResolvedValue(completedCorrectionsResponseBody); + + // Act + await getRecordCorrectionLog(req, res); + + // Assert + const expectedCompletedCorrections = mapCompletedCorrectionsToViewModel(completedCorrectionsResponseBody); + + const expectedResponse: RecordCorrectionLogViewModel = { + user: mockUser, + primaryNav: PRIMARY_NAV_KEY.RECORD_CORRECTION_LOG, + completedCorrections: expectedCompletedCorrections, + }; + + expect(res._getRenderView()).toEqual('utilisation-report-service/record-correction/correction-log.njk'); + + expect(res._getRenderData() as RecordCorrectionLogViewModel).toEqual(expectedResponse); + }); + + it('should fetch the completed fee record corrections using the users bank id', async () => { + // Arrange + jest.mocked(api.getCompletedFeeRecordCorrections).mockResolvedValue([]); + + // Act + await getRecordCorrectionLog(req, res); + + // Assert + expect(api.getCompletedFeeRecordCorrections).toHaveBeenCalledTimes(1); + expect(api.getCompletedFeeRecordCorrections).toHaveBeenCalledWith(userToken, bankId); + }); + + it('should render the "problem with service" page when fetching the completed fee record corrections fails', async () => { + // Arrange + jest.mocked(api.getCompletedFeeRecordCorrections).mockRejectedValue(new Error()); + + // Act + await getRecordCorrectionLog(req, res); + + // Assert + expect(res._getRenderView()).toEqual('_partials/problem-with-service.njk'); + expect(res._getRenderData()).toEqual({ user: mockUser }); + }); + }); +}); diff --git a/portal/server/controllers/utilisation-report-service/record-correction/record-correction-log/index.ts b/portal/server/controllers/utilisation-report-service/record-correction/record-correction-log/index.ts new file mode 100644 index 0000000000..4fb0c94ce1 --- /dev/null +++ b/portal/server/controllers/utilisation-report-service/record-correction/record-correction-log/index.ts @@ -0,0 +1,34 @@ +import { Request, Response } from 'express'; +import { asLoggedInUserSession } from '../../../../helpers/express-session'; +import { PRIMARY_NAV_KEY } from '../../../../constants'; +import api from '../../../../api'; +import { RecordCorrectionLogViewModel } from '../../../../types/view-models/record-correction/record-correction-log'; +import { mapCompletedCorrectionsToViewModel } from './helpers'; + +/** + * Controller for the GET record correction log route. + * @param req - The request object + * @param res - The response object + */ +export const getRecordCorrectionLog = async (req: Request, res: Response) => { + const { user, userToken } = asLoggedInUserSession(req.session); + + try { + const bankId = user.bank.id; + + const completedCorrections = await api.getCompletedFeeRecordCorrections(userToken, bankId); + + const mappedCompletedCorrections = mapCompletedCorrectionsToViewModel(completedCorrections); + + const viewModel: RecordCorrectionLogViewModel = { + user, + primaryNav: PRIMARY_NAV_KEY.RECORD_CORRECTION_LOG, + completedCorrections: mappedCompletedCorrections, + }; + + return res.render('utilisation-report-service/record-correction/correction-log.njk', viewModel); + } catch (error) { + console.error('Failed to get record correction log %o', error); + return res.render('_partials/problem-with-service.njk', { user }); + } +}; diff --git a/portal/server/nunjucks-configuration/index.js b/portal/server/nunjucks-configuration/index.js index cff4f19c3d..78805e6b47 100644 --- a/portal/server/nunjucks-configuration/index.js +++ b/portal/server/nunjucks-configuration/index.js @@ -13,7 +13,7 @@ const replaceWhiteSpaceWithDash = require('./filter-replaceWhiteSpaceWithDash'); dotenv.config(); const configureNunjucks = (opts) => { - const { CONTACT_US_EMAIL_ADDRESS } = process.env; + const { CONTACT_US_EMAIL_ADDRESS, FF_FEE_RECORD_CORRECTION_ENABLED } = process.env; const appViews = [ path.resolve(__dirname, '../../../node_modules/govuk-frontend/dist'), @@ -24,6 +24,7 @@ const configureNunjucks = (opts) => { const nunjucksEnvironment = nunjucks.configure(appViews, opts); nunjucksEnvironment.addGlobal('CONTACT_US_EMAIL_ADDRESS', CONTACT_US_EMAIL_ADDRESS); + nunjucksEnvironment.addGlobal('FF_FEE_RECORD_CORRECTION_ENABLED', FF_FEE_RECORD_CORRECTION_ENABLED); nunjucksEnvironment.addFilter('localiseTimestamp', filterLocaliseTimestamp); nunjucksEnvironment.addFilter('dashIfEmpty', dashIfEmpty); diff --git a/portal/server/routes/utilisation-report-service/record-correction/index.js b/portal/server/routes/utilisation-report-service/record-correction/index.js index 97e5df2062..3b450efdb4 100644 --- a/portal/server/routes/utilisation-report-service/record-correction/index.js +++ b/portal/server/routes/utilisation-report-service/record-correction/index.js @@ -7,6 +7,7 @@ const { postUtilisationReportCorrectionReview, getRecordCorrectionSent, cancelUtilisationReportCorrection, + getRecordCorrectionLog, } = require('../../../controllers/utilisation-report-service/record-correction'); const { validateRole, validateToken, validateSqlId } = require('../../middleware'); @@ -37,4 +38,10 @@ router.get( (req, res) => getRecordCorrectionSent(req, res), ); +router.get( + '/utilisation-reports/correction-log', + [validateFeeRecordCorrectionFeatureFlagIsEnabled, validateToken, validateRole({ role: [ROLES.PAYMENT_REPORT_OFFICER] })], + (req, res) => getRecordCorrectionLog(req, res), +); + module.exports = router; diff --git a/portal/server/types/view-models/record-correction/record-correction-log.ts b/portal/server/types/view-models/record-correction/record-correction-log.ts new file mode 100644 index 0000000000..3c6e5399e0 --- /dev/null +++ b/portal/server/types/view-models/record-correction/record-correction-log.ts @@ -0,0 +1,17 @@ +import { BaseViewModel } from '../base-view-model'; + +export type CompletedCorrectionViewModel = { + dateSent: { + formattedDateSent: string; + dataSortValue: number; + }; + exporter: string; + formattedReasons: string; + formattedPreviousValues: string; + formattedCorrectedValues: string; + formattedBankCommentary: string; +}; + +export type RecordCorrectionLogViewModel = BaseViewModel & { + completedCorrections: CompletedCorrectionViewModel[]; +}; diff --git a/portal/templates/_partials/primary-navigation.njk b/portal/templates/_partials/primary-navigation.njk index 85816f9094..ad40f89e46 100644 --- a/portal/templates/_partials/primary-navigation.njk +++ b/portal/templates/_partials/primary-navigation.njk @@ -46,6 +46,14 @@ "data-cy": "previous_reports" } } %} + {% set recordCorrectionLogNavItem = { + text: "Record correction log", + href: "/utilisation-reports/correction-log", + active: primaryNav == "record_correction_log", + attributes: { + "data-cy": "record_correction_log" + } + } %} {% set usersNavItem = { text: "Users", href: "/admin/users", @@ -67,6 +75,10 @@ {% if isPaymentReportOfficer %} {% set navItems = (navItems.push(reportGefUtilisationAndFeesNavItem, previousGefReportsNavItem), navItems) %} + + {% if FF_FEE_RECORD_CORRECTION_ENABLED === "true" %} + {% set navItems = (navItems.push(recordCorrectionLogNavItem), navItems) %} + {% endif %} {% endif %} {% if isAdmin %} diff --git a/portal/templates/utilisation-report-service/record-correction/_macros/correction-log-table.njk b/portal/templates/utilisation-report-service/record-correction/_macros/correction-log-table.njk new file mode 100644 index 0000000000..dd666243a8 --- /dev/null +++ b/portal/templates/utilisation-report-service/record-correction/_macros/correction-log-table.njk @@ -0,0 +1,93 @@ +{% from "govuk/components/table/macro.njk" import govukTable %} + +{% macro render(params) %} + {% set completedCorrections = params.completedCorrections %} + + {% set rows = [] %} + + {% for correction in completedCorrections %} + {% set correctionRow = [ + { + text: correction.dateSent.formattedDateSent, + attributes: { + "data-sort-value": correction.dateSent.dataSortValue, + "data-cy": "correction-log-row--date-sent" + } + }, + { + text: correction.exporter, + attributes: { + "data-cy": "correction-log-row--exporter" + } + }, + { + text: correction.formattedReasons, + attributes: { + "data-cy": "correction-log-row--formatted-reasons" + } + }, + { + text: correction.formattedCorrectedValues, + attributes: { + "data-cy": "correction-log-row--formatted-corrected-values" + } + }, + { + text: correction.formattedPreviousValues, + attributes: { + "data-cy": "correction-log-row--formatted-previous-values" + } + }, + { + text: correction.formattedBankCommentary, + attributes: { + "data-cy": "correction-log-row--bank-commentary" + } + } + ] %} + + {% set rows = (rows.push(correctionRow), rows) %} + {% endfor %} + + {{ govukTable({ + firstCellIsHeader: false, + head: [ + { + text: "Date sent", + attributes: { + "aria-sort": "descending", + "data-cy": "correction-log-header--date-sent" + } + }, + { + text: "Exporter", + attributes: { + "aria-sort": "ascending", + "data-cy": "correction-log-header--exporter" + } + }, + { + text: "Reason(s) for correction", + "data-cy": "correction-log-header--formatted-reasons" + }, + { + text: "Correct record", + "data-cy": "correction-log-header--formatted-corrected-values" + }, + { + text: "Old record", + "data-cy": "correction-log-header--formatted-previous-values" + }, + { + text: "Correction notes", + "data-cy": "correction-log-header--bank-commentary" + } + ], + rows: rows, + attributes: { + "data-module": "moj-sortable-table", + "data-cy": "correction-log-table" + } + }) }} + +{% endmacro %} diff --git a/portal/templates/utilisation-report-service/record-correction/correction-log.njk b/portal/templates/utilisation-report-service/record-correction/correction-log.njk new file mode 100644 index 0000000000..4a3a264df9 --- /dev/null +++ b/portal/templates/utilisation-report-service/record-correction/correction-log.njk @@ -0,0 +1,44 @@ +{% extends "index.njk" %} +{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/warning-text/macro.njk" import govukWarningText %} + +{% import "./_macros/correction-log-table.njk" as correctionLogTable %} + +{% block pageTitle %} + Record correction log +{% endblock %} + +{% block content %} + +

+ Record correction log +

+ + {% if completedCorrections.length === 0 %} + +

+ There are no previous record correction requests. +

+ +

+ Records will be automatically added to this page once they have been sent back to UKEF. +

+ + {% else %} + +

+ Previous record correction requests are shown below. +

+ +

+ Records are automatically added to this page once they have been sent back to UKEF. +

+ + {{ correctionLogTable.render({ + completedCorrections: completedCorrections + }) }} + + {% endif %} + +{% endblock %} \ No newline at end of file diff --git a/trade-finance-manager-ui/server/api.js b/trade-finance-manager-ui/server/api.js index 72aad6d0ec..b8b93df96f 100644 --- a/trade-finance-manager-ui/server/api.js +++ b/trade-finance-manager-ui/server/api.js @@ -96,7 +96,7 @@ const getDeals = async (queryParams, token) => { }); const { deals, pagination } = response.data; - if (queryParams.page >= pagination.totalPages) { + if (queryParams.page >= pagination?.totalPages) { throw new PageOutOfBoundsError('Requested page number exceeds the maximum page number'); } diff --git a/trade-finance-manager-ui/server/controllers/utilisation-reports/helpers/reconciliation-for-report-helper.ts b/trade-finance-manager-ui/server/controllers/utilisation-reports/helpers/reconciliation-for-report-helper.ts index 0914f8bf9d..6060003041 100644 --- a/trade-finance-manager-ui/server/controllers/utilisation-reports/helpers/reconciliation-for-report-helper.ts +++ b/trade-finance-manager-ui/server/controllers/utilisation-reports/helpers/reconciliation-for-report-helper.ts @@ -4,6 +4,7 @@ import { FeeRecordStatus, getFormattedCurrencyAndAmount, getFormattedMonetaryValue, + getKeyToDateSortValueMap, IsoDateTimeStamp, KeyingSheetAdjustment, mapCurrenciesToRadioItems, @@ -27,7 +28,6 @@ import { PremiumPaymentsTableCheckboxId } from '../../../types/premium-payments- import { getFeeRecordDisplayStatus } from './get-fee-record-display-status'; import { getKeyingSheetDisplayStatus } from './get-keying-sheet-display-status'; import { KeyingSheetCheckboxId } from '../../../types/keying-sheet-checkbox-id'; -import { getKeyToDateSortValueMap } from './get-key-to-date-sort-value-map-helper'; /** * Sort fee records by reported payments