@defra-fish/payment-mop-up-job 1.63.0-rc.8 → 1.63.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.63.0
|
|
3
|
+
"version": "1.63.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.63.0
|
|
40
|
-
"@defra-fish/connectors-lib": "1.63.0
|
|
39
|
+
"@defra-fish/business-rules-lib": "1.63.0",
|
|
40
|
+
"@defra-fish/connectors-lib": "1.63.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": "7cfb8ef668002fc340b755dd3aaa4572063e115c"
|
|
46
46
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
|
|
1
3
|
global.simulateLockError = false
|
|
2
4
|
global.lockReleased = false
|
|
3
5
|
jest.mock('@defra-fish/connectors-lib', () => ({
|
|
@@ -20,6 +22,13 @@ jest.mock('@defra-fish/connectors-lib', () => ({
|
|
|
20
22
|
})
|
|
21
23
|
}))
|
|
22
24
|
|
|
25
|
+
jest.mock('fs', () => ({
|
|
26
|
+
readFileSync: jest.fn(),
|
|
27
|
+
promises: {
|
|
28
|
+
readFile: jest.fn()
|
|
29
|
+
}
|
|
30
|
+
}))
|
|
31
|
+
|
|
23
32
|
describe('payment-mop-up-job', () => {
|
|
24
33
|
beforeEach(() => {
|
|
25
34
|
jest.clearAllMocks()
|
|
@@ -27,6 +36,22 @@ describe('payment-mop-up-job', () => {
|
|
|
27
36
|
global.lockReleased = false
|
|
28
37
|
})
|
|
29
38
|
|
|
39
|
+
it('logs startup details including name and version', () => {
|
|
40
|
+
const mockPkg = { name: 'payment-mop-up-test', version: '1.2.3' }
|
|
41
|
+
fs.readFileSync.mockReturnValue(JSON.stringify(mockPkg))
|
|
42
|
+
|
|
43
|
+
jest.isolateModules(() => {
|
|
44
|
+
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
|
|
45
|
+
require('../payment-mop-up-job.js')
|
|
46
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
47
|
+
'Payment mop up job starting at %s. name: %s. version: %s',
|
|
48
|
+
expect.any(String),
|
|
49
|
+
mockPkg.name,
|
|
50
|
+
mockPkg.version
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
30
55
|
it('starts the mop up job with --age-minutes=3 and --scan-duration=67', done => {
|
|
31
56
|
jest.isolateModules(() => {
|
|
32
57
|
const mockExecute = jest.fn()
|
|
@@ -1,10 +1,17 @@
|
|
|
1
|
+
'use strict'
|
|
1
2
|
import { execute } from './processors/processor.js'
|
|
2
3
|
import { DEFAULT_INCOMPLETE_PURCHASE_AGE_MINUTES, DEFAULT_SCAN_DURATION_HOURS } from './constants.js'
|
|
3
4
|
import { DistributedLock, airbrake } from '@defra-fish/connectors-lib'
|
|
5
|
+
import path from 'path'
|
|
6
|
+
import fs from 'fs'
|
|
7
|
+
|
|
8
|
+
const pkgPath = path.join(process.cwd(), 'package.json')
|
|
9
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
|
|
10
|
+
|
|
11
|
+
console.log('Payment mop up job starting at %s. name: %s. version: %s', new Date().toISOString(), pkg.name, pkg.version)
|
|
4
12
|
|
|
5
13
|
const incompletePurchaseAgeMinutes = Number(process.env.INCOMPLETE_PURCHASE_AGE_MINUTES || DEFAULT_INCOMPLETE_PURCHASE_AGE_MINUTES)
|
|
6
14
|
const scanDurationHours = Number(process.env.SCAN_DURATION_HOURS || DEFAULT_SCAN_DURATION_HOURS)
|
|
7
|
-
|
|
8
15
|
const lock = new DistributedLock('payment-mop-up-etl', 5 * 60 * 1000)
|
|
9
16
|
airbrake.initialise()
|
|
10
17
|
lock
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { salesApi, govUkPayApi } from '@defra-fish/connectors-lib'
|
|
2
2
|
import { execute } from '../processor.js'
|
|
3
|
-
import { GOVUK_PAY_ERROR_STATUS_CODES, PAYMENT_JOURNAL_STATUS_CODES } from '@defra-fish/business-rules-lib'
|
|
3
|
+
import { GOVUK_PAY_ERROR_STATUS_CODES, PAYMENT_JOURNAL_STATUS_CODES, PAYMENT_STATUS } from '@defra-fish/business-rules-lib'
|
|
4
4
|
import moment from 'moment'
|
|
5
5
|
|
|
6
6
|
jest.mock('@defra-fish/connectors-lib')
|
|
7
|
+
// mock bottleneck to speed up test execution
|
|
8
|
+
jest.mock('bottleneck', () =>
|
|
9
|
+
jest.fn(function () {
|
|
10
|
+
this.wrap = fn => fn
|
|
11
|
+
})
|
|
12
|
+
)
|
|
7
13
|
|
|
8
|
-
const journalEntries = [
|
|
14
|
+
const journalEntries = () => [
|
|
9
15
|
{
|
|
10
16
|
id: '4fa393ab-07f4-407e-b233-89be2a6f5f77',
|
|
11
17
|
paymentStatus: 'In Progress',
|
|
@@ -80,7 +86,8 @@ const govUkPayStatusEntries = [
|
|
|
80
86
|
}
|
|
81
87
|
]
|
|
82
88
|
|
|
83
|
-
|
|
89
|
+
// differs from other responses - see https://docs.payments.service.gov.uk/api_reference/#gov-uk-pay-api-error-codes
|
|
90
|
+
const govUkPayStatusNotFound = () => ({ code: 'P0200', description: 'Not found' })
|
|
84
91
|
|
|
85
92
|
const createPaymentEventsEntry = paymentStatus => {
|
|
86
93
|
return {
|
|
@@ -122,22 +129,20 @@ describe('processor', () => {
|
|
|
122
129
|
beforeEach(jest.clearAllMocks)
|
|
123
130
|
|
|
124
131
|
it('completes normally where there are no journal records retrieved', async () => {
|
|
125
|
-
salesApi.paymentJournals.getAll.
|
|
126
|
-
govUkPayApi.fetchPaymentStatus.mockImplementation(jest.fn())
|
|
132
|
+
salesApi.paymentJournals.getAll.mockReturnValueOnce([])
|
|
127
133
|
await execute(1, 1)
|
|
128
134
|
expect(govUkPayApi.fetchPaymentStatus).not.toHaveBeenCalled()
|
|
129
135
|
})
|
|
130
136
|
|
|
131
137
|
it('completes normally where there are journal records retrieved', async () => {
|
|
132
|
-
salesApi.paymentJournals.getAll.
|
|
133
|
-
salesApi.updatePaymentJournal.mockImplementation(jest.fn())
|
|
134
|
-
salesApi.finaliseTransaction.mockImplementation(jest.fn())
|
|
138
|
+
salesApi.paymentJournals.getAll.mockReturnValueOnce(journalEntries())
|
|
135
139
|
govUkPayStatusEntries.forEach(status => {
|
|
136
140
|
govUkPayApi.fetchPaymentStatus.mockReturnValueOnce({ json: async () => status })
|
|
137
141
|
govUkPayApi.fetchPaymentEvents.mockReturnValueOnce({ json: async () => createPaymentEventsEntry(status) })
|
|
138
142
|
})
|
|
139
143
|
|
|
140
144
|
await execute(1, 1)
|
|
145
|
+
|
|
141
146
|
expect(govUkPayApi.fetchPaymentStatus).toHaveBeenCalledTimes(govUkPayStatusEntries.length)
|
|
142
147
|
expect(govUkPayApi.fetchPaymentEvents).toHaveBeenCalledTimes(govUkPayStatusEntries.filter(s => s.state.status === 'success').length)
|
|
143
148
|
expect(salesApi.finaliseTransaction).toHaveBeenCalledTimes(govUkPayStatusEntries.filter(s => s.state.status === 'success').length)
|
|
@@ -164,17 +169,14 @@ describe('processor', () => {
|
|
|
164
169
|
it('calls fetchPaymentStatus with recurring as true since agreementId exists', async () => {
|
|
165
170
|
salesApi.retrieveStagedTransaction.mockReturnValueOnce({ recurringPayment: { agreementId: '123' } })
|
|
166
171
|
const paymentReference = '15nioqikvvnuu5l8m2qeaj0qap'
|
|
167
|
-
|
|
172
|
+
salesApi.paymentJournals.getAll.mockReturnValueOnce([
|
|
168
173
|
{
|
|
169
174
|
id: 'a0e0e5c3-1004-4271-80ba-d05eda3e8220',
|
|
170
175
|
paymentStatus: 'In Progress',
|
|
171
176
|
paymentReference,
|
|
172
177
|
paymentTimestamp: '2020-06-01T10:35:56.873Z'
|
|
173
178
|
}
|
|
174
|
-
]
|
|
175
|
-
salesApi.paymentJournals.getAll.mockReturnValue(journalEntriesAgreement)
|
|
176
|
-
salesApi.updatePaymentJournal.mockImplementation(jest.fn())
|
|
177
|
-
salesApi.finaliseTransaction.mockImplementation(jest.fn())
|
|
179
|
+
])
|
|
178
180
|
govUkPayApi.fetchPaymentStatus.mockResolvedValueOnce({
|
|
179
181
|
json: async () => ({ state: { status: 'success' } })
|
|
180
182
|
})
|
|
@@ -188,19 +190,15 @@ describe('processor', () => {
|
|
|
188
190
|
})
|
|
189
191
|
|
|
190
192
|
it('calls fetchPaymentStatus with recurring as false since agreementId does not exist', async () => {
|
|
191
|
-
salesApi.retrieveStagedTransaction.mockReturnValueOnce({})
|
|
192
193
|
const paymentReference = '25nioqikvvnuu5l8m2qeaj0qap'
|
|
193
|
-
|
|
194
|
+
salesApi.paymentJournals.getAll.mockReturnValueOnce([
|
|
194
195
|
{
|
|
195
196
|
id: 'a0e0e5c3-1004-4271-80ba-d05eda3e8220',
|
|
196
197
|
paymentStatus: 'In Progress',
|
|
197
198
|
paymentReference,
|
|
198
199
|
paymentTimestamp: '2020-06-01T10:35:56.873Z'
|
|
199
200
|
}
|
|
200
|
-
]
|
|
201
|
-
salesApi.paymentJournals.getAll.mockReturnValue(journalEntriesAgreement)
|
|
202
|
-
salesApi.updatePaymentJournal.mockImplementation(jest.fn())
|
|
203
|
-
salesApi.finaliseTransaction.mockImplementation(jest.fn())
|
|
201
|
+
])
|
|
204
202
|
govUkPayApi.fetchPaymentStatus.mockReturnValueOnce({
|
|
205
203
|
json: async () => ({ state: { status: 'success' } })
|
|
206
204
|
})
|
|
@@ -213,7 +211,7 @@ describe('processor', () => {
|
|
|
213
211
|
it('calls fetchPaymentEvents with recurring as true since agreementId exists', async () => {
|
|
214
212
|
salesApi.retrieveStagedTransaction.mockReturnValueOnce({ recurringPayment: { agreementId: '123' } })
|
|
215
213
|
const paymentReference = '35nioqikvvnuu5l8m2qeaj0qap'
|
|
216
|
-
|
|
214
|
+
salesApi.paymentJournals.getAll.mockReturnValueOnce([
|
|
217
215
|
{
|
|
218
216
|
id: 'a0e0e5c3-1004-4271-80ba-d05eda3e8220',
|
|
219
217
|
paymentStatus: 'In Progress',
|
|
@@ -221,10 +219,7 @@ describe('processor', () => {
|
|
|
221
219
|
paymentTimestamp: '2020-06-01T10:35:56.873Z',
|
|
222
220
|
agreementId: 'c9267c6e-573d-488b-99ab-ea18431fc472'
|
|
223
221
|
}
|
|
224
|
-
]
|
|
225
|
-
salesApi.paymentJournals.getAll.mockReturnValue(journalEntriesAgreement)
|
|
226
|
-
salesApi.updatePaymentJournal.mockImplementation(jest.fn())
|
|
227
|
-
salesApi.finaliseTransaction.mockImplementation(jest.fn())
|
|
222
|
+
])
|
|
228
223
|
govUkPayApi.fetchPaymentStatus.mockResolvedValueOnce({
|
|
229
224
|
json: async () => ({ state: { status: 'success' } })
|
|
230
225
|
})
|
|
@@ -238,68 +233,79 @@ describe('processor', () => {
|
|
|
238
233
|
})
|
|
239
234
|
|
|
240
235
|
it('calls fetchPaymentEvents with recurring as false since agreementId does not exist', async () => {
|
|
241
|
-
salesApi.retrieveStagedTransaction.mockReturnValueOnce({})
|
|
242
236
|
const paymentReference = '45nioqikvvnuu5l8m2qeaj0qap'
|
|
243
|
-
|
|
237
|
+
salesApi.paymentJournals.getAll.mockReturnValueOnce([
|
|
244
238
|
{
|
|
245
239
|
id: 'a0e0e5c3-1004-4271-80ba-d05eda3e8220',
|
|
246
240
|
paymentStatus: 'In Progress',
|
|
247
241
|
paymentReference,
|
|
248
242
|
paymentTimestamp: '2020-06-01T10:35:56.873Z'
|
|
249
243
|
}
|
|
250
|
-
]
|
|
251
|
-
salesApi.paymentJournals.getAll.mockReturnValue(journalEntriesAgreement)
|
|
252
|
-
salesApi.updatePaymentJournal.mockImplementation(jest.fn())
|
|
253
|
-
salesApi.finaliseTransaction.mockImplementation(jest.fn())
|
|
244
|
+
])
|
|
254
245
|
govUkPayApi.fetchPaymentStatus.mockReturnValueOnce({
|
|
255
246
|
json: async () => ({ state: { status: 'success' } })
|
|
256
247
|
})
|
|
248
|
+
govUkPayApi.fetchPaymentEvents.mockReturnValueOnce({
|
|
249
|
+
json: async () => ({ events: [{ state: { status: 'success' } }] })
|
|
250
|
+
})
|
|
257
251
|
|
|
258
252
|
await execute(1, 1)
|
|
259
253
|
|
|
260
254
|
expect(govUkPayApi.fetchPaymentEvents).toHaveBeenCalledWith(paymentReference, false)
|
|
261
255
|
})
|
|
262
256
|
|
|
263
|
-
describe('
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
salesApi.updatePaymentJournal.mockImplementation(() => {})
|
|
269
|
-
salesApi.finaliseTransaction.mockImplementation(() => {})
|
|
257
|
+
describe("When a payment isn't present in GovPay", () => {
|
|
258
|
+
const absentPaymentSetup = () => {
|
|
259
|
+
const sampleJournalEntries = journalEntries()
|
|
260
|
+
const NOT_FOUND_ID = sampleJournalEntries[2].id
|
|
261
|
+
const NOT_FOUND_PAYMENT_REFERENCE = sampleJournalEntries[2].paymentReference
|
|
270
262
|
|
|
271
|
-
|
|
263
|
+
salesApi.paymentJournals.getAll.mockReturnValueOnce(sampleJournalEntries)
|
|
264
|
+
|
|
265
|
+
govUkPayApi.fetchPaymentEvents.mockImplementationOnce(paymentReference => {
|
|
272
266
|
if (paymentReference === NOT_FOUND_PAYMENT_REFERENCE) {
|
|
273
|
-
return { json: async () => govUkPayStatusNotFound }
|
|
267
|
+
return { json: async () => govUkPayStatusNotFound() }
|
|
274
268
|
}
|
|
275
269
|
return { json: async () => createPaymentEventsEntry(govUkPayStatusEntries.find(se => se.payment_id === paymentReference)) }
|
|
276
270
|
})
|
|
277
271
|
|
|
278
272
|
govUkPayApi.fetchPaymentStatus.mockImplementation(paymentReference => {
|
|
279
273
|
if (paymentReference === NOT_FOUND_PAYMENT_REFERENCE) {
|
|
280
|
-
return { json: async () => govUkPayStatusNotFound }
|
|
274
|
+
return { json: async () => govUkPayStatusNotFound() }
|
|
281
275
|
}
|
|
282
276
|
return { json: async () => govUkPayStatusEntries.find(se => se.payment_id === paymentReference) }
|
|
283
277
|
})
|
|
284
|
-
})
|
|
285
278
|
|
|
286
|
-
|
|
279
|
+
return {
|
|
280
|
+
sampleJournalEntries,
|
|
281
|
+
NOT_FOUND_ID
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
afterEach(() => govUkPayApi.fetchPaymentStatus.mockClear())
|
|
285
|
+
|
|
286
|
+
it('no error is thrown', async () => {
|
|
287
|
+
absentPaymentSetup()
|
|
287
288
|
await expect(execute(1, 1)).resolves.toBeUndefined()
|
|
288
289
|
})
|
|
289
290
|
|
|
290
|
-
it(
|
|
291
|
+
it('other results process', async () => {
|
|
292
|
+
const { NOT_FOUND_ID, sampleJournalEntries } = absentPaymentSetup()
|
|
293
|
+
|
|
291
294
|
await execute(1, 1)
|
|
292
295
|
|
|
293
|
-
const foundIds =
|
|
296
|
+
const foundIds = sampleJournalEntries.map(j => j.id).filter(id => id !== NOT_FOUND_ID)
|
|
294
297
|
for (const foundId of foundIds) {
|
|
295
298
|
expect(salesApi.updatePaymentJournal).toHaveBeenCalledWith(foundId, expect.any(Object))
|
|
296
299
|
}
|
|
297
300
|
})
|
|
298
301
|
|
|
299
|
-
it("
|
|
300
|
-
const
|
|
302
|
+
it("it's marked as expired after 3 hours", async () => {
|
|
303
|
+
const { NOT_FOUND_ID, sampleJournalEntries } = absentPaymentSetup()
|
|
304
|
+
const missingJournalEntry = sampleJournalEntries.find(je => je.id === NOT_FOUND_ID)
|
|
301
305
|
missingJournalEntry.paymentTimestamp = moment().subtract(3, 'hours').toISOString()
|
|
306
|
+
|
|
302
307
|
await execute(1, 1)
|
|
308
|
+
|
|
303
309
|
expect(salesApi.updatePaymentJournal).toHaveBeenCalledWith(
|
|
304
310
|
NOT_FOUND_ID,
|
|
305
311
|
expect.objectContaining({
|
|
@@ -308,10 +314,13 @@ describe('processor', () => {
|
|
|
308
314
|
)
|
|
309
315
|
})
|
|
310
316
|
|
|
311
|
-
it("
|
|
312
|
-
const
|
|
317
|
+
it("it's not marked as expired if 3 hours haven't passed", async () => {
|
|
318
|
+
const { NOT_FOUND_ID, sampleJournalEntries } = absentPaymentSetup()
|
|
319
|
+
const missingJournalEntry = sampleJournalEntries.find(je => je.id === NOT_FOUND_ID)
|
|
313
320
|
missingJournalEntry.paymentTimestamp = moment().subtract(2, 'hours').toISOString()
|
|
321
|
+
|
|
314
322
|
await execute(1, 1)
|
|
323
|
+
|
|
315
324
|
expect(salesApi.updatePaymentJournal).not.toHaveBeenCalledWith(
|
|
316
325
|
NOT_FOUND_ID,
|
|
317
326
|
expect.objectContaining({
|
|
@@ -319,5 +328,87 @@ describe('processor', () => {
|
|
|
319
328
|
})
|
|
320
329
|
)
|
|
321
330
|
})
|
|
331
|
+
|
|
332
|
+
it('cancels the recurring payment when being marked as expired', async () => {
|
|
333
|
+
const { NOT_FOUND_ID, sampleJournalEntries } = absentPaymentSetup()
|
|
334
|
+
const rpid = Symbol('rp-id')
|
|
335
|
+
salesApi.retrieveStagedTransaction.mockImplementation(pjid => {
|
|
336
|
+
if (pjid === NOT_FOUND_ID) {
|
|
337
|
+
return {
|
|
338
|
+
paymentTimestamp: moment().subtract(3, 'hours'),
|
|
339
|
+
recurringPayment: { agreementId: 'abc-123', id: rpid }
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
const missingJournalEntry = sampleJournalEntries.find(je => je.id === NOT_FOUND_ID)
|
|
344
|
+
missingJournalEntry.paymentTimestamp = moment().subtract(3, 'hours').toISOString()
|
|
345
|
+
|
|
346
|
+
await execute(1, 1)
|
|
347
|
+
|
|
348
|
+
expect(salesApi.cancelRecurringPayment).toHaveBeenCalledWith(rpid)
|
|
349
|
+
salesApi.retrieveStagedTransaction.mockClear()
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it("doesn't call cancel for payments without a recurring payment", async () => {
|
|
353
|
+
const { NOT_FOUND_ID, sampleJournalEntries } = absentPaymentSetup()
|
|
354
|
+
salesApi.retrieveStagedTransaction.mockImplementationOnce(pjid => {
|
|
355
|
+
if (pjid === NOT_FOUND_ID) {
|
|
356
|
+
return {
|
|
357
|
+
paymentTimestamp: moment().subtract(3, 'hours'),
|
|
358
|
+
recurringPayment: { agreementId: 'abc-123', id: 'def-456' }
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
})
|
|
362
|
+
const missingJournalEntry = sampleJournalEntries.find(je => je.id === NOT_FOUND_ID)
|
|
363
|
+
missingJournalEntry.paymentTimestamp = moment().subtract(3, 'hours').toISOString()
|
|
364
|
+
|
|
365
|
+
await execute(1, 1)
|
|
366
|
+
|
|
367
|
+
expect(salesApi.cancelRecurringPayment).toHaveBeenCalledTimes(1)
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
describe('Recurring Payment is cancelled when', () => {
|
|
372
|
+
it('payment has an error status', async () => {
|
|
373
|
+
const id = Symbol('rp-id')
|
|
374
|
+
salesApi.paymentJournals.getAll.mockReturnValueOnce([journalEntries()[0]])
|
|
375
|
+
salesApi.retrieveStagedTransaction.mockReturnValueOnce({ recurringPayment: { agreementId: 'abc-123', id } })
|
|
376
|
+
govUkPayApi.fetchPaymentStatus.mockReturnValueOnce({
|
|
377
|
+
json: async () => ({ state: { status: PAYMENT_STATUS.Error } })
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
await execute(1, 1)
|
|
381
|
+
|
|
382
|
+
expect(salesApi.cancelRecurringPayment).toHaveBeenCalledWith(id)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it.each([
|
|
386
|
+
['expired', GOVUK_PAY_ERROR_STATUS_CODES.EXPIRED],
|
|
387
|
+
['user_cancelled', GOVUK_PAY_ERROR_STATUS_CODES.USER_CANCELLED],
|
|
388
|
+
['rejected', GOVUK_PAY_ERROR_STATUS_CODES.REJECTED]
|
|
389
|
+
])('payment status code is %s', async (_d, paymentStatus) => {
|
|
390
|
+
const id = Symbol('rp-id')
|
|
391
|
+
salesApi.paymentJournals.getAll.mockReturnValueOnce([journalEntries()[0]])
|
|
392
|
+
salesApi.retrieveStagedTransaction.mockReturnValueOnce({ recurringPayment: { agreementId: 'abc-123', id } })
|
|
393
|
+
govUkPayApi.fetchPaymentStatus.mockReturnValueOnce({
|
|
394
|
+
json: async () => ({ state: { code: paymentStatus } })
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
await execute(1, 1)
|
|
398
|
+
|
|
399
|
+
expect(salesApi.cancelRecurringPayment).toHaveBeenCalledWith(id)
|
|
400
|
+
})
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it("doesn't cancel Recurring Payment if payment succeeded", async () => {
|
|
404
|
+
salesApi.paymentJournals.getAll.mockReturnValueOnce([journalEntries()[0]])
|
|
405
|
+
salesApi.retrieveStagedTransaction.mockReturnValueOnce({ recurringPayment: { agreementId: 'abc-123', id: 'def-456' } })
|
|
406
|
+
govUkPayApi.fetchPaymentStatus.mockReturnValueOnce({
|
|
407
|
+
json: async () => ({ state: { status: 'success' } })
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
await execute(1, 1)
|
|
411
|
+
|
|
412
|
+
expect(salesApi.cancelRecurringPayment).not.toHaveBeenCalled()
|
|
322
413
|
})
|
|
323
414
|
})
|
|
@@ -3,7 +3,8 @@ import {
|
|
|
3
3
|
GOVUK_PAY_ERROR_STATUS_CODES,
|
|
4
4
|
PAYMENT_JOURNAL_STATUS_CODES,
|
|
5
5
|
TRANSACTION_SOURCE,
|
|
6
|
-
PAYMENT_TYPE
|
|
6
|
+
PAYMENT_TYPE,
|
|
7
|
+
PAYMENT_STATUS
|
|
7
8
|
} from '@defra-fish/business-rules-lib'
|
|
8
9
|
import Bottleneck from 'bottleneck'
|
|
9
10
|
import moment from 'moment'
|
|
@@ -18,11 +19,58 @@ const limiter = new Bottleneck({
|
|
|
18
19
|
maxConcurrent: process.env.CONCURRENCY || CONCURRENCY_DEFAULT
|
|
19
20
|
})
|
|
20
21
|
|
|
21
|
-
const
|
|
22
|
-
if (transaction.paymentStatus.state?.status ===
|
|
23
|
-
|
|
22
|
+
const getPaymentJournalStatusCodeFromTransaction = transaction => {
|
|
23
|
+
if (transaction.paymentStatus.state?.status === PAYMENT_STATUS.Error) {
|
|
24
|
+
return PAYMENT_JOURNAL_STATUS_CODES.Failed
|
|
25
|
+
}
|
|
26
|
+
if (transaction.paymentStatus.state?.status === PAYMENT_STATUS.Success) {
|
|
27
|
+
return PAYMENT_JOURNAL_STATUS_CODES.Completed
|
|
28
|
+
}
|
|
29
|
+
const govPayErrorStatusCode = transaction.paymentStatus.state?.code || transaction.paymentStatus.code
|
|
30
|
+
switch (govPayErrorStatusCode) {
|
|
31
|
+
case GOVUK_PAY_ERROR_STATUS_CODES.EXPIRED:
|
|
32
|
+
case GOVUK_PAY_ERROR_STATUS_CODES.NOT_FOUND:
|
|
33
|
+
return PAYMENT_JOURNAL_STATUS_CODES.Expired
|
|
34
|
+
case GOVUK_PAY_ERROR_STATUS_CODES.USER_CANCELLED:
|
|
35
|
+
return PAYMENT_JOURNAL_STATUS_CODES.Cancelled
|
|
36
|
+
case GOVUK_PAY_ERROR_STATUS_CODES.REJECTED:
|
|
37
|
+
return PAYMENT_JOURNAL_STATUS_CODES.Failed
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isExpiredCancelledRejectedOrNotFoundAndPastTimeout = transaction => {
|
|
42
|
+
const { paymentStatus, paymentTimestamp } = transaction
|
|
43
|
+
|
|
44
|
+
const isExpiredCancelledOrRejected = [
|
|
45
|
+
GOVUK_PAY_ERROR_STATUS_CODES.EXPIRED,
|
|
46
|
+
GOVUK_PAY_ERROR_STATUS_CODES.USER_CANCELLED,
|
|
47
|
+
GOVUK_PAY_ERROR_STATUS_CODES.REJECTED
|
|
48
|
+
].includes(paymentStatus.state?.code)
|
|
49
|
+
|
|
50
|
+
// The payment's not found and three hours have elapsed
|
|
51
|
+
const isNotFoundAndPastTimeout =
|
|
52
|
+
paymentStatus?.code === GOVUK_PAY_ERROR_STATUS_CODES.NOT_FOUND &&
|
|
53
|
+
moment().diff(moment(paymentTimestamp), 'hours') >= MISSING_PAYMENT_EXPIRY_TIMEOUT
|
|
54
|
+
|
|
55
|
+
return isExpiredCancelledOrRejected || isNotFoundAndPastTimeout
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const shouldUpdatePaymentJournal = transaction => {
|
|
59
|
+
const isSuccessOrError = [PAYMENT_STATUS.Success, PAYMENT_STATUS.Error].includes(transaction.paymentStatus.state?.status)
|
|
60
|
+
|
|
61
|
+
return isSuccessOrError || isExpiredCancelledRejectedOrNotFoundAndPastTimeout(transaction)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const shouldCancelRecurringPayment = transaction => {
|
|
65
|
+
if (transaction.recurringPaymentId) {
|
|
66
|
+
const isError = transaction.paymentStatus.state?.status === PAYMENT_STATUS.Error
|
|
67
|
+
|
|
68
|
+
return isError || isExpiredCancelledRejectedOrNotFoundAndPastTimeout(transaction)
|
|
24
69
|
}
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
25
72
|
|
|
73
|
+
const processPaymentResults = async transaction => {
|
|
26
74
|
if (transaction.paymentStatus.state?.status === 'success') {
|
|
27
75
|
debug(`Completing mop up finalization for transaction id: ${transaction.id}`)
|
|
28
76
|
await salesApi.finaliseTransaction(transaction.id, {
|
|
@@ -33,30 +81,16 @@ const processPaymentResults = async transaction => {
|
|
|
33
81
|
method: PAYMENT_TYPE.debit
|
|
34
82
|
}
|
|
35
83
|
})
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// The payment was rejected
|
|
49
|
-
if (transaction.paymentStatus.state?.code === GOVUK_PAY_ERROR_STATUS_CODES.REJECTED) {
|
|
50
|
-
await salesApi.updatePaymentJournal(transaction.id, { paymentStatus: PAYMENT_JOURNAL_STATUS_CODES.Failed })
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// The payment's not found and three hours have elapsed
|
|
54
|
-
if (
|
|
55
|
-
transaction.paymentStatus.code === GOVUK_PAY_ERROR_STATUS_CODES.NOT_FOUND &&
|
|
56
|
-
moment().diff(moment(transaction.paymentTimestamp), 'hours') >= MISSING_PAYMENT_EXPIRY_TIMEOUT
|
|
57
|
-
) {
|
|
58
|
-
await salesApi.updatePaymentJournal(transaction.id, { paymentStatus: PAYMENT_JOURNAL_STATUS_CODES.Expired })
|
|
59
|
-
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (shouldUpdatePaymentJournal(transaction)) {
|
|
87
|
+
await salesApi.updatePaymentJournal(transaction.id, {
|
|
88
|
+
paymentStatus: getPaymentJournalStatusCodeFromTransaction(transaction)
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (shouldCancelRecurringPayment(transaction)) {
|
|
93
|
+
await salesApi.cancelRecurringPayment(transaction.recurringPaymentId)
|
|
60
94
|
}
|
|
61
95
|
}
|
|
62
96
|
|
|
@@ -75,9 +109,7 @@ const getStatus = async (paymentReference, agreementId) => {
|
|
|
75
109
|
const getStatusWrapped = limiter.wrap(getStatus)
|
|
76
110
|
|
|
77
111
|
export const execute = async (ageMinutes, scanDurationHours) => {
|
|
78
|
-
debug(
|
|
79
|
-
`Running payment mop up processor with a payment age of ${ageMinutes} minutes ` + `and a scan duration of ${scanDurationHours} hours`
|
|
80
|
-
)
|
|
112
|
+
debug(`Running payment mop up processor with a payment age of ${ageMinutes} minutes and a scan duration of ${scanDurationHours} hours`)
|
|
81
113
|
|
|
82
114
|
const toTimestamp = moment().add(-1 * ageMinutes, 'minutes')
|
|
83
115
|
const fromTimestamp = toTimestamp.clone().add(-1 * scanDurationHours, 'hours')
|
|
@@ -89,16 +121,20 @@ export const execute = async (ageMinutes, scanDurationHours) => {
|
|
|
89
121
|
})
|
|
90
122
|
|
|
91
123
|
// Get the status for each payment from the GOV.UK Pay API.
|
|
92
|
-
const
|
|
124
|
+
const journalsWithRecurringPaymentIDs = await Promise.all(
|
|
93
125
|
paymentJournals.map(async p => {
|
|
94
126
|
const transactionRecord = await salesApi.retrieveStagedTransaction(p.id)
|
|
95
|
-
|
|
127
|
+
const paymentJournalWithStatus = {
|
|
96
128
|
...p,
|
|
97
129
|
paymentStatus: await getStatusWrapped(p.paymentReference, transactionRecord?.recurringPayment?.agreementId)
|
|
98
130
|
}
|
|
131
|
+
if (transactionRecord?.recurringPayment) {
|
|
132
|
+
paymentJournalWithStatus.recurringPaymentId = transactionRecord.recurringPayment.id
|
|
133
|
+
}
|
|
134
|
+
return paymentJournalWithStatus
|
|
99
135
|
})
|
|
100
136
|
)
|
|
101
137
|
|
|
102
138
|
// Process each result
|
|
103
|
-
await Promise.all(
|
|
139
|
+
await Promise.all(journalsWithRecurringPaymentIDs.map(async j => processPaymentResults(j)))
|
|
104
140
|
}
|