@defra/forms-engine-plugin 4.8.0 → 4.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.public/javascripts/shared.min.js +1 -1
  2. package/.public/javascripts/shared.min.js.map +1 -1
  3. package/.server/client/images/esri-logo.png +0 -0
  4. package/.server/client/javascripts/map.js +2 -0
  5. package/.server/client/javascripts/map.js.map +1 -1
  6. package/.server/server/forms/payment-v2-test.yaml +341 -0
  7. package/.server/server/plugins/engine/components/PaymentField.d.ts +7 -0
  8. package/.server/server/plugins/engine/components/PaymentField.js +58 -6
  9. package/.server/server/plugins/engine/components/PaymentField.js.map +1 -1
  10. package/.server/server/plugins/engine/models/SummaryViewModel.d.ts +2 -0
  11. package/.server/server/plugins/engine/models/SummaryViewModel.js +2 -0
  12. package/.server/server/plugins/engine/models/SummaryViewModel.js.map +1 -1
  13. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +24 -2
  14. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  15. package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +10 -0
  16. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +57 -13
  17. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
  18. package/.server/server/plugins/engine/pageControllers/errors.d.ts +1 -1
  19. package/.server/server/plugins/engine/pageControllers/errors.js +2 -2
  20. package/.server/server/plugins/engine/pageControllers/errors.js.map +1 -1
  21. package/.server/server/plugins/engine/routes/index.js +5 -2
  22. package/.server/server/plugins/engine/routes/index.js.map +1 -1
  23. package/.server/server/plugins/engine/routes/payment.js +6 -1
  24. package/.server/server/plugins/engine/routes/payment.js.map +1 -1
  25. package/.server/server/plugins/engine/routes/payment.test.js +3 -3
  26. package/.server/server/plugins/engine/routes/payment.test.js.map +1 -1
  27. package/.server/server/plugins/engine/services/localFormsService.js +6 -0
  28. package/.server/server/plugins/engine/services/localFormsService.js.map +1 -1
  29. package/.server/server/plugins/engine/views/summary.html +2 -1
  30. package/.server/server/plugins/payment/service.d.ts +2 -1
  31. package/.server/server/plugins/payment/service.js +11 -3
  32. package/.server/server/plugins/payment/service.js.map +1 -1
  33. package/.server/server/plugins/payment/types.d.ts +4 -0
  34. package/.server/server/plugins/payment/types.js +1 -0
  35. package/.server/server/plugins/payment/types.js.map +1 -1
  36. package/package.json +2 -2
  37. package/src/client/images/esri-logo.png +0 -0
  38. package/src/client/javascripts/map.js +2 -0
  39. package/src/server/forms/payment-v2-test.yaml +341 -0
  40. package/src/server/plugins/engine/components/PaymentField.ts +70 -6
  41. package/src/server/plugins/engine/models/SummaryViewModel.ts +2 -0
  42. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +32 -1
  43. package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +99 -17
  44. package/src/server/plugins/engine/pageControllers/errors.ts +2 -2
  45. package/src/server/plugins/engine/routes/index.ts +9 -2
  46. package/src/server/plugins/engine/routes/payment.js +7 -1
  47. package/src/server/plugins/engine/services/localFormsService.js +7 -0
  48. package/src/server/plugins/engine/views/summary.html +2 -1
  49. package/src/server/plugins/payment/service.js +13 -3
  50. package/src/server/plugins/payment/types.js +1 -0
@@ -5,6 +5,7 @@ import {
5
5
  hasComponents,
6
6
  hasNext,
7
7
  hasRepeater,
8
+ isPaymentPage,
8
9
  type Link,
9
10
  type Page
10
11
  } from '@defra/forms-model'
@@ -19,6 +20,7 @@ import {
19
20
  PAYMENT_EXPIRED_NOTIFICATION
20
21
  } from '../../../constants.js'
21
22
  import { ComponentCollection } from '../components/ComponentCollection.js'
23
+ import { PaymentField } from '../components/PaymentField.js'
22
24
  import { optionalText } from '../components/constants.js'
23
25
  import { type BackLink } from '../components/types.js'
24
26
  import {
@@ -44,6 +46,7 @@ import {
44
46
  type FormSubmissionState
45
47
  } from '../types.js'
46
48
  import { getComponentsByType } from '../validationHelpers.js'
49
+ import { formatCurrency } from '../../payment/helper.js'
47
50
  import {
48
51
  FormAction,
49
52
  FormStatus,
@@ -182,6 +185,25 @@ export class QuestionPageController extends PageController {
182
185
  }
183
186
  }
184
187
 
188
+ // Override payment amount display with resolved conditional amount
189
+ // getViewModel() only has the current page's payload, not full form state.
190
+ // The full state is available via context.evaluationState.
191
+ for (const comp of components) {
192
+ if ('amount' in comp.model && 'paymentState' in comp.model) {
193
+ const paymentField = this.collection.fields.find(
194
+ (f): f is PaymentField => f instanceof PaymentField
195
+ )
196
+ if (paymentField) {
197
+ const resolvedAmount = PaymentField.resolveAmount(
198
+ paymentField.options,
199
+ this.model,
200
+ context.evaluationState
201
+ )
202
+ comp.model.amount = formatCurrency(resolvedAmount)
203
+ }
204
+ }
205
+ }
206
+
185
207
  const hasIncompletePayment = components.some(({ model }) => {
186
208
  if ('paymentState' in model) {
187
209
  const paymentState = model.paymentState as
@@ -199,7 +221,9 @@ export class QuestionPageController extends PageController {
199
221
  showTitle,
200
222
  components,
201
223
  errors,
202
- allowSaveAndExit: this.shouldShowSaveAndExit(request.server),
224
+ allowSaveAndExit:
225
+ this.shouldShowSaveAndExit(request.server) &&
226
+ !isPaymentPage(this.pageDef),
203
227
  showSubmitButton: !hasIncompletePayment
204
228
  }
205
229
  }
@@ -246,6 +270,13 @@ export class QuestionPageController extends PageController {
246
270
  }
247
271
  }
248
272
 
273
+ // Skip payment pages in the normal page walk.
274
+ // Users reach the payment page via "Pay and submit" on CYA,
275
+ // not by navigating forward through the form.
276
+ if (isPaymentPage(page.pageDef)) {
277
+ return false
278
+ }
279
+
249
280
  return true
250
281
  })
251
282
 
@@ -6,6 +6,7 @@ import {
6
6
  } from '@defra/forms-model'
7
7
  import Boom from '@hapi/boom'
8
8
  import { type RouteOptions } from '@hapi/hapi'
9
+ import { StatusCodes } from 'http-status-codes'
9
10
 
10
11
  import {
11
12
  COMPONENT_STATE_ERROR,
@@ -65,6 +66,16 @@ export class SummaryPageController extends QuestionPageController {
65
66
  * The controller which is used when Page["controller"] is defined as "./pages/summary.js"
66
67
  */
67
68
 
69
+ /**
70
+ * Finds the PaymentField component across all pages in the model.
71
+ * Payment pages are skipped in the normal page walk
72
+ */
73
+ private findPaymentField(): PaymentField | undefined {
74
+ return this.model.pages
75
+ .flatMap((page) => page.collection.fields)
76
+ .find((field): field is PaymentField => field instanceof PaymentField)
77
+ }
78
+
68
79
  constructor(model: FormModel, pageDef: Page) {
69
80
  super(model, pageDef)
70
81
  this.viewName = 'summary'
@@ -85,19 +96,34 @@ export class SummaryPageController extends QuestionPageController {
85
96
  const { query } = request
86
97
  const { payload, errors, state } = context
87
98
 
88
- const paymentField = context.relevantPages
89
- .flatMap((page) => page.collection.fields)
90
- .find((field): field is PaymentField => field instanceof PaymentField)
99
+ const paymentField = this.findPaymentField()
91
100
 
92
101
  if (paymentField) {
102
+ const resolvedAmount = PaymentField.resolveAmount(
103
+ paymentField.options,
104
+ this.model,
105
+ state
106
+ )
93
107
  const paymentState = paymentField.getPaymentStateFromState(state)
94
- if (paymentState) {
108
+
109
+ if (paymentState?.amount === resolvedAmount) {
95
110
  viewModel.paymentState = paymentState
96
111
  viewModel.paymentDetails = this.buildPaymentDetails(
97
112
  paymentField,
98
113
  paymentState
99
114
  )
100
115
  }
116
+
117
+ if (resolvedAmount > 0) {
118
+ viewModel.paymentRequired = true
119
+ }
120
+ if (
121
+ paymentState &&
122
+ paymentState.preAuth?.status === 'success' &&
123
+ paymentState.amount === resolvedAmount
124
+ ) {
125
+ viewModel.paymentPreAuthorized = true
126
+ }
101
127
  }
102
128
 
103
129
  const components = this.collection.getViewModel(payload, errors, query)
@@ -157,6 +183,18 @@ export class SummaryPageController extends QuestionPageController {
157
183
  ) => {
158
184
  const { viewName } = this
159
185
 
186
+ // After GOV.UK Pay callback, auto-submit the form instead of
187
+ // showing CYA again. The payment is already pre-authorized.
188
+ if (request.query.paymentComplete === 'true') {
189
+ return this.handleFormSubmit(
190
+ request as unknown as FormRequestPayload,
191
+ context,
192
+ h
193
+ )
194
+ }
195
+
196
+ await this.reconcilePaymentState(request, context)
197
+
160
198
  const viewModel = this.getSummaryViewModel(request, context)
161
199
 
162
200
  viewModel.hasMissingNotificationEmail =
@@ -166,6 +204,33 @@ export class SummaryPageController extends QuestionPageController {
166
204
  }
167
205
  }
168
206
 
207
+ /**
208
+ * Checks if the resolved payment amount has changed since pre-auth
209
+ * and invalidates stale payment state if so.
210
+ */
211
+ private async reconcilePaymentState(
212
+ request: FormRequest,
213
+ context: FormContext
214
+ ) {
215
+ const paymentField = this.findPaymentField()
216
+
217
+ if (!paymentField) {
218
+ return
219
+ }
220
+
221
+ const resolvedAmount = PaymentField.resolveAmount(
222
+ paymentField.options,
223
+ this.model,
224
+ context.state
225
+ )
226
+ const paymentState = paymentField.getPaymentStateFromState(context.state)
227
+
228
+ if (paymentState && paymentState.amount !== resolvedAmount) {
229
+ const cacheService = getCacheService(request.server)
230
+ await cacheService.resetComponentStates(request, [paymentField.name])
231
+ }
232
+ }
233
+
169
234
  /**
170
235
  * Returns an async function. This is called in plugin.ts when there is a POST request at `/{id}/{path*}`.
171
236
  * If a form is incomplete, a user will be redirected to the start page.
@@ -293,6 +358,18 @@ export class SummaryPageController extends QuestionPageController {
293
358
  }
294
359
  }
295
360
 
361
+ // Payment page is skipped in the page walk, so proceed() would redirect
362
+ // to an earlier page. For PaymentIncomplete, redirect directly to the
363
+ // payment page using its href.
364
+ if (
365
+ error.errorType === PaymentErrorTypes.PaymentIncomplete &&
366
+ error.component.page
367
+ ) {
368
+ return h
369
+ .redirect(error.component.page.getHref(error.component.page.path))
370
+ .code(StatusCodes.SEE_OTHER)
371
+ }
372
+
296
373
  const govukError = createError(error.component.name, error.userMessage)
297
374
  request.yar.flash(COMPONENT_STATE_ERROR, govukError, true)
298
375
 
@@ -345,9 +422,9 @@ export async function submitForm(
345
422
  model: FormModel,
346
423
  emailAddress: string
347
424
  ) {
348
- await finaliseComponents(request, formMetadata, context)
425
+ await finaliseComponents(request, formMetadata, context, model)
349
426
 
350
- const paymentWasCaptured = hasPaymentBeenCaptured(context)
427
+ const paymentWasCaptured = hasPaymentBeenCaptured(context, model)
351
428
 
352
429
  const formStatus = checkFormStatus(request.params)
353
430
  const logTags = ['submit', 'submissionApi']
@@ -395,8 +472,11 @@ export async function submitForm(
395
472
  /**
396
473
  * Checks if any payment component has been captured
397
474
  */
398
- function hasPaymentBeenCaptured(context: FormContext): boolean {
399
- for (const page of context.relevantPages) {
475
+ function hasPaymentBeenCaptured(
476
+ context: FormContext,
477
+ model: FormModel
478
+ ): boolean {
479
+ for (const page of model.pages) {
400
480
  for (const field of page.collection.fields) {
401
481
  if (field instanceof PaymentField) {
402
482
  const paymentState = field.getPaymentStateFromState(context.state)
@@ -419,17 +499,19 @@ function hasPaymentBeenCaptured(context: FormContext): boolean {
419
499
  async function finaliseComponents(
420
500
  request: FormRequestPayload,
421
501
  metadata: FormMetadata,
422
- context: FormContext
502
+ context: FormContext,
503
+ model: FormModel
423
504
  ) {
424
- const relevantFields = context.relevantPages.flatMap(
425
- (page) => page.collection.fields
426
- )
505
+ // Get fields from relevant pages (normal components)
506
+ // plus PaymentField from all pages (payment page is skipped in the page walk)
507
+ const allFields = new Set([
508
+ ...context.relevantPages.flatMap((page) => page.collection.fields),
509
+ ...model.pages
510
+ .flatMap((page) => page.collection.fields)
511
+ .filter((field) => field instanceof PaymentField)
512
+ ])
427
513
 
428
- for (const component of relevantFields) {
429
- /*
430
- Each component will throw InvalidComponent if its state is invalid, which is handled
431
- by handleFormSubmit
432
- */
514
+ for (const component of allFields) {
433
515
  await component.onSubmit(request, metadata, context)
434
516
  }
435
517
  }
@@ -64,10 +64,10 @@ export class PaymentSubmissionError extends Error {
64
64
 
65
65
  static checkPaymentAmount(
66
66
  stateAmount: number,
67
- definitionAmount: number | undefined,
67
+ expectedAmount: number | undefined,
68
68
  component: FormComponent
69
69
  ) {
70
- if (stateAmount / 100 !== definitionAmount) {
70
+ if (stateAmount / 100 !== expectedAmount) {
71
71
  throw new PaymentPreAuthError(
72
72
  component,
73
73
  'The pre-authorised payment amount is somehow different from that requested. Try adding payment details again.',
@@ -1,3 +1,4 @@
1
+ import { isPaymentPage } from '@defra/forms-model'
1
2
  import Boom from '@hapi/boom'
2
3
  import {
3
4
  type ResponseObject,
@@ -102,8 +103,14 @@ export async function redirectOrMakeHandler(
102
103
  return proceed(request, h, resumeInRepeaterUrl)
103
104
  }
104
105
 
105
- // Return handler for relevant pages or preview URL direct access
106
- if (relevantPath.startsWith(page.path) || context.isForceAccess) {
106
+ // Return handler for relevant pages, payment pages, or preview URL direct access.
107
+ // Payment pages are skipped in the normal page walk but must render when the user
108
+ // is redirected there from CYA "Pay and submit".
109
+ if (
110
+ relevantPath.startsWith(page.path) ||
111
+ isPaymentPage(page.pageDef) ||
112
+ context.isForceAccess
113
+ ) {
107
114
  return makeHandler(page, context)
108
115
  }
109
116
 
@@ -103,7 +103,13 @@ function logPaymentFailure(session, paymentStatus) {
103
103
  function handlePaymentSuccess(request, h, session, sessionKey, paymentStatus) {
104
104
  flashComponentState(request, session, paymentStatus)
105
105
  request.yar.clear(sessionKey)
106
- return h.redirect(session.returnUrl).code(StatusCodes.SEE_OTHER)
106
+
107
+ // Append paymentComplete flag so the summary page auto-submits
108
+ // instead of showing CYA again
109
+ const separator = session.returnUrl.includes('?') ? '&' : '?'
110
+ const returnUrl = `${session.returnUrl}${separator}paymentComplete=true`
111
+
112
+ return h.redirect(returnUrl).code(StatusCodes.SEE_OTHER)
107
113
  }
108
114
 
109
115
  /**
@@ -65,5 +65,12 @@ export const formsService = async () => {
65
65
  slug: 'payment-test'
66
66
  })
67
67
 
68
+ await loader.addForm('src/server/forms/payment-v2-test.yaml', {
69
+ ...metadata,
70
+ id: 'c3d4e5f6-a7b8-9012-cdef-012345678901',
71
+ title: 'Apply for a lock and weir fishing permit (v2 payment test)',
72
+ slug: 'payment-v2-test'
73
+ })
74
+
68
75
  return loader.toFormsService()
69
76
  }
@@ -73,9 +73,10 @@
73
73
 
74
74
  <div class="govuk-button-group">
75
75
  {% set isDeclaration = declaration or components | length %}
76
+ {% set paymentPending = paymentRequired and not paymentState %}
76
77
 
77
78
  {{ govukButton({
78
- text: "Accept and submit" if isDeclaration else "Submit",
79
+ text: "Pay and submit" if paymentPending else ("Accept and submit" if isDeclaration else "Submit"),
79
80
  name: "action",
80
81
  value: "send",
81
82
  preventDoubleClick: true
@@ -41,6 +41,7 @@ export class PaymentService {
41
41
  * @param {string} reference
42
42
  * @param {boolean} isLivePayment
43
43
  * @param {{ formId: string, slug: string } | undefined } metadata
44
+ * @param {string} [email] - optional email to prepopulate on GOV.UK Pay
44
45
  */
45
46
  async createPayment(
46
47
  amount,
@@ -48,17 +49,26 @@ export class PaymentService {
48
49
  returnUrl,
49
50
  reference,
50
51
  isLivePayment,
51
- metadata
52
+ metadata,
53
+ email
52
54
  ) {
53
55
  try {
54
- const response = await this.postToPayProvider({
56
+ /** @type {CreatePaymentRequest} */
57
+ const payload = {
55
58
  amount,
56
59
  description,
57
60
  reference,
58
61
  metadata,
59
62
  return_url: returnUrl,
60
63
  delayed_capture: true
61
- })
64
+ }
65
+
66
+ // Prepopulate email on GOV.UK Pay if provided
67
+ if (email) {
68
+ payload.email = email
69
+ }
70
+
71
+ const response = await this.postToPayProvider(payload)
62
72
 
63
73
  logger.info(
64
74
  buildPaymentInfo(
@@ -20,6 +20,7 @@
20
20
  * @property {string} return_url - URL to redirect the user to after payment
21
21
  * @property {boolean} [delayed_capture] - Whether to delay capturing the payment
22
22
  * @property {{ formId: string, slug: string }} [metadata] - Additional metadata for the payment
23
+ * @property {string} [email] - Email to prepopulate on GOV.UK Pay (max 254 chars)
23
24
  */
24
25
 
25
26
  /**