@defra-fish/payment-mop-up-job 1.77.0-rc.8 → 1.77.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/payment-mop-up-job",
3
- "version": "1.77.0-rc.8",
3
+ "version": "1.77.0",
4
4
  "description": "Process incomplete web-sales",
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.77.0-rc.8",
40
- "@defra-fish/connectors-lib": "1.77.0-rc.8",
39
+ "@defra-fish/business-rules-lib": "1.77.0",
40
+ "@defra-fish/connectors-lib": "1.77.0",
41
41
  "bottleneck": "2.19.5",
42
42
  "debug": "4.3.3",
43
43
  "moment": "2.29.1"
44
44
  },
45
- "gitHead": "c00b869cc5cbac68623e4493fa1661baf3eb5b10"
45
+ "gitHead": "b01f396cae95e7a8ff6963e0369568d549afcc63"
46
46
  }
@@ -1,7 +1,9 @@
1
1
  import { salesApi, govUkPayApi } from '@defra-fish/connectors-lib'
2
- import { execute } from '../processor.js'
2
+ import { execute, formatTransactionData } from '../processor.js'
3
3
  import { GOVUK_PAY_ERROR_STATUS_CODES, PAYMENT_JOURNAL_STATUS_CODES, PAYMENT_STATUS } from '@defra-fish/business-rules-lib'
4
+ import { StatusCodes } from 'http-status-codes'
4
5
  import moment from 'moment'
6
+ import db from 'debug'
5
7
 
6
8
  jest.mock('@defra-fish/connectors-lib')
7
9
  // mock bottleneck to speed up test execution
@@ -11,45 +13,122 @@ jest.mock('bottleneck', () =>
11
13
  })
12
14
  )
13
15
 
16
+ jest.mock('debug', () => {
17
+ const debugInstance = jest.fn()
18
+ return jest.fn(() => debugInstance)
19
+ })
20
+
21
+ const debugMock = db()
22
+
14
23
  const journalEntries = () => [
15
24
  {
16
25
  id: '4fa393ab-07f4-407e-b233-89be2a6f5f77',
17
26
  eligibleForEarlyMopUp: false,
18
27
  paymentStatus: 'In Progress',
19
28
  paymentReference: '05nioqikvvnuu5l8m2qeaj0qap',
20
- paymentTimestamp: '2020-06-01T10:35:56.873Z'
29
+ paymentTimestamp: '2020-06-01T10:35:56.873Z',
30
+ permissions: [
31
+ {
32
+ referenceNumber: '23271027-2WC3FSG-AFPDA8',
33
+ licensee: {
34
+ firstName: 'Spooky',
35
+ lastName: 'Ghost'
36
+ }
37
+ }
38
+ ],
39
+ data: {
40
+ cost: 36.8,
41
+ dataSource: 'Web Sales',
42
+ id: 'd367bd08-7c5c-45b6-8581-f60225d59492'
43
+ }
21
44
  },
22
45
  {
23
46
  id: 'aaced854-d337-47ee-8d5e-75b26aeb90fb',
24
47
  eligibleForEarlyMopUp: false,
25
48
  paymentStatus: 'In Progress',
26
49
  paymentReference: '0f3dr9ugp7u68qq18vt9h8ma85',
27
- paymentTimestamp: '2020-06-02T07:17:23.169Z'
50
+ paymentTimestamp: '2020-06-02T07:17:23.169Z',
51
+ permissions: [
52
+ {
53
+ referenceNumber: '23271028-3XD4GRH-BGQEB9',
54
+ licensee: {
55
+ firstName: 'John',
56
+ lastName: 'Smith'
57
+ }
58
+ }
59
+ ],
60
+ data: {
61
+ cost: 45.5,
62
+ dataSource: 'Phone Sales',
63
+ id: 'e478ce19-8d6d-56c7-9692-g71336d60603'
64
+ }
28
65
  },
29
66
  {
30
67
  id: 'a0e0e5c3-1004-4271-80ba-d05eda3e8213',
31
68
  eligibleForEarlyMopUp: false,
32
69
  paymentStatus: 'In Progress',
33
70
  paymentReference: '7lufvi9sbh077rvrrmnqo63vme',
34
- paymentTimestamp: '2020-06-04T11:04:30.802Z'
71
+ paymentTimestamp: '2020-06-04T11:04:30.802Z',
72
+ permissions: [
73
+ {
74
+ referenceNumber: '23271029-4YE5HSI-CHRFC0',
75
+ licensee: {
76
+ firstName: 'Jane',
77
+ lastName: 'Doe'
78
+ }
79
+ }
80
+ ],
81
+ data: {
82
+ cost: 52.25,
83
+ dataSource: 'Web Sales',
84
+ id: 'f589df2a-9e7e-67d8-0803-h82447e71714'
85
+ }
35
86
  },
36
87
  {
37
88
  id: 'a0e0e5c3-1004-4271-80ba-d05eda3e8214',
38
89
  eligibleForEarlyMopUp: true,
39
90
  paymentStatus: 'In Progress',
40
91
  paymentReference: '7lufvi9sbh077rvrrmnqo63vmf',
41
- paymentTimestamp: '2020-06-04T12:04:30.802Z'
92
+ paymentTimestamp: '2020-06-04T12:04:30.802Z',
93
+ permissions: [
94
+ {
95
+ referenceNumber: '23271030-5ZF6ITJ-DISSGD1',
96
+ licensee: {
97
+ firstName: 'Bob',
98
+ lastName: 'Johnson'
99
+ }
100
+ }
101
+ ],
102
+ data: {
103
+ cost: 28.9,
104
+ dataSource: 'Post Office',
105
+ id: 'g69aeg3b-0f8f-78e9-1914-i93558f82825'
106
+ }
42
107
  },
43
108
  {
44
109
  id: 'a0e0e5c3-1004-4271-80ba-d05eda3e8215',
45
110
  eligibleForEarlyMopUp: false,
46
111
  paymentStatus: 'In Progress',
47
112
  paymentReference: '7lufvi9sbh077rvrrmnqo63vmg',
48
- paymentTimestamp: '2020-06-04T12:04:30.802Z'
113
+ paymentTimestamp: '2020-06-04T12:04:30.802Z',
114
+ permissions: [
115
+ {
116
+ referenceNumber: '23271031-6AG7JUK-EJUTAHE2',
117
+ licensee: {
118
+ firstName: 'Alice',
119
+ lastName: 'Williams'
120
+ }
121
+ }
122
+ ],
123
+ data: {
124
+ cost: 19.75,
125
+ dataSource: 'Web Sales',
126
+ id: 'h7aabfh4c-1g9g-89fa-2a25-j04669g93936'
127
+ }
49
128
  }
50
129
  ]
51
130
 
52
- const govUkPayStatusEntries = [
131
+ const govUkPayStatusEntries = () => [
53
132
  {
54
133
  payment_id: '05nioqikvvnuu5l8m2qeaj0qap',
55
134
  state: {
@@ -130,8 +209,63 @@ const createPaymentEventsEntry = paymentStatus => {
130
209
  }
131
210
  }
132
211
 
212
+ const mockSingleSuccessfulPayment = () => {
213
+ const [entry] = journalEntries()
214
+ const [status] = govUkPayStatusEntries()
215
+ salesApi.paymentJournals.getAll.mockReturnValueOnce([entry])
216
+ govUkPayApi.fetchPaymentStatus.mockReturnValueOnce({ json: async () => status })
217
+ govUkPayApi.fetchPaymentEvents.mockReturnValueOnce({ json: async () => createPaymentEventsEntry(status) })
218
+ return entry
219
+ }
220
+
221
+ const mockSingleRecurringPayment = () => {
222
+ const [entry] = journalEntries()
223
+ salesApi.paymentJournals.getAll.mockReturnValueOnce([entry])
224
+ salesApi.retrieveStagedTransaction.mockReturnValueOnce({ recurringPayment: { agreementId: 'abc-123', id: 'recurring-payment-id' } })
225
+ govUkPayApi.fetchPaymentStatus.mockReturnValueOnce({ json: async () => ({ state: { status: PAYMENT_STATUS.Error } }) })
226
+ return entry
227
+ }
228
+
229
+ const createError = status => Object.assign(new Error(`Error with status ${status}`), { status })
230
+
231
+ describe('formatTransactionData', () => {
232
+ it('returns empty string when transaction has no data property', () => {
233
+ const transaction = { id: 'test-id' }
234
+ const result = formatTransactionData(transaction)
235
+ expect(result).toBe('')
236
+ })
237
+
238
+ it('returns formatted string with transaction data fields', () => {
239
+ const transaction = {
240
+ id: 'test-id',
241
+ data: {
242
+ cost: 36.8,
243
+ dataSource: 'Web Sales',
244
+ id: 'd367bd08-7c5c-45b6-8581-f60225d59492'
245
+ }
246
+ }
247
+ const result = formatTransactionData(transaction)
248
+ expect(result).toBe(' [cost: 36.8, dataSource: Web Sales, id: d367bd08-7c5c-45b6-8581-f60225d59492]')
249
+ })
250
+
251
+ it('returns empty string when transaction data has no truthy fields', () => {
252
+ const transaction = { id: 'test-id', data: {} }
253
+ const result = formatTransactionData(transaction)
254
+ expect(result).toBe('')
255
+ })
256
+ })
257
+
133
258
  describe('processor', () => {
134
- beforeEach(jest.clearAllMocks)
259
+ beforeEach(() => {
260
+ jest.clearAllMocks()
261
+ govUkPayApi.fetchPaymentStatus.mockReset()
262
+ govUkPayApi.fetchPaymentEvents.mockReset()
263
+ jest.spyOn(console, 'error').mockImplementation(() => {})
264
+ })
265
+
266
+ afterEach(() => {
267
+ console.error.mockRestore()
268
+ })
135
269
 
136
270
  beforeAll(() => {
137
271
  salesApi.paymentJournals.getAll.mockReturnValue([])
@@ -145,16 +279,17 @@ describe('processor', () => {
145
279
 
146
280
  it('completes normally where there are journal records retrieved', async () => {
147
281
  salesApi.paymentJournals.getAll.mockReturnValueOnce(journalEntries())
148
- govUkPayStatusEntries.forEach(status => {
282
+ const payStatusEntries = govUkPayStatusEntries()
283
+ payStatusEntries.forEach(status => {
149
284
  govUkPayApi.fetchPaymentStatus.mockReturnValueOnce({ json: async () => status })
150
285
  govUkPayApi.fetchPaymentEvents.mockReturnValueOnce({ json: async () => createPaymentEventsEntry(status) })
151
286
  })
152
287
 
153
288
  await execute(1, 1)
154
289
 
155
- expect(govUkPayApi.fetchPaymentStatus).toHaveBeenCalledTimes(govUkPayStatusEntries.length)
156
- expect(govUkPayApi.fetchPaymentEvents).toHaveBeenCalledTimes(govUkPayStatusEntries.filter(s => s.state.status === 'success').length)
157
- expect(salesApi.finaliseTransaction).toHaveBeenCalledTimes(govUkPayStatusEntries.filter(s => s.state.status === 'success').length)
290
+ expect(govUkPayApi.fetchPaymentStatus).toHaveBeenCalledTimes(payStatusEntries.length)
291
+ expect(govUkPayApi.fetchPaymentEvents).toHaveBeenCalledTimes(payStatusEntries.filter(s => s.state.status === 'success').length)
292
+ expect(salesApi.finaliseTransaction).toHaveBeenCalledTimes(payStatusEntries.filter(s => s.state.status === 'success').length)
158
293
  expect(salesApi.finaliseTransaction).toHaveBeenCalledWith('4fa393ab-07f4-407e-b233-89be2a6f5f77', {
159
294
  payment: { amount: 82, method: 'Debit card', source: 'Gov Pay', timestamp: 'FINAL_PAYMENT_EVENT_TIMESTAMP' }
160
295
  })
@@ -175,6 +310,17 @@ describe('processor', () => {
175
310
  })
176
311
  })
177
312
 
313
+ it('treats a 410 error code from finaliseTransaction as already finalised and verifies journal status update', async () => {
314
+ const entry = mockSingleSuccessfulPayment()
315
+ salesApi.finaliseTransaction.mockRejectedValueOnce(Object.assign(new Error('Gone'), { status: StatusCodes.GONE }))
316
+
317
+ await execute(1, 1)
318
+
319
+ expect(salesApi.updatePaymentJournal).toHaveBeenCalledWith(entry.id, {
320
+ paymentStatus: PAYMENT_JOURNAL_STATUS_CODES.Completed
321
+ })
322
+ })
323
+
178
324
  it.each([
179
325
  ['2026-09-10T09:00:00.000Z', 1, 1],
180
326
  ['2024-02-29T19:00:00.000Z', 14, 5],
@@ -238,31 +384,13 @@ describe('processor', () => {
238
384
  jest.useRealTimers()
239
385
  })
240
386
 
241
- it('calls fetchPaymentStatus with recurring as true since agreementId exists', async () => {
242
- salesApi.retrieveStagedTransaction.mockReturnValueOnce({ recurringPayment: { agreementId: '123' } })
243
- const paymentReference = '15nioqikvvnuu5l8m2qeaj0qap'
244
- salesApi.paymentJournals.getAll.mockReturnValueOnce([
245
- {
246
- id: 'a0e0e5c3-1004-4271-80ba-d05eda3e8220',
247
- paymentStatus: 'In Progress',
248
- paymentReference,
249
- paymentTimestamp: '2020-06-01T10:35:56.873Z'
250
- }
251
- ])
252
- govUkPayApi.fetchPaymentStatus.mockResolvedValueOnce({
253
- json: async () => ({ state: { status: 'success' } })
254
- })
255
- govUkPayApi.fetchPaymentEvents.mockResolvedValueOnce({
256
- json: async () => ({ events: [{ state: { status: 'success' }, updated: '2020-06-01T10:35:56.873Z' }] })
257
- })
258
-
259
- await execute(1, 1)
260
-
261
- expect(govUkPayApi.fetchPaymentStatus).toHaveBeenCalledWith(paymentReference, true)
262
- })
263
-
264
- it('calls fetchPaymentStatus with recurring as false since agreementId does not exist', async () => {
265
- const paymentReference = '25nioqikvvnuu5l8m2qeaj0qap'
387
+ it.each([
388
+ ['05nioqikvvnuu5l8m2qeaj0qap', true, { recurringPayment: { agreementId: '123' } }],
389
+ ['0f3dr9ugp7u68qq18vt9h8ma85', false, {}]
390
+ ])('calls fetchPaymentStatus with recurring=%s', async (paymentReference, recurringFlag, stagingReturn) => {
391
+ if (stagingReturn.recurringPayment) {
392
+ salesApi.retrieveStagedTransaction.mockReturnValueOnce(stagingReturn)
393
+ }
266
394
  salesApi.paymentJournals.getAll.mockReturnValueOnce([
267
395
  {
268
396
  id: 'a0e0e5c3-1004-4271-80ba-d05eda3e8220',
@@ -271,41 +399,25 @@ describe('processor', () => {
271
399
  paymentTimestamp: '2020-06-01T10:35:56.873Z'
272
400
  }
273
401
  ])
274
- govUkPayApi.fetchPaymentStatus.mockReturnValueOnce({
275
- json: async () => ({ state: { status: 'success' } })
276
- })
277
-
278
- await execute(1, 1)
279
-
280
- expect(govUkPayApi.fetchPaymentStatus).toHaveBeenCalledWith(paymentReference, false)
281
- })
282
-
283
- it('calls fetchPaymentEvents with recurring as true since agreementId exists', async () => {
284
- salesApi.retrieveStagedTransaction.mockReturnValueOnce({ recurringPayment: { agreementId: '123' } })
285
- const paymentReference = '35nioqikvvnuu5l8m2qeaj0qap'
286
- salesApi.paymentJournals.getAll.mockReturnValueOnce([
287
- {
288
- id: 'a0e0e5c3-1004-4271-80ba-d05eda3e8220',
289
- paymentStatus: 'In Progress',
290
- paymentReference,
291
- paymentTimestamp: '2020-06-01T10:35:56.873Z',
292
- agreementId: 'c9267c6e-573d-488b-99ab-ea18431fc472'
293
- }
294
- ])
295
402
  govUkPayApi.fetchPaymentStatus.mockResolvedValueOnce({
296
403
  json: async () => ({ state: { status: 'success' } })
297
404
  })
298
405
  govUkPayApi.fetchPaymentEvents.mockResolvedValueOnce({
299
- json: async () => ({ events: [{ state: { status: 'success' }, updated: '2020-06-01T10:35:56.873Z' }] })
406
+ json: async () => ({ events: [{ state: { status: 'success' }, updated: '2026-10-27T00:00:00.000Z' }] })
300
407
  })
301
408
 
302
409
  await execute(1, 1)
303
410
 
304
- expect(govUkPayApi.fetchPaymentEvents).toHaveBeenCalledWith(paymentReference, true)
411
+ expect(govUkPayApi.fetchPaymentStatus).toHaveBeenCalledWith(paymentReference, recurringFlag)
305
412
  })
306
413
 
307
- it('calls fetchPaymentEvents with recurring as false since agreementId does not exist', async () => {
308
- const paymentReference = '45nioqikvvnuu5l8m2qeaj0qap'
414
+ it.each([
415
+ ['7lufvi9sbh077rvrrmnqo63vme', true, { recurringPayment: { agreementId: '123' } }],
416
+ ['7lufvi9sbh077rvrrmnqo63vmf', false, {}]
417
+ ])('calls fetchPaymentEvents with recurring=%s', async (paymentReference, recurringFlag, stagingReturn) => {
418
+ if (stagingReturn.recurringPayment) {
419
+ salesApi.retrieveStagedTransaction.mockReturnValueOnce(stagingReturn)
420
+ }
309
421
  salesApi.paymentJournals.getAll.mockReturnValueOnce([
310
422
  {
311
423
  id: 'a0e0e5c3-1004-4271-80ba-d05eda3e8220',
@@ -318,12 +430,12 @@ describe('processor', () => {
318
430
  json: async () => ({ state: { status: 'success' } })
319
431
  })
320
432
  govUkPayApi.fetchPaymentEvents.mockReturnValueOnce({
321
- json: async () => ({ events: [{ state: { status: 'success' } }] })
433
+ json: async () => ({ events: [{ state: { status: 'success' }, updated: '2026-10-27T00:00:00.000Z' }] })
322
434
  })
323
435
 
324
436
  await execute(1, 1)
325
437
 
326
- expect(govUkPayApi.fetchPaymentEvents).toHaveBeenCalledWith(paymentReference, false)
438
+ expect(govUkPayApi.fetchPaymentEvents).toHaveBeenCalledWith(paymentReference, recurringFlag)
327
439
  })
328
440
 
329
441
  describe("When a payment isn't present in GovPay", () => {
@@ -331,6 +443,7 @@ describe('processor', () => {
331
443
  const sampleJournalEntries = journalEntries()
332
444
  const NOT_FOUND_ID = sampleJournalEntries[2].id
333
445
  const NOT_FOUND_PAYMENT_REFERENCE = sampleJournalEntries[2].paymentReference
446
+ const payStatusEntries = govUkPayStatusEntries()
334
447
 
335
448
  salesApi.paymentJournals.getAll.mockReturnValueOnce(sampleJournalEntries)
336
449
 
@@ -338,14 +451,14 @@ describe('processor', () => {
338
451
  if (paymentReference === NOT_FOUND_PAYMENT_REFERENCE) {
339
452
  return { json: async () => govUkPayStatusNotFound() }
340
453
  }
341
- return { json: async () => createPaymentEventsEntry(govUkPayStatusEntries.find(se => se.payment_id === paymentReference)) }
454
+ return { json: async () => createPaymentEventsEntry(payStatusEntries.find(se => se.payment_id === paymentReference)) }
342
455
  })
343
456
 
344
457
  govUkPayApi.fetchPaymentStatus.mockImplementation(paymentReference => {
345
458
  if (paymentReference === NOT_FOUND_PAYMENT_REFERENCE) {
346
459
  return { json: async () => govUkPayStatusNotFound() }
347
460
  }
348
- return { json: async () => govUkPayStatusEntries.find(se => se.payment_id === paymentReference) }
461
+ return { json: async () => payStatusEntries.find(se => se.payment_id === paymentReference) }
349
462
  })
350
463
 
351
464
  return {
@@ -353,7 +466,6 @@ describe('processor', () => {
353
466
  NOT_FOUND_ID
354
467
  }
355
468
  }
356
- afterEach(() => govUkPayApi.fetchPaymentStatus.mockClear())
357
469
 
358
470
  it('no error is thrown', async () => {
359
471
  absentPaymentSetup()
@@ -418,7 +530,6 @@ describe('processor', () => {
418
530
  await execute(1, 1)
419
531
 
420
532
  expect(salesApi.cancelRecurringPayment).toHaveBeenCalledWith(rpid)
421
- salesApi.retrieveStagedTransaction.mockClear()
422
533
  })
423
534
 
424
535
  it("doesn't call cancel for payments without a recurring payment", async () => {
@@ -475,6 +586,9 @@ describe('processor', () => {
475
586
  it("doesn't cancel Recurring Payment if payment succeeded", async () => {
476
587
  salesApi.paymentJournals.getAll.mockReturnValueOnce([journalEntries()[0]])
477
588
  salesApi.retrieveStagedTransaction.mockReturnValueOnce({ recurringPayment: { agreementId: 'abc-123', id: 'def-456' } })
589
+ govUkPayApi.fetchPaymentEvents.mockResolvedValueOnce({
590
+ json: async () => ({ events: [{ state: { status: 'success', updated: '2026-10-27T00:00:00.000Z' } }] })
591
+ })
478
592
  govUkPayApi.fetchPaymentStatus.mockReturnValueOnce({
479
593
  json: async () => ({ state: { status: 'success' } })
480
594
  })
@@ -483,4 +597,147 @@ describe('processor', () => {
483
597
 
484
598
  expect(salesApi.cancelRecurringPayment).not.toHaveBeenCalled()
485
599
  })
600
+
601
+ it('When an error is encountered in salesApi.finalise transaction, other payments are still mopped up', async () => {
602
+ const journals = journalEntries()
603
+ salesApi.paymentJournals.getAll.mockReturnValueOnce(journals)
604
+ for (let x = 0; x < journals.length; x++) {
605
+ govUkPayApi.fetchPaymentStatus.mockResolvedValueOnce({ json: async () => ({ state: { status: 'success' } }) })
606
+ govUkPayApi.fetchPaymentEvents.mockResolvedValueOnce({
607
+ json: async () => ({ events: [{ state: { status: 'success', updated: '2026-10-27T00:00:00.000Z' } }] })
608
+ })
609
+ }
610
+ salesApi.finaliseTransaction.mockRejectedValueOnce(
611
+ Object.assign(new Error('Unexpected response from the Sales API: { "ok": false, "status": 410, "statusText": "Gone" }'), {
612
+ status: 410
613
+ })
614
+ )
615
+
616
+ await execute(1, 1)
617
+
618
+ expect(salesApi.finaliseTransaction).toHaveBeenCalledTimes(5)
619
+ })
620
+
621
+ it('cancels the recurring payment when updatePaymentJournal fails with 410', async () => {
622
+ mockSingleRecurringPayment()
623
+ salesApi.updatePaymentJournal.mockRejectedValueOnce(createError(StatusCodes.GONE))
624
+
625
+ await execute(1, 1)
626
+
627
+ expect(salesApi.cancelRecurringPayment).toHaveBeenCalled()
628
+ })
629
+
630
+ it('does not update any payment journal when retrieveStagedTransaction fails with 410', async () => {
631
+ const entry = journalEntries()[0]
632
+ salesApi.paymentJournals.getAll.mockReturnValueOnce([entry])
633
+ salesApi.retrieveStagedTransaction.mockRejectedValueOnce(Object.assign(new Error('Gone'), { status: StatusCodes.GONE }))
634
+
635
+ await execute(1, 1)
636
+
637
+ expect(salesApi.updatePaymentJournal).not.toHaveBeenCalled()
638
+ })
639
+
640
+ it('rethrows non-410 errors from retrieveStagedTransaction', async () => {
641
+ const entry = journalEntries()[0]
642
+ salesApi.paymentJournals.getAll.mockReturnValueOnce([entry])
643
+ salesApi.retrieveStagedTransaction.mockRejectedValueOnce(Object.assign(new Error('Retrieval error'), { status: 500 }))
644
+
645
+ await expect(execute(1, 1)).rejects.toThrow('Retrieval error')
646
+ })
647
+
648
+ it('skips 410 transactions but continues processing successful ones in the same batch', async () => {
649
+ const entries = journalEntries().slice(0, 2)
650
+ salesApi.paymentJournals.getAll.mockReturnValueOnce(entries)
651
+ salesApi.retrieveStagedTransaction.mockRejectedValueOnce(Object.assign(new Error('Gone'), { status: StatusCodes.GONE }))
652
+ salesApi.retrieveStagedTransaction.mockReturnValueOnce({ recurringPayment: null })
653
+ govUkPayApi.fetchPaymentStatus.mockReturnValueOnce({
654
+ json: async () => govUkPayStatusEntries()[0]
655
+ })
656
+ govUkPayApi.fetchPaymentEvents.mockReturnValueOnce({
657
+ json: async () => createPaymentEventsEntry(govUkPayStatusEntries()[0])
658
+ })
659
+
660
+ await execute(1, 1)
661
+
662
+ expect(salesApi.updatePaymentJournal.mock.calls).toEqual([[entries[1].id, expect.any(Object)]])
663
+ })
664
+
665
+ describe('logging', () => {
666
+ it('logs how many in-progress journals were found and how many of them are gone with a 410 error', async () => {
667
+ const [goneEntry, otherEntry] = journalEntries()
668
+ salesApi.paymentJournals.getAll.mockReturnValueOnce([goneEntry, otherEntry])
669
+ salesApi.retrieveStagedTransaction.mockRejectedValueOnce(createError(StatusCodes.GONE))
670
+ govUkPayApi.fetchPaymentStatus.mockReturnValueOnce({ json: async () => ({ state: { status: PAYMENT_STATUS.Error } }) })
671
+
672
+ await execute(1, 1)
673
+
674
+ expect(debugMock).toHaveBeenCalledWith('Found 2 in-progress payment journals, 1 of which are gone - 410 error')
675
+ })
676
+
677
+ describe.each([
678
+ [
679
+ 'finalising the transaction',
680
+ error => {
681
+ mockSingleSuccessfulPayment()
682
+ salesApi.finaliseTransaction.mockRejectedValueOnce(error)
683
+ }
684
+ ],
685
+ [
686
+ 'updating the payment journal',
687
+ error => {
688
+ mockSingleSuccessfulPayment()
689
+ salesApi.updatePaymentJournal.mockRejectedValueOnce(error)
690
+ }
691
+ ],
692
+ [
693
+ 'cancelling the recurring payment',
694
+ error => {
695
+ mockSingleRecurringPayment()
696
+ salesApi.cancelRecurringPayment.mockRejectedValueOnce(error)
697
+ }
698
+ ],
699
+ [
700
+ 'retrieving the staged transaction',
701
+ error => {
702
+ salesApi.paymentJournals.getAll.mockReturnValueOnce([journalEntries()[0]])
703
+ salesApi.retrieveStagedTransaction.mockRejectedValueOnce(error)
704
+ }
705
+ ]
706
+ ])('When %s fails', (_d, setupFailure) => {
707
+ it('logs the error with console.error when there is a non-410 error', async () => {
708
+ const error = createError(StatusCodes.INTERNAL_SERVER_ERROR)
709
+ setupFailure(error)
710
+
711
+ await execute(1, 1).catch(() => {})
712
+
713
+ expect(console.error).toHaveBeenCalledWith(expect.any(String), error)
714
+ })
715
+
716
+ it('does not log with console.error when there is a 410 error', async () => {
717
+ setupFailure(createError(StatusCodes.GONE))
718
+
719
+ await execute(1, 1)
720
+
721
+ expect(console.error).not.toHaveBeenCalled()
722
+ })
723
+
724
+ it('logs the error with debug when there is a 410 error', async () => {
725
+ const error = createError(StatusCodes.GONE)
726
+ setupFailure(error)
727
+
728
+ await execute(1, 1)
729
+
730
+ expect(debugMock).toHaveBeenCalledWith(error)
731
+ })
732
+
733
+ it('does not log with debug when there is a non-410 error', async () => {
734
+ const error = createError(StatusCodes.INTERNAL_SERVER_ERROR)
735
+ setupFailure(error)
736
+
737
+ await execute(1, 1).catch(() => {})
738
+
739
+ expect(debugMock).not.toHaveBeenCalledWith(error)
740
+ })
741
+ })
742
+ })
486
743
  })
@@ -9,6 +9,7 @@ import {
9
9
  import Bottleneck from 'bottleneck'
10
10
  import moment from 'moment'
11
11
  import db from 'debug'
12
+ import { StatusCodes } from 'http-status-codes'
12
13
  import { RATE_LIMIT_MS_DEFAULT, CONCURRENCY_DEFAULT } from '../constants.js'
13
14
  const debug = db('payment-mop-up-job:execute')
14
15
 
@@ -19,6 +20,17 @@ const limiter = new Bottleneck({
19
20
  maxConcurrent: process.env.CONCURRENCY || CONCURRENCY_DEFAULT
20
21
  })
21
22
 
23
+ const formatTransactionData = transaction => {
24
+ if (!transaction.data) {
25
+ return ''
26
+ }
27
+ const { cost, dataSource, id } = transaction.data
28
+ const fields = [cost !== undefined && `cost: ${cost}`, dataSource && `dataSource: ${dataSource}`, id && `id: ${id}`].filter(Boolean)
29
+ return fields.length ? ` [${fields.join(', ')}]` : ''
30
+ }
31
+
32
+ export { formatTransactionData }
33
+
22
34
  const getPaymentJournalStatusCodeFromTransaction = transaction => {
23
35
  if (transaction.paymentStatus.state?.status === PAYMENT_STATUS.Error) {
24
36
  return PAYMENT_JOURNAL_STATUS_CODES.Failed
@@ -70,30 +82,72 @@ const shouldCancelRecurringPayment = transaction => {
70
82
  return false
71
83
  }
72
84
 
73
- const processPaymentResults = async transaction => {
85
+ const finaliseTransaction = async transaction => {
74
86
  if (transaction.paymentStatus.state?.status === 'success') {
75
- debug(`Completing mop up finalization for transaction id: ${transaction.id}`)
76
- await salesApi.finaliseTransaction(transaction.id, {
77
- payment: {
78
- amount: transaction.paymentStatus.amount / 100,
79
- timestamp: transaction.paymentStatus.transactionTimestamp,
80
- source: TRANSACTION_SOURCE.govPay,
81
- method: PAYMENT_TYPE.debit
87
+ try {
88
+ await salesApi.finaliseTransaction(transaction.id, {
89
+ payment: {
90
+ amount: transaction.paymentStatus.amount / 100,
91
+ timestamp: transaction.paymentStatus.transactionTimestamp,
92
+ source: TRANSACTION_SOURCE.govPay,
93
+ method: PAYMENT_TYPE.debit
94
+ }
95
+ })
96
+ debug(`Completing mop up finalisation for transaction id: ${transaction.id}`)
97
+ } catch (error) {
98
+ if (error.status !== StatusCodes.GONE) {
99
+ console.error(`Transaction id: ${transaction.id} finalisation failed${formatTransactionData(transaction)}`, error)
100
+ throw error
82
101
  }
83
- })
102
+
103
+ debug(`Transaction id: ${transaction.id} has already been finalised (410 Gone)${formatTransactionData(transaction)}`)
104
+ debug(error)
105
+ }
84
106
  }
107
+ }
85
108
 
109
+ const updatePaymentJournalIfNeeded = async transaction => {
86
110
  if (shouldUpdatePaymentJournal(transaction)) {
87
- await salesApi.updatePaymentJournal(transaction.id, {
88
- paymentStatus: getPaymentJournalStatusCodeFromTransaction(transaction)
89
- })
111
+ try {
112
+ await salesApi.updatePaymentJournal(transaction.id, {
113
+ paymentStatus: getPaymentJournalStatusCodeFromTransaction(transaction)
114
+ })
115
+ debug(`Mop up journal updated for transaction id: ${transaction.id}`)
116
+ } catch (error) {
117
+ if (error.status !== StatusCodes.GONE) {
118
+ console.error(`Transaction id: ${transaction.id} journal update failed${formatTransactionData(transaction)}`, error)
119
+ throw error
120
+ }
121
+
122
+ debug(`Transaction id: ${transaction.id} journal already updated (410 Gone)${formatTransactionData(transaction)}`)
123
+ debug(error)
124
+ }
90
125
  }
126
+ }
91
127
 
128
+ const cancelRecurringPaymentIfNeeded = async transaction => {
92
129
  if (shouldCancelRecurringPayment(transaction)) {
93
- await salesApi.cancelRecurringPayment(transaction.recurringPaymentId)
130
+ try {
131
+ await salesApi.cancelRecurringPayment(transaction.recurringPaymentId)
132
+ debug(`Mop up recurring payment cancelled for transaction id: ${transaction.id}`)
133
+ } catch (error) {
134
+ if (error.status !== StatusCodes.GONE) {
135
+ console.error(`Transaction id: ${transaction.id} recurring payment cancellation failed${formatTransactionData(transaction)}`, error)
136
+ throw error
137
+ }
138
+
139
+ debug(`Transaction id: ${transaction.id} recurring payment already cancelled (410 Gone)${formatTransactionData(transaction)}`)
140
+ debug(error)
141
+ }
94
142
  }
95
143
  }
96
144
 
145
+ const processPaymentResults = async transaction => {
146
+ await finaliseTransaction(transaction)
147
+ await updatePaymentJournalIfNeeded(transaction)
148
+ await cancelRecurringPaymentIfNeeded(transaction)
149
+ }
150
+
97
151
  const getStatus = async (paymentReference, agreementId) => {
98
152
  const recurring = !!agreementId
99
153
  const paymentStatusResponse = await govUkPayApi.fetchPaymentStatus(paymentReference, recurring)
@@ -123,20 +177,47 @@ export const execute = async (ageMinutes, scanDurationHours) => {
123
177
  const paymentJournalsToMopUp = paymentJournals.filter(pj => toTimestamp.isAfter(pj.paymentTimestamp) || pj.eligibleForEarlyMopUp)
124
178
 
125
179
  // Get the status for each payment from the GOV.UK Pay API.
126
- const journalsWithRecurringPaymentIDs = await Promise.all(
127
- paymentJournalsToMopUp.map(async p => {
128
- const transactionRecord = await salesApi.retrieveStagedTransaction(p.id)
129
- const paymentJournalWithStatus = {
130
- ...p,
131
- paymentStatus: await getStatusWrapped(p.paymentReference, transactionRecord?.recurringPayment?.agreementId)
180
+ const journalsWithRecurringPaymentIDs = await (async () => {
181
+ const retrievalResults = await Promise.allSettled(
182
+ paymentJournalsToMopUp.map(async p => {
183
+ const transactionRecord = await salesApi.retrieveStagedTransaction(p.id)
184
+ const paymentJournalWithStatus = {
185
+ ...p,
186
+ paymentStatus: await getStatusWrapped(p.paymentReference, transactionRecord?.recurringPayment?.agreementId)
187
+ }
188
+ if (transactionRecord?.recurringPayment) {
189
+ paymentJournalWithStatus.recurringPaymentId = transactionRecord.recurringPayment.id
190
+ }
191
+ return paymentJournalWithStatus
192
+ })
193
+ )
194
+
195
+ const goneCount = retrievalResults.filter(result => result.reason?.status === StatusCodes.GONE).length
196
+ debug(`Found ${paymentJournals.length} in-progress payment journals, ${goneCount} of which are gone - 410 error`)
197
+
198
+ const fulfilled = []
199
+
200
+ retrievalResults.forEach((result, index) => {
201
+ if (result.status === 'fulfilled') {
202
+ fulfilled.push(result.value)
203
+ } else if (result.reason?.status === StatusCodes.GONE) {
204
+ const failedJournal = paymentJournalsToMopUp[index]
205
+ debug(`Cannot retrieve transaction id: ${failedJournal.id} (410 Gone)${formatTransactionData(failedJournal)}`)
206
+ debug(result.reason)
207
+ } else {
208
+ console.error(
209
+ `Cannot retrieve transaction id: ${paymentJournalsToMopUp[index]?.id} - retrieval failed${formatTransactionData(
210
+ paymentJournalsToMopUp[index]
211
+ )}`,
212
+ result.reason
213
+ )
214
+ throw result.reason
132
215
  }
133
- if (transactionRecord?.recurringPayment) {
134
- paymentJournalWithStatus.recurringPaymentId = transactionRecord.recurringPayment.id
135
- }
136
- return paymentJournalWithStatus
137
216
  })
138
- )
217
+
218
+ return fulfilled
219
+ })()
139
220
 
140
221
  // Process each result
141
- await Promise.all(journalsWithRecurringPaymentIDs.map(async j => processPaymentResults(j)))
222
+ await Promise.allSettled(journalsWithRecurringPaymentIDs.map(async j => processPaymentResults(j)))
142
223
  }