@defra/forms-engine-plugin 4.0.42 → 4.0.44
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/.public/javascripts/shared.min.js +1 -1
- package/.public/javascripts/shared.min.js.map +1 -1
- package/.public/stylesheets/application.min.css +1 -1
- package/.public/stylesheets/application.min.css.map +1 -1
- package/.server/client/javascripts/location-map.js +8 -4
- package/.server/client/javascripts/location-map.js.map +1 -1
- package/.server/client/stylesheets/_payment-field.scss +8 -0
- package/.server/client/stylesheets/application.scss +2 -0
- package/.server/index.js +3 -1
- package/.server/index.js.map +1 -1
- package/.server/server/constants.d.ts +1 -0
- package/.server/server/constants.js +1 -0
- package/.server/server/constants.js.map +1 -1
- package/.server/server/forms/payment-test.yaml +42 -0
- package/.server/server/forms/register-as-a-unicorn-breeder.yaml +14 -0
- package/.server/server/plugins/engine/components/FormComponent.d.ts +1 -0
- package/.server/server/plugins/engine/components/FormComponent.js +1 -0
- package/.server/server/plugins/engine/components/FormComponent.js.map +1 -1
- package/.server/server/plugins/engine/components/PaymentField.d.ts +135 -0
- package/.server/server/plugins/engine/components/PaymentField.js +228 -0
- package/.server/server/plugins/engine/components/PaymentField.js.map +1 -0
- package/.server/server/plugins/engine/components/PaymentField.types.d.ts +21 -0
- package/.server/server/plugins/engine/components/PaymentField.types.js +2 -0
- package/.server/server/plugins/engine/components/PaymentField.types.js.map +1 -0
- package/.server/server/plugins/engine/components/UkAddressField.d.ts +1 -1
- package/.server/server/plugins/engine/components/UkAddressField.js +3 -1
- package/.server/server/plugins/engine/components/UkAddressField.js.map +1 -1
- package/.server/server/plugins/engine/components/helpers/components.d.ts +1 -1
- package/.server/server/plugins/engine/components/helpers/components.js +3 -0
- package/.server/server/plugins/engine/components/helpers/components.js.map +1 -1
- package/.server/server/plugins/engine/components/index.d.ts +1 -0
- package/.server/server/plugins/engine/components/index.js +1 -0
- package/.server/server/plugins/engine/components/index.js.map +1 -1
- package/.server/server/plugins/engine/configureEnginePlugin.d.ts +1 -1
- package/.server/server/plugins/engine/configureEnginePlugin.js +4 -2
- package/.server/server/plugins/engine/configureEnginePlugin.js.map +1 -1
- package/.server/server/plugins/engine/helpers.d.ts +1 -0
- package/.server/server/plugins/engine/models/SummaryViewModel.d.ts +3 -0
- package/.server/server/plugins/engine/models/SummaryViewModel.js +7 -0
- package/.server/server/plugins/engine/models/SummaryViewModel.js.map +1 -1
- package/.server/server/plugins/engine/options.js +2 -1
- package/.server/server/plugins/engine/options.js.map +1 -1
- package/.server/server/plugins/engine/outputFormatters/human/v1.js +34 -1
- package/.server/server/plugins/engine/outputFormatters/human/v1.js.map +1 -1
- package/.server/server/plugins/engine/outputFormatters/machine/v2.d.ts +22 -0
- package/.server/server/plugins/engine/outputFormatters/machine/v2.js +43 -1
- package/.server/server/plugins/engine/outputFormatters/machine/v2.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +29 -8
- package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/StartPageController.d.ts +2 -0
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +17 -0
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +173 -51
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/errors.d.ts +31 -0
- package/.server/server/plugins/engine/pageControllers/errors.js +59 -2
- package/.server/server/plugins/engine/pageControllers/errors.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/helpers/submission.d.ts +27 -0
- package/.server/server/plugins/engine/pageControllers/helpers/submission.js +77 -0
- package/.server/server/plugins/engine/pageControllers/helpers/submission.js.map +1 -0
- package/.server/server/plugins/engine/plugin.js +10 -5
- package/.server/server/plugins/engine/plugin.js.map +1 -1
- package/.server/server/plugins/engine/routes/index.js +8 -4
- package/.server/server/plugins/engine/routes/index.js.map +1 -1
- package/.server/server/plugins/engine/routes/payment-helper.d.ts +14 -0
- package/.server/server/plugins/engine/routes/payment-helper.js +41 -0
- package/.server/server/plugins/engine/routes/payment-helper.js.map +1 -0
- package/.server/server/plugins/engine/routes/payment-helper.test.js +81 -0
- package/.server/server/plugins/engine/routes/payment-helper.test.js.map +1 -0
- package/.server/server/plugins/engine/routes/payment.d.ts +8 -0
- package/.server/server/plugins/engine/routes/payment.js +140 -0
- package/.server/server/plugins/engine/routes/payment.js.map +1 -0
- package/.server/server/plugins/engine/routes/payment.test.js +187 -0
- package/.server/server/plugins/engine/routes/payment.test.js.map +1 -0
- package/.server/server/plugins/engine/services/localFormsService.js +6 -0
- package/.server/server/plugins/engine/services/localFormsService.js.map +1 -1
- package/.server/server/plugins/engine/types/schema.js +7 -0
- package/.server/server/plugins/engine/types/schema.js.map +1 -1
- package/.server/server/plugins/engine/types.d.ts +20 -1
- package/.server/server/plugins/engine/types.js +4 -0
- package/.server/server/plugins/engine/types.js.map +1 -1
- package/.server/server/plugins/engine/validationHelpers.d.ts +1 -1
- package/.server/server/plugins/engine/validationHelpers.js.map +1 -1
- package/.server/server/plugins/engine/views/components/paymentfield.html +42 -0
- package/.server/server/plugins/engine/views/index.html +9 -1
- package/.server/server/plugins/engine/views/partials/form.html +20 -5
- package/.server/server/plugins/engine/views/summary.html +17 -1
- package/.server/server/plugins/map/routes/get-os-token.d.ts +6 -0
- package/.server/server/plugins/map/routes/get-os-token.js +41 -0
- package/.server/server/plugins/map/routes/get-os-token.js.map +1 -0
- package/.server/server/plugins/map/routes/get-os-token.test.js +49 -0
- package/.server/server/plugins/map/routes/get-os-token.test.js.map +1 -0
- package/.server/server/plugins/map/routes/index.d.ts +1 -11
- package/.server/server/plugins/map/routes/index.js +60 -16
- package/.server/server/plugins/map/routes/index.js.map +1 -1
- package/.server/server/plugins/map/types.d.ts +1 -0
- package/.server/server/plugins/map/types.js +1 -0
- package/.server/server/plugins/map/types.js.map +1 -1
- package/.server/server/plugins/nunjucks/filters/field.d.ts +1 -1
- package/.server/server/plugins/payment/helper.d.ts +30 -0
- package/.server/server/plugins/payment/helper.js +49 -0
- package/.server/server/plugins/payment/helper.js.map +1 -0
- package/.server/server/plugins/payment/helper.test.js +37 -0
- package/.server/server/plugins/payment/helper.test.js.map +1 -0
- package/.server/server/plugins/payment/service.d.ts +40 -0
- package/.server/server/plugins/payment/service.js +129 -0
- package/.server/server/plugins/payment/service.js.map +1 -0
- package/.server/server/plugins/payment/service.test.js +162 -0
- package/.server/server/plugins/payment/service.test.js.map +1 -0
- package/.server/server/plugins/payment/types.d.ts +172 -0
- package/.server/server/plugins/payment/types.js +78 -0
- package/.server/server/plugins/payment/types.js.map +1 -0
- package/.server/server/types.d.ts +3 -0
- package/.server/server/types.js.map +1 -1
- package/.server/typings/hapi/index.d.js.map +1 -1
- package/README.md +12 -9
- package/package.json +2 -2
- package/src/client/javascripts/location-map.js +12 -4
- package/src/client/stylesheets/_payment-field.scss +8 -0
- package/src/client/stylesheets/application.scss +2 -0
- package/src/index.ts +5 -1
- package/src/server/constants.js +1 -0
- package/src/server/forms/payment-test.yaml +42 -0
- package/src/server/forms/register-as-a-unicorn-breeder.yaml +14 -0
- package/src/server/plugins/engine/components/FormComponent.ts +1 -0
- package/src/server/plugins/engine/components/PaymentField.test.ts +611 -0
- package/src/server/plugins/engine/components/PaymentField.ts +367 -0
- package/src/server/plugins/engine/components/PaymentField.types.ts +21 -0
- package/src/server/plugins/engine/components/UkAddressField.ts +2 -1
- package/src/server/plugins/engine/components/helpers/components.ts +5 -0
- package/src/server/plugins/engine/components/index.ts +1 -0
- package/src/server/plugins/engine/configureEnginePlugin.ts +4 -2
- package/src/server/plugins/engine/models/SummaryViewModel.ts +8 -0
- package/src/server/plugins/engine/options.js +2 -1
- package/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts +147 -0
- package/src/server/plugins/engine/outputFormatters/human/v1.test.ts +105 -103
- package/src/server/plugins/engine/outputFormatters/human/v1.ts +61 -2
- package/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts +115 -0
- package/src/server/plugins/engine/outputFormatters/machine/v2.ts +60 -1
- package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +32 -6
- package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +247 -72
- package/src/server/plugins/engine/pageControllers/errors.test.ts +13 -1
- package/src/server/plugins/engine/pageControllers/errors.ts +79 -4
- package/src/server/plugins/engine/pageControllers/helpers/submission.test.ts +299 -0
- package/src/server/plugins/engine/pageControllers/helpers/submission.ts +110 -0
- package/src/server/plugins/engine/plugin.ts +17 -10
- package/src/server/plugins/engine/routes/index.ts +17 -16
- package/src/server/plugins/engine/routes/payment-helper.js +39 -0
- package/src/server/plugins/engine/routes/payment-helper.test.js +90 -0
- package/src/server/plugins/engine/routes/payment.js +151 -0
- package/src/server/plugins/engine/routes/payment.test.js +180 -0
- package/src/server/plugins/engine/services/localFormsService.js +7 -0
- package/src/server/plugins/engine/types/schema.ts +9 -0
- package/src/server/plugins/engine/types.ts +25 -1
- package/src/server/plugins/engine/validationHelpers.ts +1 -1
- package/src/server/plugins/engine/views/components/paymentfield.html +42 -0
- package/src/server/plugins/engine/views/index.html +9 -1
- package/src/server/plugins/engine/views/partials/form.html +20 -5
- package/src/server/plugins/engine/views/summary.html +17 -1
- package/src/server/plugins/map/routes/get-os-token.js +41 -0
- package/src/server/plugins/map/routes/get-os-token.test.js +55 -0
- package/src/server/plugins/map/routes/index.js +70 -24
- package/src/server/plugins/map/types.js +1 -0
- package/src/server/plugins/payment/helper.js +56 -0
- package/src/server/plugins/payment/helper.test.js +52 -0
- package/src/server/plugins/payment/service.js +171 -0
- package/src/server/plugins/payment/service.test.js +205 -0
- package/src/server/plugins/payment/types.js +77 -0
- package/src/server/types.ts +3 -0
- package/src/typings/hapi/index.d.ts +1 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { getAccessToken } from '~/src/server/plugins/map/routes/get-os-token.js'
|
|
2
|
+
import { post } from '~/src/server/services/httpService.js'
|
|
3
|
+
|
|
4
|
+
jest.mock('~/src/server/services/httpService.ts')
|
|
5
|
+
|
|
6
|
+
describe('OS OAuth token', () => {
|
|
7
|
+
describe('getAccessToken', () => {
|
|
8
|
+
it('should get access token', async () => {
|
|
9
|
+
jest.mocked(post).mockResolvedValueOnce({
|
|
10
|
+
res: /** @type {IncomingMessage} */ ({
|
|
11
|
+
statusCode: 200,
|
|
12
|
+
headers: {}
|
|
13
|
+
}),
|
|
14
|
+
payload: {
|
|
15
|
+
access_token: 'access_token',
|
|
16
|
+
expires_in: '299',
|
|
17
|
+
issued_at: '1770036762387',
|
|
18
|
+
token_type: 'Bearer'
|
|
19
|
+
},
|
|
20
|
+
error: undefined
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const token = await getAccessToken({
|
|
24
|
+
ordnanceSurveyApiKey: 'apikey',
|
|
25
|
+
ordnanceSurveyApiSecret: 'apisecret'
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
expect(token).toBe('access_token')
|
|
29
|
+
|
|
30
|
+
expect(post).toHaveBeenCalledWith('https://api.os.uk/oauth2/token/v1', {
|
|
31
|
+
headers: {
|
|
32
|
+
Authorization: `Basic ${btoa('apikey:apisecret')}`,
|
|
33
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
34
|
+
},
|
|
35
|
+
payload: 'grant_type=client_credentials',
|
|
36
|
+
json: true
|
|
37
|
+
})
|
|
38
|
+
expect(post).toHaveBeenCalledTimes(1)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should return an cached token', async () => {
|
|
42
|
+
const token = await getAccessToken({
|
|
43
|
+
ordnanceSurveyApiKey: 'apikey',
|
|
44
|
+
ordnanceSurveyApiSecret: 'apisecret'
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
expect(token).toBe('access_token')
|
|
48
|
+
expect(post).toHaveBeenCalledTimes(0)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @import { IncomingMessage } from 'node:http'
|
|
55
|
+
*/
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { resolve } from 'node:path'
|
|
2
2
|
|
|
3
|
+
import { StatusCodes } from 'http-status-codes'
|
|
3
4
|
import Joi from 'joi'
|
|
4
5
|
|
|
6
|
+
import { getAccessToken } from '~/src/server/plugins/map/routes/get-os-token.js'
|
|
5
7
|
import { find, nearest } from '~/src/server/plugins/map/service.js'
|
|
6
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
get,
|
|
10
|
+
request as httpRequest
|
|
11
|
+
} from '~/src/server/services/httpService.js'
|
|
7
12
|
|
|
8
13
|
/**
|
|
9
14
|
* Gets the map support routes
|
|
@@ -11,17 +16,18 @@ import { request as httpRequest } from '~/src/server/services/httpService.js'
|
|
|
11
16
|
*/
|
|
12
17
|
export function getRoutes(options) {
|
|
13
18
|
return [
|
|
19
|
+
mapStyleResourceRoutes(),
|
|
14
20
|
mapProxyRoute(options),
|
|
21
|
+
tileProxyRoute(options),
|
|
15
22
|
geocodeProxyRoute(options),
|
|
16
|
-
reverseGeocodeProxyRoute(options)
|
|
17
|
-
...tileRoutes()
|
|
23
|
+
reverseGeocodeProxyRoute(options)
|
|
18
24
|
]
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
/**
|
|
22
28
|
* Proxies ordnance survey requests from the front end to api.os.com
|
|
23
|
-
* Used for VTS map
|
|
24
|
-
* and adding the
|
|
29
|
+
* Used for the VTS map source by forwarding on the request
|
|
30
|
+
* and adding the auth token and SRS (spatial reference system)
|
|
25
31
|
* @param {MapConfiguration} options - the map options
|
|
26
32
|
* @returns {ServerRoute<MapProxyGetRequestRefs>}
|
|
27
33
|
*/
|
|
@@ -32,14 +38,15 @@ function mapProxyRoute(options) {
|
|
|
32
38
|
handler: async (request, h) => {
|
|
33
39
|
const { query } = request
|
|
34
40
|
const targetUrl = new URL(decodeURIComponent(query.url))
|
|
41
|
+
const token = await getAccessToken(options)
|
|
35
42
|
|
|
36
|
-
|
|
37
|
-
targetUrl.searchParams.set('key', options.ordnanceSurveyApiKey)
|
|
38
|
-
if (!targetUrl.searchParams.has('srs')) {
|
|
39
|
-
targetUrl.searchParams.set('srs', '3857')
|
|
40
|
-
}
|
|
43
|
+
targetUrl.searchParams.set('srs', '3857')
|
|
41
44
|
|
|
42
|
-
const proxyResponse = await httpRequest('get', targetUrl.toString()
|
|
45
|
+
const proxyResponse = await httpRequest('get', targetUrl.toString(), {
|
|
46
|
+
headers: {
|
|
47
|
+
Authorization: `Bearer ${token}`
|
|
48
|
+
}
|
|
49
|
+
})
|
|
43
50
|
const buffer = proxyResponse.payload
|
|
44
51
|
const contentType = proxyResponse.res.headers['content-type']
|
|
45
52
|
const response = h.response(buffer)
|
|
@@ -63,7 +70,44 @@ function mapProxyRoute(options) {
|
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
/**
|
|
66
|
-
* Proxies ordnance survey
|
|
73
|
+
* Proxies ordnance survey requests from the front end to api.os.uk
|
|
74
|
+
* Used for VTS map tiles forwarding on the request and adding the auth token
|
|
75
|
+
* @param {MapConfiguration} options - the map options
|
|
76
|
+
* @returns {ServerRoute<MapProxyGetRequestRefs>}
|
|
77
|
+
*/
|
|
78
|
+
function tileProxyRoute(options) {
|
|
79
|
+
return {
|
|
80
|
+
method: 'GET',
|
|
81
|
+
path: '/api/tile/{z}/{y}/{x}.pbf',
|
|
82
|
+
handler: async (request, h) => {
|
|
83
|
+
const { z, y, x } = request.params
|
|
84
|
+
const token = await getAccessToken(options)
|
|
85
|
+
|
|
86
|
+
const url = `https://api.os.uk/maps/vector/v1/vts/tile/${z}/${y}/${x}.pbf?srs=3857`
|
|
87
|
+
|
|
88
|
+
const { payload, res } = await get(url, {
|
|
89
|
+
headers: {
|
|
90
|
+
Authorization: `Bearer ${token}`,
|
|
91
|
+
Accept: 'application/x-protobuf'
|
|
92
|
+
},
|
|
93
|
+
json: false,
|
|
94
|
+
gunzip: true
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
if (res.statusCode && res.statusCode !== StatusCodes.OK.valueOf()) {
|
|
98
|
+
return h.response('Tile fetch failed').code(res.statusCode)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return h
|
|
102
|
+
.response(payload)
|
|
103
|
+
.type('application/x-protobuf')
|
|
104
|
+
.header('Cache-Control', 'public, max-age=86400')
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Proxies ordnance survey geocode requests from the front end to api.os.uk
|
|
67
111
|
* Used for the gazzeteer address lookup to find name from query strings like postcode and place names
|
|
68
112
|
* @param {MapConfiguration} options - the map options
|
|
69
113
|
* @returns {ServerRoute<MapGeocodeGetRequestRefs>}
|
|
@@ -91,7 +135,7 @@ function geocodeProxyRoute(options) {
|
|
|
91
135
|
}
|
|
92
136
|
|
|
93
137
|
/**
|
|
94
|
-
* Proxies ordnance survey reverse geocode requests from the front end to api.os.
|
|
138
|
+
* Proxies ordnance survey reverse geocode requests from the front end to api.os.uk
|
|
95
139
|
* Used to find name from easting and northing points.
|
|
96
140
|
* N.B this endpoint is currently not used by the front end but will be soon in "maps V2"
|
|
97
141
|
* @param {MapConfiguration} options - the map options
|
|
@@ -124,20 +168,22 @@ function reverseGeocodeProxyRoute(options) {
|
|
|
124
168
|
}
|
|
125
169
|
}
|
|
126
170
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
171
|
+
/**
|
|
172
|
+
* Resource routes to return sprites and glyphs
|
|
173
|
+
* @returns {ServerRoute<MapProxyGetRequestRefs>}
|
|
174
|
+
*/
|
|
175
|
+
function mapStyleResourceRoutes() {
|
|
176
|
+
return {
|
|
177
|
+
method: 'GET',
|
|
178
|
+
path: '/api/maps/vts/{path*}',
|
|
179
|
+
options: {
|
|
180
|
+
handler: {
|
|
181
|
+
directory: {
|
|
182
|
+
path: resolve(import.meta.dirname, './vts')
|
|
137
183
|
}
|
|
138
184
|
}
|
|
139
185
|
}
|
|
140
|
-
|
|
186
|
+
}
|
|
141
187
|
}
|
|
142
188
|
|
|
143
189
|
/**
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { format } from 'date-fns'
|
|
2
|
+
|
|
3
|
+
import { PaymentService } from '~/src/server/plugins/payment/service.js'
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_PAYMENT_HELP_URL =
|
|
6
|
+
'https://www.gov.uk/government/organisations/department-for-environment-food-rural-affairs'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Determine which payment API key value to use.
|
|
10
|
+
* If a draft preview form or a live preview form, read the TEST API key value specific to that form.
|
|
11
|
+
* If a live (non-preview) form, read the LIVE API key value specific to that form.
|
|
12
|
+
* @param {boolean} isLivePayment - true if this is a live payment (as opposed to a test one)
|
|
13
|
+
* @param {string} formId - id of the form
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function getPaymentApiKey(isLivePayment, formId) {
|
|
17
|
+
const apiKeyValue = isLivePayment
|
|
18
|
+
? process.env[`PAYMENT_PROVIDER_API_KEY_LIVE_${formId}`]
|
|
19
|
+
: process.env[`PAYMENT_PROVIDER_API_KEY_TEST_${formId}`]
|
|
20
|
+
|
|
21
|
+
if (!apiKeyValue) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`Missing payment api key for ${isLivePayment ? 'live' : 'test'} form id ${formId}`
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
return apiKeyValue
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a PaymentService instance with the appropriate API key
|
|
31
|
+
* @param {boolean} isLivePayment - true if this is a live payment
|
|
32
|
+
* @param {string} formId - id of the form
|
|
33
|
+
* @returns {PaymentService}
|
|
34
|
+
*/
|
|
35
|
+
export function createPaymentService(isLivePayment, formId) {
|
|
36
|
+
const apiKey = getPaymentApiKey(isLivePayment, formId)
|
|
37
|
+
return new PaymentService(apiKey)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Formats a payment date for display
|
|
42
|
+
* @param {string} isoString - ISO date string
|
|
43
|
+
* @returns {string} Formatted date string (e.g., "26 January 2026 5:01pm")
|
|
44
|
+
*/
|
|
45
|
+
export function formatPaymentDate(isoString) {
|
|
46
|
+
return format(new Date(isoString), 'd MMMM yyyy h:mmaaa')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Formats a payment amount with two decimal places
|
|
51
|
+
* @param {number} amount - amount in pounds
|
|
52
|
+
* @returns {string} Formatted amount (e.g., "£10.00")
|
|
53
|
+
*/
|
|
54
|
+
export function formatPaymentAmount(amount) {
|
|
55
|
+
return `£${amount.toFixed(2)}`
|
|
56
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { config } from '~/src/config/index.js'
|
|
2
|
+
import {
|
|
3
|
+
formatPaymentAmount,
|
|
4
|
+
formatPaymentDate,
|
|
5
|
+
getPaymentApiKey
|
|
6
|
+
} from '~/src/server/plugins/payment/helper.js'
|
|
7
|
+
|
|
8
|
+
describe('getPaymentApiKey', () => {
|
|
9
|
+
config.set('paymentProviderApiKeyTest', 'TEST-API-KEY')
|
|
10
|
+
const formId = 'form-id'
|
|
11
|
+
process.env['PAYMENT_PROVIDER_API_KEY_LIVE_form-id'] = 'LIVE-API-KEY'
|
|
12
|
+
process.env['PAYMENT_PROVIDER_API_KEY_TEST_form-id'] = 'TEST-API-KEY'
|
|
13
|
+
|
|
14
|
+
it('should read test key when non-live form', () => {
|
|
15
|
+
const apiKey = getPaymentApiKey(false, formId)
|
|
16
|
+
expect(apiKey).toBe('TEST-API-KEY')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('should read live key when live form', () => {
|
|
20
|
+
const apiKey = getPaymentApiKey(true, formId)
|
|
21
|
+
expect(apiKey).toBe('LIVE-API-KEY')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should throw if TEST key is missing', () => {
|
|
25
|
+
expect(() => getPaymentApiKey(false, 'form-id-missing')).toThrow(
|
|
26
|
+
'Missing payment api key for test form id form-id-missing'
|
|
27
|
+
)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should throw if LIVE key is missing', () => {
|
|
31
|
+
expect(() => getPaymentApiKey(true, 'form-id-missing')).toThrow(
|
|
32
|
+
'Missing payment api key for live form id form-id-missing'
|
|
33
|
+
)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('formatPaymentDate', () => {
|
|
38
|
+
it('should format ISO date string to en-GB format', () => {
|
|
39
|
+
const result = formatPaymentDate('2025-11-10T17:01:29.000Z')
|
|
40
|
+
expect(result).toBe('10 November 2025 5:01pm')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('formatPaymentAmount', () => {
|
|
45
|
+
it('should format whole number with two decimal places', () => {
|
|
46
|
+
expect(formatPaymentAmount(10)).toBe('£10.00')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('should format decimal amount', () => {
|
|
50
|
+
expect(formatPaymentAmount(99.5)).toBe('£99.50')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { StatusCodes } from 'http-status-codes'
|
|
2
|
+
|
|
3
|
+
import { createLogger } from '~/src/server/common/helpers/logging/logger.js'
|
|
4
|
+
import { get, post, postJson } from '~/src/server/services/httpService.js'
|
|
5
|
+
|
|
6
|
+
const PAYMENT_BASE_URL = 'https://publicapi.payments.service.gov.uk'
|
|
7
|
+
const PAYMENT_ENDPOINT = '/v1/payments'
|
|
8
|
+
|
|
9
|
+
const logger = createLogger()
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} apiKey
|
|
13
|
+
* @returns {{ Authorization: string }}
|
|
14
|
+
*/
|
|
15
|
+
function getAuthHeaders(apiKey) {
|
|
16
|
+
return {
|
|
17
|
+
Authorization: `Bearer ${apiKey}`
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class PaymentService {
|
|
22
|
+
/** @type {string} */
|
|
23
|
+
#apiKey
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} apiKey - API key to use (global config for test value, per-form config for live value)
|
|
27
|
+
*/
|
|
28
|
+
constructor(apiKey) {
|
|
29
|
+
this.#apiKey = apiKey
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Creates a payment with delayed capture (pre-authorisation)
|
|
34
|
+
* @param {number} amount - in pence
|
|
35
|
+
* @param {string} description
|
|
36
|
+
* @param {string} returnUrl
|
|
37
|
+
* @param {string} reference
|
|
38
|
+
* @param {{ formId: string, slug: string }} metadata
|
|
39
|
+
*/
|
|
40
|
+
async createPayment(amount, description, returnUrl, reference, metadata) {
|
|
41
|
+
const response = await this.postToPayProvider({
|
|
42
|
+
amount,
|
|
43
|
+
description,
|
|
44
|
+
reference,
|
|
45
|
+
metadata,
|
|
46
|
+
return_url: returnUrl,
|
|
47
|
+
delayed_capture: true
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
paymentId: response.payment_id,
|
|
52
|
+
paymentUrl: response._links.next_url.href
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} paymentId
|
|
58
|
+
* @returns {Promise<GetPaymentResponse>}
|
|
59
|
+
*/
|
|
60
|
+
async getPaymentStatus(paymentId) {
|
|
61
|
+
const getByType = /** @type {typeof get<GetPaymentApiResponse>} */ (get)
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const response = await getByType(
|
|
65
|
+
`${PAYMENT_BASE_URL}${PAYMENT_ENDPOINT}/${paymentId}`,
|
|
66
|
+
{
|
|
67
|
+
headers: getAuthHeaders(this.#apiKey),
|
|
68
|
+
json: true
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if (response.error) {
|
|
73
|
+
const errorMessage =
|
|
74
|
+
response.error instanceof Error
|
|
75
|
+
? response.error.message
|
|
76
|
+
: JSON.stringify(response.error)
|
|
77
|
+
throw new Error(`Failed to get payment status: ${errorMessage}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
state: response.payload.state,
|
|
82
|
+
_links: response.payload._links,
|
|
83
|
+
email: response.payload.email,
|
|
84
|
+
paymentId: response.payload.payment_id,
|
|
85
|
+
amount: response.payload.amount
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
const error = /** @type {Error} */ (err)
|
|
89
|
+
logger.error(
|
|
90
|
+
error,
|
|
91
|
+
`[payment] Error getting payment status for paymentId=${paymentId}: ${error.message}`
|
|
92
|
+
)
|
|
93
|
+
throw err
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Captures a payment that is in 'capturable' status
|
|
99
|
+
* @param {string} paymentId
|
|
100
|
+
* @returns {Promise<boolean>}
|
|
101
|
+
*/
|
|
102
|
+
async capturePayment(paymentId) {
|
|
103
|
+
try {
|
|
104
|
+
const response = await post(
|
|
105
|
+
`${PAYMENT_BASE_URL}${PAYMENT_ENDPOINT}/${paymentId}/capture`,
|
|
106
|
+
{
|
|
107
|
+
headers: getAuthHeaders(this.#apiKey)
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const statusCode = response.res.statusCode
|
|
112
|
+
|
|
113
|
+
if (
|
|
114
|
+
statusCode === StatusCodes.OK ||
|
|
115
|
+
statusCode === StatusCodes.NO_CONTENT
|
|
116
|
+
) {
|
|
117
|
+
logger.info(`[payment] Successfully captured payment ${paymentId}`)
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
logger.error(
|
|
122
|
+
`[payment] Capture failed for paymentId=${paymentId}: HTTP ${statusCode}`
|
|
123
|
+
)
|
|
124
|
+
return false
|
|
125
|
+
} catch (err) {
|
|
126
|
+
const error = /** @type {Error} */ (err)
|
|
127
|
+
logger.error(
|
|
128
|
+
error,
|
|
129
|
+
`[payment] Error capturing payment for paymentId=${paymentId}: ${error.message}`
|
|
130
|
+
)
|
|
131
|
+
throw err
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {CreatePaymentRequest} payload
|
|
137
|
+
*/
|
|
138
|
+
async postToPayProvider(payload) {
|
|
139
|
+
const postJsonByType =
|
|
140
|
+
/** @type {typeof postJson<CreatePaymentResponse>} */ (postJson)
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const response = await postJsonByType(
|
|
144
|
+
`${PAYMENT_BASE_URL}${PAYMENT_ENDPOINT}`,
|
|
145
|
+
{
|
|
146
|
+
payload,
|
|
147
|
+
headers: getAuthHeaders(this.#apiKey)
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if (response.payload?.state.status !== 'created') {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Failed to create payment for reference=${payload.reference}`
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return response.payload
|
|
158
|
+
} catch (err) {
|
|
159
|
+
const error = /** @type {Error} */ (err)
|
|
160
|
+
logger.error(
|
|
161
|
+
error,
|
|
162
|
+
`[payment] Error creating payment for reference=${payload.reference}: ${error.message}`
|
|
163
|
+
)
|
|
164
|
+
throw err
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @import { CreatePaymentRequest, CreatePaymentResponse, GetPaymentApiResponse, GetPaymentResponse } from '~/src/server/plugins/payment/types.js'
|
|
171
|
+
*/
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { PaymentService } from '~/src/server/plugins/payment/service.js'
|
|
2
|
+
import { get, post, postJson } from '~/src/server/services/httpService.js'
|
|
3
|
+
|
|
4
|
+
jest.mock('~/src/server/services/httpService.ts')
|
|
5
|
+
|
|
6
|
+
describe('payment service', () => {
|
|
7
|
+
const service = new PaymentService('my-api-key')
|
|
8
|
+
describe('constructor', () => {
|
|
9
|
+
it('should create instance', () => {
|
|
10
|
+
expect(service).toBeDefined()
|
|
11
|
+
})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('createPayment', () => {
|
|
15
|
+
it('should create a payment', async () => {
|
|
16
|
+
const createPaymentResult = {
|
|
17
|
+
payment_id: 'payment-id-12345',
|
|
18
|
+
_links: {
|
|
19
|
+
next_url: {
|
|
20
|
+
href: 'http://next-url-href/payment'
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
state: {
|
|
24
|
+
status: 'created'
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
jest.mocked(postJson).mockResolvedValueOnce({
|
|
28
|
+
res: /** @type {IncomingMessage} */ ({
|
|
29
|
+
statusCode: 200,
|
|
30
|
+
headers: {}
|
|
31
|
+
}),
|
|
32
|
+
payload: createPaymentResult,
|
|
33
|
+
error: undefined
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const referenceNumber = 'ABC-DEF-123'
|
|
37
|
+
const returnUrl = 'http://localhost:3009/payment-callback-handler'
|
|
38
|
+
const metadata = { formId: 'form-id', slug: 'my-form-slug' }
|
|
39
|
+
const payment = await service.createPayment(
|
|
40
|
+
100,
|
|
41
|
+
'Payment description',
|
|
42
|
+
returnUrl,
|
|
43
|
+
referenceNumber,
|
|
44
|
+
metadata
|
|
45
|
+
)
|
|
46
|
+
expect(payment.paymentId).toBe('payment-id-12345')
|
|
47
|
+
expect(payment.paymentUrl).toBe('http://next-url-href/payment')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('should throw if fails to create a payment - failed API call', async () => {
|
|
51
|
+
jest
|
|
52
|
+
.mocked(postJson)
|
|
53
|
+
.mockRejectedValueOnce(new Error('internal creation error'))
|
|
54
|
+
|
|
55
|
+
const referenceNumber = 'ABC-DEF-123'
|
|
56
|
+
const returnUrl = 'http://localhost:3009/payment-callback-handler'
|
|
57
|
+
const metadata = { formId: 'form-id', slug: 'my-form-slug' }
|
|
58
|
+
await expect(() =>
|
|
59
|
+
service.createPayment(
|
|
60
|
+
100,
|
|
61
|
+
'Payment description',
|
|
62
|
+
returnUrl,
|
|
63
|
+
referenceNumber,
|
|
64
|
+
metadata
|
|
65
|
+
)
|
|
66
|
+
).rejects.toThrow('internal creation error')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should throw if fails to create a payment - bad result from API call', async () => {
|
|
70
|
+
const createPaymentResult = {
|
|
71
|
+
state: {
|
|
72
|
+
status: 'failed'
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
jest.mocked(postJson).mockResolvedValueOnce({
|
|
76
|
+
res: /** @type {IncomingMessage} */ ({
|
|
77
|
+
statusCode: 200,
|
|
78
|
+
headers: {}
|
|
79
|
+
}),
|
|
80
|
+
payload: createPaymentResult,
|
|
81
|
+
error: undefined
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const referenceNumber = 'ABC-DEF-123'
|
|
85
|
+
const returnUrl = 'http://localhost:3009/payment-callback-handler'
|
|
86
|
+
const metadata = { formId: 'form-id', slug: 'my-form-slug' }
|
|
87
|
+
await expect(() =>
|
|
88
|
+
service.createPayment(
|
|
89
|
+
100,
|
|
90
|
+
'Payment description',
|
|
91
|
+
returnUrl,
|
|
92
|
+
referenceNumber,
|
|
93
|
+
metadata
|
|
94
|
+
)
|
|
95
|
+
).rejects.toThrow('Failed to create payment')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('getPaymentStatus', () => {
|
|
100
|
+
it('should get payment status if exists', async () => {
|
|
101
|
+
const getPaymentStatusResult = {
|
|
102
|
+
payment_id: 'payment-id-12345',
|
|
103
|
+
_links: {
|
|
104
|
+
next_url: {
|
|
105
|
+
href: 'http://next-url-href/payment'
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
state: {
|
|
109
|
+
status: 'created'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
jest.mocked(get).mockResolvedValueOnce({
|
|
114
|
+
res: /** @type {IncomingMessage} */ ({
|
|
115
|
+
statusCode: 200,
|
|
116
|
+
headers: {}
|
|
117
|
+
}),
|
|
118
|
+
payload: getPaymentStatusResult,
|
|
119
|
+
error: undefined
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const paymentStatus = await service.getPaymentStatus('payment-id-12345')
|
|
123
|
+
expect(paymentStatus.paymentId).toBe('payment-id-12345')
|
|
124
|
+
expect(paymentStatus._links.next_url?.href).toBe(
|
|
125
|
+
'http://next-url-href/payment'
|
|
126
|
+
)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('should handle payment status error', async () => {
|
|
130
|
+
jest.mocked(get).mockResolvedValueOnce({
|
|
131
|
+
res: /** @type {IncomingMessage} */ ({
|
|
132
|
+
statusCode: 200,
|
|
133
|
+
headers: {}
|
|
134
|
+
}),
|
|
135
|
+
payload: undefined,
|
|
136
|
+
error: new Error('some-error')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
await expect(() =>
|
|
140
|
+
service.getPaymentStatus('payment-id-12345')
|
|
141
|
+
).rejects.toThrow('Failed to get payment status: some-error')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('capturePayment', () => {
|
|
146
|
+
it('should return true when successful capture with statusCode 200', async () => {
|
|
147
|
+
const capturePaymentResult = {}
|
|
148
|
+
jest.mocked(post).mockResolvedValueOnce({
|
|
149
|
+
res: /** @type {IncomingMessage} */ ({
|
|
150
|
+
statusCode: 200,
|
|
151
|
+
headers: {}
|
|
152
|
+
}),
|
|
153
|
+
payload: capturePaymentResult,
|
|
154
|
+
error: undefined
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const captureResult = await service.capturePayment('payment-id-12345')
|
|
158
|
+
expect(captureResult).toBe(true)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('should return true when successful capture with statusCode 204', async () => {
|
|
162
|
+
const capturePaymentResult = {}
|
|
163
|
+
jest.mocked(post).mockResolvedValueOnce({
|
|
164
|
+
res: /** @type {IncomingMessage} */ ({
|
|
165
|
+
statusCode: 204,
|
|
166
|
+
headers: {}
|
|
167
|
+
}),
|
|
168
|
+
payload: capturePaymentResult,
|
|
169
|
+
error: undefined
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const captureResult = await service.capturePayment('payment-id-12345')
|
|
173
|
+
expect(captureResult).toBe(true)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('should return false when status code not 200 or 204', async () => {
|
|
177
|
+
const capturePaymentResult = {}
|
|
178
|
+
jest.mocked(post).mockResolvedValueOnce({
|
|
179
|
+
res: /** @type {IncomingMessage} */ ({
|
|
180
|
+
statusCode: 500,
|
|
181
|
+
headers: {}
|
|
182
|
+
}),
|
|
183
|
+
payload: capturePaymentResult,
|
|
184
|
+
error: undefined
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const captureResult = await service.capturePayment('payment-id-12345')
|
|
188
|
+
expect(captureResult).toBe(false)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('should throw when internal error', async () => {
|
|
192
|
+
jest
|
|
193
|
+
.mocked(post)
|
|
194
|
+
.mockRejectedValueOnce(new Error('internal capture error'))
|
|
195
|
+
|
|
196
|
+
await expect(() =>
|
|
197
|
+
service.capturePayment('payment-id-12345')
|
|
198
|
+
).rejects.toThrow('internal capture error')
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @import { IncomingMessage } from 'node:http'
|
|
205
|
+
*/
|