@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.0",
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.0",
40
- "@defra-fish/connectors-lib": "1.62.0-rc.0",
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": "d56e1a0d0f4b22f1dded800c0ed511b737cb91a7"
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(debugMock).toHaveBeenCalledWith('Recurring Payments job disabled')
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(debugMock).toHaveBeenCalledWith('Recurring Payments job enabled')
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: " when env is true', async () => {
103
+ it('debug log displays "Recurring Payments found:" when env is true', async () => {
72
104
  await processRecurringPayments()
73
105
 
74
- expect(debugMock).toHaveBeenCalledWith('Recurring Payments found: ', [])
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('debug should log payment status for recurring payment', async () => {
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(mockStatus)
464
+ getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess())
311
465
 
312
466
  await processRecurringPayments()
313
467
 
314
- expect(debugMock).toHaveBeenCalledWith(`Payment status for ${mockPaymentId}: ${mockStatus.state.status}`)
468
+ console.log(debugLogger.mock.calls)
469
+ expect(debugLogger).toHaveBeenCalledWith(`Payment status for ${mockPaymentId}: ${PAYMENT_STATUS.Success}`)
315
470
  })
316
471
 
317
- it('debug should inform that the recurring payment has been processed', async () => {
318
- const mockTransactionId = 'test-transaction-id'
319
- salesApi.createTransaction.mockReturnValueOnce({
320
- cost: 50
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
- id: mockTransactionId
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
- const mockPaymentResponse = { payment_id: 'payment-id', agreementId: 'agreement-1' }
339
- sendPayment.mockResolvedValueOnce(mockPaymentResponse)
340
- getPaymentStatus.mockResolvedValueOnce(getPaymentStatusSuccess())
521
+
522
+ const networkError = new Error('network meltdown')
523
+ getPaymentStatus.mockRejectedValueOnce(networkError)
341
524
 
342
525
  await processRecurringPayments()
343
526
 
344
- expect(debugMock).toHaveBeenCalledWith(`Processed Recurring Payment for ${mockTransactionId}`)
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({ state: { status: 'Pending' } })
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.processRPResult).not.toHaveBeenCalledWith()
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 payments = []
10
- const PAYMENT_STATUS_SUCCESS = 'success'
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() === 'true') {
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
- await takeRecurringPayment(agreementId, transaction)
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
- debug('Creating new transaction based on: ', referenceNumber, 'with agreementId: ', agreementId)
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
- payments.push({
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 record => {
104
- const agreementId = record.entity.agreementId
105
- const paymentId = getPaymentId(agreementId)
106
- const {
107
- state: { status }
108
- } = await getPaymentStatus(paymentId)
109
- debug(`Payment status for ${paymentId}: ${status}`)
110
- if (status === PAYMENT_STATUS_SUCCESS) {
111
- const payment = payments.find(p => p.paymentId === paymentId)
112
- await salesApi.processRPResult(payment.transaction.id, paymentId, payment.created_date)
113
- debug(`Processed Recurring Payment for ${payment.transaction.id}`)
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
- const getPaymentId = agreementId => {
118
- return payments.find(p => p.agreementId === agreementId).paymentId
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
+ }