@defra-fish/sales-api-service 1.64.0-rc.2 → 1.64.0-rc.21

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.64.0-rc.2",
3
+ "version": "1.64.0-rc.21",
4
4
  "description": "Rod Licensing Sales API",
5
5
  "type": "module",
6
6
  "engines": {
@@ -35,22 +35,22 @@
35
35
  "test": "echo \"Error: run tests from root\" && exit 1"
36
36
  },
37
37
  "dependencies": {
38
- "@defra-fish/business-rules-lib": "1.64.0-rc.2",
39
- "@defra-fish/connectors-lib": "1.64.0-rc.2",
40
- "@defra-fish/dynamics-lib": "1.64.0-rc.2",
41
- "@hapi/boom": "^9.1.2",
42
- "@hapi/hapi": "^20.1.3",
43
- "@hapi/inert": "^6.0.3",
44
- "@hapi/vision": "^6.1.0",
45
- "debug": "^4.3.3",
46
- "dot-prop": "^6.0.1",
47
- "hapi-and-healthy": "^7.0.7",
48
- "hapi-swagger": "^14.2.5",
49
- "ioredis": "^4.28.5",
50
- "joi": "^17.6.0",
51
- "moment": "^2.29.1",
52
- "moment-timezone": "^0.5.34",
53
- "uuid": "^8.3.2"
38
+ "@defra-fish/business-rules-lib": "1.64.0-rc.21",
39
+ "@defra-fish/connectors-lib": "1.64.0-rc.21",
40
+ "@defra-fish/dynamics-lib": "1.64.0-rc.21",
41
+ "@hapi/boom": "9.1.2",
42
+ "@hapi/hapi": "20.1.3",
43
+ "@hapi/inert": "6.0.3",
44
+ "@hapi/vision": "6.1.0",
45
+ "debug": "4.3.3",
46
+ "dot-prop": "6.0.1",
47
+ "hapi-and-healthy": "7.0.7",
48
+ "hapi-swagger": "14.2.5",
49
+ "ioredis": "4.28.5",
50
+ "joi": "17.6.0",
51
+ "moment": "2.29.1",
52
+ "moment-timezone": "0.5.34",
53
+ "uuid": "8.3.2"
54
54
  },
55
- "gitHead": "b901cb67affda78c747af57f88753b8039bd2172"
55
+ "gitHead": "d93d2a58352234a4f929a566809b338516ebeb1c"
56
56
  }
@@ -20,10 +20,29 @@ export const authenticateRenewalRequestQuerySchema = Joi.object({
20
20
  }).label('authenticate-renewal-request-query')
21
21
 
22
22
  export const authenticateRenewalResponseSchema = Joi.object({
23
- permission: {
23
+ permission: Joi.object({
24
24
  ...finalisedPermissionSchemaContent,
25
25
  licensee: contactResponseSchema,
26
26
  concessions: concessionProofSchema,
27
27
  permit: permitSchema
28
- }
28
+ })
29
29
  }).label('authenticate-renewal-response')
30
+
31
+ export const rcpAuthenticateRenewalResponseSchema = Joi.object({
32
+ permission: Joi.object({
33
+ ...finalisedPermissionSchemaContent,
34
+ licensee: contactResponseSchema,
35
+ concessions: concessionProofSchema,
36
+ permit: permitSchema
37
+ }),
38
+ recurringPayment: Joi.object({
39
+ id: Joi.string().uuid().required(),
40
+ agreementId: Joi.string().required(),
41
+ status: Joi.alternatives().try(Joi.number(), Joi.string()).required(),
42
+ nextDueDate: Joi.date().required(),
43
+ cancelledDate: Joi.date().allow(null),
44
+ cancelledReason: Joi.string().allow(null),
45
+ endDate: Joi.date().required(),
46
+ lastDigitsCardNumbers: Joi.string().required()
47
+ }).optional()
48
+ }).label('rcp-authenticate-renewal-response')
@@ -1,31 +1,37 @@
1
- import { contactForLicenseeNoReference, executeQuery } from '@defra-fish/dynamics-lib'
2
- import db from 'debug'
3
- jest.mock('@defra-fish/dynamics-lib')
4
- jest.mock('debug')
1
+ import '@defra-fish/dynamics-lib'
5
2
 
6
3
  describe('executeWithErrorLog', () => {
7
- it('throws error', async () => {
8
- const debug = jest.fn()
9
- db.mockReturnValueOnce(debug)
10
- executeQuery.mockImplementation(() => {
11
- throw new Error()
4
+ it('logs the filter when executeQuery fails via the handler', async () => {
5
+ jest.resetModules()
6
+
7
+ const debugSpy = jest.fn()
8
+ jest.doMock('debug', () => jest.fn(() => debugSpy))
9
+
10
+ jest.doMock('@defra-fish/dynamics-lib', () => {
11
+ const actual = jest.requireActual('@defra-fish/dynamics-lib')
12
+ return {
13
+ ...actual,
14
+ executeQuery: jest.fn().mockRejectedValueOnce(new Error('boom')),
15
+ contactForLicenseeNoReference: jest.fn(() => ({ filter: 'query filter test' })),
16
+ permissionForContacts: jest.fn(() => [])
17
+ }
12
18
  })
13
- contactForLicenseeNoReference.mockReturnValueOnce({ filter: 'query filter test' })
14
- const authenticate = require('../authenticate.js').default
19
+
20
+ const authenticate = (await import('../authenticate.js')).default
15
21
  const [
16
22
  {
17
23
  options: { handler }
18
24
  }
19
25
  ] = authenticate
20
- const mockRequest = {
26
+
27
+ const request = {
21
28
  query: { licenseeBirthDate: '', licenseePostcode: '' },
22
29
  params: { referenceNumber: '' }
23
30
  }
31
+ const h = { response: () => ({ code: () => {} }) }
24
32
 
25
- try {
26
- await handler(mockRequest)
27
- } catch {}
33
+ await handler(request, h).catch(() => {})
28
34
 
29
- expect(debug).toHaveBeenCalledWith('Error executing query with filter query filter test')
35
+ expect(debugSpy).toHaveBeenCalledWith('Error executing query with filter query filter test')
30
36
  })
31
37
  })
@@ -7,6 +7,14 @@ import {
7
7
  MOCK_CONCESSION_PROOF_ENTITY,
8
8
  MOCK_CONCESSION
9
9
  } from '../../../__mocks__/test-data.js'
10
+ import authenticate from '../authenticate.js'
11
+ import { findLinkedRecurringPayment } from '../../../services/recurring-payments.service.js'
12
+
13
+ const [
14
+ {
15
+ options: { handler }
16
+ }
17
+ ] = authenticate
10
18
 
11
19
  jest.mock('@defra-fish/dynamics-lib', () => ({
12
20
  ...jest.requireActual('@defra-fish/dynamics-lib'),
@@ -15,6 +23,10 @@ jest.mock('@defra-fish/dynamics-lib', () => ({
15
23
  permissionForContacts: jest.fn()
16
24
  }))
17
25
 
26
+ jest.mock('../../../services/recurring-payments.service.js', () => ({
27
+ findLinkedRecurringPayment: jest.fn()
28
+ }))
29
+
18
30
  let server = null
19
31
 
20
32
  describe('authenticate handler', () => {
@@ -26,6 +38,10 @@ describe('authenticate handler', () => {
26
38
  await server.stop()
27
39
  })
28
40
 
41
+ beforeEach(() => {
42
+ jest.clearAllMocks()
43
+ })
44
+
29
45
  describe('authenticateRenewal', () => {
30
46
  it('authenticates a renewal request', async () => {
31
47
  executeQuery.mockResolvedValueOnce([
@@ -144,6 +160,7 @@ describe('authenticate handler', () => {
144
160
  statusCode: 500
145
161
  })
146
162
  expect(consoleErrorSpy).toHaveBeenCalled()
163
+ consoleErrorSpy.mockRestore()
147
164
  })
148
165
 
149
166
  it('throws 401 errors if the renewal could not be authenticated', async () => {
@@ -190,4 +207,230 @@ describe('authenticate handler', () => {
190
207
  })
191
208
  })
192
209
  })
210
+
211
+ describe('authenticateRecurringPayment', () => {
212
+ const baseUrl = '/authenticate/rcp/CD379B?licenseeBirthDate=2000-01-01&licenseePostcode=AB12 3CD'
213
+
214
+ it('authenticates a recurring payment request and returns recurringPayment', async () => {
215
+ executeQuery
216
+ .mockResolvedValueOnce([{ entity: MOCK_EXISTING_CONTACT_ENTITY, expanded: {} }])
217
+ .mockResolvedValueOnce([
218
+ {
219
+ entity: MOCK_EXISTING_PERMISSION_ENTITY,
220
+ expanded: {
221
+ licensee: { entity: MOCK_EXISTING_CONTACT_ENTITY, expanded: {} },
222
+ concessionProofs: [{ entity: MOCK_CONCESSION_PROOF_ENTITY, expanded: { concession: { entity: MOCK_CONCESSION } } }],
223
+ permit: { entity: MOCK_1DAY_SENIOR_PERMIT_ENTITY, expanded: {} }
224
+ }
225
+ }
226
+ ])
227
+ .mockResolvedValueOnce([{ entity: MOCK_CONCESSION_PROOF_ENTITY, expanded: { concession: { entity: MOCK_CONCESSION } } }])
228
+
229
+ findLinkedRecurringPayment.mockResolvedValueOnce({
230
+ id: 'rcp-123',
231
+ status: 1
232
+ })
233
+
234
+ const result = await server.inject({ method: 'GET', url: baseUrl })
235
+ const body = JSON.parse(result.payload)
236
+
237
+ expect({
238
+ statusCode: result.statusCode,
239
+ body
240
+ }).toMatchObject({
241
+ statusCode: 200,
242
+ body: {
243
+ permission: expect.objectContaining({
244
+ ...MOCK_EXISTING_PERMISSION_ENTITY.toJSON(),
245
+ licensee: MOCK_EXISTING_CONTACT_ENTITY.toJSON(),
246
+ concessions: [
247
+ {
248
+ id: MOCK_CONCESSION.id,
249
+ proof: MOCK_CONCESSION_PROOF_ENTITY.toJSON()
250
+ }
251
+ ],
252
+ permit: MOCK_1DAY_SENIOR_PERMIT_ENTITY.toJSON()
253
+ }),
254
+ recurringPayment: expect.objectContaining({ id: 'rcp-123', status: 1 })
255
+ }
256
+ })
257
+ })
258
+
259
+ it('calls findLinkedRecurringPayment with permission id', async () => {
260
+ executeQuery.mockResolvedValueOnce([{ entity: MOCK_EXISTING_CONTACT_ENTITY, expanded: {} }]).mockResolvedValueOnce([
261
+ {
262
+ entity: MOCK_EXISTING_PERMISSION_ENTITY,
263
+ expanded: {
264
+ licensee: { entity: MOCK_EXISTING_CONTACT_ENTITY, expanded: {} },
265
+ concessionProofs: [],
266
+ permit: { entity: MOCK_1DAY_SENIOR_PERMIT_ENTITY, expanded: {} }
267
+ }
268
+ }
269
+ ])
270
+
271
+ findLinkedRecurringPayment.mockResolvedValueOnce({ id: 'rcp-123' })
272
+
273
+ await server.inject({ method: 'GET', url: baseUrl })
274
+
275
+ expect(findLinkedRecurringPayment).toHaveBeenCalledWith(MOCK_EXISTING_PERMISSION_ENTITY.id)
276
+ })
277
+
278
+ it('returns 401 when no contacts found', async () => {
279
+ executeQuery.mockResolvedValueOnce([])
280
+
281
+ const result = await server.inject({ method: 'GET', url: baseUrl })
282
+ const body = JSON.parse(result.payload)
283
+
284
+ expect({
285
+ statusCode: result.statusCode,
286
+ body
287
+ }).toMatchObject({
288
+ statusCode: 401,
289
+ body: {
290
+ error: 'Unauthorized',
291
+ message: 'The licensee could not be authenticated'
292
+ }
293
+ })
294
+ })
295
+
296
+ it('returns 401 when no permissions match', async () => {
297
+ executeQuery.mockResolvedValueOnce([{ entity: MOCK_EXISTING_CONTACT_ENTITY, expanded: {} }]).mockResolvedValueOnce([])
298
+
299
+ const result = await server.inject({ method: 'GET', url: baseUrl })
300
+ const body = JSON.parse(result.payload)
301
+
302
+ expect({
303
+ statusCode: result.statusCode,
304
+ body
305
+ }).toMatchObject({
306
+ statusCode: 401,
307
+ body: {
308
+ error: 'Unauthorized',
309
+ message: 'The licensee could not be authenticated'
310
+ }
311
+ })
312
+ })
313
+
314
+ it('returns 500 when multiple permissions match', async () => {
315
+ executeQuery.mockResolvedValueOnce([{ entity: MOCK_EXISTING_CONTACT_ENTITY, expanded: {} }]).mockResolvedValueOnce([
316
+ {
317
+ entity: { id: 'p1', referenceNumber: 'CD379B' },
318
+ expanded: { concessionProofs: [], licensee: { entity: {}, expanded: {} }, permit: { entity: {}, expanded: {} } }
319
+ },
320
+ {
321
+ entity: { id: 'p2', referenceNumber: 'CD379B' },
322
+ expanded: { concessionProofs: [], licensee: { entity: {}, expanded: {} }, permit: { entity: {}, expanded: {} } }
323
+ }
324
+ ])
325
+
326
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
327
+
328
+ const result = await server.inject({ method: 'GET', url: baseUrl })
329
+ const body = JSON.parse(result.payload)
330
+
331
+ expect({
332
+ statusCode: result.statusCode,
333
+ body
334
+ }).toMatchObject({
335
+ statusCode: 500,
336
+ body: {
337
+ error: 'Internal Server Error',
338
+ message: 'Unable to authenticate, non-unique results for query'
339
+ }
340
+ })
341
+ expect(consoleErrorSpy).toHaveBeenCalled()
342
+ consoleErrorSpy.mockRestore()
343
+ })
344
+
345
+ it('returns 400 when query params are missing', async () => {
346
+ const result = await server.inject({ method: 'GET', url: '/authenticate/rcp/CD379B?' })
347
+ const body = JSON.parse(result.payload)
348
+
349
+ expect({
350
+ statusCode: result.statusCode,
351
+ body
352
+ }).toMatchObject({
353
+ statusCode: 400,
354
+ body: {
355
+ error: 'Bad Request',
356
+ message: 'Invalid query: "licenseeBirthDate" is required'
357
+ }
358
+ })
359
+ })
360
+
361
+ describe('if no concessions are returned', () => {
362
+ it('returns permission and recurringPayment without concessions', async () => {
363
+ executeQuery.mockResolvedValueOnce([{ entity: MOCK_EXISTING_CONTACT_ENTITY, expanded: {} }]).mockResolvedValueOnce([
364
+ {
365
+ entity: MOCK_EXISTING_PERMISSION_ENTITY,
366
+ expanded: {
367
+ licensee: { entity: MOCK_EXISTING_CONTACT_ENTITY, expanded: {} },
368
+ concessionProofs: [],
369
+ permit: { entity: MOCK_1DAY_SENIOR_PERMIT_ENTITY, expanded: {} }
370
+ }
371
+ }
372
+ ])
373
+
374
+ findLinkedRecurringPayment.mockResolvedValueOnce({
375
+ id: 'rcp-789',
376
+ status: 1
377
+ })
378
+
379
+ const result = await server.inject({ method: 'GET', url: baseUrl })
380
+ const body = JSON.parse(result.payload)
381
+
382
+ expect({
383
+ statusCode: result.statusCode,
384
+ body
385
+ }).toMatchObject({
386
+ statusCode: 200,
387
+ body: {
388
+ permission: expect.objectContaining({
389
+ ...MOCK_EXISTING_PERMISSION_ENTITY.toJSON(),
390
+ licensee: MOCK_EXISTING_CONTACT_ENTITY.toJSON(),
391
+ concessions: [],
392
+ permit: MOCK_1DAY_SENIOR_PERMIT_ENTITY.toJSON()
393
+ }),
394
+ recurringPayment: expect.objectContaining({ id: 'rcp-789', status: 1 })
395
+ }
396
+ })
397
+ })
398
+ })
399
+ })
400
+
401
+ it('changes reference number to uppercase', async () => {
402
+ const sampleQueryReferenceNumber = 'abc123'
403
+ const sampleResultReferenceNumber = sampleQueryReferenceNumber.toUpperCase()
404
+ const makeMockEntity = (obj = {}) => ({
405
+ ...obj,
406
+ toJSON: () => obj
407
+ })
408
+ executeQuery.mockReturnValueOnce([{ entity: { id: 'hgk-999' } }]).mockReturnValueOnce([
409
+ {
410
+ entity: makeMockEntity({
411
+ referenceNumber: sampleResultReferenceNumber
412
+ }),
413
+ expanded: {
414
+ concessionProofs: [],
415
+ licensee: { entity: makeMockEntity() },
416
+ permit: { entity: makeMockEntity() }
417
+ }
418
+ }
419
+ ])
420
+ const mockRequest = {
421
+ query: { licenseeBirthDate: '', licenseePostcode: '' },
422
+ params: { referenceNumber: sampleQueryReferenceNumber }
423
+ }
424
+ const mockResponseToolkit = { response: jest.fn(() => ({ code: () => {} })) }
425
+
426
+ await handler(mockRequest, mockResponseToolkit)
427
+
428
+ expect(mockResponseToolkit.response).toHaveBeenCalledWith(
429
+ expect.objectContaining({
430
+ permission: expect.objectContaining({
431
+ referenceNumber: sampleResultReferenceNumber
432
+ })
433
+ })
434
+ )
435
+ })
193
436
  })
@@ -2,12 +2,16 @@ import Boom from '@hapi/boom'
2
2
  import {
3
3
  authenticateRenewalRequestParamsSchema,
4
4
  authenticateRenewalRequestQuerySchema,
5
- authenticateRenewalResponseSchema
5
+ authenticateRenewalResponseSchema,
6
+ rcpAuthenticateRenewalResponseSchema
6
7
  } from '../../schema/authenticate.schema.js'
7
8
  import db from 'debug'
8
9
  import { permissionForContacts, concessionsByIds, executeQuery, contactForLicenseeNoReference } from '@defra-fish/dynamics-lib'
10
+ import { findLinkedRecurringPayment } from '../../services/recurring-payments.service.js'
11
+
9
12
  const debug = db('sales:renewal-authentication')
10
13
  const failAuthenticate = 'The licensee could not be authenticated'
14
+ const HTTP_OK = 200
11
15
 
12
16
  const executeWithErrorLog = async query => {
13
17
  try {
@@ -18,45 +22,51 @@ const executeWithErrorLog = async query => {
18
22
  }
19
23
  }
20
24
 
25
+ const getAuthenticatedPermission = async request => {
26
+ const { licenseeBirthDate, licenseePostcode } = request.query
27
+ const contacts = await executeWithErrorLog(contactForLicenseeNoReference(licenseeBirthDate, licenseePostcode))
28
+
29
+ if (!contacts.length) {
30
+ throw Boom.unauthorized(failAuthenticate)
31
+ }
32
+
33
+ const contactIds = contacts.map(contact => contact.entity.id)
34
+ const permissions = await executeWithErrorLog(permissionForContacts(contactIds))
35
+ const results = permissions.filter(p => p.entity.referenceNumber.endsWith(request.params.referenceNumber.toUpperCase()))
36
+
37
+ if (results.length === 0) {
38
+ throw Boom.unauthorized(failAuthenticate)
39
+ }
40
+
41
+ if (results.length > 1) {
42
+ throw new Error('Unable to authenticate, non-unique results for query')
43
+ }
44
+
45
+ const [permission] = results
46
+ const concessionIds = permission.expanded.concessionProofs.map(f => f.entity.id)
47
+ const concessionProofs = permission.expanded.concessionProofs.length ? await executeWithErrorLog(concessionsByIds(concessionIds)) : []
48
+
49
+ return {
50
+ permission: {
51
+ ...permission.entity.toJSON(),
52
+ licensee: permission.expanded.licensee.entity.toJSON(),
53
+ concessions: concessionProofs.map(c => ({
54
+ id: c.expanded.concession.entity.id,
55
+ proof: c.entity.toJSON()
56
+ })),
57
+ permit: permission.expanded.permit.entity.toJSON()
58
+ }
59
+ }
60
+ }
61
+
21
62
  export default [
22
63
  {
23
64
  method: 'GET',
24
65
  path: '/authenticate/renewal/{referenceNumber}',
25
66
  options: {
26
67
  handler: async (request, h) => {
27
- const { licenseeBirthDate, licenseePostcode } = request.query
28
- const contacts = await executeWithErrorLog(contactForLicenseeNoReference(licenseeBirthDate, licenseePostcode))
29
- if (contacts.length > 0) {
30
- const contactIds = contacts.map(contact => contact.entity.id)
31
- const permissions = await executeWithErrorLog(permissionForContacts(contactIds))
32
- const results = permissions.filter(p => p.entity.referenceNumber.endsWith(request.params.referenceNumber))
33
- if (results.length === 1) {
34
- let concessionProofs = []
35
- if (results[0].expanded.concessionProofs.length > 0) {
36
- const ids = results[0].expanded.concessionProofs.map(f => f.entity.id)
37
- concessionProofs = await executeWithErrorLog(concessionsByIds(ids))
38
- }
39
- return h
40
- .response({
41
- permission: {
42
- ...results[0].entity.toJSON(),
43
- licensee: results[0].expanded.licensee.entity.toJSON(),
44
- concessions: concessionProofs.map(c => ({
45
- id: c.expanded.concession.entity.id,
46
- proof: c.entity.toJSON()
47
- })),
48
- permit: results[0].expanded.permit.entity.toJSON()
49
- }
50
- })
51
- .code(200)
52
- } else if (results.length === 0) {
53
- throw Boom.unauthorized(failAuthenticate)
54
- } else {
55
- throw new Error('Unable to authenticate, non-unique results for query')
56
- }
57
- } else {
58
- throw Boom.unauthorized(failAuthenticate)
59
- }
68
+ const permissionData = await getAuthenticatedPermission(request)
69
+ return h.response(permissionData).code(HTTP_OK)
60
70
  },
61
71
  description: 'Authenticate a licensee by checking the licence number corresponds with the provided contact details',
62
72
  notes: `
@@ -77,5 +87,40 @@ export default [
77
87
  }
78
88
  }
79
89
  }
90
+ },
91
+ {
92
+ method: 'GET',
93
+ path: '/authenticate/rcp/{referenceNumber}',
94
+ options: {
95
+ handler: async (request, h) => {
96
+ const { permission } = await getAuthenticatedPermission(request)
97
+ const recurringPayment = await findLinkedRecurringPayment(permission.id)
98
+ return h.response({ permission, recurringPayment }).code(HTTP_OK)
99
+ },
100
+ description:
101
+ 'Authenticate a licensee by checking the licence number corresponds with the provided contact details. Checking agreement id exists and recurring payment is active and not cancelled',
102
+ notes: `
103
+ Authenticate a licensee by checking the licence number corresponds with the provided contact details. Checking agreement id exists and recurring payment is active and not cancelled
104
+ `,
105
+ tags: ['api', 'authenticate'],
106
+ validate: {
107
+ params: authenticateRenewalRequestParamsSchema,
108
+ query: authenticateRenewalRequestQuerySchema
109
+ },
110
+ plugins: {
111
+ 'hapi-swagger': {
112
+ responses: {
113
+ 200: {
114
+ description: 'The licensee was successfully authenticated',
115
+ schema: rcpAuthenticateRenewalResponseSchema
116
+ },
117
+ 401: { description: failAuthenticate }
118
+ },
119
+ order: 2
120
+ }
121
+ }
122
+ }
80
123
  }
81
124
  ]
125
+
126
+ export const errorLogTest = { executeWithErrorLog }
@@ -6,7 +6,9 @@ import {
6
6
  findById,
7
7
  Permission,
8
8
  persist,
9
- RecurringPayment
9
+ RecurringPayment,
10
+ findRecurringPaymentByPermissionId,
11
+ retrieveGlobalOptionSets
10
12
  } from '@defra-fish/dynamics-lib'
11
13
  import {
12
14
  getRecurringPayments,
@@ -15,7 +17,8 @@ import {
15
17
  processRPResult,
16
18
  findNewestExistingRecurringPaymentInCrm,
17
19
  getRecurringPaymentAgreement,
18
- cancelRecurringPayment
20
+ cancelRecurringPayment,
21
+ findLinkedRecurringPayment
19
22
  } from '../recurring-payments.service.js'
20
23
  import { calculateEndDate, generatePermissionNumber } from '../permissions.service.js'
21
24
  import { getObfuscatedDob } from '../contacts.service.js'
@@ -50,7 +53,9 @@ jest.mock('@defra-fish/dynamics-lib', () => ({
50
53
  dynamicsClient: {
51
54
  retrieveMultipleRequest: jest.fn(() => ({ value: [] }))
52
55
  },
53
- persist: jest.fn()
56
+ persist: jest.fn(),
57
+ findRecurringPaymentByPermissionId: jest.fn(() => ({ toRetrieveRequest: () => {} })),
58
+ retrieveGlobalOptionSets: jest.fn()
54
59
  }))
55
60
 
56
61
  jest.mock('@defra-fish/connectors-lib', () => ({
@@ -720,8 +725,8 @@ describe('recurring payments service', () => {
720
725
  status: expect.objectContaining({ id: TRANSACTION_STATUS.FINALISED }),
721
726
  payment: expect.objectContaining({
722
727
  amount: mockTransaction.cost,
723
- method: TRANSACTION_SOURCE.govPay,
724
- source: PAYMENT_TYPE.debit,
728
+ source: TRANSACTION_SOURCE.govPay,
729
+ method: PAYMENT_TYPE.debit,
725
730
  timestamp: fakeNow
726
731
  })
727
732
  })
@@ -776,8 +781,8 @@ describe('recurring payments service', () => {
776
781
  it('returns a Recurring Payment (not a plain object)', async () => {
777
782
  jest.spyOn(RecurringPayment, 'fromResponse')
778
783
  dynamicsClient.retrieveMultipleRequest.mockReturnValueOnce(getMockResponse())
779
- const rcp = await findNewestExistingRecurringPaymentInCrm()
780
- expect(RecurringPayment.fromResponse.mock.results[0].value).toBe(rcp)
784
+ const recurringPayment = await findNewestExistingRecurringPaymentInCrm()
785
+ expect(RecurringPayment.fromResponse.mock.results[0].value).toBe(recurringPayment)
781
786
  })
782
787
 
783
788
  it.each([
@@ -811,14 +816,14 @@ describe('recurring payments service', () => {
811
816
  ]
812
817
  ])('returns most recent existing recurring payment from %s', async (_desc, mockResponseData, expectedId) => {
813
818
  dynamicsClient.retrieveMultipleRequest.mockReturnValueOnce({ value: mockResponseData })
814
- const rcp = await findNewestExistingRecurringPaymentInCrm()
815
- expect(rcp.id).toBe(expectedId)
819
+ const recurringPayment = await findNewestExistingRecurringPaymentInCrm()
820
+ expect(recurringPayment.id).toBe(expectedId)
816
821
  })
817
822
 
818
823
  it('returns boolean false if no recurring payments found', async () => {
819
824
  dynamicsClient.retrieveMultipleRequest.mockReturnValueOnce({ value: [] })
820
- const rcp = await findNewestExistingRecurringPaymentInCrm()
821
- expect(rcp).toBeFalsy()
825
+ const recurringPayment = await findNewestExistingRecurringPaymentInCrm()
826
+ expect(recurringPayment).toBeFalsy()
822
827
  })
823
828
  })
824
829
 
@@ -882,6 +887,7 @@ describe('recurring payments service', () => {
882
887
 
883
888
  describe('cancelRecurringPayment', () => {
884
889
  it('should call findById with RecurringPayment and the provided id', async () => {
890
+ retrieveGlobalOptionSets.mockReturnValueOnce({ cached: jest.fn().mockResolvedValue({ definition: 'mock-def' }) })
885
891
  findById.mockReturnValueOnce(getMockRecurringPayment())
886
892
  const id = 'abc123'
887
893
  await cancelRecurringPayment(id)
@@ -889,6 +895,20 @@ describe('recurring payments service', () => {
889
895
  })
890
896
 
891
897
  it('should call persist with the updated RecurringPayment', async () => {
898
+ retrieveGlobalOptionSets.mockReturnValueOnce({
899
+ cached: jest.fn().mockResolvedValue({
900
+ defra_cancelledreasons: {
901
+ options: {
902
+ 910400002: {
903
+ id: 910400002,
904
+ label: 'Payment Failure',
905
+ description: 'Payment Failure'
906
+ }
907
+ }
908
+ }
909
+ })
910
+ })
911
+
892
912
  const recurringPayment = getMockRecurringPayment()
893
913
  findById.mockReturnValueOnce(recurringPayment)
894
914
 
@@ -907,4 +927,71 @@ describe('recurring payments service', () => {
907
927
  await expect(cancelRecurringPayment('id')).rejects.toThrow('Invalid id provided for recurring payment cancellation')
908
928
  })
909
929
  })
930
+
931
+ describe('findLinkedRecurringPayment', () => {
932
+ const arrangeLinkedRcpSuccess = mockResponse => {
933
+ jest.spyOn(RecurringPayment, 'fromResponse')
934
+ dynamicsClient.retrieveMultipleRequest.mockReturnValueOnce(mockResponse)
935
+ retrieveGlobalOptionSets.mockReturnValueOnce({
936
+ cached: jest.fn().mockResolvedValue({ definition: 'mock-def' })
937
+ })
938
+ }
939
+
940
+ it('passes permission id to findRecurringPaymentByPermissionId', async () => {
941
+ const permissionId = Symbol('permission-id')
942
+ await findLinkedRecurringPayment(permissionId)
943
+ expect(findRecurringPaymentByPermissionId).toHaveBeenCalledWith(permissionId)
944
+ })
945
+
946
+ it('passes query created by findRecurringPaymentByPermissionId to retrieveMultipleRequest', async () => {
947
+ const retrieveRequest = Symbol('retrieve request')
948
+ findRecurringPaymentByPermissionId.mockReturnValueOnce({ toRetrieveRequest: () => retrieveRequest })
949
+ await findLinkedRecurringPayment()
950
+ expect(dynamicsClient.retrieveMultipleRequest).toHaveBeenCalledWith(retrieveRequest)
951
+ })
952
+
953
+ it('calls RecurringPayment.fromResponse with response and definitions', async () => {
954
+ arrangeLinkedRcpSuccess(getMockResponse())
955
+ await findLinkedRecurringPayment('abc123')
956
+ expect(RecurringPayment.fromResponse).toHaveBeenCalledWith(expect.any(Object), expect.anything())
957
+ })
958
+
959
+ it('returns the RecurringPayment produced by fromResponse', async () => {
960
+ arrangeLinkedRcpSuccess(getMockResponse())
961
+ const recurringPayment = await findLinkedRecurringPayment('abc123')
962
+ expect(RecurringPayment.fromResponse.mock.results[0].value).toBe(recurringPayment)
963
+ })
964
+
965
+ it.each([
966
+ [
967
+ 'two with last most recent',
968
+ [
969
+ { defra_recurringpaymentid: 'rcp-123', defra_enddate: '2024-01-01T00:00:00Z' },
970
+ { defra_recurringpaymentid: 'rcp-234', defra_enddate: '2025-01-01T00:00:00Z' }
971
+ ],
972
+ 'rcp-234'
973
+ ],
974
+ [
975
+ 'three with middle most recent',
976
+ [
977
+ { defra_recurringpaymentid: 'rcp-345', defra_enddate: '2023-01-01T00:00:00Z' },
978
+ { defra_recurringpaymentid: 'rcp-456', defra_enddate: '2026-01-01T00:00:00Z' },
979
+ { defra_recurringpaymentid: 'rcp-567', defra_enddate: '2025-01-01T00:00:00Z' }
980
+ ],
981
+ 'rcp-456'
982
+ ]
983
+ ])('returns the most recent linked recurring payment (%s)', async (_desc, mockData, expectedId) => {
984
+ dynamicsClient.retrieveMultipleRequest.mockReturnValueOnce({ value: mockData })
985
+ retrieveGlobalOptionSets.mockReturnValueOnce({ cached: jest.fn().mockResolvedValue({ def: 'mock' }) })
986
+
987
+ const recurringPayment = await findLinkedRecurringPayment('abc123')
988
+ expect(recurringPayment.id).toBe(expectedId)
989
+ })
990
+
991
+ it('returns false if no linked recurring payments found', async () => {
992
+ dynamicsClient.retrieveMultipleRequest.mockReturnValueOnce({ value: [] })
993
+ const recurringPayment = await findLinkedRecurringPayment('abc123')
994
+ expect(recurringPayment).toBeFalsy()
995
+ })
996
+ })
910
997
  })
@@ -5,7 +5,9 @@ import {
5
5
  findDueRecurringPayments,
6
6
  findRecurringPaymentsByAgreementId,
7
7
  persist,
8
- RecurringPayment
8
+ RecurringPayment,
9
+ findRecurringPaymentByPermissionId,
10
+ retrieveGlobalOptionSets
9
11
  } from '@defra-fish/dynamics-lib'
10
12
  import { calculateEndDate, generatePermissionNumber } from './permissions.service.js'
11
13
  import { getObfuscatedDob } from './contacts.service.js'
@@ -136,8 +138,8 @@ export const processRPResult = async (transactionId, paymentId, createdDate) =>
136
138
  status: { id: TRANSACTION_STATUS.FINALISED },
137
139
  payment: {
138
140
  amount: transactionRecord.cost,
139
- method: TRANSACTION_SOURCE.govPay,
140
- source: PAYMENT_TYPE.debit,
141
+ method: PAYMENT_TYPE.debit,
142
+ source: TRANSACTION_SOURCE.govPay,
141
143
  timestamp: new Date().toISOString()
142
144
  }
143
145
  }),
@@ -182,3 +184,14 @@ const determineRecurringPaymentName = (transactionRecord, contact) => {
182
184
  const [dueYear] = transactionRecord.payment.recurring.nextDueDate.split('-')
183
185
  return [contact.firstName, contact.lastName, dueYear].join(' ')
184
186
  }
187
+
188
+ export const findLinkedRecurringPayment = async permissionId => {
189
+ const query = findRecurringPaymentByPermissionId(permissionId)
190
+ const response = await dynamicsClient.retrieveMultipleRequest(query.toRetrieveRequest())
191
+ if (response.value.length) {
192
+ const [rcpResponseData] = response.value.sort((a, b) => Date.parse(b.defra_enddate) - Date.parse(a.defra_enddate))
193
+ const definition = await retrieveGlobalOptionSets().cached()
194
+ return RecurringPayment.fromResponse(rcpResponseData, definition)
195
+ }
196
+ return false
197
+ }