@defra/forms-engine-plugin 4.8.0 → 4.9.0
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/.server/server/forms/payment-v2-test.yaml +341 -0
- package/.server/server/plugins/engine/components/PaymentField.d.ts +7 -0
- package/.server/server/plugins/engine/components/PaymentField.js +58 -6
- package/.server/server/plugins/engine/components/PaymentField.js.map +1 -1
- package/.server/server/plugins/engine/models/SummaryViewModel.d.ts +2 -0
- package/.server/server/plugins/engine/models/SummaryViewModel.js +2 -0
- package/.server/server/plugins/engine/models/SummaryViewModel.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +24 -2
- package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +10 -0
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +57 -13
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/errors.d.ts +1 -1
- package/.server/server/plugins/engine/pageControllers/errors.js +2 -2
- package/.server/server/plugins/engine/pageControllers/errors.js.map +1 -1
- package/.server/server/plugins/engine/routes/index.js +5 -2
- package/.server/server/plugins/engine/routes/index.js.map +1 -1
- package/.server/server/plugins/engine/routes/payment.js +6 -1
- package/.server/server/plugins/engine/routes/payment.js.map +1 -1
- package/.server/server/plugins/engine/routes/payment.test.js +3 -3
- package/.server/server/plugins/engine/routes/payment.test.js.map +1 -1
- 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/views/summary.html +2 -1
- package/.server/server/plugins/payment/service.d.ts +2 -1
- package/.server/server/plugins/payment/service.js +11 -3
- package/.server/server/plugins/payment/service.js.map +1 -1
- package/.server/server/plugins/payment/types.d.ts +4 -0
- package/.server/server/plugins/payment/types.js +1 -0
- package/.server/server/plugins/payment/types.js.map +1 -1
- package/package.json +2 -2
- package/src/server/forms/payment-v2-test.yaml +341 -0
- package/src/server/plugins/engine/components/PaymentField.ts +70 -6
- package/src/server/plugins/engine/models/SummaryViewModel.ts +2 -0
- package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +32 -1
- package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +99 -17
- package/src/server/plugins/engine/pageControllers/errors.ts +2 -2
- package/src/server/plugins/engine/routes/index.ts +9 -2
- package/src/server/plugins/engine/routes/payment.js +7 -1
- package/src/server/plugins/engine/services/localFormsService.js +7 -0
- package/src/server/plugins/engine/views/summary.html +2 -1
- package/src/server/plugins/payment/service.js +13 -3
- 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:
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
399
|
-
|
|
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
|
-
|
|
425
|
-
|
|
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
|
|
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
|
-
|
|
67
|
+
expectedAmount: number | undefined,
|
|
68
68
|
component: FormComponent
|
|
69
69
|
) {
|
|
70
|
-
if (stateAmount / 100 !==
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|