@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.
Files changed (169) hide show
  1. package/.public/javascripts/shared.min.js +1 -1
  2. package/.public/javascripts/shared.min.js.map +1 -1
  3. package/.public/stylesheets/application.min.css +1 -1
  4. package/.public/stylesheets/application.min.css.map +1 -1
  5. package/.server/client/javascripts/location-map.js +8 -4
  6. package/.server/client/javascripts/location-map.js.map +1 -1
  7. package/.server/client/stylesheets/_payment-field.scss +8 -0
  8. package/.server/client/stylesheets/application.scss +2 -0
  9. package/.server/index.js +3 -1
  10. package/.server/index.js.map +1 -1
  11. package/.server/server/constants.d.ts +1 -0
  12. package/.server/server/constants.js +1 -0
  13. package/.server/server/constants.js.map +1 -1
  14. package/.server/server/forms/payment-test.yaml +42 -0
  15. package/.server/server/forms/register-as-a-unicorn-breeder.yaml +14 -0
  16. package/.server/server/plugins/engine/components/FormComponent.d.ts +1 -0
  17. package/.server/server/plugins/engine/components/FormComponent.js +1 -0
  18. package/.server/server/plugins/engine/components/FormComponent.js.map +1 -1
  19. package/.server/server/plugins/engine/components/PaymentField.d.ts +135 -0
  20. package/.server/server/plugins/engine/components/PaymentField.js +228 -0
  21. package/.server/server/plugins/engine/components/PaymentField.js.map +1 -0
  22. package/.server/server/plugins/engine/components/PaymentField.types.d.ts +21 -0
  23. package/.server/server/plugins/engine/components/PaymentField.types.js +2 -0
  24. package/.server/server/plugins/engine/components/PaymentField.types.js.map +1 -0
  25. package/.server/server/plugins/engine/components/UkAddressField.d.ts +1 -1
  26. package/.server/server/plugins/engine/components/UkAddressField.js +3 -1
  27. package/.server/server/plugins/engine/components/UkAddressField.js.map +1 -1
  28. package/.server/server/plugins/engine/components/helpers/components.d.ts +1 -1
  29. package/.server/server/plugins/engine/components/helpers/components.js +3 -0
  30. package/.server/server/plugins/engine/components/helpers/components.js.map +1 -1
  31. package/.server/server/plugins/engine/components/index.d.ts +1 -0
  32. package/.server/server/plugins/engine/components/index.js +1 -0
  33. package/.server/server/plugins/engine/components/index.js.map +1 -1
  34. package/.server/server/plugins/engine/configureEnginePlugin.d.ts +1 -1
  35. package/.server/server/plugins/engine/configureEnginePlugin.js +4 -2
  36. package/.server/server/plugins/engine/configureEnginePlugin.js.map +1 -1
  37. package/.server/server/plugins/engine/helpers.d.ts +1 -0
  38. package/.server/server/plugins/engine/models/SummaryViewModel.d.ts +3 -0
  39. package/.server/server/plugins/engine/models/SummaryViewModel.js +7 -0
  40. package/.server/server/plugins/engine/models/SummaryViewModel.js.map +1 -1
  41. package/.server/server/plugins/engine/options.js +2 -1
  42. package/.server/server/plugins/engine/options.js.map +1 -1
  43. package/.server/server/plugins/engine/outputFormatters/human/v1.js +34 -1
  44. package/.server/server/plugins/engine/outputFormatters/human/v1.js.map +1 -1
  45. package/.server/server/plugins/engine/outputFormatters/machine/v2.d.ts +22 -0
  46. package/.server/server/plugins/engine/outputFormatters/machine/v2.js +43 -1
  47. package/.server/server/plugins/engine/outputFormatters/machine/v2.js.map +1 -1
  48. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +29 -8
  49. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  50. package/.server/server/plugins/engine/pageControllers/StartPageController.d.ts +2 -0
  51. package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +17 -0
  52. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +173 -51
  53. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
  54. package/.server/server/plugins/engine/pageControllers/errors.d.ts +31 -0
  55. package/.server/server/plugins/engine/pageControllers/errors.js +59 -2
  56. package/.server/server/plugins/engine/pageControllers/errors.js.map +1 -1
  57. package/.server/server/plugins/engine/pageControllers/helpers/submission.d.ts +27 -0
  58. package/.server/server/plugins/engine/pageControllers/helpers/submission.js +77 -0
  59. package/.server/server/plugins/engine/pageControllers/helpers/submission.js.map +1 -0
  60. package/.server/server/plugins/engine/plugin.js +10 -5
  61. package/.server/server/plugins/engine/plugin.js.map +1 -1
  62. package/.server/server/plugins/engine/routes/index.js +8 -4
  63. package/.server/server/plugins/engine/routes/index.js.map +1 -1
  64. package/.server/server/plugins/engine/routes/payment-helper.d.ts +14 -0
  65. package/.server/server/plugins/engine/routes/payment-helper.js +41 -0
  66. package/.server/server/plugins/engine/routes/payment-helper.js.map +1 -0
  67. package/.server/server/plugins/engine/routes/payment-helper.test.js +81 -0
  68. package/.server/server/plugins/engine/routes/payment-helper.test.js.map +1 -0
  69. package/.server/server/plugins/engine/routes/payment.d.ts +8 -0
  70. package/.server/server/plugins/engine/routes/payment.js +140 -0
  71. package/.server/server/plugins/engine/routes/payment.js.map +1 -0
  72. package/.server/server/plugins/engine/routes/payment.test.js +187 -0
  73. package/.server/server/plugins/engine/routes/payment.test.js.map +1 -0
  74. package/.server/server/plugins/engine/services/localFormsService.js +6 -0
  75. package/.server/server/plugins/engine/services/localFormsService.js.map +1 -1
  76. package/.server/server/plugins/engine/types/schema.js +7 -0
  77. package/.server/server/plugins/engine/types/schema.js.map +1 -1
  78. package/.server/server/plugins/engine/types.d.ts +20 -1
  79. package/.server/server/plugins/engine/types.js +4 -0
  80. package/.server/server/plugins/engine/types.js.map +1 -1
  81. package/.server/server/plugins/engine/validationHelpers.d.ts +1 -1
  82. package/.server/server/plugins/engine/validationHelpers.js.map +1 -1
  83. package/.server/server/plugins/engine/views/components/paymentfield.html +42 -0
  84. package/.server/server/plugins/engine/views/index.html +9 -1
  85. package/.server/server/plugins/engine/views/partials/form.html +20 -5
  86. package/.server/server/plugins/engine/views/summary.html +17 -1
  87. package/.server/server/plugins/map/routes/get-os-token.d.ts +6 -0
  88. package/.server/server/plugins/map/routes/get-os-token.js +41 -0
  89. package/.server/server/plugins/map/routes/get-os-token.js.map +1 -0
  90. package/.server/server/plugins/map/routes/get-os-token.test.js +49 -0
  91. package/.server/server/plugins/map/routes/get-os-token.test.js.map +1 -0
  92. package/.server/server/plugins/map/routes/index.d.ts +1 -11
  93. package/.server/server/plugins/map/routes/index.js +60 -16
  94. package/.server/server/plugins/map/routes/index.js.map +1 -1
  95. package/.server/server/plugins/map/types.d.ts +1 -0
  96. package/.server/server/plugins/map/types.js +1 -0
  97. package/.server/server/plugins/map/types.js.map +1 -1
  98. package/.server/server/plugins/nunjucks/filters/field.d.ts +1 -1
  99. package/.server/server/plugins/payment/helper.d.ts +30 -0
  100. package/.server/server/plugins/payment/helper.js +49 -0
  101. package/.server/server/plugins/payment/helper.js.map +1 -0
  102. package/.server/server/plugins/payment/helper.test.js +37 -0
  103. package/.server/server/plugins/payment/helper.test.js.map +1 -0
  104. package/.server/server/plugins/payment/service.d.ts +40 -0
  105. package/.server/server/plugins/payment/service.js +129 -0
  106. package/.server/server/plugins/payment/service.js.map +1 -0
  107. package/.server/server/plugins/payment/service.test.js +162 -0
  108. package/.server/server/plugins/payment/service.test.js.map +1 -0
  109. package/.server/server/plugins/payment/types.d.ts +172 -0
  110. package/.server/server/plugins/payment/types.js +78 -0
  111. package/.server/server/plugins/payment/types.js.map +1 -0
  112. package/.server/server/types.d.ts +3 -0
  113. package/.server/server/types.js.map +1 -1
  114. package/.server/typings/hapi/index.d.js.map +1 -1
  115. package/README.md +12 -9
  116. package/package.json +2 -2
  117. package/src/client/javascripts/location-map.js +12 -4
  118. package/src/client/stylesheets/_payment-field.scss +8 -0
  119. package/src/client/stylesheets/application.scss +2 -0
  120. package/src/index.ts +5 -1
  121. package/src/server/constants.js +1 -0
  122. package/src/server/forms/payment-test.yaml +42 -0
  123. package/src/server/forms/register-as-a-unicorn-breeder.yaml +14 -0
  124. package/src/server/plugins/engine/components/FormComponent.ts +1 -0
  125. package/src/server/plugins/engine/components/PaymentField.test.ts +611 -0
  126. package/src/server/plugins/engine/components/PaymentField.ts +367 -0
  127. package/src/server/plugins/engine/components/PaymentField.types.ts +21 -0
  128. package/src/server/plugins/engine/components/UkAddressField.ts +2 -1
  129. package/src/server/plugins/engine/components/helpers/components.ts +5 -0
  130. package/src/server/plugins/engine/components/index.ts +1 -0
  131. package/src/server/plugins/engine/configureEnginePlugin.ts +4 -2
  132. package/src/server/plugins/engine/models/SummaryViewModel.ts +8 -0
  133. package/src/server/plugins/engine/options.js +2 -1
  134. package/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts +147 -0
  135. package/src/server/plugins/engine/outputFormatters/human/v1.test.ts +105 -103
  136. package/src/server/plugins/engine/outputFormatters/human/v1.ts +61 -2
  137. package/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts +115 -0
  138. package/src/server/plugins/engine/outputFormatters/machine/v2.ts +60 -1
  139. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +32 -6
  140. package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +247 -72
  141. package/src/server/plugins/engine/pageControllers/errors.test.ts +13 -1
  142. package/src/server/plugins/engine/pageControllers/errors.ts +79 -4
  143. package/src/server/plugins/engine/pageControllers/helpers/submission.test.ts +299 -0
  144. package/src/server/plugins/engine/pageControllers/helpers/submission.ts +110 -0
  145. package/src/server/plugins/engine/plugin.ts +17 -10
  146. package/src/server/plugins/engine/routes/index.ts +17 -16
  147. package/src/server/plugins/engine/routes/payment-helper.js +39 -0
  148. package/src/server/plugins/engine/routes/payment-helper.test.js +90 -0
  149. package/src/server/plugins/engine/routes/payment.js +151 -0
  150. package/src/server/plugins/engine/routes/payment.test.js +180 -0
  151. package/src/server/plugins/engine/services/localFormsService.js +7 -0
  152. package/src/server/plugins/engine/types/schema.ts +9 -0
  153. package/src/server/plugins/engine/types.ts +25 -1
  154. package/src/server/plugins/engine/validationHelpers.ts +1 -1
  155. package/src/server/plugins/engine/views/components/paymentfield.html +42 -0
  156. package/src/server/plugins/engine/views/index.html +9 -1
  157. package/src/server/plugins/engine/views/partials/form.html +20 -5
  158. package/src/server/plugins/engine/views/summary.html +17 -1
  159. package/src/server/plugins/map/routes/get-os-token.js +41 -0
  160. package/src/server/plugins/map/routes/get-os-token.test.js +55 -0
  161. package/src/server/plugins/map/routes/index.js +70 -24
  162. package/src/server/plugins/map/types.js +1 -0
  163. package/src/server/plugins/payment/helper.js +56 -0
  164. package/src/server/plugins/payment/helper.test.js +52 -0
  165. package/src/server/plugins/payment/service.js +171 -0
  166. package/src/server/plugins/payment/service.test.js +205 -0
  167. package/src/server/plugins/payment/types.js +77 -0
  168. package/src/server/types.ts +3 -0
  169. 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 { request as httpRequest } from '~/src/server/services/httpService.js'
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 tiles, sprites and fonts by forwarding on the request
24
- * and adding the apikey and optionally an SRS (spatial reference system)
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
- // Add API key server-side and set SRS
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 geocode requests from the front end to api.os.com
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.com
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
- function tileRoutes() {
128
- return [
129
- {
130
- method: 'GET',
131
- path: '/api/maps/vts/{path*}',
132
- options: {
133
- handler: {
134
- directory: {
135
- path: resolve(import.meta.dirname, './vts')
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
  /**
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * @typedef {{
3
3
  * ordnanceSurveyApiKey: string
4
+ * ordnanceSurveyApiSecret: string
4
5
  * }} MapConfiguration
5
6
  */
6
7
 
@@ -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
+ */