@defra-fish/recurring-payments-job 1.62.0-rc.8 → 1.62.0
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defra-fish/recurring-payments-job",
|
|
3
|
-
"version": "1.62.0
|
|
3
|
+
"version": "1.62.0",
|
|
4
4
|
"description": "Rod Licensing Recurring Payments Job",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -36,11 +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
|
|
40
|
-
"@defra-fish/connectors-lib": "1.62.0
|
|
39
|
+
"@defra-fish/business-rules-lib": "1.62.0",
|
|
40
|
+
"@defra-fish/connectors-lib": "1.62.0",
|
|
41
41
|
"commander": "^7.2.0",
|
|
42
42
|
"debug": "^4.3.3",
|
|
43
43
|
"moment-timezone": "^0.5.34"
|
|
44
44
|
},
|
|
45
|
-
"gitHead": "
|
|
45
|
+
"gitHead": "1f00bb713b3a160a8dfa3c355df23bb44f14c4f2"
|
|
46
46
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { salesApi } from '@defra-fish/connectors-lib'
|
|
2
|
-
import { PAYMENT_JOURNAL_STATUS_CODES } from '@defra-fish/business-rules-lib'
|
|
2
|
+
import { PAYMENT_STATUS, PAYMENT_JOURNAL_STATUS_CODES } from '@defra-fish/business-rules-lib'
|
|
3
3
|
import { processRecurringPayments } from '../recurring-payments-processor.js'
|
|
4
|
-
import { getPaymentStatus, sendPayment } from '../services/govuk-pay-service.js'
|
|
4
|
+
import { getPaymentStatus, isGovPayUp, sendPayment } from '../services/govuk-pay-service.js'
|
|
5
5
|
import db from 'debug'
|
|
6
6
|
|
|
7
7
|
jest.mock('@defra-fish/business-rules-lib', () => ({
|
|
@@ -33,13 +33,15 @@ jest.mock('@defra-fish/connectors-lib', () => ({
|
|
|
33
33
|
getPaymentJournal: jest.fn()
|
|
34
34
|
}
|
|
35
35
|
}))
|
|
36
|
+
|
|
36
37
|
jest.mock('../services/govuk-pay-service.js', () => ({
|
|
37
|
-
sendPayment: jest.fn(),
|
|
38
|
-
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)
|
|
39
41
|
}))
|
|
42
|
+
|
|
40
43
|
jest.mock('debug', () => jest.fn(() => jest.fn()))
|
|
41
44
|
|
|
42
|
-
const debugMock = db.mock.results[0].value
|
|
43
45
|
const PAYMENT_STATUS_DELAY = 60000
|
|
44
46
|
const getPaymentStatusSuccess = () => ({ state: { status: 'payment status success' } })
|
|
45
47
|
const getPaymentStatusFailure = () => ({ state: { status: 'payment status failure' } })
|
|
@@ -57,25 +59,37 @@ const getMockPaymentRequestResponse = () => [
|
|
|
57
59
|
}
|
|
58
60
|
]
|
|
59
61
|
|
|
62
|
+
const getMockDueRecurringPayment = (referenceNumber = '123', agreementId = 'test-agreement-id') => ({
|
|
63
|
+
entity: { agreementId },
|
|
64
|
+
expanded: { activePermission: { entity: { referenceNumber } } }
|
|
65
|
+
})
|
|
66
|
+
|
|
60
67
|
describe('recurring-payments-processor', () => {
|
|
68
|
+
const [{ value: debugLogger }] = db.mock.results
|
|
69
|
+
|
|
61
70
|
beforeEach(() => {
|
|
62
71
|
jest.clearAllMocks()
|
|
63
72
|
process.env.RUN_RECURRING_PAYMENTS = 'true'
|
|
64
73
|
global.setTimeout = jest.fn((cb, ms) => cb())
|
|
65
74
|
})
|
|
66
75
|
|
|
67
|
-
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 () => {
|
|
68
77
|
process.env.RUN_RECURRING_PAYMENTS = 'false'
|
|
69
78
|
|
|
70
79
|
await processRecurringPayments()
|
|
71
80
|
|
|
72
|
-
expect(
|
|
81
|
+
expect(debugLogger).toHaveBeenCalledWith('Recurring Payments job disabled')
|
|
73
82
|
})
|
|
74
83
|
|
|
75
|
-
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 () => {
|
|
76
85
|
await processRecurringPayments()
|
|
77
86
|
|
|
78
|
-
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.')
|
|
79
93
|
})
|
|
80
94
|
|
|
81
95
|
it('get recurring payments is called when env is true', async () => {
|
|
@@ -86,10 +100,143 @@ describe('recurring-payments-processor', () => {
|
|
|
86
100
|
expect(salesApi.getDueRecurringPayments).toHaveBeenCalledWith(date)
|
|
87
101
|
})
|
|
88
102
|
|
|
89
|
-
it('debug displays "Recurring Payments found:
|
|
103
|
+
it('debug log displays "Recurring Payments found:" when env is true', async () => {
|
|
90
104
|
await processRecurringPayments()
|
|
91
105
|
|
|
92
|
-
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
|
+
})
|
|
93
240
|
})
|
|
94
241
|
|
|
95
242
|
it('prepares the data for found recurring payments', async () => {
|
|
@@ -236,16 +383,6 @@ describe('recurring-payments-processor', () => {
|
|
|
236
383
|
)
|
|
237
384
|
})
|
|
238
385
|
|
|
239
|
-
it('raises an error if createTransaction fails', async () => {
|
|
240
|
-
salesApi.getDueRecurringPayments.mockReturnValueOnce([getMockDueRecurringPayment()])
|
|
241
|
-
const error = 'Wuh-oh!'
|
|
242
|
-
salesApi.createTransaction.mockImplementationOnce(() => {
|
|
243
|
-
throw new Error(error)
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
await expect(processRecurringPayments()).rejects.toThrowError(error)
|
|
247
|
-
})
|
|
248
|
-
|
|
249
386
|
it('prepares and sends the payment request', async () => {
|
|
250
387
|
const agreementId = Symbol('agreementId')
|
|
251
388
|
const transactionId = 'transactionId'
|
|
@@ -304,7 +441,7 @@ describe('recurring-payments-processor', () => {
|
|
|
304
441
|
expect(getPaymentStatus).toHaveBeenCalledWith('test-payment-id')
|
|
305
442
|
})
|
|
306
443
|
|
|
307
|
-
it('
|
|
444
|
+
it('should log payment status for recurring payment', async () => {
|
|
308
445
|
const mockPaymentId = 'test-payment-id'
|
|
309
446
|
const mockResponse = [
|
|
310
447
|
{
|
|
@@ -318,48 +455,76 @@ describe('recurring-payments-processor', () => {
|
|
|
318
455
|
}
|
|
319
456
|
}
|
|
320
457
|
]
|
|
321
|
-
const mockStatus = getPaymentStatusSuccess()
|
|
322
458
|
salesApi.getDueRecurringPayments.mockResolvedValueOnce(mockResponse)
|
|
323
459
|
salesApi.createTransaction.mockResolvedValueOnce({
|
|
324
460
|
id: mockPaymentId
|
|
325
461
|
})
|
|
326
462
|
const mockPaymentResponse = { payment_id: mockPaymentId, agreementId: 'agreement-1' }
|
|
327
463
|
sendPayment.mockResolvedValueOnce(mockPaymentResponse)
|
|
328
|
-
getPaymentStatus.mockResolvedValueOnce(
|
|
464
|
+
getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess())
|
|
329
465
|
|
|
330
466
|
await processRecurringPayments()
|
|
331
467
|
|
|
332
|
-
|
|
468
|
+
console.log(debugLogger.mock.calls)
|
|
469
|
+
expect(debugLogger).toHaveBeenCalledWith(`Payment status for ${mockPaymentId}: ${PAYMENT_STATUS.Success}`)
|
|
333
470
|
})
|
|
334
471
|
|
|
335
|
-
it('
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
339
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'
|
|
340
493
|
const mockResponse = [
|
|
341
|
-
{
|
|
342
|
-
entity: { agreementId: 'agreement-1' },
|
|
343
|
-
expanded: {
|
|
344
|
-
activePermission: {
|
|
345
|
-
entity: {
|
|
346
|
-
referenceNumber: 'ref-1'
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
}
|
|
494
|
+
{ entity: { agreementId: 'agreement-1' }, expanded: { activePermission: { entity: { referenceNumber: 'ref-1' } } } }
|
|
351
495
|
]
|
|
352
496
|
salesApi.getDueRecurringPayments.mockResolvedValueOnce(mockResponse)
|
|
353
|
-
salesApi.createTransaction.mockResolvedValueOnce({
|
|
354
|
-
|
|
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'
|
|
355
502
|
})
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
getPaymentStatus.
|
|
503
|
+
|
|
504
|
+
const apiError = { response: { status: statusCode, data: 'boom' } }
|
|
505
|
+
getPaymentStatus.mockRejectedValueOnce(apiError)
|
|
359
506
|
|
|
360
507
|
await processRecurringPayments()
|
|
361
508
|
|
|
362
|
-
expect(
|
|
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'
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
const networkError = new Error('network meltdown')
|
|
523
|
+
getPaymentStatus.mockRejectedValueOnce(networkError)
|
|
524
|
+
|
|
525
|
+
await processRecurringPayments()
|
|
526
|
+
|
|
527
|
+
expect(debugLogger).toHaveBeenCalledWith(`Unexpected error fetching payment status for ${mockPaymentId}.`)
|
|
363
528
|
})
|
|
364
529
|
|
|
365
530
|
it('should call setTimeout with correct delay when there are recurring payments', async () => {
|
|
@@ -387,6 +552,9 @@ describe('recurring-payments-processor', () => {
|
|
|
387
552
|
})
|
|
388
553
|
|
|
389
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
|
+
})
|
|
390
558
|
const mockTransactionId = 'test-transaction-id'
|
|
391
559
|
const mockPaymentId = 'test-payment-id'
|
|
392
560
|
const mockPaymentCreatedDate = '2025-01-01T00:00:00.000Z'
|
|
@@ -397,6 +565,7 @@ describe('recurring-payments-processor', () => {
|
|
|
397
565
|
|
|
398
566
|
await processRecurringPayments()
|
|
399
567
|
|
|
568
|
+
console.log(salesApi.processRPResult.mock.calls, mockTransactionId, mockPaymentId, mockPaymentCreatedDate)
|
|
400
569
|
expect(salesApi.processRPResult).toHaveBeenCalledWith(mockTransactionId, mockPaymentId, mockPaymentCreatedDate)
|
|
401
570
|
})
|
|
402
571
|
|
|
@@ -586,7 +755,7 @@ describe('recurring-payments-processor', () => {
|
|
|
586
755
|
|
|
587
756
|
const permits = []
|
|
588
757
|
for (let i = 0; i < count; i++) {
|
|
589
|
-
permits.push(`permit${i}`)
|
|
758
|
+
permits.push(Symbol(`permit${i}`))
|
|
590
759
|
}
|
|
591
760
|
|
|
592
761
|
permits.forEach((permit, i) => {
|
|
@@ -615,8 +784,3 @@ describe('recurring-payments-processor', () => {
|
|
|
615
784
|
})
|
|
616
785
|
})
|
|
617
786
|
})
|
|
618
|
-
|
|
619
|
-
const getMockDueRecurringPayment = (referenceNumber = '123', agreementId = 'test-agreement-id') => ({
|
|
620
|
-
entity: { agreementId },
|
|
621
|
-
expanded: { activePermission: { entity: { referenceNumber } } }
|
|
622
|
-
})
|
|
@@ -1,63 +1,91 @@
|
|
|
1
1
|
import moment from 'moment-timezone'
|
|
2
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 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
|
+
}
|
|
10
28
|
|
|
11
29
|
export const processRecurringPayments = async () => {
|
|
12
|
-
if (process.env.RUN_RECURRING_PAYMENTS?.toLowerCase()
|
|
13
|
-
debug('Recurring Payments job enabled')
|
|
14
|
-
const date = new Date().toISOString().split('T')[0]
|
|
15
|
-
const response = await salesApi.getDueRecurringPayments(date)
|
|
16
|
-
debug('Recurring Payments found: ', response)
|
|
17
|
-
await Promise.all(response.map(record => processRecurringPayment(record)))
|
|
18
|
-
if (response.length > 0) {
|
|
19
|
-
await new Promise(resolve => setTimeout(resolve, PAYMENT_STATUS_DELAY))
|
|
20
|
-
await Promise.all(response.map(record => processRecurringPaymentStatus(record)))
|
|
21
|
-
}
|
|
22
|
-
} else {
|
|
30
|
+
if (process.env.RUN_RECURRING_PAYMENTS?.toLowerCase() !== 'true') {
|
|
23
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.')
|
|
24
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
|
|
25
63
|
}
|
|
26
64
|
|
|
27
65
|
const processRecurringPayment = async record => {
|
|
28
66
|
const referenceNumber = record.expanded.activePermission.entity.referenceNumber
|
|
29
67
|
const agreementId = record.entity.agreementId
|
|
30
68
|
const transaction = await createNewTransaction(referenceNumber, agreementId)
|
|
31
|
-
|
|
69
|
+
return takeRecurringPayment(agreementId, transaction)
|
|
32
70
|
}
|
|
33
71
|
|
|
34
72
|
const createNewTransaction = async (referenceNumber, agreementId) => {
|
|
35
73
|
const transactionData = await processPermissionData(referenceNumber, agreementId)
|
|
36
|
-
|
|
37
|
-
try {
|
|
38
|
-
const response = await salesApi.createTransaction(transactionData)
|
|
39
|
-
debug('New transaction created:', response)
|
|
40
|
-
return response
|
|
41
|
-
} catch (e) {
|
|
42
|
-
console.error('Error creating transaction', JSON.stringify(transactionData))
|
|
43
|
-
throw e
|
|
44
|
-
}
|
|
74
|
+
return salesApi.createTransaction(transactionData)
|
|
45
75
|
}
|
|
46
76
|
|
|
47
77
|
const takeRecurringPayment = async (agreementId, transaction) => {
|
|
48
78
|
const preparedPayment = preparePayment(agreementId, transaction)
|
|
49
|
-
debug('Requesting payment:', preparedPayment)
|
|
50
79
|
const payment = await sendPayment(preparedPayment)
|
|
51
|
-
|
|
80
|
+
return {
|
|
52
81
|
agreementId,
|
|
53
82
|
paymentId: payment.payment_id,
|
|
54
83
|
created_date: payment.created_date,
|
|
55
84
|
transaction
|
|
56
|
-
}
|
|
85
|
+
}
|
|
57
86
|
}
|
|
58
87
|
|
|
59
88
|
const processPermissionData = async (referenceNumber, agreementId) => {
|
|
60
|
-
debug('Preparing data based on', referenceNumber, 'with agreementId', agreementId)
|
|
61
89
|
const data = await salesApi.preparePermissionDataForRenewal(referenceNumber)
|
|
62
90
|
const licenseeWithoutCountryCode = Object.assign((({ countryCode: _countryCode, ...l }) => l)(data.licensee))
|
|
63
91
|
return {
|
|
@@ -99,28 +127,37 @@ const preparePayment = (agreementId, transaction) => {
|
|
|
99
127
|
return result
|
|
100
128
|
}
|
|
101
129
|
|
|
102
|
-
const processRecurringPaymentStatus = async
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
if (status === PAYMENT_STATUS.Failure || status === PAYMENT_STATUS.Error) {
|
|
115
|
-
console.error(`Payment failed. Recurring payment agreement for: ${agreementId} set to be cancelled. Updating payment journal.`)
|
|
116
|
-
if (await salesApi.getPaymentJournal(payment.transaction.id)) {
|
|
117
|
-
await salesApi.updatePaymentJournal(payment.transaction.id, {
|
|
118
|
-
paymentStatus: PAYMENT_JOURNAL_STATUS_CODES.Failed
|
|
119
|
-
})
|
|
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}`)
|
|
120
141
|
}
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
123
154
|
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
}
|
|
126
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
|
+
}
|