@defra-fish/recurring-payments-job 1.62.0-rc.0 → 1.62.0-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ When the RP job runs, all RP entries with an nextDueDate of the current date, a
|
|
|
9
9
|
# Environment variables
|
|
10
10
|
|
|
11
11
|
| name | description | required | default | valid | notes |
|
|
12
|
-
| ------------------------------ | --------------------------------------------------------------- | :------: | ------------------- | ----------------------------- | --------------------------------------------------------------------------------- |
|
|
12
|
+
| ------------------------------ | --------------------------------------------------------------- | :------: | ------------------- | ----------------------------- | --------------------------------------------------------------------------------- | --- |
|
|
13
13
|
| NODE_ENV | Node environment | no | | development, test, production | |
|
|
14
14
|
| RUN_RECURRING_PAYMENTS | Determine whether to run recurring payments job or not | yes | | | |
|
|
15
15
|
| SALES_API_URL | URL for the sales API | no | http://0.0.0.0:4000 | | |
|
|
@@ -22,7 +22,7 @@ When the RP job runs, all RP entries with an nextDueDate of the current date, a
|
|
|
22
22
|
| DYNAMICS_API_PATH | Full URL to the Dynamics API | yes | | | The full URL to the dynamics web api. e.g. https://dynamics-server/api/data/v9.1/ |
|
|
23
23
|
| DYNAMICS_API_VERSION | The version of the Dynamics API | yes | | | The version of the dynamics web api. e.g. 9.1 |
|
|
24
24
|
| 1 |
|
|
25
|
-
| RECURRING_PAYMENTS_LOCAL_DELAY | Delay for running recurring payments until sales api is running | no | | |
|
|
25
|
+
| RECURRING_PAYMENTS_LOCAL_DELAY | Delay for running recurring payments until sales api is running | no | | | | |
|
|
26
26
|
|
|
27
27
|
### See also:
|
|
28
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defra-fish/recurring-payments-job",
|
|
3
|
-
"version": "1.62.0-rc.
|
|
3
|
+
"version": "1.62.0-rc.10",
|
|
4
4
|
"description": "Rod Licensing Recurring Payments Job",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -36,10 +36,11 @@
|
|
|
36
36
|
"test": "echo \"Error: run tests from root\" && exit 1"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@defra-fish/business-rules-lib": "1.62.0-rc.
|
|
40
|
-
"@defra-fish/connectors-lib": "1.62.0-rc.
|
|
39
|
+
"@defra-fish/business-rules-lib": "1.62.0-rc.10",
|
|
40
|
+
"@defra-fish/connectors-lib": "1.62.0-rc.10",
|
|
41
41
|
"commander": "^7.2.0",
|
|
42
|
+
"debug": "^4.3.3",
|
|
42
43
|
"moment-timezone": "^0.5.34"
|
|
43
44
|
},
|
|
44
|
-
"gitHead": "
|
|
45
|
+
"gitHead": "7be5ffd5ab38a7b7690cb2b7f7c711c5d8473860"
|
|
45
46
|
}
|
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import { salesApi } from '@defra-fish/connectors-lib'
|
|
2
|
+
import { PAYMENT_STATUS, PAYMENT_JOURNAL_STATUS_CODES } from '@defra-fish/business-rules-lib'
|
|
2
3
|
import { processRecurringPayments } from '../recurring-payments-processor.js'
|
|
3
|
-
import { getPaymentStatus, sendPayment } from '../services/govuk-pay-service.js'
|
|
4
|
+
import { getPaymentStatus, isGovPayUp, sendPayment } from '../services/govuk-pay-service.js'
|
|
4
5
|
import db from 'debug'
|
|
5
6
|
|
|
6
|
-
jest.mock('@defra-fish/business-rules-lib')
|
|
7
|
+
jest.mock('@defra-fish/business-rules-lib', () => ({
|
|
8
|
+
PAYMENT_STATUS: {
|
|
9
|
+
Success: 'payment status success',
|
|
10
|
+
Failure: 'payment status failure',
|
|
11
|
+
Error: 'payment status error'
|
|
12
|
+
},
|
|
13
|
+
PAYMENT_JOURNAL_STATUS_CODES: {
|
|
14
|
+
InProgress: 'in progress payment',
|
|
15
|
+
Cancelled: 'cancelled payment',
|
|
16
|
+
Failed: 'failed payment',
|
|
17
|
+
Expired: 'expired payment',
|
|
18
|
+
Completed: 'completed payment'
|
|
19
|
+
}
|
|
20
|
+
}))
|
|
7
21
|
jest.mock('@defra-fish/connectors-lib', () => ({
|
|
8
22
|
salesApi: {
|
|
9
23
|
getDueRecurringPayments: jest.fn(() => []),
|
|
@@ -14,18 +28,24 @@ jest.mock('@defra-fish/connectors-lib', () => ({
|
|
|
14
28
|
id: 'test-transaction-id',
|
|
15
29
|
cost: 30
|
|
16
30
|
})),
|
|
17
|
-
processRPResult: jest.fn()
|
|
31
|
+
processRPResult: jest.fn(),
|
|
32
|
+
updatePaymentJournal: jest.fn(),
|
|
33
|
+
getPaymentJournal: jest.fn()
|
|
18
34
|
}
|
|
19
35
|
}))
|
|
36
|
+
|
|
20
37
|
jest.mock('../services/govuk-pay-service.js', () => ({
|
|
21
|
-
sendPayment: jest.fn(),
|
|
22
|
-
getPaymentStatus: jest.fn()
|
|
38
|
+
sendPayment: jest.fn(() => ({ payment_id: 'payment_id', created_date: '2025-07-18T09:00:00.000Z' })),
|
|
39
|
+
getPaymentStatus: jest.fn(),
|
|
40
|
+
isGovPayUp: jest.fn(() => true)
|
|
23
41
|
}))
|
|
42
|
+
|
|
24
43
|
jest.mock('debug', () => jest.fn(() => jest.fn()))
|
|
25
44
|
|
|
26
|
-
const debugMock = db.mock.results[0].value
|
|
27
45
|
const PAYMENT_STATUS_DELAY = 60000
|
|
28
|
-
const getPaymentStatusSuccess = () => ({ state: { status: 'success' } })
|
|
46
|
+
const getPaymentStatusSuccess = () => ({ state: { status: 'payment status success' } })
|
|
47
|
+
const getPaymentStatusFailure = () => ({ state: { status: 'payment status failure' } })
|
|
48
|
+
const getPaymentStatusError = () => ({ state: { status: 'payment status error' } })
|
|
29
49
|
const getMockPaymentRequestResponse = () => [
|
|
30
50
|
{
|
|
31
51
|
entity: { agreementId: 'agreement-1' },
|
|
@@ -39,25 +59,37 @@ const getMockPaymentRequestResponse = () => [
|
|
|
39
59
|
}
|
|
40
60
|
]
|
|
41
61
|
|
|
62
|
+
const getMockDueRecurringPayment = (referenceNumber = '123', agreementId = 'test-agreement-id') => ({
|
|
63
|
+
entity: { agreementId },
|
|
64
|
+
expanded: { activePermission: { entity: { referenceNumber } } }
|
|
65
|
+
})
|
|
66
|
+
|
|
42
67
|
describe('recurring-payments-processor', () => {
|
|
68
|
+
const [{ value: debugLogger }] = db.mock.results
|
|
69
|
+
|
|
43
70
|
beforeEach(() => {
|
|
44
71
|
jest.clearAllMocks()
|
|
45
72
|
process.env.RUN_RECURRING_PAYMENTS = 'true'
|
|
46
73
|
global.setTimeout = jest.fn((cb, ms) => cb())
|
|
47
74
|
})
|
|
48
75
|
|
|
49
|
-
it('debug displays "Recurring Payments job disabled" when env is false', async () => {
|
|
76
|
+
it('debug log displays "Recurring Payments job disabled" when env is false', async () => {
|
|
50
77
|
process.env.RUN_RECURRING_PAYMENTS = 'false'
|
|
51
78
|
|
|
52
79
|
await processRecurringPayments()
|
|
53
80
|
|
|
54
|
-
expect(
|
|
81
|
+
expect(debugLogger).toHaveBeenCalledWith('Recurring Payments job disabled')
|
|
55
82
|
})
|
|
56
83
|
|
|
57
|
-
it('debug displays "Recurring Payments job enabled" when env is true', async () => {
|
|
84
|
+
it('debug log displays "Recurring Payments job enabled" when env is true', async () => {
|
|
58
85
|
await processRecurringPayments()
|
|
59
86
|
|
|
60
|
-
expect(
|
|
87
|
+
expect(debugLogger).toHaveBeenCalledWith('Recurring Payments job enabled')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('throws if Gov.UK Pay is not healthy', async () => {
|
|
91
|
+
isGovPayUp.mockResolvedValueOnce(false)
|
|
92
|
+
await expect(() => processRecurringPayments()).rejects.toThrow('Run aborted, Gov.UK Pay health endpoint is reporting problems.')
|
|
61
93
|
})
|
|
62
94
|
|
|
63
95
|
it('get recurring payments is called when env is true', async () => {
|
|
@@ -68,10 +100,143 @@ describe('recurring-payments-processor', () => {
|
|
|
68
100
|
expect(salesApi.getDueRecurringPayments).toHaveBeenCalledWith(date)
|
|
69
101
|
})
|
|
70
102
|
|
|
71
|
-
it('debug displays "Recurring Payments found:
|
|
103
|
+
it('debug log displays "Recurring Payments found:" when env is true', async () => {
|
|
72
104
|
await processRecurringPayments()
|
|
73
105
|
|
|
74
|
-
expect(
|
|
106
|
+
expect(debugLogger).toHaveBeenNthCalledWith(2, 'Recurring Payments found:', [])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('When RP fetch throws an error...', () => {
|
|
110
|
+
it('processRecurringPayments re-throws the error', async () => {
|
|
111
|
+
const error = new Error('Test error')
|
|
112
|
+
salesApi.getDueRecurringPayments.mockImplementationOnce(() => {
|
|
113
|
+
throw error
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
await expect(processRecurringPayments()).rejects.toThrowError(error)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('calls console.error with error message', async () => {
|
|
120
|
+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
|
|
121
|
+
const error = new Error('Test error')
|
|
122
|
+
salesApi.getDueRecurringPayments.mockImplementationOnce(() => {
|
|
123
|
+
throw error
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await processRecurringPayments()
|
|
128
|
+
} catch {}
|
|
129
|
+
|
|
130
|
+
expect(errorSpy).toHaveBeenCalledWith('Run aborted. Error fetching due recurring payments:', error)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('When payment request throws an error...', () => {
|
|
135
|
+
it('debug is called with error message', async () => {
|
|
136
|
+
salesApi.getDueRecurringPayments.mockReturnValueOnce(getMockPaymentRequestResponse())
|
|
137
|
+
const oopsie = new Error('payment gate down')
|
|
138
|
+
sendPayment.mockRejectedValueOnce(oopsie)
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
await processRecurringPayments()
|
|
142
|
+
} catch {}
|
|
143
|
+
|
|
144
|
+
expect(debugLogger).toHaveBeenCalledWith(expect.any(String), oopsie)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('prepares and sends all payment requests, even if some fail', async () => {
|
|
148
|
+
const agreementIds = [Symbol('agreementId1'), Symbol('agreementId2'), Symbol('agreementId3'), Symbol('agreementId4')]
|
|
149
|
+
salesApi.getDueRecurringPayments.mockReturnValueOnce([
|
|
150
|
+
getMockDueRecurringPayment('fee', agreementIds[0]),
|
|
151
|
+
getMockDueRecurringPayment('fi', agreementIds[1]),
|
|
152
|
+
getMockDueRecurringPayment('foe', agreementIds[2]),
|
|
153
|
+
getMockDueRecurringPayment('fum', agreementIds[3])
|
|
154
|
+
])
|
|
155
|
+
|
|
156
|
+
const permissionData = { licensee: { countryCode: 'GB-ENG' } }
|
|
157
|
+
for (let x = 0; x < agreementIds.length; x++) {
|
|
158
|
+
salesApi.preparePermissionDataForRenewal.mockReturnValueOnce(permissionData)
|
|
159
|
+
salesApi.createTransaction.mockReturnValueOnce({
|
|
160
|
+
cost: 50,
|
|
161
|
+
id: `transaction-id-${x + 1}`
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
if (x === 1) {
|
|
165
|
+
const err = new Error('Payment request failed')
|
|
166
|
+
sendPayment.mockRejectedValueOnce(err)
|
|
167
|
+
} else {
|
|
168
|
+
sendPayment.mockResolvedValueOnce({ payment_id: `test-payment-id-${x + 1}`, agreementId: agreementIds[x] })
|
|
169
|
+
}
|
|
170
|
+
if (x < 3) {
|
|
171
|
+
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess())
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const expectedData = {
|
|
175
|
+
amount: 5000,
|
|
176
|
+
description: 'The recurring card payment for your rod fishing licence',
|
|
177
|
+
reference: 'transactionId',
|
|
178
|
+
authorisation_mode: 'agreement'
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
await processRecurringPayments()
|
|
182
|
+
|
|
183
|
+
expect(sendPayment).toHaveBeenCalledTimes(4)
|
|
184
|
+
expect(sendPayment).toHaveBeenNthCalledWith(
|
|
185
|
+
1,
|
|
186
|
+
expect.objectContaining({ ...expectedData, reference: 'transaction-id-1', agreement_id: agreementIds[0] })
|
|
187
|
+
)
|
|
188
|
+
expect(sendPayment).toHaveBeenNthCalledWith(
|
|
189
|
+
2,
|
|
190
|
+
expect.objectContaining({ ...expectedData, reference: 'transaction-id-2', agreement_id: agreementIds[1] })
|
|
191
|
+
)
|
|
192
|
+
expect(sendPayment).toHaveBeenNthCalledWith(
|
|
193
|
+
3,
|
|
194
|
+
expect.objectContaining({ ...expectedData, reference: 'transaction-id-3', agreement_id: agreementIds[2] })
|
|
195
|
+
)
|
|
196
|
+
expect(sendPayment).toHaveBeenNthCalledWith(
|
|
197
|
+
4,
|
|
198
|
+
expect.objectContaining({ ...expectedData, reference: 'transaction-id-4', agreement_id: agreementIds[3] })
|
|
199
|
+
)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('logs an error for every failure', async () => {
|
|
203
|
+
const errors = [new Error('error 1'), new Error('error 2'), new Error('error 3')]
|
|
204
|
+
salesApi.getDueRecurringPayments.mockReturnValueOnce([
|
|
205
|
+
getMockDueRecurringPayment('fee', 'a1'),
|
|
206
|
+
getMockDueRecurringPayment('fi', 'a2'),
|
|
207
|
+
getMockDueRecurringPayment('foe', 'a3')
|
|
208
|
+
])
|
|
209
|
+
const permissionData = { licensee: { countryCode: 'GB-ENG' } }
|
|
210
|
+
salesApi.preparePermissionDataForRenewal
|
|
211
|
+
.mockRejectedValueOnce(errors[0])
|
|
212
|
+
.mockReturnValueOnce(permissionData)
|
|
213
|
+
.mockReturnValueOnce(permissionData)
|
|
214
|
+
salesApi.createTransaction.mockRejectedValueOnce(errors[1]).mockReturnValueOnce({ cost: 50, id: 'transaction-id-3' })
|
|
215
|
+
sendPayment.mockRejectedValueOnce(errors[2])
|
|
216
|
+
|
|
217
|
+
await processRecurringPayments()
|
|
218
|
+
|
|
219
|
+
expect(debugLogger).toHaveBeenCalledWith(expect.any(String), ...errors)
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe('When payment status request throws an error...', () => {
|
|
224
|
+
it('processRecurringPayments requests payment status for all payments, even if some throw errors', async () => {
|
|
225
|
+
const dueRecurringPayments = []
|
|
226
|
+
for (let x = 0; x < 6; x++) {
|
|
227
|
+
dueRecurringPayments.push(getMockDueRecurringPayment())
|
|
228
|
+
if ([1, 3].includes(x)) {
|
|
229
|
+
getPaymentStatus.mockRejectedValueOnce(new Error(`status failure ${x}`))
|
|
230
|
+
} else {
|
|
231
|
+
getPaymentStatus.mockReturnValueOnce(getPaymentStatusSuccess())
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
salesApi.getDueRecurringPayments.mockReturnValueOnce(dueRecurringPayments)
|
|
235
|
+
|
|
236
|
+
await processRecurringPayments()
|
|
237
|
+
|
|
238
|
+
expect(getPaymentStatus).toHaveBeenCalledTimes(6)
|
|
239
|
+
})
|
|
75
240
|
})
|
|
76
241
|
|
|
77
242
|
it('prepares the data for found recurring payments', async () => {
|
|
@@ -218,16 +383,6 @@ describe('recurring-payments-processor', () => {
|
|
|
218
383
|
)
|
|
219
384
|
})
|
|
220
385
|
|
|
221
|
-
it('raises an error if createTransaction fails', async () => {
|
|
222
|
-
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment()])
|
|
223
|
-
const error = 'Wuh-oh!'
|
|
224
|
-
salesApi.createTransaction.mockImplementationOnce(() => {
|
|
225
|
-
throw new Error(error)
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
await expect(processRecurringPayments()).rejects.toThrowError(error)
|
|
229
|
-
})
|
|
230
|
-
|
|
231
386
|
it('prepares and sends the payment request', async () => {
|
|
232
387
|
const agreementId = Symbol('agreementId')
|
|
233
388
|
const transactionId = 'transactionId'
|
|
@@ -286,7 +441,7 @@ describe('recurring-payments-processor', () => {
|
|
|
286
441
|
expect(getPaymentStatus).toHaveBeenCalledWith('test-payment-id')
|
|
287
442
|
})
|
|
288
443
|
|
|
289
|
-
it('
|
|
444
|
+
it('should log payment status for recurring payment', async () => {
|
|
290
445
|
const mockPaymentId = 'test-payment-id'
|
|
291
446
|
const mockResponse = [
|
|
292
447
|
{
|
|
@@ -300,48 +455,76 @@ describe('recurring-payments-processor', () => {
|
|
|
300
455
|
}
|
|
301
456
|
}
|
|
302
457
|
]
|
|
303
|
-
const mockStatus = getPaymentStatusSuccess()
|
|
304
458
|
salesApi.getDueRecurringPayments.mockResolvedValueOnce(mockResponse)
|
|
305
459
|
salesApi.createTransaction.mockResolvedValueOnce({
|
|
306
460
|
id: mockPaymentId
|
|
307
461
|
})
|
|
308
462
|
const mockPaymentResponse = { payment_id: mockPaymentId, agreementId: 'agreement-1' }
|
|
309
463
|
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
|
|
310
|
-
getPaymentStatus.mockResolvedValueOnce(
|
|
464
|
+
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess())
|
|
311
465
|
|
|
312
466
|
await processRecurringPayments()
|
|
313
467
|
|
|
314
|
-
|
|
468
|
+
console.log(debugLogger.mock.calls)
|
|
469
|
+
expect(debugLogger).toHaveBeenCalledWith(`Payment status for ${mockPaymentId}: ${PAYMENT_STATUS.Success}`)
|
|
315
470
|
})
|
|
316
471
|
|
|
317
|
-
it('
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
472
|
+
it('logs an error if createTransaction fails', async () => {
|
|
473
|
+
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment()])
|
|
474
|
+
const error = new Error('Wuh-oh!')
|
|
475
|
+
salesApi.createTransaction.mockImplementationOnce(() => {
|
|
476
|
+
throw error
|
|
321
477
|
})
|
|
478
|
+
|
|
479
|
+
await processRecurringPayments()
|
|
480
|
+
|
|
481
|
+
expect(debugLogger).toHaveBeenCalledWith(expect.any(String), error)
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
it.each([
|
|
485
|
+
[400, 'Failed to fetch status for payment test-payment-id, error 400'],
|
|
486
|
+
[486, 'Failed to fetch status for payment test-payment-id, error 486'],
|
|
487
|
+
[499, 'Failed to fetch status for payment test-payment-id, error 499'],
|
|
488
|
+
[500, 'Payment status API error for test-payment-id, error 500'],
|
|
489
|
+
[512, 'Payment status API error for test-payment-id, error 512'],
|
|
490
|
+
[599, 'Payment status API error for test-payment-id, error 599']
|
|
491
|
+
])('logs the correct message when getPaymentStatus rejects with HTTP %i', async (statusCode, expectedMessage) => {
|
|
492
|
+
const mockPaymentId = 'test-payment-id'
|
|
322
493
|
const mockResponse = [
|
|
323
|
-
{
|
|
324
|
-
entity: { agreementId: 'agreement-1' },
|
|
325
|
-
expanded: {
|
|
326
|
-
activePermission: {
|
|
327
|
-
entity: {
|
|
328
|
-
referenceNumber: 'ref-1'
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
}
|
|
494
|
+
{ entity: { agreementId: 'agreement-1' }, expanded: { activePermission: { entity: { referenceNumber: 'ref-1' } } } }
|
|
333
495
|
]
|
|
334
496
|
salesApi.getDueRecurringPayments.mockResolvedValueOnce(mockResponse)
|
|
335
|
-
salesApi.createTransaction.mockResolvedValueOnce({
|
|
336
|
-
|
|
497
|
+
salesApi.createTransaction.mockResolvedValueOnce({ id: mockPaymentId })
|
|
498
|
+
sendPayment.mockResolvedValueOnce({
|
|
499
|
+
payment_id: mockPaymentId,
|
|
500
|
+
agreementId: 'agreement-1',
|
|
501
|
+
created_date: '2025-04-30T12:00:00Z'
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
const apiError = { response: { status: statusCode, data: 'boom' } }
|
|
505
|
+
getPaymentStatus.mockRejectedValueOnce(apiError)
|
|
506
|
+
|
|
507
|
+
await processRecurringPayments()
|
|
508
|
+
|
|
509
|
+
expect(debugLogger).toHaveBeenCalledWith(expectedMessage)
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
it('logs the generic unexpected-error message and still rejects', async () => {
|
|
513
|
+
const mockPaymentId = 'test-payment-id'
|
|
514
|
+
salesApi.getDueRecurringPayments.mockResolvedValueOnce(getMockPaymentRequestResponse())
|
|
515
|
+
salesApi.createTransaction.mockResolvedValueOnce({ id: mockPaymentId })
|
|
516
|
+
sendPayment.mockResolvedValueOnce({
|
|
517
|
+
payment_id: mockPaymentId,
|
|
518
|
+
agreementId: 'agreement-1',
|
|
519
|
+
created_date: '2025-04-30T12:00:00.000Z'
|
|
337
520
|
})
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
getPaymentStatus.
|
|
521
|
+
|
|
522
|
+
const networkError = new Error('network meltdown')
|
|
523
|
+
getPaymentStatus.mockRejectedValueOnce(networkError)
|
|
341
524
|
|
|
342
525
|
await processRecurringPayments()
|
|
343
526
|
|
|
344
|
-
expect(
|
|
527
|
+
expect(debugLogger).toHaveBeenCalledWith(`Unexpected error fetching payment status for ${mockPaymentId}.`)
|
|
345
528
|
})
|
|
346
529
|
|
|
347
530
|
it('should call setTimeout with correct delay when there are recurring payments', async () => {
|
|
@@ -369,6 +552,9 @@ describe('recurring-payments-processor', () => {
|
|
|
369
552
|
})
|
|
370
553
|
|
|
371
554
|
it('calls processRPResult with transaction id, payment id and created date when payment is successful', async () => {
|
|
555
|
+
debugLogger.mockImplementation(function () {
|
|
556
|
+
console.log(...arguments)
|
|
557
|
+
})
|
|
372
558
|
const mockTransactionId = 'test-transaction-id'
|
|
373
559
|
const mockPaymentId = 'test-payment-id'
|
|
374
560
|
const mockPaymentCreatedDate = '2025-01-01T00:00:00.000Z'
|
|
@@ -379,6 +565,7 @@ describe('recurring-payments-processor', () => {
|
|
|
379
565
|
|
|
380
566
|
await processRecurringPayments()
|
|
381
567
|
|
|
568
|
+
console.log(salesApi.processRPResult.mock.calls, mockTransactionId, mockPaymentId, mockPaymentCreatedDate)
|
|
382
569
|
expect(salesApi.processRPResult).toHaveBeenCalledWith(mockTransactionId, mockPaymentId, mockPaymentCreatedDate)
|
|
383
570
|
})
|
|
384
571
|
|
|
@@ -387,11 +574,69 @@ describe('recurring-payments-processor', () => {
|
|
|
387
574
|
salesApi.getDueRecurringPayments.mockResolvedValueOnce(getMockPaymentRequestResponse())
|
|
388
575
|
salesApi.createTransaction.mockResolvedValueOnce({ id: mockPaymentId, cost: 30 })
|
|
389
576
|
sendPayment.mockResolvedValueOnce({ payment_id: mockPaymentId, agreementId: 'agreement-1' })
|
|
390
|
-
getPaymentStatus.mockResolvedValueOnce(
|
|
577
|
+
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusFailure())
|
|
578
|
+
|
|
579
|
+
await processRecurringPayments()
|
|
580
|
+
|
|
581
|
+
expect(salesApi.processRPResult).not.toHaveBeenCalled()
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
it.each([
|
|
585
|
+
['agreement-id', getPaymentStatusFailure(), 'failure'],
|
|
586
|
+
['test-agreement-id', getPaymentStatusFailure(), 'failure'],
|
|
587
|
+
['another-agreement-id', getPaymentStatusFailure(), 'failure'],
|
|
588
|
+
['agreement-id', getPaymentStatusError(), 'error'],
|
|
589
|
+
['test-agreement-id', getPaymentStatusError(), 'error'],
|
|
590
|
+
['another-agreement-id', getPaymentStatusError(), 'error']
|
|
591
|
+
])(
|
|
592
|
+
'console error displays "Payment failed. Recurring payment agreement for: %s set to be cancelled" when payment is a %status',
|
|
593
|
+
async (agreementId, mockStatus, status) => {
|
|
594
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
|
|
595
|
+
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment('reference', agreementId)])
|
|
596
|
+
const mockPaymentResponse = { payment_id: 'test-payment-id', created_date: '2025-01-01T00:00:00.000Z' }
|
|
597
|
+
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
|
|
598
|
+
getPaymentStatus.mockResolvedValueOnce(mockStatus)
|
|
599
|
+
|
|
600
|
+
await processRecurringPayments()
|
|
601
|
+
|
|
602
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
603
|
+
`Payment failed. Recurring payment agreement for: ${agreementId} set to be cancelled. Updating payment journal.`
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
it('updatePaymentJournal is called with transaction id and failed status code payment is not succesful and payment journal exists', async () => {
|
|
609
|
+
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment()])
|
|
610
|
+
const transactionId = 'test-transaction-id'
|
|
611
|
+
salesApi.createTransaction.mockReturnValueOnce({
|
|
612
|
+
cost: 50,
|
|
613
|
+
id: transactionId
|
|
614
|
+
})
|
|
615
|
+
const mockPaymentResponse = { payment_id: 'test-payment-id', created_date: '2025-01-01T00:00:00.000Z' }
|
|
616
|
+
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
|
|
617
|
+
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusFailure())
|
|
618
|
+
salesApi.getPaymentJournal.mockResolvedValueOnce(true)
|
|
619
|
+
|
|
620
|
+
await processRecurringPayments()
|
|
621
|
+
|
|
622
|
+
expect(salesApi.updatePaymentJournal).toHaveBeenCalledWith(transactionId, { paymentStatus: PAYMENT_JOURNAL_STATUS_CODES.Failed })
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
it('updatePaymentJournal is not called when failed status code payment is not succesful but payment journal does not exist', async () => {
|
|
626
|
+
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment()])
|
|
627
|
+
const transactionId = 'test-transaction-id'
|
|
628
|
+
salesApi.createTransaction.mockReturnValueOnce({
|
|
629
|
+
cost: 50,
|
|
630
|
+
id: transactionId
|
|
631
|
+
})
|
|
632
|
+
const mockPaymentResponse = { payment_id: 'test-payment-id', created_date: '2025-01-01T00:00:00.000Z' }
|
|
633
|
+
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
|
|
634
|
+
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusFailure())
|
|
635
|
+
salesApi.getPaymentJournal.mockResolvedValueOnce(undefined)
|
|
391
636
|
|
|
392
637
|
await processRecurringPayments()
|
|
393
638
|
|
|
394
|
-
expect(salesApi.
|
|
639
|
+
expect(salesApi.updatePaymentJournal).not.toHaveBeenCalled()
|
|
395
640
|
})
|
|
396
641
|
|
|
397
642
|
describe.each([2, 3, 10])('if there are %d recurring payments', count => {
|
|
@@ -400,7 +645,6 @@ describe('recurring-payments-processor', () => {
|
|
|
400
645
|
for (let i = 0; i < count; i++) {
|
|
401
646
|
references.push(Symbol('reference' + i))
|
|
402
647
|
}
|
|
403
|
-
|
|
404
648
|
const mockGetDueRecurringPayments = []
|
|
405
649
|
references.forEach(reference => {
|
|
406
650
|
mockGetDueRecurringPayments.push(getMockDueRecurringPayment(reference))
|
|
@@ -511,7 +755,7 @@ describe('recurring-payments-processor', () => {
|
|
|
511
755
|
|
|
512
756
|
const permits = []
|
|
513
757
|
for (let i = 0; i < count; i++) {
|
|
514
|
-
permits.push(`permit${i}`)
|
|
758
|
+
permits.push(Symbol(`permit${i}`))
|
|
515
759
|
}
|
|
516
760
|
|
|
517
761
|
permits.forEach((permit, i) => {
|
|
@@ -540,8 +784,3 @@ describe('recurring-payments-processor', () => {
|
|
|
540
784
|
})
|
|
541
785
|
})
|
|
542
786
|
})
|
|
543
|
-
|
|
544
|
-
const getMockDueRecurringPayment = (referenceNumber = '123', agreementId = 'test-agreement-id') => ({
|
|
545
|
-
entity: { agreementId },
|
|
546
|
-
expanded: { activePermission: { entity: { referenceNumber } } }
|
|
547
|
-
})
|
|
@@ -1,64 +1,91 @@
|
|
|
1
1
|
import moment from 'moment-timezone'
|
|
2
|
-
import { SERVICE_LOCAL_TIME } from '@defra-fish/business-rules-lib'
|
|
2
|
+
import { PAYMENT_STATUS, SERVICE_LOCAL_TIME, PAYMENT_JOURNAL_STATUS_CODES } from '@defra-fish/business-rules-lib'
|
|
3
3
|
import { salesApi } from '@defra-fish/connectors-lib'
|
|
4
|
-
import { getPaymentStatus, sendPayment } from './services/govuk-pay-service.js'
|
|
4
|
+
import { getPaymentStatus, sendPayment, isGovPayUp } from './services/govuk-pay-service.js'
|
|
5
5
|
import db from 'debug'
|
|
6
|
+
|
|
6
7
|
const debug = db('recurring-payments:processor')
|
|
7
8
|
|
|
8
9
|
const PAYMENT_STATUS_DELAY = 60000
|
|
9
|
-
const
|
|
10
|
-
const
|
|
10
|
+
const MIN_CLIENT_ERROR = 400
|
|
11
|
+
const MAX_CLIENT_ERROR = 499
|
|
12
|
+
const MIN_SERVER_ERROR = 500
|
|
13
|
+
const MAX_SERVER_ERROR = 599
|
|
14
|
+
|
|
15
|
+
const isClientError = code => code >= MIN_CLIENT_ERROR && code <= MAX_CLIENT_ERROR
|
|
16
|
+
const isServerError = code => code >= MIN_SERVER_ERROR && code <= MAX_SERVER_ERROR
|
|
17
|
+
|
|
18
|
+
const fetchDueRecurringPayments = async date => {
|
|
19
|
+
try {
|
|
20
|
+
const duePayments = await salesApi.getDueRecurringPayments(date)
|
|
21
|
+
debug('Recurring Payments found:', duePayments)
|
|
22
|
+
return duePayments
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.error('Run aborted. Error fetching due recurring payments:', error)
|
|
25
|
+
throw error
|
|
26
|
+
}
|
|
27
|
+
}
|
|
11
28
|
|
|
12
29
|
export const processRecurringPayments = async () => {
|
|
13
|
-
if (process.env.RUN_RECURRING_PAYMENTS?.toLowerCase()
|
|
14
|
-
debug('Recurring Payments job enabled')
|
|
15
|
-
const date = new Date().toISOString().split('T')[0]
|
|
16
|
-
const response = await salesApi.getDueRecurringPayments(date)
|
|
17
|
-
debug('Recurring Payments found: ', response)
|
|
18
|
-
await Promise.all(response.map(record => processRecurringPayment(record)))
|
|
19
|
-
if (response.length > 0) {
|
|
20
|
-
await new Promise(resolve => setTimeout(resolve, PAYMENT_STATUS_DELAY))
|
|
21
|
-
await Promise.all(response.map(record => processRecurringPaymentStatus(record)))
|
|
22
|
-
}
|
|
23
|
-
} else {
|
|
30
|
+
if (process.env.RUN_RECURRING_PAYMENTS?.toLowerCase() !== 'true') {
|
|
24
31
|
debug('Recurring Payments job disabled')
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!(await isGovPayUp())) {
|
|
36
|
+
debug('Gov.UK Pay reporting unhealthy, aborting run')
|
|
37
|
+
throw new Error('Run aborted, Gov.UK Pay health endpoint is reporting problems.')
|
|
25
38
|
}
|
|
39
|
+
|
|
40
|
+
debug('Recurring Payments job enabled')
|
|
41
|
+
const date = new Date().toISOString().split('T')[0]
|
|
42
|
+
|
|
43
|
+
const dueRCPayments = await fetchDueRecurringPayments(date)
|
|
44
|
+
if (dueRCPayments.length === 0) {
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const payments = await requestPayments(dueRCPayments)
|
|
49
|
+
|
|
50
|
+
await new Promise(resolve => setTimeout(resolve, PAYMENT_STATUS_DELAY))
|
|
51
|
+
|
|
52
|
+
await Promise.allSettled(payments.map(p => processRecurringPaymentStatus(p)))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const requestPayments = async dueRCPayments => {
|
|
56
|
+
const paymentRequestResults = await Promise.allSettled(dueRCPayments.map(processRecurringPayment))
|
|
57
|
+
const payments = paymentRequestResults.filter(prr => prr.status === 'fulfilled').map(p => p.value)
|
|
58
|
+
const failures = paymentRequestResults.filter(prr => prr.status === 'rejected').map(f => f.reason)
|
|
59
|
+
if (failures.length) {
|
|
60
|
+
debug('Error requesting payments:', ...failures)
|
|
61
|
+
}
|
|
62
|
+
return payments
|
|
26
63
|
}
|
|
27
64
|
|
|
28
65
|
const processRecurringPayment = async record => {
|
|
29
66
|
const referenceNumber = record.expanded.activePermission.entity.referenceNumber
|
|
30
67
|
const agreementId = record.entity.agreementId
|
|
31
68
|
const transaction = await createNewTransaction(referenceNumber, agreementId)
|
|
32
|
-
|
|
69
|
+
return takeRecurringPayment(agreementId, transaction)
|
|
33
70
|
}
|
|
34
71
|
|
|
35
72
|
const createNewTransaction = async (referenceNumber, agreementId) => {
|
|
36
73
|
const transactionData = await processPermissionData(referenceNumber, agreementId)
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
const response = await salesApi.createTransaction(transactionData)
|
|
40
|
-
debug('New transaction created:', response)
|
|
41
|
-
return response
|
|
42
|
-
} catch (e) {
|
|
43
|
-
console.error('Error creating transaction', JSON.stringify(transactionData))
|
|
44
|
-
throw e
|
|
45
|
-
}
|
|
74
|
+
return salesApi.createTransaction(transactionData)
|
|
46
75
|
}
|
|
47
76
|
|
|
48
77
|
const takeRecurringPayment = async (agreementId, transaction) => {
|
|
49
78
|
const preparedPayment = preparePayment(agreementId, transaction)
|
|
50
|
-
debug('Requesting payment:', preparedPayment)
|
|
51
79
|
const payment = await sendPayment(preparedPayment)
|
|
52
|
-
|
|
80
|
+
return {
|
|
53
81
|
agreementId,
|
|
54
82
|
paymentId: payment.payment_id,
|
|
55
83
|
created_date: payment.created_date,
|
|
56
84
|
transaction
|
|
57
|
-
}
|
|
85
|
+
}
|
|
58
86
|
}
|
|
59
87
|
|
|
60
88
|
const processPermissionData = async (referenceNumber, agreementId) => {
|
|
61
|
-
debug('Preparing data based on', referenceNumber, 'with agreementId', agreementId)
|
|
62
89
|
const data = await salesApi.preparePermissionDataForRenewal(referenceNumber)
|
|
63
90
|
const licenseeWithoutCountryCode = Object.assign((({ countryCode: _countryCode, ...l }) => l)(data.licensee))
|
|
64
91
|
return {
|
|
@@ -100,20 +127,37 @@ const preparePayment = (agreementId, transaction) => {
|
|
|
100
127
|
return result
|
|
101
128
|
}
|
|
102
129
|
|
|
103
|
-
const processRecurringPaymentStatus = async
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
130
|
+
const processRecurringPaymentStatus = async payment => {
|
|
131
|
+
try {
|
|
132
|
+
const {
|
|
133
|
+
state: { status }
|
|
134
|
+
} = await getPaymentStatus(payment.paymentId)
|
|
135
|
+
|
|
136
|
+
debug(`Payment status for ${payment.paymentId}: ${status}`)
|
|
137
|
+
|
|
138
|
+
if (status === PAYMENT_STATUS.Success) {
|
|
139
|
+
await salesApi.processRPResult(payment.transaction.id, payment.paymentId, payment.created_date)
|
|
140
|
+
debug(`Processed Recurring Payment for ${payment.transaction.id}`)
|
|
141
|
+
}
|
|
142
|
+
if (status === PAYMENT_STATUS.Failure || status === PAYMENT_STATUS.Error) {
|
|
143
|
+
console.error(
|
|
144
|
+
`Payment failed. Recurring payment agreement for: ${payment.agreementId} set to be cancelled. Updating payment journal.`
|
|
145
|
+
)
|
|
146
|
+
if (await salesApi.getPaymentJournal(payment.transaction.id)) {
|
|
147
|
+
await salesApi.updatePaymentJournal(payment.transaction.id, {
|
|
148
|
+
paymentStatus: PAYMENT_JOURNAL_STATUS_CODES.Failed
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const status = error.response?.status
|
|
116
154
|
|
|
117
|
-
|
|
118
|
-
|
|
155
|
+
if (isClientError(status)) {
|
|
156
|
+
debug(`Failed to fetch status for payment ${payment.paymentId}, error ${status}`)
|
|
157
|
+
} else if (isServerError(status)) {
|
|
158
|
+
debug(`Payment status API error for ${payment.paymentId}, error ${status}`)
|
|
159
|
+
} else {
|
|
160
|
+
debug(`Unexpected error fetching payment status for ${payment.paymentId}.`)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
119
163
|
}
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import { getPaymentStatus, sendPayment } from '../govuk-pay-service.js'
|
|
1
|
+
import { getPaymentStatus, sendPayment, isGovPayUp } from '../govuk-pay-service.js'
|
|
2
2
|
import { govUkPayApi } from '@defra-fish/connectors-lib'
|
|
3
|
+
import db from 'debug'
|
|
3
4
|
|
|
4
5
|
jest.mock('@defra-fish/connectors-lib')
|
|
6
|
+
jest.mock('debug', () => jest.fn(() => jest.fn()))
|
|
7
|
+
const mockDebug = db.mock.results[0].value
|
|
5
8
|
|
|
6
9
|
describe('govuk-pay-service', () => {
|
|
10
|
+
it('initialises logger', () => {
|
|
11
|
+
expect(db).toHaveBeenCalledWith('recurring-payments:gov.uk-pay-service')
|
|
12
|
+
})
|
|
13
|
+
|
|
7
14
|
describe('sendPayment', () => {
|
|
8
15
|
const preparedPayment = { id: '1234' }
|
|
9
16
|
|
|
@@ -117,4 +124,34 @@ describe('govuk-pay-service', () => {
|
|
|
117
124
|
await expect(getPaymentStatus('test-payment-id')).rejects.toThrow('Network error')
|
|
118
125
|
})
|
|
119
126
|
})
|
|
127
|
+
|
|
128
|
+
describe('isGovPayUp', () => {
|
|
129
|
+
it.each([
|
|
130
|
+
[true, 'true', 'true'],
|
|
131
|
+
[false, 'true', 'false'],
|
|
132
|
+
[false, 'false', 'true'],
|
|
133
|
+
[false, 'false', 'false']
|
|
134
|
+
])('resolves to %p if healthy is %s and deadlocks is %s', async (expectedResult, pingHealthy, deadlocksHealthy) => {
|
|
135
|
+
govUkPayApi.isGovPayUp.mockResolvedValueOnce({
|
|
136
|
+
ok: true,
|
|
137
|
+
text: async () => `{"ping":{"healthy":${pingHealthy}},"deadlocks":{"healthy":${deadlocksHealthy}}}`
|
|
138
|
+
})
|
|
139
|
+
expect(await isGovPayUp()).toBe(expectedResult)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("resolves to false if we don't receive a 2xx response", async () => {
|
|
143
|
+
govUkPayApi.isGovPayUp.mockResolvedValueOnce({
|
|
144
|
+
ok: false
|
|
145
|
+
})
|
|
146
|
+
expect(await isGovPayUp()).toBe(false)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("logs if we don't receive a 2xx response", async () => {
|
|
150
|
+
govUkPayApi.isGovPayUp.mockResolvedValueOnce({
|
|
151
|
+
ok: false
|
|
152
|
+
})
|
|
153
|
+
await isGovPayUp()
|
|
154
|
+
expect(mockDebug).toHaveBeenCalledWith('Health endpoint unavailable')
|
|
155
|
+
})
|
|
156
|
+
})
|
|
120
157
|
})
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { govUkPayApi } from '@defra-fish/connectors-lib'
|
|
2
|
+
import db from 'debug'
|
|
3
|
+
const debug = db('recurring-payments:gov.uk-pay-service')
|
|
2
4
|
|
|
3
5
|
export const sendPayment = async preparedPayment => {
|
|
4
6
|
try {
|
|
@@ -31,3 +33,13 @@ export const getPaymentStatus = async paymentId => {
|
|
|
31
33
|
throw error
|
|
32
34
|
}
|
|
33
35
|
}
|
|
36
|
+
|
|
37
|
+
export const isGovPayUp = async () => {
|
|
38
|
+
const response = await govUkPayApi.isGovPayUp()
|
|
39
|
+
if (response.ok) {
|
|
40
|
+
const isHealthy = JSON.parse(await response.text())
|
|
41
|
+
return isHealthy.ping.healthy && isHealthy.deadlocks.healthy
|
|
42
|
+
}
|
|
43
|
+
debug('Health endpoint unavailable')
|
|
44
|
+
return false
|
|
45
|
+
}
|