diff --git a/jest.setup.cjs b/jest.setup.cjs
index d9e1df3b6..2686c4c7f 100644
--- a/jest.setup.cjs
+++ b/jest.setup.cjs
@@ -13,3 +13,4 @@ process.env.UPLOADER_BUCKET_NAME = 'dummy-bucket'
process.env.GOOGLE_ANALYTICS_TRACKING_ID = 'G-123456789'
process.env.SUBMISSION_EMAIL_ADDRESS = 'dummy@defra.gov.uk'
process.env.ORDNANCE_SURVEY_API_KEY = 'dummy'
+process.env.PAYMENT_PROVIDER_API_KEY_TEST = 'test-api-key'
diff --git a/package-lock.json b/package-lock.json
index da22096df..126aba582 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3935,9 +3935,9 @@
}
},
"node_modules/@jest/reporters/node_modules/glob": {
- "version": "10.4.5",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
- "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -9325,9 +9325,9 @@
}
},
"node_modules/expr-eval-fork": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/expr-eval-fork/-/expr-eval-fork-3.0.0.tgz",
- "integrity": "sha512-29S+IZ2g8qSk5q7gOUYozO7zi4mj/sCVo+HB2h0f0ER4ZCZr9b/+5SWIedvV0SHq3IxBW2/TJrPn77YxMsoVwg==",
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/expr-eval-fork/-/expr-eval-fork-3.0.1.tgz",
+ "integrity": "sha512-JRex9aykIt6AqhcQK+u1bFcBy2f+muwJoGCtAZmOC0yrktaCegtH42sLnZdNsD2/Ko9j+3pLWi4nIkNQez02bg==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -11407,9 +11407,9 @@
}
},
"node_modules/jest-config/node_modules/glob": {
- "version": "10.4.5",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
- "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -11977,9 +11977,9 @@
}
},
"node_modules/jest-runtime/node_modules/glob": {
- "version": "10.4.5",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
- "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -12773,9 +12773,9 @@
}
},
"node_modules/lodash": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.clonedeep": {
diff --git a/src/server/constants.js b/src/server/constants.js
index 301539c14..b9e68652b 100644
--- a/src/server/constants.js
+++ b/src/server/constants.js
@@ -3,3 +3,4 @@ export const FORM_PREFIX = ''
export const EXTERNAL_STATE_PAYLOAD = 'EXTERNAL_STATE_PAYLOAD'
export const EXTERNAL_STATE_APPENDAGE = 'EXTERNAL_STATE_APPENDAGE'
export const COMPONENT_STATE_ERROR = 'COMPONENT_STATE_ERROR'
+export const PAYMENT_EXPIRED_NOTIFICATION = 'PAYMENT_EXPIRED_NOTIFICATION'
diff --git a/src/server/forms/payment-test.yaml b/src/server/forms/payment-test.yaml
index 3f01dc2ce..c1996a85b 100644
--- a/src/server/forms/payment-test.yaml
+++ b/src/server/forms/payment-test.yaml
@@ -1,4 +1,5 @@
---
+schema: 2
name: Payment Test Form
declaration: "
All the answers you have provided are true to the best of your knowledge.
"
pages:
diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts
index 015274975..09ad39eb0 100644
--- a/src/server/plugins/engine/components/FormComponent.ts
+++ b/src/server/plugins/engine/components/FormComponent.ts
@@ -191,7 +191,7 @@ export class FormComponent extends ComponentBase {
return value.filter(isFormValue)
}
- return this.isValue(value) ? value : null
+ return this.isValue(value) ? (value as Item['value']) : null
}
getContextValueFromState(
diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts
new file mode 100644
index 000000000..6f11d2afb
--- /dev/null
+++ b/src/server/plugins/engine/components/PaymentField.test.ts
@@ -0,0 +1,490 @@
+import {
+ ComponentType,
+ type FormMetadata,
+ type PaymentFieldComponent
+} from '@defra/forms-model'
+
+import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
+import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
+import {
+ getAnswer,
+ type Field
+} from '~/src/server/plugins/engine/components/helpers/components.js'
+import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
+import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js'
+import {
+ type FormContext,
+ type FormValue
+} from '~/src/server/plugins/engine/types.js'
+import {
+ type FormRequestPayload,
+ type FormResponseToolkit
+} from '~/src/server/routes/types.js'
+import { get, post, postJson } from '~/src/server/services/httpService.js'
+import definition from '~/test/form/definitions/blank.js'
+import { getFormData, getFormState } from '~/test/helpers/component-helpers.js'
+
+jest.mock('~/src/server/services/httpService.ts')
+
+describe('PaymentField', () => {
+ let model: FormModel
+
+ beforeEach(() => {
+ model = new FormModel(definition, {
+ basePath: 'test'
+ })
+ })
+
+ describe('Defaults', () => {
+ let def: PaymentFieldComponent
+ let collection: ComponentCollection
+ let field: Field
+
+ beforeEach(() => {
+ def = {
+ title: 'Example payment field',
+ name: 'myComponent',
+ type: ComponentType.PaymentField,
+ options: {
+ amount: 100,
+ description: 'Test payment description'
+ }
+ } satisfies PaymentFieldComponent
+
+ collection = new ComponentCollection([def], { model })
+ field = collection.fields[0]
+ })
+
+ describe('Schema', () => {
+ it('uses component title as label as default', () => {
+ const { formSchema } = collection
+ const { keys } = formSchema.describe()
+
+ expect(keys).toHaveProperty(
+ 'myComponent',
+ expect.objectContaining({
+ flags: expect.objectContaining({
+ label: 'Example payment field'
+ })
+ })
+ )
+ })
+
+ it('uses component name as keys', () => {
+ const { formSchema } = collection
+ const { keys } = formSchema.describe()
+
+ expect(field.keys).toEqual(['myComponent'])
+ expect(field.collection).toBeUndefined()
+
+ for (const key of field.keys) {
+ expect(keys).toHaveProperty(key)
+ }
+ })
+
+ it('is required by default', () => {
+ const { formSchema } = collection
+ const { keys } = formSchema.describe()
+
+ expect(keys).toHaveProperty(
+ 'myComponent',
+ expect.objectContaining({
+ keys: expect.objectContaining({
+ amount: expect.objectContaining({
+ flags: expect.objectContaining({
+ presence: 'required'
+ })
+ })
+ })
+ })
+ )
+ })
+
+ it('adds errors for empty value', () => {
+ const payment = {
+ paymentId: '',
+ reference: '',
+ amount: 0,
+ description: '',
+ uuid: '',
+ formId: '',
+ isLivePayment: false
+ }
+ const result = collection.validate(getFormData(payment))
+
+ const errors = result.errors ?? []
+
+ expect(errors[0]).toEqual(
+ expect.objectContaining({
+ text: 'Enter myComponent.paymentId'
+ })
+ )
+
+ expect(errors[1]).toEqual(
+ expect.objectContaining({
+ text: 'Enter myComponent.reference'
+ })
+ )
+
+ expect(errors[2]).toEqual(
+ expect.objectContaining({
+ text: 'Enter myComponent.description'
+ })
+ )
+
+ expect(errors[3]).toEqual(
+ expect.objectContaining({
+ text: 'Enter myComponent.uuid'
+ })
+ )
+
+ expect(errors[4]).toEqual(
+ expect.objectContaining({
+ text: 'Enter myComponent.formId'
+ })
+ )
+
+ expect(errors[5]).toEqual(
+ expect.objectContaining({
+ text: 'Select myComponent.preAuth'
+ })
+ )
+ })
+
+ it('adds errors for invalid values', () => {
+ const result1 = collection.validate(getFormData(['invalid']))
+ const result2 = collection.validate(
+ // @ts-expect-error - Allow invalid param for test
+ getFormData({ unknown: 'invalid' })
+ )
+
+ expect(result1.errors).toBeTruthy()
+ expect(result2.errors).toBeTruthy()
+ })
+ })
+
+ describe('State', () => {
+ const paymentForState = {
+ paymentId: 'payment-id',
+ reference: 'payment-ref',
+ amount: 150,
+ description: 'payment description',
+ uuid: 'ee501106-4ce1-4947-91a7-7cc1a335ccd8',
+ formId: 'form-id',
+ isLivePayment: false
+ }
+ it('returns text from state', () => {
+ const state1 = getFormState(paymentForState)
+ const state2 = getFormState(null)
+
+ const answer1 = getAnswer(field, state1)
+ const answer2 = getAnswer(field, state2)
+
+ expect(answer1).toBe('£150.00 - payment description')
+ expect(answer2).toBe('')
+ })
+ })
+
+ describe('View model', () => {
+ it('sets Nunjucks component defaults', () => {
+ const viewModel = field.getViewModel(getFormData(undefined))
+
+ expect(viewModel).toEqual(
+ expect.objectContaining({
+ label: { text: def.title },
+ name: 'myComponent',
+ id: 'myComponent',
+ amount: '100.00',
+ attributes: {},
+ description: 'Test payment description'
+ })
+ )
+ })
+
+ it('sets Nunjucks component values', () => {
+ const paymentForViewModel = {
+ paymentId: 'payment-id',
+ reference: 'payment-ref',
+ uuid: 'ee501106-4ce1-4947-91a7-7cc1a335ccd8',
+ formId: 'form-id',
+ amount: 100,
+ description: 'Test payment description',
+ isLivePayment: false
+ } as unknown as FormValue
+ const viewModel = field.getViewModel(getFormData(paymentForViewModel))
+
+ expect(viewModel).toEqual(
+ expect.objectContaining({
+ label: { text: def.title },
+ name: 'myComponent',
+ id: 'myComponent',
+ amount: '100.00',
+ attributes: {},
+ description: 'Test payment description'
+ })
+ )
+ })
+ })
+
+ describe('AllPossibleErrors', () => {
+ it('should return errors', () => {
+ const errors = field.getAllPossibleErrors()
+ expect(errors.baseErrors).not.toBeEmpty()
+ expect(errors.advancedSettingsErrors).toBeEmpty()
+ })
+ })
+ })
+
+ describe('dispatcher and onSubmit', () => {
+ const def = {
+ title: 'Example payment field',
+ name: 'myComponent',
+ type: ComponentType.PaymentField,
+ options: {
+ amount: 100,
+ description: 'Test payment description'
+ }
+ } satisfies PaymentFieldComponent
+
+ const collection = new ComponentCollection([def], { model })
+ const paymentField = collection.fields[0] as PaymentField
+
+ describe('dispatcher', () => {
+ it('should create payment and redirect to gov pay', async () => {
+ const mockYarSet = jest.fn()
+ const mockRequest = {
+ server: {
+ plugins: {
+ // eslint-disable-next-line no-useless-computed-key
+ ['forms-engine-plugin']: {
+ baseUrl: 'base-url'
+ }
+ }
+ },
+ yar: {
+ set: mockYarSet
+ }
+ } as unknown as FormRequestPayload
+ const mockH = {
+ redirect: jest
+ .fn()
+ .mockReturnValueOnce({ code: jest.fn().mockReturnValueOnce('ok') })
+ } as unknown as FormResponseToolkit
+ const args = {
+ controller: {
+ model: {
+ formId: 'form-id',
+ basePath: 'base-path',
+ name: 'PaymentModel'
+ },
+ getState: jest
+ .fn()
+ .mockResolvedValueOnce({ $$__referenceNumber: 'pay-ref-123' })
+ },
+ component: paymentField,
+ sourceUrl: 'http://localhost:3009/test-payment',
+ isLive: false,
+ isPreview: true
+ }
+ // @ts-expect-error - partial mock
+ jest.mocked(postJson).mockResolvedValueOnce({
+ payload: {
+ state: {
+ status: 'created'
+ },
+ payment_id: 'new-payment-id',
+ _links: {
+ next_url: {
+ href: '/next-url'
+ }
+ }
+ }
+ })
+
+ const res = await PaymentField.dispatcher(mockRequest, mockH, args)
+ expect(res).toBe('ok')
+ expect(mockYarSet).toHaveBeenCalledWith(expect.any(String), {
+ amount: 100,
+ componentName: 'myComponent',
+ description: 'Test payment description',
+ failureUrl: 'http://localhost:3009/test-payment',
+ formId: 'form-id',
+ isLivePayment: false,
+ paymentId: 'new-payment-id',
+ reference: 'pay-ref-123',
+ returnUrl: 'base-url/base-path/summary',
+ uuid: expect.any(String)
+ })
+ })
+ })
+
+ describe('onSubmit', () => {
+ it('should throw if missing state', async () => {
+ const mockRequest = {} as unknown as FormRequestPayload
+
+ const error = await paymentField
+ .onSubmit(
+ mockRequest,
+ {} as FormMetadata,
+ { state: {} } as FormContext
+ )
+ .catch((e: unknown) => e)
+
+ expect(error).toBeInstanceOf(InvalidComponentStateError)
+ expect((error as InvalidComponentStateError).component).toBe(
+ paymentField
+ )
+ expect((error as InvalidComponentStateError).userMessage).toBe(
+ 'Complete the payment to continue'
+ )
+ })
+
+ it('should ignore if our state says payment already captured', async () => {
+ const mockRequest = {} as unknown as FormRequestPayload
+
+ await paymentField.onSubmit(
+ mockRequest,
+ {} as FormMetadata,
+ {
+ state: {
+ myComponent: {
+ capture: {
+ status: 'success'
+ },
+ paymentId: 'payment-id',
+ amount: 123,
+ description: 'Payment desc'
+ }
+ }
+ } as unknown as FormContext
+ )
+ expect(get).not.toHaveBeenCalled()
+ expect(post).not.toHaveBeenCalled()
+ })
+
+ it('should mark payment already captured according to gov pay', async () => {
+ const mockRequest = {} as unknown as FormRequestPayload
+ jest
+ .mocked(get)
+ // @ts-expect-error - partial mock
+ .mockResolvedValueOnce({ payload: { state: { status: 'success' } } })
+ await paymentField.onSubmit(
+ mockRequest,
+ {} as FormMetadata,
+ {
+ state: {
+ myComponent: {
+ paymentId: 'payment-id',
+ amount: 123,
+ description: 'Payment desc',
+ isLivePayment: false,
+ formId: 'form-id'
+ }
+ }
+ } as unknown as FormContext
+ )
+ expect(get).toHaveBeenCalled()
+ expect(post).not.toHaveBeenCalled()
+ })
+
+ it('should throw if bad status', async () => {
+ const mockRequest = {} as unknown as FormRequestPayload
+ jest
+ .mocked(get)
+ // @ts-expect-error - partial mock
+ .mockResolvedValueOnce({ payload: { state: { status: 'bad' } } })
+ const error = await paymentField
+ .onSubmit(
+ mockRequest,
+ {} as FormMetadata,
+ {
+ state: {
+ myComponent: {
+ paymentId: 'payment-id',
+ amount: 123,
+ description: 'Payment desc',
+ isLivePayment: false,
+ formId: 'form-id'
+ }
+ }
+ } as unknown as FormContext
+ )
+ .catch((e: unknown) => e)
+
+ expect(error).toBeInstanceOf(InvalidComponentStateError)
+ expect((error as InvalidComponentStateError).component).toBe(
+ paymentField
+ )
+ expect((error as InvalidComponentStateError).userMessage).toBe(
+ 'Your payment authorisation has expired. Please add your payment details again.'
+ )
+ })
+
+ it('should throw if error during capture', async () => {
+ const mockRequest = {} as unknown as FormRequestPayload
+ jest
+ .mocked(get)
+ // @ts-expect-error - partial mock
+ .mockResolvedValueOnce({
+ payload: { state: { status: 'capturable' } }
+ })
+ // @ts-expect-error - partial mock
+ jest.mocked(post).mockResolvedValueOnce({ res: { statusCode: 400 } })
+ const error = await paymentField
+ .onSubmit(
+ mockRequest,
+ {} as FormMetadata,
+ {
+ state: {
+ myComponent: {
+ paymentId: 'payment-id',
+ amount: 123,
+ description: 'Payment desc',
+ isLivePayment: false,
+ formId: 'form-id'
+ }
+ }
+ } as unknown as FormContext
+ )
+ .catch((e: unknown) => e)
+
+ expect(error).toBeInstanceOf(InvalidComponentStateError)
+ expect((error as InvalidComponentStateError).component).toBe(
+ paymentField
+ )
+ expect((error as InvalidComponentStateError).userMessage).toBe(
+ 'There was a problem and your form was not submitted. Try submitting the form again.'
+ )
+ })
+
+ it('should capture payment if no errors', async () => {
+ const mockRequest = {} as unknown as FormRequestPayload
+ jest
+ .mocked(get)
+ // @ts-expect-error - partial mock
+ .mockResolvedValueOnce({
+ payload: { state: { status: 'capturable' } }
+ })
+ // @ts-expect-error - partial mock
+ jest.mocked(post).mockResolvedValueOnce({ res: { statusCode: 200 } })
+ await paymentField.onSubmit(
+ mockRequest,
+ {} as FormMetadata,
+ {
+ state: {
+ myComponent: {
+ paymentId: 'payment-id',
+ amount: 123,
+ description: 'Payment desc',
+ isLivePayment: false,
+ formId: 'form-id'
+ }
+ }
+ } as unknown as FormContext
+ )
+ expect(get).toHaveBeenCalled()
+ expect(post).toHaveBeenCalled()
+ })
+ })
+ })
+})
diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts
index 0c2175196..19d8b18f1 100644
--- a/src/server/plugins/engine/components/PaymentField.ts
+++ b/src/server/plugins/engine/components/PaymentField.ts
@@ -5,9 +5,12 @@ import {
type PaymentFieldComponent
} from '@defra/forms-model'
import { StatusCodes } from 'http-status-codes'
+import joi, { type ObjectSchema } from 'joi'
import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'
import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js'
+import { getPluginOptions } from '~/src/server/plugins/engine/helpers.js'
+import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js'
import {
type AnyFormRequest,
type FormContext,
@@ -17,13 +20,17 @@ import {
import {
type ErrorMessageTemplateList,
type FormPayload,
+ type FormState,
+ type FormStateValue,
type FormSubmissionError,
type FormSubmissionState
} from '~/src/server/plugins/engine/types.js'
-import { PaymentService } from '~/src/server/plugins/payment/service.js'
+import { createPaymentService } from '~/src/server/plugins/payment/helper.js'
export class PaymentField extends FormComponent {
declare options: PaymentFieldComponent['options']
+ declare formSchema: ObjectSchema
+ declare stateSchema: ObjectSchema
constructor(
def: PaymentFieldComponent,
@@ -32,6 +39,31 @@ export class PaymentField extends FormComponent {
super(def, props)
this.options = def.options
+
+ const paymentStateSchema = joi
+ .object({
+ paymentId: joi.string().required(),
+ reference: joi.string().required(),
+ amount: joi.number().required(),
+ description: joi.string().required(),
+ uuid: joi.string().uuid().required(),
+ formId: joi.string().required(),
+ isLivePayment: joi.boolean().required(),
+ preAuth: joi
+ .object({
+ status: joi
+ .string()
+ .valid('success', 'failed', 'started')
+ .required(),
+ createdAt: joi.string().isoDate().required()
+ })
+ .required()
+ })
+ .unknown(true)
+ .label(this.label)
+
+ this.formSchema = paymentStateSchema
+ this.stateSchema = paymentStateSchema.default(null).allow(null)
}
/**
@@ -40,7 +72,7 @@ export class PaymentField extends FormComponent {
getPaymentStateFromState(
state: FormSubmissionState
): PaymentState | undefined {
- const value = state[this.name] as unknown
+ const value = state[this.name]
return this.isPaymentState(value) ? value : undefined
}
@@ -88,6 +120,13 @@ export class PaymentField extends FormComponent {
)
}
+ /**
+ * Override base isState to validate PaymentState
+ */
+ isState(value?: FormStateValue | FormState): value is FormState {
+ return this.isPaymentState(value)
+ }
+
/**
* For error preview page that shows all possible errors on a component
*/
@@ -112,67 +151,139 @@ export class PaymentField extends FormComponent {
/**
* Dispatcher for external redirect to GOV.UK Pay
- * STUB - Jez to implement
*/
static async dispatcher(
request: FormRequestPayload,
h: FormResponseToolkit,
args: PaymentDispatcherArgs
): Promise {
- const paymentService = new PaymentService()
+ const isLivePayment = args.isLive && !args.isPreview
+ const formId = args.controller.model.formId
+ const paymentService = createPaymentService(isLivePayment, formId)
- // 1. Generate UUID token and store in session
const uuid = randomUUID()
- const { options } = args.component
+ const { options, name: componentName } = args.component
const { model } = args.controller
const state = await args.controller.getState(request)
+ const reference = state.$$__referenceNumber as string
+ const amount = options.amount ?? 0
+ const description = options.description ?? ''
- const data = {
- uuid,
- reference: state.$$__referenceNumber,
- description: options.description,
- amount: options.amount
- } as PaymentState
-
- request.yar.set(`${request.url.pathname}-payment`, data)
-
- const formId = model.formId
const slug = `/${model.basePath}`
- // 2. Call paymentService.createPayment()
- // GOV.UK Pay expects amount in pence, so multiply pounds by 100
- const amountInPence = Math.round(data.amount * 100)
+ const { baseUrl } = getPluginOptions(request.server)
+ const payCallbackUrl = `${baseUrl}/payment-callback?uuid=${uuid}`
+ const summaryUrl = `${baseUrl}/${model.basePath}/summary`
+ const paymentPageUrl = args.sourceUrl
+
+ const amountInPence = Math.round(amount * 100)
const payment = await paymentService.createPayment(
amountInPence,
- data.description,
- uuid,
- data.reference,
+ description,
+ payCallbackUrl,
+ reference,
{ formId, slug }
)
- // 3. Redirect to GOV.UK Pay paymentUrl
+ const sessionData: PaymentSessionData = {
+ uuid,
+ formId,
+ reference,
+ amount,
+ description,
+ paymentId: payment.paymentId,
+ componentName,
+ returnUrl: summaryUrl,
+ failureUrl: paymentPageUrl,
+ isLivePayment
+ }
+
+ request.yar.set(`payment-${uuid}`, sessionData)
+
return h.redirect(payment.paymentUrl).code(StatusCodes.SEE_OTHER)
}
/**
* Called on form submission to capture the payment
- * STUB - Jez to implement
+ * @see https://docs.payments.service.gov.uk/delayed_capture/#delay-taking-a-payment
*/
- onSubmit(
- _request: FormRequestPayload,
+ async onSubmit(
+ request: FormRequestPayload,
_metadata: FormMetadata,
- _context: FormContext
+ context: FormContext
): Promise {
- // TODO: Implement
- // 1. Get payment state from context
- // 2. If already captured, skip
- // 3. Call paymentService.getPaymentStatus() to validate pre-auth
- // 4. Call paymentService.capturePayment()
- // 5. Update payment state with capture status
- // 6. If capture fails, throw InvalidComponentStateError
- return Promise.resolve()
+ const paymentState = this.getPaymentStateFromState(context.state)
+
+ if (!paymentState) {
+ throw new InvalidComponentStateError(
+ this,
+ 'Complete the payment to continue',
+ { shouldResetState: true }
+ )
+ }
+
+ if (paymentState.capture?.status === 'success') {
+ return
+ }
+
+ const { paymentId, isLivePayment, formId } = paymentState
+ const paymentService = createPaymentService(isLivePayment, formId)
+
+ /**
+ * @see https://docs.payments.service.gov.uk/api_reference/#payment-status-lifecycle
+ */
+ const status = await paymentService.getPaymentStatus(paymentId)
+
+ if (status.state.status === 'success') {
+ await this.markPaymentCaptured(request, paymentState)
+ return
+ }
+
+ if (status.state.status !== 'capturable') {
+ throw new InvalidComponentStateError(
+ this,
+ 'Your payment authorisation has expired. Please add your payment details again.',
+ { shouldResetState: true, isPaymentExpired: true }
+ )
+ }
+
+ const captured = await paymentService.capturePayment(paymentId)
+
+ if (!captured) {
+ throw new InvalidComponentStateError(
+ this,
+ 'There was a problem and your form was not submitted. Try submitting the form again.',
+ { shouldResetState: false }
+ )
+ }
+
+ await this.markPaymentCaptured(request, paymentState)
+ }
+
+ /**
+ * Updates payment state to mark capture as successful
+ * This ensures we don't try to re-capture on submission retry
+ */
+ private async markPaymentCaptured(
+ request: FormRequestPayload,
+ paymentState: PaymentState
+ ): Promise {
+ const updatedState: PaymentState = {
+ ...paymentState,
+ capture: {
+ status: 'success',
+ createdAt: new Date().toISOString()
+ }
+ }
+
+ if (this.page) {
+ const currentState = await this.page.getState(request)
+ await this.page.mergeState(request, currentState, {
+ [this.name]: updatedState
+ })
+ }
}
}
@@ -187,5 +298,22 @@ export interface PaymentDispatcherArgs {
}
component: PaymentField
sourceUrl: string
- paymentService: PaymentService
+ isLive: boolean
+ isPreview: boolean
+}
+
+/**
+ * Session data stored when dispatching to GOV.UK Pay
+ */
+export interface PaymentSessionData {
+ uuid: string
+ formId: string
+ reference: string
+ amount: number
+ description: string
+ paymentId: string
+ componentName: string
+ returnUrl: string
+ failureUrl: string
+ isLivePayment: boolean
}
diff --git a/src/server/plugins/engine/components/PaymentField.types.ts b/src/server/plugins/engine/components/PaymentField.types.ts
index 22c720c8c..b68e6aee8 100644
--- a/src/server/plugins/engine/components/PaymentField.types.ts
+++ b/src/server/plugins/engine/components/PaymentField.types.ts
@@ -7,6 +7,9 @@ export interface PaymentState {
amount: number
description: string
uuid: string
+ formId: string
+ isLivePayment: boolean
+ payerEmail?: string
capture?: {
status: 'success' | 'failed'
createdAt: string
@@ -16,41 +19,3 @@ export interface PaymentState {
createdAt: string
}
}
-
-/**
- * Response from GOV.UK Pay API
- */
-export interface PaymentStatus {
- amount: number
- state: {
- status:
- | 'created'
- | 'started'
- | 'submitted'
- | 'capturable'
- | 'success'
- | 'failed'
- | 'cancelled'
- | 'error'
- finished: boolean
- message?: string
- code?: string
- canRetry?: boolean
- }
- createdDate: string
-}
-
-/**
- * Service interface for GOV.UK Pay integration
- */
-export interface PaymentService {
- createPayment(
- amount: number,
- description: string,
- metadata: { formId: string; slug: string }
- ): Promise<{ paymentId: string; paymentUrl: string }>
-
- getPaymentStatus(paymentId: string): Promise
-
- capturePayment(paymentId: string): Promise
-}
diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts
index 7172e07ad..f41fc0d98 100644
--- a/src/server/plugins/engine/models/SummaryViewModel.ts
+++ b/src/server/plugins/engine/models/SummaryViewModel.ts
@@ -1,5 +1,7 @@
import { SchemaVersion, type Section } from '@defra/forms-model'
+import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
+import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js'
import {
getAnswer,
type Field
@@ -52,6 +54,8 @@ export class SummaryViewModel {
hasMissingNotificationEmail?: boolean
components?: ComponentViewModel[]
allowSaveAndExit = false
+ paymentState?: PaymentState
+ paymentDetails?: CheckAnswers
constructor(
request: FormContextRequest,
@@ -144,6 +148,10 @@ export class SummaryViewModel {
)
} else {
for (const field of collection.fields) {
+ // PaymentField is rendered in its own section, skip it here
+ if (field instanceof PaymentField) {
+ continue
+ }
items.push(ItemField(page, state, field, { path, errors }))
}
}
diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts
index 5bd4ffe48..7f1f6d8e6 100644
--- a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts
+++ b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts
@@ -249,6 +249,7 @@ describe('Adapter v1 formatter', () => {
exampleField: 'hello world',
exampleField2: 'hello world'
},
+ payments: {},
repeaters: {
exampleRepeat: [
{
diff --git a/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts b/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts
new file mode 100644
index 000000000..2eaaf886f
--- /dev/null
+++ b/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts
@@ -0,0 +1,146 @@
+import { format as dateFormat } from 'date-fns'
+import { outdent } from 'outdent'
+
+import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js'
+import { FormModel } from '~/src/server/plugins/engine/models/index.js'
+import { format } from '~/src/server/plugins/engine/outputFormatters/human/v1.js'
+import {
+ SummaryPageController,
+ getFormSubmissionData
+} from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js'
+import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js'
+import { FormStatus } from '~/src/server/routes/types.js'
+import definitionPayment from '~/test/form/definitions/payment.js'
+
+describe('v1 human formatter', () => {
+ describe('Payment', () => {
+ const modelPayment = new FormModel(definitionPayment, {
+ basePath: 'test'
+ })
+
+ const submitResponse = {
+ message: 'Submit completed',
+ result: {
+ files: {
+ main: '00000000-0000-0000-0000-000000000000',
+ repeaters: {
+ pizza: '11111111-1111-1111-1111-111111111111'
+ }
+ }
+ }
+ }
+
+ const statePayment = {
+ $$__referenceNumber: 'foobar',
+ licenceLength: 365,
+ fullName: 'John Smith',
+ paymentField: {
+ paymentId: 'payment-id',
+ reference: 'payment-ref',
+ amount: 250,
+ description: 'Payment desc',
+ uuid: 'uuid',
+ formId: 'form-id',
+ isLivePayment: false,
+ preAuth: {
+ status: 'success',
+ createdAt: '2026-01-02T11:00:04+0000'
+ }
+ } as PaymentState
+ }
+
+ const pageUrl = new URL('http://example.com/repeat/pizza-order/summary')
+
+ const requestPayment = buildFormContextRequest({
+ method: 'get',
+ url: pageUrl,
+ path: pageUrl.pathname,
+ params: {
+ path: 'summary',
+ slug: 'payment'
+ },
+ query: {},
+ app: { model: modelPayment }
+ })
+
+ const pageDefPayment = definitionPayment.pages[2]
+
+ const controllerPayment = new SummaryPageController(
+ modelPayment,
+ pageDefPayment
+ )
+
+ const contextPayment = modelPayment.getFormContext(
+ requestPayment,
+ statePayment
+ )
+ const summaryViewModelPayment = controllerPayment.getSummaryViewModel(
+ requestPayment,
+ contextPayment
+ )
+
+ const itemsPayment = getFormSubmissionData(
+ summaryViewModelPayment.context,
+ summaryViewModelPayment.details
+ )
+
+ it('should add payment details', () => {
+ const body = format(
+ contextPayment,
+ itemsPayment,
+ modelPayment,
+ submitResponse,
+ {
+ state: FormStatus.Draft,
+ isPreview: true
+ }
+ )
+
+ const dateNow = new Date()
+
+ expect(body).toContain(
+ outdent`
+ ${definitionPayment.name} form received at ${dateFormat(dateNow, 'h:mmaaa')} on ${dateFormat(dateNow, 'd MMMM yyyy')}.
+
+ ---
+
+ ## Which fishing licence do you want to get?
+
+ 12 months \\(365\\)
+
+ ---
+
+ ## What\\'s your name?
+
+ John Smith
+
+ ---
+
+ [Download main form \\(CSV\\)](https://forms-designer/file-download/00000000-0000-0000-0000-000000000000)
+
+ ---
+
+ # Your payment of £250.00 was successful
+
+ ## Payment for
+
+ Payment desc
+
+ ---
+
+ ## Total amount
+
+ £250.00
+
+ ---
+
+ ## Date of payment
+
+ 2 January 2026 – 11:00:04
+
+ ---
+ `
+ )
+ })
+ })
+})
diff --git a/src/server/plugins/engine/outputFormatters/human/v1.test.ts b/src/server/plugins/engine/outputFormatters/human/v1.test.ts
index b2c4dac18..d4ebb36d8 100644
--- a/src/server/plugins/engine/outputFormatters/human/v1.test.ts
+++ b/src/server/plugins/engine/outputFormatters/human/v1.test.ts
@@ -11,133 +11,135 @@ import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControl
import { FormStatus } from '~/src/server/routes/types.js'
import definition from '~/test/form/definitions/repeat-mixed.js'
-const itemId1 = 'abc-123'
-const itemId2 = 'xyz-987'
-
-const submitResponse = {
- message: 'Submit completed',
- result: {
- files: {
- main: '00000000-0000-0000-0000-000000000000',
- repeaters: {
- pizza: '11111111-1111-1111-1111-111111111111'
+describe('v1 human formatter', () => {
+ const itemId1 = 'abc-123'
+ const itemId2 = 'xyz-987'
+
+ const submitResponse = {
+ message: 'Submit completed',
+ result: {
+ files: {
+ main: '00000000-0000-0000-0000-000000000000',
+ repeaters: {
+ pizza: '11111111-1111-1111-1111-111111111111'
+ }
}
}
}
-}
-const model = new FormModel(definition, {
- basePath: 'test'
-})
+ const model = new FormModel(definition, {
+ basePath: 'test'
+ })
-const state = {
- $$__referenceNumber: 'foobar',
- orderType: 'delivery',
- pizza: [
- {
- toppings: 'Ham',
- quantity: 2,
- itemId: itemId1
- },
- {
- toppings: 'Pepperoni',
- quantity: 1,
- itemId: itemId2
- }
- ]
-}
-
-const pageDef = definition.pages[2]
-const pageUrl = new URL('http://example.com/repeat/pizza-order/summary')
-
-const controller = new SummaryPageController(model, pageDef)
-
-const request = buildFormContextRequest({
- method: 'get',
- url: pageUrl,
- path: pageUrl.pathname,
- params: {
- path: 'pizza-order',
- slug: 'repeat'
- },
- query: {},
- app: { model }
-})
+ const state = {
+ $$__referenceNumber: 'foobar',
+ orderType: 'delivery',
+ pizza: [
+ {
+ toppings: 'Ham',
+ quantity: 2,
+ itemId: itemId1
+ },
+ {
+ toppings: 'Pepperoni',
+ quantity: 1,
+ itemId: itemId2
+ }
+ ]
+ }
-const context = model.getFormContext(request, state)
-const summaryViewModel = controller.getSummaryViewModel(request, context)
+ const pageDef = definition.pages[2]
+ const pageUrl = new URL('http://example.com/repeat/pizza-order/summary')
-const items = getFormSubmissionData(
- summaryViewModel.context,
- summaryViewModel.details
-)
+ const controller = new SummaryPageController(model, pageDef)
-describe('getPersonalisation', () => {
- it.each([
- {
- state: FormStatus.Live,
- isPreview: false
+ const request = buildFormContextRequest({
+ method: 'get',
+ url: pageUrl,
+ path: pageUrl.pathname,
+ params: {
+ path: 'pizza-order',
+ slug: 'repeat'
},
- {
- state: FormStatus.Draft,
- isPreview: true
- }
- ])('should personalise $state email', (formStatus) => {
- const body = format(context, items, model, submitResponse, formStatus)
-
- const dateNow = new Date()
- const dateExpiry = addDays(dateNow, 90)
+ query: {},
+ app: { model }
+ })
- // Check for link expiry message
- expect(body).toContain(
- `^ For security reasons, the links in this email expire at ${dateFormat(dateExpiry, 'h:mmaaa')} on ${dateFormat(dateExpiry, 'eeee d MMMM yyyy')}`
- )
+ const context = model.getFormContext(request, state)
+ const summaryViewModel = controller.getSummaryViewModel(request, context)
+
+ const items = getFormSubmissionData(
+ summaryViewModel.context,
+ summaryViewModel.details
+ )
+
+ describe('getPersonalisation', () => {
+ it.each([
+ {
+ state: FormStatus.Live,
+ isPreview: false
+ },
+ {
+ state: FormStatus.Draft,
+ isPreview: true
+ }
+ ])('should personalise $state email', (formStatus) => {
+ const body = format(context, items, model, submitResponse, formStatus)
- expect(body).toContain(
- outdent`
- ${definition.name} form received at ${dateFormat(dateNow, 'h:mmaaa')} on ${dateFormat(dateNow, 'd MMMM yyyy')}.
+ const dateNow = new Date()
+ const dateExpiry = addDays(dateNow, 90)
- ---
+ // Check for link expiry message
+ expect(body).toContain(
+ `^ For security reasons, the links in this email expire at ${dateFormat(dateExpiry, 'h:mmaaa')} on ${dateFormat(dateExpiry, 'eeee d MMMM yyyy')}`
+ )
- ## How would you like to receive your pizza?
+ expect(body).toContain(
+ outdent`
+ ${definition.name} form received at ${dateFormat(dateNow, 'h:mmaaa')} on ${dateFormat(dateNow, 'd MMMM yyyy')}.
- Delivery
+ ---
- ---
+ ## How would you like to receive your pizza?
- ## Pizza
+ Delivery
- [Download Pizza \\(CSV\\)](https://forms-designer/file-download/11111111-1111-1111-1111-111111111111)
+ ---
- ---
+ ## Pizza
- [Download main form \\(CSV\\)](https://forms-designer/file-download/00000000-0000-0000-0000-000000000000)
- `
- )
- })
+ [Download Pizza \\(CSV\\)](https://forms-designer/file-download/11111111-1111-1111-1111-111111111111)
- it('should add test warnings to preview email only', () => {
- const formStatus = {
- state: FormStatus.Draft,
- isPreview: true
- }
+ ---
- const body1 = format(context, items, model, submitResponse, {
- state: FormStatus.Live,
- isPreview: false
+ [Download main form \\(CSV\\)](https://forms-designer/file-download/00000000-0000-0000-0000-000000000000)
+ `
+ )
})
- const body2 = format(context, items, model, submitResponse, {
- state: FormStatus.Draft,
- isPreview: true
- })
+ it('should add test warnings to preview email only', () => {
+ const formStatus = {
+ state: FormStatus.Draft,
+ isPreview: true
+ }
+
+ const body1 = format(context, items, model, submitResponse, {
+ state: FormStatus.Live,
+ isPreview: false
+ })
- expect(body1).not.toContain(
- `This is a test of the ${definition.name} ${formStatus.state} form`
- )
+ const body2 = format(context, items, model, submitResponse, {
+ state: FormStatus.Draft,
+ isPreview: true
+ })
- expect(body2).toContain(
- `This is a test of the ${definition.name} ${formStatus.state} form`
- )
+ expect(body1).not.toContain(
+ `This is a test of the ${definition.name} ${formStatus.state} form`
+ )
+
+ expect(body2).toContain(
+ `This is a test of the ${definition.name} ${formStatus.state} form`
+ )
+ })
})
})
diff --git a/src/server/plugins/engine/outputFormatters/human/v1.ts b/src/server/plugins/engine/outputFormatters/human/v1.ts
index d83bcebc6..9e2c30174 100644
--- a/src/server/plugins/engine/outputFormatters/human/v1.ts
+++ b/src/server/plugins/engine/outputFormatters/human/v1.ts
@@ -7,10 +7,18 @@ import { addDays, format as dateFormat } from 'date-fns'
import { config } from '~/src/config/index.js'
import { getAnswer } from '~/src/server/plugins/engine/components/helpers/components.js'
import { escapeMarkdown } from '~/src/server/plugins/engine/components/helpers/index.js'
+import { PaymentField } from '~/src/server/plugins/engine/components/index.js'
import { type checkFormStatus } from '~/src/server/plugins/engine/helpers.js'
import { type FormModel } from '~/src/server/plugins/engine/models/index.js'
-import { type DetailItem } from '~/src/server/plugins/engine/models/types.js'
+import {
+ type DetailItem,
+ type DetailItemField
+} from '~/src/server/plugins/engine/models/types.js'
import { type FormContext } from '~/src/server/plugins/engine/types.js'
+import {
+ formatPaymentAmount,
+ formatPaymentDate
+} from '~/src/server/plugins/payment/helper.js'
const designerUrl = config.get('designerUrl')
@@ -49,7 +57,10 @@ export function format(
lines.push(`${formName} form received at ${escapeMarkdown(formattedNow)}.\n`)
lines.push('---\n')
- items.forEach((item) => {
+ const regularItems = items.filter((item) => !isPaymentItem(item))
+ const paymentItems = items.filter((item) => isPaymentItem(item))
+
+ regularItems.forEach((item) => {
const label = escapeMarkdown(item.label)
lines.push(`## ${label}\n`)
@@ -73,5 +84,53 @@ export function format(
const filename = escapeMarkdown('Download main form (CSV)')
lines.push(`[${filename}](${designerUrl}/file-download/${files.main})\n`)
+ appendPaymentSection(paymentItems, lines)
+
return lines.join('\n')
}
+
+/**
+ * Check if an item is a PaymentField
+ */
+function isPaymentItem(item: DetailItem): boolean {
+ if ('subItems' in item) {
+ return false
+ }
+ return item.field instanceof PaymentField
+}
+
+/**
+ * Appends the payment details section to the email lines if payment exists
+ */
+function appendPaymentSection(paymentItems: DetailItem[], lines: string[]) {
+ if (paymentItems.length === 0) {
+ return
+ }
+
+ const paymentItem = paymentItems[0] as DetailItemField
+ const paymentField = paymentItem.field as PaymentField
+ const paymentState = paymentField.getPaymentStateFromState(paymentItem.state)
+
+ if (!paymentState) {
+ return
+ }
+
+ const formattedAmount = formatPaymentAmount(paymentState.amount)
+ const dateOfPayment = paymentState.preAuth?.createdAt
+ ? formatPaymentDate(paymentState.preAuth.createdAt)
+ : ''
+
+ lines.push(
+ '---\n',
+ `# Your payment of ${formattedAmount} was successful\n`,
+ '## Payment for\n',
+ `${escapeMarkdown(paymentState.description)}\n`,
+ '---\n',
+ '## Total amount\n',
+ `${formattedAmount}\n`,
+ '---\n',
+ '## Date of payment\n',
+ `${escapeMarkdown(dateOfPayment)}\n`,
+ '---\n'
+ )
+}
diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts b/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts
new file mode 100644
index 000000000..b815bcebe
--- /dev/null
+++ b/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts
@@ -0,0 +1,113 @@
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+
+import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js'
+import { FormModel } from '~/src/server/plugins/engine/models/index.js'
+import { format } from '~/src/server/plugins/engine/outputFormatters/machine/v2.js'
+import {
+ SummaryPageController,
+ getFormSubmissionData
+} from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js'
+import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js'
+import { FormStatus } from '~/src/server/routes/types.js'
+import definition from '~/test/form/definitions/payment.js'
+
+const submitResponse = {
+ message: 'Submit completed',
+ result: {
+ files: {
+ main: '00000000-0000-0000-0000-000000000000',
+ repeaters: {
+ pizza: '11111111-1111-1111-1111-111111111111'
+ }
+ }
+ }
+}
+
+const model = new FormModel(definition, {
+ basePath: 'test'
+})
+
+const formStatus = {
+ isPreview: false,
+ state: FormStatus.Live
+}
+
+const state = {
+ $$__referenceNumber: 'foobar',
+ licenceLength: 365,
+ fullName: 'John Smith',
+ paymentField: {
+ paymentId: 'payment-id',
+ reference: 'payment-ref',
+ amount: 250,
+ description: 'Payment desc',
+ uuid: 'uuid',
+ formId: 'form-id',
+ isLivePayment: false,
+ preAuth: {
+ status: 'success',
+ createdAt: '2026-01-02T11:00:04+0000'
+ }
+ } as PaymentState
+}
+
+const pageUrl = new URL('http://example.com/repeat/pizza-order/summary')
+
+const request = buildFormContextRequest({
+ method: 'get',
+ url: pageUrl,
+ path: pageUrl.pathname,
+ params: {
+ path: 'summary',
+ slug: 'payment'
+ },
+ query: {},
+ app: { model }
+})
+
+const context = model.getFormContext(request, state)
+
+const pageDef = definition.pages[2]
+
+const controller = new SummaryPageController(model, pageDef)
+
+const summaryViewModel = controller.getSummaryViewModel(request, context)
+
+const items = getFormSubmissionData(
+ summaryViewModel.context,
+ summaryViewModel.details
+)
+
+describe('getPersonalisation', () => {
+ it('should return the machine output', () => {
+ model.def = definition
+
+ const body = format(context, items, model, submitResponse, formStatus)
+
+ const parsedBody = JSON.parse(body)
+
+ const expectedData = {
+ main: {
+ licenceLength: 365,
+ fullName: 'John Smith'
+ },
+ payments: {
+ paymentField: {
+ amount: 250,
+ createdAt: '2026-01-02T11:00:04+0000',
+ description: 'Payment desc',
+ paymentId: 'payment-id',
+ reference: 'payment-ref'
+ }
+ },
+ repeaters: {},
+ files: {}
+ }
+
+ expect(parsedBody.meta.schemaVersion).toBe('2')
+ expect(parsedBody.meta.timestamp).toBeDateString()
+ expect(parsedBody.meta.definition).toEqual(definition)
+ expect(parsedBody.meta.referenceNumber).toBe('foobar')
+ expect(parsedBody.data).toEqual(expectedData)
+ })
+})
diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.test.ts b/src/server/plugins/engine/outputFormatters/machine/v2.test.ts
index 78703fc3d..e53458581 100644
--- a/src/server/plugins/engine/outputFormatters/machine/v2.test.ts
+++ b/src/server/plugins/engine/outputFormatters/machine/v2.test.ts
@@ -233,6 +233,7 @@ describe('getPersonalisation', () => {
exampleField: 'hello world',
exampleField2: 'hello world'
},
+ payments: {},
repeaters: {
exampleRepeat: [
{
@@ -291,6 +292,7 @@ describe('getPersonalisation', () => {
main: {
orderType: 'delivery'
},
+ payments: {},
repeaters: {
pizza: [
{
diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.ts b/src/server/plugins/engine/outputFormatters/machine/v2.ts
index 6b7ccbe22..710ee289d 100644
--- a/src/server/plugins/engine/outputFormatters/machine/v2.ts
+++ b/src/server/plugins/engine/outputFormatters/machine/v2.ts
@@ -1,7 +1,10 @@
import { type SubmitResponsePayload } from '@defra/forms-model'
import { config } from '~/src/config/index.js'
-import { FileUploadField } from '~/src/server/plugins/engine/components/index.js'
+import {
+ FileUploadField,
+ PaymentField
+} from '~/src/server/plugins/engine/components/index.js'
import { type checkFormStatus } from '~/src/server/plugins/engine/helpers.js'
import { type FormModel } from '~/src/server/plugins/engine/models/index.js'
import {
@@ -16,6 +19,18 @@ import {
type RichFormValue
} from '~/src/server/plugins/engine/types.js'
+/**
+ * Payment data for the machine output format
+ * Defined locally to avoid circular dependency with types.ts
+ */
+interface PaymentOutput {
+ paymentId: string
+ reference: string
+ amount: number
+ description: string
+ createdAt: string
+}
+
const designerUrl = config.get('designerUrl')
export function format(
@@ -71,6 +86,15 @@ export function format(
* userDownloadLink: 'https://forms-designer/file-download/123-456-789'
* }
* ]
+ * },
+ * payments: {
+ * paymentComponentName: {
+ * paymentId: 'abc123',
+ * reference: 'REF-123',
+ * amount: 10.00,
+ * description: 'Application fee',
+ * createdAt: '2025-01-23T10:30:00.000Z'
+ * }
* }
* }
*/
@@ -82,7 +106,8 @@ export function categoriseData(items: DetailItem[]) {
string,
{ fileId: string; fileName: string; userDownloadLink: string }[]
>
- } = { main: {}, repeaters: {}, files: {} }
+ payments: Record
+ } = { main: {}, repeaters: {}, files: {}, payments: {} }
items.forEach((item) => {
const { name, state } = item
@@ -91,6 +116,11 @@ export function categoriseData(items: DetailItem[]) {
output.repeaters[name] = extractRepeaters(item)
} else if (isFileUploadFieldItem(item)) {
output.files[name] = extractFileUploads(item)
+ } else if (isPaymentFieldItem(item)) {
+ const payment = extractPayment(item)
+ if (payment) {
+ output.payments[name] = payment
+ }
} else {
output.main[name] = item.field.getFormValueFromState(state)
}
@@ -148,3 +178,32 @@ function isFileUploadFieldItem(
): item is FileUploadFieldDetailitem {
return item.field instanceof FileUploadField
}
+
+function isPaymentFieldItem(item: DetailItemField): item is DetailItemField & {
+ field: PaymentField
+} {
+ return item.field instanceof PaymentField
+}
+
+/**
+ * Returns the "payments" section of the response body
+ * @param item - the payment item in the form
+ * @returns the payment data
+ */
+function extractPayment(
+ item: DetailItemField & { field: PaymentField }
+): PaymentOutput | undefined {
+ const paymentState = item.field.getPaymentStateFromState(item.state)
+
+ if (!paymentState) {
+ return undefined
+ }
+
+ return {
+ paymentId: paymentState.paymentId,
+ reference: paymentState.reference,
+ amount: paymentState.amount,
+ description: paymentState.description,
+ createdAt: paymentState.preAuth?.createdAt ?? ''
+ }
+}
diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts
index 880df645c..217878c3d 100644
--- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts
+++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts
@@ -15,12 +15,14 @@ import { type ValidationErrorItem } from 'joi'
import {
COMPONENT_STATE_ERROR,
EXTERNAL_STATE_APPENDAGE,
- EXTERNAL_STATE_PAYLOAD
+ EXTERNAL_STATE_PAYLOAD,
+ PAYMENT_EXPIRED_NOTIFICATION
} from '~/src/server/constants.js'
import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
import { optionalText } from '~/src/server/plugins/engine/components/constants.js'
import { type BackLink } from '~/src/server/plugins/engine/components/types.js'
import {
+ checkFormStatus,
getCacheService,
getErrors,
getSaveAndExitHelpers,
@@ -47,6 +49,7 @@ import {
import { getComponentsByType } from '~/src/server/plugins/engine/validationHelpers.js'
import {
FormAction,
+ FormStatus,
type FormRequest,
type FormRequestPayload,
type FormRequestPayloadRefs,
@@ -437,6 +440,12 @@ export class QuestionPageController extends PageController {
viewModel.errors = (viewModel.errors ?? []).concat(flashedErrors)
+ const paymentExpiredFlash = request.yar.flash(
+ PAYMENT_EXPIRED_NOTIFICATION
+ )
+ viewModel.showPaymentExpiredNotification =
+ !Array.isArray(paymentExpiredFlash)
+
/**
* Content components can be hidden based on a condition. If the condition evaluates to true, it is safe to be kept, otherwise discard it
*/
@@ -616,11 +625,17 @@ export class QuestionPageController extends PageController {
// Clear any previous state appendage
request.yar.clear(EXTERNAL_STATE_APPENDAGE)
+ // Determine if this is a live form (not preview/draft)
+ const { state, isPreview } = checkFormStatus(request.params)
+ const isLive = state === FormStatus.Live
+
return await selectedComponent.dispatcher(request, h, {
component,
controller: this,
sourceUrl: request.url.toString(),
- actionArgs: args
+ actionArgs: args,
+ isLive,
+ isPreview
})
}
diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts
index 8507b9731..0eafd3333 100644
--- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts
+++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts
@@ -7,9 +7,12 @@ import {
import Boom from '@hapi/boom'
import { type RouteOptions } from '@hapi/hapi'
-import { COMPONENT_STATE_ERROR } from '~/src/server/constants.js'
+import {
+ COMPONENT_STATE_ERROR,
+ PAYMENT_EXPIRED_NOTIFICATION
+} from '~/src/server/constants.js'
import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
-import { getAnswer } from '~/src/server/plugins/engine/components/helpers/components.js'
+import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
import {
checkEmailAddressForLiveFormSubmission,
checkFormStatus,
@@ -22,15 +25,28 @@ import {
} from '~/src/server/plugins/engine/models/index.js'
import {
type Detail,
- type DetailItem
+ type DetailItem,
+ type DetailItemField
} from '~/src/server/plugins/engine/models/types.js'
import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js'
-import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js'
+import {
+ InvalidComponentStateError,
+ PostPaymentSubmissionError
+} from '~/src/server/plugins/engine/pageControllers/errors.js'
+import {
+ buildMainRecords,
+ buildRepeaterRecords
+} from '~/src/server/plugins/engine/pageControllers/helpers/submission.js'
import {
type FormConfirmationState,
type FormContext,
type FormContextRequest
} from '~/src/server/plugins/engine/types.js'
+import {
+ DEFAULT_PAYMENT_HELP_URL,
+ formatPaymentAmount,
+ formatPaymentDate
+} from '~/src/server/plugins/payment/helper.js'
import {
FormAction,
type FormRequest,
@@ -65,11 +81,25 @@ export class SummaryPageController extends QuestionPageController {
const viewModel = new SummaryViewModel(request, this, context)
const { query } = request
- const { payload, errors } = context
+ const { payload, errors, state } = context
+
+ const paymentField = context.relevantPages
+ .flatMap((page) => page.collection.fields)
+ .find((field): field is PaymentField => field instanceof PaymentField)
+
+ if (paymentField) {
+ const paymentState = paymentField.getPaymentStateFromState(state)
+ if (paymentState) {
+ viewModel.paymentState = paymentState
+ viewModel.paymentDetails = this.buildPaymentDetails(
+ paymentField,
+ paymentState
+ )
+ }
+ }
+
const components = this.collection.getViewModel(payload, errors, query)
- // We already figure these out in the base page controller. Take them and apply them to our page-specific model.
- // This is a stop-gap until we can add proper inheritance in place.
viewModel.backLink = this.getBackLink(request, context)
viewModel.feedbackLink = this.feedbackLink
viewModel.phaseTag = this.phaseTag
@@ -80,6 +110,40 @@ export class SummaryPageController extends QuestionPageController {
return viewModel
}
+ private buildPaymentDetails(
+ paymentField: PaymentField,
+ paymentState: NonNullable<
+ ReturnType
+ >
+ ) {
+ const rows = [
+ {
+ key: { text: 'Payment for' },
+ value: { text: paymentState.description }
+ },
+ {
+ key: { text: 'Total amount' },
+ value: { text: formatPaymentAmount(paymentState.amount) }
+ },
+ {
+ key: { text: 'Reference' },
+ value: { text: paymentState.reference }
+ }
+ ]
+
+ if (paymentState.preAuth?.createdAt) {
+ rows.push({
+ key: { text: 'Date of payment' },
+ value: { text: formatPaymentDate(paymentState.preAuth.createdAt) }
+ })
+ }
+
+ return {
+ title: { text: 'Payment details' },
+ summaryList: { rows }
+ }
+ }
+
/**
* Returns an async function. This is called in plugin.ts when there is a GET request at `/{id}/{path*}`,
*/
@@ -110,7 +174,6 @@ export class SummaryPageController extends QuestionPageController {
context: FormContext,
h: FormResponseToolkit
) => {
- // Check if this is a save-and-exit action
const { action } = request.payload
if (action === FormAction.SaveAndExit) {
return this.handleSaveAndExit(request, context, h)
@@ -133,14 +196,12 @@ export class SummaryPageController extends QuestionPageController {
const { formsService } = this.model.services
const { getFormMetadata } = formsService
- // Get the form metadata using the `slug` param
const formMetadata = await getFormMetadata(params.slug)
const { notificationEmail } = formMetadata
const { isPreview } = checkFormStatus(request.params)
checkEmailAddressForLiveFormSubmission(notificationEmail, isPreview)
- // Send submission email
if (notificationEmail) {
const viewModel = this.getSummaryViewModel(request, context)
@@ -155,20 +216,7 @@ export class SummaryPageController extends QuestionPageController {
formMetadata
)
} catch (error) {
- if (error instanceof InvalidComponentStateError) {
- const govukError = createError(
- error.component.name,
- error.userMessage
- )
-
- request.yar.flash(COMPONENT_STATE_ERROR, govukError, true)
-
- await cacheService.resetComponentStates(request, error.getStateKeys())
-
- return this.proceed(request, h, error.component.page?.path)
- }
-
- throw error
+ return this.handleSubmissionError(error, request, h)
}
}
@@ -177,12 +225,80 @@ export class SummaryPageController extends QuestionPageController {
formId: context.state.formId
} as FormConfirmationState)
- // Clear all form data
await cacheService.clearState(request)
return this.proceed(request, h, this.getStatusPath())
}
+ /**
+ * Handles errors during form submission
+ */
+ private async handleSubmissionError(
+ error: unknown,
+ request: FormRequestPayload,
+ h: FormResponseToolkit
+ ) {
+ if (error instanceof InvalidComponentStateError) {
+ return this.handleInvalidComponentStateError(error, request, h)
+ }
+
+ if (error instanceof PostPaymentSubmissionError) {
+ return this.handlePostPaymentSubmissionError(error, request, h)
+ }
+
+ throw error
+ }
+
+ /**
+ * Handles InvalidComponentStateError during submission
+ */
+ private async handleInvalidComponentStateError(
+ error: InvalidComponentStateError,
+ request: FormRequestPayload,
+ h: FormResponseToolkit
+ ) {
+ const cacheService = getCacheService(request.server)
+
+ if (error.shouldResetState) {
+ await cacheService.resetComponentStates(request, error.getStateKeys())
+
+ if (error.isPaymentExpired) {
+ request.yar.flash(PAYMENT_EXPIRED_NOTIFICATION, true, true)
+ return this.proceed(request, h, error.component.page?.path)
+ }
+ }
+
+ const govukError = createError(error.component.name, error.userMessage)
+ request.yar.flash(COMPONENT_STATE_ERROR, govukError, true)
+
+ const redirectPath = error.shouldResetState
+ ? error.component.page?.path
+ : undefined
+
+ return this.proceed(request, h, redirectPath)
+ }
+
+ /**
+ * Handles PostPaymentSubmissionError during submission
+ */
+ private handlePostPaymentSubmissionError(
+ error: PostPaymentSubmissionError,
+ request: FormRequestPayload,
+ h: FormResponseToolkit
+ ) {
+ const helpUrl = error.helpLink ?? DEFAULT_PAYMENT_HELP_URL
+ const helpLinkHtml = ` or you can contact us (opens in new tab) and quote your reference number to arrange a refund`
+
+ const govukError = createError(
+ 'submission',
+ `There was a problem and your form was not submitted. Try submitting the form again${helpLinkHtml}.`
+ )
+
+ request.yar.flash(COMPONENT_STATE_ERROR, govukError, true)
+
+ return this.proceed(request, h)
+ }
+
get postRouteOptions(): RouteOptions {
return {
ext: {
@@ -207,39 +323,66 @@ export async function submitForm(
) {
await finaliseComponents(request, metadata, context)
+ const paymentWasCaptured = hasPaymentBeenCaptured(context)
+
const formStatus = checkFormStatus(request.params)
const logTags = ['submit', 'submissionApi']
request.logger.info(logTags, 'Preparing email', formStatus)
- // Get detail items
const items = getFormSubmissionData(
summaryViewModel.context,
summaryViewModel.details
)
- // Submit data
- request.logger.info(logTags, 'Submitting data')
- const submitResponse = await submitData(
- model,
- items,
- emailAddress,
- request.yar.id
- )
+ try {
+ request.logger.info(logTags, 'Submitting data')
+ const submitResponse = await submitData(
+ model,
+ items,
+ emailAddress,
+ request.yar.id
+ )
+
+ if (submitResponse === undefined) {
+ throw Boom.badRequest('Unexpected empty response from submit api')
+ }
- if (submitResponse === undefined) {
- throw Boom.badRequest('Unexpected empty response from submit api')
+ await model.services.outputService.submit(
+ context,
+ request,
+ model,
+ emailAddress,
+ items,
+ submitResponse,
+ formMetadata
+ )
+ } catch (err) {
+ if (paymentWasCaptured) {
+ throw new PostPaymentSubmissionError(
+ context.referenceNumber,
+ formMetadata.contact?.online?.url
+ )
+ }
+ throw err
}
+}
- return model.services.outputService.submit(
- context,
- request,
- model,
- emailAddress,
- items,
- submitResponse,
- formMetadata
- )
+/**
+ * Checks if any payment component has been captured
+ */
+function hasPaymentBeenCaptured(context: FormContext): boolean {
+ for (const page of context.relevantPages) {
+ for (const field of page.collection.fields) {
+ if (field instanceof PaymentField) {
+ const paymentState = field.getPaymentStateFromState(context.state)
+ if (paymentState?.capture?.status === 'success') {
+ return true
+ }
+ }
+ }
+ }
+ return false
}
/**
@@ -279,43 +422,50 @@ function submitData(
const payload: SubmitPayload = {
sessionId,
retrievalKey,
-
- // Main form answers
- main: items
- .filter((item) => 'field' in item)
- .map((item) => ({
- name: item.name,
- title: item.label,
- value: getAnswer(item.field, item.state, { format: 'data' })
- })),
-
- // Repeater form answers
- repeaters: items
- .filter((item) => 'subItems' in item)
- .map((item) => ({
- name: item.name,
- title: item.label,
-
- // Repeater item values
- value: item.subItems.map((detailItems) =>
- detailItems.map((subItem) => ({
- name: subItem.name,
- title: subItem.label,
- value: getAnswer(subItem.field, subItem.state, { format: 'data' })
- }))
- )
- }))
+ main: buildMainRecords(items),
+ repeaters: buildRepeaterRecords(items)
}
return submit(payload)
}
export function getFormSubmissionData(context: FormContext, details: Detail[]) {
- return context.relevantPages
+ const items = context.relevantPages
.map(({ href }) =>
details.flatMap(({ items }) =>
items.filter(({ page }) => page.href === href)
)
)
.flat()
+
+ const paymentItems = getPaymentFieldItems(context)
+
+ return [...items, ...paymentItems]
+}
+
+/**
+ * Gets DetailItems for PaymentField components
+ * PaymentField is excluded from summaryDetails for UI but needs to be in submission data
+ */
+function getPaymentFieldItems(context: FormContext): DetailItemField[] {
+ const items: DetailItemField[] = []
+
+ for (const page of context.relevantPages) {
+ for (const field of page.collection.fields) {
+ if (field instanceof PaymentField) {
+ items.push({
+ name: field.name,
+ page,
+ title: field.title,
+ label: field.label,
+ field,
+ state: context.state,
+ href: page.href,
+ value: field.getDisplayStringFromState(context.state)
+ })
+ }
+ }
+ }
+
+ return items
}
diff --git a/src/server/plugins/engine/pageControllers/errors.test.ts b/src/server/plugins/engine/pageControllers/errors.test.ts
index f74709180..c0953627e 100644
--- a/src/server/plugins/engine/pageControllers/errors.test.ts
+++ b/src/server/plugins/engine/pageControllers/errors.test.ts
@@ -3,7 +3,10 @@ import { ComponentType } from '@defra/forms-model'
import { FileUploadField } from '~/src/server/plugins/engine/components/FileUploadField.js'
import { TextField } from '~/src/server/plugins/engine/components/TextField.js'
import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
-import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js'
+import {
+ InvalidComponentStateError,
+ PostPaymentSubmissionError
+} from '~/src/server/plugins/engine/pageControllers/errors.js'
import definition from '~/test/form/definitions/file-upload-basic.js'
describe('InvalidComponentStateError', () => {
@@ -63,4 +66,16 @@ describe('InvalidComponentStateError', () => {
expect(stateKeys).toEqual(['textField'])
})
})
+
+ describe('PostPaymentSubmissionError', () => {
+ it('should instantiate', () => {
+ const error = new PostPaymentSubmissionError(
+ 'reference-number',
+ '/help-link'
+ )
+ expect(error).toBeDefined()
+ expect(error.referenceNumber).toBe('reference-number')
+ expect(error.helpLink).toBe('/help-link')
+ })
+ })
})
diff --git a/src/server/plugins/engine/pageControllers/errors.ts b/src/server/plugins/engine/pageControllers/errors.ts
index c96fb76f7..92167af79 100644
--- a/src/server/plugins/engine/pageControllers/errors.ts
+++ b/src/server/plugins/engine/pageControllers/errors.ts
@@ -1,5 +1,38 @@
import { type FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'
+/**
+ * Thrown when form submission fails after payment has been captured.
+ * User needs to retry or contact support for a refund.
+ */
+export class PostPaymentSubmissionError extends Error {
+ public readonly referenceNumber: string
+ public readonly helpLink?: string
+
+ constructor(referenceNumber: string, helpLink?: string) {
+ super('Form submission failed after payment capture')
+ this.name = 'PostPaymentSubmissionError'
+ this.referenceNumber = referenceNumber
+ this.helpLink = helpLink
+ }
+}
+
+export interface InvalidComponentStateErrorOptions {
+ /**
+ * Whether to reset the component state and redirect to the component's page.
+ * - `true`: Reset state and redirect (e.g., payment expired - user must re-enter)
+ * - `false`: Keep state and stay on current page with error (e.g., capture failed - user can retry)
+ * @default true
+ */
+ shouldResetState?: boolean
+
+ /**
+ * Whether this error is due to payment expiry.
+ * When true, an "Important" notification banner will be shown on the payment page.
+ * @default false
+ */
+ isPaymentExpired?: boolean
+}
+
/**
* Thrown when a component has an invalid state. This is typically only required where state needs
* to be checked against an external source upon submission of a form. For example: file upload
@@ -11,13 +44,21 @@ import { type FormComponent } from '~/src/server/plugins/engine/components/FormC
export class InvalidComponentStateError extends Error {
public readonly component: FormComponent
public readonly userMessage: string
+ public readonly shouldResetState: boolean
+ public readonly isPaymentExpired: boolean
- constructor(component: FormComponent, userMessage: string) {
+ constructor(
+ component: FormComponent,
+ userMessage: string,
+ options: InvalidComponentStateErrorOptions = {}
+ ) {
const message = `Invalid component state for: ${component.name}`
super(message)
this.name = 'InvalidComponentStateError'
this.component = component
this.userMessage = userMessage
+ this.shouldResetState = options.shouldResetState ?? true
+ this.isPaymentExpired = options.isPaymentExpired ?? false
}
getStateKeys() {
diff --git a/src/server/plugins/engine/pageControllers/helpers/submission.test.ts b/src/server/plugins/engine/pageControllers/helpers/submission.test.ts
new file mode 100644
index 000000000..62688bd85
--- /dev/null
+++ b/src/server/plugins/engine/pageControllers/helpers/submission.test.ts
@@ -0,0 +1,299 @@
+import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
+import { TextField } from '~/src/server/plugins/engine/components/TextField.js'
+import { type DetailItemField } from '~/src/server/plugins/engine/models/types.js'
+import {
+ buildMainRecords,
+ buildPaymentRecords,
+ buildRepeaterRecords
+} from '~/src/server/plugins/engine/pageControllers/helpers/submission.js'
+import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js'
+
+describe('Submission helpers', () => {
+ describe('buildPaymentRecords', () => {
+ it('should return empty array when no payment state exists', () => {
+ const mockPaymentField = Object.create(PaymentField.prototype)
+ mockPaymentField.getPaymentStateFromState = jest
+ .fn()
+ .mockReturnValue(undefined)
+
+ const item = {
+ name: 'payment',
+ label: 'Payment',
+ field: mockPaymentField,
+ state: {} as FormSubmissionState
+ } as unknown as DetailItemField
+
+ const result = buildPaymentRecords(item)
+
+ expect(result).toEqual([])
+ expect(mockPaymentField.getPaymentStateFromState).toHaveBeenCalledWith(
+ item.state
+ )
+ })
+
+ it('should return four records when payment state exists', () => {
+ const mockPaymentState = {
+ paymentId: 'pay_123',
+ description: 'Application fee',
+ amount: 150,
+ reference: 'REF-ABC-123',
+ preAuth: {
+ status: 'success',
+ createdAt: '2026-01-26T14:30:00.000Z'
+ }
+ }
+
+ const mockPaymentField = Object.create(PaymentField.prototype)
+ mockPaymentField.getPaymentStateFromState = jest
+ .fn()
+ .mockReturnValue(mockPaymentState)
+
+ const item = {
+ name: 'payment',
+ label: 'Payment',
+ field: mockPaymentField,
+ state: {} as FormSubmissionState
+ } as unknown as DetailItemField
+
+ const result = buildPaymentRecords(item)
+
+ expect(result).toHaveLength(4)
+ expect(result[0]).toEqual({
+ name: 'payment_paymentDescription',
+ title: 'Payment description',
+ value: 'Application fee'
+ })
+ expect(result[1]).toEqual({
+ name: 'payment_paymentAmount',
+ title: 'Payment amount',
+ value: '£150.00'
+ })
+ expect(result[2]).toEqual({
+ name: 'payment_paymentReference',
+ title: 'Payment reference',
+ value: 'REF-ABC-123'
+ })
+ expect(result[3].name).toBe('payment_paymentDate')
+ expect(result[3].title).toBe('Payment date')
+ // Date will be formatted, just check it's not empty
+ expect(result[3].value).not.toBe('')
+ })
+
+ it('should return empty date when preAuth.createdAt is missing', () => {
+ const mockPaymentState = {
+ paymentId: 'pay_123',
+ description: 'Application fee',
+ amount: 150,
+ reference: 'REF-ABC-123',
+ preAuth: {
+ status: 'success'
+ // createdAt is missing
+ }
+ }
+
+ const mockPaymentField = Object.create(PaymentField.prototype)
+ mockPaymentField.getPaymentStateFromState = jest
+ .fn()
+ .mockReturnValue(mockPaymentState)
+
+ const item = {
+ name: 'payment',
+ label: 'Payment',
+ field: mockPaymentField,
+ state: {} as FormSubmissionState
+ } as unknown as DetailItemField
+
+ const result = buildPaymentRecords(item)
+
+ expect(result[3]).toEqual({
+ name: 'payment_paymentDate',
+ title: 'Payment date',
+ value: ''
+ })
+ })
+ })
+
+ describe('buildMainRecords', () => {
+ it('should return empty array for empty items', () => {
+ const result = buildMainRecords([])
+ expect(result).toEqual([])
+ })
+
+ it('should process regular fields correctly', () => {
+ const mockTextField = Object.create(TextField.prototype)
+ mockTextField.getDisplayStringFromState = jest
+ .fn()
+ .mockReturnValue('John Doe')
+ mockTextField.getContextValueFromState = jest
+ .fn()
+ .mockReturnValue('John Doe')
+
+ const items = [
+ {
+ name: 'fullName',
+ label: 'Full name',
+ field: mockTextField,
+ state: { fullName: 'John Doe' } as FormSubmissionState
+ }
+ ] as unknown as DetailItemField[]
+
+ const result = buildMainRecords(items)
+
+ expect(result).toHaveLength(1)
+ expect(result[0]).toEqual({
+ name: 'fullName',
+ title: 'Full name',
+ value: 'John Doe'
+ })
+ })
+
+ it('should expand PaymentField into four records', () => {
+ const mockPaymentState = {
+ paymentId: 'pay_123',
+ description: 'Licence fee',
+ amount: 75.5,
+ reference: 'LIC-999',
+ preAuth: {
+ status: 'success',
+ createdAt: '2026-01-26T10:00:00.000Z'
+ }
+ }
+
+ const mockPaymentField = Object.create(PaymentField.prototype)
+ mockPaymentField.getPaymentStateFromState = jest
+ .fn()
+ .mockReturnValue(mockPaymentState)
+
+ const items = [
+ {
+ name: 'licencePayment',
+ label: 'Licence Payment',
+ field: mockPaymentField,
+ state: {} as FormSubmissionState
+ }
+ ] as unknown as DetailItemField[]
+
+ const result = buildMainRecords(items)
+
+ expect(result).toHaveLength(4)
+ expect(result.map((r) => r.name)).toEqual([
+ 'licencePayment_paymentDescription',
+ 'licencePayment_paymentAmount',
+ 'licencePayment_paymentReference',
+ 'licencePayment_paymentDate'
+ ])
+ })
+
+ it('should handle mixed regular and payment fields', () => {
+ const mockTextField = Object.create(TextField.prototype)
+ mockTextField.getDisplayStringFromState = jest
+ .fn()
+ .mockReturnValue('test@example.com')
+ mockTextField.getContextValueFromState = jest
+ .fn()
+ .mockReturnValue('test@example.com')
+
+ const mockPaymentState = {
+ paymentId: 'pay_456',
+ description: 'Registration fee',
+ amount: 25,
+ reference: 'REG-001',
+ preAuth: { status: 'success', createdAt: '2026-01-26T12:00:00.000Z' }
+ }
+
+ const mockPaymentField = Object.create(PaymentField.prototype)
+ mockPaymentField.getPaymentStateFromState = jest
+ .fn()
+ .mockReturnValue(mockPaymentState)
+
+ const items = [
+ {
+ name: 'email',
+ label: 'Email address',
+ field: mockTextField,
+ state: { email: 'test@example.com' } as FormSubmissionState
+ },
+ {
+ name: 'payment',
+ label: 'Payment',
+ field: mockPaymentField,
+ state: {} as FormSubmissionState
+ }
+ ] as unknown as DetailItemField[]
+
+ const result = buildMainRecords(items)
+
+ // 1 regular field + 4 payment fields = 5 records
+ expect(result).toHaveLength(5)
+ expect(result[0].name).toBe('email')
+ expect(result[1].name).toBe('payment_paymentDescription')
+ })
+
+ it('should skip repeater items (items with subItems)', () => {
+ const repeaterItem = {
+ name: 'addresses',
+ label: 'Addresses',
+ subItems: [[]]
+ }
+
+ const result = buildMainRecords([
+ repeaterItem as unknown as DetailItemField
+ ])
+
+ expect(result).toEqual([])
+ })
+ })
+
+ describe('buildRepeaterRecords', () => {
+ it('should return empty array when no repeater items', () => {
+ const mockField = Object.create(TextField.prototype)
+
+ const items = [
+ {
+ name: 'textField',
+ label: 'Text',
+ field: mockField,
+ state: {} as FormSubmissionState
+ }
+ ]
+
+ const result = buildRepeaterRecords(items as unknown as DetailItemField[])
+
+ expect(result).toEqual([])
+ })
+
+ it('should process repeater items correctly', () => {
+ const mockSubField = Object.create(TextField.prototype)
+ mockSubField.getDisplayStringFromState = jest
+ .fn()
+ .mockReturnValue('123 Main St')
+ mockSubField.getContextValueFromState = jest
+ .fn()
+ .mockReturnValue('123 Main St')
+
+ const items = [
+ {
+ name: 'addresses',
+ label: 'Addresses',
+ subItems: [
+ [
+ {
+ name: 'street',
+ label: 'Street',
+ field: mockSubField,
+ state: { street: '123 Main St' } as FormSubmissionState
+ }
+ ]
+ ]
+ }
+ ]
+
+ const result = buildRepeaterRecords(items as unknown as DetailItemField[])
+
+ expect(result).toHaveLength(1)
+ expect(result[0].name).toBe('addresses')
+ expect(result[0].title).toBe('Addresses')
+ expect(result[0].value).toHaveLength(1)
+ })
+ })
+})
diff --git a/src/server/plugins/engine/pageControllers/helpers/submission.ts b/src/server/plugins/engine/pageControllers/helpers/submission.ts
new file mode 100644
index 000000000..879b26ad2
--- /dev/null
+++ b/src/server/plugins/engine/pageControllers/helpers/submission.ts
@@ -0,0 +1,110 @@
+import { type SubmitPayload } from '@defra/forms-model'
+
+import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js'
+import { getAnswer } from '~/src/server/plugins/engine/components/helpers/components.js'
+import {
+ type DetailItem,
+ type DetailItemField
+} from '~/src/server/plugins/engine/models/types.js'
+import {
+ formatPaymentAmount,
+ formatPaymentDate
+} from '~/src/server/plugins/payment/helper.js'
+
+export interface SubmitRecord {
+ name: string
+ title: string
+ value: string
+}
+
+/**
+ * Builds the main submission records from field items.
+ * Regular fields are converted to single records, while PaymentField
+ * components are expanded into four separate records.
+ */
+export function buildMainRecords(items: DetailItem[]): SubmitRecord[] {
+ const fieldItems = items.filter(
+ (item): item is DetailItemField => 'field' in item
+ )
+
+ const records: SubmitRecord[] = []
+
+ for (const item of fieldItems) {
+ if (item.field instanceof PaymentField) {
+ records.push(...buildPaymentRecords(item))
+ } else {
+ records.push({
+ name: item.name,
+ title: item.label,
+ value: getAnswer(item.field, item.state, { format: 'data' })
+ })
+ }
+ }
+
+ return records
+}
+
+/**
+ * Expands a PaymentField into four submission records:
+ * - Payment description
+ * - Payment amount (formatted with currency symbol)
+ * - Payment reference
+ * - Payment date (formatted date/time)
+ *
+ * Returns an empty array if no payment state exists.
+ */
+export function buildPaymentRecords(item: DetailItemField): SubmitRecord[] {
+ const paymentState = (item.field as PaymentField).getPaymentStateFromState(
+ item.state
+ )
+
+ if (!paymentState) {
+ return []
+ }
+
+ return [
+ {
+ name: `${item.name}_paymentDescription`,
+ title: 'Payment description',
+ value: paymentState.description
+ },
+ {
+ name: `${item.name}_paymentAmount`,
+ title: 'Payment amount',
+ value: formatPaymentAmount(paymentState.amount)
+ },
+ {
+ name: `${item.name}_paymentReference`,
+ title: 'Payment reference',
+ value: paymentState.reference
+ },
+ {
+ name: `${item.name}_paymentDate`,
+ title: 'Payment date',
+ value: paymentState.preAuth?.createdAt
+ ? formatPaymentDate(paymentState.preAuth.createdAt)
+ : ''
+ }
+ ]
+}
+
+/**
+ * Builds the repeater submission records from repeater items.
+ */
+export function buildRepeaterRecords(
+ items: DetailItem[]
+): SubmitPayload['repeaters'] {
+ return items
+ .filter((item) => 'subItems' in item)
+ .map((item) => ({
+ name: item.name,
+ title: item.label,
+ value: item.subItems.map((detailItems) =>
+ detailItems.map((subItem) => ({
+ name: subItem.name,
+ title: subItem.label,
+ value: getAnswer(subItem.field, subItem.state, { format: 'data' })
+ }))
+ )
+ }))
+}
diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts
index 14b915bf5..ae8468fe0 100644
--- a/src/server/plugins/engine/plugin.ts
+++ b/src/server/plugins/engine/plugin.ts
@@ -10,6 +10,7 @@ import { type FormModel } from '~/src/server/plugins/engine/models/index.js'
import { validatePluginOptions } from '~/src/server/plugins/engine/options.js'
import { getRoutes as getFileUploadStatusRoutes } from '~/src/server/plugins/engine/routes/file-upload.js'
import { makeLoadFormPreHandler } from '~/src/server/plugins/engine/routes/index.js'
+import { getRoutes as getPaymentRoutes } from '~/src/server/plugins/engine/routes/payment.js'
import { getRoutes as getQuestionRoutes } from '~/src/server/plugins/engine/routes/questions.js'
import { getRoutes as getRepeaterItemDeleteRoutes } from '~/src/server/plugins/engine/routes/repeaters/item-delete.js'
import { getRoutes as getRepeaterSummaryRoutes } from '~/src/server/plugins/engine/routes/repeaters/summary.js'
@@ -37,7 +38,8 @@ export const plugin = {
viewContext,
preparePageEventRequestOptions,
onRequest,
- ordnanceSurveyApiKey
+ ordnanceSurveyApiKey,
+ baseUrl
} = options
const cacheService =
@@ -61,6 +63,7 @@ export const plugin = {
server.expose('viewContext', viewContext)
server.expose('cacheService', cacheService)
server.expose('saveAndExit', saveAndExit)
+ server.expose('baseUrl', baseUrl)
server.app.model = model
@@ -93,19 +96,21 @@ export const plugin = {
}
const routes = [
- ...getQuestionRoutes(
+ ...getPaymentRoutes(),
+ ...getFileUploadStatusRoutes(),
+ ...getRepeaterSummaryRoutes(getRouteOptions, postRouteOptions, onRequest),
+ ...getRepeaterItemDeleteRoutes(
getRouteOptions,
postRouteOptions,
- preparePageEventRequestOptions,
onRequest
),
- ...getRepeaterSummaryRoutes(getRouteOptions, postRouteOptions, onRequest),
- ...getRepeaterItemDeleteRoutes(
+
+ ...getQuestionRoutes(
getRouteOptions,
postRouteOptions,
+ preparePageEventRequestOptions,
onRequest
- ),
- ...getFileUploadStatusRoutes()
+ )
]
server.route(routes as unknown as ServerRoute[]) // TODO
diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts
index 543aa766e..f62c180f5 100644
--- a/src/server/plugins/engine/routes/index.ts
+++ b/src/server/plugins/engine/routes/index.ts
@@ -10,10 +10,7 @@ import {
EXTERNAL_STATE_PAYLOAD
} from '~/src/server/constants.js'
import { resolveFormModel } from '~/src/server/plugins/engine/beta/form-context.js'
-import {
- FormComponent,
- isFormState
-} from '~/src/server/plugins/engine/components/FormComponent.js'
+import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'
import {
checkFormStatus,
findPage,
@@ -119,6 +116,7 @@ async function importExternalComponentState(
const typedStateAppendage = externalComponentData as ExternalStateAppendage
const componentName = typedStateAppendage.component
const stateAppendage = typedStateAppendage.data
+
const component = request.app.model?.componentMap.get(componentName)
if (!component) {
@@ -137,33 +135,24 @@ async function importExternalComponentState(
throw new Error(`State for component ${componentName} is invalid`)
}
- const componentState = isFormState(stateAppendage)
- ? Object.fromEntries(
- Object.entries(stateAppendage).map(([key, value]) => [
- `${componentName}__${key}`,
- value
- ])
- )
- : { [componentName]: stateAppendage }
+ // Store component state under the component name
+ const componentState = { [componentName]: stateAppendage }
- // Save the external component state immediately
- const pageState = page.getStateFromValidForm(
- request,
- state,
- componentState as FormPayload
- )
- const savedState = await page.mergeState(request, state, pageState)
+ // Save the external component state directly (already has correct key format)
+ const savedState = await page.mergeState(request, state, componentState)
// Merge any stashed payload into the local state
const payload = request.yar.flash(EXTERNAL_STATE_PAYLOAD)
const stashedPayload = Array.isArray(payload) ? {} : (payload as FormPayload)
- const localState = page.getStateFromValidForm(request, savedState, {
- ...stashedPayload,
- ...componentState
- } as FormPayload)
+ if (Object.keys(stashedPayload).length) {
+ const localState = page.getStateFromValidForm(request, savedState, {
+ ...stashedPayload
+ } as FormPayload)
+ return { ...savedState, ...localState }
+ }
- return { ...savedState, ...localState }
+ return savedState
}
export function makeLoadFormPreHandler(server: Server, options: PluginOptions) {
diff --git a/src/server/plugins/engine/routes/payment-helper.js b/src/server/plugins/engine/routes/payment-helper.js
new file mode 100644
index 000000000..f2ce83b24
--- /dev/null
+++ b/src/server/plugins/engine/routes/payment-helper.js
@@ -0,0 +1,39 @@
+import Boom from '@hapi/boom'
+
+import { PAYMENT_SESSION_PREFIX } from '~/src/server/plugins/engine/routes/payment.js'
+import { getPaymentApiKey } from '~/src/server/plugins/payment/helper.js'
+import { PaymentService } from '~/src/server/plugins/payment/service.js'
+
+/**
+ * Validates session data and retrieves payment status
+ * @param {Request} request - the request
+ * @param {string} uuid - the payment UUID
+ * @returns {Promise<{ session: PaymentSessionData, sessionKey: string, paymentStatus: GetPaymentResponse }>}
+ */
+export async function getPaymentContext(request, uuid) {
+ const sessionKey = `${PAYMENT_SESSION_PREFIX}${uuid}`
+ const session = /** @type {PaymentSessionData | null} */ (
+ request.yar.get(sessionKey)
+ )
+
+ if (!session) {
+ throw Boom.badRequest(`No payment session found for uuid=${uuid}`)
+ }
+
+ const { paymentId, isLivePayment, formId } = session
+
+ if (!paymentId) {
+ throw Boom.badRequest('No paymentId in session')
+ }
+
+ const apiKey = getPaymentApiKey(isLivePayment, formId)
+ const paymentService = new PaymentService(apiKey)
+ const paymentStatus = await paymentService.getPaymentStatus(paymentId)
+
+ return { session, sessionKey, paymentStatus }
+}
+
+/**
+ * @import { Request } from '@hapi/hapi'
+ * @import { GetPaymentResponse, PaymentSessionData } from '~/src/server/plugins/payment/types.js'
+ */
diff --git a/src/server/plugins/engine/routes/payment-helper.test.js b/src/server/plugins/engine/routes/payment-helper.test.js
new file mode 100644
index 000000000..3a1212586
--- /dev/null
+++ b/src/server/plugins/engine/routes/payment-helper.test.js
@@ -0,0 +1,90 @@
+import { getPaymentContext } from '~/src/server/plugins/engine/routes/payment-helper.js'
+import { get } from '~/src/server/services/httpService.js'
+
+jest.mock('~/src/server/services/httpService.ts')
+
+describe('payment helper', () => {
+ const uuid = '5a54c2fe-da49-4202-8cd3-2121eaca03c3'
+ it('should throw if no session', async () => {
+ const mockRequest = {
+ yar: {
+ get: jest.fn().mockReturnValueOnce(undefined)
+ }
+ }
+ // @ts-expect-error - partial request mock
+ await expect(() => getPaymentContext(mockRequest, uuid)).rejects.toThrow(
+ 'No payment session found for uuid=5a54c2fe-da49-4202-8cd3-2121eaca03c3'
+ )
+ })
+
+ it('should throw if no payment id', async () => {
+ const mockRequest = {
+ yar: {
+ get: jest.fn().mockReturnValueOnce({})
+ }
+ }
+ // @ts-expect-error - partial request mock
+ await expect(() => getPaymentContext(mockRequest, uuid)).rejects.toThrow(
+ 'No paymentId in session'
+ )
+ })
+
+ it('should get context successfully', async () => {
+ const mockRequest = {
+ yar: {
+ get: jest.fn().mockReturnValueOnce({
+ paymentId: 'payment-id',
+ isLivePayment: false,
+ formId: 'form-id'
+ })
+ }
+ }
+
+ const getPaymentStatusApiResult = {
+ payment_id: 'payment-id-12345',
+ _links: {
+ next_url: {
+ href: 'http://next-url-href/payment'
+ }
+ },
+ state: {
+ status: 'created'
+ }
+ }
+
+ jest.mocked(get).mockResolvedValueOnce({
+ res: /** @type {IncomingMessage} */ ({
+ statusCode: 200,
+ headers: {}
+ }),
+ payload: getPaymentStatusApiResult,
+ error: undefined
+ })
+
+ // @ts-expect-error - partial request mock
+ const res = await getPaymentContext(mockRequest, uuid)
+ expect(res).toEqual({
+ paymentStatus: {
+ paymentId: 'payment-id-12345',
+ _links: {
+ next_url: {
+ href: 'http://next-url-href/payment'
+ }
+ },
+ state: {
+ status: 'created'
+ }
+ },
+ session: {
+ formId: 'form-id',
+ isLivePayment: false,
+ paymentId: 'payment-id'
+ },
+ sessionKey: 'payment-5a54c2fe-da49-4202-8cd3-2121eaca03c3'
+ })
+ })
+})
+
+/**
+ * @import { IncomingMessage } from 'node:http'
+ */
diff --git a/src/server/plugins/engine/routes/payment.js b/src/server/plugins/engine/routes/payment.js
new file mode 100644
index 000000000..5c360e38e
--- /dev/null
+++ b/src/server/plugins/engine/routes/payment.js
@@ -0,0 +1,151 @@
+import Boom from '@hapi/boom'
+import { StatusCodes } from 'http-status-codes'
+import Joi from 'joi'
+
+import { EXTERNAL_STATE_APPENDAGE } from '~/src/server/constants.js'
+import { getPaymentContext } from '~/src/server/plugins/engine/routes/payment-helper.js'
+
+export const PAYMENT_RETURN_PATH = '/payment-callback'
+export const PAYMENT_SESSION_PREFIX = 'payment-'
+
+/**
+ * Flash form component state after successful payment
+ * @param {Request} request - the request
+ * @param {PaymentSessionData} session - the session data containing payment state
+ * @param {GetPaymentResponse} paymentStatus - the payment status response from GOV.UK Pay
+ */
+function flashComponentState(request, session, paymentStatus) {
+ /** @type {PaymentState} */
+ const paymentState = {
+ paymentId: paymentStatus.paymentId,
+ reference: session.reference,
+ amount: session.amount,
+ description: session.description,
+ uuid: session.uuid,
+ formId: session.formId,
+ isLivePayment: session.isLivePayment,
+ payerEmail: paymentStatus.email,
+ preAuth: {
+ status: 'success',
+ createdAt: new Date().toISOString()
+ }
+ }
+
+ /** @type {ExternalStateAppendage} */
+ const appendage = {
+ component: session.componentName,
+ data: /** @type {FormState} */ (/** @type {unknown} */ (paymentState))
+ }
+
+ request.yar.flash(EXTERNAL_STATE_APPENDAGE, appendage, true)
+}
+
+/**
+ * Gets the payment routes for handling GOV.UK Pay callbacks
+ * @returns {ServerRoute[]}
+ */
+export function getRoutes() {
+ return [getReturnRoute()]
+}
+
+/**
+ * Handles successful payment states (capturable/success)
+ * @param {Request} request - the request
+ * @param {ResponseToolkit} h - the response toolkit
+ * @param {PaymentSessionData} session - the session data
+ * @param {string} sessionKey - the session key
+ * @param {GetPaymentResponse} paymentStatus - the payment status from GOV.UK Pay
+ */
+function handlePaymentSuccess(request, h, session, sessionKey, paymentStatus) {
+ flashComponentState(request, session, paymentStatus)
+ request.yar.clear(sessionKey)
+ return h.redirect(session.returnUrl).code(StatusCodes.SEE_OTHER)
+}
+
+/**
+ * Handles failed/cancelled/error payment states
+ * @param {Request} request - the request
+ * @param {ResponseToolkit} h - the response toolkit
+ * @param {PaymentSessionData} session - the session data
+ * @param {string} sessionKey - the session key
+ */
+function handlePaymentFailure(request, h, session, sessionKey) {
+ request.yar.clear(sessionKey)
+ return h.redirect(session.failureUrl).code(StatusCodes.SEE_OTHER)
+}
+
+/**
+ * Route handler for payment return URL
+ * This is called when GOV.UK Pay redirects the user back after payment
+ * @returns {ServerRoute}
+ */
+function getReturnRoute() {
+ return {
+ method: 'GET',
+ path: PAYMENT_RETURN_PATH,
+ async handler(request, h) {
+ const { uuid } = /** @type {{ uuid: string }} */ (request.query)
+ const { session, sessionKey, paymentStatus } = await getPaymentContext(
+ request,
+ uuid
+ )
+
+ /**
+ * @see https://docs.payments.service.gov.uk/api_reference/#payment-status-lifecycle
+ */
+ const { status } = paymentStatus.state
+
+ switch (status) {
+ case 'capturable':
+ case 'success':
+ return handlePaymentSuccess(
+ request,
+ h,
+ session,
+ sessionKey,
+ paymentStatus
+ )
+
+ case 'cancelled':
+ case 'failed':
+ case 'error':
+ return handlePaymentFailure(request, h, session, sessionKey)
+
+ case 'created':
+ case 'started':
+ case 'submitted': {
+ const nextUrl = paymentStatus._links.next_url?.href
+
+ if (!nextUrl) {
+ throw Boom.badRequest(
+ `Payment in state '${status}' but no next_url available`
+ )
+ }
+
+ return h.redirect(nextUrl).code(StatusCodes.SEE_OTHER)
+ }
+
+ default: {
+ const unknownStatus = /** @type {string} */ (status)
+ throw Boom.internal(`Unknown payment status: ${unknownStatus}`)
+ }
+ }
+ },
+ options: {
+ validate: {
+ query: Joi.object()
+ .keys({
+ uuid: Joi.string().uuid().required()
+ })
+ .required()
+ }
+ }
+ }
+}
+
+/**
+ * @import { Request, ResponseToolkit, ServerRoute } from '@hapi/hapi'
+ * @import { GetPaymentResponse, PaymentSessionData } from '~/src/server/plugins/payment/types.js'
+ * @import { PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js'
+ * @import { ExternalStateAppendage, FormState } from '~/src/server/plugins/engine/types.js'
+ */
diff --git a/src/server/plugins/engine/routes/payment.test.js b/src/server/plugins/engine/routes/payment.test.js
new file mode 100644
index 000000000..a1e077ab6
--- /dev/null
+++ b/src/server/plugins/engine/routes/payment.test.js
@@ -0,0 +1,178 @@
+import { StatusCodes } from 'http-status-codes'
+
+import { createServer } from '~/src/server/index.js'
+import { getPaymentContext } from '~/src/server/plugins/engine/routes/payment-helper.js'
+import { renderResponse } from '~/test/helpers/component-helpers.js'
+
+jest.mock('~/src/server/plugins/engine/routes/payment-helper.js')
+
+describe('Payment routes', () => {
+ /** @type {Server} */
+ let server
+
+ beforeAll(async () => {
+ server = await createServer()
+ await server.initialize()
+ })
+
+ beforeEach(() => {
+ jest.resetAllMocks()
+ })
+
+ describe('Return route /payment-callback', () => {
+ const uuid = '06a5b11e-e3e0-48a2-8ac3-56c0fcb6c20d'
+ const options = {
+ method: 'get',
+ url: `/payment-callback?uuid=${uuid}`
+ }
+
+ const paymentSessionData = {
+ uuid,
+ formId: 'form-id',
+ reference: 'form-ref-123',
+ paymentId: 'payment-id',
+ amount: 123,
+ description: 'Payment desc',
+ isLivePayment: false,
+ componentName: 'my-component',
+ returnUrl: 'http://host.com/return-url',
+ failureUrl: 'http://host.com/failure-url'
+ }
+ const sessionKey = 'session-key'
+
+ test.each([
+ { status: 'capturable', finalUrl: 'http://host.com/return-url' },
+ { status: 'success', finalUrl: 'http://host.com/return-url' },
+ { status: 'cancelled', finalUrl: 'http://host.com/failure-url' },
+ { status: 'failed', finalUrl: 'http://host.com/failure-url' },
+ { status: 'error', finalUrl: 'http://host.com/failure-url' },
+ { status: 'created', finalUrl: '/next-url' },
+ { status: 'started', finalUrl: '/next-url' },
+ { status: 'submitted', finalUrl: '/next-url' }
+ ])('should handle payment status of $row.status', async (row) => {
+ const paymentStatus = {
+ paymentId: 'new-payment-id',
+ _links: {
+ next_url: {
+ href: '/next-url',
+ method: 'get'
+ },
+ self: {
+ href: '/self',
+ method: 'get'
+ }
+ },
+ state: /** @type {PaymentResponseState} */ ({
+ status: row.status,
+ finished: true
+ })
+ }
+ jest.mocked(getPaymentContext).mockResolvedValueOnce({
+ session: paymentSessionData,
+ sessionKey,
+ paymentStatus
+ })
+ const { response } = await renderResponse(server, options)
+
+ expect(response.statusCode).toBe(StatusCodes.SEE_OTHER)
+ expect(response.headers.location).toBe(row.finalUrl)
+ })
+
+ it('should throw if nextUrl is missing', async () => {
+ const paymentStatus = {
+ paymentId: 'new-payment-id',
+ _links: {
+ next_url: {},
+ self: {
+ href: '/self',
+ method: 'get'
+ }
+ },
+ state: /** @type {PaymentResponseState} */ ({
+ status: 'created',
+ finished: true
+ })
+ }
+ jest.mocked(getPaymentContext).mockResolvedValueOnce({
+ session: paymentSessionData,
+ sessionKey,
+ // @ts-expect-error - deliberate missing element from object
+ paymentStatus
+ })
+ const { response } = await renderResponse(server, options)
+
+ expect(response.statusCode).toBe(StatusCodes.BAD_REQUEST)
+ // @ts-expect-error - error object
+ expect(response.result?.message).toBe(
+ "Payment in state 'created' but no next_url available"
+ )
+ })
+
+ it('should throw if invalid status', async () => {
+ const paymentStatus = {
+ paymentId: 'new-payment-id',
+ _links: {
+ next_url: {
+ href: '/next-url',
+ method: 'get'
+ },
+ self: {
+ href: '/self',
+ method: 'get'
+ }
+ },
+ state: {
+ status: 'invalid',
+ finished: true
+ }
+ }
+ jest.mocked(getPaymentContext).mockResolvedValueOnce({
+ session: paymentSessionData,
+ sessionKey,
+ // @ts-expect-error - deliberate invalid value which doesnt meet type
+ paymentStatus
+ })
+ const { response } = await renderResponse(server, options)
+
+ expect(response.statusCode).toBe(StatusCodes.INTERNAL_SERVER_ERROR)
+ // @ts-expect-error - error object
+ expect(response.result?.message).toBe('Unknown payment status: invalid')
+ })
+
+ it('should handle payment with email from GOV.UK Pay response', async () => {
+ const paymentStatus = {
+ paymentId: 'new-payment-id',
+ payment_id: 'new-payment-id',
+ email: 'payer@example.com',
+ _links: {
+ next_url: {
+ href: '/next-url',
+ method: 'get'
+ },
+ self: {
+ href: '/self',
+ method: 'get'
+ }
+ },
+ state: /** @type {PaymentResponseState} */ ({
+ status: 'success',
+ finished: true
+ })
+ }
+ jest.mocked(getPaymentContext).mockResolvedValueOnce({
+ session: paymentSessionData,
+ sessionKey,
+ paymentStatus
+ })
+ const { response } = await renderResponse(server, options)
+
+ expect(response.statusCode).toBe(StatusCodes.SEE_OTHER)
+ expect(response.headers.location).toBe('http://host.com/return-url')
+ })
+ })
+})
+
+/**
+ * @import { Server } from '@hapi/hapi'
+ * @import { PaymentResponseState } from '~/src/server/plugins/payment/types.js'
+ */
diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts
index 83db70bfe..073615261 100644
--- a/src/server/plugins/engine/types.ts
+++ b/src/server/plugins/engine/types.ts
@@ -15,9 +15,13 @@ import {
import { type JoiExpression, type ValidationErrorItem } from 'joi'
import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'
+import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js'
import { type UkAddressState } from '~/src/server/plugins/engine/components/UkAddressField.js'
import { type Component } from '~/src/server/plugins/engine/components/helpers/components.js'
-import { type FileUploadField } from '~/src/server/plugins/engine/components/index.js'
+import {
+ type FileUploadField,
+ type PaymentField
+} from '~/src/server/plugins/engine/components/index.js'
import {
type BackLink,
type ComponentText,
@@ -120,6 +124,7 @@ export type FormValue =
| Item['value'][]
| UploadState
| RepeatListState
+ | PaymentState
| undefined
export type FormState = Partial>
@@ -329,6 +334,7 @@ export interface FormPageViewModel extends PageViewModelBase {
hasMissingNotificationEmail?: boolean
allowSaveAndExit: boolean
showSubmitButton?: boolean
+ showPaymentExpiredNotification?: boolean
}
export interface RepeaterSummaryPageViewModel extends PageViewModelBase {
@@ -393,6 +399,8 @@ export interface ExternalArgs {
controller: QuestionPageController
sourceUrl: string
actionArgs: Record
+ isLive: boolean
+ isPreview: boolean
}
export interface PostcodeLookupExternalArgs extends ExternalArgs {
@@ -453,6 +461,14 @@ export interface FormAdapterFile {
userDownloadLink: string
}
+export interface FormAdapterPayment {
+ paymentId: string
+ reference: string
+ amount: number
+ description: string
+ createdAt: string
+}
+
export interface FormAdapterSubmissionMessageResult {
files: {
main: string
@@ -466,6 +482,13 @@ export interface FormAdapterSubmissionMessageResult {
export type FileUploadFieldDetailitem = Omit & {
field: FileUploadField
}
+
+/**
+ * A detail item specifically for payments
+ */
+export type PaymentFieldDetailItem = Omit & {
+ field: PaymentField
+}
export type RichFormValue =
| FormValue
| FormPayload
@@ -479,6 +502,7 @@ export interface FormAdapterSubmissionMessageData {
main: Record
repeaters: Record[]>
files: Record
+ payments: Record
}
export interface FormAdapterSubmissionMessagePayload {
diff --git a/src/server/plugins/engine/types/schema.test.ts b/src/server/plugins/engine/types/schema.test.ts
index a77c9257a..80b140421 100644
--- a/src/server/plugins/engine/types/schema.test.ts
+++ b/src/server/plugins/engine/types/schema.test.ts
@@ -56,7 +56,8 @@ describe('Schema validation', () => {
'http://localhost:3005/file-download/489ecc1b-a145-4618-ba5a-b4a0d5ee2dbd'
}
]
- }
+ },
+ payments: {}
}
describe('formAdapterSubmissionMessageMetaSchema', () => {
diff --git a/src/server/plugins/engine/types/schema.ts b/src/server/plugins/engine/types/schema.ts
index 203c4f1ec..3e28c8d57 100644
--- a/src/server/plugins/engine/types/schema.ts
+++ b/src/server/plugins/engine/types/schema.ts
@@ -43,6 +43,7 @@ export const formAdapterSubmissionMessageDataSchema =
Joi.object().keys({
main: Joi.object(),
repeaters: Joi.object(),
+ payments: Joi.object(),
files: Joi.object().pattern(
Joi.string(),
Joi.array().items(
diff --git a/src/server/plugins/engine/views/components/paymentfield.html b/src/server/plugins/engine/views/components/paymentfield.html
index 3a5260554..0199af6da 100644
--- a/src/server/plugins/engine/views/components/paymentfield.html
+++ b/src/server/plugins/engine/views/components/paymentfield.html
@@ -3,7 +3,6 @@
{% macro PaymentField(component) %}
{% set model = component.model %}
- {% set paymentState = model.paymentState %}
{% set amount = model.amount %}
{% set description = model.description %}
@@ -20,34 +19,15 @@ {{ model.label.text if model.label and model.label.t
You can submit the form after you have added your payment details.
Total amount:
- £{{ amount }}
+ £{{ amount }}
- {% if paymentState and paymentState.preAuth and paymentState.preAuth.status == 'success' %}
- {# Payment pre-authorised - show confirmation #}
-
- Payment ready
-
-
- Reference: {{ paymentState.reference }}
-
- {{ govukButton({
- text: "Use different payment details",
- attributes: {
- name: "action",
- value: "external-" + model.name
- },
- classes: "govuk-button--secondary govuk-!-margin-bottom-0"
- }) }}
- {% else %}
- {# No payment yet - show button to initiate #}
- {{ govukButton({
- text: "Add payment details",
- attributes: {
- name: "action",
- value: "external-" + model.name
- },
- classes: "govuk-!-margin-bottom-0"
- }) }}
- {% endif %}
+ {{ govukButton({
+ text: "Add payment details",
+ attributes: {
+ name: "action",
+ value: "external-" + model.name
+ },
+ classes: "govuk-!-margin-bottom-0"
+ }) }}
{% endmacro %}
diff --git a/src/server/plugins/engine/views/index.html b/src/server/plugins/engine/views/index.html
index c5cc8d8f3..2c142e569 100644
--- a/src/server/plugins/engine/views/index.html
+++ b/src/server/plugins/engine/views/index.html
@@ -1,6 +1,7 @@
{% extends baseLayoutPath %}
{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %}
+{% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %}
{% from "partials/components.html" import componentList with context %}
{% block content %}
@@ -10,7 +11,14 @@
{% include "partials/preview-banner.html" %}
{% endif %}
- {% if errors | length %}
+ {% if showPaymentExpiredNotification %}
+ {{ govukNotificationBanner({
+ titleText: "Important",
+ html: 'Your payment has been cancelled
Your payment details were deleted because the form was inactive for 5 days.
Add your payment details again.
'
+ }) }}
+ {% endif %}
+
+ {% if errors | length and not showPaymentExpiredNotification %}
{{ govukErrorSummary({
titleText: "There is a problem",
errorList: checkErrorTemplates(errors)
diff --git a/src/server/plugins/engine/views/summary.html b/src/server/plugins/engine/views/summary.html
index 765c52487..100c952dc 100644
--- a/src/server/plugins/engine/views/summary.html
+++ b/src/server/plugins/engine/views/summary.html
@@ -3,6 +3,7 @@
{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %}
{% from "govuk/components/summary-list/macro.njk" import govukSummaryList %}
{% from "govuk/components/button/macro.njk" import govukButton %}
+{% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %}
{% from "partials/components.html" import componentList with context %}
{% from "govuk/components/input/macro.njk" import govukInput %}
@@ -13,6 +14,14 @@
{% include "partials/preview-banner.html" %}
{% endif %}
+ {% if paymentState and paymentState.preAuth and paymentState.preAuth.status == 'success' %}
+ {{ govukNotificationBanner({
+ type: "success",
+ titleText: "Success",
+ html: "We have your payment details
Your payment is on hold. We will charge you when you submit the form.
"
+ }) }}
+ {% endif %}
+
{% if errors %}
{{ govukErrorSummary({
titleText: "There is a problem",
@@ -41,6 +50,13 @@
{% endif %}
{% endfor %}
+ {% if paymentDetails %}
+
+ {{ paymentDetails.title.text }}
+
+ {{ govukSummaryList(paymentDetails.summaryList) }}
+ {% endif %}
+