@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.0",
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.0",
40
- "@defra-fish/connectors-lib": "1.63.0-rc.0",
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": "127bd6db86a97eb1b229f3de30e27046119ffca6"
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
- 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)
@@ -161,48 +166,146 @@ describe('processor', () => {
161
166
  })
162
167
  })
163
168
 
164
- describe('Result not present in GovPay', () => {
165
- const NOT_FOUND_ID = journalEntries[2].id
166
- const NOT_FOUND_PAYMENT_REFERENCE = journalEntries[2].paymentReference
167
- beforeEach(() => {
168
- salesApi.paymentJournals.getAll.mockReturnValue(journalEntries)
169
- salesApi.updatePaymentJournal.mockImplementation(() => {})
170
- salesApi.finaliseTransaction.mockImplementation(() => {})
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.mockImplementation(paymentReference => {
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
- 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()
188
288
  await expect(execute(1, 1)).resolves.toBeUndefined()
189
289
  })
190
290
 
191
- 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
+
192
294
  await execute(1, 1)
193
295
 
194
- 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)
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("when a payment isn't present in GovPay, it's marked as expired after 3 hours", async () => {
201
- const missingJournalEntry = journalEntries.find(je => je.id === NOT_FOUND_ID)
202
- missingJournalEntry.paymentTimestamp = moment()
203
- .subtract(3, 'hours')
204
- .toISOString()
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("when a payment isn't present in GovPay, it's not marked as expired if 3 hours haven't passed", async () => {
215
- const missingJournalEntry = journalEntries.find(je => je.id === NOT_FOUND_ID)
216
- missingJournalEntry.paymentTimestamp = moment()
217
- .subtract(2, 'hours')
218
- .toISOString()
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 processPaymentResults = async transaction => {
23
- if (transaction.paymentStatus.state?.status === 'error') {
24
- 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)
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
- await salesApi.updatePaymentJournal(transaction.id, { paymentStatus: PAYMENT_JOURNAL_STATUS_CODES.Completed })
38
- } else {
39
- // The payment expired
40
- if (transaction.paymentStatus.state?.code === GOVUK_PAY_ERROR_STATUS_CODES.EXPIRED) {
41
- await salesApi.updatePaymentJournal(transaction.id, { paymentStatus: PAYMENT_JOURNAL_STATUS_CODES.Expired })
42
- }
43
-
44
- // The user cancelled the payment
45
- if (transaction.paymentStatus.state?.code === GOVUK_PAY_ERROR_STATUS_CODES.USER_CANCELLED) {
46
- await salesApi.updatePaymentJournal(transaction.id, { paymentStatus: PAYMENT_JOURNAL_STATUS_CODES.Cancelled })
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 paymentStatusResponse = await govUkPayApi.fetchPaymentStatus(paymentReference)
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 transactions = await Promise.all(
95
- paymentJournals.map(async p => ({
96
- ...p,
97
- paymentStatus: await getStatusWrapped(p.paymentReference)
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(transactions.map(async t => processPaymentResults(t)))
139
+ await Promise.all(journalsWithRecurringPaymentIDs.map(async j => processPaymentResults(j)))
103
140
  }