Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/connectors-lib/src/__tests__/govuk-pay-api.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
})
18 changes: 18 additions & 0 deletions packages/connectors-lib/src/govuk-pay-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {} })),
Expand All @@ -70,7 +71,8 @@ jest.mock('@defra-fish/connectors-lib', () => ({
}
})),
govUkPayApi: {
getRecurringPaymentAgreementInformation: jest.fn()
getRecurringPaymentAgreementInformation: jest.fn(),
cancelRecurringPaymentAgreement: jest.fn()
}
}))

Expand Down Expand Up @@ -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())
Expand All @@ -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: {
Expand All @@ -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: {
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
findDueRecurringPayments,
findRecurringPaymentsByAgreementId,
persist,
Permission,
RecurringPayment,
findRecurringPaymentByPermissionId,
retrieveGlobalOptionSets
Expand All @@ -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()

Expand Down Expand Up @@ -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(' ')
Expand Down
Loading