@defra-fish/sales-api-service 1.57.0-rc.4 → 1.57.0-rc.6

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/sales-api-service",
3
- "version": "1.57.0-rc.4",
3
+ "version": "1.57.0-rc.6",
4
4
  "description": "Rod Licensing Sales API",
5
5
  "type": "module",
6
6
  "engines": {
@@ -35,9 +35,9 @@
35
35
  "test": "echo \"Error: run tests from root\" && exit 1"
36
36
  },
37
37
  "dependencies": {
38
- "@defra-fish/business-rules-lib": "1.57.0-rc.4",
39
- "@defra-fish/connectors-lib": "1.57.0-rc.4",
40
- "@defra-fish/dynamics-lib": "1.57.0-rc.4",
38
+ "@defra-fish/business-rules-lib": "1.57.0-rc.6",
39
+ "@defra-fish/connectors-lib": "1.57.0-rc.6",
40
+ "@defra-fish/dynamics-lib": "1.57.0-rc.6",
41
41
  "@hapi/boom": "^9.1.2",
42
42
  "@hapi/hapi": "^20.1.3",
43
43
  "@hapi/inert": "^6.0.3",
@@ -52,5 +52,5 @@
52
52
  "moment-timezone": "^0.5.34",
53
53
  "uuid": "^8.3.2"
54
54
  },
55
- "gitHead": "54475774a2f320e8ba0ed1e6baa624e23f14decc"
55
+ "gitHead": "5c21e5443b8224f43c980c6a53746c593196500b"
56
56
  }
@@ -8,7 +8,7 @@ Object {
8
8
  "endDate": 2023-11-12T00:00:00.000Z,
9
9
  "name": "Test Name",
10
10
  "nextDueDate": 2023-11-02T00:00:00.000Z,
11
- "publicId": "1234456",
11
+ "publicId": "abcdef99987",
12
12
  "status": 0,
13
13
  }
14
14
  `;
@@ -1,5 +1,6 @@
1
- import { findDueRecurringPayments } from '@defra-fish/dynamics-lib'
2
- import { getRecurringPayments, processRecurringPayment } from '../recurring-payments.service.js'
1
+ import { findDueRecurringPayments, Permission } from '@defra-fish/dynamics-lib'
2
+ import { getRecurringPayments, processRecurringPayment, generateRecurringPaymentRecord } from '../recurring-payments.service.js'
3
+ import { createHash } from 'node:crypto'
3
4
 
4
5
  jest.mock('@defra-fish/dynamics-lib', () => ({
5
6
  ...jest.requireActual('@defra-fish/dynamics-lib'),
@@ -8,6 +9,13 @@ jest.mock('@defra-fish/dynamics-lib', () => ({
8
9
  findDueRecurringPayments: jest.fn()
9
10
  }))
10
11
 
12
+ jest.mock('node:crypto', () => ({
13
+ createHash: jest.fn(() => ({
14
+ update: () => {},
15
+ digest: () => 'abcdef99987'
16
+ }))
17
+ }))
18
+
11
19
  const dynamicsLib = jest.requireMock('@defra-fish/dynamics-lib')
12
20
 
13
21
  const getMockRecurringPayment = () => ({
@@ -81,6 +89,22 @@ const getMockPermission = () => ({
81
89
  })
82
90
 
83
91
  describe('recurring payments service', () => {
92
+ const createSimpleSampleTransactionRecord = () => ({ payment: { recurring: true }, permissions: [{}] })
93
+ const createSamplePermission = overrides => {
94
+ const p = new Permission()
95
+ p.referenceNumber = 'ABC123'
96
+ p.issueDate = '2024-12-04T11:15:12Z'
97
+ p.startDate = '2024-12-04T11:45:12Z'
98
+ p.endDate = '2025-12-03T23:59:59.999Z'
99
+ p.stagingId = 'aaa-111-bbb-222'
100
+ p.isRenewal = false
101
+ p.isLicenseForYou = 1
102
+ for (const key in overrides) {
103
+ p[key] = overrides[key]
104
+ }
105
+ return p
106
+ }
107
+
84
108
  beforeEach(jest.clearAllMocks)
85
109
  describe('getRecurringPayments', () => {
86
110
  it('should equal result of findDueRecurringPayments query', async () => {
@@ -123,7 +147,6 @@ describe('recurring payments service', () => {
123
147
  cancelledReason: null,
124
148
  endDate: new Date('2023-11-12'),
125
149
  agreementId: '435678',
126
- publicId: '1234456',
127
150
  status: 0
128
151
  }
129
152
  },
@@ -133,5 +156,175 @@ describe('recurring payments service', () => {
133
156
  const result = await processRecurringPayment(transactionRecord, contact)
134
157
  expect(result.recurringPayment).toMatchSnapshot()
135
158
  })
159
+
160
+ it.each(['abc-123', 'def-987'])('generates a publicId %s for the recurring payment', async samplePublicId => {
161
+ createHash.mockReturnValue({
162
+ update: () => {},
163
+ digest: () => samplePublicId
164
+ })
165
+ const result = await processRecurringPayment(createSimpleSampleTransactionRecord(), getMockContact())
166
+ expect(result.recurringPayment.publicId).toBe(samplePublicId)
167
+ })
168
+
169
+ it('passes the unique id of the entity to the hash.update function', async () => {
170
+ const update = jest.fn()
171
+ createHash.mockReturnValueOnce({
172
+ update,
173
+ digest: () => {}
174
+ })
175
+ const { recurringPayment } = await processRecurringPayment(createSimpleSampleTransactionRecord(), getMockContact())
176
+ expect(update).toHaveBeenCalledWith(recurringPayment.uniqueContentId)
177
+ })
178
+
179
+ it('hashes using sha256', async () => {
180
+ await processRecurringPayment(createSimpleSampleTransactionRecord(), getMockContact())
181
+ expect(createHash).toHaveBeenCalledWith('sha256')
182
+ })
183
+
184
+ it('uses base64 hash string', async () => {
185
+ const digest = jest.fn()
186
+ createHash.mockReturnValueOnce({
187
+ update: () => {},
188
+ digest
189
+ })
190
+ await processRecurringPayment(createSimpleSampleTransactionRecord(), getMockContact())
191
+ expect(digest).toHaveBeenCalledWith('base64')
192
+ })
193
+ })
194
+
195
+ describe('generateRecurringPaymentRecord', () => {
196
+ const createFinalisedSampleTransaction = (agreementId, permission) => ({
197
+ expires: 1732892402,
198
+ cost: 35.8,
199
+ isRecurringPaymentSupported: true,
200
+ permissions: [
201
+ {
202
+ permitId: 'permit-id-1',
203
+ licensee: {},
204
+ referenceNumber: '23211125-2WC3FBP-ABNDT8',
205
+ isLicenceForYou: true,
206
+ ...permission
207
+ }
208
+ ],
209
+ agreementId,
210
+ payment: {
211
+ amount: 35.8,
212
+ source: 'Gov Pay',
213
+ method: 'Debit card',
214
+ timestamp: '2024-11-22T15:00:45.922Z'
215
+ },
216
+ id: 'd26d646f-ed0f-4cf1-b6c1-ccfbbd611757',
217
+ dataSource: 'Web Sales',
218
+ transactionId: 'd26d646f-ed0f-4cf1-b6c1-ccfbbd611757',
219
+ status: { id: 'FINALISED' }
220
+ })
221
+
222
+ it.each([
223
+ [
224
+ 'same day start - next due on issue date plus one year minus ten days',
225
+ 'iujhy7u8ijhy7u8iuuiuu8ie89',
226
+ {
227
+ startDate: '2024-11-22T15:30:45.922Z',
228
+ issueDate: '2024-11-22T15:00:45.922Z',
229
+ endDate: '2025-11-21T23:59:59.999Z'
230
+ },
231
+ '2025-11-12T00:00:00.000Z'
232
+ ],
233
+ [
234
+ 'next day start - next due on end date minus ten days',
235
+ '89iujhy7u8i87yu9iokjuij901',
236
+ {
237
+ startDate: '2024-11-23T00:00:00.000Z',
238
+ issueDate: '2024-11-22T15:00:45.922Z',
239
+ endDate: '2025-11-22T23:59:59.999Z'
240
+ },
241
+ '2025-11-12T00:00:00.000Z'
242
+ ],
243
+ [
244
+ 'starts ten days after issue - next due on issue date plus one year',
245
+ '9o8u7yhui89u8i9oiu8i8u7yhu',
246
+ {
247
+ startDate: '2024-11-22T00:00:00.000Z',
248
+ issueDate: '2024-11-12T15:00:45.922Z',
249
+ endDate: '2025-11-21T23:59:59.999Z'
250
+ },
251
+ '2025-11-12T00:00:00.000Z'
252
+ ],
253
+ [
254
+ 'starts twenty days after issue - next due on issue date plus one year',
255
+ '9o8u7yhui89u8i9oiu8i8u7yhu',
256
+ {
257
+ startDate: '2024-12-01T00:00:00.000Z',
258
+ issueDate: '2024-11-12T15:00:45.922Z',
259
+ endDate: '2025-01-30T23:59:59.999Z'
260
+ },
261
+ '2025-11-12T00:00:00.000Z'
262
+ ],
263
+ [
264
+ "issued on 29th Feb '24, starts on 30th March '24 - next due on 28th Feb '25",
265
+ 'hy7u8ijhyu78jhyu8iu8hjiujn',
266
+ {
267
+ startDate: '2024-03-30T00:00:00.000Z',
268
+ issueDate: '2024-02-29T12:38:24.123Z',
269
+ endDate: '2025-03-29T23:59:59.999Z'
270
+ },
271
+ '2025-02-28T00:00:00.000Z'
272
+ ],
273
+ [
274
+ "issued on 30th March '25 at 1am, starts at 1:30am - next due on 20th March '26",
275
+ 'jhy67uijhy67u87yhtgjui8u7j',
276
+ {
277
+ startDate: '2025-03-30T01:30:00.000Z',
278
+ issueDate: '2025-03-30T01:00:00.000Z',
279
+ endDate: '2026-03-29T23:59:59.999Z'
280
+ },
281
+ '2026-03-20T00:00:00.000Z'
282
+ ]
283
+ ])('creates record from transaction with %s', (_d, agreementId, permissionData, expectedNextDueDate) => {
284
+ const sampleTransaction = createFinalisedSampleTransaction(agreementId, permissionData)
285
+ const permission = createSamplePermission(permissionData)
286
+
287
+ const rpRecord = generateRecurringPaymentRecord(sampleTransaction, permission)
288
+
289
+ expect(rpRecord).toEqual(
290
+ expect.objectContaining({
291
+ payment: expect.objectContaining({
292
+ recurring: expect.objectContaining({
293
+ name: '',
294
+ nextDueDate: expectedNextDueDate,
295
+ cancelledDate: null,
296
+ cancelledReason: null,
297
+ endDate: permissionData.endDate,
298
+ agreementId,
299
+ status: 1
300
+ })
301
+ }),
302
+ permissions: expect.arrayContaining([permission])
303
+ })
304
+ )
305
+ })
306
+
307
+ it.each([
308
+ [
309
+ 'start date is thirty one days after issue date',
310
+ {
311
+ startDate: '2024-12-14T00:00:00.000Z',
312
+ issueDate: '2024-11-12T15:00:45.922Z',
313
+ endDate: '2025-12-13T23:59:59.999Z'
314
+ }
315
+ ],
316
+ [
317
+ 'start date precedes issue date',
318
+ {
319
+ startDate: '2024-11-11T00:00:00.000Z',
320
+ issueDate: '2024-11-12T15:00:45.922Z',
321
+ endDate: '2025-11-10T23:59:59.999Z'
322
+ }
323
+ ]
324
+ ])('throws an error for invalid dates when %s', (_d, permission) => {
325
+ const sampleTransaction = createFinalisedSampleTransaction('hyu78ijhyu78ijuhyu78ij9iu6', permission)
326
+
327
+ expect(() => generateRecurringPaymentRecord(sampleTransaction)).toThrow('Invalid dates provided for permission')
328
+ })
136
329
  })
137
330
  })
@@ -1,22 +1,59 @@
1
1
  import { executeQuery, findDueRecurringPayments, RecurringPayment } from '@defra-fish/dynamics-lib'
2
+ import { createHash } from 'node:crypto'
3
+ import { ADVANCED_PURCHASE_MAX_DAYS } from '@defra-fish/business-rules-lib'
4
+ import moment from 'moment'
2
5
 
3
6
  export const getRecurringPayments = date => executeQuery(findDueRecurringPayments(date))
4
7
 
8
+ const getNextDueDate = (startDate, issueDate, endDate) => {
9
+ const mStart = moment(startDate)
10
+ if (mStart.isAfter(moment(issueDate)) && mStart.isSameOrBefore(moment(issueDate).add(ADVANCED_PURCHASE_MAX_DAYS, 'days'), 'day')) {
11
+ if (mStart.isSame(moment(issueDate), 'day')) {
12
+ return moment(startDate).add(1, 'year').subtract(10, 'days').startOf('day').toISOString()
13
+ }
14
+ if (mStart.isBefore(moment(issueDate).add(10, 'days'), 'day')) {
15
+ return moment(endDate).subtract(10, 'days').startOf('day').toISOString()
16
+ }
17
+ return moment(issueDate).add(1, 'year').startOf('day').toISOString()
18
+ }
19
+ throw new Error('Invalid dates provided for permission')
20
+ }
21
+
22
+ export const generateRecurringPaymentRecord = (transactionRecord, permission) => {
23
+ const [{ startDate, issueDate, endDate }] = transactionRecord.permissions
24
+ return {
25
+ payment: {
26
+ recurring: {
27
+ name: '',
28
+ nextDueDate: getNextDueDate(startDate, issueDate, endDate),
29
+ cancelledDate: null,
30
+ cancelledReason: null,
31
+ endDate,
32
+ agreementId: transactionRecord.agreementId,
33
+ status: 1
34
+ }
35
+ },
36
+ permissions: [permission]
37
+ }
38
+ }
39
+
5
40
  /**
6
41
  * Process a recurring payment instruction
7
42
  * @param transactionRecord
8
43
  * @returns {Promise<{recurringPayment: RecurringPayment | null}>}
9
44
  */
10
45
  export const processRecurringPayment = async (transactionRecord, contact) => {
46
+ const hash = createHash('sha256')
11
47
  if (transactionRecord.payment?.recurring) {
12
48
  const recurringPayment = new RecurringPayment()
49
+ hash.update(recurringPayment.uniqueContentId)
13
50
  recurringPayment.name = transactionRecord.payment.recurring.name
14
51
  recurringPayment.nextDueDate = transactionRecord.payment.recurring.nextDueDate
15
52
  recurringPayment.cancelledDate = transactionRecord.payment.recurring.cancelledDate
16
53
  recurringPayment.cancelledReason = transactionRecord.payment.recurring.cancelledReason
17
54
  recurringPayment.endDate = transactionRecord.payment.recurring.endDate
18
55
  recurringPayment.agreementId = transactionRecord.payment.recurring.agreementId
19
- recurringPayment.publicId = transactionRecord.payment.recurring.publicId
56
+ recurringPayment.publicId = hash.digest('base64')
20
57
  recurringPayment.status = transactionRecord.payment.recurring.status
21
58
  const [permission] = transactionRecord.permissions
22
59
  recurringPayment.bindToEntity(RecurringPayment.definition.relationships.activePermission, permission)
@@ -26,6 +26,7 @@ import { TRANSACTION_STAGING_TABLE, TRANSACTION_STAGING_HISTORY_TABLE } from '..
26
26
  import AwsMock from 'aws-sdk'
27
27
  import { POCL_DATA_SOURCE, DDE_DATA_SOURCE } from '@defra-fish/business-rules-lib'
28
28
  import moment from 'moment'
29
+ import { processRecurringPayment, generateRecurringPaymentRecord } from '../../recurring-payments.service.js'
29
30
 
30
31
  jest.mock('../../reference-data.service.js', () => ({
31
32
  ...jest.requireActual('../../reference-data.service.js'),
@@ -64,9 +65,12 @@ jest.mock('@defra-fish/business-rules-lib', () => ({
64
65
  START_AFTER_PAYMENT_MINUTES: 30
65
66
  }))
66
67
 
68
+ jest.mock('../../recurring-payments.service.js')
69
+
67
70
  describe('transaction service', () => {
68
71
  beforeAll(() => {
69
72
  TRANSACTION_STAGING_TABLE.TableName = 'TestTable'
73
+ processRecurringPayment.mockResolvedValue({})
70
74
  })
71
75
 
72
76
  beforeEach(jest.clearAllMocks)
@@ -125,6 +129,7 @@ describe('transaction service', () => {
125
129
  [
126
130
  'licences with a recurring payment',
127
131
  () => {
132
+ processRecurringPayment.mockResolvedValueOnce({ recurringPayment: new RecurringPayment() })
128
133
  const mockRecord = mockFinalisedTransactionRecord()
129
134
  mockRecord.payment.recurring = {
130
135
  name: 'Test name',
@@ -143,9 +148,9 @@ describe('transaction service', () => {
143
148
  expect.any(Transaction),
144
149
  expect.any(TransactionJournal),
145
150
  expect.any(TransactionJournal),
146
- expect.any(RecurringPayment),
147
151
  expect.any(Contact),
148
152
  expect.any(Permission),
153
+ expect.any(RecurringPayment),
149
154
  expect.any(RecurringPaymentInstruction),
150
155
  expect.any(ConcessionProof)
151
156
  ]
@@ -369,6 +374,43 @@ describe('transaction service', () => {
369
374
  expect(paymentJournal.total).toBe(cost)
370
375
  })
371
376
  })
377
+
378
+ describe('recurring payment processing', () => {
379
+ it('passes transaction record to generateRecurringPaymentRecord', async () => {
380
+ const callingArgs = {}
381
+ generateRecurringPaymentRecord.mockImplementationOnce(transaction => {
382
+ callingArgs.transaction = JSON.parse(JSON.stringify(transaction))
383
+ })
384
+ const mockRecord = mockFinalisedTransactionRecord()
385
+ AwsMock.DynamoDB.DocumentClient.__setResponse('get', { Item: mockRecord })
386
+ await processQueue({ id: mockRecord.id })
387
+ // jest.fn args aren't immutable and transaction is changed in processQueue, so we use our clone that hasn't changed
388
+ expect(callingArgs.transaction).toEqual(mockRecord)
389
+ })
390
+
391
+ it('passes permission to generateRecurringPaymentRecord', async () => {
392
+ const mockRecord = mockFinalisedTransactionRecord()
393
+ const expectedPermissionData = {}
394
+ const keysToCopy = ['referenceNumber', 'issueDate', 'startDate', 'endDate', 'isRenewal']
395
+ for (const key of keysToCopy) {
396
+ expectedPermissionData[key] = mockRecord.permissions[0][key]
397
+ }
398
+ AwsMock.DynamoDB.DocumentClient.__setResponse('get', { Item: mockRecord })
399
+
400
+ await processQueue({ id: mockRecord.id })
401
+
402
+ expect(generateRecurringPaymentRecord).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining(expectedPermissionData))
403
+ })
404
+
405
+ it('passes return value of generateRecurringPaymentRecord to processRecurringPayment', async () => {
406
+ const rprSymbol = Symbol('rpr')
407
+ const finalisedTransaction = mockFinalisedTransactionRecord()
408
+ generateRecurringPaymentRecord.mockReturnValueOnce(rprSymbol)
409
+ AwsMock.DynamoDB.DocumentClient.__setResponse('get', { Item: finalisedTransaction })
410
+ await processQueue({ id: finalisedTransaction.id })
411
+ expect(processRecurringPayment).toHaveBeenCalledWith(rprSymbol, expect.any(Contact))
412
+ })
413
+ })
372
414
  })
373
415
 
374
416
  describe('.getTransactionJournalRefNumber', () => {
@@ -12,7 +12,7 @@ import {
12
12
  } from '@defra-fish/dynamics-lib'
13
13
  import { DDE_DATA_SOURCE, FULFILMENT_SWITCHOVER_DATE, POCL_TRANSACTION_SOURCES } from '@defra-fish/business-rules-lib'
14
14
  import { getReferenceDataForEntityAndId, getGlobalOptionSetValue, getReferenceDataForEntity } from '../reference-data.service.js'
15
- import { processRecurringPayment } from '../recurring-payments.service.js'
15
+ import { generateRecurringPaymentRecord, processRecurringPayment } from '../recurring-payments.service.js'
16
16
  import { resolveContactPayload } from '../contacts.service.js'
17
17
  import { retrieveStagedTransaction } from './retrieve-transaction.js'
18
18
  import { TRANSACTION_STAGING_TABLE, TRANSACTION_STAGING_HISTORY_TABLE } from '../../config.js'
@@ -65,11 +65,6 @@ export async function processQueue ({ id }) {
65
65
  isRenewal
66
66
  )
67
67
 
68
- const { recurringPayment } = await processRecurringPayment(transactionRecord, contact)
69
- if (recurringPayment) {
70
- entities.push(recurringPayment)
71
- }
72
-
73
68
  permission.bindToEntity(Permission.definition.relationships.licensee, contact)
74
69
  permission.bindToEntity(Permission.definition.relationships.permit, permit)
75
70
  permission.bindToEntity(Permission.definition.relationships.transaction, transaction)
@@ -78,7 +73,10 @@ export async function processQueue ({ id }) {
78
73
 
79
74
  entities.push(contact, permission)
80
75
 
76
+ const { recurringPayment } = await processRecurringPayment(generateRecurringPaymentRecord(transactionRecord, permission), contact)
77
+
81
78
  if (recurringPayment && permit.isRecurringPaymentSupported) {
79
+ entities.push(recurringPayment)
82
80
  const paymentInstruction = new RecurringPaymentInstruction()
83
81
  paymentInstruction.bindToEntity(RecurringPaymentInstruction.definition.relationships.licensee, contact)
84
82
  paymentInstruction.bindToEntity(RecurringPaymentInstruction.definition.relationships.permit, permit)