diff --git a/packages/connectors-lib/src/__tests__/govuk-pay-api.spec.js b/packages/connectors-lib/src/__tests__/govuk-pay-api.spec.js index 22a37f6e94..a043bbe4be 100644 --- a/packages/connectors-lib/src/__tests__/govuk-pay-api.spec.js +++ b/packages/connectors-lib/src/__tests__/govuk-pay-api.spec.js @@ -203,4 +203,30 @@ describe('govuk-pay-api-connector', () => { expect(consoleErrorSpy).toHaveBeenCalled() }) }) + + describe('cancelRecurringPaymentAgreement', () => { + it('cancels a recurring payment agreement', async () => { + fetch.mockReturnValueOnce({ ok: true, status: 204 }) + await expect(govUkPayApi.cancelRecurringPaymentAgreement(123)).resolves.toEqual(expect.objectContaining({ ok: true, status: 204 })) + }) + + it('calls the correct endpoint with recurring headers', async () => { + fetch.mockReturnValueOnce({ ok: true, status: 204 }) + await govUkPayApi.cancelRecurringPaymentAgreement(123) + expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/agreement/123/cancel', { + headers: recurringHeaders, + method: 'post', + timeout: 10000 + }) + }) + + it('logs and throws errors', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn()) + fetch.mockImplementation(() => { + throw new Error('cancel error') + }) + await expect(govUkPayApi.cancelRecurringPaymentAgreement(123)).rejects.toEqual(Error('cancel error')) + expect(consoleErrorSpy).toHaveBeenCalled() + }) + }) }) diff --git a/packages/connectors-lib/src/govuk-pay-api.js b/packages/connectors-lib/src/govuk-pay-api.js index 1274ba2d5e..0c8f3ed585 100644 --- a/packages/connectors-lib/src/govuk-pay-api.js +++ b/packages/connectors-lib/src/govuk-pay-api.js @@ -113,3 +113,21 @@ export const getRecurringPaymentAgreementInformation = async agreementId => { throw err } } + +/** + * Cancel a recurring payment agreement in GOV.UK Pay + * @param agreementId - the agreement to cancel + * @returns {Promise<*>} + */ +export const cancelRecurringPaymentAgreement = async agreementId => { + try { + return fetch(`${process.env.GOV_PAY_RCP_API_URL}/${agreementId}/cancel`, { + headers: headers(true), + method: 'post', + timeout: process.env.GOV_PAY_REQUEST_TIMEOUT_MS || GOV_PAY_REQUEST_TIMEOUT_MS_DEFAULT + }) + } catch (err) { + console.error(`Error cancelling recurring payment agreement in the GOV.UK API service - agreementId: ${agreementId}`, err) + throw err + } +} diff --git a/packages/sales-api-service/src/schema/recurring-payments.schema.js b/packages/sales-api-service/src/schema/recurring-payments.schema.js index 1e5e9ab03b..5bac35b64e 100644 --- a/packages/sales-api-service/src/schema/recurring-payments.schema.js +++ b/packages/sales-api-service/src/schema/recurring-payments.schema.js @@ -35,5 +35,5 @@ export const cancelRecurringPaymentRequestParamsSchema = Joi.object({ }) export const cancelRecurringPaymentRequestQuerySchema = Joi.object({ - reason: Joi.string().required().valid('Payment Failure', 'User Cancelled') + reason: Joi.string().required().valid('Payment Failure', 'User Cancelled', 'Business Cancelled') }) diff --git a/packages/sales-api-service/src/services/__tests__/recurring-payments.service.spec.js b/packages/sales-api-service/src/services/__tests__/recurring-payments.service.spec.js index f6e6f8e797..4a09898206 100644 --- a/packages/sales-api-service/src/services/__tests__/recurring-payments.service.spec.js +++ b/packages/sales-api-service/src/services/__tests__/recurring-payments.service.spec.js @@ -52,7 +52,8 @@ jest.mock('@defra-fish/dynamics-lib', () => ({ findDueRecurringPayments: jest.fn(), findRecurringPaymentsByAgreementId: jest.fn(() => ({ toRetrieveRequest: () => {} })), dynamicsClient: { - retrieveMultipleRequest: jest.fn(() => ({ value: [] })) + retrieveMultipleRequest: jest.fn(() => ({ value: [] })), + retrieveRequest: jest.fn(() => ({ _defra_activepermission_value: 'mock-permission-id' })) }, persist: jest.fn(), findRecurringPaymentByPermissionId: jest.fn(() => ({ toRetrieveRequest: () => {} })), @@ -70,7 +71,8 @@ jest.mock('@defra-fish/connectors-lib', () => ({ } })), govUkPayApi: { - getRecurringPaymentAgreementInformation: jest.fn() + getRecurringPaymentAgreementInformation: jest.fn(), + cancelRecurringPaymentAgreement: jest.fn() } })) @@ -895,6 +897,18 @@ describe('recurring payments service', () => { }) describe('cancelRecurringPayment', () => { + const mockPermission = new Permission() + mockPermission.isRecurringPayment = true + + beforeEach(() => { + govUkPayApi.cancelRecurringPaymentAgreement.mockResolvedValue({ ok: true, status: 204 }) + dynamicsClient.retrieveRequest.mockResolvedValue({ _defra_activepermission_value: 'mock-permission-id' }) + findById.mockImplementation(entityType => { + if (entityType === Permission) return mockPermission + return null + }) + }) + it('should call findById with RecurringPayment and the provided id', async () => { retrieveGlobalOptionSets.mockReturnValueOnce({ cached: jest.fn().mockResolvedValue({ definition: 'mock-def' }) }) findById.mockReturnValueOnce(getMockRecurringPayment()) @@ -911,7 +925,7 @@ describe('recurring payments service', () => { expect(getGlobalOptionSetValue).toHaveBeenCalledWith(RecurringPayment.definition.mappings.cancelledReason.ref, reason) }) - it('should set cancelledDate when reason is not User Cancelled and call persist with the updated RecurringPayment', async () => { + it('should set cancelledDate when reason is Payment Failure and call persist with the updated RecurringPayment', async () => { retrieveGlobalOptionSets.mockReturnValueOnce({ cached: jest.fn().mockResolvedValue({ defra_cancelledreasons: { @@ -938,11 +952,12 @@ describe('recurring payments service', () => { ...recurringPayment, cancelledReason, cancelledDate: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) - }) + }), + mockPermission ]) }) - it('should not set cancelledDate when reason is User Cancelled', async () => { + it('should set cancelledDate when reason is User Cancelled', async () => { retrieveGlobalOptionSets.mockReturnValueOnce({ cached: jest.fn().mockResolvedValue({ defra_cancelledreasons: { @@ -970,11 +985,128 @@ describe('recurring payments service', () => { expect.objectContaining({ ...recurringPayment, cancelledReason, - cancelledDate: null - }) + cancelledDate: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) + }), + mockPermission ]) }) + it('should call cancelRecurringPaymentAgreement on GovPay when agreementId exists', async () => { + const recurringPayment = getMockRecurringPayment() + findById.mockReturnValueOnce(recurringPayment) + + await cancelRecurringPayment('id', 'User Cancelled') + + expect(govUkPayApi.cancelRecurringPaymentAgreement).toHaveBeenCalledWith(recurringPayment.agreementId) + }) + + it('should not call cancelRecurringPaymentAgreement on GovPay when agreementId does not exist', async () => { + const recurringPayment = getMockRecurringPayment({ agreementId: undefined }) + findById.mockReturnValueOnce(recurringPayment) + + await cancelRecurringPayment('id', 'Payment Failure') + + expect(govUkPayApi.cancelRecurringPaymentAgreement).not.toHaveBeenCalled() + }) + + it('should not throw when GovPay returns 404 (agreement already cancelled)', async () => { + const recurringPayment = getMockRecurringPayment() + findById.mockReturnValueOnce(recurringPayment) + govUkPayApi.cancelRecurringPaymentAgreement.mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found' }) + + await expect(cancelRecurringPayment('id', 'User Cancelled')).resolves.toBeDefined() + }) + + it('should still persist to CRM when GovPay returns 404', async () => { + const recurringPayment = getMockRecurringPayment() + findById.mockReturnValueOnce(recurringPayment) + govUkPayApi.cancelRecurringPaymentAgreement.mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found' }) + + await cancelRecurringPayment('id', 'User Cancelled') + expect(persist).toHaveBeenCalled() + }) + + it('should not throw when GovPay returns 400 (agreement in invalid state)', async () => { + const recurringPayment = getMockRecurringPayment() + findById.mockReturnValueOnce(recurringPayment) + govUkPayApi.cancelRecurringPaymentAgreement.mockResolvedValueOnce({ ok: false, status: 400, statusText: 'Bad Request' }) + + await expect(cancelRecurringPayment('id', 'User Cancelled')).resolves.toBeDefined() + }) + + it('should still persist to CRM when GovPay returns 400', async () => { + const recurringPayment = getMockRecurringPayment() + findById.mockReturnValueOnce(recurringPayment) + govUkPayApi.cancelRecurringPaymentAgreement.mockResolvedValueOnce({ ok: false, status: 400, statusText: 'Bad Request' }) + + await cancelRecurringPayment('id', 'User Cancelled') + expect(persist).toHaveBeenCalled() + }) + + it('should throw when GovPay returns an unexpected error', async () => { + const recurringPayment = getMockRecurringPayment() + findById.mockReturnValueOnce(recurringPayment) + govUkPayApi.cancelRecurringPaymentAgreement.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: jest.fn().mockResolvedValue('Server error') + }) + + await expect(cancelRecurringPayment('id', 'User Cancelled')).rejects.toThrow('Failed to cancel GovPay agreement') + }) + + it('should set isRecurringPayment to false on the linked permission', async () => { + const recurringPayment = getMockRecurringPayment() + findById.mockReturnValueOnce(recurringPayment) + + await cancelRecurringPayment('id', 'User Cancelled') + + expect(mockPermission.isRecurringPayment).toBe(false) + }) + + it('should persist the linked permission alongside the recurring payment', async () => { + const recurringPayment = getMockRecurringPayment() + findById.mockReturnValueOnce(recurringPayment) + + await cancelRecurringPayment('id', 'User Cancelled') + + expect(persist).toHaveBeenCalledWith([expect.any(RecurringPayment), mockPermission]) + }) + + it('should query dynamics for the active permission lookup value', async () => { + const recurringPayment = getMockRecurringPayment() + findById.mockReturnValueOnce(recurringPayment) + + await cancelRecurringPayment('id', 'User Cancelled') + + expect(dynamicsClient.retrieveRequest).toHaveBeenCalledWith({ + key: 'id', + collection: RecurringPayment.definition.dynamicsCollection, + select: ['_defra_activepermission_value'] + }) + }) + + it('should look up the permission by the id returned from dynamics', async () => { + const recurringPayment = getMockRecurringPayment() + findById.mockReturnValueOnce(recurringPayment) + dynamicsClient.retrieveRequest.mockResolvedValueOnce({ _defra_activepermission_value: 'specific-permission-id' }) + + await cancelRecurringPayment('id', 'User Cancelled') + + expect(findById).toHaveBeenCalledWith(Permission, 'specific-permission-id') + }) + + it('should still persist when no linked permission is found', async () => { + const recurringPayment = getMockRecurringPayment() + findById.mockReturnValueOnce(recurringPayment) + dynamicsClient.retrieveRequest.mockResolvedValueOnce({ _defra_activepermission_value: null }) + + await cancelRecurringPayment('id', 'User Cancelled') + + expect(persist).toHaveBeenCalledWith([expect.any(RecurringPayment)]) + }) + it('should raise an error when there are no matches', async () => { findById.mockReturnValueOnce(undefined) diff --git a/packages/sales-api-service/src/services/recurring-payments.service.js b/packages/sales-api-service/src/services/recurring-payments.service.js index 83003db82a..177ec8ade8 100644 --- a/packages/sales-api-service/src/services/recurring-payments.service.js +++ b/packages/sales-api-service/src/services/recurring-payments.service.js @@ -5,6 +5,7 @@ import { findDueRecurringPayments, findRecurringPaymentsByAgreementId, persist, + Permission, RecurringPayment, findRecurringPaymentByPermissionId, retrieveGlobalOptionSets @@ -21,6 +22,7 @@ import { getGlobalOptionSetValue } from './reference-data.service.js' import moment from 'moment' import { AWS, govUkPayApi } from '@defra-fish/connectors-lib' import db from 'debug' +import { StatusCodes } from 'http-status-codes' const debug = db('sales:recurring') const { sqs, docClient } = AWS() @@ -170,21 +172,57 @@ export const cancelRecurringPayment = async (id, reason) => { const recurringPayment = await findById(RecurringPayment, id) if (recurringPayment) { const data = recurringPayment - const isUserCancelled = reason === 'User Cancelled' - if (!isUserCancelled) { - data.cancelledDate = new Date().toISOString().split('T')[0] + data.cancelledDate = new Date().toISOString().split('T')[0] + data.cancelledReason = await getGlobalOptionSetValue(RecurringPayment.definition.mappings.cancelledReason.ref, reason) + + if (data.agreementId) { + await cancelGovPayAgreement(data.agreementId) } - data.cancelledReason = await getGlobalOptionSetValue(RecurringPayment.definition.mappings.cancelledReason.ref, reason) const updatedRecurringPayment = Object.assign(new RecurringPayment(), data) - await persist([updatedRecurringPayment]) + const entitiesToPersist = [updatedRecurringPayment] + + const linkedPermission = await getLinkedPermission(id) + if (linkedPermission) { + linkedPermission.isRecurringPayment = false + entitiesToPersist.push(linkedPermission) + } + + await persist(entitiesToPersist) return updatedRecurringPayment } else { throw new Error('Invalid id provided for recurring payment cancellation') } } +const getLinkedPermission = async recurringPaymentId => { + const record = await dynamicsClient.retrieveRequest({ + key: recurringPaymentId, + collection: RecurringPayment.definition.dynamicsCollection, + select: ['_defra_activepermission_value'] + }) + const permissionId = record._defra_activepermission_value + if (permissionId) { + return findById(Permission, permissionId) + } + return null +} + +const cancelGovPayAgreement = async agreementId => { + const response = await govUkPayApi.cancelRecurringPaymentAgreement(agreementId) + if (response.ok) { + debug('Successfully cancelled GovPay agreement: %s', agreementId) + } else if (response.status === StatusCodes.NOT_FOUND) { + debug('GovPay agreement not found (already cancelled or does not exist): %s', agreementId) + } else if (response.status === StatusCodes.BAD_REQUEST) { + debug('GovPay agreement cannot be cancelled (invalid state): %s', agreementId) + } else { + const body = await response.text().catch(() => 'Unable to read response body') + throw new Error(`Failed to cancel GovPay agreement ${agreementId}: ${response.status} ${response.statusText} - ${body}`) + } +} + const determineRecurringPaymentName = (transactionRecord, contact) => { const [dueYear] = transactionRecord.payment.recurring.nextDueDate.split('-') return [contact.firstName, contact.lastName, dueYear].join(' ')