Skip to content

Cancel agreement via Sales API#2358

Open
lailien3 wants to merge 5 commits into
developfrom
feature/iwtf-5037-cancel-agreement-cia-sales-api
Open

Cancel agreement via Sales API#2358
lailien3 wants to merge 5 commits into
developfrom
feature/iwtf-5037-cancel-agreement-cia-sales-api

Conversation

@lailien3
Copy link
Copy Markdown
Contributor

@lailien3 lailien3 commented Apr 8, 2026

https://eaflood.atlassian.net/browse/IWTF-5037

We want to notify GovPay when an agreement is cancelled so that we’re not relying on the CRM for this logic

lailien3 added 5 commits April 8, 2026 13:56
https://eaflood.atlassian.net/browse/IWTF-5037

We want to notify GovPay when an agreement is cancelled

So that we’re not relying on the CRM for this logic

It’s done when

we don’t have special handling in packages/sales-api-service/src/services/recurring-payments.service.js for omitting the cancellation date when it’s user cancelled

cancellation is initiated by a POST from the Sales API to GovPay API unless by refund using the 3-step CRM process

all possible responses from Gov Pay are handled appropriately
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented May 7, 2026


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')
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a test to make sure this validates now?

throw new Error('cancel error')
})
await expect(govUkPayApi.cancelRecurringPaymentAgreement(123)).rejects.toEqual(Error('cancel error'))
expect(consoleErrorSpy).toHaveBeenCalled()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would prefer to confirm what error message the spy has been called with

return null
}

const cancelGovPayAgreement = async agreementId => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suspect this is just me being a massive pedant, but it's GOV.UK Pay, not GovPay - I know we've been a bit inconsistent about this, but I'd prefer to use the full name, especially if the connector is called govUkPayApi. Same goes for various console logs, test names, etc

entitiesToPersist.push(linkedPermission)
}

await persist(entitiesToPersist)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wondering what kind of logging/error handling we have if the cancellation fails at this stage, since would be awkward to have a situation where it's cancelled on GOV.UK Pay but not in the CRM. I think it'll go just into the queue and try to save again later, but might be worth doing some testing around it.

expect(govUkPayApi.cancelRecurringPaymentAgreement).not.toHaveBeenCalled()
})

it('should not throw when GovPay returns 404 (agreement already cancelled)', async () => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it really return a 404 if the agreement has already been cancelled? Seems odd that we wouldn't be able to distinguish between a cancelled agreement and the agreement ID just being wrong

expect(govUkPayApi.cancelRecurringPaymentAgreement).toHaveBeenCalledWith(recurringPayment.agreementId)
})

it('should not call cancelRecurringPaymentAgreement on GovPay when agreementId does not exist', async () => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense to me - but also makes me think that if we aren't doing the cancellation in GOV.UK Pay, we probably shouldn't be doing the rest of the cancellation stuff either. So maybe this should be a condition that just throws an error for all of cancelRecurringPayment() instead

await expect(cancelRecurringPayment('id', 'User Cancelled')).resolves.toBeDefined()
})

it('should still persist to CRM when GovPay returns 400', async () => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to do this? I'm concerned this could mean the CRM would show a record as cancelled when the agreement might still be active in GOV.UK Pay (although then I guess we would still not be requesting the payment when it would have been due, so maybe it's the same result). How does the CRM handle this situation currently?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same goes for handling the 404 response above

govUkPayApi.cancelRecurringPaymentAgreement.mockResolvedValueOnce({ ok: false, status: 400, statusText: 'Bad Request' })

await cancelRecurringPayment('id', 'User Cancelled')
expect(persist).toHaveBeenCalled()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we check what persist() is being called with?

govUkPayApi.cancelRecurringPaymentAgreement.mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found' })

await cancelRecurringPayment('id', 'User Cancelled')
expect(persist).toHaveBeenCalled()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we check what persist() is being called with?

Comment on lines +200 to +204
const record = await dynamicsClient.retrieveRequest({
key: recurringPaymentId,
collection: RecurringPayment.definition.dynamicsCollection,
select: ['_defra_activepermission_value']
})
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usually we define these queries in dynamics-lib, then call them from the applications as needed through the dynamicsClient. Have a look at findLinkedRecurringPayment() further down in this file for an example of the pattern in action.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants