@defra-fish/recurring-payments-job
Version:
Rod Licensing Recurring Payments Job
1,120 lines (931 loc) • 40.5 kB
JavaScript
import { airbrake, salesApi } from '@defra-fish/connectors-lib'
import { PAYMENT_STATUS, PAYMENT_JOURNAL_STATUS_CODES } from '@defra-fish/business-rules-lib'
import { execute } from '../recurring-payments-processor.js'
import { getPaymentStatus, isGovPayUp, sendPayment } from '../services/govuk-pay-service.js'
import db from 'debug'
jest.mock('@defra-fish/business-rules-lib', () => ({
PAYMENT_STATUS: {
Success: 'payment status success',
Failure: 'payment status failure',
Error: 'payment status error'
},
PAYMENT_JOURNAL_STATUS_CODES: {
InProgress: 'in progress payment',
Cancelled: 'cancelled payment',
Failed: 'failed payment',
Expired: 'expired payment',
Completed: 'completed payment'
}
}))
jest.mock('@defra-fish/connectors-lib', () => ({
airbrake: {
initialise: jest.fn(),
flush: jest.fn()
},
salesApi: {
cancelRecurringPayment: jest.fn(),
createPaymentJournal: jest.fn(),
createTransaction: jest.fn(() => ({
id: 'test-transaction-id',
cost: 30,
recurringPayment: {
id: 'recurring-payment-1'
}
})),
getDueRecurringPayments: jest.fn(() => []),
getPaymentJournal: jest.fn(),
preparePermissionDataForRenewal: jest.fn(() => ({
licensee: { countryCode: 'GB-ENG' }
})),
processRPResult: jest.fn(),
updatePaymentJournal: jest.fn()
}
}))
jest.mock('../services/govuk-pay-service.js', () => ({
sendPayment: jest.fn(() => ({ payment_id: 'payment_id', created_date: '2025-07-18T09:00:00.000Z' })),
getPaymentStatus: jest.fn(),
isGovPayUp: jest.fn(() => true)
}))
jest.mock('debug', () => jest.fn(() => jest.fn()))
const PAYMENT_STATUS_DELAY = 60000
const getPaymentStatusSuccess = () => ({ state: { status: 'payment status success' } })
const getPaymentStatusFailure = () => ({ state: { status: 'payment status failure' } })
const getPaymentStatusError = () => ({ state: { status: 'payment status error' } })
const getMockPaymentRequestResponse = () => [
{
entity: { agreementId: 'agreement-1' },
expanded: {
activePermission: {
entity: {
referenceNumber: 'ref-1'
}
}
}
}
]
const getMockDueRecurringPayment = ({ agreementId = 'test-agreement-id', id = 'abc-123', referenceNumber = '123' } = {}) => ({
entity: { id, agreementId },
expanded: { activePermission: { entity: { referenceNumber } } }
})
// eslint-disable-next-line camelcase
const getMockSendPaymentResponse = ({ payment_id = 'pay-1', agreementId = 'agr-1', created_date = '2025-01-01T00:00:00.000Z' } = {}) => ({
payment_id,
agreementId,
created_date
})
describe('recurring-payments-processor', () => {
const [{ value: debugLogger }] = db.mock.results
beforeEach(() => {
jest.clearAllMocks()
process.env.RUN_RECURRING_PAYMENTS = 'true'
global.setTimeout = jest.fn((cb, ms) => cb())
})
it('initialises airbrake', () => {
jest.isolateModules(async () => {
require('../recurring-payments-processor.js')
await execute()
expect(airbrake.initialise).toHaveBeenCalled()
})
})
it('flushes airbrake before script ends', () => {
jest.isolateModules(async () => {
const { execute } = require('../recurring-payments-processor.js')
await execute()
expect(airbrake.flush).toHaveBeenCalled()
})
})
it("doesn't flush airbrake before execute has been called", () => {
jest.isolateModules(() => {
require('../recurring-payments-processor.js')
expect(airbrake.flush).not.toHaveBeenCalled()
})
})
it.each([
['SIGINT', 130],
['SIGTERM', 137]
])('flushes airbrake on %s signal', (signal, code) => {
jest.isolateModules(() => {
// setup a delay so script doesn't call processRecurringPayments and exit naturally
process.env.RECURRING_PAYMENTS_LOCAL_DELAY = '1'
const signalCallbacks = {}
jest.spyOn(process, 'on')
jest.spyOn(process, 'exit')
process.on.mockImplementation((signalToken, callback) => {
signalCallbacks[signalToken] = callback
})
process.exit.mockImplementation(() => {
// so we don't crash out of the tests!
})
require('../recurring-payments-processor.js')
signalCallbacks[signal]()
expect(airbrake.flush).toHaveBeenCalled()
process.on.mockRestore()
process.exit.mockRestore()
})
})
it.each([
['SIGINT', 130],
['SIGTERM', 137]
])('calls process.exit on %s signal with %i code', (signal, code) => {
jest.isolateModules(() => {
const signalCallbacks = {}
jest.spyOn(process, 'on')
jest.spyOn(process, 'exit')
process.on.mockImplementation((signalToken, callback) => {
signalCallbacks[signalToken] = callback
})
process.exit.mockImplementation(() => {
// so we don't crash out of the tests!
})
require('../recurring-payments-job.js')
signalCallbacks[signal]()
expect(process.exit).toHaveBeenCalledWith(code)
process.on.mockRestore()
process.exit.mockRestore()
})
})
it('debug log displays "Recurring Payments job disabled" when env is false', async () => {
process.env.RUN_RECURRING_PAYMENTS = 'false'
await execute()
expect(debugLogger).toHaveBeenCalledWith('Recurring Payments job disabled')
})
it('debug log displays "Recurring Payments job enabled" when env is true', async () => {
await execute()
expect(debugLogger).toHaveBeenCalledWith('Recurring Payments job enabled')
})
it('logs console error if Gov.UK Pay is not healthy', async () => {
jest.spyOn(console, 'error')
isGovPayUp.mockResolvedValueOnce(false)
await execute()
expect(console.error).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Run aborted, Gov.UK Pay health endpoint is reporting problems.'
})
)
console.error.mockReset()
})
it('get recurring payments is called when env is true', async () => {
const date = new Date().toISOString().split('T')[0]
await execute()
expect(salesApi.getDueRecurringPayments).toHaveBeenCalledWith(date)
})
it('debug log displays "Recurring Payments found:" when env is true', async () => {
await execute()
expect(debugLogger).toHaveBeenNthCalledWith(2, 'Recurring Payments found:', [])
})
describe('When RP fetch throws an error...', () => {
it('calls console.error with error message', async () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
const error = new Error('Test error')
salesApi.getDueRecurringPayments.mockImplementationOnce(() => {
throw error
})
try {
await execute()
} catch {}
expect(errorSpy).toHaveBeenCalledWith('Run aborted. Error fetching due recurring payments:', error)
})
})
describe('When payment request throws an error...', () => {
it('console.error is called with error message', async () => {
jest.spyOn(console, 'error')
salesApi.getDueRecurringPayments.mockReturnValueOnce(getMockPaymentRequestResponse())
const oopsie = new Error('payment gate down')
sendPayment.mockRejectedValueOnce(oopsie)
try {
await execute()
} catch {}
expect(console.error).toHaveBeenCalledWith(expect.any(String), oopsie)
})
it('prepares and sends all payment requests, even if some fail', async () => {
const agreementIds = [Symbol('agreementId1'), Symbol('agreementId2'), Symbol('agreementId3'), Symbol('agreementId4')]
salesApi.getDueRecurringPayments.mockReturnValueOnce([
getMockDueRecurringPayment({ referenceNumber: 'fee', agreementId: agreementIds[0] }),
getMockDueRecurringPayment({ referenceNumber: 'fi', agreementId: agreementIds[1] }),
getMockDueRecurringPayment({ referenceNumber: 'foe', agreementId: agreementIds[2] }),
getMockDueRecurringPayment({ referenceNumber: 'fum', agreementId: agreementIds[3] })
])
const permissionData = { licensee: { countryCode: 'GB-ENG' } }
for (let x = 0; x < agreementIds.length; x++) {
salesApi.preparePermissionDataForRenewal.mockReturnValueOnce(permissionData)
salesApi.createTransaction.mockReturnValueOnce({
cost: 50,
id: `transaction-id-${x + 1}`
})
if (x === 1) {
const err = new Error('Payment request failed')
sendPayment.mockRejectedValueOnce(err)
} else {
sendPayment.mockResolvedValueOnce({ payment_id: `test-payment-id-${x + 1}`, agreementId: agreementIds[x] })
}
if (x < 3) {
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess())
}
}
const expectedData = {
amount: 5000,
description: 'The recurring card payment for your rod fishing licence',
reference: 'transactionId',
authorisation_mode: 'agreement'
}
await execute()
expect(sendPayment).toHaveBeenCalledTimes(4)
expect(sendPayment).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ ...expectedData, reference: 'transaction-id-1', agreement_id: agreementIds[0] })
)
expect(sendPayment).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ ...expectedData, reference: 'transaction-id-2', agreement_id: agreementIds[1] })
)
expect(sendPayment).toHaveBeenNthCalledWith(
3,
expect.objectContaining({ ...expectedData, reference: 'transaction-id-3', agreement_id: agreementIds[2] })
)
expect(sendPayment).toHaveBeenNthCalledWith(
4,
expect.objectContaining({ ...expectedData, reference: 'transaction-id-4', agreement_id: agreementIds[3] })
)
})
it('logs an error for every failure', async () => {
jest.spyOn(console, 'error')
const errors = [new Error('error 1'), new Error('error 2'), new Error('error 3')]
salesApi.getDueRecurringPayments.mockReturnValueOnce([
getMockDueRecurringPayment({ referenceNumber: 'fee', agreementId: 'a1' }),
getMockDueRecurringPayment({ referenceNumber: 'fi', agreementId: 'a2' }),
getMockDueRecurringPayment({ referenceNumber: 'foe', agreementId: 'a3' })
])
const permissionData = { licensee: { countryCode: 'GB-ENG' } }
salesApi.preparePermissionDataForRenewal
.mockRejectedValueOnce(errors[0])
.mockReturnValueOnce(permissionData)
.mockReturnValueOnce(permissionData)
salesApi.createTransaction.mockRejectedValueOnce(errors[1]).mockReturnValueOnce({ cost: 50, id: 'transaction-id-3' })
sendPayment.mockRejectedValueOnce(errors[2])
await execute()
expect(console.error).toHaveBeenCalledWith(expect.any(String), ...errors)
})
describe('when the error is caused by an invalid agreementId', () => {
it('logs out the ids', async () => {
jest.spyOn(console, 'log')
salesApi.getDueRecurringPayments.mockReturnValueOnce(getMockPaymentRequestResponse())
const oopsie = new Error('Invalid attribute value: agreement_id. Agreement does not exist')
sendPayment.mockRejectedValueOnce(oopsie)
try {
await execute()
} catch (e) {}
expect(console.log).toHaveBeenCalledWith(
'%s is an invalid agreementId. Recurring payment %s will be cancelled',
'agreement-1',
'recurring-payment-1'
)
})
it('cancels the recurring payment', async () => {
salesApi.getDueRecurringPayments.mockReturnValueOnce(getMockPaymentRequestResponse())
const oopsie = new Error('Invalid attribute value: agreement_id. Agreement does not exist')
sendPayment.mockRejectedValueOnce(oopsie)
try {
await execute()
} catch (e) {}
expect(salesApi.cancelRecurringPayment).toHaveBeenCalledWith('recurring-payment-1')
})
})
describe('when the error is caused by a reason other than invalid agreementId', () => {
it('does not try to cancel the recurring payment', async () => {
salesApi.getDueRecurringPayments.mockReturnValueOnce(getMockPaymentRequestResponse())
const oopsie = new Error('The moon blew up without warning and for no apparent reason')
sendPayment.mockRejectedValueOnce(oopsie)
try {
await execute()
} catch (e) {
expect(salesApi.cancelRecurringPayment).not.toHaveBeenCalledWith('recurring-payment-1')
}
})
})
})
describe('When payment status request throws an error...', () => {
it('processRecurringPayments requests payment status for all payments, even if some throw errors', async () => {
const dueRecurringPayments = []
for (let x = 0; x < 6; x++) {
dueRecurringPayments.push(getMockDueRecurringPayment())
if ([1, 3].includes(x)) {
getPaymentStatus.mockRejectedValueOnce(new Error(`status failure ${x}`))
} else {
getPaymentStatus.mockReturnValueOnce(getPaymentStatusSuccess())
}
}
salesApi.getDueRecurringPayments.mockReturnValueOnce(dueRecurringPayments)
await execute()
expect(getPaymentStatus).toHaveBeenCalledTimes(6)
})
})
it('prepares the data for found recurring payments', async () => {
const referenceNumber = Symbol('reference')
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment({ referenceNumber })])
const mockPaymentResponse = { payment_id: 'test-payment-id', created_date: '2025-01-01T00:00:00.000Z' }
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess())
await execute()
expect(salesApi.preparePermissionDataForRenewal).toHaveBeenCalledWith(referenceNumber)
})
it('creates a transaction with the correct data', async () => {
const id = Symbol('recurring-payment-id')
const agreementId = Symbol('agreement-id')
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment({ agreementId, id })])
const isLicenceForYou = Symbol('isLicenceForYou')
const isRenewal = Symbol('isRenewal')
const country = Symbol('country')
const permitId = Symbol('permitId')
const firstName = Symbol('firstName')
const lastName = Symbol('lastName')
salesApi.preparePermissionDataForRenewal.mockReturnValueOnce({
isLicenceForYou,
isRenewal,
licensee: {
firstName,
lastName,
country,
countryCode: 'GB-ENG'
},
licenceStartDate: '2020-01-01',
licenceStartTime: 3,
permitId
})
const expectedData = {
dataSource: 'Recurring Payment',
recurringPayment: {
agreementId,
id
},
permissions: [
{
isLicenceForYou,
isRenewal,
issueDate: null,
licensee: {
firstName,
lastName,
country
},
permitId,
startDate: '2020-01-01T03:00:00.000Z'
}
]
}
const mockPaymentResponse = { payment_id: 'test-payment-id', agreementId: 'test-agreement-id' }
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess())
await execute()
expect(salesApi.createTransaction).toHaveBeenCalledWith(expectedData)
})
it('creates a payment journal entry', async () => {
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment()])
const samplePayment = {
payment_id: Symbol('payment-id'),
created_date: Symbol('created-date')
}
const sampleTransaction = {
id: Symbol('transaction-id'),
cost: 99
}
sendPayment.mockResolvedValueOnce(samplePayment)
salesApi.createTransaction.mockResolvedValueOnce(sampleTransaction)
await execute()
expect(salesApi.createPaymentJournal).toHaveBeenCalledWith(
sampleTransaction.id,
expect.objectContaining({
paymentReference: samplePayment.payment_id,
paymentTimestamp: samplePayment.created_date,
paymentStatus: PAYMENT_JOURNAL_STATUS_CODES.InProgress
})
)
})
it('strips the concession name returned by preparePermissionDataForRenewal before passing to createTransaction', async () => {
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment()])
salesApi.preparePermissionDataForRenewal.mockReturnValueOnce({
licensee: {
countryCode: 'GB-ENG'
},
concessions: [
{
id: 'abc-123',
name: 'concession-type-1',
proof: { type: 'NO-PROOF' }
}
]
})
const mockPaymentResponse = { payment_id: 'test-payment-id' }
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess())
await execute()
expect(salesApi.createTransaction).toHaveBeenCalledWith(
expect.objectContaining({
permissions: expect.arrayContaining([
expect.objectContaining({
concessions: expect.arrayContaining([
expect.not.objectContaining({
name: 'concession-type-1'
})
])
})
])
})
)
})
it('assigns the correct startDate when licenceStartTime is present', async () => {
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment()])
salesApi.preparePermissionDataForRenewal.mockReturnValueOnce({
licensee: { countryCode: 'GB-ENG' },
licenceStartDate: '2020-03-14',
licenceStartTime: 15
})
const mockPaymentResponse = { payment_id: 'test-payment-id' }
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess())
await execute()
expect(salesApi.createTransaction).toHaveBeenCalledWith(
expect.objectContaining({
permissions: [expect.objectContaining({ startDate: '2020-03-14T15:00:00.000Z' })]
})
)
})
it('assigns the correct startDate when licenceStartTime is not present', async () => {
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment()])
salesApi.preparePermissionDataForRenewal.mockReturnValueOnce({
licensee: { countryCode: 'GB-ENG' },
licenceStartDate: '2020-03-14'
})
const mockPaymentResponse = { payment_id: 'test-payment-id' }
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess())
await execute()
expect(salesApi.createTransaction).toHaveBeenCalledWith(
expect.objectContaining({
permissions: [expect.objectContaining({ startDate: '2020-03-14T00:00:00.000Z' })]
})
)
})
it('prepares and sends the payment request', async () => {
const agreementId = Symbol('agreementId')
const transactionId = 'transactionId'
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment({ referenceNumber: 'foo', agreementId: agreementId })])
salesApi.preparePermissionDataForRenewal.mockReturnValueOnce({
licensee: { countryCode: 'GB-ENG' }
})
salesApi.createTransaction.mockReturnValueOnce({
cost: 50,
id: transactionId
})
const mockPaymentResponse = { payment_id: 'test-payment-id', agreementId }
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess())
const expectedData = {
amount: 5000,
description: 'The recurring card payment for your rod fishing licence',
reference: transactionId,
authorisation_mode: 'agreement',
agreement_id: agreementId
}
await execute()
expect(sendPayment).toHaveBeenCalledWith(expectedData)
})
it('should call getPaymentStatus with payment id', async () => {
const mockResponse = [
{
entity: { agreementId: 'agreement-1' },
expanded: {
activePermission: {
entity: {
referenceNumber: 'ref-1'
}
}
}
}
]
salesApi.getDueRecurringPayments.mockResolvedValueOnce(mockResponse)
salesApi.createTransaction.mockResolvedValueOnce({
id: 'payment-id-1'
})
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess())
const mockPaymentResponse = { payment_id: 'test-payment-id', agreementId: 'agreement-1' }
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
await execute()
expect(getPaymentStatus).toHaveBeenCalledWith('test-payment-id')
})
it('should log payment status for recurring payment', async () => {
const mockPaymentId = 'test-payment-id'
const mockResponse = [
{
entity: { agreementId: 'agreement-1' },
expanded: {
activePermission: {
entity: {
referenceNumber: 'ref-1'
}
}
}
}
]
salesApi.getDueRecurringPayments.mockResolvedValueOnce(mockResponse)
salesApi.createTransaction.mockResolvedValueOnce({
id: mockPaymentId
})
const mockPaymentResponse = { payment_id: mockPaymentId, agreementId: 'agreement-1' }
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess())
await execute()
console.log(debugLogger.mock.calls)
expect(debugLogger).toHaveBeenCalledWith(`Payment status for ${mockPaymentId}: ${PAYMENT_STATUS.Success}`)
})
it('logs an error if createTransaction fails', async () => {
jest.spyOn(console, 'error')
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment()])
const error = new Error('Wuh-oh!')
salesApi.createTransaction.mockImplementationOnce(() => {
throw error
})
await execute()
expect(console.error).toHaveBeenCalledWith(expect.any(String), error)
})
// --- //
it('should log errors from await salesApi.processRPResult', async () => {
salesApi.getDueRecurringPayments.mockResolvedValueOnce([getMockDueRecurringPayment()])
salesApi.createTransaction.mockResolvedValueOnce({ id: 'trans-1', cost: 30 })
const payment = getMockSendPaymentResponse()
sendPayment.mockResolvedValueOnce(payment)
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess())
const boom = new Error('boom')
salesApi.processRPResult.mockImplementation(transId => (transId === 'trans-1' ? Promise.reject(boom) : Promise.resolve()))
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
await execute()
expect(errorSpy).toHaveBeenCalledWith('Failed to process Recurring Payment for trans-1', boom)
errorSpy.mockRestore()
})
describe('handling failures for multiple due payments', () => {
beforeEach(() => {
salesApi.getDueRecurringPayments.mockResolvedValueOnce([getMockDueRecurringPayment(), getMockDueRecurringPayment()])
salesApi.preparePermissionDataForRenewal.mockResolvedValueOnce({ licensee: { countryCode: 'GB-ENG' } })
salesApi.createTransaction.mockResolvedValueOnce({ id: 'trans-1', cost: 30 }).mockResolvedValueOnce({ id: 'trans-2', cost: 30 })
})
it('continues when one sendPayment rejects (Promise.allSettled check)', async () => {
const secondPayment = getMockSendPaymentResponse({
payment_id: 'test-payment-second',
agreementId: 'agr-2',
created_date: '2025-01-01T00:00:00.000Z'
})
const gatewayDown = new Error('gateway down')
sendPayment.mockRejectedValueOnce(gatewayDown).mockResolvedValueOnce(secondPayment)
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess())
salesApi.processRPResult.mockResolvedValueOnce()
await execute()
const summary = {
statusArgs: getPaymentStatus.mock.calls,
rpResultArgs: salesApi.processRPResult.mock.calls
}
expect(summary).toEqual({
statusArgs: [[secondPayment.payment_id]],
rpResultArgs: [['trans-2', secondPayment.payment_id, secondPayment.created_date]]
})
})
it('continues when processRPResult rejects for one payment', async () => {
const firstPayment = getMockSendPaymentResponse({
payment_id: 'pay-1',
agreementId: 'agr-1',
created_date: '2025-01-01T00:00:00.000Z'
})
const secondPayment = getMockSendPaymentResponse({
payment_id: 'pay-2',
agreementId: 'agr-2',
created_date: '2025-01-01T00:01:00.000Z'
})
sendPayment.mockResolvedValueOnce(firstPayment).mockResolvedValueOnce(secondPayment)
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess()).mockResolvedValueOnce(getPaymentStatusSuccess())
const boom = new Error('boom')
salesApi.processRPResult.mockImplementation(transId => (transId === 'trans-1' ? Promise.reject(boom) : Promise.resolve()))
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
await execute()
const summary = {
rpResultArgs: salesApi.processRPResult.mock.calls,
rpCount: salesApi.processRPResult.mock.calls.length,
firstError: errorSpy.mock.calls[0]
}
errorSpy.mockRestore()
expect(summary).toEqual({
rpResultArgs: expect.arrayContaining([
['trans-1', firstPayment.payment_id, firstPayment.created_date],
['trans-2', secondPayment.payment_id, secondPayment.created_date]
]),
rpCount: 2,
firstError: ['Failed to process Recurring Payment for trans-1', boom]
})
})
it('does not abort when getPaymentStatus rejects for one payment (allSettled at status stage)', async () => {
const p1 = getMockSendPaymentResponse({ payment_id: 'pay-1', created_date: '2025-01-01T00:00:00.000Z' })
const p2 = getMockSendPaymentResponse({ payment_id: 'pay-2', created_date: '2025-01-01T00:01:00.000Z' })
sendPayment.mockResolvedValueOnce(p1).mockResolvedValueOnce(p2)
getPaymentStatus.mockImplementation(async id => {
if (id === p1.payment_id) {
throw Object.assign(new Error('HTTP 500'), { response: { status: 500, data: 'boom' } })
}
return getPaymentStatusSuccess()
})
salesApi.processRPResult.mockResolvedValueOnce()
await execute()
const summary = {
statusArgs: getPaymentStatus.mock.calls,
statusCount: getPaymentStatus.mock.calls.length,
rpResultArgs: salesApi.processRPResult.mock.calls,
rpCount: salesApi.processRPResult.mock.calls.length
}
expect(summary).toEqual({
statusArgs: expect.arrayContaining([[p1.payment_id], [p2.payment_id]]),
statusCount: 2,
rpResultArgs: expect.arrayContaining([['trans-2', p2.payment_id, p2.created_date]]),
rpCount: 1
})
})
})
// --- //
it.each([
[400, 'Failed to fetch status for payment test-payment-id, error 400'],
[486, 'Failed to fetch status for payment test-payment-id, error 486'],
[499, 'Failed to fetch status for payment test-payment-id, error 499'],
[500, 'Payment status API error for test-payment-id, error 500'],
[512, 'Payment status API error for test-payment-id, error 512'],
[599, 'Payment status API error for test-payment-id, error 599']
])('logs the correct message when getPaymentStatus rejects with HTTP %i', async (statusCode, expectedMessage) => {
jest.spyOn(console, 'error')
const mockPaymentId = 'test-payment-id'
const mockResponse = [
{ entity: { agreementId: 'agreement-1' }, expanded: { activePermission: { entity: { referenceNumber: 'ref-1' } } } }
]
salesApi.getDueRecurringPayments.mockResolvedValueOnce(mockResponse)
salesApi.createTransaction.mockResolvedValueOnce({ id: mockPaymentId })
sendPayment.mockResolvedValueOnce({
payment_id: mockPaymentId,
agreementId: 'agreement-1',
created_date: '2025-04-30T12:00:00Z'
})
const apiError = { response: { status: statusCode, data: 'boom' } }
getPaymentStatus.mockRejectedValueOnce(apiError)
await execute()
expect(console.error).toHaveBeenCalledWith(expectedMessage)
})
it('logs the generic unexpected-error message and still rejects', async () => {
jest.spyOn(console, 'error')
const mockPaymentId = 'test-payment-id'
salesApi.getDueRecurringPayments.mockResolvedValueOnce(getMockPaymentRequestResponse())
salesApi.createTransaction.mockResolvedValueOnce({ id: mockPaymentId })
sendPayment.mockResolvedValueOnce({
payment_id: mockPaymentId,
agreementId: 'agreement-1',
created_date: '2025-04-30T12:00:00.000Z'
})
const networkError = new Error('network meltdown')
getPaymentStatus.mockRejectedValueOnce(networkError)
await execute()
expect(console.error).toHaveBeenCalledWith(`Unexpected error fetching payment status for ${mockPaymentId}.`)
})
it('should call setTimeout with correct delay when there are recurring payments', async () => {
const referenceNumber = Symbol('reference')
salesApi.getDueRecurringPayments.mockResolvedValueOnce([getMockDueRecurringPayment({ referenceNumber })])
const mockPaymentResponse = { payment_id: 'test-payment-id' }
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess())
const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(cb => cb())
await execute()
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), PAYMENT_STATUS_DELAY)
})
it('should not call setTimeout when there are no recurring payments', async () => {
salesApi.getDueRecurringPayments.mockResolvedValueOnce([])
const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation(cb => cb())
await execute()
expect(setTimeoutSpy).not.toHaveBeenCalled()
})
it('calls processRPResult with transaction id, payment id and created date when payment is successful', async () => {
debugLogger.mockImplementation(function () {
console.log(...arguments)
})
const mockTransactionId = 'test-transaction-id'
const mockPaymentId = 'test-payment-id'
const mockPaymentCreatedDate = '2025-01-01T00:00:00.000Z'
salesApi.getDueRecurringPayments.mockResolvedValueOnce(getMockPaymentRequestResponse())
salesApi.createTransaction.mockResolvedValueOnce({ id: mockTransactionId, cost: 30 })
sendPayment.mockResolvedValueOnce({ payment_id: mockPaymentId, agreementId: 'agreement-1', created_date: mockPaymentCreatedDate })
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess())
await execute()
console.log(salesApi.processRPResult.mock.calls, mockTransactionId, mockPaymentId, mockPaymentCreatedDate)
expect(salesApi.processRPResult).toHaveBeenCalledWith(mockTransactionId, mockPaymentId, mockPaymentCreatedDate)
})
it("doesn't call processRPResult if payment status is not successful", async () => {
const mockPaymentId = 'test-payment-id'
salesApi.getDueRecurringPayments.mockResolvedValueOnce(getMockPaymentRequestResponse())
salesApi.createTransaction.mockResolvedValueOnce({ id: mockPaymentId, cost: 30 })
sendPayment.mockResolvedValueOnce({ payment_id: mockPaymentId, agreementId: 'agreement-1' })
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusFailure())
await execute()
expect(salesApi.processRPResult).not.toHaveBeenCalled()
})
it.each([
['agreement-id', getPaymentStatusFailure(), 'failure'],
['test-agreement-id', getPaymentStatusFailure(), 'failure'],
['another-agreement-id', getPaymentStatusFailure(), 'failure'],
['agreement-id', getPaymentStatusError(), 'error'],
['test-agreement-id', getPaymentStatusError(), 'error'],
['another-agreement-id', getPaymentStatusError(), 'error']
])(
'console error displays "Payment failed. Recurring payment agreement for: %s set to be cancelled" when payment is a %status',
async (agreementId, mockStatus, status) => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment({ agreementId })])
const mockPaymentResponse = { payment_id: 'test-payment-id', created_date: '2025-01-01T00:00:00.000Z' }
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
getPaymentStatus.mockResolvedValueOnce(mockStatus)
await execute()
expect(consoleErrorSpy).toHaveBeenCalledWith(
`Payment failed. Recurring payment agreement for: ${agreementId} set to be cancelled. Updating payment journal.`
)
}
)
it.each([
['a failure', 'agreement-id', getPaymentStatusFailure()],
['a failure', 'test-agreement-id', getPaymentStatusFailure()],
['a failure', 'another-agreement-id', getPaymentStatusFailure()],
['an error', 'agreement-id', getPaymentStatusError()],
['an error', 'test-agreement-id', getPaymentStatusError()],
['an error', 'another-agreement-id', getPaymentStatusError()]
])('cancelRecurringPayment is called when payment is %s', async (_status, agreementId, mockStatus) => {
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment({ agreementId })])
const id = Symbol('recurring-payment-id')
salesApi.createTransaction.mockResolvedValueOnce({
recurringPayment: {
id
}
})
const mockPaymentResponse = { payment_id: 'test-payment-id', created_date: '2025-01-01T00:00:00.000Z' }
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
getPaymentStatus.mockResolvedValueOnce(mockStatus)
await execute()
expect(salesApi.cancelRecurringPayment).toHaveBeenCalledWith(id)
})
it('updatePaymentJournal is called with transaction id and failed status code payment is not succesful and payment journal exists', async () => {
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment()])
const transactionId = 'test-transaction-id'
salesApi.createTransaction.mockReturnValueOnce({
cost: 50,
id: transactionId
})
const mockPaymentResponse = { payment_id: 'test-payment-id', created_date: '2025-01-01T00:00:00.000Z' }
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusFailure())
salesApi.getPaymentJournal.mockResolvedValueOnce(true)
await execute()
expect(salesApi.updatePaymentJournal).toHaveBeenCalledWith(transactionId, { paymentStatus: PAYMENT_JOURNAL_STATUS_CODES.Failed })
})
it('updatePaymentJournal is not called when failed status code payment is not succesful but payment journal does not exist', async () => {
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment()])
const transactionId = 'test-transaction-id'
salesApi.createTransaction.mockReturnValueOnce({
cost: 50,
id: transactionId
})
const mockPaymentResponse = { payment_id: 'test-payment-id', created_date: '2025-01-01T00:00:00.000Z' }
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusFailure())
salesApi.getPaymentJournal.mockResolvedValueOnce(undefined)
await execute()
expect(salesApi.updatePaymentJournal).not.toHaveBeenCalled()
})
describe.each([2, 3, 10])('if there are %d recurring payments', count => {
it('prepares the data for each one', async () => {
const references = []
for (let i = 0; i < count; i++) {
references.push(Symbol('reference' + i))
}
const mockGetDueRecurringPayments = []
references.forEach(referenceNumber => {
mockGetDueRecurringPayments.push(getMockDueRecurringPayment({ referenceNumber }))
})
salesApi.getDueRecurringPayments.mockReturnValueOnce(mockGetDueRecurringPayments)
const mockPaymentResponse = { payment_id: 'test-payment-id' }
sendPayment.mockResolvedValue(mockPaymentResponse)
const mockPaymentStatus = getPaymentStatusSuccess()
getPaymentStatus.mockResolvedValue(mockPaymentStatus)
const expectedData = []
references.forEach(reference => {
expectedData.push([reference])
})
await execute()
expect(salesApi.preparePermissionDataForRenewal.mock.calls).toEqual(expectedData)
})
it('creates a transaction for each one', async () => {
const mockGetDueRecurringPayments = []
const agreementIds = []
const ids = []
for (let i = 0; i < count; i++) {
const agreementId = Symbol(`agreement-id-${i}`)
const id = Symbol(`recurring-payment-${i}`)
agreementIds.push(agreementId)
ids.push(id)
mockGetDueRecurringPayments.push(getMockDueRecurringPayment({ agreementId, id, referenceNumber: i }))
}
salesApi.getDueRecurringPayments.mockReturnValueOnce(mockGetDueRecurringPayments)
const permits = []
for (let i = 0; i < count; i++) {
permits.push(Symbol(`permit${i}`))
}
permits.forEach(permit => {
salesApi.preparePermissionDataForRenewal.mockReturnValueOnce({
licensee: { countryCode: 'GB-ENG' },
permitId: permit
})
})
const expectedData = []
permits.forEach((permit, i) => {
expectedData.push([
{
dataSource: 'Recurring Payment',
recurringPayment: {
agreementId: agreementIds[i],
id: ids[i]
},
permissions: [expect.objectContaining({ permitId: permit })]
}
])
})
await execute()
expect(salesApi.createTransaction.mock.calls).toEqual(expectedData)
})
it('sends a payment for each one', async () => {
const mockGetDueRecurringPayments = []
const agreementIds = []
for (let i = 0; i < count; i++) {
const agreementId = Symbol(`agreementId${1}`)
agreementIds.push(agreementId)
mockGetDueRecurringPayments.push(getMockDueRecurringPayment({ agreementId }))
}
salesApi.getDueRecurringPayments.mockReturnValueOnce(mockGetDueRecurringPayments)
const permits = []
for (let i = 0; i < count; i++) {
permits.push(Symbol(`permit${i}`))
}
permits.forEach((permit, i) => {
salesApi.preparePermissionDataForRenewal.mockReturnValueOnce({
licensee: { countryCode: 'GB-ENG' }
})
salesApi.createTransaction.mockReturnValueOnce({
cost: i,
id: permit
})
})
const expectedData = []
permits.forEach((permit, i) => {
expectedData.push([
{
amount: i * 100,
description: 'The recurring card payment for your rod fishing licence',
reference: permit,
authorisation_mode: 'agreement',
agreement_id: agreementIds[i]
}
])
})
await execute()
expect(sendPayment.mock.calls).toEqual(expectedData)
})
it('gets the payment status for each one', async () => {
const mockGetDueRecurringPayments = []
const agreementIds = []
for (let i = 0; i < count; i++) {
const agreementId = Symbol(`agreementId${1}`)
agreementIds.push(agreementId)
mockGetDueRecurringPayments.push(getMockDueRecurringPayment({ agreementId }))
}
salesApi.getDueRecurringPayments.mockReturnValueOnce(mockGetDueRecurringPayments)
const permits = []
for (let i = 0; i < count; i++) {
permits.push(Symbol(`permit${i}`))
}
permits.forEach((permit, i) => {
salesApi.preparePermissionDataForRenewal.mockReturnValueOnce({
licensee: { countryCode: 'GB-ENG' }
})
salesApi.createTransaction.mockReturnValueOnce({
cost: i,
id: permit
})
})
const expectedData = []
permits.forEach((_, index) => {
const paymentId = `payment-id-${index}`
expectedData.push(paymentId)
const mockPaymentResponse = { payment_id: paymentId }
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
})
await execute()
expectedData.forEach(paymentId => {
expect(getPaymentStatus).toHaveBeenCalledWith(paymentId)
})
})
})
})