@defra-fish/sales-api-service 1.76.0-rc.5 → 1.77.0-rc.1

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.76.0-rc.5",
3
+ "version": "1.77.0-rc.1",
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.76.0-rc.5",
39
- "@defra-fish/connectors-lib": "1.76.0-rc.5",
40
- "@defra-fish/dynamics-lib": "1.76.0-rc.5",
38
+ "@defra-fish/business-rules-lib": "1.77.0-rc.1",
39
+ "@defra-fish/connectors-lib": "1.77.0-rc.1",
40
+ "@defra-fish/dynamics-lib": "1.77.0-rc.1",
41
41
  "@hapi/boom": "9.1.2",
42
42
  "@hapi/hapi": "20.1.3",
43
43
  "@hapi/inert": "6.0.3",
@@ -53,5 +53,5 @@
53
53
  "moment-timezone": "0.5.34",
54
54
  "uuid": "8.3.2"
55
55
  },
56
- "gitHead": "68399ce4682d08a92e4c073b19f178294ad855dc"
56
+ "gitHead": "2d2758c633c72fb22cd69fd5c9cbf0046568a9a9"
57
57
  }
@@ -165,8 +165,28 @@ export const MOCK_1DAY_FULL_PERMIT_DYNAMICS_RESPONSE = {
165
165
  defra_itemid: '42289'
166
166
  }
167
167
 
168
+ export const MOCK_8DAY_SENIOR_PERMIT_DYNAMICS_RESPONSE = {
169
+ '@odata.etag': 'W/"51026150"',
170
+ defra_availablefrom: '2017-03-31T23:00:00Z',
171
+ defra_availableto: '2021-03-31T22:59:00Z',
172
+ defra_durationnumericpart: 8,
173
+ defra_durationdaymonthyearpart: 910400000,
174
+ defra_numberofrods: 2,
175
+ defra_permittype: 910400000,
176
+ defra_advertisedprice: 12.0,
177
+ defra_datasource: 910400002,
178
+ defra_permitid: 'a01b34a0-0c66-e611-80dc-c4346bad0190',
179
+ defra_name: 'Coarse 8 day 2 Rod Licence (Senior)',
180
+ defra_permitsubtype: 910400001,
181
+ defra_isforfulfilment: false,
182
+ defra_iscountersales: true,
183
+ defra_recurringsupported: false,
184
+ defra_itemid: '42291'
185
+ }
186
+
168
187
  export const MOCK_1DAY_SENIOR_PERMIT_ENTITY = Permit.fromResponse(MOCK_1DAY_SENIOR_PERMIT_DYNAMICS_RESPONSE, optionSetData)
169
188
  export const MOCK_1DAY_FULL_PERMIT_ENTITY = Permit.fromResponse(MOCK_1DAY_FULL_PERMIT_DYNAMICS_RESPONSE, optionSetData)
189
+ export const MOCK_8DAY_SENIOR_PERMIT_ENTITY = Permit.fromResponse(MOCK_8DAY_SENIOR_PERMIT_DYNAMICS_RESPONSE, optionSetData)
170
190
 
171
191
  export const MOCK_12MONTH_SENIOR_PERMIT_DYNAMICS_RESPONSE = {
172
192
  '@odata.etag': 'W/"51026180"',
@@ -204,9 +224,47 @@ export const MOCK_12MONTH_DISABLED_PERMIT_DYNAMICS_RESPONSE = {
204
224
  defra_recurringsupported: true,
205
225
  defra_itemid: '42376'
206
226
  }
227
+ export const MOCK_12MONTH_JUNIOR_PERMIT_DYNAMICS_RESPONSE = {
228
+ '@odata.etag': 'W/"51026204"',
229
+ defra_availablefrom: '2017-03-31T23:00:00Z',
230
+ defra_availableto: '2021-03-31T22:59:00Z',
231
+ defra_durationnumericpart: 12,
232
+ defra_durationdaymonthyearpart: 910400001,
233
+ defra_numberofrods: 2,
234
+ defra_permittype: 910400000,
235
+ defra_advertisedprice: 6.0,
236
+ defra_datasource: 910400002,
237
+ defra_permitid: 'f21b34a0-0c66-e611-80dc-c4346bad0190',
238
+ defra_name: 'Coarse 12 month 2 Rod Licence (Junior)',
239
+ defra_permitsubtype: 910400001,
240
+ defra_isforfulfilment: true,
241
+ defra_iscountersales: true,
242
+ defra_recurringsupported: true,
243
+ defra_itemid: '42410'
244
+ }
245
+ export const MOCK_12MONTH_FULL_PERMIT_DYNAMICS_RESPONSE = {
246
+ '@odata.etag': 'W/"51026211"',
247
+ defra_availablefrom: '2017-03-31T23:00:00Z',
248
+ defra_availableto: '2021-03-31T22:59:00Z',
249
+ defra_durationnumericpart: 12,
250
+ defra_durationdaymonthyearpart: 910400001,
251
+ defra_numberofrods: 2,
252
+ defra_permittype: 910400000,
253
+ defra_advertisedprice: 54.0,
254
+ defra_datasource: 910400002,
255
+ defra_permitid: 'f41b34a0-0c66-e611-80dc-c4346bad0190',
256
+ defra_name: 'Coarse 12 month 2 Rod Licence (Full)',
257
+ defra_permitsubtype: 910400001,
258
+ defra_isforfulfilment: true,
259
+ defra_iscountersales: true,
260
+ defra_recurringsupported: true,
261
+ defra_itemid: '42420'
262
+ }
207
263
 
208
264
  export const MOCK_12MONTH_SENIOR_PERMIT = Permit.fromResponse(MOCK_12MONTH_SENIOR_PERMIT_DYNAMICS_RESPONSE, optionSetData)
209
265
  export const MOCK_12MONTH_DISABLED_PERMIT = Permit.fromResponse(MOCK_12MONTH_DISABLED_PERMIT_DYNAMICS_RESPONSE, optionSetData)
266
+ export const MOCK_12MONTH_JUNIOR_PERMIT = Permit.fromResponse(MOCK_12MONTH_JUNIOR_PERMIT_DYNAMICS_RESPONSE, optionSetData)
267
+ export const MOCK_12MONTH_FULL_PERMIT = Permit.fromResponse(MOCK_12MONTH_FULL_PERMIT_DYNAMICS_RESPONSE, optionSetData)
210
268
 
211
269
  export const MOCK_CONCESSION_PROOF_ENTITY = ConcessionProof.fromResponse(
212
270
  {
@@ -0,0 +1,93 @@
1
+ import moment from 'moment'
2
+ import { licenceDetailsRequestQuerySchema, licenceDetailsResponseSchema } from '../licence-details.schema.js'
3
+
4
+ const getRequestSampleData = () => ({
5
+ licenseeFirstName: 'Laila',
6
+ licenseeLastName: 'Alial',
7
+ licenseeBirthDate: '2000-10-03',
8
+ licenseePostcode: 'W8 7PP'
9
+ })
10
+
11
+ const optionSetOptionSample = () => ({ id: 910400000, label: 'Example Label', description: 'Example Description' })
12
+
13
+ const addDays = days => moment().add(days, 'days').toISOString()
14
+
15
+ const getResponseSampleData = () => ({
16
+ licences: [
17
+ {
18
+ id: 'a17fc331-141b-4fc0-8549-329d6934fadb',
19
+ referenceNumber: '00310321-2DC3FAS-F4A315',
20
+ issueDate: new Date().toISOString(),
21
+ startDate: addDays(7),
22
+ endDate: addDays(7 + 365),
23
+ stagingId: 'a17fc331-141b-4fc0-8549-329d6934fadb',
24
+ licensee: {
25
+ firstName: 'Laila',
26
+ lastName: 'Alial',
27
+ birthDate: '2000-10-03',
28
+ premises: '1',
29
+ town: 'Exampleton',
30
+ postcode: 'W8 7PP',
31
+ country: optionSetOptionSample()
32
+ },
33
+ permit: {
34
+ id: 'a17fc331-141b-4fc0-8549-329d6934fadb',
35
+ description: 'Coarse 12 month 3 Rod Licence',
36
+ availableFrom: new Date().toISOString(),
37
+ availableTo: new Date().toISOString(),
38
+ isForFulfilment: true,
39
+ isCounterSales: false,
40
+ isRecurringPaymentSupported: true
41
+ }
42
+ }
43
+ ]
44
+ })
45
+
46
+ describe('licenceDetailsRequestQuerySchema', () => {
47
+ it('validates successfully when all query parameters are valid', async () => {
48
+ const result = await licenceDetailsRequestQuerySchema.validateAsync(getRequestSampleData())
49
+ expect(result).toEqual(getRequestSampleData())
50
+ })
51
+
52
+ it.each([['licenseeFirstName'], ['licenseeLastName'], ['licenseeBirthDate'], ['licenseePostcode']])(
53
+ 'fails when %s is missing',
54
+ async field => {
55
+ const { [field]: _, ...invalidData } = getRequestSampleData()
56
+ await expect(licenceDetailsRequestQuerySchema.validateAsync(invalidData)).rejects.toThrow(`"${field}" is required`)
57
+ }
58
+ )
59
+
60
+ it('validates and formats a UK postcode', async () => {
61
+ const result = await licenceDetailsRequestQuerySchema.validateAsync({ ...getRequestSampleData(), licenseePostcode: 'w87pp' })
62
+ expect(result.licenseePostcode).toBe('W8 7PP')
63
+ })
64
+
65
+ it('validates an overseas postcode', async () => {
66
+ const result = await licenceDetailsRequestQuerySchema.validateAsync({ ...getRequestSampleData(), licenseePostcode: '90210' })
67
+ expect(result.licenseePostcode).toBe('90210')
68
+ })
69
+
70
+ it('fails when the postcode is invalid', async () => {
71
+ await expect(
72
+ licenceDetailsRequestQuerySchema.validateAsync({ ...getRequestSampleData(), licenseePostcode: '!!invalid!!' })
73
+ ).rejects.toThrow('"licenseePostcode" does not match any of the allowed types')
74
+ })
75
+ })
76
+
77
+ describe('licenceDetailsResponseSchema', () => {
78
+ it('validates successfully with a valid licences array', async () => {
79
+ const sampleData = getResponseSampleData()
80
+ const result = await licenceDetailsResponseSchema.validateAsync(sampleData)
81
+ expect(result).toEqual(sampleData)
82
+ })
83
+
84
+ it('fails when the licences array is missing', async () => {
85
+ await expect(licenceDetailsResponseSchema.validateAsync({})).rejects.toThrow('"licences" is required')
86
+ })
87
+
88
+ it('fails when a nested field is invalid', async () => {
89
+ const invalidData = getResponseSampleData()
90
+ invalidData.licences[0].permit.id = 'not-a-guid'
91
+ await expect(licenceDetailsResponseSchema.validateAsync(invalidData)).rejects.toThrow('"licences[0].permit.id" must be a valid GUID')
92
+ })
93
+ })
@@ -0,0 +1,29 @@
1
+ import Joi from 'joi'
2
+ import { validation } from '@defra-fish/business-rules-lib'
3
+ import { permitSchema } from './permit.schema.js'
4
+ import { contactResponseSchema } from './contact.schema.js'
5
+ import { finalisedPermissionSchemaContent } from './permission.schema.js'
6
+
7
+ export const licenceDetailsRequestQuerySchema = Joi.object({
8
+ licenseeFirstName: validation.contact.createFirstNameValidator(Joi).description('The first name of the licensee'),
9
+ licenseeLastName: validation.contact.createLastNameValidator(Joi).description('The last name of the licensee'),
10
+ licenseeBirthDate: validation.contact.createBirthDateValidator(Joi).description('The date of birth of the licensee'),
11
+ licenseePostcode: Joi.alternatives()
12
+ .try(
13
+ validation.contact.createUKPostcodeValidator(Joi).description('The postcode of the licensee'),
14
+ validation.contact.createOverseasPostcodeValidator(Joi)
15
+ )
16
+ .required()
17
+ }).label('licence-details-request-query')
18
+
19
+ export const licenceDetailsResponseSchema = Joi.object({
20
+ licences: Joi.array()
21
+ .items(
22
+ Joi.object({
23
+ ...finalisedPermissionSchemaContent,
24
+ licensee: contactResponseSchema,
25
+ permit: permitSchema
26
+ })
27
+ )
28
+ .required()
29
+ }).label('licence-details-response')
@@ -3,17 +3,10 @@ import { v4 as uuidv4 } from 'uuid'
3
3
  import { PAYMENT_JOURNAL_STATUS_CODES } from '@defra-fish/business-rules-lib'
4
4
 
5
5
  export const paymentJournalEntryParamsSchema = Joi.object({
6
- id: Joi.string()
7
- .min(1)
8
- .trim()
9
- .required()
10
- .description('The payment journal identifier')
11
- .example(uuidv4())
6
+ id: Joi.string().min(1).trim().required().description('The payment journal identifier').example(uuidv4())
12
7
  })
13
8
 
14
- const paymentReferenceSchema = Joi.string()
15
- .min(1)
16
- .example(uuidv4())
9
+ const paymentReferenceSchema = Joi.string().min(1).example(uuidv4())
17
10
  const paymentTimestampSchema = Joi.string()
18
11
  .isoDate()
19
12
  .required()
@@ -40,17 +33,17 @@ export const createPaymentJournalRequestSchema = Joi.object({
40
33
  export const updatePaymentJournalRequestSchema = Joi.object({
41
34
  paymentReference: paymentReferenceSchema.optional(),
42
35
  paymentTimestamp: paymentTimestampSchema.optional(),
43
- paymentStatus: paymentStatusSchema.optional()
36
+ paymentStatus: paymentStatusSchema.optional(),
37
+ eligibleForEarlyMopUp: Joi.boolean()
38
+ .description('A flag showing whether the payment journal can be mopped up before the normal elapsed time has occurred')
39
+ .optional()
44
40
  })
45
- .or('paymentTimestamp', 'paymentReference', 'paymentStatus')
41
+ .or('paymentTimestamp', 'paymentReference', 'paymentStatus', 'eligibleForEarlyMopUp')
46
42
  .label('update-payment-journal-request')
47
43
 
48
44
  export const paymentJournalResponseSchema = createPaymentJournalRequestSchema
49
45
  .append({
50
- id: Joi.string()
51
- .trim()
52
- .required()
53
- .example(uuidv4()),
46
+ id: Joi.string().trim().required().example(uuidv4()),
54
47
  expires: Joi.number().required()
55
48
  })
56
49
  .label('payment-journal-response')
@@ -61,20 +54,12 @@ export const paymentJournalResponseSchema = createPaymentJournalRequestSchema
61
54
  */
62
55
  export const paymentJournalQueryParams = Joi.object({
63
56
  paymentStatus: paymentStatusSchema,
64
- from: Joi.string()
65
- .isoDate()
66
- .required()
67
- .description('From date (inclusive)'),
68
- to: Joi.string()
69
- .isoDate()
70
- .required()
71
- .description('To date (inclusive)')
57
+ from: Joi.string().isoDate().required().description('From date (inclusive)'),
58
+ to: Joi.string().isoDate().required().description('To date (inclusive)')
72
59
  }).external(value => {
73
60
  if (value.from > value.to) {
74
61
  throw new Error('From date must not be after to date')
75
62
  }
76
63
  })
77
64
 
78
- export const paymentJournalQueryResponse = Joi.array()
79
- .items(paymentJournalResponseSchema)
80
- .label('payment-journal-query-response')
65
+ export const paymentJournalQueryResponse = Joi.array().items(paymentJournalResponseSchema).label('payment-journal-query-response')
@@ -0,0 +1,258 @@
1
+ import moment from 'moment'
2
+ import { contactForLicenseeByPersonalDetails, executeQuery, permissionForContacts, Permission } from '@defra-fish/dynamics-lib'
3
+ import route, { getLicenceDetails } from '../licence-details.js'
4
+ import db from 'debug'
5
+ import {
6
+ MOCK_EXISTING_PERMISSION_ENTITY,
7
+ MOCK_EXISTING_CONTACT_ENTITY,
8
+ MOCK_1DAY_SENIOR_PERMIT_ENTITY,
9
+ MOCK_8DAY_SENIOR_PERMIT_ENTITY,
10
+ MOCK_12MONTH_SENIOR_PERMIT,
11
+ MOCK_12MONTH_DISABLED_PERMIT,
12
+ MOCK_12MONTH_JUNIOR_PERMIT,
13
+ MOCK_12MONTH_FULL_PERMIT
14
+ } from '../../../__mocks__/test-data.js'
15
+
16
+ jest.mock('@defra-fish/dynamics-lib', () => ({
17
+ ...jest.requireActual('@defra-fish/dynamics-lib'),
18
+ contactForLicenseeByPersonalDetails: jest.fn(),
19
+ executeQuery: jest.fn(),
20
+ permissionForContacts: jest.fn()
21
+ }))
22
+
23
+ jest.mock('debug', () => jest.fn(() => jest.fn()))
24
+ const { value: mockDebug } = db.mock.results[db.mock.calls.findIndex(c => c[0] === 'sales:licence-details')]
25
+
26
+ describe('licence-details handler', () => {
27
+ beforeEach(() => {
28
+ jest.clearAllMocks()
29
+ contactForLicenseeByPersonalDetails.mockReturnValue({ filter: 'mock-contact-filter' })
30
+ permissionForContacts.mockImplementation(contactIds => ({ filter: `contactIds eq ${contactIds.join(',')}` }))
31
+ })
32
+
33
+ const baseRequest = {
34
+ query: {
35
+ licenseeFirstName: 'Bilbo',
36
+ licenseeLastName: 'Baggins',
37
+ licenseeBirthDate: '2000-10-03',
38
+ licenseePostcode: 'AB12 3CD'
39
+ }
40
+ }
41
+
42
+ const mockContact = () => ({ entity: MOCK_EXISTING_CONTACT_ENTITY, expanded: {} })
43
+
44
+ const addDays = days => moment().add(days, 'days').toISOString()
45
+
46
+ const mockPermission = ({ permit = MOCK_12MONTH_SENIOR_PERMIT, startDate = addDays(-30), endDate = addDays(30) } = {}) => ({
47
+ entity: Object.assign(new Permission(), MOCK_EXISTING_PERMISSION_ENTITY, { startDate, endDate }),
48
+ expanded: {
49
+ licensee: { entity: MOCK_EXISTING_CONTACT_ENTITY, expanded: {} },
50
+ permit: { entity: permit, expanded: {} }
51
+ }
52
+ })
53
+
54
+ const expectNotFoundError = promise =>
55
+ expect(promise).rejects.toMatchObject({
56
+ output: {
57
+ statusCode: 404,
58
+ payload: {
59
+ error: 'Not Found',
60
+ message: 'Licence details could not be found for the provided contact details',
61
+ statusCode: 404
62
+ }
63
+ }
64
+ })
65
+
66
+ const mockContactWithPermissions = (...permissions) => {
67
+ executeQuery.mockResolvedValueOnce([mockContact()])
68
+ executeQuery.mockResolvedValueOnce(permissions)
69
+ }
70
+
71
+ it('registers the expected route metadata', () => {
72
+ const routeMetadata = {
73
+ method: route[0].method,
74
+ path: route[0].path,
75
+ description: route[0].options.description,
76
+ notes: route[0].options.notes.trim(),
77
+ tags: route[0].options.tags
78
+ }
79
+
80
+ expect(routeMetadata).toMatchInlineSnapshot(`
81
+ Object {
82
+ "description": "Look up licence details for a licensee using their name, postcode and date of birth",
83
+ "method": "GET",
84
+ "notes": "Look up licence details for a licensee using their name, postcode and date of birth",
85
+ "path": "/licenceDetails",
86
+ "tags": Array [
87
+ "api",
88
+ "licence-details",
89
+ ],
90
+ }
91
+ `)
92
+ })
93
+
94
+ it('returns 500 if executeQuery throws', async () => {
95
+ executeQuery.mockRejectedValueOnce(new Error('some error'))
96
+
97
+ await expect(getLicenceDetails(baseRequest)).rejects.toThrow('some error')
98
+ expect(mockDebug).toHaveBeenCalledWith('Error executing query with filter mock-contact-filter')
99
+ })
100
+
101
+ it('logs an unknown filter if executeQuery throws with no query', async () => {
102
+ contactForLicenseeByPersonalDetails.mockReturnValueOnce(undefined)
103
+ executeQuery.mockRejectedValueOnce(new Error('some error'))
104
+
105
+ await expect(getLicenceDetails(baseRequest)).rejects.toThrow('some error')
106
+ expect(mockDebug).toHaveBeenCalledWith('Error executing query with filter unknown')
107
+ })
108
+
109
+ it.each([
110
+ ['Frodo', 'Underhill', '1993-09-22', 'EF45 6GH'],
111
+ ['Samwise', 'Gamgee', '1998-03-06', 'IJ78 9KL'],
112
+ ['Peregrin', 'Took', '1994-04-01', 'ZZ99 9ZZ']
113
+ ])(
114
+ 'calls contactForLicenseeByPersonalDetails with the name, dob and postcode from the query (%s %s)',
115
+ async (licenseeFirstName, licenseeLastName, licenseeBirthDate, licenseePostcode) => {
116
+ mockContactWithPermissions(mockPermission())
117
+ const query = { licenseeFirstName, licenseeLastName, licenseeBirthDate, licenseePostcode }
118
+
119
+ await getLicenceDetails({ query })
120
+
121
+ expect(contactForLicenseeByPersonalDetails).toHaveBeenCalledWith(query)
122
+ }
123
+ )
124
+
125
+ it('throws a not found error if no contacts match the provided details', async () => {
126
+ executeQuery.mockResolvedValueOnce([])
127
+
128
+ await expectNotFoundError(getLicenceDetails(baseRequest))
129
+ })
130
+
131
+ it('does not call permissionForContacts if no contacts match the provided details', async () => {
132
+ executeQuery.mockResolvedValueOnce([])
133
+
134
+ await expect(getLicenceDetails(baseRequest)).rejects.toThrow()
135
+ expect(permissionForContacts).not.toHaveBeenCalled()
136
+ })
137
+
138
+ it('calls permissionForContacts with contact ids from contactForLicenseeByPersonalDetails', async () => {
139
+ mockContactWithPermissions(mockPermission())
140
+
141
+ await getLicenceDetails(baseRequest)
142
+
143
+ expect(permissionForContacts).toHaveBeenCalledWith([MOCK_EXISTING_CONTACT_ENTITY.id])
144
+ })
145
+
146
+ it('throws a not found error if no permissions are found for the matching contacts', async () => {
147
+ executeQuery.mockResolvedValueOnce([mockContact()])
148
+ executeQuery.mockResolvedValueOnce([])
149
+
150
+ await expectNotFoundError(getLicenceDetails(baseRequest))
151
+ })
152
+
153
+ it('returns licence details matching the expected shape', async () => {
154
+ const permission = mockPermission()
155
+ mockContactWithPermissions(permission)
156
+
157
+ await expect(getLicenceDetails(baseRequest)).resolves.toMatchObject({
158
+ licences: [
159
+ expect.objectContaining({
160
+ ...permission.entity.toJSON(),
161
+ licensee: MOCK_EXISTING_CONTACT_ENTITY.toJSON(),
162
+ permit: MOCK_12MONTH_SENIOR_PERMIT.toJSON()
163
+ })
164
+ ]
165
+ })
166
+ })
167
+
168
+ it('returns multiple licences when more than one permission matches', async () => {
169
+ mockContactWithPermissions(mockPermission(), mockPermission())
170
+
171
+ const result = await getLicenceDetails(baseRequest)
172
+
173
+ expect(result).toMatchObject({
174
+ licences: expect.arrayContaining([expect.any(Object), expect.any(Object)])
175
+ })
176
+ })
177
+
178
+ it('returns 2 licences when more than one permission matches', async () => {
179
+ mockContactWithPermissions(mockPermission(), mockPermission())
180
+
181
+ const result = await getLicenceDetails(baseRequest)
182
+
183
+ expect(result.licences).toHaveLength(2)
184
+ })
185
+
186
+ it.each([
187
+ ['1-day', MOCK_1DAY_SENIOR_PERMIT_ENTITY],
188
+ ['8-day', MOCK_8DAY_SENIOR_PERMIT_ENTITY]
189
+ ])('excludes %s permits from the response', async (_, permit) => {
190
+ mockContactWithPermissions(mockPermission({ permit }))
191
+
192
+ await expectNotFoundError(getLicenceDetails(baseRequest))
193
+ })
194
+
195
+ it('excludes expired licences from the response', async () => {
196
+ mockContactWithPermissions(mockPermission({ endDate: addDays(-1) }))
197
+
198
+ await expectNotFoundError(getLicenceDetails(baseRequest))
199
+ })
200
+
201
+ it('includes licences that have been issued but have not yet started', async () => {
202
+ mockContactWithPermissions(mockPermission({ startDate: addDays(30), endDate: addDays(395) }))
203
+
204
+ await expect(getLicenceDetails(baseRequest)).resolves.toMatchObject({
205
+ licences: [expect.any(Object)]
206
+ })
207
+ })
208
+
209
+ it.each([
210
+ ['junior', MOCK_12MONTH_JUNIOR_PERMIT],
211
+ ['full', MOCK_12MONTH_FULL_PERMIT],
212
+ ['full, disabled', MOCK_12MONTH_DISABLED_PERMIT],
213
+ ['senior', MOCK_12MONTH_SENIOR_PERMIT]
214
+ ])('includes active twelve month licences for %s permits regardless of concession', async (_, permit) => {
215
+ mockContactWithPermissions(mockPermission({ permit }))
216
+
217
+ await expect(getLicenceDetails(baseRequest)).resolves.toMatchObject({
218
+ licences: [expect.any(Object)]
219
+ })
220
+ })
221
+
222
+ it('returns a valid result for a matching contact', async () => {
223
+ mockContactWithPermissions(mockPermission())
224
+
225
+ await expect(getLicenceDetails(baseRequest)).resolves.toMatchObject({
226
+ licences: [
227
+ expect.objectContaining({
228
+ id: MOCK_EXISTING_PERMISSION_ENTITY.id,
229
+ licensee: MOCK_EXISTING_CONTACT_ENTITY.toJSON(),
230
+ permit: MOCK_12MONTH_SENIOR_PERMIT.toJSON()
231
+ })
232
+ ]
233
+ })
234
+ })
235
+
236
+ it('documents the validation contract for the route query string', () => {
237
+ expect(route[0].options.validate.query).toBeDefined()
238
+ })
239
+
240
+ it('handler wraps the licence details in a 200 response', async () => {
241
+ mockContactWithPermissions(mockPermission())
242
+ const code = jest.fn()
243
+ const h = { response: jest.fn().mockReturnValue({ code }) }
244
+
245
+ await route[0].options.handler(baseRequest, h)
246
+
247
+ expect(h.response).toHaveBeenCalledWith({
248
+ licences: [
249
+ expect.objectContaining({
250
+ id: MOCK_EXISTING_PERMISSION_ENTITY.id,
251
+ licensee: MOCK_EXISTING_CONTACT_ENTITY.toJSON(),
252
+ permit: MOCK_12MONTH_SENIOR_PERMIT.toJSON()
253
+ })
254
+ ]
255
+ })
256
+ expect(code).toHaveBeenCalledWith(200)
257
+ })
258
+ })
@@ -132,7 +132,7 @@ describe('payment journals handlers', () => {
132
132
  expect(JSON.parse(result.payload)).toMatchObject({
133
133
  error: 'Unprocessable Entity',
134
134
  message:
135
- 'Invalid payload: "update-payment-journal-request" must contain at least one of [paymentTimestamp, paymentReference, paymentStatus]',
135
+ 'Invalid payload: "update-payment-journal-request" must contain at least one of [paymentTimestamp, paymentReference, paymentStatus, eligibleForEarlyMopUp]',
136
136
  statusCode: 422
137
137
  })
138
138
  })
@@ -8,6 +8,7 @@ import Authenticate from './authenticate.js'
8
8
  import Users from './system-users.js'
9
9
  import RecurringPayment from './recurring-payments.js'
10
10
  import Renewals from './renewals.js'
11
+ import LicenceDetails from './licence-details.js'
11
12
 
12
13
  import Static from './static.js'
13
14
 
@@ -22,5 +23,6 @@ export default [
22
23
  ...Authenticate,
23
24
  ...Users,
24
25
  ...RecurringPayment,
25
- ...Renewals
26
+ ...Renewals,
27
+ ...LicenceDetails
26
28
  ]
@@ -0,0 +1,86 @@
1
+ import Boom from '@hapi/boom'
2
+ import { licenceDetailsRequestQuerySchema, licenceDetailsResponseSchema } from '../../schema/licence-details.schema.js'
3
+ import db from 'debug'
4
+ import { permissionForContacts, contactForLicenseeByPersonalDetails, executeQuery } from '@defra-fish/dynamics-lib'
5
+
6
+ const debug = db('sales:licence-details')
7
+ const failLicenceDetails = 'Licence details could not be found for the provided contact details'
8
+ const HTTP_OK = 200
9
+ const TWELVE_MONTH_DURATION_MAGNITUDE = 12
10
+ const TWELVE_MONTH_DURATION_DESIGNATOR = 'M'
11
+
12
+ const executeWithErrorLog = async query => {
13
+ try {
14
+ return await executeQuery(query)
15
+ } catch (e) {
16
+ debug(`Error executing query with filter ${query?.filter ?? 'unknown'}`)
17
+ throw e
18
+ }
19
+ }
20
+
21
+ const isActiveTwelveMonthLicence = permission => {
22
+ const { durationMagnitude, durationDesignator } = permission.expanded.permit.entity
23
+ const isTwelveMonthPermit =
24
+ durationMagnitude === TWELVE_MONTH_DURATION_MAGNITUDE && durationDesignator.description === TWELVE_MONTH_DURATION_DESIGNATOR
25
+ const hasNotExpired = new Date(permission.entity.endDate) >= new Date()
26
+ return isTwelveMonthPermit && hasNotExpired
27
+ }
28
+
29
+ export const getLicenceDetails = async request => {
30
+ const { licenseeFirstName, licenseeLastName, licenseeBirthDate, licenseePostcode } = request.query
31
+ const contacts = await executeWithErrorLog(
32
+ contactForLicenseeByPersonalDetails({ licenseeFirstName, licenseeLastName, licenseeBirthDate, licenseePostcode })
33
+ )
34
+
35
+ if (!contacts.length) {
36
+ throw Boom.notFound(failLicenceDetails)
37
+ }
38
+
39
+ const contactIds = contacts.map(contact => contact.entity.id)
40
+ const permissions = await executeWithErrorLog(permissionForContacts(contactIds))
41
+ const activeLicences = permissions.filter(isActiveTwelveMonthLicence)
42
+
43
+ if (!activeLicences.length) {
44
+ throw Boom.notFound(failLicenceDetails)
45
+ }
46
+
47
+ return {
48
+ licences: activeLicences.map(permission => ({
49
+ ...permission.entity.toJSON(),
50
+ licensee: permission.expanded.licensee.entity.toJSON(),
51
+ permit: permission.expanded.permit.entity.toJSON()
52
+ }))
53
+ }
54
+ }
55
+
56
+ const handler = async (request, h) => {
57
+ const licenceDetails = await getLicenceDetails(request)
58
+ return h.response(licenceDetails).code(HTTP_OK)
59
+ }
60
+
61
+ export default [
62
+ {
63
+ method: 'GET',
64
+ path: '/licenceDetails',
65
+ options: {
66
+ handler,
67
+ description: 'Look up licence details for a licensee using their name, postcode and date of birth',
68
+ notes: `
69
+ Look up licence details for a licensee using their name, postcode and date of birth
70
+ `,
71
+ tags: ['api', 'licence-details'],
72
+ validate: {
73
+ query: licenceDetailsRequestQuerySchema
74
+ },
75
+ plugins: {
76
+ 'hapi-swagger': {
77
+ responses: {
78
+ 200: { description: 'Licence details were found for the given contact details', schema: licenceDetailsResponseSchema },
79
+ 404: { description: failLicenceDetails }
80
+ },
81
+ order: 1
82
+ }
83
+ }
84
+ }
85
+ }
86
+ ]
@@ -27,7 +27,7 @@ describe('payment-journals service', () => {
27
27
  await createPaymentJournal('test-id', { some: 'data' })
28
28
  expect(docClient.put).toHaveBeenCalledWith({
29
29
  TableName: PAYMENTS_TABLE.TableName,
30
- Item: { id: 'test-id', some: 'data', expires: expect.any(Number) },
30
+ Item: { id: 'test-id', some: 'data', eligibleForEarlyMopUp: false, expires: expect.any(Number) },
31
31
  ConditionExpression: 'attribute_not_exists(id)'
32
32
  })
33
33
  })
@@ -10,7 +10,7 @@ const debug = db('sales:paymentjournals')
10
10
  * @returns {Promise<*>}
11
11
  */
12
12
  export async function createPaymentJournal (id, payload) {
13
- const record = { id, expires: Math.floor(Date.now() / 1000) + PAYMENTS_TABLE.Ttl, ...payload }
13
+ const record = { id, expires: Math.floor(Date.now() / 1000) + PAYMENTS_TABLE.Ttl, eligibleForEarlyMopUp: false, ...payload }
14
14
  await docClient.put({ TableName: PAYMENTS_TABLE.TableName, Item: record, ConditionExpression: 'attribute_not_exists(id)' })
15
15
  debug('Payment journal stored with payload %o', record)
16
16
  return record
@@ -11,6 +11,7 @@ import BusinessRulesLib from '@defra-fish/business-rules-lib'
11
11
  import moment from 'moment'
12
12
  import { TRANSACTION_STATUS } from '../constants.js'
13
13
  import permissionsService from '../../permissions.service.js'
14
+ import { updatePaymentJournal } from '../../paymentjournals/payment-journals.service.js'
14
15
  import { AWS } from '@defra-fish/connectors-lib'
15
16
  import db from 'debug'
16
17
 
@@ -19,6 +20,8 @@ const { docClient, sqs } = AWS.mock.results[0].value
19
20
  const debugLogger = db.mock.results[1].value // first call is by retrieve-transaction.js
20
21
  global.structuredClone = global.structuredClone ?? (obj => JSON.parse(JSON.stringify(obj)))
21
22
 
23
+ jest.mock('../../paymentjournals/payment-journals.service.js')
24
+
22
25
  jest.mock('../../permissions.service.js', () => ({
23
26
  generatePermissionNumber: jest.fn(() => MOCK_PERMISSION_NUMBER),
24
27
  calculateEndDate: jest.fn(() => MOCK_END_DATE)
@@ -327,6 +330,18 @@ describe('transaction service', () => {
327
330
  expect(docClient.createUpdateExpression).toHaveBeenNthCalledWith(2, originalRecord)
328
331
  })
329
332
 
333
+ it('updates the payment journal to be eligible for mop-up when the SQS message fails to send', async () => {
334
+ const { mockRecord } = sqsFailureSetup()
335
+ try {
336
+ await finaliseTransaction({
337
+ id: mockRecord.id,
338
+ payment: getSamplePayment()
339
+ })
340
+ } catch (e) {}
341
+
342
+ expect(updatePaymentJournal).toHaveBeenCalledWith(mockRecord.id, expect.objectContaining({ eligibleForEarlyMopUp: true }))
343
+ })
344
+
330
345
  it('throws an internal server error if the SQS message fails to send', async () => {
331
346
  const {
332
347
  mockRecord: { id }
@@ -8,6 +8,7 @@ import moment from 'moment'
8
8
  import Boom from '@hapi/boom'
9
9
  import { AWS } from '@defra-fish/connectors-lib'
10
10
  import db from 'debug'
11
+ import { updatePaymentJournal } from '../paymentjournals/payment-journals.service.js'
11
12
  const { sqs, docClient } = AWS()
12
13
  const debug = db('sales:transactions')
13
14
 
@@ -77,6 +78,7 @@ export async function finaliseTransaction ({ id, ...payload }) {
77
78
  ...docClient.createUpdateExpression(originalTransactionRecord),
78
79
  ReturnValues: 'ALL_NEW'
79
80
  })
81
+ await updatePaymentJournal(id, { eligibleForEarlyMopUp: true })
80
82
  } catch (rollbackError) {
81
83
  throw Boom.internal(`Failed to rollback transaction record ${id} after SQS send failure`, rollbackError)
82
84
  }