@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
|
|
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
|
|
40
|
-
"@defra-fish/connectors-lib": "1.77.0
|
|
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": "
|
|
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(
|
|
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
|
|
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(
|
|
156
|
-
expect(govUkPayApi.fetchPaymentEvents).toHaveBeenCalledTimes(
|
|
157
|
-
expect(salesApi.finaliseTransaction).toHaveBeenCalledTimes(
|
|
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(
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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: '
|
|
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.
|
|
411
|
+
expect(govUkPayApi.fetchPaymentStatus).toHaveBeenCalledWith(paymentReference, recurringFlag)
|
|
305
412
|
})
|
|
306
413
|
|
|
307
|
-
it(
|
|
308
|
-
|
|
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,
|
|
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(
|
|
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 () =>
|
|
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
|
|
85
|
+
const finaliseTransaction = async transaction => {
|
|
74
86
|
if (transaction.paymentStatus.state?.status === 'success') {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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.
|
|
222
|
+
await Promise.allSettled(journalsWithRecurringPaymentIDs.map(async j => processPaymentResults(j)))
|
|
142
223
|
}
|