@defra-fish/payment-mop-up-job 1.63.0-rc.0 → 1.63.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defra-fish/payment-mop-up-job",
|
|
3
|
-
"version": "1.63.0-rc.
|
|
3
|
+
"version": "1.63.0-rc.10",
|
|
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-rc.
|
|
40
|
-
"@defra-fish/connectors-lib": "1.63.0-rc.
|
|
39
|
+
"@defra-fish/business-rules-lib": "1.63.0-rc.10",
|
|
40
|
+
"@defra-fish/connectors-lib": "1.63.0-rc.10",
|
|
41
41
|
"bottleneck": "^2.19.5",
|
|
42
42
|
"debug": "^4.3.3",
|
|
43
43
|
"moment": "^2.29.1"
|
|
44
44
|
},
|
|
45
|
-
"gitHead": "
|
|
45
|
+
"gitHead": "c4301461b148cbf244c1bb5c834521243ec0a534"
|
|
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)
|
|
@@ -161,48 +166,146 @@ describe('processor', () => {
|
|
|
161
166
|
})
|
|
162
167
|
})
|
|
163
168
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
it('calls fetchPaymentStatus with recurring as true since agreementId exists', async () => {
|
|
170
|
+
salesApi.retrieveStagedTransaction.mockReturnValueOnce({ recurringPayment: { agreementId: '123' } })
|
|
171
|
+
const paymentReference = '15nioqikvvnuu5l8m2qeaj0qap'
|
|
172
|
+
salesApi.paymentJournals.getAll.mockReturnValueOnce([
|
|
173
|
+
{
|
|
174
|
+
id: 'a0e0e5c3-1004-4271-80ba-d05eda3e8220',
|
|
175
|
+
paymentStatus: 'In Progress',
|
|
176
|
+
paymentReference,
|
|
177
|
+
paymentTimestamp: '2020-06-01T10:35:56.873Z'
|
|
178
|
+
}
|
|
179
|
+
])
|
|
180
|
+
govUkPayApi.fetchPaymentStatus.mockResolvedValueOnce({
|
|
181
|
+
json: async () => ({ state: { status: 'success' } })
|
|
182
|
+
})
|
|
183
|
+
govUkPayApi.fetchPaymentEvents.mockResolvedValueOnce({
|
|
184
|
+
json: async () => ({ events: [{ state: { status: 'success' }, updated: '2020-06-01T10:35:56.873Z' }] })
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
await execute(1, 1)
|
|
188
|
+
|
|
189
|
+
expect(govUkPayApi.fetchPaymentStatus).toHaveBeenCalledWith(paymentReference, true)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('calls fetchPaymentStatus with recurring as false since agreementId does not exist', async () => {
|
|
193
|
+
const paymentReference = '25nioqikvvnuu5l8m2qeaj0qap'
|
|
194
|
+
salesApi.paymentJournals.getAll.mockReturnValueOnce([
|
|
195
|
+
{
|
|
196
|
+
id: 'a0e0e5c3-1004-4271-80ba-d05eda3e8220',
|
|
197
|
+
paymentStatus: 'In Progress',
|
|
198
|
+
paymentReference,
|
|
199
|
+
paymentTimestamp: '2020-06-01T10:35:56.873Z'
|
|
200
|
+
}
|
|
201
|
+
])
|
|
202
|
+
govUkPayApi.fetchPaymentStatus.mockReturnValueOnce({
|
|
203
|
+
json: async () => ({ state: { status: 'success' } })
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
await execute(1, 1)
|
|
207
|
+
|
|
208
|
+
expect(govUkPayApi.fetchPaymentStatus).toHaveBeenCalledWith(paymentReference, false)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('calls fetchPaymentEvents with recurring as true since agreementId exists', async () => {
|
|
212
|
+
salesApi.retrieveStagedTransaction.mockReturnValueOnce({ recurringPayment: { agreementId: '123' } })
|
|
213
|
+
const paymentReference = '35nioqikvvnuu5l8m2qeaj0qap'
|
|
214
|
+
salesApi.paymentJournals.getAll.mockReturnValueOnce([
|
|
215
|
+
{
|
|
216
|
+
id: 'a0e0e5c3-1004-4271-80ba-d05eda3e8220',
|
|
217
|
+
paymentStatus: 'In Progress',
|
|
218
|
+
paymentReference,
|
|
219
|
+
paymentTimestamp: '2020-06-01T10:35:56.873Z',
|
|
220
|
+
agreementId: 'c9267c6e-573d-488b-99ab-ea18431fc472'
|
|
221
|
+
}
|
|
222
|
+
])
|
|
223
|
+
govUkPayApi.fetchPaymentStatus.mockResolvedValueOnce({
|
|
224
|
+
json: async () => ({ state: { status: 'success' } })
|
|
225
|
+
})
|
|
226
|
+
govUkPayApi.fetchPaymentEvents.mockResolvedValueOnce({
|
|
227
|
+
json: async () => ({ events: [{ state: { status: 'success' }, updated: '2020-06-01T10:35:56.873Z' }] })
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
await execute(1, 1)
|
|
231
|
+
|
|
232
|
+
expect(govUkPayApi.fetchPaymentEvents).toHaveBeenCalledWith(paymentReference, true)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('calls fetchPaymentEvents with recurring as false since agreementId does not exist', async () => {
|
|
236
|
+
const paymentReference = '45nioqikvvnuu5l8m2qeaj0qap'
|
|
237
|
+
salesApi.paymentJournals.getAll.mockReturnValueOnce([
|
|
238
|
+
{
|
|
239
|
+
id: 'a0e0e5c3-1004-4271-80ba-d05eda3e8220',
|
|
240
|
+
paymentStatus: 'In Progress',
|
|
241
|
+
paymentReference,
|
|
242
|
+
paymentTimestamp: '2020-06-01T10:35:56.873Z'
|
|
243
|
+
}
|
|
244
|
+
])
|
|
245
|
+
govUkPayApi.fetchPaymentStatus.mockReturnValueOnce({
|
|
246
|
+
json: async () => ({ state: { status: 'success' } })
|
|
247
|
+
})
|
|
248
|
+
govUkPayApi.fetchPaymentEvents.mockReturnValueOnce({
|
|
249
|
+
json: async () => ({ events: [{ state: { status: 'success' } }] })
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
await execute(1, 1)
|
|
253
|
+
|
|
254
|
+
expect(govUkPayApi.fetchPaymentEvents).toHaveBeenCalledWith(paymentReference, false)
|
|
255
|
+
})
|
|
256
|
+
|
|
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
|
|
262
|
+
|
|
263
|
+
salesApi.paymentJournals.getAll.mockReturnValueOnce(sampleJournalEntries)
|
|
171
264
|
|
|
172
|
-
govUkPayApi.fetchPaymentEvents.
|
|
265
|
+
govUkPayApi.fetchPaymentEvents.mockImplementationOnce(paymentReference => {
|
|
173
266
|
if (paymentReference === NOT_FOUND_PAYMENT_REFERENCE) {
|
|
174
|
-
return { json: async () => govUkPayStatusNotFound }
|
|
267
|
+
return { json: async () => govUkPayStatusNotFound() }
|
|
175
268
|
}
|
|
176
269
|
return { json: async () => createPaymentEventsEntry(govUkPayStatusEntries.find(se => se.payment_id === paymentReference)) }
|
|
177
270
|
})
|
|
178
271
|
|
|
179
272
|
govUkPayApi.fetchPaymentStatus.mockImplementation(paymentReference => {
|
|
180
273
|
if (paymentReference === NOT_FOUND_PAYMENT_REFERENCE) {
|
|
181
|
-
return { json: async () => govUkPayStatusNotFound }
|
|
274
|
+
return { json: async () => govUkPayStatusNotFound() }
|
|
182
275
|
}
|
|
183
276
|
return { json: async () => govUkPayStatusEntries.find(se => se.payment_id === paymentReference) }
|
|
184
277
|
})
|
|
185
|
-
})
|
|
186
278
|
|
|
187
|
-
|
|
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()
|
|
188
288
|
await expect(execute(1, 1)).resolves.toBeUndefined()
|
|
189
289
|
})
|
|
190
290
|
|
|
191
|
-
it(
|
|
291
|
+
it('other results process', async () => {
|
|
292
|
+
const { NOT_FOUND_ID, sampleJournalEntries } = absentPaymentSetup()
|
|
293
|
+
|
|
192
294
|
await execute(1, 1)
|
|
193
295
|
|
|
194
|
-
const foundIds =
|
|
296
|
+
const foundIds = sampleJournalEntries.map(j => j.id).filter(id => id !== NOT_FOUND_ID)
|
|
195
297
|
for (const foundId of foundIds) {
|
|
196
298
|
expect(salesApi.updatePaymentJournal).toHaveBeenCalledWith(foundId, expect.any(Object))
|
|
197
299
|
}
|
|
198
300
|
})
|
|
199
301
|
|
|
200
|
-
it("
|
|
201
|
-
const
|
|
202
|
-
missingJournalEntry
|
|
203
|
-
|
|
204
|
-
|
|
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)
|
|
305
|
+
missingJournalEntry.paymentTimestamp = moment().subtract(3, 'hours').toISOString()
|
|
306
|
+
|
|
205
307
|
await execute(1, 1)
|
|
308
|
+
|
|
206
309
|
expect(salesApi.updatePaymentJournal).toHaveBeenCalledWith(
|
|
207
310
|
NOT_FOUND_ID,
|
|
208
311
|
expect.objectContaining({
|
|
@@ -211,12 +314,13 @@ describe('processor', () => {
|
|
|
211
314
|
)
|
|
212
315
|
})
|
|
213
316
|
|
|
214
|
-
it("
|
|
215
|
-
const
|
|
216
|
-
missingJournalEntry
|
|
217
|
-
|
|
218
|
-
|
|
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)
|
|
320
|
+
missingJournalEntry.paymentTimestamp = moment().subtract(2, 'hours').toISOString()
|
|
321
|
+
|
|
219
322
|
await execute(1, 1)
|
|
323
|
+
|
|
220
324
|
expect(salesApi.updatePaymentJournal).not.toHaveBeenCalledWith(
|
|
221
325
|
NOT_FOUND_ID,
|
|
222
326
|
expect.objectContaining({
|
|
@@ -224,5 +328,87 @@ describe('processor', () => {
|
|
|
224
328
|
})
|
|
225
329
|
)
|
|
226
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()
|
|
227
413
|
})
|
|
228
414
|
})
|
|
@@ -3,9 +3,9 @@ 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
9
|
import Bottleneck from 'bottleneck'
|
|
10
10
|
import moment from 'moment'
|
|
11
11
|
import db from 'debug'
|
|
@@ -19,11 +19,58 @@ const limiter = new Bottleneck({
|
|
|
19
19
|
maxConcurrent: process.env.CONCURRENCY || CONCURRENCY_DEFAULT
|
|
20
20
|
})
|
|
21
21
|
|
|
22
|
-
const
|
|
23
|
-
if (transaction.paymentStatus.state?.status ===
|
|
24
|
-
|
|
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)
|
|
25
69
|
}
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
26
72
|
|
|
73
|
+
const processPaymentResults = async transaction => {
|
|
27
74
|
if (transaction.paymentStatus.state?.status === 'success') {
|
|
28
75
|
debug(`Completing mop up finalization for transaction id: ${transaction.id}`)
|
|
29
76
|
await salesApi.finaliseTransaction(transaction.id, {
|
|
@@ -34,38 +81,25 @@ const processPaymentResults = async transaction => {
|
|
|
34
81
|
method: PAYMENT_TYPE.debit
|
|
35
82
|
}
|
|
36
83
|
})
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// The payment was rejected
|
|
50
|
-
if (transaction.paymentStatus.state?.code === GOVUK_PAY_ERROR_STATUS_CODES.REJECTED) {
|
|
51
|
-
await salesApi.updatePaymentJournal(transaction.id, { paymentStatus: PAYMENT_JOURNAL_STATUS_CODES.Failed })
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// The payment's not found and three hours have elapsed
|
|
55
|
-
if (
|
|
56
|
-
transaction.paymentStatus.code === GOVUK_PAY_ERROR_STATUS_CODES.NOT_FOUND &&
|
|
57
|
-
moment().diff(moment(transaction.paymentTimestamp), 'hours') >= MISSING_PAYMENT_EXPIRY_TIMEOUT
|
|
58
|
-
) {
|
|
59
|
-
await salesApi.updatePaymentJournal(transaction.id, { paymentStatus: PAYMENT_JOURNAL_STATUS_CODES.Expired })
|
|
60
|
-
}
|
|
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)
|
|
61
94
|
}
|
|
62
95
|
}
|
|
63
96
|
|
|
64
|
-
const getStatus = async paymentReference => {
|
|
65
|
-
const
|
|
97
|
+
const getStatus = async (paymentReference, agreementId) => {
|
|
98
|
+
const recurring = !!agreementId
|
|
99
|
+
const paymentStatusResponse = await govUkPayApi.fetchPaymentStatus(paymentReference, recurring)
|
|
66
100
|
const paymentStatus = await paymentStatusResponse.json()
|
|
67
101
|
if (paymentStatus.state?.status === 'success') {
|
|
68
|
-
const eventsResponse = await govUkPayApi.fetchPaymentEvents(paymentReference)
|
|
102
|
+
const eventsResponse = await govUkPayApi.fetchPaymentEvents(paymentReference, recurring)
|
|
69
103
|
const { events } = await eventsResponse.json()
|
|
70
104
|
paymentStatus.transactionTimestamp = events.find(e => e.state.status === 'success')?.updated
|
|
71
105
|
}
|
|
@@ -75,15 +109,11 @@ const getStatus = async paymentReference => {
|
|
|
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')
|
|
84
116
|
|
|
85
|
-
console.log(`Scanning the payment journal for payments created between ${fromTimestamp} and ${toTimestamp}`)
|
|
86
|
-
|
|
87
117
|
const paymentJournals = await salesApi.paymentJournals.getAll({
|
|
88
118
|
paymentStatus: PAYMENT_JOURNAL_STATUS_CODES.InProgress,
|
|
89
119
|
from: fromTimestamp.toISOString(),
|
|
@@ -91,13 +121,20 @@ export const execute = async (ageMinutes, scanDurationHours) => {
|
|
|
91
121
|
})
|
|
92
122
|
|
|
93
123
|
// Get the status for each payment from the GOV.UK Pay API.
|
|
94
|
-
const
|
|
95
|
-
paymentJournals.map(async p =>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
124
|
+
const journalsWithRecurringPaymentIDs = await Promise.all(
|
|
125
|
+
paymentJournals.map(async p => {
|
|
126
|
+
const transactionRecord = await salesApi.retrieveStagedTransaction(p.id)
|
|
127
|
+
const paymentJournalWithStatus = {
|
|
128
|
+
...p,
|
|
129
|
+
paymentStatus: await getStatusWrapped(p.paymentReference, transactionRecord?.recurringPayment?.agreementId)
|
|
130
|
+
}
|
|
131
|
+
if (transactionRecord?.recurringPayment) {
|
|
132
|
+
paymentJournalWithStatus.recurringPaymentId = transactionRecord.recurringPayment.id
|
|
133
|
+
}
|
|
134
|
+
return paymentJournalWithStatus
|
|
135
|
+
})
|
|
99
136
|
)
|
|
100
137
|
|
|
101
138
|
// Process each result
|
|
102
|
-
await Promise.all(
|
|
139
|
+
await Promise.all(journalsWithRecurringPaymentIDs.map(async j => processPaymentResults(j)))
|
|
103
140
|
}
|