@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-rc.8",
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-rc.8",
40
- "@defra-fish/connectors-lib": "1.63.0-rc.8",
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": "4376fb63f6e1867708241521e43c4bce5791e08a"
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
- const govUkPayStatusNotFound = { code: 'P0200', description: 'Not found' }
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.mockReturnValue([])
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.mockReturnValue(journalEntries)
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
- const journalEntriesAgreement = [
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
- const journalEntriesAgreement = [
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
- const journalEntriesAgreement = [
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
- const journalEntriesAgreement = [
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('Result not present in GovPay', () => {
264
- const NOT_FOUND_ID = journalEntries[2].id
265
- const NOT_FOUND_PAYMENT_REFERENCE = journalEntries[2].paymentReference
266
- beforeEach(() => {
267
- salesApi.paymentJournals.getAll.mockReturnValue(journalEntries)
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
- govUkPayApi.fetchPaymentEvents.mockImplementation(paymentReference => {
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
- it("When a payment isn't present in GovPay, no error is thrown", async () => {
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("when a payment isn't present in GovPay, other results process", async () => {
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 = journalEntries.map(j => j.id).filter(id => id !== NOT_FOUND_ID)
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("when a payment isn't present in GovPay, it's marked as expired after 3 hours", async () => {
300
- const missingJournalEntry = journalEntries.find(je => je.id === NOT_FOUND_ID)
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("when a payment isn't present in GovPay, it's not marked as expired if 3 hours haven't passed", async () => {
312
- const missingJournalEntry = journalEntries.find(je => je.id === NOT_FOUND_ID)
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 processPaymentResults = async transaction => {
22
- if (transaction.paymentStatus.state?.status === 'error') {
23
- await salesApi.updatePaymentJournal(transaction.id, { paymentStatus: PAYMENT_JOURNAL_STATUS_CODES.Failed })
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
- await salesApi.updatePaymentJournal(transaction.id, { paymentStatus: PAYMENT_JOURNAL_STATUS_CODES.Completed })
37
- } else {
38
- // The payment expired
39
- if (transaction.paymentStatus.state?.code === GOVUK_PAY_ERROR_STATUS_CODES.EXPIRED) {
40
- await salesApi.updatePaymentJournal(transaction.id, { paymentStatus: PAYMENT_JOURNAL_STATUS_CODES.Expired })
41
- }
42
-
43
- // The user cancelled the payment
44
- if (transaction.paymentStatus.state?.code === GOVUK_PAY_ERROR_STATUS_CODES.USER_CANCELLED) {
45
- await salesApi.updatePaymentJournal(transaction.id, { paymentStatus: PAYMENT_JOURNAL_STATUS_CODES.Cancelled })
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 transactions = await Promise.all(
124
+ const journalsWithRecurringPaymentIDs = await Promise.all(
93
125
  paymentJournals.map(async p => {
94
126
  const transactionRecord = await salesApi.retrieveStagedTransaction(p.id)
95
- return {
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(transactions.map(async t => processPaymentResults(t)))
139
+ await Promise.all(journalsWithRecurringPaymentIDs.map(async j => processPaymentResults(j)))
104
140
  }