@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 +5 -5
- package/src/__mocks__/test-data.js +58 -0
- package/src/schema/__tests__/licence-details.schema.spec.js +93 -0
- package/src/schema/licence-details.schema.js +29 -0
- package/src/schema/payment-journal.schema.js +11 -26
- package/src/server/routes/__tests__/licence-details.spec.js +258 -0
- package/src/server/routes/__tests__/payment-journals.spec.js +1 -1
- package/src/server/routes/index.js +3 -1
- package/src/server/routes/licence-details.js +86 -0
- package/src/services/paymentjournals/__tests__/payment-journals.service.spec.js +1 -1
- package/src/services/paymentjournals/payment-journals.service.js +1 -1
- package/src/services/transactions/__tests__/finalise-transaction.spec.js +15 -0
- package/src/services/transactions/finalise-transaction.js +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defra-fish/sales-api-service",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
39
|
-
"@defra-fish/connectors-lib": "1.
|
|
40
|
-
"@defra-fish/dynamics-lib": "1.
|
|
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": "
|
|
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
|
-
|
|
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
|
}
|