@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
@@ -7,9 +7,12 @@ import {
7
7
  import Boom from '@hapi/boom'
8
8
  import { type RouteOptions } from '@hapi/hapi'
9
9
 
10
- import { COMPONENT_STATE_ERROR } from '~/src/server/constants.js'
10
+ import {
11
+ COMPONENT_STATE_ERROR,
12
+ PAYMENT_EXPIRED_NOTIFICATION
13
+ } from '~/src/server/constants.js'
11
14
  import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
12
- import { getAnswer } from '~/src/server/plugins/engine/components/helpers/components.js'
15
+ import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
13
16
  import {
14
17
  checkEmailAddressForLiveFormSubmission,
15
18
  checkFormStatus,
@@ -22,15 +25,30 @@ import {
22
25
  } from '~/src/server/plugins/engine/models/index.js'
23
26
  import {
24
27
  type Detail,
25
- type DetailItem
28
+ type DetailItem,
29
+ type DetailItemField
26
30
  } from '~/src/server/plugins/engine/models/types.js'
27
31
  import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js'
28
- import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js'
32
+ import {
33
+ InvalidComponentStateError,
34
+ PaymentErrorTypes,
35
+ PaymentPreAuthError,
36
+ PaymentSubmissionError
37
+ } from '~/src/server/plugins/engine/pageControllers/errors.js'
38
+ import {
39
+ buildMainRecords,
40
+ buildRepeaterRecords
41
+ } from '~/src/server/plugins/engine/pageControllers/helpers/submission.js'
29
42
  import {
30
43
  type FormConfirmationState,
31
44
  type FormContext,
32
45
  type FormContextRequest
33
46
  } from '~/src/server/plugins/engine/types.js'
47
+ import {
48
+ DEFAULT_PAYMENT_HELP_URL,
49
+ formatPaymentAmount,
50
+ formatPaymentDate
51
+ } from '~/src/server/plugins/payment/helper.js'
34
52
  import {
35
53
  FormAction,
36
54
  type FormRequest,
@@ -65,11 +83,25 @@ export class SummaryPageController extends QuestionPageController {
65
83
  const viewModel = new SummaryViewModel(request, this, context)
66
84
 
67
85
  const { query } = request
68
- const { payload, errors } = context
86
+ const { payload, errors, state } = context
87
+
88
+ const paymentField = context.relevantPages
89
+ .flatMap((page) => page.collection.fields)
90
+ .find((field): field is PaymentField => field instanceof PaymentField)
91
+
92
+ if (paymentField) {
93
+ const paymentState = paymentField.getPaymentStateFromState(state)
94
+ if (paymentState) {
95
+ viewModel.paymentState = paymentState
96
+ viewModel.paymentDetails = this.buildPaymentDetails(
97
+ paymentField,
98
+ paymentState
99
+ )
100
+ }
101
+ }
102
+
69
103
  const components = this.collection.getViewModel(payload, errors, query)
70
104
 
71
- // We already figure these out in the base page controller. Take them and apply them to our page-specific model.
72
- // This is a stop-gap until we can add proper inheritance in place.
73
105
  viewModel.backLink = this.getBackLink(request, context)
74
106
  viewModel.feedbackLink = this.feedbackLink
75
107
  viewModel.phaseTag = this.phaseTag
@@ -80,6 +112,40 @@ export class SummaryPageController extends QuestionPageController {
80
112
  return viewModel
81
113
  }
82
114
 
115
+ private buildPaymentDetails(
116
+ paymentField: PaymentField,
117
+ paymentState: NonNullable<
118
+ ReturnType<PaymentField['getPaymentStateFromState']>
119
+ >
120
+ ) {
121
+ const rows = [
122
+ {
123
+ key: { text: 'Payment for' },
124
+ value: { text: paymentState.description }
125
+ },
126
+ {
127
+ key: { text: 'Total amount' },
128
+ value: { text: formatPaymentAmount(paymentState.amount) }
129
+ },
130
+ {
131
+ key: { text: 'Reference' },
132
+ value: { text: paymentState.reference }
133
+ }
134
+ ]
135
+
136
+ if (paymentState.preAuth?.createdAt) {
137
+ rows.push({
138
+ key: { text: 'Date of payment' },
139
+ value: { text: formatPaymentDate(paymentState.preAuth.createdAt) }
140
+ })
141
+ }
142
+
143
+ return {
144
+ title: { text: 'Payment details' },
145
+ summaryList: { rows }
146
+ }
147
+ }
148
+
83
149
  /**
84
150
  * Returns an async function. This is called in plugin.ts when there is a GET request at `/{id}/{path*}`,
85
151
  */
@@ -110,7 +176,6 @@ export class SummaryPageController extends QuestionPageController {
110
176
  context: FormContext,
111
177
  h: FormResponseToolkit
112
178
  ) => {
113
- // Check if this is a save-and-exit action
114
179
  const { action } = request.payload
115
180
  if (action === FormAction.SaveAndExit) {
116
181
  return this.handleSaveAndExit(request, context, h)
@@ -133,14 +198,12 @@ export class SummaryPageController extends QuestionPageController {
133
198
  const { formsService } = this.model.services
134
199
  const { getFormMetadata } = formsService
135
200
 
136
- // Get the form metadata using the `slug` param
137
201
  const formMetadata = await getFormMetadata(params.slug)
138
202
  const { notificationEmail } = formMetadata
139
203
  const { isPreview } = checkFormStatus(request.params)
140
204
 
141
205
  checkEmailAddressForLiveFormSubmission(notificationEmail, isPreview)
142
206
 
143
- // Send submission email
144
207
  if (notificationEmail) {
145
208
  const viewModel = this.getSummaryViewModel(request, context)
146
209
 
@@ -155,20 +218,7 @@ export class SummaryPageController extends QuestionPageController {
155
218
  formMetadata
156
219
  )
157
220
  } catch (error) {
158
- if (error instanceof InvalidComponentStateError) {
159
- const govukError = createError(
160
- error.component.name,
161
- error.userMessage
162
- )
163
-
164
- request.yar.flash(COMPONENT_STATE_ERROR, govukError, true)
165
-
166
- await cacheService.resetComponentStates(request, error.getStateKeys())
167
-
168
- return this.proceed(request, h, error.component.page?.path)
169
- }
170
-
171
- throw error
221
+ return this.handleSubmissionError(error, request, h)
172
222
  }
173
223
  }
174
224
 
@@ -178,12 +228,103 @@ export class SummaryPageController extends QuestionPageController {
178
228
  referenceNumber: context.referenceNumber
179
229
  } as FormConfirmationState)
180
230
 
181
- // Clear all form data
182
231
  await cacheService.clearState(request)
183
232
 
184
233
  return this.proceed(request, h, this.getStatusPath())
185
234
  }
186
235
 
236
+ /**
237
+ * Handles errors during form submission
238
+ */
239
+ private async handleSubmissionError(
240
+ error: unknown,
241
+ request: FormRequestPayload,
242
+ h: FormResponseToolkit
243
+ ) {
244
+ if (error instanceof InvalidComponentStateError) {
245
+ return this.handleInvalidComponentStateError(error, request, h)
246
+ }
247
+
248
+ if (error instanceof PaymentPreAuthError) {
249
+ return this.handlePaymentPreAuthError(error, request, h)
250
+ }
251
+
252
+ if (error instanceof PaymentSubmissionError) {
253
+ return this.handlePaymentSubmissionError(error, request, h)
254
+ }
255
+
256
+ throw error
257
+ }
258
+
259
+ /**
260
+ * Handles InvalidComponentStateError during submission
261
+ */
262
+ private async handleInvalidComponentStateError(
263
+ error: InvalidComponentStateError,
264
+ request: FormRequestPayload,
265
+ h: FormResponseToolkit
266
+ ) {
267
+ const cacheService = getCacheService(request.server)
268
+
269
+ const govukError = createError(error.component.name, error.userMessage)
270
+
271
+ request.yar.flash(COMPONENT_STATE_ERROR, govukError, true)
272
+
273
+ await cacheService.resetComponentStates(request, error.getStateKeys())
274
+
275
+ return this.proceed(request, h, error.component.page?.path)
276
+ }
277
+
278
+ /**
279
+ * Handles PaymentPreAuthError during submission
280
+ */
281
+ private async handlePaymentPreAuthError(
282
+ error: PaymentPreAuthError,
283
+ request: FormRequestPayload,
284
+ h: FormResponseToolkit
285
+ ) {
286
+ const cacheService = getCacheService(request.server)
287
+
288
+ if (error.shouldResetState) {
289
+ await cacheService.resetComponentStates(request, error.getStateKeys())
290
+
291
+ if (error.errorType === PaymentErrorTypes.PaymentExpired) {
292
+ request.yar.flash(PAYMENT_EXPIRED_NOTIFICATION, true, true)
293
+ return this.proceed(request, h, error.component.page?.path)
294
+ }
295
+ }
296
+
297
+ const govukError = createError(error.component.name, error.userMessage)
298
+ request.yar.flash(COMPONENT_STATE_ERROR, govukError, true)
299
+
300
+ const redirectPath = error.shouldResetState
301
+ ? error.component.page?.path
302
+ : undefined
303
+
304
+ return this.proceed(request, h, redirectPath)
305
+ }
306
+
307
+ /**
308
+ * Handles PaymentSubmissionError during submission
309
+ */
310
+ private handlePaymentSubmissionError(
311
+ error: PaymentSubmissionError,
312
+ request: FormRequestPayload,
313
+ h: FormResponseToolkit
314
+ ) {
315
+ const helpUrl = error.helpLink ?? DEFAULT_PAYMENT_HELP_URL
316
+ const helpLinkHtml = ` or you can <a href="${helpUrl}" target="_blank" rel="noopener noreferrer" class="govuk-link">contact us (opens in new tab)</a> and quote your reference number to arrange a refund`
317
+
318
+ const govukError = createError(
319
+ 'submission',
320
+ `There was a problem and your form was not submitted. Try submitting the form again${helpLinkHtml}.`
321
+ )
322
+
323
+ request.yar.flash(COMPONENT_STATE_ERROR, govukError, true)
324
+
325
+ return this.proceed(request, h)
326
+ }
327
+
187
328
  get postRouteOptions(): RouteOptions<FormRequestPayloadRefs> {
188
329
  return {
189
330
  ext: {
@@ -208,39 +349,66 @@ export async function submitForm(
208
349
  ) {
209
350
  await finaliseComponents(request, metadata, context)
210
351
 
352
+ const paymentWasCaptured = hasPaymentBeenCaptured(context)
353
+
211
354
  const formStatus = checkFormStatus(request.params)
212
355
  const logTags = ['submit', 'submissionApi']
213
356
 
214
357
  request.logger.info(logTags, 'Preparing email', formStatus)
215
358
 
216
- // Get detail items
217
359
  const items = getFormSubmissionData(
218
360
  summaryViewModel.context,
219
361
  summaryViewModel.details
220
362
  )
221
363
 
222
- // Submit data
223
- request.logger.info(logTags, 'Submitting data')
224
- const submitResponse = await submitData(
225
- model,
226
- items,
227
- emailAddress,
228
- request.yar.id
229
- )
364
+ try {
365
+ request.logger.info(logTags, 'Submitting data')
366
+ const submitResponse = await submitData(
367
+ model,
368
+ items,
369
+ emailAddress,
370
+ request.yar.id
371
+ )
230
372
 
231
- if (submitResponse === undefined) {
232
- throw Boom.badRequest('Unexpected empty response from submit api')
373
+ if (submitResponse === undefined) {
374
+ throw Boom.badRequest('Unexpected empty response from submit api')
375
+ }
376
+
377
+ await model.services.outputService.submit(
378
+ context,
379
+ request,
380
+ model,
381
+ emailAddress,
382
+ items,
383
+ submitResponse,
384
+ formMetadata
385
+ )
386
+ } catch (err) {
387
+ if (paymentWasCaptured) {
388
+ throw new PaymentSubmissionError(
389
+ context.referenceNumber,
390
+ formMetadata.contact?.online?.url
391
+ )
392
+ }
393
+ throw err
233
394
  }
395
+ }
234
396
 
235
- return model.services.outputService.submit(
236
- context,
237
- request,
238
- model,
239
- emailAddress,
240
- items,
241
- submitResponse,
242
- formMetadata
243
- )
397
+ /**
398
+ * Checks if any payment component has been captured
399
+ */
400
+ function hasPaymentBeenCaptured(context: FormContext): boolean {
401
+ for (const page of context.relevantPages) {
402
+ for (const field of page.collection.fields) {
403
+ if (field instanceof PaymentField) {
404
+ const paymentState = field.getPaymentStateFromState(context.state)
405
+ if (paymentState?.capture?.status === 'success') {
406
+ return true
407
+ }
408
+ }
409
+ }
410
+ }
411
+ return false
244
412
  }
245
413
 
246
414
  /**
@@ -280,43 +448,50 @@ function submitData(
280
448
  const payload: SubmitPayload = {
281
449
  sessionId,
282
450
  retrievalKey,
283
-
284
- // Main form answers
285
- main: items
286
- .filter((item) => 'field' in item)
287
- .map((item) => ({
288
- name: item.name,
289
- title: item.label,
290
- value: getAnswer(item.field, item.state, { format: 'data' })
291
- })),
292
-
293
- // Repeater form answers
294
- repeaters: items
295
- .filter((item) => 'subItems' in item)
296
- .map((item) => ({
297
- name: item.name,
298
- title: item.label,
299
-
300
- // Repeater item values
301
- value: item.subItems.map((detailItems) =>
302
- detailItems.map((subItem) => ({
303
- name: subItem.name,
304
- title: subItem.label,
305
- value: getAnswer(subItem.field, subItem.state, { format: 'data' })
306
- }))
307
- )
308
- }))
451
+ main: buildMainRecords(items),
452
+ repeaters: buildRepeaterRecords(items)
309
453
  }
310
454
 
311
455
  return submit(payload)
312
456
  }
313
457
 
314
458
  export function getFormSubmissionData(context: FormContext, details: Detail[]) {
315
- return context.relevantPages
459
+ const items = context.relevantPages
316
460
  .map(({ href }) =>
317
461
  details.flatMap(({ items }) =>
318
462
  items.filter(({ page }) => page.href === href)
319
463
  )
320
464
  )
321
465
  .flat()
466
+
467
+ const paymentItems = getPaymentFieldItems(context)
468
+
469
+ return [...items, ...paymentItems]
470
+ }
471
+
472
+ /**
473
+ * Gets DetailItems for PaymentField components
474
+ * PaymentField is excluded from summaryDetails for UI but needs to be in submission data
475
+ */
476
+ function getPaymentFieldItems(context: FormContext): DetailItemField[] {
477
+ const items: DetailItemField[] = []
478
+
479
+ for (const page of context.relevantPages) {
480
+ for (const field of page.collection.fields) {
481
+ if (field instanceof PaymentField) {
482
+ items.push({
483
+ name: field.name,
484
+ page,
485
+ title: field.title,
486
+ label: field.label,
487
+ field,
488
+ state: context.state,
489
+ href: page.href,
490
+ value: field.getDisplayStringFromState(context.state)
491
+ })
492
+ }
493
+ }
494
+ }
495
+
496
+ return items
322
497
  }
@@ -3,7 +3,10 @@ import { ComponentType } from '@defra/forms-model'
3
3
  import { FileUploadField } from '~/src/server/plugins/engine/components/FileUploadField.js'
4
4
  import { TextField } from '~/src/server/plugins/engine/components/TextField.js'
5
5
  import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
6
- import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js'
6
+ import {
7
+ InvalidComponentStateError,
8
+ PaymentSubmissionError
9
+ } from '~/src/server/plugins/engine/pageControllers/errors.js'
7
10
  import definition from '~/test/form/definitions/file-upload-basic.js'
8
11
 
9
12
  describe('InvalidComponentStateError', () => {
@@ -63,4 +66,13 @@ describe('InvalidComponentStateError', () => {
63
66
  expect(stateKeys).toEqual(['textField'])
64
67
  })
65
68
  })
69
+
70
+ describe('PaymentSubmissionError', () => {
71
+ it('should instantiate', () => {
72
+ const error = new PaymentSubmissionError('reference-number', '/help-link')
73
+ expect(error).toBeDefined()
74
+ expect(error.referenceNumber).toBe('reference-number')
75
+ expect(error.helpLink).toBe('/help-link')
76
+ })
77
+ })
66
78
  })
@@ -1,5 +1,83 @@
1
1
  import { type FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'
2
2
 
3
+ export enum PaymentErrorTypes {
4
+ PaymentExpired = 'PaymentExpired',
5
+ PaymentIncomplete = 'PaymentIncomplete',
6
+ PaymentAmountMismatch = 'PaymentAmountMismatch'
7
+ }
8
+
9
+ function getStateKeys(component: FormComponent) {
10
+ const extraStateKeys = component.page?.getStateKeys(component) ?? []
11
+
12
+ return [component.name].concat(extraStateKeys)
13
+ }
14
+
15
+ export class PaymentPreAuthError extends Error {
16
+ public readonly component: FormComponent
17
+ public readonly userMessage: string
18
+
19
+ /**
20
+ * Whether to reset the component state and redirect to the component's page.
21
+ * - `true`: Reset state and redirect (e.g., payment expired - user must re-enter)
22
+ * - `false`: Keep state and stay on current page with error (e.g., capture failed - user can retry)
23
+ */
24
+ public readonly shouldResetState: boolean
25
+
26
+ /**
27
+ * When supplied, an "Important" notification banner will be shown based on the value.
28
+ */
29
+ public readonly errorType: PaymentErrorTypes | undefined
30
+
31
+ constructor(
32
+ component: FormComponent,
33
+ userMessage: string,
34
+ shouldResetState: boolean,
35
+ errorType?: PaymentErrorTypes
36
+ ) {
37
+ super('Payment capture failed')
38
+ this.name = 'PaymentPreAuthError'
39
+ this.component = component
40
+ this.userMessage = userMessage
41
+ this.shouldResetState = shouldResetState
42
+ this.errorType = errorType
43
+ }
44
+
45
+ getStateKeys() {
46
+ return getStateKeys(this.component)
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Thrown when form submission fails after payment has been captured.
52
+ * User needs to retry or contact support for a refund.
53
+ */
54
+ export class PaymentSubmissionError extends Error {
55
+ public readonly referenceNumber: string
56
+ public readonly helpLink?: string
57
+
58
+ constructor(referenceNumber: string, helpLink?: string) {
59
+ super('Form submission failed after payment capture')
60
+ this.name = 'PaymentSubmissionError'
61
+ this.referenceNumber = referenceNumber
62
+ this.helpLink = helpLink
63
+ }
64
+
65
+ static checkPaymentAmount(
66
+ stateAmount: number,
67
+ definitionAmount: number | undefined,
68
+ component: FormComponent
69
+ ) {
70
+ if (stateAmount / 100 !== definitionAmount) {
71
+ throw new PaymentPreAuthError(
72
+ component,
73
+ 'The pre-authorised payment amount is somehow different from that requested. Try adding payment details again.',
74
+ true,
75
+ PaymentErrorTypes.PaymentIncomplete
76
+ )
77
+ }
78
+ }
79
+ }
80
+
3
81
  /**
4
82
  * Thrown when a component has an invalid state. This is typically only required where state needs
5
83
  * to be checked against an external source upon submission of a form. For example: file upload
@@ -21,9 +99,6 @@ export class InvalidComponentStateError extends Error {
21
99
  }
22
100
 
23
101
  getStateKeys() {
24
- const extraStateKeys =
25
- this.component.page?.getStateKeys(this.component) ?? []
26
-
27
- return [this.component.name].concat(extraStateKeys)
102
+ return getStateKeys(this.component)
28
103
  }
29
104
  }