From 02f73aa9add1df2ff8ab9f0bd014294336daf92a Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 20 Jan 2026 16:41:10 +0000 Subject: [PATCH 01/70] feat(engine): add PaymentField component scaffolding for GOV.UK Pay integration --- package-lock.json | 8 +- package.json | 2 +- .../plugins/engine/components/PaymentField.ts | 154 ++++++++++++++++++ .../engine/components/PaymentField.types.ts | 55 +++++++ src/server/plugins/engine/components/index.ts | 1 + 5 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 src/server/plugins/engine/components/PaymentField.ts create mode 100644 src/server/plugins/engine/components/PaymentField.types.ts diff --git a/package-lock.json b/package-lock.json index 15f2dd736..59697058a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.597", + "@defra/forms-model": "^3.0.606", "@defra/hapi-tracing": "^1.29.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -2218,9 +2218,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.597", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.597.tgz", - "integrity": "sha512-msdGKxl4L3GxPeF4dAFyTOwGgNExAeQWh5SkuzESdzRa9IQhlDzNh6fniT7D02b6vjGbHoy8jMQ5E/gjuvyBCw==", + "version": "3.0.606", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.606.tgz", + "integrity": "sha512-Oz5Mqj/lqeiNSrCOHJ9PWap+kx4IR95oBXXrBUBqSu+LRVVfdyWp0Fm5WbAlKO63ixT2gMNjJFxiG6NxhN4tug==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", diff --git a/package.json b/package.json index fc8f99e52..72fb0dd82 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.597", + "@defra/forms-model": "^3.0.606", "@defra/hapi-tracing": "^1.29.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts new file mode 100644 index 000000000..f8d8436b1 --- /dev/null +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -0,0 +1,154 @@ +import { + type FormMetadata, + type PaymentFieldComponent +} from '@defra/forms-model' + +import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' +import { + type PaymentService, + type PaymentState +} from '~/src/server/plugins/engine/components/PaymentField.types.js' +import { + type FormContext, + type FormRequestPayload, + type FormResponseToolkit +} from '~/src/server/plugins/engine/types/index.js' +import { + type ErrorMessageTemplateList, + type FormPayload, + type FormSubmissionError, + type FormSubmissionState +} from '~/src/server/plugins/engine/types.js' + +export class PaymentField extends FormComponent { + declare options: PaymentFieldComponent['options'] + + constructor( + def: PaymentFieldComponent, + props: ConstructorParameters[1] + ) { + super(def, props) + + this.options = def.options + } + + /** + * Gets the PaymentState from form submission state + */ + getPaymentStateFromState( + state: FormSubmissionState + ): PaymentState | undefined { + const value = state[this.name] as unknown + return this.isPaymentState(value) ? value : undefined + } + + getDisplayStringFromState(state: FormSubmissionState): string { + const value = this.getPaymentStateFromState(state) + + if (!value) { + return '' + } + + return `£${value.amount.toFixed(2)} - ${value.description}` + } + + getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + const viewModel = super.getViewModel(payload, errors) + + const paymentState = this.isPaymentState(payload[this.name] as unknown) + ? (payload[this.name] as unknown as PaymentState) + : undefined + + return { + ...viewModel, + amount: this.options.amount, + description: this.options.description, + paymentState + } + } + + /** + * Type guard to check if value is PaymentState + */ + isPaymentState(value: unknown): value is PaymentState { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + + const state = value as PaymentState + return ( + typeof state.paymentId === 'string' && + typeof state.amount === 'number' && + typeof state.description === 'string' + ) + } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return PaymentField.getAllPossibleErrors() + } + + /** + * Static version of getAllPossibleErrors that doesn't require a component instance. + */ + static getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { + type: 'paymentRequired', + template: 'Complete the payment to continue' + } + ], + advancedSettingsErrors: [] + } + } + + /** + * Dispatcher for external redirect to GOV.UK Pay + * STUB - Jez to implement + */ + static dispatcher( + _request: FormRequestPayload, + _h: FormResponseToolkit, + _args: PaymentDispatcherArgs + ): Promise { + // TODO: Implement + // 1. Generate UUID token and store in session + // 2. Call paymentService.createPayment() + // 3. Redirect to GOV.UK Pay paymentUrl + return Promise.reject(new Error('PaymentField.dispatcher not implemented')) + } + + /** + * Called on form submission to capture the payment + * STUB - Jez to implement + */ + onSubmit( + _request: FormRequestPayload, + _metadata: FormMetadata, + _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() + } +} + +export interface PaymentDispatcherArgs { + controller: { + model: { + name: string + } + title: string + } + component: PaymentField + sourceUrl: string + paymentService: PaymentService +} diff --git a/src/server/plugins/engine/components/PaymentField.types.ts b/src/server/plugins/engine/components/PaymentField.types.ts new file mode 100644 index 000000000..572e66be4 --- /dev/null +++ b/src/server/plugins/engine/components/PaymentField.types.ts @@ -0,0 +1,55 @@ +/** + * Component state stored in session after pre-auth + */ +export interface PaymentState { + paymentId: string + reference: string + amount: number + description: string + capture?: { + status: 'success' | 'failed' + createdAt: string + } + preAuth?: { + status: 'success' | 'failed' | 'started' + 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/components/index.ts b/src/server/plugins/engine/components/index.ts index da04c5a62..2b621a65f 100644 --- a/src/server/plugins/engine/components/index.ts +++ b/src/server/plugins/engine/components/index.ts @@ -29,3 +29,4 @@ export { OsGridRefField } from '~/src/server/plugins/engine/components/OsGridRef export { NationalGridFieldNumberField } from '~/src/server/plugins/engine/components/NationalGridFieldNumberField.js' export { LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js' export { HiddenField } from '~/src/server/plugins/engine/components/HiddenField.js' +export { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js' From 6d004f1e7a919db162781786ff1acc99c3f5768c Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 21 Jan 2026 09:10:51 +0000 Subject: [PATCH 02/70] feat(types): add optional paymentService to Services interface --- src/server/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/server/types.ts b/src/server/types.ts index 87823c0cd..2cd6aff17 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -6,6 +6,7 @@ import { } from '@defra/forms-model' import { type Server } from '@hapi/hapi' +import { type PaymentService } from '~/src/server/plugins/engine/components/PaymentField.types.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 PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' @@ -42,6 +43,7 @@ export interface Services { formsService: FormsService formSubmissionService: FormSubmissionService outputService: OutputService + paymentService?: PaymentService } export interface RouteConfig { From f8f5b94861383fbd436492060949f334106c304b Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 21 Jan 2026 09:24:41 +0000 Subject: [PATCH 03/70] feat(forms): add payment section for unicorn breeder licence with GOV.UK Pay integration --- .../forms/register-as-a-unicorn-breeder.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/server/forms/register-as-a-unicorn-breeder.yaml b/src/server/forms/register-as-a-unicorn-breeder.yaml index fa653641d..66683758b 100644 --- a/src/server/forms/register-as-a-unicorn-breeder.yaml +++ b/src/server/forms/register-as-a-unicorn-breeder.yaml @@ -242,6 +242,20 @@ pages: content: 'Fill in this field' options: required: false + next: + - path: '/pay-for-your-licence' + - title: Pay for your licence + path: '/pay-for-your-licence' + section: section + components: + - name: licencePayment + title: Unicorn breeder licence fee + type: PaymentField + hint: You'll be redirected to GOV.UK Pay to complete your payment + options: + required: true + amount: 50 + description: Unicorn breeder annual licence fee next: - path: '/summary' conditions: From 6a45f7cb11a67e70dba47b401bce8729d489acd4 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 21 Jan 2026 09:28:25 +0000 Subject: [PATCH 04/70] feat(engine): integrate PaymentField component into component creation logic --- src/server/plugins/engine/components/helpers/components.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index d307e6517..c60b61712 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -35,6 +35,7 @@ export type Field = InstanceType< | typeof Components.UkAddressField | typeof Components.FileUploadField | typeof Components.HiddenField + | typeof Components.PaymentField > // Guidance component instances only @@ -191,6 +192,10 @@ export function createComponent( case ComponentType.HiddenField: component = new Components.HiddenField(def, options) break + + case ComponentType.PaymentField: + component = new Components.PaymentField(def, options) + break } if (typeof component === 'undefined') { From ceb55eec0be297c486ea86dd2daf3988fbee2dd4 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 21 Jan 2026 09:56:15 +0000 Subject: [PATCH 05/70] Changed dispatcher to async Created skeleton PaymentService --- .../plugins/engine/components/PaymentField.ts | 38 +++++++++++++----- .../engine/components/PaymentField.types.ts | 16 +------- .../engine/components/UkAddressField.ts | 20 +++++----- .../pageControllers/QuestionPageController.ts | 6 +-- .../plugins/engine/services/paymentService.js | 40 +++++++++++++++++++ .../plugins/engine/validationHelpers.ts | 2 +- 6 files changed, 85 insertions(+), 37 deletions(-) create mode 100644 src/server/plugins/engine/services/paymentService.js diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index f8d8436b1..592cd0a51 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -1,13 +1,14 @@ +import { randomUUID } from 'node:crypto' + import { type FormMetadata, type PaymentFieldComponent } from '@defra/forms-model' +import { StatusCodes } from 'http-status-codes' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' -import { - type PaymentService, - type PaymentState -} from '~/src/server/plugins/engine/components/PaymentField.types.js' +import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js' +import { PaymentService } from '~/src/server/plugins/engine/services/paymentService.js' import { type FormContext, type FormRequestPayload, @@ -109,16 +110,35 @@ export class PaymentField extends FormComponent { * Dispatcher for external redirect to GOV.UK Pay * STUB - Jez to implement */ - static dispatcher( - _request: FormRequestPayload, - _h: FormResponseToolkit, + static async dispatcher( + request: FormRequestPayload, + h: FormResponseToolkit, _args: PaymentDispatcherArgs ): Promise { - // TODO: Implement + const paymentService = new PaymentService() + // 1. Generate UUID token and store in session + const uuid = randomUUID() + + const data = { + uuid, + reference: 'form-ref', + description: 'payment desc', + amount: 1 + } as PaymentState + + request.yar.set(`${request.url.pathname}-payment`, data) + // 2. Call paymentService.createPayment() + const payment = await paymentService.createPayment( + 1, + 'payment desc', + uuid, + { formId: 'form-id', slug: 'slug' } + ) + // 3. Redirect to GOV.UK Pay paymentUrl - return Promise.reject(new Error('PaymentField.dispatcher not implemented')) + return h.redirect(payment.paymentUrl).code(StatusCodes.SEE_OTHER) } /** diff --git a/src/server/plugins/engine/components/PaymentField.types.ts b/src/server/plugins/engine/components/PaymentField.types.ts index 572e66be4..b78c9cf73 100644 --- a/src/server/plugins/engine/components/PaymentField.types.ts +++ b/src/server/plugins/engine/components/PaymentField.types.ts @@ -6,6 +6,7 @@ export interface PaymentState { reference: string amount: number description: string + uuid: string capture?: { status: 'success' | 'failed' createdAt: string @@ -38,18 +39,3 @@ export interface PaymentStatus { } 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/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index 66eb2aebf..f669c2593 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -284,21 +284,23 @@ export class UkAddressField extends FormComponent { ) } - static dispatcher( + static async dispatcher( request: FormRequestPayload, h: FormResponseToolkit, args: PostcodeLookupExternalArgs ) { const { controller, component } = args - return dispatch(request, h, { - formName: controller.model.name, - componentName: component.name, - componentHint: component.hint, - componentTitle: component.title || controller.title, - step: args.actionArgs.step, - sourceUrl: args.sourceUrl - }) + return Promise.resolve( + dispatch(request, h, { + formName: controller.model.name, + componentName: component.name, + componentHint: component.hint, + componentTitle: component.title || controller.title, + step: args.actionArgs.step, + sourceUrl: args.sourceUrl + }) + ) } } diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index a3ee65e62..73ef2d1b6 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -517,7 +517,7 @@ export class QuestionPageController extends PageController { const action = request.payload.action if (action?.startsWith(FormAction.External)) { - return this.dispatchExternal(request, h, context) + return await this.dispatchExternal(request, h, context) } /** @@ -551,7 +551,7 @@ export class QuestionPageController extends PageController { } } - private dispatchExternal( + private async dispatchExternal( request: FormRequestPayload, h: FormResponseToolkit, context: FormContext @@ -602,7 +602,7 @@ export class QuestionPageController extends PageController { // Clear any previous state appendage request.yar.clear(EXTERNAL_STATE_APPENDAGE) - return selectedComponent.dispatcher(request, h, { + return await selectedComponent.dispatcher(request, h, { component, controller: this, sourceUrl: request.url.toString(), diff --git a/src/server/plugins/engine/services/paymentService.js b/src/server/plugins/engine/services/paymentService.js new file mode 100644 index 000000000..270eddfda --- /dev/null +++ b/src/server/plugins/engine/services/paymentService.js @@ -0,0 +1,40 @@ +export class PaymentService { + /** + * Creates a payment request, calls the payment provider, and receives a redirect url and payment id + * from the payment provider. + * The call uses 'delayed capture' (aka pre-authorisation) to reserve the user's money in preparation for + * later taking the money with a capturePayment() call. + * @param {number} _amount - amount of the payment + * @param {string} _description - a description of the payment which will appear on the payment provider's pages + * @param {string} uuid - unique id to verify the request matches the response + * @param {{ formId: string, slug: string }} _metadata + * @returns {Promise<{ paymentId: string, paymentUrl: string }>} + */ + createPayment(_amount, _description, uuid, _metadata) { + return Promise.resolve({ + paymentId: '12345abcde', + paymentUrl: `http://pay-somthing.com?nonce=${uuid}` + }) + } + + /** + * Get the status of a payment + * @param {string} _paymentId - payment id (returned from createPayment() call) + * @returns {Promise} + */ + getPaymentStatus(_paymentId) { + return Promise.resolve(/** @type {PaymentStatus} */ ({})) + } + + /** + * Takes the money reserved by previous pre-authorisation + * @param {string} _paymentId - payment id (returned from createPayment() call) + */ + capturePayment(_paymentId) { + return Promise.resolve(true) + } +} + +/** + * @import { PaymentStatus } from '~/src/server/plugins/engine/components/PaymentField.types.js' + */ diff --git a/src/server/plugins/engine/validationHelpers.ts b/src/server/plugins/engine/validationHelpers.ts index 268cf5792..fa5fcfe9b 100644 --- a/src/server/plugins/engine/validationHelpers.ts +++ b/src/server/plugins/engine/validationHelpers.ts @@ -20,7 +20,7 @@ export interface ExternalComponent { request: FormRequestPayload, h: FormResponseToolkit, args: ExternalArgs - ): ResponseObject + ): Promise } /** From 4098209d3b3df66b82db39b1092251ee2c233aee Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 21 Jan 2026 10:20:40 +0000 Subject: [PATCH 06/70] WIP: add paymentfield.html --- .../engine/views/components/paymentfield.html | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/server/plugins/engine/views/components/paymentfield.html diff --git a/src/server/plugins/engine/views/components/paymentfield.html b/src/server/plugins/engine/views/components/paymentfield.html new file mode 100644 index 000000000..a3e40f20b --- /dev/null +++ b/src/server/plugins/engine/views/components/paymentfield.html @@ -0,0 +1,60 @@ +{% from "govuk/components/fieldset/macro.njk" import govukFieldset %} +{% from "govuk/components/warning-text/macro.njk" import govukWarningText %} +{% from "govuk/components/button/macro.njk" import govukButton %} + +{% macro PaymentField(component) %} + {% set model = component.model %} + {% set paymentState = model.paymentState %} + {% set amount = model.amount %} + {% set description = model.description %} + + {% set paymentContent %} +

{{ description }}

+ + {{ govukWarningText({ + text: "You may see a pending transaction in your bank account but you will only be charged when you submit the form.", + iconFallbackText: "Warning" + }) }} + +

You can submit the form after you have added your payment details.

+ +

Total 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" + }) }} + {% else %} + {# No payment yet - show button to initiate #} + {{ govukButton({ + text: "Add payment details", + attributes: { + name: "action", + value: "external-" + model.name + } + }) }} + {% endif %} + {% endset %} + + {{ govukFieldset({ + legend: { + text: model.label.text if model.label and model.label.text else "Payment details required", + classes: "govuk-fieldset__legend--m", + isPageHeading: false + }, + html: paymentContent + }) }} +{% endmacro %} From d44d758372649f386dcc7b4c615a847cb039a5cd Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 21 Jan 2026 11:05:18 +0000 Subject: [PATCH 07/70] feat(types): define PaymentService interface --- .../engine/components/PaymentField.types.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/server/plugins/engine/components/PaymentField.types.ts b/src/server/plugins/engine/components/PaymentField.types.ts index b78c9cf73..22c720c8c 100644 --- a/src/server/plugins/engine/components/PaymentField.types.ts +++ b/src/server/plugins/engine/components/PaymentField.types.ts @@ -39,3 +39,18 @@ export interface PaymentStatus { } 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 +} From 7cafbea9f88bca672eee3e2a39208e8727d2038d Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 21 Jan 2026 11:27:58 +0000 Subject: [PATCH 08/70] fix(PaymentField): format amount to two decimal places in view model --- src/server/plugins/engine/components/PaymentField.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index 592cd0a51..3ef4e2b83 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -60,9 +60,12 @@ export class PaymentField extends FormComponent { ? (payload[this.name] as unknown as PaymentState) : undefined + const amount = this.options.amount ?? 0 + const formattedAmount = amount.toFixed(2) + return { ...viewModel, - amount: this.options.amount, + amount: formattedAmount, description: this.options.description, paymentState } From 6d49994a051a788cb876c385b2ce9f5198bbb0b4 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 21 Jan 2026 11:59:08 +0000 Subject: [PATCH 09/70] feat(payment): Add payment field UI styling and hide Continue until payment added --- src/client/stylesheets/_payment-field.scss | 8 +++++++ src/client/stylesheets/application.scss | 1 + src/index.ts | 6 +++++- .../pageControllers/QuestionPageController.ts | 16 +++++++++++++- src/server/plugins/engine/types.ts | 1 + .../engine/views/components/paymentfield.html | 21 +++++++------------ .../plugins/engine/views/partials/form.html | 12 ++++++----- 7 files changed, 44 insertions(+), 21 deletions(-) create mode 100644 src/client/stylesheets/_payment-field.scss diff --git a/src/client/stylesheets/_payment-field.scss b/src/client/stylesheets/_payment-field.scss new file mode 100644 index 000000000..af8a043b8 --- /dev/null +++ b/src/client/stylesheets/_payment-field.scss @@ -0,0 +1,8 @@ +@use "govuk-frontend" as *; + +.app-payment-field { + background-color: govuk-colour("light-grey"); + border-top: 5px solid govuk-colour("blue"); + padding: govuk-spacing(4); + margin-bottom: govuk-spacing(6); +} diff --git a/src/client/stylesheets/application.scss b/src/client/stylesheets/application.scss index 2aa7b70f1..61f7d4c1d 100644 --- a/src/client/stylesheets/application.scss +++ b/src/client/stylesheets/application.scss @@ -3,6 +3,7 @@ @use "code"; @use "tag-env"; @use "location-fields"; +@use "payment-field"; // An example of some user-supplied styling // Not great practice but it illustrates the point diff --git a/src/index.ts b/src/index.ts index a9e1835fb..6353419ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,7 +23,11 @@ const ordnanceSurveyApiKey = config.get('ordnanceSurveyApiKey') * Main entrypoint to the application. */ async function startServer() { - const server = await createServer({ ordnanceSurveyApiKey }) + const server = await createServer({ + ordnanceSurveyApiKey, + // Enable save and exit for devserver + saveAndExit: (_request, h) => h.redirect('/') + }) await server.start() process.send?.('online') diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 73ef2d1b6..880df645c 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -182,6 +182,19 @@ export class QuestionPageController extends PageController { } } + // Check if any PaymentField component needs payment to be added + // If so, hide the submit button until payment is ready + const hasIncompletePayment = components.some(({ model }) => { + // Check if this is a PaymentField by looking for paymentState in model + if ('paymentState' in model && 'amount' in model) { + const paymentState = model.paymentState as + | { preAuth?: { status?: string } } + | undefined + return !paymentState?.preAuth?.status + } + return false + }) + return { ...viewModel, backLink: this.getBackLink(request, context), @@ -189,7 +202,8 @@ export class QuestionPageController extends PageController { showTitle, components, errors, - allowSaveAndExit: this.shouldShowSaveAndExit(request.server) + allowSaveAndExit: this.shouldShowSaveAndExit(request.server), + showSubmitButton: !hasIncompletePayment } } diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 447a5b1db..83db70bfe 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -328,6 +328,7 @@ export interface FormPageViewModel extends PageViewModelBase { errors?: FormSubmissionError[] hasMissingNotificationEmail?: boolean allowSaveAndExit: boolean + showSubmitButton?: boolean } export interface RepeaterSummaryPageViewModel extends PageViewModelBase { diff --git a/src/server/plugins/engine/views/components/paymentfield.html b/src/server/plugins/engine/views/components/paymentfield.html index a3e40f20b..3a5260554 100644 --- a/src/server/plugins/engine/views/components/paymentfield.html +++ b/src/server/plugins/engine/views/components/paymentfield.html @@ -1,4 +1,3 @@ -{% from "govuk/components/fieldset/macro.njk" import govukFieldset %} {% from "govuk/components/warning-text/macro.njk" import govukWarningText %} {% from "govuk/components/button/macro.njk" import govukButton %} @@ -8,7 +7,9 @@ {% set amount = model.amount %} {% set description = model.description %} - {% set paymentContent %} +
+

{{ model.label.text if model.label and model.label.text else "Payment details required" }}

+

{{ description }}

{{ govukWarningText({ @@ -35,7 +36,7 @@ name: "action", value: "external-" + model.name }, - classes: "govuk-button--secondary" + classes: "govuk-button--secondary govuk-!-margin-bottom-0" }) }} {% else %} {# No payment yet - show button to initiate #} @@ -44,17 +45,9 @@ attributes: { name: "action", value: "external-" + model.name - } + }, + classes: "govuk-!-margin-bottom-0" }) }} {% endif %} - {% endset %} - - {{ govukFieldset({ - legend: { - text: model.label.text if model.label and model.label.text else "Payment details required", - classes: "govuk-fieldset__legend--m", - isPageHeading: false - }, - html: paymentContent - }) }} +
{% endmacro %} diff --git a/src/server/plugins/engine/views/partials/form.html b/src/server/plugins/engine/views/partials/form.html index a2732ba39..e28dfd700 100644 --- a/src/server/plugins/engine/views/partials/form.html +++ b/src/server/plugins/engine/views/partials/form.html @@ -15,11 +15,13 @@ {% endif %}
- {{ govukButton({ - text: buttonText, - isStartButton: isStartPage, - preventDoubleClick: true - }) }} + {% if showSubmitButton !== false %} + {{ govukButton({ + text: buttonText, + isStartButton: isStartPage, + preventDoubleClick: true + }) }} + {% endif %} {% if allowSaveAndExit %} {{ govukButton({ From 6bbd76626e5638326adeea3e117a7d3ecc223fb4 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 21 Jan 2026 13:59:26 +0000 Subject: [PATCH 10/70] Added implementation for createPayment --- src/config/index.ts | 8 ++ .../plugins/engine/components/PaymentField.ts | 30 ++++-- .../plugins/engine/services/paymentService.js | 40 -------- src/server/plugins/payment/service.js | 96 +++++++++++++++++++ src/server/plugins/payment/types.js | 46 +++++++++ src/server/types.ts | 2 +- 6 files changed, 172 insertions(+), 50 deletions(-) delete mode 100644 src/server/plugins/engine/services/paymentService.js create mode 100644 src/server/plugins/payment/service.js create mode 100644 src/server/plugins/payment/types.js diff --git a/src/config/index.ts b/src/config/index.ts index 96fe12dac..7db6bab1b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -260,6 +260,14 @@ export const config = convict({ nullable: true, default: undefined, env: 'ORDNANCE_SURVEY_API_KEY' + } as SchemaObj, + + paymentProviderApiKeyTest: { + doc: 'A test API key for integrating with a payment provider', + format: String, + nullable: true, + default: undefined, + env: 'PAYMENT_PROVIDER_API_KEY_TEST' } as SchemaObj }) diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index 592cd0a51..fc2e460ed 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -8,8 +8,8 @@ import { StatusCodes } from 'http-status-codes' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js' -import { PaymentService } from '~/src/server/plugins/engine/services/paymentService.js' import { + type AnyFormRequest, type FormContext, type FormRequestPayload, type FormResponseToolkit @@ -20,6 +20,7 @@ import { type FormSubmissionError, type FormSubmissionState } from '~/src/server/plugins/engine/types.js' +import { PaymentService } from '~/src/server/plugins/payment/service.js' export class PaymentField extends FormComponent { declare options: PaymentFieldComponent['options'] @@ -113,28 +114,37 @@ export class PaymentField extends FormComponent { static async dispatcher( request: FormRequestPayload, h: FormResponseToolkit, - _args: PaymentDispatcherArgs + args: PaymentDispatcherArgs ): Promise { const paymentService = new PaymentService() // 1. Generate UUID token and store in session const uuid = randomUUID() + const { options } = args.component + const { model } = args.controller + + const state = await args.controller.getState(request) + const data = { uuid, - reference: 'form-ref', - description: 'payment desc', - amount: 1 + 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() const payment = await paymentService.createPayment( - 1, - 'payment desc', + data.amount, + data.description, uuid, - { formId: 'form-id', slug: 'slug' } + data.reference, + { formId, slug } ) // 3. Redirect to GOV.UK Pay paymentUrl @@ -164,9 +174,11 @@ export class PaymentField extends FormComponent { export interface PaymentDispatcherArgs { controller: { model: { + formId: string + basePath: string name: string } - title: string + getState: (request: AnyFormRequest) => Promise } component: PaymentField sourceUrl: string diff --git a/src/server/plugins/engine/services/paymentService.js b/src/server/plugins/engine/services/paymentService.js deleted file mode 100644 index 270eddfda..000000000 --- a/src/server/plugins/engine/services/paymentService.js +++ /dev/null @@ -1,40 +0,0 @@ -export class PaymentService { - /** - * Creates a payment request, calls the payment provider, and receives a redirect url and payment id - * from the payment provider. - * The call uses 'delayed capture' (aka pre-authorisation) to reserve the user's money in preparation for - * later taking the money with a capturePayment() call. - * @param {number} _amount - amount of the payment - * @param {string} _description - a description of the payment which will appear on the payment provider's pages - * @param {string} uuid - unique id to verify the request matches the response - * @param {{ formId: string, slug: string }} _metadata - * @returns {Promise<{ paymentId: string, paymentUrl: string }>} - */ - createPayment(_amount, _description, uuid, _metadata) { - return Promise.resolve({ - paymentId: '12345abcde', - paymentUrl: `http://pay-somthing.com?nonce=${uuid}` - }) - } - - /** - * Get the status of a payment - * @param {string} _paymentId - payment id (returned from createPayment() call) - * @returns {Promise} - */ - getPaymentStatus(_paymentId) { - return Promise.resolve(/** @type {PaymentStatus} */ ({})) - } - - /** - * Takes the money reserved by previous pre-authorisation - * @param {string} _paymentId - payment id (returned from createPayment() call) - */ - capturePayment(_paymentId) { - return Promise.resolve(true) - } -} - -/** - * @import { PaymentStatus } from '~/src/server/plugins/engine/components/PaymentField.types.js' - */ diff --git a/src/server/plugins/payment/service.js b/src/server/plugins/payment/service.js new file mode 100644 index 000000000..9689fad88 --- /dev/null +++ b/src/server/plugins/payment/service.js @@ -0,0 +1,96 @@ +import { config } from '~/src/config/index.js' +import { createLogger } from '~/src/server/common/helpers/logging/logger.js' +import { postJson } from '~/src/server/services/httpService.js' + +const PAYMENT_BASE_URL = 'https://publicapi.payments.service.gov.uk' +const PAYMENT_ENDPOINT = '/v1/payments' + +const logger = createLogger() + +export class PaymentService { + /** + * Creates a payment request, calls the payment provider, and receives a redirect url and payment id + * from the payment provider. + * The call uses 'delayed capture' (aka pre-authorisation) to reserve the user's money in preparation for + * later taking the money with a capturePayment() call. + * @param {number} amount - amount of the payment + * @param {string} description - a description of the payment which will appear on the payment provider's pages + * @param {string} uuid - unique id to verify the request matches the response + * @param {string} reference - form reference + * @param {{ formId: string, slug: string }} metadata + * @returns {Promise<{ paymentId: string, paymentUrl: string }>} + */ + async createPayment(amount, description, uuid, reference, metadata) { + const response = await this.postToPayProvider({ + amount, + description, + reference, + metadata, + return_url: `http://localhost:3009/register-as-a-unicorn-breeder/summary?uuid=${uuid}`, + delayed_capture: true + }) + + return { + paymentId: response.payment_id, + paymentUrl: response._links.next_url.href + } + } + + /** + * Get the status of a payment + * @param {string} _paymentId - payment id (returned from createPayment() call) + * @returns {Promise} + */ + getPaymentStatus(_paymentId) { + return Promise.resolve(/** @type {PaymentStatus} */ ({})) + } + + /** + * Takes the money reserved by previous pre-authorisation + * @param {string} _paymentId - payment id (returned from createPayment() call) + */ + capturePayment(_paymentId) { + return Promise.resolve(true) + } + + /** + * Send data to the Pay provider + * @param {CreatePaymentRequest} payload - data to send + */ + async postToPayProvider(payload) { + const postJsonByType = + /** @type {typeof postJson} */ (postJson) + + const apiKeyTest = config.get('paymentProviderApiKeyTest') + + try { + const response = await postJsonByType( + `${PAYMENT_BASE_URL}${PAYMENT_ENDPOINT}`, + { + payload, + headers: { + Authorization: `Bearer ${apiKeyTest}` + } + } + ) + + if (response.payload?.state.status !== 'created') { + throw new Error('Failed to create payment') + } + + return response.payload + } catch (err) { + const error = /** @type {Error} */ (err) + logger.error( + error, + `[payment] Error creating payment for form-id=${payload.metadata.formId} slug=${payload.metadata.slug} reference=${payload.reference}: ${error.message}` + ) + throw err + } + } +} + +/** + * @import { PaymentStatus } from '~/src/server/plugins/engine/components/PaymentField.types.js' + * @import { CreatePaymentRequest, CreatePaymentResponse } from '~/src/server/plugins/payment/types.js' + */ diff --git a/src/server/plugins/payment/types.js b/src/server/plugins/payment/types.js new file mode 100644 index 000000000..d3bb5ebc6 --- /dev/null +++ b/src/server/plugins/payment/types.js @@ -0,0 +1,46 @@ +/** + * Gov Uk Pay API result status + * @typedef {object} PaymentStateResult + * @property {string} status - status of payment + * @property {boolean} finished - true if payment is finished + */ + +/** + * @typedef {object} PaymentLink + * @property {string} href - url + * @property {string} method - get/post + */ + +/** + * @typedef {object} PaymentLinks + * @property {PaymentLink} self - current url + * @property {PaymentLink} next_url - next url + */ + +/** + * @typedef {object} CreatePaymentMetadata + * @property {string} formId - id of the form + * @property {string} slug - slug of the form + */ + +/** + * Gov Uk Pay create payment request + * @typedef {object} CreatePaymentRequest + * @property {number} amount - payment amount + * @property {string} reference - form reference number + * @property {string} description - payment description + * @property {string} return_url - unique payment id + * @property {CreatePaymentMetadata} metadata - custom metadata + * @property {boolean} delayed_capture - denotes pre-auth only + */ + +/** + * Gov Uk Pay create payment response + * @typedef {object} CreatePaymentResponse + * @property {Date} created_date - date of creation + * @property {PaymentStateResult} state - result state + * @property {PaymentLinks} _links - payment links + * @property {string} reference - form reference number + * @property {number} amount - payment amount + * @property {string} payment_id - unique payment id + */ diff --git a/src/server/types.ts b/src/server/types.ts index 2cd6aff17..138692861 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -6,7 +6,6 @@ import { } from '@defra/forms-model' import { type Server } from '@hapi/hapi' -import { type PaymentService } from '~/src/server/plugins/engine/components/PaymentField.types.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 PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' @@ -16,6 +15,7 @@ import { type PluginOptions, type PreparePageEventRequestOptions } from '~/src/server/plugins/engine/types.js' +import { type PaymentService } from '~/src/server/plugins/payment/service.js' import { type FormRequestPayload, type FormStatus From 393f3dab94abf8122d2d9e3dcc1a3f9ecf12c024 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 21 Jan 2026 14:01:06 +0000 Subject: [PATCH 11/70] feat(devserver): Add payment-test form for PaymentField development --- src/server/forms/payment-test.yaml | 30 +++++++++++++++++++ .../engine/services/localFormsService.js | 7 +++++ 2 files changed, 37 insertions(+) create mode 100644 src/server/forms/payment-test.yaml diff --git a/src/server/forms/payment-test.yaml b/src/server/forms/payment-test.yaml new file mode 100644 index 000000000..3f01dc2ce --- /dev/null +++ b/src/server/forms/payment-test.yaml @@ -0,0 +1,30 @@ +--- +name: Payment Test Form +declaration: "

All the answers you have provided are true to the best of your knowledge.

" +pages: + - title: A page title + path: '/pay-for-your-licence' + components: + - name: pageGuidance + type: Html + title: Guidance + content: "

Random guidance

" + options: {} + - name: licencePayment + title: Payment details required + type: PaymentField + options: + required: true + amount: 300 + description: Processing fee for your application. + next: + - path: '/summary' + - title: Summary + path: '/summary' + controller: './pages/summary.js' + components: [] + next: [] +conditions: [] +sections: [] +lists: [] +startPage: '/pay-for-your-licence' diff --git a/src/server/plugins/engine/services/localFormsService.js b/src/server/plugins/engine/services/localFormsService.js index 6dc0d2d3f..c3fbfaef4 100644 --- a/src/server/plugins/engine/services/localFormsService.js +++ b/src/server/plugins/engine/services/localFormsService.js @@ -58,5 +58,12 @@ export const formsService = async () => { slug: 'simple-form' }) + await loader.addForm('src/server/forms/payment-test.yaml', { + ...metadata, + id: 'b2c3d4e5-f6a7-8901-bcde-f01234567890', + title: 'Payment Test Form', + slug: 'payment-test' + }) + return loader.toFormsService() } From 6b6d396120f7ad0ea2f7de4b7737f3e4a0954a2e Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 21 Jan 2026 14:16:54 +0000 Subject: [PATCH 12/70] fix(PaymentField): convert payment amount to pence for payment processing --- src/server/plugins/engine/components/PaymentField.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index 772d100b5..0c2175196 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -142,8 +142,10 @@ export class PaymentField extends FormComponent { 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 payment = await paymentService.createPayment( - data.amount, + amountInPence, data.description, uuid, data.reference, From 9c316c36219b398be88981f85680d4cc2fbdfb60 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 21 Jan 2026 18:52:07 +0000 Subject: [PATCH 13/70] WIP --- .../engine/components/FormComponent.ts | 2 +- .../plugins/engine/components/PaymentField.ts | 101 +++++++++-- .../engine/components/PaymentField.types.ts | 38 ---- .../plugins/engine/models/SummaryViewModel.ts | 8 + .../pageControllers/SummaryPageController.ts | 70 +++++++- src/server/plugins/engine/plugin.ts | 19 +- src/server/plugins/engine/routes/index.ts | 37 ++-- src/server/plugins/engine/routes/payment.js | 166 ++++++++++++++++++ src/server/plugins/engine/types.ts | 2 + .../engine/views/components/paymentfield.html | 36 +--- src/server/plugins/engine/views/summary.html | 16 ++ src/server/plugins/payment/service.js | 115 ++++++++---- src/server/plugins/payment/types.js | 53 +++--- src/typings/hapi/index.d.ts | 1 + 14 files changed, 484 insertions(+), 180 deletions(-) create mode 100644 src/server/plugins/engine/routes/payment.js 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.ts b/src/server/plugins/engine/components/PaymentField.ts index 0c2175196..57c22ff37 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -5,9 +5,11 @@ 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 { type AnyFormRequest, type FormContext, @@ -17,6 +19,8 @@ import { import { type ErrorMessageTemplateList, type FormPayload, + type FormState, + type FormStateValue, type FormSubmissionError, type FormSubmissionState } from '~/src/server/plugins/engine/types.js' @@ -24,6 +28,8 @@ import { PaymentService } from '~/src/server/plugins/payment/service.js' export class PaymentField extends FormComponent { declare options: PaymentFieldComponent['options'] + declare formSchema: ObjectSchema + declare stateSchema: ObjectSchema constructor( def: PaymentFieldComponent, @@ -32,6 +38,30 @@ export class PaymentField extends FormComponent { super(def, props) this.options = def.options + + // Payment state is validated as an object with the required fields + 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(), + 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 +70,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 +118,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,7 +149,6 @@ export class PaymentField extends FormComponent { /** * Dispatcher for external redirect to GOV.UK Pay - * STUB - Jez to implement */ static async dispatcher( request: FormRequestPayload, @@ -121,38 +157,52 @@ export class PaymentField extends FormComponent { ): Promise { const paymentService = new PaymentService() - // 1. Generate UUID token and store in session + // 1. Generate UUID token 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 data = { - uuid, - reference: state.$$__referenceNumber, - description: options.description, - amount: options.amount - } as PaymentState - - request.yar.set(`${request.url.pathname}-payment`, data) + const reference = state.$$__referenceNumber as string + const amount = options.amount ?? 0 + const description = options.description ?? '' const formId = model.formId const slug = `/${model.basePath}` - // 2. Call paymentService.createPayment() + // 2. Build the return URL for GOV.UK Pay + const { baseUrl } = getPluginOptions(request.server) + const returnUrl = `${baseUrl}/payment-callback?uuid=${uuid}` + + // Build the summary URL to redirect to after payment + const summaryUrl = `${baseUrl}/${model.basePath}/summary` + + // 3. Call paymentService.createPayment() // GOV.UK Pay expects amount in pence, so multiply pounds by 100 - const amountInPence = Math.round(data.amount * 100) + const amountInPence = Math.round(amount * 100) const payment = await paymentService.createPayment( amountInPence, - data.description, - uuid, - data.reference, + description, + returnUrl, + reference, { formId, slug } ) - // 3. Redirect to GOV.UK Pay paymentUrl + // 4. Store session data for the return route to use + const sessionData: PaymentSessionData = { + uuid, + reference, + amount, + description, + paymentId: payment.paymentId, + componentName, + sourceUrl: summaryUrl + } + + request.yar.set(`payment-${uuid}`, sessionData) + + // 5. Redirect to GOV.UK Pay paymentUrl return h.redirect(payment.paymentUrl).code(StatusCodes.SEE_OTHER) } @@ -189,3 +239,16 @@ export interface PaymentDispatcherArgs { sourceUrl: string paymentService: PaymentService } + +/** + * Session data stored when dispatching to GOV.UK Pay + */ +export interface PaymentSessionData { + uuid: string + reference: string + amount: number + description: string + paymentId: string + componentName: string + sourceUrl: string +} diff --git a/src/server/plugins/engine/components/PaymentField.types.ts b/src/server/plugins/engine/components/PaymentField.types.ts index 22c720c8c..b937073f7 100644 --- a/src/server/plugins/engine/components/PaymentField.types.ts +++ b/src/server/plugins/engine/components/PaymentField.types.ts @@ -16,41 +16,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/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 8507b9731..fd0e245e1 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -9,6 +9,7 @@ import { type RouteOptions } from '@hapi/hapi' import { COMPONENT_STATE_ERROR } from '~/src/server/constants.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js' import { getAnswer } from '~/src/server/plugins/engine/components/helpers/components.js' import { checkEmailAddressForLiveFormSubmission, @@ -65,7 +66,7 @@ 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 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. @@ -77,9 +78,76 @@ export class SummaryPageController extends QuestionPageController { viewModel.allowSaveAndExit = this.shouldShowSaveAndExit(request.server) viewModel.errors = errors + // Find PaymentField and extract payment state for the summary banner + 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 + ) + } + } + return viewModel } + private buildPaymentDetails( + paymentField: PaymentField, + paymentState: NonNullable< + ReturnType + > + ) { + const formatDate = (isoString: string) => { + const date = new Date(isoString) + return ( + date.toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric' + }) + + ' – ' + + date.toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + ) + } + + const rows = [ + { + key: { text: 'Payment for' }, + value: { text: paymentState.description } + }, + { + key: { text: 'Total amount' }, + value: { text: `£${paymentState.amount}` } + }, + { + key: { text: 'Reference' }, + value: { text: paymentState.reference } + } + ] + + if (paymentState.preAuth?.createdAt) { + rows.push({ + key: { text: 'Date details were entered' }, + value: { text: formatDate(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*}`, */ 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.js b/src/server/plugins/engine/routes/payment.js new file mode 100644 index 000000000..6eb4c32ef --- /dev/null +++ b/src/server/plugins/engine/routes/payment.js @@ -0,0 +1,166 @@ +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 { PaymentService } from '~/src/server/plugins/payment/service.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 {string} paymentId - the payment id from GOV.UK Pay + */ +function flashComponentState(request, session, paymentId) { + /** @type {PaymentState} */ + const paymentState = { + paymentId, + reference: session.reference, + amount: session.amount, + description: session.description, + uuid: session.uuid, + 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()] +} + +/** + * 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 paymentService = new PaymentService() + + // 1. Get session data using the UUID as the key + 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}`) + } + + // 2. Get payment status from GOV.UK Pay + const { paymentId } = session + + if (!paymentId) { + throw Boom.badRequest('No paymentId in session') + } + + const paymentStatus = await paymentService.getPaymentStatus(paymentId) + + // 3. Handle different payment states based on GOV.UK Pay status lifecycle + // @see https://docs.payments.service.gov.uk/api_reference/#payment-status-lifecycle + const { status } = paymentStatus.state + + switch (status) { + case 'capturable': + // Pre-auth successful - flash the state and redirect back + flashComponentState(request, session, paymentId) + request.yar.clear(sessionKey) + return h.redirect(session.sourceUrl).code(StatusCodes.SEE_OTHER) + + case 'success': + // Payment already captured (shouldn't happen with delayed_capture: true) + flashComponentState(request, session, paymentId) + request.yar.clear(sessionKey) + return h.redirect(session.sourceUrl).code(StatusCodes.SEE_OTHER) + + case 'cancelled': + // User cancelled payment (P0030) + request.yar.clear(sessionKey) + // TODO: Flash an error message with paymentStatus.state.message + return h.redirect(session.sourceUrl).code(StatusCodes.SEE_OTHER) + + case 'failed': + // Payment failed - could be P0010 (rejected), P0020 (expired), P0040 (service cancelled), P0050 (provider error) + request.yar.clear(sessionKey) + // TODO: Flash an error message with paymentStatus.state.message and paymentStatus.state.code + return h.redirect(session.sourceUrl).code(StatusCodes.SEE_OTHER) + + case 'error': + // Technical error on GOV.UK Pay side - no funds taken + request.yar.clear(sessionKey) + // TODO: Flash an error message + return h.redirect(session.sourceUrl).code(StatusCodes.SEE_OTHER) + + case 'created': + case 'started': + case 'submitted': { + // User came back too early or payment still processing + // Redirect back to GOV.UK Pay to continue + const nextUrl = paymentStatus._links.next_url?.href + + if (nextUrl) { + return h.redirect(nextUrl).code(StatusCodes.SEE_OTHER) + } + + throw Boom.badRequest( + `Payment in state '${status}' but no next_url available` + ) + } + + default: { + // this should never be reached but Sonar will complain + const unknownStatus = /** @type {string} */ (status) + throw Boom.internal(`Unknown payment status: ${unknownStatus}`) + } + } + }, + options: { + validate: { + query: Joi.object() + .keys({ + uuid: Joi.string().uuid().required() + }) + .required() + } + } + } +} + +/** + * Payment session data stored when dispatching to GOV.UK Pay + * @typedef {object} PaymentSessionData + * @property {string} uuid - unique identifier for this payment attempt + * @property {string} reference - form reference number + * @property {number} amount - amount in pounds + * @property {string} description - payment description + * @property {string} paymentId - GOV.UK Pay payment ID + * @property {string} componentName - name of the PaymentField component + * @property {string} sourceUrl - URL to redirect back to after payment + */ + +/** + * @import { Request, ServerRoute } from '@hapi/hapi' + * @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/types.ts b/src/server/plugins/engine/types.ts index 83db70bfe..9a887fb6e 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -15,6 +15,7 @@ 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' @@ -120,6 +121,7 @@ export type FormValue = | Item['value'][] | UploadState | RepeatListState + | PaymentState | undefined export type FormState = Partial> diff --git a/src/server/plugins/engine/views/components/paymentfield.html b/src/server/plugins/engine/views/components/paymentfield.html index 3a5260554..673164a34 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 %} @@ -22,32 +21,13 @@

{{ model.label.text if model.label and model.label.t

Total 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/summary.html b/src/server/plugins/engine/views/summary.html index 765c52487..859509f84 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 %} +
diff --git a/src/server/plugins/payment/service.js b/src/server/plugins/payment/service.js index 9689fad88..e7eac94dd 100644 --- a/src/server/plugins/payment/service.js +++ b/src/server/plugins/payment/service.js @@ -1,32 +1,38 @@ import { config } from '~/src/config/index.js' import { createLogger } from '~/src/server/common/helpers/logging/logger.js' -import { postJson } from '~/src/server/services/httpService.js' +import { get, post, postJson } from '~/src/server/services/httpService.js' const PAYMENT_BASE_URL = 'https://publicapi.payments.service.gov.uk' const PAYMENT_ENDPOINT = '/v1/payments' const logger = createLogger() +/** + * @returns {{ Authorization: string }} + */ +function getAuthHeaders() { + const apiKey = config.get('paymentProviderApiKeyTest') + return { + Authorization: `Bearer ${apiKey}` + } +} + export class PaymentService { /** - * Creates a payment request, calls the payment provider, and receives a redirect url and payment id - * from the payment provider. - * The call uses 'delayed capture' (aka pre-authorisation) to reserve the user's money in preparation for - * later taking the money with a capturePayment() call. - * @param {number} amount - amount of the payment - * @param {string} description - a description of the payment which will appear on the payment provider's pages - * @param {string} uuid - unique id to verify the request matches the response - * @param {string} reference - form reference + * Creates a payment with delayed capture (pre-authorisation) + * @param {number} amount - in pence + * @param {string} description + * @param {string} returnUrl + * @param {string} reference * @param {{ formId: string, slug: string }} metadata - * @returns {Promise<{ paymentId: string, paymentUrl: string }>} */ - async createPayment(amount, description, uuid, reference, metadata) { + async createPayment(amount, description, returnUrl, reference, metadata) { const response = await this.postToPayProvider({ amount, description, reference, metadata, - return_url: `http://localhost:3009/register-as-a-unicorn-breeder/summary?uuid=${uuid}`, + return_url: returnUrl, delayed_capture: true }) @@ -37,40 +43,88 @@ export class PaymentService { } /** - * Get the status of a payment - * @param {string} _paymentId - payment id (returned from createPayment() call) - * @returns {Promise} + * @param {string} paymentId + * @returns {Promise} */ - getPaymentStatus(_paymentId) { - return Promise.resolve(/** @type {PaymentStatus} */ ({})) + async getPaymentStatus(paymentId) { + const getByType = /** @type {typeof get} */ (get) + + try { + const response = await getByType( + `${PAYMENT_BASE_URL}${PAYMENT_ENDPOINT}/${paymentId}`, + { + headers: getAuthHeaders(), + json: true + } + ) + + if (response.error) { + const errorMessage = + response.error instanceof Error + ? response.error.message + : JSON.stringify(response.error) + throw new Error(`Failed to get payment status: ${errorMessage}`) + } + + return response.payload + } catch (err) { + const error = /** @type {Error} */ (err) + logger.error( + error, + `[payment] Error getting payment status for paymentId=${paymentId}: ${error.message}` + ) + throw err + } } /** - * Takes the money reserved by previous pre-authorisation - * @param {string} _paymentId - payment id (returned from createPayment() call) + * Captures a payment that is in 'capturable' status + * @param {string} paymentId + * @returns {Promise} */ - capturePayment(_paymentId) { - return Promise.resolve(true) + async capturePayment(paymentId) { + try { + const response = await post( + `${PAYMENT_BASE_URL}${PAYMENT_ENDPOINT}/${paymentId}/capture`, + { + headers: getAuthHeaders() + } + ) + + const statusCode = response.res.statusCode + + if (statusCode === 200 || statusCode === 204) { + logger.info(`[payment] Successfully captured payment ${paymentId}`) + return true + } + + logger.error( + `[payment] Capture failed for paymentId=${paymentId}: HTTP ${statusCode}` + ) + return false + } catch (err) { + const error = /** @type {Error} */ (err) + logger.error( + error, + `[payment] Error capturing payment for paymentId=${paymentId}: ${error.message}` + ) + throw err + } } /** - * Send data to the Pay provider - * @param {CreatePaymentRequest} payload - data to send + * @param {CreatePaymentRequest} payload */ async postToPayProvider(payload) { const postJsonByType = /** @type {typeof postJson} */ (postJson) - const apiKeyTest = config.get('paymentProviderApiKeyTest') - try { const response = await postJsonByType( `${PAYMENT_BASE_URL}${PAYMENT_ENDPOINT}`, { payload, - headers: { - Authorization: `Bearer ${apiKeyTest}` - } + headers: getAuthHeaders() } ) @@ -83,7 +137,7 @@ export class PaymentService { const error = /** @type {Error} */ (err) logger.error( error, - `[payment] Error creating payment for form-id=${payload.metadata.formId} slug=${payload.metadata.slug} reference=${payload.reference}: ${error.message}` + `[payment] Error creating payment for reference=${payload.reference}: ${error.message}` ) throw err } @@ -91,6 +145,5 @@ export class PaymentService { } /** - * @import { PaymentStatus } from '~/src/server/plugins/engine/components/PaymentField.types.js' - * @import { CreatePaymentRequest, CreatePaymentResponse } from '~/src/server/plugins/payment/types.js' + * @import { CreatePaymentRequest, CreatePaymentResponse, GetPaymentResponse } from '~/src/server/plugins/payment/types.js' */ diff --git a/src/server/plugins/payment/types.js b/src/server/plugins/payment/types.js index d3bb5ebc6..d78da3b7e 100644 --- a/src/server/plugins/payment/types.js +++ b/src/server/plugins/payment/types.js @@ -1,46 +1,37 @@ /** - * Gov Uk Pay API result status - * @typedef {object} PaymentStateResult - * @property {string} status - status of payment - * @property {boolean} finished - true if payment is finished + * @typedef {object} PaymentState + * @property {'created' | 'started' | 'submitted' | 'capturable' | 'success' | 'failed' | 'cancelled' | 'error'} status - Current status of the payment + * @property {boolean} finished - Whether the payment process has completed + * @property {string} [message] - Human-readable message about the payment state + * @property {string} [code] - Error or status code for the payment state */ /** * @typedef {object} PaymentLink - * @property {string} href - url - * @property {string} method - get/post + * @property {string} href - URL of the linked resource + * @property {string} method - HTTP method to use for the link */ /** - * @typedef {object} PaymentLinks - * @property {PaymentLink} self - current url - * @property {PaymentLink} next_url - next url - */ - -/** - * @typedef {object} CreatePaymentMetadata - * @property {string} formId - id of the form - * @property {string} slug - slug of the form + * @typedef {object} CreatePaymentRequest + * @property {number} amount - Payment amount in pence + * @property {string} reference - Unique reference for the payment + * @property {string} description - Human-readable description of the payment + * @property {string} return_url - URL to redirect the user to after payment + * @property {boolean} [delayed_capture] - Whether to delay capturing the payment + * @property {{ formId: string, slug: string }} [metadata] - Additional metadata for the payment */ /** - * Gov Uk Pay create payment request - * @typedef {object} CreatePaymentRequest - * @property {number} amount - payment amount - * @property {string} reference - form reference number - * @property {string} description - payment description - * @property {string} return_url - unique payment id - * @property {CreatePaymentMetadata} metadata - custom metadata - * @property {boolean} delayed_capture - denotes pre-auth only + * @typedef {object} CreatePaymentResponse + * @property {string} payment_id - Unique identifier for the created payment + * @property {PaymentState} state - Current state of the payment + * @property {{ next_url: PaymentLink }} _links - HATEOAS links for the payment */ /** - * Gov Uk Pay create payment response - * @typedef {object} CreatePaymentResponse - * @property {Date} created_date - date of creation - * @property {PaymentStateResult} state - result state - * @property {PaymentLinks} _links - payment links - * @property {string} reference - form reference number - * @property {number} amount - payment amount - * @property {string} payment_id - unique payment id + * @typedef {object} GetPaymentResponse + * @property {string} payment_id - Unique identifier for the payment + * @property {PaymentState} state - Current state of the payment + * @property {{ self: PaymentLink, next_url?: PaymentLink }} _links - HATEOAS links for the payment */ diff --git a/src/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index ef364f63b..64334e517 100644 --- a/src/typings/hapi/index.d.ts +++ b/src/typings/hapi/index.d.ts @@ -40,6 +40,7 @@ declare module '@hapi/hapi' { request: AnyFormRequest | null ) => Record | Promise> saveAndExit?: PluginOptions['saveAndExit'] + baseUrl: string } } From 1fa9b37428e64eab2f86ede4e3665cbdf7307609 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Thu, 22 Jan 2026 09:00:30 +0000 Subject: [PATCH 14/70] Styling fix --- src/server/plugins/engine/views/components/paymentfield.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/engine/views/components/paymentfield.html b/src/server/plugins/engine/views/components/paymentfield.html index 673164a34..0199af6da 100644 --- a/src/server/plugins/engine/views/components/paymentfield.html +++ b/src/server/plugins/engine/views/components/paymentfield.html @@ -19,7 +19,7 @@

{{ 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 }}

{{ govukButton({ text: "Add payment details", From caa6073d8e1eaa5f90870be838a8d0fda91a0fca Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Thu, 22 Jan 2026 15:31:21 +0000 Subject: [PATCH 15/70] WIP: progress commit --- src/config/index.ts | 8 + .../plugins/engine/components/PaymentField.ts | 111 +++++++++++--- .../engine/components/PaymentField.types.ts | 1 + .../engine/outputFormatters/machine/v2.ts | 59 +++++++- .../pageControllers/QuestionPageController.ts | 9 +- .../pageControllers/SummaryPageController.ts | 137 +++++++++++++++--- .../plugins/engine/pageControllers/errors.ts | 34 ++++- src/server/plugins/engine/routes/payment.js | 30 ++-- src/server/plugins/engine/types.ts | 21 ++- .../plugins/engine/types/schema.test.ts | 3 +- src/server/plugins/payment/service.js | 35 ++++- 11 files changed, 380 insertions(+), 68 deletions(-) diff --git a/src/config/index.ts b/src/config/index.ts index 7db6bab1b..ca78ba560 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -268,6 +268,14 @@ export const config = convict({ nullable: true, default: undefined, env: 'PAYMENT_PROVIDER_API_KEY_TEST' + } as SchemaObj, + + paymentProviderApiKeyLive: { + doc: 'A live API key for integrating with a payment provider', + format: String, + nullable: true, + default: undefined, + env: 'PAYMENT_PROVIDER_API_KEY_LIVE' } as SchemaObj }) diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index 57c22ff37..0657e6d1f 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -10,6 +10,7 @@ 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, @@ -47,6 +48,7 @@ export class PaymentField extends FormComponent { amount: joi.number().required(), description: joi.string().required(), uuid: joi.string().uuid().required(), + isLive: joi.boolean().required(), preAuth: joi .object({ status: joi @@ -155,7 +157,8 @@ export class PaymentField extends FormComponent { h: FormResponseToolkit, args: PaymentDispatcherArgs ): Promise { - const paymentService = new PaymentService() + const { isLive } = args + const paymentService = new PaymentService({ isLive }) // 1. Generate UUID token const uuid = randomUUID() @@ -173,10 +176,11 @@ export class PaymentField extends FormComponent { // 2. Build the return URL for GOV.UK Pay const { baseUrl } = getPluginOptions(request.server) - const returnUrl = `${baseUrl}/payment-callback?uuid=${uuid}` + const payCallbackUrl = `${baseUrl}/payment-callback?uuid=${uuid}` - // Build the summary URL to redirect to after payment + // Build URLs for redirect after payment const summaryUrl = `${baseUrl}/${model.basePath}/summary` + const paymentPageUrl = args.sourceUrl // 3. Call paymentService.createPayment() // GOV.UK Pay expects amount in pence, so multiply pounds by 100 @@ -184,7 +188,7 @@ export class PaymentField extends FormComponent { const payment = await paymentService.createPayment( amountInPence, description, - returnUrl, + payCallbackUrl, reference, { formId, slug } ) @@ -197,7 +201,9 @@ export class PaymentField extends FormComponent { description, paymentId: payment.paymentId, componentName, - sourceUrl: summaryUrl + returnUrl: summaryUrl, + failureUrl: paymentPageUrl, + isLive } request.yar.set(`payment-${uuid}`, sessionData) @@ -208,21 +214,86 @@ export class PaymentField extends FormComponent { /** * 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) { + // No payment state - redirect to payment page to complete payment + throw new InvalidComponentStateError( + this, + 'Complete the payment to continue', + { shouldResetState: true } + ) + } + + // Skip if already captured + if (paymentState.capture?.status === 'success') { + return + } + + const { paymentId, isLive } = paymentState + const paymentService = new PaymentService({ isLive }) + + // Verify payment is still in capturable state + const status = await paymentService.getPaymentStatus(paymentId) + + // If already captured (success state), mark as captured and continue + 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 } + ) + } + + // Capture the payment + 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() + } + } + + // Update the state in the page controller + if (this.page) { + const currentState = await this.page.getState(request) + await this.page.mergeState(request, currentState, { + [this.name]: updatedState + }) + } } } @@ -237,7 +308,7 @@ export interface PaymentDispatcherArgs { } component: PaymentField sourceUrl: string - paymentService: PaymentService + isLive: boolean } /** @@ -250,5 +321,7 @@ export interface PaymentSessionData { description: string paymentId: string componentName: string - sourceUrl: string + returnUrl: string + failureUrl: string + isLive: boolean } diff --git a/src/server/plugins/engine/components/PaymentField.types.ts b/src/server/plugins/engine/components/PaymentField.types.ts index b937073f7..5d7b93b85 100644 --- a/src/server/plugins/engine/components/PaymentField.types.ts +++ b/src/server/plugins/engine/components/PaymentField.types.ts @@ -7,6 +7,7 @@ export interface PaymentState { amount: number description: string uuid: string + isLive: boolean capture?: { status: 'success' | 'failed' createdAt: string diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.ts b/src/server/plugins/engine/outputFormatters/machine/v2.ts index 6b7ccbe22..c2cebb9a8 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 { @@ -12,7 +15,9 @@ import { import { type FileUploadFieldDetailitem, type FormAdapterFile, + type FormAdapterPayment, type FormContext, + type PaymentFieldDetailItem, type RichFormValue } from '~/src/server/plugins/engine/types.js' @@ -71,6 +76,14 @@ 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' + * } * } * } */ @@ -82,7 +95,16 @@ export function categoriseData(items: DetailItem[]) { string, { fileId: string; fileName: string; userDownloadLink: string }[] > - } = { main: {}, repeaters: {}, files: {} } + payments: Record< + string, + { + paymentId: string + reference: string + amount: number + description: string + } + > + } = { main: {}, repeaters: {}, files: {}, payments: {} } items.forEach((item) => { const { name, state } = item @@ -91,6 +113,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 +175,31 @@ function isFileUploadFieldItem( ): item is FileUploadFieldDetailitem { return item.field instanceof FileUploadField } + +function isPaymentFieldItem( + item: DetailItemField +): item is PaymentFieldDetailItem { + 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: PaymentFieldDetailItem +): FormAdapterPayment | 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 + } +} diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 880df645c..34be9c650 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -21,6 +21,7 @@ import { ComponentCollection } from '~/src/server/plugins/engine/components/Comp 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 +48,7 @@ import { import { getComponentsByType } from '~/src/server/plugins/engine/validationHelpers.js' import { FormAction, + FormStatus, type FormRequest, type FormRequestPayload, type FormRequestPayloadRefs, @@ -616,11 +618,16 @@ 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 } = 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 }) } diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index fd0e245e1..840c347ef 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -23,10 +23,14 @@ 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 { type FormConfirmationState, type FormContext, @@ -231,9 +235,32 @@ export class SummaryPageController extends QuestionPageController { request.yar.flash(COMPONENT_STATE_ERROR, govukError, true) - await cacheService.resetComponentStates(request, error.getStateKeys()) + if (error.shouldResetState) { + // Reset state and redirect to component page (e.g., payment expired) + await cacheService.resetComponentStates( + request, + error.getStateKeys() + ) + return this.proceed(request, h, error.component.page?.path) + } + + // Stay on CYA page with error (e.g., capture failed, user can retry) + return this.proceed(request, h) + } + + if (error instanceof PostPaymentSubmissionError) { + const helpLink = error.helpLink + ? ` 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${helpLink}.` + ) + + request.yar.flash(COMPONENT_STATE_ERROR, govukError, true) - return this.proceed(request, h, error.component.page?.path) + return this.proceed(request, h) } throw error @@ -275,6 +302,9 @@ export async function submitForm( ) { await finaliseComponents(request, metadata, context) + // Check if payment was captured (for Flow 9 error handling) + const paymentWasCaptured = hasPaymentBeenCaptured(context) + const formStatus = checkFormStatus(request.params) const logTags = ['submit', 'submissionApi'] @@ -286,28 +316,55 @@ export async function submitForm( summaryViewModel.details ) - // Submit data - request.logger.info(logTags, 'Submitting data') - const submitResponse = await submitData( - model, - items, - emailAddress, - request.yar.id - ) + try { + // Submit data + 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 } /** @@ -379,11 +436,43 @@ function submitData( } 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() + + // Add payment field items (excluded from details for UI but needed for submission) + 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.ts b/src/server/plugins/engine/pageControllers/errors.ts index c96fb76f7..c54fd5bbb 100644 --- a/src/server/plugins/engine/pageControllers/errors.ts +++ b/src/server/plugins/engine/pageControllers/errors.ts @@ -1,5 +1,31 @@ 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 +} + /** * 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 +37,19 @@ 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 - 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 } getStateKeys() { diff --git a/src/server/plugins/engine/routes/payment.js b/src/server/plugins/engine/routes/payment.js index 6eb4c32ef..b2c84ed3e 100644 --- a/src/server/plugins/engine/routes/payment.js +++ b/src/server/plugins/engine/routes/payment.js @@ -22,6 +22,7 @@ function flashComponentState(request, session, paymentId) { amount: session.amount, description: session.description, uuid: session.uuid, + isLive: session.isLive, preAuth: { status: 'success', createdAt: new Date().toISOString() @@ -56,7 +57,6 @@ function getReturnRoute() { path: PAYMENT_RETURN_PATH, async handler(request, h) { const { uuid } = /** @type {{ uuid: string }} */ (request.query) - const paymentService = new PaymentService() // 1. Get session data using the UUID as the key const sessionKey = `${PAYMENT_SESSION_PREFIX}${uuid}` @@ -69,12 +69,13 @@ function getReturnRoute() { } // 2. Get payment status from GOV.UK Pay - const { paymentId } = session + const { paymentId, isLive } = session if (!paymentId) { throw Boom.badRequest('No paymentId in session') } + const paymentService = new PaymentService({ isLive }) const paymentStatus = await paymentService.getPaymentStatus(paymentId) // 3. Handle different payment states based on GOV.UK Pay status lifecycle @@ -83,34 +84,33 @@ function getReturnRoute() { switch (status) { case 'capturable': - // Pre-auth successful - flash the state and redirect back + // Pre-auth successful - flash the state and redirect to summary flashComponentState(request, session, paymentId) request.yar.clear(sessionKey) - return h.redirect(session.sourceUrl).code(StatusCodes.SEE_OTHER) + return h.redirect(session.returnUrl).code(StatusCodes.SEE_OTHER) case 'success': // Payment already captured (shouldn't happen with delayed_capture: true) flashComponentState(request, session, paymentId) request.yar.clear(sessionKey) - return h.redirect(session.sourceUrl).code(StatusCodes.SEE_OTHER) + return h.redirect(session.returnUrl).code(StatusCodes.SEE_OTHER) case 'cancelled': - // User cancelled payment (P0030) + // User cancelled payment (P0030) - redirect to payment page to retry request.yar.clear(sessionKey) - // TODO: Flash an error message with paymentStatus.state.message - return h.redirect(session.sourceUrl).code(StatusCodes.SEE_OTHER) + return h.redirect(session.failureUrl).code(StatusCodes.SEE_OTHER) case 'failed': - // Payment failed - could be P0010 (rejected), P0020 (expired), P0040 (service cancelled), P0050 (provider error) + // Payment failed (P0010 rejected, P0020 expired, P0040 service cancelled, P0050 provider error) + // Redirect to payment page to retry request.yar.clear(sessionKey) - // TODO: Flash an error message with paymentStatus.state.message and paymentStatus.state.code - return h.redirect(session.sourceUrl).code(StatusCodes.SEE_OTHER) + return h.redirect(session.failureUrl).code(StatusCodes.SEE_OTHER) case 'error': // Technical error on GOV.UK Pay side - no funds taken + // Redirect to payment page to retry request.yar.clear(sessionKey) - // TODO: Flash an error message - return h.redirect(session.sourceUrl).code(StatusCodes.SEE_OTHER) + return h.redirect(session.failureUrl).code(StatusCodes.SEE_OTHER) case 'created': case 'started': @@ -156,7 +156,9 @@ function getReturnRoute() { * @property {string} description - payment description * @property {string} paymentId - GOV.UK Pay payment ID * @property {string} componentName - name of the PaymentField component - * @property {string} sourceUrl - URL to redirect back to after payment + * @property {string} returnUrl - URL to redirect to after successful payment + * @property {string} failureUrl - URL to redirect to after failed/cancelled payment + * @property {boolean} isLive - whether the payment is using live API key */ /** diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 9a887fb6e..112dee825 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -18,7 +18,10 @@ import { FormComponent } from '~/src/server/plugins/engine/components/FormCompon 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, @@ -395,6 +398,7 @@ export interface ExternalArgs { controller: QuestionPageController sourceUrl: string actionArgs: Record + isLive: boolean } export interface PostcodeLookupExternalArgs extends ExternalArgs { @@ -455,6 +459,13 @@ export interface FormAdapterFile { userDownloadLink: string } +export interface FormAdapterPayment { + paymentId: string + reference: string + amount: number + description: string +} + export interface FormAdapterSubmissionMessageResult { files: { main: string @@ -468,6 +479,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 @@ -481,6 +499,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/payment/service.js b/src/server/plugins/payment/service.js index e7eac94dd..9595e610a 100644 --- a/src/server/plugins/payment/service.js +++ b/src/server/plugins/payment/service.js @@ -8,16 +8,41 @@ const PAYMENT_ENDPOINT = '/v1/payments' const logger = createLogger() /** + * @param {string} apiKey * @returns {{ Authorization: string }} */ -function getAuthHeaders() { - const apiKey = config.get('paymentProviderApiKeyTest') +function getAuthHeaders(apiKey) { return { Authorization: `Bearer ${apiKey}` } } +/** + * Gets the fallback API key from global config + * @param {boolean} isLive + * @returns {string} + */ +function getFallbackApiKey(isLive) { + return /** @type {string} */ ( + isLive + ? config.get('paymentProviderApiKeyLive') + : config.get('paymentProviderApiKeyTest') + ) +} + export class PaymentService { + /** @type {string} */ + #apiKey + + /** + * @param {object} options + * @param {string} [options.apiKey] - API key to use (if not provided, falls back to global config) + * @param {boolean} [options.isLive] - whether to use live API key (only used if apiKey not provided) + */ + constructor({ apiKey, isLive = false } = {}) { + this.#apiKey = apiKey ?? getFallbackApiKey(isLive) + } + /** * Creates a payment with delayed capture (pre-authorisation) * @param {number} amount - in pence @@ -53,7 +78,7 @@ export class PaymentService { const response = await getByType( `${PAYMENT_BASE_URL}${PAYMENT_ENDPOINT}/${paymentId}`, { - headers: getAuthHeaders(), + headers: getAuthHeaders(this.#apiKey), json: true } ) @@ -87,7 +112,7 @@ export class PaymentService { const response = await post( `${PAYMENT_BASE_URL}${PAYMENT_ENDPOINT}/${paymentId}/capture`, { - headers: getAuthHeaders() + headers: getAuthHeaders(this.#apiKey) } ) @@ -124,7 +149,7 @@ export class PaymentService { `${PAYMENT_BASE_URL}${PAYMENT_ENDPOINT}`, { payload, - headers: getAuthHeaders() + headers: getAuthHeaders(this.#apiKey) } ) From 41fbf7a209ad72b0227ce32d21c03685d2a6c0fe Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Thu, 22 Jan 2026 15:48:11 +0000 Subject: [PATCH 16/70] Fixed tests --- src/server/plugins/engine/outputFormatters/adapter/v1.test.ts | 1 + src/server/plugins/engine/outputFormatters/machine/v2.test.ts | 2 ++ src/server/plugins/engine/types/schema.ts | 1 + 3 files changed, 4 insertions(+) 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/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/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( From 28c5566dbc16bb0020e9affef000069198a9eb9f Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Thu, 22 Jan 2026 17:19:23 +0000 Subject: [PATCH 17/70] feat(payment): add payment expired notification banner --- src/server/constants.js | 1 + .../plugins/engine/components/PaymentField.ts | 2 +- .../pageControllers/QuestionPageController.ts | 9 +++++++- .../pageControllers/SummaryPageController.ts | 23 +++++++++++++------ .../plugins/engine/pageControllers/errors.ts | 9 ++++++++ src/server/plugins/engine/types.ts | 1 + src/server/plugins/engine/views/index.html | 10 +++++++- 7 files changed, 45 insertions(+), 10 deletions(-) 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/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index 0657e6d1f..6c377b16c 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -253,7 +253,7 @@ export class PaymentField extends FormComponent { throw new InvalidComponentStateError( this, 'Your payment authorisation has expired. Please add your payment details again.', - { shouldResetState: true } + { shouldResetState: true, isPaymentExpired: true } ) } diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 34be9c650..4af84ae00 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -15,7 +15,8 @@ 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' @@ -439,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 */ diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 840c347ef..8dba3290f 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -7,7 +7,10 @@ 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 { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js' import { getAnswer } from '~/src/server/plugins/engine/components/helpers/components.js' @@ -228,6 +231,18 @@ export class SummaryPageController extends QuestionPageController { ) } catch (error) { if (error instanceof InvalidComponentStateError) { + 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 @@ -236,15 +251,9 @@ export class SummaryPageController extends QuestionPageController { request.yar.flash(COMPONENT_STATE_ERROR, govukError, true) if (error.shouldResetState) { - // Reset state and redirect to component page (e.g., payment expired) - await cacheService.resetComponentStates( - request, - error.getStateKeys() - ) return this.proceed(request, h, error.component.page?.path) } - // Stay on CYA page with error (e.g., capture failed, user can retry) return this.proceed(request, h) } diff --git a/src/server/plugins/engine/pageControllers/errors.ts b/src/server/plugins/engine/pageControllers/errors.ts index c54fd5bbb..92167af79 100644 --- a/src/server/plugins/engine/pageControllers/errors.ts +++ b/src/server/plugins/engine/pageControllers/errors.ts @@ -24,6 +24,13 @@ export interface InvalidComponentStateErrorOptions { * @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 } /** @@ -38,6 +45,7 @@ export class InvalidComponentStateError extends Error { public readonly component: FormComponent public readonly userMessage: string public readonly shouldResetState: boolean + public readonly isPaymentExpired: boolean constructor( component: FormComponent, @@ -50,6 +58,7 @@ export class InvalidComponentStateError extends Error { this.component = component this.userMessage = userMessage this.shouldResetState = options.shouldResetState ?? true + this.isPaymentExpired = options.isPaymentExpired ?? false } getStateKeys() { diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 112dee825..fc870e88f 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -334,6 +334,7 @@ export interface FormPageViewModel extends PageViewModelBase { hasMissingNotificationEmail?: boolean allowSaveAndExit: boolean showSubmitButton?: boolean + showPaymentExpiredNotification?: boolean } export interface RepeaterSummaryPageViewModel extends PageViewModelBase { 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) From 7591d88e7c218a95d3065a48e1a5dc9ae59ac678 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 23 Jan 2026 08:53:18 +0000 Subject: [PATCH 18/70] Handles test/live API key --- src/config/index.ts | 8 ------- .../plugins/engine/components/PaymentField.ts | 24 ++++++++++++------- .../engine/components/PaymentField.types.ts | 3 ++- .../pageControllers/QuestionPageController.ts | 5 ++-- src/server/plugins/engine/routes/payment.js | 12 ++++++---- src/server/plugins/engine/types.ts | 1 + src/server/plugins/payment/helper.js | 22 +++++++++++++++++ src/server/plugins/payment/service.js | 22 +++-------------- 8 files changed, 55 insertions(+), 42 deletions(-) create mode 100644 src/server/plugins/payment/helper.js diff --git a/src/config/index.ts b/src/config/index.ts index ca78ba560..7db6bab1b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -268,14 +268,6 @@ export const config = convict({ nullable: true, default: undefined, env: 'PAYMENT_PROVIDER_API_KEY_TEST' - } as SchemaObj, - - paymentProviderApiKeyLive: { - doc: 'A live API key for integrating with a payment provider', - format: String, - nullable: true, - default: undefined, - env: 'PAYMENT_PROVIDER_API_KEY_LIVE' } as SchemaObj }) diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index 0657e6d1f..64f98de6a 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -25,6 +25,7 @@ import { type FormSubmissionError, type FormSubmissionState } from '~/src/server/plugins/engine/types.js' +import { getPaymentApiKey } from '~/src/server/plugins/payment/helper.js' import { PaymentService } from '~/src/server/plugins/payment/service.js' export class PaymentField extends FormComponent { @@ -48,7 +49,8 @@ export class PaymentField extends FormComponent { amount: joi.number().required(), description: joi.string().required(), uuid: joi.string().uuid().required(), - isLive: joi.boolean().required(), + formId: joi.string().required(), + isLivePayment: joi.boolean().required(), preAuth: joi .object({ status: joi @@ -157,8 +159,11 @@ export class PaymentField extends FormComponent { h: FormResponseToolkit, args: PaymentDispatcherArgs ): Promise { - const { isLive } = args - const paymentService = new PaymentService({ isLive }) + const isLivePayment = args.isLive && !args.isPreview + const formId = args.controller.model.formId + const apiKeyValue = getPaymentApiKey(isLivePayment, formId) + + const paymentService = new PaymentService(apiKeyValue) // 1. Generate UUID token const uuid = randomUUID() @@ -171,7 +176,6 @@ export class PaymentField extends FormComponent { const amount = options.amount ?? 0 const description = options.description ?? '' - const formId = model.formId const slug = `/${model.basePath}` // 2. Build the return URL for GOV.UK Pay @@ -196,6 +200,7 @@ export class PaymentField extends FormComponent { // 4. Store session data for the return route to use const sessionData: PaymentSessionData = { uuid, + formId, reference, amount, description, @@ -203,7 +208,7 @@ export class PaymentField extends FormComponent { componentName, returnUrl: summaryUrl, failureUrl: paymentPageUrl, - isLive + isLivePayment } request.yar.set(`payment-${uuid}`, sessionData) @@ -237,8 +242,9 @@ export class PaymentField extends FormComponent { return } - const { paymentId, isLive } = paymentState - const paymentService = new PaymentService({ isLive }) + const { paymentId, isLivePayment, formId } = paymentState + const apiKey = getPaymentApiKey(isLivePayment, formId) + const paymentService = new PaymentService(apiKey) // Verify payment is still in capturable state const status = await paymentService.getPaymentStatus(paymentId) @@ -309,6 +315,7 @@ export interface PaymentDispatcherArgs { component: PaymentField sourceUrl: string isLive: boolean + isPreview: boolean } /** @@ -316,6 +323,7 @@ export interface PaymentDispatcherArgs { */ export interface PaymentSessionData { uuid: string + formId: string reference: string amount: number description: string @@ -323,5 +331,5 @@ export interface PaymentSessionData { componentName: string returnUrl: string failureUrl: string - isLive: boolean + isLivePayment: boolean } diff --git a/src/server/plugins/engine/components/PaymentField.types.ts b/src/server/plugins/engine/components/PaymentField.types.ts index 5d7b93b85..ed1f6cff2 100644 --- a/src/server/plugins/engine/components/PaymentField.types.ts +++ b/src/server/plugins/engine/components/PaymentField.types.ts @@ -7,7 +7,8 @@ export interface PaymentState { amount: number description: string uuid: string - isLive: boolean + formId: string + isLivePayment: boolean capture?: { status: 'success' | 'failed' createdAt: string diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 34be9c650..7765bee6e 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -619,7 +619,7 @@ export class QuestionPageController extends PageController { request.yar.clear(EXTERNAL_STATE_APPENDAGE) // Determine if this is a live form (not preview/draft) - const { state } = checkFormStatus(request.params) + const { state, isPreview } = checkFormStatus(request.params) const isLive = state === FormStatus.Live return await selectedComponent.dispatcher(request, h, { @@ -627,7 +627,8 @@ export class QuestionPageController extends PageController { controller: this, sourceUrl: request.url.toString(), actionArgs: args, - isLive + isLive, + isPreview }) } diff --git a/src/server/plugins/engine/routes/payment.js b/src/server/plugins/engine/routes/payment.js index b2c84ed3e..14f21d088 100644 --- a/src/server/plugins/engine/routes/payment.js +++ b/src/server/plugins/engine/routes/payment.js @@ -3,6 +3,7 @@ import { StatusCodes } from 'http-status-codes' import Joi from 'joi' import { EXTERNAL_STATE_APPENDAGE } from '~/src/server/constants.js' +import { getPaymentApiKey } from '~/src/server/plugins/payment/helper.js' import { PaymentService } from '~/src/server/plugins/payment/service.js' export const PAYMENT_RETURN_PATH = '/payment-callback' @@ -22,7 +23,8 @@ function flashComponentState(request, session, paymentId) { amount: session.amount, description: session.description, uuid: session.uuid, - isLive: session.isLive, + formId: session.formId, + isLivePayment: session.isLivePayment, preAuth: { status: 'success', createdAt: new Date().toISOString() @@ -69,13 +71,14 @@ function getReturnRoute() { } // 2. Get payment status from GOV.UK Pay - const { paymentId, isLive } = session + const { paymentId, isLivePayment, formId } = session if (!paymentId) { throw Boom.badRequest('No paymentId in session') } - const paymentService = new PaymentService({ isLive }) + const apiKey = getPaymentApiKey(isLivePayment, formId) + const paymentService = new PaymentService(apiKey) const paymentStatus = await paymentService.getPaymentStatus(paymentId) // 3. Handle different payment states based on GOV.UK Pay status lifecycle @@ -151,6 +154,7 @@ function getReturnRoute() { * Payment session data stored when dispatching to GOV.UK Pay * @typedef {object} PaymentSessionData * @property {string} uuid - unique identifier for this payment attempt + * @property {string} formId - id of the form * @property {string} reference - form reference number * @property {number} amount - amount in pounds * @property {string} description - payment description @@ -158,7 +162,7 @@ function getReturnRoute() { * @property {string} componentName - name of the PaymentField component * @property {string} returnUrl - URL to redirect to after successful payment * @property {string} failureUrl - URL to redirect to after failed/cancelled payment - * @property {boolean} isLive - whether the payment is using live API key + * @property {boolean} isLivePayment - whether the payment is using live API key */ /** diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 112dee825..72e8bc4fb 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -399,6 +399,7 @@ export interface ExternalArgs { sourceUrl: string actionArgs: Record isLive: boolean + isPreview: boolean } export interface PostcodeLookupExternalArgs extends ExternalArgs { diff --git a/src/server/plugins/payment/helper.js b/src/server/plugins/payment/helper.js new file mode 100644 index 000000000..79b4384fb --- /dev/null +++ b/src/server/plugins/payment/helper.js @@ -0,0 +1,22 @@ +import { config } from '~/src/config/index.js' + +/** + * Determine which payment API key value to use. + * If a non-live non-preview form, use the TEST API key value. + * If a live (non-preview) form, read the API key value specific to that form. + * @param {boolean} isLivePayment - true if this is a live payment (as opposed to a test one) + * @param {string} formId - id of the form + * @returns {string} + */ +export function getPaymentApiKey(isLivePayment, formId) { + const apiKeyValue = isLivePayment + ? process.env[`PAYMENT_PROVIDER_API_KEY_LIVE_${formId}`] + : config.get('paymentProviderApiKeyTest') + + if (!apiKeyValue) { + throw new Error( + `Missing payment api key for ${isLivePayment ? 'live' : 'test'} form id ${formId}` + ) + } + return apiKeyValue +} diff --git a/src/server/plugins/payment/service.js b/src/server/plugins/payment/service.js index 9595e610a..bad81e8bf 100644 --- a/src/server/plugins/payment/service.js +++ b/src/server/plugins/payment/service.js @@ -1,4 +1,3 @@ -import { config } from '~/src/config/index.js' import { createLogger } from '~/src/server/common/helpers/logging/logger.js' import { get, post, postJson } from '~/src/server/services/httpService.js' @@ -17,30 +16,15 @@ function getAuthHeaders(apiKey) { } } -/** - * Gets the fallback API key from global config - * @param {boolean} isLive - * @returns {string} - */ -function getFallbackApiKey(isLive) { - return /** @type {string} */ ( - isLive - ? config.get('paymentProviderApiKeyLive') - : config.get('paymentProviderApiKeyTest') - ) -} - export class PaymentService { /** @type {string} */ #apiKey /** - * @param {object} options - * @param {string} [options.apiKey] - API key to use (if not provided, falls back to global config) - * @param {boolean} [options.isLive] - whether to use live API key (only used if apiKey not provided) + * @param {string} apiKey - API key to use (global config for test value, per-form config for live value) */ - constructor({ apiKey, isLive = false } = {}) { - this.#apiKey = apiKey ?? getFallbackApiKey(isLive) + constructor(apiKey) { + this.#apiKey = apiKey } /** From 87adc2d8f1f7ed248021a1b1ce357a7f2142581a Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Fri, 23 Jan 2026 11:30:44 +0000 Subject: [PATCH 19/70] fix(summary): update button text from Accept and send to Accept and submit --- src/server/plugins/engine/views/summary.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/engine/views/summary.html b/src/server/plugins/engine/views/summary.html index 859509f84..100c952dc 100644 --- a/src/server/plugins/engine/views/summary.html +++ b/src/server/plugins/engine/views/summary.html @@ -75,7 +75,7 @@

Declaration

{% set isDeclaration = declaration or components | length %} {{ govukButton({ - text: "Accept and send" if isDeclaration else "Send", + text: "Accept and submit" if isDeclaration else "Submit", name: "action", value: "send", preventDoubleClick: true From 960c118eb063c5d9e866ccc5488bba7c5978e1ff Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Fri, 23 Jan 2026 12:27:15 +0000 Subject: [PATCH 20/70] feat(payment): add createdAt field to FormAdapterPayment and update related functions --- src/server/plugins/engine/outputFormatters/machine/v2.ts | 7 +++++-- src/server/plugins/engine/types.ts | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.ts b/src/server/plugins/engine/outputFormatters/machine/v2.ts index c2cebb9a8..3b79709a2 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v2.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v2.ts @@ -82,7 +82,8 @@ export function format( * paymentId: 'abc123', * reference: 'REF-123', * amount: 10.00, - * description: 'Application fee' + * description: 'Application fee', + * createdAt: '2025-01-23T10:30:00.000Z' * } * } * } @@ -102,6 +103,7 @@ export function categoriseData(items: DetailItem[]) { reference: string amount: number description: string + createdAt: string } > } = { main: {}, repeaters: {}, files: {}, payments: {} } @@ -200,6 +202,7 @@ function extractPayment( paymentId: paymentState.paymentId, reference: paymentState.reference, amount: paymentState.amount, - description: paymentState.description + description: paymentState.description, + createdAt: paymentState.preAuth?.createdAt ?? '' } } diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 52c241cf1..073615261 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -466,6 +466,7 @@ export interface FormAdapterPayment { reference: string amount: number description: string + createdAt: string } export interface FormAdapterSubmissionMessageResult { From 5debb87d52093f1bf4ac0cb2c4f28fa6d5ce41df Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Fri, 23 Jan 2026 15:24:03 +0000 Subject: [PATCH 21/70] refactor(sonar): reduce complexity --- .../pageControllers/SummaryPageController.ts | 110 ++++++++------ src/server/plugins/engine/routes/payment.js | 135 ++++++++++-------- 2 files changed, 148 insertions(+), 97 deletions(-) diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 8dba3290f..3e44978f4 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -230,61 +230,89 @@ export class SummaryPageController extends QuestionPageController { formMetadata ) } catch (error) { - if (error instanceof InvalidComponentStateError) { - 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) - } - } + return this.handleSubmissionError(error, request, h) + } + } - const govukError = createError( - error.component.name, - error.userMessage - ) + await cacheService.setConfirmationState(request, { + confirmed: true, + formId: context.state.formId + } as FormConfirmationState) - request.yar.flash(COMPONENT_STATE_ERROR, govukError, true) + // Clear all form data + await cacheService.clearState(request) - if (error.shouldResetState) { - return this.proceed(request, h, error.component.page?.path) - } + return this.proceed(request, h, this.getStatusPath()) + } - return this.proceed(request, h) - } + /** + * 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) { - const helpLink = error.helpLink - ? ` or you can contact us (opens in new tab) and quote your reference number to arrange a refund` - : '' + if (error instanceof PostPaymentSubmissionError) { + return this.handlePostPaymentSubmissionError(error, request, h) + } - const govukError = createError( - 'submission', - `There was a problem and your form was not submitted. Try submitting the form again${helpLink}.` - ) + throw error + } - request.yar.flash(COMPONENT_STATE_ERROR, govukError, true) + /** + * Handles InvalidComponentStateError during submission + */ + private async handleInvalidComponentStateError( + error: InvalidComponentStateError, + request: FormRequestPayload, + h: FormResponseToolkit + ) { + const cacheService = getCacheService(request.server) - return this.proceed(request, h) - } + if (error.shouldResetState) { + await cacheService.resetComponentStates(request, error.getStateKeys()) - throw error + if (error.isPaymentExpired) { + request.yar.flash(PAYMENT_EXPIRED_NOTIFICATION, true, true) + return this.proceed(request, h, error.component.page?.path) } } - await cacheService.setConfirmationState(request, { - confirmed: true, - formId: context.state.formId - } as FormConfirmationState) + const govukError = createError(error.component.name, error.userMessage) + request.yar.flash(COMPONENT_STATE_ERROR, govukError, true) - // Clear all form data - await cacheService.clearState(request) + const redirectPath = error.shouldResetState + ? error.component.page?.path + : undefined - return this.proceed(request, h, this.getStatusPath()) + return this.proceed(request, h, redirectPath) + } + + /** + * Handles PostPaymentSubmissionError during submission + */ + private handlePostPaymentSubmissionError( + error: PostPaymentSubmissionError, + request: FormRequestPayload, + h: FormResponseToolkit + ) { + const helpLink = error.helpLink + ? ` 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${helpLink}.` + ) + + request.yar.flash(COMPONENT_STATE_ERROR, govukError, true) + + return this.proceed(request, h) } get postRouteOptions(): RouteOptions { diff --git a/src/server/plugins/engine/routes/payment.js b/src/server/plugins/engine/routes/payment.js index 14f21d088..dae2328a6 100644 --- a/src/server/plugins/engine/routes/payment.js +++ b/src/server/plugins/engine/routes/payment.js @@ -48,6 +48,61 @@ export function getRoutes() { return [getReturnRoute()] } +/** + * 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 }>} + */ +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 } +} + +/** + * 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 {string} paymentId - the payment id + */ +function handlePaymentSuccess(request, h, session, sessionKey, paymentId) { + flashComponentState(request, session, paymentId) + 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 @@ -59,83 +114,50 @@ function getReturnRoute() { path: PAYMENT_RETURN_PATH, async handler(request, h) { const { uuid } = /** @type {{ uuid: string }} */ (request.query) - - // 1. Get session data using the UUID as the key - const sessionKey = `${PAYMENT_SESSION_PREFIX}${uuid}` - const session = /** @type {PaymentSessionData | null} */ ( - request.yar.get(sessionKey) + const { session, sessionKey, paymentStatus } = await getPaymentContext( + request, + uuid ) - if (!session) { - throw Boom.badRequest(`No payment session found for uuid=${uuid}`) - } - - // 2. Get payment status from GOV.UK Pay - 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) - - // 3. Handle different payment states based on GOV.UK Pay status lifecycle + // Handle different payment states based on GOV.UK Pay status lifecycle // @see https://docs.payments.service.gov.uk/api_reference/#payment-status-lifecycle const { status } = paymentStatus.state switch (status) { + // Pre-auth successful or already captured case 'capturable': - // Pre-auth successful - flash the state and redirect to summary - flashComponentState(request, session, paymentId) - request.yar.clear(sessionKey) - return h.redirect(session.returnUrl).code(StatusCodes.SEE_OTHER) - case 'success': - // Payment already captured (shouldn't happen with delayed_capture: true) - flashComponentState(request, session, paymentId) - request.yar.clear(sessionKey) - return h.redirect(session.returnUrl).code(StatusCodes.SEE_OTHER) + return handlePaymentSuccess( + request, + h, + session, + sessionKey, + session.paymentId + ) + // Payment failed, cancelled, or errored - redirect to retry case 'cancelled': - // User cancelled payment (P0030) - redirect to payment page to retry - request.yar.clear(sessionKey) - return h.redirect(session.failureUrl).code(StatusCodes.SEE_OTHER) - case 'failed': - // Payment failed (P0010 rejected, P0020 expired, P0040 service cancelled, P0050 provider error) - // Redirect to payment page to retry - request.yar.clear(sessionKey) - return h.redirect(session.failureUrl).code(StatusCodes.SEE_OTHER) - case 'error': - // Technical error on GOV.UK Pay side - no funds taken - // Redirect to payment page to retry - request.yar.clear(sessionKey) - return h.redirect(session.failureUrl).code(StatusCodes.SEE_OTHER) + return handlePaymentFailure(request, h, session, sessionKey) + // User came back too early - redirect back to GOV.UK Pay case 'created': case 'started': case 'submitted': { - // User came back too early or payment still processing - // Redirect back to GOV.UK Pay to continue const nextUrl = paymentStatus._links.next_url?.href - if (nextUrl) { - return h.redirect(nextUrl).code(StatusCodes.SEE_OTHER) + if (!nextUrl) { + throw Boom.badRequest( + `Payment in state '${status}' but no next_url available` + ) } - throw Boom.badRequest( - `Payment in state '${status}' but no next_url available` - ) + return h.redirect(nextUrl).code(StatusCodes.SEE_OTHER) } - default: { - // this should never be reached but Sonar will complain - const unknownStatus = /** @type {string} */ (status) - throw Boom.internal(`Unknown payment status: ${unknownStatus}`) - } + default: + throw Boom.internal(`Unknown payment status: ${String(status)}`) } }, options: { @@ -166,7 +188,8 @@ function getReturnRoute() { */ /** - * @import { Request, ServerRoute } from '@hapi/hapi' + * @import { Request, ResponseToolkit, ServerRoute } from '@hapi/hapi' * @import { PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js' + * @import { GetPaymentResponse } from '~/src/server/plugins/payment/types.js' * @import { ExternalStateAppendage, FormState } from '~/src/server/plugins/engine/types.js' */ From eabbe41bc2d988e8d27367d220d3b47c8ba0ea9c Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Fri, 23 Jan 2026 15:42:05 +0000 Subject: [PATCH 22/70] refactor(sonar): replace magic numbers with http-status-codes --- src/server/plugins/payment/service.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/payment/service.js b/src/server/plugins/payment/service.js index bad81e8bf..bd22042aa 100644 --- a/src/server/plugins/payment/service.js +++ b/src/server/plugins/payment/service.js @@ -1,3 +1,5 @@ +import { StatusCodes } from 'http-status-codes' + import { createLogger } from '~/src/server/common/helpers/logging/logger.js' import { get, post, postJson } from '~/src/server/services/httpService.js' @@ -102,7 +104,10 @@ export class PaymentService { const statusCode = response.res.statusCode - if (statusCode === 200 || statusCode === 204) { + if ( + statusCode === StatusCodes.OK || + statusCode === StatusCodes.NO_CONTENT + ) { logger.info(`[payment] Successfully captured payment ${paymentId}`) return true } From 41a710e29f1ccda9e4a7c8db446234b0e91acc61 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 23 Jan 2026 16:00:02 +0000 Subject: [PATCH 23/70] Added service and help tests --- src/server/plugins/payment/helper.test.js | 18 ++ src/server/plugins/payment/service.js | 4 +- src/server/plugins/payment/service.test.js | 205 +++++++++++++++++++++ 3 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 src/server/plugins/payment/helper.test.js create mode 100644 src/server/plugins/payment/service.test.js diff --git a/src/server/plugins/payment/helper.test.js b/src/server/plugins/payment/helper.test.js new file mode 100644 index 000000000..624538584 --- /dev/null +++ b/src/server/plugins/payment/helper.test.js @@ -0,0 +1,18 @@ +import { config } from '~/src/config/index.js' +import { getPaymentApiKey } from '~/src/server/plugins/payment/helper.js' + +describe('getPaymentApiKey', () => { + config.set('paymentProviderApiKeyTest', 'TEST-API-KEY') + const formId = 'form-id' + process.env['PAYMENT_PROVIDER_API_KEY_LIVE_form-id'] = 'LIVE-API-KEY' + + it('should read test key when non-live form', () => { + const apiKey = getPaymentApiKey(false, formId) + expect(apiKey).toBe('TEST-API-KEY') + }) + + it('should read live key when live form', () => { + const apiKey = getPaymentApiKey(true, formId) + expect(apiKey).toBe('LIVE-API-KEY') + }) +}) diff --git a/src/server/plugins/payment/service.js b/src/server/plugins/payment/service.js index bad81e8bf..734e955f5 100644 --- a/src/server/plugins/payment/service.js +++ b/src/server/plugins/payment/service.js @@ -138,7 +138,9 @@ export class PaymentService { ) if (response.payload?.state.status !== 'created') { - throw new Error('Failed to create payment') + throw new Error( + `Failed to create payment for reference=${payload.reference}` + ) } return response.payload diff --git a/src/server/plugins/payment/service.test.js b/src/server/plugins/payment/service.test.js new file mode 100644 index 000000000..e97deb1e8 --- /dev/null +++ b/src/server/plugins/payment/service.test.js @@ -0,0 +1,205 @@ +import { PaymentService } from '~/src/server/plugins/payment/service.js' +import { get, post, postJson } from '~/src/server/services/httpService.js' + +jest.mock('~/src/server/services/httpService.ts') + +describe('payment service', () => { + const service = new PaymentService('my-api-key') + describe('constructor', () => { + it('should create instance', () => { + expect(service).toBeDefined() + }) + }) + + describe('createPayment', () => { + it('should create a payment', async () => { + const createPaymentResult = { + payment_id: 'payment-id-12345', + _links: { + next_url: { + href: 'http://next-url-href/payment' + } + }, + state: { + status: 'created' + } + } + jest.mocked(postJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: createPaymentResult, + error: undefined + }) + + const referenceNumber = 'ABC-DEF-123' + const returnUrl = 'http://localhost:3009/payment-callback-handler' + const metadata = { formId: 'form-id', slug: 'my-form-slug' } + const payment = await service.createPayment( + 100, + 'Payment description', + returnUrl, + referenceNumber, + metadata + ) + expect(payment.paymentId).toBe('payment-id-12345') + expect(payment.paymentUrl).toBe('http://next-url-href/payment') + }) + + it('should throw if fails to create a payment - failed API call', async () => { + jest + .mocked(postJson) + .mockRejectedValueOnce(new Error('internal creation error')) + + const referenceNumber = 'ABC-DEF-123' + const returnUrl = 'http://localhost:3009/payment-callback-handler' + const metadata = { formId: 'form-id', slug: 'my-form-slug' } + await expect(() => + service.createPayment( + 100, + 'Payment description', + returnUrl, + referenceNumber, + metadata + ) + ).rejects.toThrow('internal creation error') + }) + + it('should throw if fails to create a payment - bad result from API call', async () => { + const createPaymentResult = { + state: { + status: 'failed' + } + } + jest.mocked(postJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: createPaymentResult, + error: undefined + }) + + const referenceNumber = 'ABC-DEF-123' + const returnUrl = 'http://localhost:3009/payment-callback-handler' + const metadata = { formId: 'form-id', slug: 'my-form-slug' } + await expect(() => + service.createPayment( + 100, + 'Payment description', + returnUrl, + referenceNumber, + metadata + ) + ).rejects.toThrow('Failed to create payment') + }) + }) + + describe('getPaymentStatus', () => { + it('should get payment status if exists', async () => { + const getPaymentStatusResult = { + 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: getPaymentStatusResult, + error: undefined + }) + + const paymentStatus = await service.getPaymentStatus('payment-id-12345') + expect(paymentStatus.payment_id).toBe('payment-id-12345') + expect(paymentStatus._links.next_url?.href).toBe( + 'http://next-url-href/payment' + ) + }) + + it('should handle payment status error', async () => { + jest.mocked(get).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: undefined, + error: new Error('some-error') + }) + + await expect(() => + service.getPaymentStatus('payment-id-12345') + ).rejects.toThrow('Failed to get payment status: some-error') + }) + }) + + describe('capturePayment', () => { + it('should return true when successful capture with statusCode 200', async () => { + const capturePaymentResult = {} + jest.mocked(post).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: capturePaymentResult, + error: undefined + }) + + const captureResult = await service.capturePayment('payment-id-12345') + expect(captureResult).toBe(true) + }) + + it('should return true when successful capture with statusCode 204', async () => { + const capturePaymentResult = {} + jest.mocked(post).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 204, + headers: {} + }), + payload: capturePaymentResult, + error: undefined + }) + + const captureResult = await service.capturePayment('payment-id-12345') + expect(captureResult).toBe(true) + }) + + it('should return false when status code not 200 or 204', async () => { + const capturePaymentResult = {} + jest.mocked(post).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 500, + headers: {} + }), + payload: capturePaymentResult, + error: undefined + }) + + const captureResult = await service.capturePayment('payment-id-12345') + expect(captureResult).toBe(false) + }) + + it('should throw when internal error', async () => { + jest + .mocked(post) + .mockRejectedValueOnce(new Error('internal capture error')) + + await expect(() => + service.capturePayment('payment-id-12345') + ).rejects.toThrow('internal capture error') + }) + }) +}) + +/** + * @import { IncomingMessage } from 'node:http' + */ From 1ee302ac08a0de6ac1ad10b5c6d27e8c980bca46 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 26 Jan 2026 08:58:25 +0000 Subject: [PATCH 24/70] Added some tests for PaymentField --- .../engine/components/PaymentField.test.ts | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 src/server/plugins/engine/components/PaymentField.test.ts 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..cc8c2ab0e --- /dev/null +++ b/src/server/plugins/engine/components/PaymentField.test.ts @@ -0,0 +1,221 @@ +import { ComponentType, type PaymentFieldComponent } from '@defra/forms-model' + +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.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 { type FormValue } from '~/src/server/plugins/engine/types.js' +import definition from '~/test/form/definitions/blank.js' +import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' + +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() + }) + }) + }) +}) From 17de9a6c95888802c391ed377b5a888637de3d44 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 26 Jan 2026 11:18:36 +0000 Subject: [PATCH 25/70] More coverage on PaymentField --- .../engine/components/PaymentField.test.ts | 249 +++++++++++++++++- 1 file changed, 247 insertions(+), 2 deletions(-) diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts index cc8c2ab0e..1f4b37c44 100644 --- a/src/server/plugins/engine/components/PaymentField.test.ts +++ b/src/server/plugins/engine/components/PaymentField.test.ts @@ -1,15 +1,30 @@ -import { ComponentType, type PaymentFieldComponent } from '@defra/forms-model' +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 { type FormValue } from '~/src/server/plugins/engine/types.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 @@ -218,4 +233,234 @@ describe('PaymentField', () => { }) }) }) + + 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 + + await expect(() => + paymentField.onSubmit( + mockRequest, + {} as FormMetadata, + { state: {} } as FormContext + ) + ).rejects.toThrow('Invalid component state for: myComponent') + }) + + it('should ignore if 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() + }) + + // TODO - understand the difference between this test and the previous + it('should mark payment already captured', async () => { + const mockRequest = {} as unknown as FormRequestPayload + // @ts-expect-error - partial mock + jest + .mocked(get) + .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 + // @ts-expect-error - partial mock + jest + .mocked(get) + .mockResolvedValueOnce({ payload: { state: { status: 'bad' } } }) + await expect(() => + paymentField.onSubmit( + mockRequest, + {} as FormMetadata, + { + state: { + myComponent: { + paymentId: 'payment-id', + amount: 123, + description: 'Payment desc', + isLivePayment: false, + formId: 'form-id' + } + } + } as unknown as FormContext + ) + ).rejects.toThrow() + }) + + it('should throw if error during capture', async () => { + const mockRequest = {} as unknown as FormRequestPayload + // @ts-expect-error - partial mock + jest + .mocked(get) + .mockResolvedValueOnce({ + payload: { state: { status: 'capturable' } } + }) + // @ts-expect-error - partial mock + jest.mocked(post).mockResolvedValueOnce({ res: { statusCode: 400 } }) + await expect(() => + paymentField.onSubmit( + mockRequest, + {} as FormMetadata, + { + state: { + myComponent: { + paymentId: 'payment-id', + amount: 123, + description: 'Payment desc', + isLivePayment: false, + formId: 'form-id' + } + } + } as unknown as FormContext + ) + ).rejects.toThrow() + }) + + it('should capture payment if no errors', async () => { + const mockRequest = {} as unknown as FormRequestPayload + // @ts-expect-error - partial mock + jest + .mocked(get) + .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() + }) + }) + }) }) From d325679ddb3b3ea5b55cb06eb16eb7fe0f01d624 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 26 Jan 2026 11:25:23 +0000 Subject: [PATCH 26/70] Corrected formating/linting --- src/server/plugins/engine/components/PaymentField.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts index 1f4b37c44..8445bada7 100644 --- a/src/server/plugins/engine/components/PaymentField.test.ts +++ b/src/server/plugins/engine/components/PaymentField.test.ts @@ -356,9 +356,9 @@ describe('PaymentField', () => { // TODO - understand the difference between this test and the previous it('should mark payment already captured', async () => { const mockRequest = {} as unknown as FormRequestPayload - // @ts-expect-error - partial mock jest .mocked(get) + // @ts-expect-error - partial mock .mockResolvedValueOnce({ payload: { state: { status: 'success' } } }) await paymentField.onSubmit( mockRequest, @@ -381,9 +381,9 @@ describe('PaymentField', () => { it('should throw if bad status', async () => { const mockRequest = {} as unknown as FormRequestPayload - // @ts-expect-error - partial mock jest .mocked(get) + // @ts-expect-error - partial mock .mockResolvedValueOnce({ payload: { state: { status: 'bad' } } }) await expect(() => paymentField.onSubmit( @@ -406,9 +406,9 @@ describe('PaymentField', () => { it('should throw if error during capture', async () => { const mockRequest = {} as unknown as FormRequestPayload - // @ts-expect-error - partial mock jest .mocked(get) + // @ts-expect-error - partial mock .mockResolvedValueOnce({ payload: { state: { status: 'capturable' } } }) @@ -435,9 +435,9 @@ describe('PaymentField', () => { it('should capture payment if no errors', async () => { const mockRequest = {} as unknown as FormRequestPayload - // @ts-expect-error - partial mock jest .mocked(get) + // @ts-expect-error - partial mock .mockResolvedValueOnce({ payload: { state: { status: 'capturable' } } }) From 0e7b7c5602be89d1428f8a4376997a0fc735fa75 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 26 Jan 2026 11:28:47 +0000 Subject: [PATCH 27/70] Added env var for tests --- jest.setup.cjs | 1 + 1 file changed, 1 insertion(+) 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' From a34a866a88bd0e3ca19acbb06ccb9fba94827eb9 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Fri, 23 Jan 2026 16:46:55 +0000 Subject: [PATCH 28/70] chore(payment-test): add schema version to payment test form --- src/server/forms/payment-test.yaml | 1 + 1 file changed, 1 insertion(+) 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: From dbc6e127182b46f2ee5142fa60b44b41f9f55537 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Fri, 23 Jan 2026 17:07:58 +0000 Subject: [PATCH 29/70] feat(payment): enhance email formatting to include payment details section --- .../engine/outputFormatters/human/v1.ts | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/server/plugins/engine/outputFormatters/human/v1.ts b/src/server/plugins/engine/outputFormatters/human/v1.ts index d83bcebc6..2e9280ba1 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.ts @@ -7,9 +7,13 @@ 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' const designerUrl = config.get('designerUrl') @@ -49,7 +53,11 @@ export function format( lines.push(`${formName} form received at ${escapeMarkdown(formattedNow)}.\n`) lines.push('---\n') - items.forEach((item) => { + // Separate payment items from regular items + 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 +81,51 @@ 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 + } + + // Get the first payment item (forms only have one payment) + const paymentItem = paymentItems[0] as DetailItemField + const paymentField = paymentItem.field as PaymentField + const paymentState = paymentField.getPaymentStateFromState(paymentItem.state) + + if (!paymentState) { + return + } + + const dateOfPayment = paymentState.preAuth?.createdAt + ? `${dateFormat(new Date(paymentState.preAuth.createdAt), 'h:mmaaa')} on ${dateFormat(new Date(paymentState.preAuth.createdAt), 'd MMMM yyyy')}` + : '' + + lines.push('---\n') + lines.push(`# Your payment of £${paymentState.amount} was successful\n`) + lines.push('## Payment for\n') + lines.push(`${escapeMarkdown(paymentState.description)}\n`) + lines.push('---\n') + lines.push('## Total amount\n') + lines.push(`£${paymentState.amount}\n`) + lines.push('---\n') + lines.push('## Date of payment\n') + lines.push(`${escapeMarkdown(dateOfPayment)}\n`) + lines.push('---\n') +} From 536c86be4447eb9eeb1b768ed1373a870443bf40 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Mon, 26 Jan 2026 13:58:02 +0000 Subject: [PATCH 30/70] refactor: add factory for payment service, helpers, remove unneeded comments --- .../plugins/engine/components/PaymentField.ts | 28 +++-------- .../engine/outputFormatters/human/v1.ts | 13 +++-- .../pageControllers/SummaryPageController.ts | 49 +++++-------------- src/server/plugins/engine/routes/payment.js | 20 ++++---- src/server/plugins/payment/helper.js | 44 +++++++++++++++++ 5 files changed, 78 insertions(+), 76 deletions(-) diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index f8ed140de..19d8b18f1 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -25,8 +25,7 @@ import { type FormSubmissionError, type FormSubmissionState } from '~/src/server/plugins/engine/types.js' -import { getPaymentApiKey } from '~/src/server/plugins/payment/helper.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'] @@ -41,7 +40,6 @@ export class PaymentField extends FormComponent { this.options = def.options - // Payment state is validated as an object with the required fields const paymentStateSchema = joi .object({ paymentId: joi.string().required(), @@ -161,11 +159,8 @@ export class PaymentField extends FormComponent { ): Promise { const isLivePayment = args.isLive && !args.isPreview const formId = args.controller.model.formId - const apiKeyValue = getPaymentApiKey(isLivePayment, formId) + const paymentService = createPaymentService(isLivePayment, formId) - const paymentService = new PaymentService(apiKeyValue) - - // 1. Generate UUID token const uuid = randomUUID() const { options, name: componentName } = args.component @@ -178,16 +173,11 @@ export class PaymentField extends FormComponent { const slug = `/${model.basePath}` - // 2. Build the return URL for GOV.UK Pay const { baseUrl } = getPluginOptions(request.server) const payCallbackUrl = `${baseUrl}/payment-callback?uuid=${uuid}` - - // Build URLs for redirect after payment const summaryUrl = `${baseUrl}/${model.basePath}/summary` const paymentPageUrl = args.sourceUrl - // 3. Call paymentService.createPayment() - // GOV.UK Pay expects amount in pence, so multiply pounds by 100 const amountInPence = Math.round(amount * 100) const payment = await paymentService.createPayment( amountInPence, @@ -197,7 +187,6 @@ export class PaymentField extends FormComponent { { formId, slug } ) - // 4. Store session data for the return route to use const sessionData: PaymentSessionData = { uuid, formId, @@ -213,7 +202,6 @@ export class PaymentField extends FormComponent { request.yar.set(`payment-${uuid}`, sessionData) - // 5. Redirect to GOV.UK Pay paymentUrl return h.redirect(payment.paymentUrl).code(StatusCodes.SEE_OTHER) } @@ -229,7 +217,6 @@ export class PaymentField extends FormComponent { const paymentState = this.getPaymentStateFromState(context.state) if (!paymentState) { - // No payment state - redirect to payment page to complete payment throw new InvalidComponentStateError( this, 'Complete the payment to continue', @@ -237,19 +224,18 @@ export class PaymentField extends FormComponent { ) } - // Skip if already captured if (paymentState.capture?.status === 'success') { return } const { paymentId, isLivePayment, formId } = paymentState - const apiKey = getPaymentApiKey(isLivePayment, formId) - const paymentService = new PaymentService(apiKey) + const paymentService = createPaymentService(isLivePayment, formId) - // Verify payment is still in capturable state + /** + * @see https://docs.payments.service.gov.uk/api_reference/#payment-status-lifecycle + */ const status = await paymentService.getPaymentStatus(paymentId) - // If already captured (success state), mark as captured and continue if (status.state.status === 'success') { await this.markPaymentCaptured(request, paymentState) return @@ -263,7 +249,6 @@ export class PaymentField extends FormComponent { ) } - // Capture the payment const captured = await paymentService.capturePayment(paymentId) if (!captured) { @@ -293,7 +278,6 @@ export class PaymentField extends FormComponent { } } - // Update the state in the page controller if (this.page) { const currentState = await this.page.getState(request) await this.page.mergeState(request, currentState, { diff --git a/src/server/plugins/engine/outputFormatters/human/v1.ts b/src/server/plugins/engine/outputFormatters/human/v1.ts index 2e9280ba1..5a5f954b8 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.ts @@ -15,6 +15,10 @@ import { 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') @@ -53,7 +57,6 @@ export function format( lines.push(`${formName} form received at ${escapeMarkdown(formattedNow)}.\n`) lines.push('---\n') - // Separate payment items from regular items const regularItems = items.filter((item) => !isPaymentItem(item)) const paymentItems = items.filter((item) => isPaymentItem(item)) @@ -104,7 +107,6 @@ function appendPaymentSection(paymentItems: DetailItem[], lines: string[]) { return } - // Get the first payment item (forms only have one payment) const paymentItem = paymentItems[0] as DetailItemField const paymentField = paymentItem.field as PaymentField const paymentState = paymentField.getPaymentStateFromState(paymentItem.state) @@ -113,17 +115,18 @@ function appendPaymentSection(paymentItems: DetailItem[], lines: string[]) { return } + const formattedAmount = formatPaymentAmount(paymentState.amount) const dateOfPayment = paymentState.preAuth?.createdAt - ? `${dateFormat(new Date(paymentState.preAuth.createdAt), 'h:mmaaa')} on ${dateFormat(new Date(paymentState.preAuth.createdAt), 'd MMMM yyyy')}` + ? formatPaymentDate(paymentState.preAuth.createdAt) : '' lines.push('---\n') - lines.push(`# Your payment of £${paymentState.amount} was successful\n`) + lines.push(`# Your payment of ${formattedAmount} was successful\n`) lines.push('## Payment for\n') lines.push(`${escapeMarkdown(paymentState.description)}\n`) lines.push('---\n') lines.push('## Total amount\n') - lines.push(`£${paymentState.amount}\n`) + lines.push(`${formattedAmount}\n`) lines.push('---\n') lines.push('## Date of payment\n') lines.push(`${escapeMarkdown(dateOfPayment)}\n`) diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 3e44978f4..f786edc65 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -39,6 +39,11 @@ import { 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, @@ -76,8 +81,6 @@ export class SummaryPageController extends QuestionPageController { const { payload, errors, state } = context 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 @@ -85,7 +88,6 @@ export class SummaryPageController extends QuestionPageController { viewModel.allowSaveAndExit = this.shouldShowSaveAndExit(request.server) viewModel.errors = errors - // Find PaymentField and extract payment state for the summary banner const paymentField = context.relevantPages .flatMap((page) => page.collection.fields) .find((field): field is PaymentField => field instanceof PaymentField) @@ -110,23 +112,6 @@ export class SummaryPageController extends QuestionPageController { ReturnType > ) { - const formatDate = (isoString: string) => { - const date = new Date(isoString) - return ( - date.toLocaleDateString('en-GB', { - day: 'numeric', - month: 'long', - year: 'numeric' - }) + - ' – ' + - date.toLocaleTimeString('en-GB', { - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }) - ) - } - const rows = [ { key: { text: 'Payment for' }, @@ -134,7 +119,7 @@ export class SummaryPageController extends QuestionPageController { }, { key: { text: 'Total amount' }, - value: { text: `£${paymentState.amount}` } + value: { text: formatPaymentAmount(paymentState.amount) } }, { key: { text: 'Reference' }, @@ -144,8 +129,8 @@ export class SummaryPageController extends QuestionPageController { if (paymentState.preAuth?.createdAt) { rows.push({ - key: { text: 'Date details were entered' }, - value: { text: formatDate(paymentState.preAuth.createdAt) } + key: { text: 'Date of payment' }, + value: { text: formatPaymentDate(paymentState.preAuth.createdAt) } }) } @@ -185,7 +170,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) @@ -208,14 +192,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) @@ -239,7 +221,6 @@ 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()) @@ -301,13 +282,12 @@ export class SummaryPageController extends QuestionPageController { request: FormRequestPayload, h: FormResponseToolkit ) { - const helpLink = error.helpLink - ? ` or you can contact us (opens in new tab) and quote your reference number to arrange a refund` - : '' + 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${helpLink}.` + `There was a problem and your form was not submitted. Try submitting the form again${helpLinkHtml}.` ) request.yar.flash(COMPONENT_STATE_ERROR, govukError, true) @@ -339,7 +319,6 @@ export async function submitForm( ) { await finaliseComponents(request, metadata, context) - // Check if payment was captured (for Flow 9 error handling) const paymentWasCaptured = hasPaymentBeenCaptured(context) const formStatus = checkFormStatus(request.params) @@ -347,14 +326,12 @@ export async function submitForm( request.logger.info(logTags, 'Preparing email', formStatus) - // Get detail items const items = getFormSubmissionData( summaryViewModel.context, summaryViewModel.details ) try { - // Submit data request.logger.info(logTags, 'Submitting data') const submitResponse = await submitData( model, @@ -442,7 +419,6 @@ function submitData( sessionId, retrievalKey, - // Main form answers main: items .filter((item) => 'field' in item) .map((item) => ({ @@ -451,14 +427,12 @@ function submitData( 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, @@ -481,7 +455,6 @@ export function getFormSubmissionData(context: FormContext, details: Detail[]) { ) .flat() - // Add payment field items (excluded from details for UI but needed for submission) const paymentItems = getPaymentFieldItems(context) return [...items, ...paymentItems] diff --git a/src/server/plugins/engine/routes/payment.js b/src/server/plugins/engine/routes/payment.js index dae2328a6..902c42ef4 100644 --- a/src/server/plugins/engine/routes/payment.js +++ b/src/server/plugins/engine/routes/payment.js @@ -3,8 +3,7 @@ import { StatusCodes } from 'http-status-codes' import Joi from 'joi' import { EXTERNAL_STATE_APPENDAGE } from '~/src/server/constants.js' -import { getPaymentApiKey } from '~/src/server/plugins/payment/helper.js' -import { PaymentService } from '~/src/server/plugins/payment/service.js' +import { createPaymentService } from '~/src/server/plugins/payment/helper.js' export const PAYMENT_RETURN_PATH = '/payment-callback' export const PAYMENT_SESSION_PREFIX = 'payment-' @@ -70,8 +69,7 @@ async function getPaymentContext(request, uuid) { throw Boom.badRequest('No paymentId in session') } - const apiKey = getPaymentApiKey(isLivePayment, formId) - const paymentService = new PaymentService(apiKey) + const paymentService = createPaymentService(isLivePayment, formId) const paymentStatus = await paymentService.getPaymentStatus(paymentId) return { session, sessionKey, paymentStatus } @@ -119,12 +117,12 @@ function getReturnRoute() { uuid ) - // Handle different payment states based on GOV.UK Pay status lifecycle - // @see https://docs.payments.service.gov.uk/api_reference/#payment-status-lifecycle + /** + * @see https://docs.payments.service.gov.uk/api_reference/#payment-status-lifecycle + */ const { status } = paymentStatus.state switch (status) { - // Pre-auth successful or already captured case 'capturable': case 'success': return handlePaymentSuccess( @@ -135,13 +133,11 @@ function getReturnRoute() { session.paymentId ) - // Payment failed, cancelled, or errored - redirect to retry case 'cancelled': case 'failed': case 'error': return handlePaymentFailure(request, h, session, sessionKey) - // User came back too early - redirect back to GOV.UK Pay case 'created': case 'started': case 'submitted': { @@ -156,8 +152,10 @@ function getReturnRoute() { return h.redirect(nextUrl).code(StatusCodes.SEE_OTHER) } - default: - throw Boom.internal(`Unknown payment status: ${String(status)}`) + default: { + const unknownStatus = /** @type {string} */ (status) + throw Boom.internal(`Unknown payment status: ${unknownStatus}`) + } } }, options: { diff --git a/src/server/plugins/payment/helper.js b/src/server/plugins/payment/helper.js index 79b4384fb..90ae7b822 100644 --- a/src/server/plugins/payment/helper.js +++ b/src/server/plugins/payment/helper.js @@ -1,4 +1,8 @@ import { config } from '~/src/config/index.js' +import { PaymentService } from '~/src/server/plugins/payment/service.js' + +export const DEFAULT_PAYMENT_HELP_URL = + 'https://www.gov.uk/government/organisations/department-for-environment-food-rural-affairs' /** * Determine which payment API key value to use. @@ -20,3 +24,43 @@ export function getPaymentApiKey(isLivePayment, formId) { } return apiKeyValue } + +/** + * Creates a PaymentService instance with the appropriate API key + * @param {boolean} isLivePayment - true if this is a live payment + * @param {string} formId - id of the form + * @returns {PaymentService} + */ +export function createPaymentService(isLivePayment, formId) { + const apiKey = getPaymentApiKey(isLivePayment, formId) + return new PaymentService(apiKey) +} + +/** + * Formats a payment date for display + * @param {string} isoString - ISO date string + * @returns {string} Formatted date string (e.g., "26 January 2026 – 17:01:29") + */ +export function formatPaymentDate(isoString) { + const date = new Date(isoString) + const dateStr = date.toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric' + }) + const timeStr = date.toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + return `${dateStr} – ${timeStr}` +} + +/** + * Formats a payment amount with two decimal places + * @param {number} amount - amount in pounds + * @returns {string} Formatted amount (e.g., "£10.00") + */ +export function formatPaymentAmount(amount) { + return `£${amount.toFixed(2)}` +} From 30be6de8dd912df4c8c02176cec0fc0cbab2c844 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 26 Jan 2026 14:34:30 +0000 Subject: [PATCH 31/70] Payment route tests --- .../engine/components/PaymentField.test.ts | 48 ++++++++--- .../plugins/engine/routes/payment-helper.js | 39 +++++++++ src/server/plugins/engine/routes/payment.js | 49 +---------- .../plugins/engine/routes/payment.test.js | 85 +++++++++++++++++++ src/server/plugins/payment/types.js | 15 ++++ 5 files changed, 177 insertions(+), 59 deletions(-) create mode 100644 src/server/plugins/engine/routes/payment-helper.js create mode 100644 src/server/plugins/engine/routes/payment.test.js diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts index 8445bada7..6f11d2afb 100644 --- a/src/server/plugins/engine/components/PaymentField.test.ts +++ b/src/server/plugins/engine/components/PaymentField.test.ts @@ -11,6 +11,7 @@ import { 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 @@ -321,16 +322,24 @@ describe('PaymentField', () => { it('should throw if missing state', async () => { const mockRequest = {} as unknown as FormRequestPayload - await expect(() => - paymentField.onSubmit( + const error = await paymentField + .onSubmit( mockRequest, {} as FormMetadata, { state: {} } as FormContext ) - ).rejects.toThrow('Invalid component state for: myComponent') + .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 payment already captured', async () => { + it('should ignore if our state says payment already captured', async () => { const mockRequest = {} as unknown as FormRequestPayload await paymentField.onSubmit( @@ -353,8 +362,7 @@ describe('PaymentField', () => { expect(post).not.toHaveBeenCalled() }) - // TODO - understand the difference between this test and the previous - it('should mark payment already captured', async () => { + it('should mark payment already captured according to gov pay', async () => { const mockRequest = {} as unknown as FormRequestPayload jest .mocked(get) @@ -385,8 +393,8 @@ describe('PaymentField', () => { .mocked(get) // @ts-expect-error - partial mock .mockResolvedValueOnce({ payload: { state: { status: 'bad' } } }) - await expect(() => - paymentField.onSubmit( + const error = await paymentField + .onSubmit( mockRequest, {} as FormMetadata, { @@ -401,7 +409,15 @@ describe('PaymentField', () => { } } as unknown as FormContext ) - ).rejects.toThrow() + .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 () => { @@ -414,8 +430,8 @@ describe('PaymentField', () => { }) // @ts-expect-error - partial mock jest.mocked(post).mockResolvedValueOnce({ res: { statusCode: 400 } }) - await expect(() => - paymentField.onSubmit( + const error = await paymentField + .onSubmit( mockRequest, {} as FormMetadata, { @@ -430,7 +446,15 @@ describe('PaymentField', () => { } } as unknown as FormContext ) - ).rejects.toThrow() + .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 () => { 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.js b/src/server/plugins/engine/routes/payment.js index dae2328a6..21fa6e380 100644 --- a/src/server/plugins/engine/routes/payment.js +++ b/src/server/plugins/engine/routes/payment.js @@ -3,8 +3,7 @@ import { StatusCodes } from 'http-status-codes' import Joi from 'joi' import { EXTERNAL_STATE_APPENDAGE } from '~/src/server/constants.js' -import { getPaymentApiKey } from '~/src/server/plugins/payment/helper.js' -import { PaymentService } from '~/src/server/plugins/payment/service.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-' @@ -48,35 +47,6 @@ export function getRoutes() { return [getReturnRoute()] } -/** - * 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 }>} - */ -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 } -} - /** * Handles successful payment states (capturable/success) * @param {Request} request - the request @@ -172,24 +142,9 @@ function getReturnRoute() { } } -/** - * Payment session data stored when dispatching to GOV.UK Pay - * @typedef {object} PaymentSessionData - * @property {string} uuid - unique identifier for this payment attempt - * @property {string} formId - id of the form - * @property {string} reference - form reference number - * @property {number} amount - amount in pounds - * @property {string} description - payment description - * @property {string} paymentId - GOV.UK Pay payment ID - * @property {string} componentName - name of the PaymentField component - * @property {string} returnUrl - URL to redirect to after successful payment - * @property {string} failureUrl - URL to redirect to after failed/cancelled payment - * @property {boolean} isLivePayment - whether the payment is using live API key - */ - /** * @import { Request, ResponseToolkit, ServerRoute } from '@hapi/hapi' + * @import { PaymentSessionData } from '~/src/server/plugins/payment/types.js' * @import { PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js' - * @import { GetPaymentResponse } from '~/src/server/plugins/payment/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..d3ddd6a37 --- /dev/null +++ b/src/server/plugins/engine/routes/payment.test.js @@ -0,0 +1,85 @@ +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' } + ])('should handle payment status of $row.status', async (row) => { + const paymentStatus = { + paymentId: 'new-payment-id', + // TODO - resolve name mismatch + payment_id: 'new-payment-id', + _links: { + next_url: { + href: '/next-url', + method: 'get' + }, + self: { + href: '/self', + method: 'get' + } + }, + state: /** @type {PaymentState} */ ({ + 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) + }) + }) +}) + +/** + * @import { Server } from '@hapi/hapi' + * @import { PaymentState } from '~/src/server/plugins/payment/types.js' + */ diff --git a/src/server/plugins/payment/types.js b/src/server/plugins/payment/types.js index d78da3b7e..531ec0b31 100644 --- a/src/server/plugins/payment/types.js +++ b/src/server/plugins/payment/types.js @@ -35,3 +35,18 @@ * @property {PaymentState} state - Current state of the payment * @property {{ self: PaymentLink, next_url?: PaymentLink }} _links - HATEOAS links for the payment */ + +/** + * Payment session data stored when dispatching to GOV.UK Pay + * @typedef {object} PaymentSessionData + * @property {string} uuid - unique identifier for this payment attempt + * @property {string} formId - id of the form + * @property {string} reference - form reference number + * @property {number} amount - amount in pounds + * @property {string} description - payment description + * @property {string} paymentId - GOV.UK Pay payment ID + * @property {string} componentName - name of the PaymentField component + * @property {string} returnUrl - URL to redirect to after successful payment + * @property {string} failureUrl - URL to redirect to after failed/cancelled payment + * @property {boolean} isLivePayment - whether the payment is using live API key + */ From a69a7eef5b5ba87f9e457ae0c0f09835074a1b05 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 26 Jan 2026 15:14:16 +0000 Subject: [PATCH 32/70] Extra route coverage --- .../plugins/engine/routes/payment.test.js | 70 ++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/engine/routes/payment.test.js b/src/server/plugins/engine/routes/payment.test.js index d3ddd6a37..174b4dd9a 100644 --- a/src/server/plugins/engine/routes/payment.test.js +++ b/src/server/plugins/engine/routes/payment.test.js @@ -45,7 +45,10 @@ describe('Payment routes', () => { { 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: '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', @@ -76,6 +79,71 @@ describe('Payment routes', () => { 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', + // TODO - resolve name mismatch + payment_id: 'new-payment-id', + _links: { + next_url: {}, + self: { + href: '/self', + method: 'get' + } + }, + state: /** @type {PaymentState} */ ({ + status: 'created', + finished: true + }) + } + jest.mocked(getPaymentContext).mockResolvedValueOnce({ + session: paymentSessionData, + sessionKey, + // @ts-expect-error - missing elements deliberately for test + 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', + // TODO - resolve name mismatch + payment_id: '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 - invalid status deliberately for test + 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') + }) }) }) From c32768c8360ea51888ebbe2c14228c868ea5d5be Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Mon, 26 Jan 2026 14:07:40 +0000 Subject: [PATCH 33/70] test(payment): add tests for formatPaymentDate and formatPaymentAmount function --- src/server/plugins/payment/helper.test.js | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/payment/helper.test.js b/src/server/plugins/payment/helper.test.js index 624538584..e11974619 100644 --- a/src/server/plugins/payment/helper.test.js +++ b/src/server/plugins/payment/helper.test.js @@ -1,5 +1,9 @@ import { config } from '~/src/config/index.js' -import { getPaymentApiKey } from '~/src/server/plugins/payment/helper.js' +import { + formatPaymentAmount, + formatPaymentDate, + getPaymentApiKey +} from '~/src/server/plugins/payment/helper.js' describe('getPaymentApiKey', () => { config.set('paymentProviderApiKeyTest', 'TEST-API-KEY') @@ -16,3 +20,20 @@ describe('getPaymentApiKey', () => { expect(apiKey).toBe('LIVE-API-KEY') }) }) + +describe('formatPaymentDate', () => { + it('should format ISO date string to en-GB format', () => { + const result = formatPaymentDate('2025-11-10T17:01:29.000Z') + expect(result).toBe('10 November 2025 – 17:01:29') + }) +}) + +describe('formatPaymentAmount', () => { + it('should format whole number with two decimal places', () => { + expect(formatPaymentAmount(10)).toBe('£10.00') + }) + + it('should format decimal amount', () => { + expect(formatPaymentAmount(99.5)).toBe('£99.50') + }) +}) From a3ca63f3c820faf0a04d4ffc44f05e3c6ef31519 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Mon, 26 Jan 2026 17:25:14 +0000 Subject: [PATCH 34/70] refactor: fix circular import --- .../engine/outputFormatters/machine/v2.ts | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.ts b/src/server/plugins/engine/outputFormatters/machine/v2.ts index 3b79709a2..710ee289d 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v2.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v2.ts @@ -15,12 +15,22 @@ import { import { type FileUploadFieldDetailitem, type FormAdapterFile, - type FormAdapterPayment, type FormContext, - type PaymentFieldDetailItem, 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( @@ -96,16 +106,7 @@ export function categoriseData(items: DetailItem[]) { string, { fileId: string; fileName: string; userDownloadLink: string }[] > - payments: Record< - string, - { - paymentId: string - reference: string - amount: number - description: string - createdAt: string - } - > + payments: Record } = { main: {}, repeaters: {}, files: {}, payments: {} } items.forEach((item) => { @@ -178,9 +179,9 @@ function isFileUploadFieldItem( return item.field instanceof FileUploadField } -function isPaymentFieldItem( - item: DetailItemField -): item is PaymentFieldDetailItem { +function isPaymentFieldItem(item: DetailItemField): item is DetailItemField & { + field: PaymentField +} { return item.field instanceof PaymentField } @@ -190,8 +191,8 @@ function isPaymentFieldItem( * @returns the payment data */ function extractPayment( - item: PaymentFieldDetailItem -): FormAdapterPayment | undefined { + item: DetailItemField & { field: PaymentField } +): PaymentOutput | undefined { const paymentState = item.field.getPaymentStateFromState(item.state) if (!paymentState) { From 2c29585d622095d3e433799283128393088eb335 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 27 Jan 2026 08:38:02 +0000 Subject: [PATCH 35/70] Tests for payment-helper --- .../engine/routes/payment-helper.test.js | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/server/plugins/engine/routes/payment-helper.test.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..9cdfededd --- /dev/null +++ b/src/server/plugins/engine/routes/payment-helper.test.js @@ -0,0 +1,80 @@ +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 getPaymentStatusResult = { + 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: getPaymentStatusResult, + error: undefined + }) + + // @ts-expect-error - partial request mock + const res = await getPaymentContext(mockRequest, uuid) + expect(res).toEqual({ + paymentStatus: getPaymentStatusResult, + session: { + formId: 'form-id', + isLivePayment: false, + paymentId: 'payment-id' + }, + sessionKey: 'payment-5a54c2fe-da49-4202-8cd3-2121eaca03c3' + }) + }) +}) + +/** + * @import { IncomingMessage } from 'node:http' + */ From 9cd6adb88ead3dca0a522db60804f1c47e1c4c59 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 27 Jan 2026 08:57:23 +0000 Subject: [PATCH 36/70] feat(submission): implement building submission records for csv --- .../pageControllers/SummaryPageController.ts | 33 +- .../helpers/submission.test.ts | 299 ++++++++++++++++++ .../pageControllers/helpers/submission.ts | 110 +++++++ 3 files changed, 418 insertions(+), 24 deletions(-) create mode 100644 src/server/plugins/engine/pageControllers/helpers/submission.test.ts create mode 100644 src/server/plugins/engine/pageControllers/helpers/submission.ts diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index f786edc65..33551ac54 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -13,7 +13,6 @@ import { } from '~/src/server/constants.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js' -import { getAnswer } from '~/src/server/plugins/engine/components/helpers/components.js' import { checkEmailAddressForLiveFormSubmission, checkFormStatus, @@ -34,6 +33,10 @@ 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, @@ -406,6 +409,9 @@ async function finaliseComponents( } } +/** + * Builds and submits the payload to forms-submission-api. + */ function submitData( model: FormModel, items: DetailItem[], @@ -418,29 +424,8 @@ function submitData( const payload: SubmitPayload = { sessionId, retrievalKey, - - main: items - .filter((item) => 'field' in item) - .map((item) => ({ - name: item.name, - title: item.label, - value: getAnswer(item.field, item.state, { format: 'data' }) - })), - - repeaters: 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' }) - })) - ) - })) + main: buildMainRecords(items), + repeaters: buildRepeaterRecords(items) } return submit(payload) 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..360acc424 --- /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_paymentFor', + title: 'Payment for', + value: 'Application fee' + }) + expect(result[1]).toEqual({ + name: 'payment_totalAmount', + title: 'Total amount', + value: '£150.00' + }) + expect(result[2]).toEqual({ + name: 'payment_reference', + title: 'Reference', + value: 'REF-ABC-123' + }) + expect(result[3].name).toBe('payment_dateOfPayment') + expect(result[3].title).toBe('Date of payment') + // 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_dateOfPayment', + title: 'Date of payment', + 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_paymentFor', + 'licencePayment_totalAmount', + 'licencePayment_reference', + 'licencePayment_dateOfPayment' + ]) + }) + + 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_paymentFor') + }) + + 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..b6a1b5f1a --- /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 for (description) + * - Total amount (formatted with currency symbol) + * - Reference + * - Date of payment (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}_paymentFor`, + title: 'Payment for', + value: paymentState.description + }, + { + name: `${item.name}_totalAmount`, + title: 'Total amount', + value: formatPaymentAmount(paymentState.amount) + }, + { + name: `${item.name}_reference`, + title: 'Reference', + value: paymentState.reference + }, + { + name: `${item.name}_dateOfPayment`, + title: 'Date of payment', + 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' }) + })) + ) + })) +} From 5dd9502cb41d46b3c2bb20f83c870e41e9cb1dfb Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 27 Jan 2026 11:26:20 +0000 Subject: [PATCH 37/70] Coverage on human v1 --- .../engine/outputFormatters/human/v1.test.ts | 316 ++++++++++++------ test/form/definitions/payment.js | 128 +++++++ 2 files changed, 347 insertions(+), 97 deletions(-) create mode 100644 test/form/definitions/payment.js diff --git a/src/server/plugins/engine/outputFormatters/human/v1.test.ts b/src/server/plugins/engine/outputFormatters/human/v1.test.ts index b2c4dac18..e3b0ff392 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.test.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.test.ts @@ -9,135 +9,257 @@ import { } 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' import definition from '~/test/form/definitions/repeat-mixed.js' -const itemId1 = 'abc-123' -const itemId2 = 'xyz-987' +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 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) + query: {}, + app: { model } + }) - const dateNow = new Date() - const dateExpiry = addDays(dateNow, 90) + const context = model.getFormContext(request, state) + const summaryViewModel = controller.getSummaryViewModel(request, context) - // 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 items = getFormSubmissionData( + summaryViewModel.context, + summaryViewModel.details + ) - expect(body).toContain( - outdent` - ${definition.name} form received at ${dateFormat(dateNow, 'h:mmaaa')} on ${dateFormat(dateNow, 'd MMMM yyyy')}. + 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) - --- + const dateNow = new Date() + const dateExpiry = addDays(dateNow, 90) - ## How would you like to receive your pizza? + // 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')}` + ) - Delivery + expect(body).toContain( + outdent` + ${definition.name} form received at ${dateFormat(dateNow, 'h:mmaaa')} on ${dateFormat(dateNow, 'd MMMM yyyy')}. - --- + --- - ## Pizza + ## How would you like to receive your pizza? - [Download Pizza \\(CSV\\)](https://forms-designer/file-download/11111111-1111-1111-1111-111111111111) + Delivery - --- + --- - [Download main form \\(CSV\\)](https://forms-designer/file-download/00000000-0000-0000-0000-000000000000) - ` - ) + ## Pizza + + [Download Pizza \\(CSV\\)](https://forms-designer/file-download/11111111-1111-1111-1111-111111111111) + + --- + + [Download main form \\(CSV\\)](https://forms-designer/file-download/00000000-0000-0000-0000-000000000000) + ` + ) + }) + + 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 + }) + + const body2 = format(context, items, model, submitResponse, { + state: FormStatus.Draft, + isPreview: true + }) + + 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` + ) + }) }) - it('should add test warnings to preview email only', () => { - const formStatus = { - state: FormStatus.Draft, - isPreview: true + describe('Payment', () => { + const modelPayment = new FormModel(definitionPayment, { + basePath: 'test' + }) + + 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' + } + } } - const body1 = format(context, items, model, submitResponse, { - state: FormStatus.Live, - isPreview: false + const requestPayment = buildFormContextRequest({ + method: 'get', + url: pageUrl, + path: pageUrl.pathname, + params: { + path: 'summary', + slug: 'payment' + }, + query: {}, + app: { model: modelPayment } }) - const body2 = format(context, items, model, submitResponse, { - state: FormStatus.Draft, - isPreview: true - }) + const pageDefPayment = definitionPayment.pages[2] - expect(body1).not.toContain( - `This is a test of the ${definition.name} ${formStatus.state} form` + const controllerPayment = new SummaryPageController( + modelPayment, + pageDefPayment ) - expect(body2).toContain( - `This is a test of the ${definition.name} ${formStatus.state} form` + // TODO - sort type issue + // @ts-expect-error - statePayment doesnt quite match up to type + 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/test/form/definitions/payment.js b/test/form/definitions/payment.js new file mode 100644 index 000000000..479bf0799 --- /dev/null +++ b/test/form/definitions/payment.js @@ -0,0 +1,128 @@ +import { + ComponentType, + ControllerPath, + ControllerType +} from '@defra/forms-model' + +import { + createListFromFactory, + createListItemFactory +} from '~/test/form/factory.js' + +export default /** @satisfies {FormDefinition} */ ({ + name: 'Simple payment', + startPage: '/licence', + pages: /** @type {const} */ ([ + { + title: 'Buy a rod fishing licence', + path: '/licence', + components: [ + { + options: { + bold: true + }, + type: ComponentType.RadiosField, + name: 'licenceLength', + title: 'Which fishing licence do you want to get?', + list: 'licenceLengthDays' + } + ], + section: 'licenceDetails', + next: [ + { + path: '/full-name' + } + ] + }, + { + title: "What's your name?", + path: '/full-name', + components: [ + { + schema: { + max: 70 + }, + options: {}, + type: ComponentType.TextField, + name: 'fullName', + title: "What's your name?" + } + ], + section: 'personalDetails', + next: [ + { + path: '/payment' + } + ] + }, + { + path: '/payment', + title: 'Payment', + components: [ + { + options: { + amount: 250, + description: 'Pay for your licence' + }, + type: ComponentType.PaymentField, + name: 'paymentField', + title: "What's your name?" + } + ], + next: [ + { + path: ControllerPath.Summary + } + ] + }, + { + path: ControllerPath.Summary, + controller: ControllerType.Summary, + title: 'Summary' + } + ]), + sections: [ + { + name: 'licenceDetails', + title: 'Licence details' + }, + { + name: 'personalDetails', + title: 'Personal details' + } + ], + conditions: [], + lists: [ + createListFromFactory({ + name: 'licenceLengthDays', + title: 'Licence length (days)', + type: 'number', + items: [ + createListItemFactory({ + id: '52fc51fc-c75a-4b08-9c9e-6bd99b9bc49b', + text: '1 day', + value: 1, + description: 'Valid for 24 hours from the start time that you select' + }), + createListItemFactory({ + id: '56b7b34f-23b3-4446-ac8e-b2443d18588e', + text: '8 day', + value: 8, + description: + 'Valid for 8 consecutive days from the start time that you select' + }), + createListItemFactory({ + id: '1af54fbc-eec2-4e1e-bd53-2415abf62677', + text: '12 months', + value: 365, + description: + '12-month licences are now valid for 365 days from their start date and can be purchased at any time during the year' + }) + ] + }) + ] +}) + +/** + * @import { FormDefinition } from '@defra/forms-model' + */ From 09aa7cdddc5352be1d5aedaf759a09c59b378a43 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 27 Jan 2026 11:26:45 +0000 Subject: [PATCH 38/70] Lint fix --- src/server/plugins/engine/outputFormatters/human/v1.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/engine/outputFormatters/human/v1.test.ts b/src/server/plugins/engine/outputFormatters/human/v1.test.ts index e3b0ff392..0b9708522 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.test.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.test.ts @@ -188,9 +188,9 @@ describe('v1 human formatter', () => { ) // TODO - sort type issue - // @ts-expect-error - statePayment doesnt quite match up to type const contextPayment = modelPayment.getFormContext( requestPayment, + // @ts-expect-error - statePayment doesnt quite match up to type statePayment ) const summaryViewModelPayment = controllerPayment.getSummaryViewModel( From a95c376f39f80565035e4955aa754688741b3dea Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 27 Jan 2026 12:34:35 +0000 Subject: [PATCH 39/70] feat(payment): add payer email handling in payment state and pre-fill email fields --- .../engine/components/PaymentField.types.ts | 1 + .../pageControllers/SummaryPageController.ts | 61 +++++++++++++++---- src/server/plugins/engine/routes/payment.js | 17 +++--- .../plugins/engine/routes/payment.test.js | 31 ++++++++++ src/server/plugins/payment/types.js | 2 + 5 files changed, 92 insertions(+), 20 deletions(-) diff --git a/src/server/plugins/engine/components/PaymentField.types.ts b/src/server/plugins/engine/components/PaymentField.types.ts index ed1f6cff2..b68e6aee8 100644 --- a/src/server/plugins/engine/components/PaymentField.types.ts +++ b/src/server/plugins/engine/components/PaymentField.types.ts @@ -9,6 +9,7 @@ export interface PaymentState { uuid: string formId: string isLivePayment: boolean + payerEmail?: string capture?: { status: 'success' | 'failed' createdAt: string diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 33551ac54..e5b28676c 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -12,6 +12,7 @@ import { PAYMENT_EXPIRED_NOTIFICATION } from '~/src/server/constants.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { EmailAddressField } from '~/src/server/plugins/engine/components/EmailAddressField.js' import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js' import { checkEmailAddressForLiveFormSubmission, @@ -40,7 +41,8 @@ import { import { type FormConfirmationState, type FormContext, - type FormContextRequest + type FormContextRequest, + type FormPayload } from '~/src/server/plugins/engine/types.js' import { DEFAULT_PAYMENT_HELP_URL, @@ -82,19 +84,12 @@ export class SummaryPageController extends QuestionPageController { const { query } = request const { payload, errors, state } = context - const components = this.collection.getViewModel(payload, errors, query) - - viewModel.backLink = this.getBackLink(request, context) - viewModel.feedbackLink = this.feedbackLink - viewModel.phaseTag = this.phaseTag - viewModel.components = components - viewModel.allowSaveAndExit = this.shouldShowSaveAndExit(request.server) - viewModel.errors = errors const paymentField = context.relevantPages .flatMap((page) => page.collection.fields) .find((field): field is PaymentField => field instanceof PaymentField) + let payerEmail: string | undefined if (paymentField) { const paymentState = paymentField.getPaymentStateFromState(state) if (paymentState) { @@ -103,12 +98,57 @@ export class SummaryPageController extends QuestionPageController { paymentField, paymentState ) + if (paymentState.payerEmail) { + payerEmail = paymentState.payerEmail + } } } + const componentPayload = this.getPrefilledPayload(payload, payerEmail) + const components = this.collection.getViewModel( + componentPayload, + errors, + query + ) + + viewModel.backLink = this.getBackLink(request, context) + viewModel.feedbackLink = this.feedbackLink + viewModel.phaseTag = this.phaseTag + viewModel.components = components + viewModel.allowSaveAndExit = this.shouldShowSaveAndExit(request.server) + viewModel.errors = errors + return viewModel } + /** + * Pre-fills EmailAddressField components with payer email if available. + */ + private getPrefilledPayload( + payload: FormPayload, + payerEmail?: string + ): FormPayload { + if (!payerEmail) { + return payload + } + + const emailFields = this.collection.fields.filter( + (field) => field instanceof EmailAddressField + ) + + if (emailFields.length === 0) { + return payload + } + + const prefilledPayload = { ...payload } + for (const field of emailFields) { + // Only pre-fill if not already set + prefilledPayload[field.name] ??= payerEmail + } + + return prefilledPayload + } + private buildPaymentDetails( paymentField: PaymentField, paymentState: NonNullable< @@ -409,9 +449,6 @@ async function finaliseComponents( } } -/** - * Builds and submits the payload to forms-submission-api. - */ function submitData( model: FormModel, items: DetailItem[], diff --git a/src/server/plugins/engine/routes/payment.js b/src/server/plugins/engine/routes/payment.js index 3828654fa..e3885fdf9 100644 --- a/src/server/plugins/engine/routes/payment.js +++ b/src/server/plugins/engine/routes/payment.js @@ -12,18 +12,19 @@ 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 {string} paymentId - the payment id from GOV.UK Pay + * @param {GetPaymentResponse} paymentStatus - the payment status response from GOV.UK Pay */ -function flashComponentState(request, session, paymentId) { +function flashComponentState(request, session, paymentStatus) { /** @type {PaymentState} */ const paymentState = { - paymentId, + paymentId: paymentStatus.payment_id, 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() @@ -53,10 +54,10 @@ export function getRoutes() { * @param {ResponseToolkit} h - the response toolkit * @param {PaymentSessionData} session - the session data * @param {string} sessionKey - the session key - * @param {string} paymentId - the payment id + * @param {GetPaymentResponse} paymentStatus - the payment status from GOV.UK Pay */ -function handlePaymentSuccess(request, h, session, sessionKey, paymentId) { - flashComponentState(request, session, paymentId) +function handlePaymentSuccess(request, h, session, sessionKey, paymentStatus) { + flashComponentState(request, session, paymentStatus) request.yar.clear(sessionKey) return h.redirect(session.returnUrl).code(StatusCodes.SEE_OTHER) } @@ -102,7 +103,7 @@ function getReturnRoute() { h, session, sessionKey, - session.paymentId + paymentStatus ) case 'cancelled': @@ -144,7 +145,7 @@ function getReturnRoute() { /** * @import { Request, ResponseToolkit, ServerRoute } from '@hapi/hapi' - * @import { PaymentSessionData } from '~/src/server/plugins/payment/types.js' + * @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 index 174b4dd9a..6e561c6df 100644 --- a/src/server/plugins/engine/routes/payment.test.js +++ b/src/server/plugins/engine/routes/payment.test.js @@ -144,6 +144,37 @@ describe('Payment routes', () => { // @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 {PaymentState} */ ({ + 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') + }) }) }) diff --git a/src/server/plugins/payment/types.js b/src/server/plugins/payment/types.js index 531ec0b31..1137c32dd 100644 --- a/src/server/plugins/payment/types.js +++ b/src/server/plugins/payment/types.js @@ -30,10 +30,12 @@ */ /** + * Response from GOV.UK Pay GET /v1/payments/{PAYMENT_ID} endpoint * @typedef {object} GetPaymentResponse * @property {string} payment_id - Unique identifier for the payment * @property {PaymentState} state - Current state of the payment * @property {{ self: PaymentLink, next_url?: PaymentLink }} _links - HATEOAS links for the payment + * @property {string} [email] - The paying user's email address */ /** From 435ac07cf248e32294f8a213be38adb631860e0b Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 27 Jan 2026 12:35:13 +0000 Subject: [PATCH 40/70] Coverage on machine v2 --- .../outputFormatters/human/v1.payment.test.ts | 146 ++++++++++++++++++ .../engine/outputFormatters/human/v1.test.ts | 120 -------------- .../machine/v2.payment.test.ts | 113 ++++++++++++++ .../plugins/engine/routes/payment.test.js | 6 +- src/server/plugins/payment/types.js | 6 +- 5 files changed, 265 insertions(+), 126 deletions(-) create mode 100644 src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts create mode 100644 src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts 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 0b9708522..d4ebb36d8 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.test.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.test.ts @@ -9,7 +9,6 @@ import { } 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' import definition from '~/test/form/definitions/repeat-mixed.js' describe('v1 human formatter', () => { @@ -143,123 +142,4 @@ describe('v1 human formatter', () => { ) }) }) - - describe('Payment', () => { - const modelPayment = new FormModel(definitionPayment, { - basePath: 'test' - }) - - 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' - } - } - } - - 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 - ) - - // TODO - sort type issue - const contextPayment = modelPayment.getFormContext( - requestPayment, - // @ts-expect-error - statePayment doesnt quite match up to type - 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/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/routes/payment.test.js b/src/server/plugins/engine/routes/payment.test.js index 174b4dd9a..2fbb43a3d 100644 --- a/src/server/plugins/engine/routes/payment.test.js +++ b/src/server/plugins/engine/routes/payment.test.js @@ -64,7 +64,7 @@ describe('Payment routes', () => { method: 'get' } }, - state: /** @type {PaymentState} */ ({ + state: /** @type {PaymentResponseState} */ ({ status: row.status, finished: true }) @@ -92,7 +92,7 @@ describe('Payment routes', () => { method: 'get' } }, - state: /** @type {PaymentState} */ ({ + state: /** @type {PaymentResponseState} */ ({ status: 'created', finished: true }) @@ -149,5 +149,5 @@ describe('Payment routes', () => { /** * @import { Server } from '@hapi/hapi' - * @import { PaymentState } from '~/src/server/plugins/payment/types.js' + * @import { PaymentResponseState } from '~/src/server/plugins/payment/types.js' */ diff --git a/src/server/plugins/payment/types.js b/src/server/plugins/payment/types.js index 531ec0b31..7bacaa3dd 100644 --- a/src/server/plugins/payment/types.js +++ b/src/server/plugins/payment/types.js @@ -1,5 +1,5 @@ /** - * @typedef {object} PaymentState + * @typedef {object} PaymentResponseState * @property {'created' | 'started' | 'submitted' | 'capturable' | 'success' | 'failed' | 'cancelled' | 'error'} status - Current status of the payment * @property {boolean} finished - Whether the payment process has completed * @property {string} [message] - Human-readable message about the payment state @@ -25,14 +25,14 @@ /** * @typedef {object} CreatePaymentResponse * @property {string} payment_id - Unique identifier for the created payment - * @property {PaymentState} state - Current state of the payment + * @property {PaymentResponseState} state - Current state of the payment * @property {{ next_url: PaymentLink }} _links - HATEOAS links for the payment */ /** * @typedef {object} GetPaymentResponse * @property {string} payment_id - Unique identifier for the payment - * @property {PaymentState} state - Current state of the payment + * @property {PaymentResponseState} state - Current state of the payment * @property {{ self: PaymentLink, next_url?: PaymentLink }} _links - HATEOAS links for the payment */ From 5340c7aba463b41ce4e0e0c0945fcbe1692df0bc Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 27 Jan 2026 12:47:25 +0000 Subject: [PATCH 41/70] Corrected type --- src/server/plugins/engine/routes/payment.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/engine/routes/payment.test.js b/src/server/plugins/engine/routes/payment.test.js index 16fc06032..e339028fd 100644 --- a/src/server/plugins/engine/routes/payment.test.js +++ b/src/server/plugins/engine/routes/payment.test.js @@ -160,7 +160,7 @@ describe('Payment routes', () => { method: 'get' } }, - state: /** @type {PaymentState} */ ({ + state: /** @type {PaymentResponseState} */ ({ status: 'success', finished: true }) From 6ed0b828217a4b6e0c1b2b1559bbe4784087e16d Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 27 Jan 2026 13:31:01 +0000 Subject: [PATCH 42/70] Corrected types --- .../engine/routes/payment-helper.test.js | 16 ++++++++--- src/server/plugins/engine/routes/payment.js | 2 +- .../plugins/engine/routes/payment.test.js | 10 ++----- src/server/plugins/payment/service.js | 11 +++++--- src/server/plugins/payment/service.test.js | 2 +- src/server/plugins/payment/types.js | 27 ++++++++++++++++--- 6 files changed, 49 insertions(+), 19 deletions(-) diff --git a/src/server/plugins/engine/routes/payment-helper.test.js b/src/server/plugins/engine/routes/payment-helper.test.js index 9cdfededd..3a1212586 100644 --- a/src/server/plugins/engine/routes/payment-helper.test.js +++ b/src/server/plugins/engine/routes/payment-helper.test.js @@ -40,7 +40,7 @@ describe('payment helper', () => { } } - const getPaymentStatusResult = { + const getPaymentStatusApiResult = { payment_id: 'payment-id-12345', _links: { next_url: { @@ -57,14 +57,24 @@ describe('payment helper', () => { statusCode: 200, headers: {} }), - payload: getPaymentStatusResult, + payload: getPaymentStatusApiResult, error: undefined }) // @ts-expect-error - partial request mock const res = await getPaymentContext(mockRequest, uuid) expect(res).toEqual({ - paymentStatus: getPaymentStatusResult, + paymentStatus: { + paymentId: 'payment-id-12345', + _links: { + next_url: { + href: 'http://next-url-href/payment' + } + }, + state: { + status: 'created' + } + }, session: { formId: 'form-id', isLivePayment: false, diff --git a/src/server/plugins/engine/routes/payment.js b/src/server/plugins/engine/routes/payment.js index e3885fdf9..5c360e38e 100644 --- a/src/server/plugins/engine/routes/payment.js +++ b/src/server/plugins/engine/routes/payment.js @@ -17,7 +17,7 @@ export const PAYMENT_SESSION_PREFIX = 'payment-' function flashComponentState(request, session, paymentStatus) { /** @type {PaymentState} */ const paymentState = { - paymentId: paymentStatus.payment_id, + paymentId: paymentStatus.paymentId, reference: session.reference, amount: session.amount, description: session.description, diff --git a/src/server/plugins/engine/routes/payment.test.js b/src/server/plugins/engine/routes/payment.test.js index e339028fd..a1e077ab6 100644 --- a/src/server/plugins/engine/routes/payment.test.js +++ b/src/server/plugins/engine/routes/payment.test.js @@ -52,8 +52,6 @@ describe('Payment routes', () => { ])('should handle payment status of $row.status', async (row) => { const paymentStatus = { paymentId: 'new-payment-id', - // TODO - resolve name mismatch - payment_id: 'new-payment-id', _links: { next_url: { href: '/next-url', @@ -83,8 +81,6 @@ describe('Payment routes', () => { it('should throw if nextUrl is missing', async () => { const paymentStatus = { paymentId: 'new-payment-id', - // TODO - resolve name mismatch - payment_id: 'new-payment-id', _links: { next_url: {}, self: { @@ -100,7 +96,7 @@ describe('Payment routes', () => { jest.mocked(getPaymentContext).mockResolvedValueOnce({ session: paymentSessionData, sessionKey, - // @ts-expect-error - missing elements deliberately for test + // @ts-expect-error - deliberate missing element from object paymentStatus }) const { response } = await renderResponse(server, options) @@ -115,8 +111,6 @@ describe('Payment routes', () => { it('should throw if invalid status', async () => { const paymentStatus = { paymentId: 'new-payment-id', - // TODO - resolve name mismatch - payment_id: 'new-payment-id', _links: { next_url: { href: '/next-url', @@ -135,7 +129,7 @@ describe('Payment routes', () => { jest.mocked(getPaymentContext).mockResolvedValueOnce({ session: paymentSessionData, sessionKey, - // @ts-expect-error - invalid status deliberately for test + // @ts-expect-error - deliberate invalid value which doesnt meet type paymentStatus }) const { response } = await renderResponse(server, options) diff --git a/src/server/plugins/payment/service.js b/src/server/plugins/payment/service.js index 4451a9cdf..e04e42a7c 100644 --- a/src/server/plugins/payment/service.js +++ b/src/server/plugins/payment/service.js @@ -58,7 +58,7 @@ export class PaymentService { * @returns {Promise} */ async getPaymentStatus(paymentId) { - const getByType = /** @type {typeof get} */ (get) + const getByType = /** @type {typeof get} */ (get) try { const response = await getByType( @@ -77,7 +77,12 @@ export class PaymentService { throw new Error(`Failed to get payment status: ${errorMessage}`) } - return response.payload + return { + state: response.payload.state, + _links: response.payload._links, + email: response.payload.email, + paymentId: response.payload.payment_id + } } catch (err) { const error = /** @type {Error} */ (err) logger.error( @@ -161,5 +166,5 @@ export class PaymentService { } /** - * @import { CreatePaymentRequest, CreatePaymentResponse, GetPaymentResponse } from '~/src/server/plugins/payment/types.js' + * @import { CreatePaymentRequest, CreatePaymentResponse, GetPaymentApiResponse, GetPaymentResponse } from '~/src/server/plugins/payment/types.js' */ diff --git a/src/server/plugins/payment/service.test.js b/src/server/plugins/payment/service.test.js index e97deb1e8..f28018db6 100644 --- a/src/server/plugins/payment/service.test.js +++ b/src/server/plugins/payment/service.test.js @@ -120,7 +120,7 @@ describe('payment service', () => { }) const paymentStatus = await service.getPaymentStatus('payment-id-12345') - expect(paymentStatus.payment_id).toBe('payment-id-12345') + expect(paymentStatus.paymentId).toBe('payment-id-12345') expect(paymentStatus._links.next_url?.href).toBe( 'http://next-url-href/payment' ) diff --git a/src/server/plugins/payment/types.js b/src/server/plugins/payment/types.js index 8b2382b60..328050bbb 100644 --- a/src/server/plugins/payment/types.js +++ b/src/server/plugins/payment/types.js @@ -30,14 +30,35 @@ */ /** - * Response from GOV.UK Pay GET /v1/payments/{PAYMENT_ID} endpoint - * @typedef {object} GetPaymentResponse - * @property {string} payment_id - Unique identifier for the payment + * Base response from GOV.UK Pay GET /v1/payments/{PAYMENT_ID} endpoint + * @typedef {object} GetPaymentResponseBase * @property {PaymentResponseState} state - Current state of the payment * @property {{ self: PaymentLink, next_url?: PaymentLink }} _links - HATEOAS links for the payment * @property {string} [email] - The paying user's email address */ +/** + * Response from GOV.UK Pay GET /v1/payments/{PAYMENT_ID} endpoint - not underscore in property name + * @typedef {object} GetPaymentApiResponsePaymentProp + * @property {string} payment_id - Unique identifier for the payment + */ + +/** + * Response from GOV.UK Pay GET /v1/payments/{PAYMENT_ID} endpoint + * @typedef {GetPaymentResponseBase & GetPaymentApiResponsePaymentProp} GetPaymentApiResponse + */ + +/** + * Response returned from getPaymentStatus - subtley different from GetPaymentApiResponse + * @typedef {object} GetPaymentResponsePaymentProp + * @property {string} paymentId - Unique identifier for the payment - note no underscore in property name + */ + +/** + * Response returned from getPaymentStatus - subtley different from GetPaymentApiResponse + * @typedef {GetPaymentResponseBase & GetPaymentResponsePaymentProp} GetPaymentResponse + */ + /** * Payment session data stored when dispatching to GOV.UK Pay * @typedef {object} PaymentSessionData From 7c95570ed02cf7c164fc46524340a7ab1f4ce7be Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 27 Jan 2026 16:15:04 +0000 Subject: [PATCH 43/70] refactor: simplify formatPaymentDate function using date-fns --- src/server/plugins/payment/helper.js | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/server/plugins/payment/helper.js b/src/server/plugins/payment/helper.js index 90ae7b822..6e719624e 100644 --- a/src/server/plugins/payment/helper.js +++ b/src/server/plugins/payment/helper.js @@ -1,3 +1,5 @@ +import { format } from 'date-fns' + import { config } from '~/src/config/index.js' import { PaymentService } from '~/src/server/plugins/payment/service.js' @@ -42,18 +44,7 @@ export function createPaymentService(isLivePayment, formId) { * @returns {string} Formatted date string (e.g., "26 January 2026 – 17:01:29") */ export function formatPaymentDate(isoString) { - const date = new Date(isoString) - const dateStr = date.toLocaleDateString('en-GB', { - day: 'numeric', - month: 'long', - year: 'numeric' - }) - const timeStr = date.toLocaleTimeString('en-GB', { - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }) - return `${dateStr} – ${timeStr}` + return format(new Date(isoString), 'd MMMM yyyy – HH:mm:ss') } /** From f069bfb32609aab9d8b26c66130c741b4c056e06 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 27 Jan 2026 16:46:02 +0000 Subject: [PATCH 44/70] refactor(submission): update payment field names and descriptions for clarity --- .../helpers/submission.test.ts | 30 +++++++++---------- .../pageControllers/helpers/submission.ts | 24 +++++++-------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/server/plugins/engine/pageControllers/helpers/submission.test.ts b/src/server/plugins/engine/pageControllers/helpers/submission.test.ts index 360acc424..62688bd85 100644 --- a/src/server/plugins/engine/pageControllers/helpers/submission.test.ts +++ b/src/server/plugins/engine/pageControllers/helpers/submission.test.ts @@ -59,22 +59,22 @@ describe('Submission helpers', () => { expect(result).toHaveLength(4) expect(result[0]).toEqual({ - name: 'payment_paymentFor', - title: 'Payment for', + name: 'payment_paymentDescription', + title: 'Payment description', value: 'Application fee' }) expect(result[1]).toEqual({ - name: 'payment_totalAmount', - title: 'Total amount', + name: 'payment_paymentAmount', + title: 'Payment amount', value: '£150.00' }) expect(result[2]).toEqual({ - name: 'payment_reference', - title: 'Reference', + name: 'payment_paymentReference', + title: 'Payment reference', value: 'REF-ABC-123' }) - expect(result[3].name).toBe('payment_dateOfPayment') - expect(result[3].title).toBe('Date of payment') + 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('') }) @@ -106,8 +106,8 @@ describe('Submission helpers', () => { const result = buildPaymentRecords(item) expect(result[3]).toEqual({ - name: 'payment_dateOfPayment', - title: 'Date of payment', + name: 'payment_paymentDate', + title: 'Payment date', value: '' }) }) @@ -177,10 +177,10 @@ describe('Submission helpers', () => { expect(result).toHaveLength(4) expect(result.map((r) => r.name)).toEqual([ - 'licencePayment_paymentFor', - 'licencePayment_totalAmount', - 'licencePayment_reference', - 'licencePayment_dateOfPayment' + 'licencePayment_paymentDescription', + 'licencePayment_paymentAmount', + 'licencePayment_paymentReference', + 'licencePayment_paymentDate' ]) }) @@ -226,7 +226,7 @@ describe('Submission helpers', () => { // 1 regular field + 4 payment fields = 5 records expect(result).toHaveLength(5) expect(result[0].name).toBe('email') - expect(result[1].name).toBe('payment_paymentFor') + expect(result[1].name).toBe('payment_paymentDescription') }) it('should skip repeater items (items with subItems)', () => { diff --git a/src/server/plugins/engine/pageControllers/helpers/submission.ts b/src/server/plugins/engine/pageControllers/helpers/submission.ts index b6a1b5f1a..879b26ad2 100644 --- a/src/server/plugins/engine/pageControllers/helpers/submission.ts +++ b/src/server/plugins/engine/pageControllers/helpers/submission.ts @@ -46,10 +46,10 @@ export function buildMainRecords(items: DetailItem[]): SubmitRecord[] { /** * Expands a PaymentField into four submission records: - * - Payment for (description) - * - Total amount (formatted with currency symbol) - * - Reference - * - Date of payment (formatted date/time) + * - Payment description + * - Payment amount (formatted with currency symbol) + * - Payment reference + * - Payment date (formatted date/time) * * Returns an empty array if no payment state exists. */ @@ -64,23 +64,23 @@ export function buildPaymentRecords(item: DetailItemField): SubmitRecord[] { return [ { - name: `${item.name}_paymentFor`, - title: 'Payment for', + name: `${item.name}_paymentDescription`, + title: 'Payment description', value: paymentState.description }, { - name: `${item.name}_totalAmount`, - title: 'Total amount', + name: `${item.name}_paymentAmount`, + title: 'Payment amount', value: formatPaymentAmount(paymentState.amount) }, { - name: `${item.name}_reference`, - title: 'Reference', + name: `${item.name}_paymentReference`, + title: 'Payment reference', value: paymentState.reference }, { - name: `${item.name}_dateOfPayment`, - title: 'Date of payment', + name: `${item.name}_paymentDate`, + title: 'Payment date', value: paymentState.preAuth?.createdAt ? formatPaymentDate(paymentState.preAuth.createdAt) : '' From ccfb248497423cd111219767668fc3885517422b Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 28 Jan 2026 11:28:54 +0000 Subject: [PATCH 45/70] Fixed vulnerabilities with npm audit fix --- package-lock.json | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) 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": { From dcad7b20a50e9215170257a4a0ae1c904dd2f63d Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 28 Jan 2026 14:27:27 +0000 Subject: [PATCH 46/70] Removed pre-fill email as needs more thinking --- .../pageControllers/SummaryPageController.ts | 43 +------------------ 1 file changed, 2 insertions(+), 41 deletions(-) diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index e5b28676c..0eafd3333 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -12,7 +12,6 @@ import { PAYMENT_EXPIRED_NOTIFICATION } from '~/src/server/constants.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' -import { EmailAddressField } from '~/src/server/plugins/engine/components/EmailAddressField.js' import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js' import { checkEmailAddressForLiveFormSubmission, @@ -41,8 +40,7 @@ import { import { type FormConfirmationState, type FormContext, - type FormContextRequest, - type FormPayload + type FormContextRequest } from '~/src/server/plugins/engine/types.js' import { DEFAULT_PAYMENT_HELP_URL, @@ -89,7 +87,6 @@ export class SummaryPageController extends QuestionPageController { .flatMap((page) => page.collection.fields) .find((field): field is PaymentField => field instanceof PaymentField) - let payerEmail: string | undefined if (paymentField) { const paymentState = paymentField.getPaymentStateFromState(state) if (paymentState) { @@ -98,18 +95,10 @@ export class SummaryPageController extends QuestionPageController { paymentField, paymentState ) - if (paymentState.payerEmail) { - payerEmail = paymentState.payerEmail - } } } - const componentPayload = this.getPrefilledPayload(payload, payerEmail) - const components = this.collection.getViewModel( - componentPayload, - errors, - query - ) + const components = this.collection.getViewModel(payload, errors, query) viewModel.backLink = this.getBackLink(request, context) viewModel.feedbackLink = this.feedbackLink @@ -121,34 +110,6 @@ export class SummaryPageController extends QuestionPageController { return viewModel } - /** - * Pre-fills EmailAddressField components with payer email if available. - */ - private getPrefilledPayload( - payload: FormPayload, - payerEmail?: string - ): FormPayload { - if (!payerEmail) { - return payload - } - - const emailFields = this.collection.fields.filter( - (field) => field instanceof EmailAddressField - ) - - if (emailFields.length === 0) { - return payload - } - - const prefilledPayload = { ...payload } - for (const field of emailFields) { - // Only pre-fill if not already set - prefilledPayload[field.name] ??= payerEmail - } - - return prefilledPayload - } - private buildPaymentDetails( paymentField: PaymentField, paymentState: NonNullable< From 0d97703baa766f7de3d3df0b5b8f54bbe951843b Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 28 Jan 2026 15:15:46 +0000 Subject: [PATCH 47/70] Extra coverage --- .../engine/pageControllers/errors.test.ts | 17 ++++++++++++++++- src/server/plugins/payment/helper.test.js | 6 ++++++ 2 files changed, 22 insertions(+), 1 deletion(-) 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/payment/helper.test.js b/src/server/plugins/payment/helper.test.js index e11974619..a6375cb04 100644 --- a/src/server/plugins/payment/helper.test.js +++ b/src/server/plugins/payment/helper.test.js @@ -19,6 +19,12 @@ describe('getPaymentApiKey', () => { const apiKey = getPaymentApiKey(true, formId) expect(apiKey).toBe('LIVE-API-KEY') }) + + it('should throw if key is missing', () => { + expect(() => getPaymentApiKey(true, 'form-id-missing')).toThrow( + 'Missing payment api key for live form id form-id-missing' + ) + }) }) describe('formatPaymentDate', () => { From acd3849665f331a76279b39ae30586f03dff5437 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 28 Jan 2026 15:20:49 +0000 Subject: [PATCH 48/70] Sonar fix --- .../engine/outputFormatters/human/v1.ts | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/server/plugins/engine/outputFormatters/human/v1.ts b/src/server/plugins/engine/outputFormatters/human/v1.ts index 5a5f954b8..9e2c30174 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.ts @@ -120,15 +120,17 @@ function appendPaymentSection(paymentItems: DetailItem[], lines: string[]) { ? formatPaymentDate(paymentState.preAuth.createdAt) : '' - lines.push('---\n') - lines.push(`# Your payment of ${formattedAmount} was successful\n`) - lines.push('## Payment for\n') - lines.push(`${escapeMarkdown(paymentState.description)}\n`) - lines.push('---\n') - lines.push('## Total amount\n') - lines.push(`${formattedAmount}\n`) - lines.push('---\n') - lines.push('## Date of payment\n') - lines.push(`${escapeMarkdown(dateOfPayment)}\n`) - lines.push('---\n') + 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' + ) } From 3010607667bd468d0091e1aa8a7e153fde864e53 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 28 Jan 2026 15:48:56 +0000 Subject: [PATCH 49/70] Sonar fix --- package-lock.json | 72 ++++++++++--------- .../engine/components/UkAddressField.ts | 19 +++-- 2 files changed, 49 insertions(+), 42 deletions(-) diff --git a/package-lock.json b/package-lock.json index ee6823c9f..0d78afd53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -322,6 +322,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2141,6 +2142,7 @@ "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -2233,6 +2235,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2256,6 +2259,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -3086,7 +3090,8 @@ "version": "0.2.10", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@foliojs-fork/fontkit": { "version": "1.9.2", @@ -6306,6 +6311,7 @@ "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.4", @@ -6336,6 +6342,7 @@ "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.4", "@typescript-eslint/types": "8.46.4", @@ -7277,6 +7284,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8113,6 +8121,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -8232,6 +8241,7 @@ "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -8368,6 +8378,7 @@ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "license": "MIT", + "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -9508,6 +9519,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -10520,6 +10532,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -10755,6 +10768,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10931,6 +10945,7 @@ "integrity": "sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "builtins": "^5.0.1", @@ -11007,6 +11022,7 @@ "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -13225,6 +13241,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -14274,6 +14291,7 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -14283,6 +14301,7 @@ "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", @@ -14358,6 +14377,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -14783,6 +14803,7 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz", "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -15087,7 +15108,6 @@ "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz", "integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==", "license": "SEE LICENSE IN LICENSE.txt", - "peer": true, "dependencies": { "@mapbox/geojson-rewind": "^0.5.2", "@mapbox/geojson-types": "^1.0.2", @@ -15120,29 +15140,25 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/mapbox-gl/node_modules/@mapbox/tiny-sdf": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz", "integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/mapbox-gl/node_modules/@mapbox/unitbezier": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/mapbox-gl/node_modules/@mapbox/vector-tile": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@mapbox/point-geometry": "~0.1.0" } @@ -15151,29 +15167,25 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/mapbox-gl/node_modules/geojson-vt": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/mapbox-gl/node_modules/kdbush": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/mapbox-gl/node_modules/pbf": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "ieee754": "^1.1.12", "resolve-protobuf-schema": "^2.1.0" @@ -15186,22 +15198,19 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/mapbox-gl/node_modules/quickselect": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/mapbox-gl/node_modules/supercluster": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", "license": "ISC", - "peer": true, "dependencies": { "kdbush": "^3.0.0" } @@ -15210,8 +15219,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/maplibre-gl": { "version": "5.16.0", @@ -16616,6 +16624,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -17192,6 +17201,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -17265,15 +17275,6 @@ "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", "license": "ISC" }, - "node_modules/preact": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-8.5.3.tgz", - "integrity": "sha512-O3kKP+1YdgqHOFsZF2a9JVdtqD+RPzCQc3rP+Ualf7V6rmRDchZ9MJbiGTT7LuyqFKZqlHSOyO/oMFmI2lVTsw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -18504,6 +18505,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -19293,6 +19295,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", @@ -20086,6 +20089,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -20235,6 +20239,7 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -20369,6 +20374,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -20687,6 +20693,7 @@ "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -20755,6 +20762,7 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index f669c2593..f21ea4a7e 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -284,6 +284,7 @@ export class UkAddressField extends FormComponent { ) } + // eslint-disable-next-line @typescript-eslint/require-await static async dispatcher( request: FormRequestPayload, h: FormResponseToolkit, @@ -291,16 +292,14 @@ export class UkAddressField extends FormComponent { ) { const { controller, component } = args - return Promise.resolve( - dispatch(request, h, { - formName: controller.model.name, - componentName: component.name, - componentHint: component.hint, - componentTitle: component.title || controller.title, - step: args.actionArgs.step, - sourceUrl: args.sourceUrl - }) - ) + return dispatch(request, h, { + formName: controller.model.name, + componentName: component.name, + componentHint: component.hint, + componentTitle: component.title || controller.title, + step: args.actionArgs.step, + sourceUrl: args.sourceUrl + }) } } From 730c9731b640173fc21a7c117f14ca9957cb983a Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 28 Jan 2026 17:11:44 +0000 Subject: [PATCH 50/70] Fixed formatting of buttons on payment page --- .../engine/views/components/paymentfield.html | 13 +++++++++++++ src/server/plugins/engine/views/partials/form.html | 10 +++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/engine/views/components/paymentfield.html b/src/server/plugins/engine/views/components/paymentfield.html index 0199af6da..69599c2a9 100644 --- a/src/server/plugins/engine/views/components/paymentfield.html +++ b/src/server/plugins/engine/views/components/paymentfield.html @@ -21,6 +21,7 @@

{{ model.label.text if model.label and model.label.t

Total amount:

£{{ amount }}

+
{{ govukButton({ text: "Add payment details", attributes: { @@ -29,5 +30,17 @@

{{ model.label.text if model.label and model.label.t }, classes: "govuk-!-margin-bottom-0" }) }} + + {% if allowSaveAndExit %} + {{ govukButton({ + text: "Save and exit", + classes: "govuk-button--secondary", + name: "action", + value: "save-and-exit", + preventDoubleClick: true + }) }} + {% endif %} +

+ {% endmacro %} diff --git a/src/server/plugins/engine/views/partials/form.html b/src/server/plugins/engine/views/partials/form.html index e28dfd700..697659539 100644 --- a/src/server/plugins/engine/views/partials/form.html +++ b/src/server/plugins/engine/views/partials/form.html @@ -1,6 +1,14 @@ {% from "govuk/components/button/macro.njk" import govukButton %} {% from "govuk/components/summary-list/macro.njk" import govukSummaryList -%} +{% set noPaymentFields = true %} + +{% for comp in components %} + {% if comp.type == 'PaymentField' %} + {% set noPaymentFields = false %} + {% endif %} +{% endfor %} + @@ -23,7 +31,7 @@ }) }} {% endif %} - {% if allowSaveAndExit %} + {% if allowSaveAndExit and noPaymentFields %} {{ govukButton({ text: "Save and exit", classes: "govuk-button--secondary", From 018faa03d3fd07b640b339268c1ac145eb615449 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Thu, 29 Jan 2026 11:22:39 +0000 Subject: [PATCH 51/70] fix(payment): show confirmation message when navigating back after pre-auth --- .../engine/components/PaymentField.test.ts | 54 +++++++++++++++++++ .../plugins/engine/components/PaymentField.ts | 43 ++++++++++++--- .../engine/views/components/paymentfield.html | 39 ++++++++++---- 3 files changed, 121 insertions(+), 15 deletions(-) diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts index 6f11d2afb..344615a30 100644 --- a/src/server/plugins/engine/components/PaymentField.test.ts +++ b/src/server/plugins/engine/components/PaymentField.test.ts @@ -316,6 +316,60 @@ describe('PaymentField', () => { uuid: expect.any(String) }) }) + + it('should redirect to summary if payment is already pre-authorised', async () => { + const mockRedirectCode = jest.fn().mockReturnValueOnce('redirected') + const mockH = { + redirect: jest.fn().mockReturnValueOnce({ code: mockRedirectCode }) + } as unknown as FormResponseToolkit + const mockRequest = { + server: { + plugins: { + // eslint-disable-next-line no-useless-computed-key + ['forms-engine-plugin']: { + baseUrl: 'base-url' + } + } + }, + yar: { + set: jest.fn() + } + } as unknown as FormRequestPayload + const args = { + controller: { + model: { + formId: 'form-id', + basePath: 'base-path', + name: 'PaymentModel' + }, + getState: jest.fn().mockResolvedValueOnce({ + $$__referenceNumber: 'pay-ref-123', + myComponent: { + paymentId: 'existing-payment-id', + amount: 100, + description: 'Test payment', + preAuth: { + status: 'success', + createdAt: '2026-01-29T12:00:00.000Z' + } + } + }) + }, + component: paymentField, + sourceUrl: 'http://localhost:3009/test-payment', + isLive: false, + isPreview: true + } + + const res = await PaymentField.dispatcher(mockRequest, mockH, args) + + expect(res).toBe('redirected') + expect(mockH.redirect).toHaveBeenCalledWith( + 'base-url/base-path/summary' + ) + expect(mockRedirectCode).toHaveBeenCalledWith(303) + expect(postJson).not.toHaveBeenCalled() + }) }) describe('onSubmit', () => { diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index 19d8b18f1..5154bad73 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -108,6 +108,13 @@ export class PaymentField extends FormComponent { * Type guard to check if value is PaymentState */ isPaymentState(value: unknown): value is PaymentState { + return PaymentField.isPaymentState(value) + } + + /** + * Static type guard to check if value is PaymentState + */ + static isPaymentState(value: unknown): value is PaymentState { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false } @@ -127,6 +134,21 @@ export class PaymentField extends FormComponent { return this.isPaymentState(value) } + /** + * Override base isValue to recognize PaymentState objects + * The base implementation only recognises primitives (string, number, boolean) + */ + isValue(value?: FormStateValue | FormState): value is PaymentState { + return this.isPaymentState(value) + } + + /** + * Override base getFormValue to handle PaymentState objects + */ + getFormValue(value?: FormStateValue | FormState) { + return this.isValue(value) ? value : undefined + } + /** * For error preview page that shows all possible errors on a component */ @@ -157,25 +179,34 @@ export class PaymentField extends FormComponent { h: FormResponseToolkit, args: PaymentDispatcherArgs ): Promise { + const { options, name: componentName } = args.component + const { model } = args.controller + + const state = await args.controller.getState(request) + const { baseUrl } = getPluginOptions(request.server) + const summaryUrl = `${baseUrl}/${model.basePath}/summary` + + const existingPaymentState = state[componentName] + if ( + PaymentField.isPaymentState(existingPaymentState) && + existingPaymentState.preAuth?.status === 'success' + ) { + return h.redirect(summaryUrl).code(StatusCodes.SEE_OTHER) + } + const isLivePayment = args.isLive && !args.isPreview const formId = args.controller.model.formId const paymentService = createPaymentService(isLivePayment, formId) const uuid = randomUUID() - 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 slug = `/${model.basePath}` - 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) diff --git a/src/server/plugins/engine/views/components/paymentfield.html b/src/server/plugins/engine/views/components/paymentfield.html index 69599c2a9..048d5e590 100644 --- a/src/server/plugins/engine/views/components/paymentfield.html +++ b/src/server/plugins/engine/views/components/paymentfield.html @@ -5,22 +5,29 @@ {% set model = component.model %} {% set amount = model.amount %} {% set description = model.description %} + {% set paymentState = model.paymentState %} + {% set isPreAuthorised = paymentState and paymentState.preAuth and paymentState.preAuth.status == 'success' %}
-

{{ model.label.text if model.label and model.label.text else "Payment details required" }}

+ {% if isPreAuthorised %} + {# Payment already pre-authorised - show confirmation message #} +

You have already authorised a payment for this form

-

{{ description }}

+

Continue to submit the form. You will not be charged twice.

+ {% else %} + {# No pre-authorisation - show payment form #} +

{{ model.label.text if model.label and model.label.text else "Payment details required" }}

- {{ govukWarningText({ - text: "You may see a pending transaction in your bank account but you will only be charged when you submit the form.", - iconFallbackText: "Warning" - }) }} +

{{ description }}

-

You can submit the form after you have added your payment details.

+ {{ govukWarningText({ + text: "You may see a pending transaction in your bank account but you will only be charged when you submit the form.", + iconFallbackText: "Warning" + }) }} -

Total amount:

-

£{{ amount }}

+

You can submit the form after you have added your payment details.

+<<<<<<< Updated upstream
{{ govukButton({ text: "Add payment details", @@ -42,5 +49,19 @@

{{ model.label.text if model.label and model.label.t {% endif %}

+======= +

Total amount:

+

£{{ amount }}

+ + {{ govukButton({ + text: "Add payment details", + attributes: { + name: "action", + value: "external-" + model.name + }, + classes: "govuk-!-margin-bottom-0" + }) }} + {% endif %} +>>>>>>> Stashed changes
{% endmacro %} From 6852900fd4becefdee456283ce7c7b14228e2cad Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Thu, 29 Jan 2026 12:02:10 +0000 Subject: [PATCH 52/70] refactor(payment): move Save and exit button to form level --- .../engine/views/components/paymentfield.html | 27 +------------------ .../plugins/engine/views/partials/form.html | 9 +++++-- 2 files changed, 8 insertions(+), 28 deletions(-) diff --git a/src/server/plugins/engine/views/components/paymentfield.html b/src/server/plugins/engine/views/components/paymentfield.html index 048d5e590..b75ff5024 100644 --- a/src/server/plugins/engine/views/components/paymentfield.html +++ b/src/server/plugins/engine/views/components/paymentfield.html @@ -27,29 +27,6 @@

{{ model.label.text if model.label and model.label.t

You can submit the form after you have added your payment details.

-<<<<<<< Updated upstream -
- {{ govukButton({ - text: "Add payment details", - attributes: { - name: "action", - value: "external-" + model.name - }, - classes: "govuk-!-margin-bottom-0" - }) }} - - {% if allowSaveAndExit %} - {{ govukButton({ - text: "Save and exit", - classes: "govuk-button--secondary", - name: "action", - value: "save-and-exit", - preventDoubleClick: true - }) }} - {% endif %} -
- -=======

Total amount:

£{{ amount }}

@@ -58,10 +35,8 @@

{{ model.label.text if model.label and model.label.t attributes: { name: "action", value: "external-" + model.name - }, - classes: "govuk-!-margin-bottom-0" + } }) }} {% endif %} ->>>>>>> Stashed changes {% endmacro %} diff --git a/src/server/plugins/engine/views/partials/form.html b/src/server/plugins/engine/views/partials/form.html index 697659539..fb19e3b0f 100644 --- a/src/server/plugins/engine/views/partials/form.html +++ b/src/server/plugins/engine/views/partials/form.html @@ -2,10 +2,15 @@ {% from "govuk/components/summary-list/macro.njk" import govukSummaryList -%} {% set noPaymentFields = true %} +{% set hasIncompletePayment = false %} {% for comp in components %} {% if comp.type == 'PaymentField' %} {% set noPaymentFields = false %} + {# Check if payment is incomplete (no preAuth status) #} + {% if not comp.model.paymentState or not comp.model.paymentState.preAuth or comp.model.paymentState.preAuth.status != 'success' %} + {% set hasIncompletePayment = true %} + {% endif %} {% endif %} {% endfor %} @@ -23,7 +28,7 @@ {% endif %}
- {% if showSubmitButton !== false %} + {% if showSubmitButton !== false and not hasIncompletePayment %} {{ govukButton({ text: buttonText, isStartButton: isStartPage, @@ -31,7 +36,7 @@ }) }} {% endif %} - {% if allowSaveAndExit and noPaymentFields %} + {% if allowSaveAndExit %} {{ govukButton({ text: "Save and exit", classes: "govuk-button--secondary", From e49eee46494f0bd5ad01c823553a67eedb3ab58f Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Thu, 29 Jan 2026 12:20:27 +0000 Subject: [PATCH 53/70] fix(payment): address PR review comments --- .../pageControllers/QuestionPageController.ts | 5 +-- src/server/plugins/engine/routes/index.ts | 40 +++++++++++++------ 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 217878c3d..2d7c8b58b 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -185,11 +185,8 @@ export class QuestionPageController extends PageController { } } - // Check if any PaymentField component needs payment to be added - // If so, hide the submit button until payment is ready const hasIncompletePayment = components.some(({ model }) => { - // Check if this is a PaymentField by looking for paymentState in model - if ('paymentState' in model && 'amount' in model) { + if ('paymentState' in model) { const paymentState = model.paymentState as | { preAuth?: { status?: string } } | undefined diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index f62c180f5..3ef1e56ff 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -10,7 +10,10 @@ import { EXTERNAL_STATE_PAYLOAD } from '~/src/server/constants.js' import { resolveFormModel } from '~/src/server/plugins/engine/beta/form-context.js' -import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' +import { + FormComponent, + isFormState +} from '~/src/server/plugins/engine/components/FormComponent.js' import { checkFormStatus, findPage, @@ -135,24 +138,35 @@ async function importExternalComponentState( throw new Error(`State for component ${componentName} is invalid`) } - // Store component state under the component name - const componentState = { [componentName]: stateAppendage } - - // Save the external component state directly (already has correct key format) - const savedState = await page.mergeState(request, state, componentState) + // Components with a collection (e.g. UkAddressField) need flattened keys + // Components without a collection (e.g. PaymentField) store state as nested object + const componentState = + component.collection && isFormState(stateAppendage) + ? Object.fromEntries( + Object.entries(stateAppendage).map(([key, value]) => [ + `${componentName}__${key}`, + value + ]) + ) + : { [componentName]: stateAppendage } + + const pageState = page.getStateFromValidForm( + request, + state, + componentState as FormPayload + ) + const savedState = await page.mergeState(request, state, pageState) // Merge any stashed payload into the local state const payload = request.yar.flash(EXTERNAL_STATE_PAYLOAD) const stashedPayload = Array.isArray(payload) ? {} : (payload as FormPayload) - if (Object.keys(stashedPayload).length) { - const localState = page.getStateFromValidForm(request, savedState, { - ...stashedPayload - } as FormPayload) - return { ...savedState, ...localState } - } + const localState = page.getStateFromValidForm(request, savedState, { + ...stashedPayload, + ...componentState + } as FormPayload) - return savedState + return { ...savedState, ...localState } } export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { From 225770844ec381a266473c64daabb35577cf5b11 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Fri, 30 Jan 2026 12:18:24 +0000 Subject: [PATCH 54/70] revert: payment state handling --- src/server/plugins/engine/routes/index.ts | 40 ++++++++--------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 3ef1e56ff..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, @@ -138,35 +135,24 @@ async function importExternalComponentState( throw new Error(`State for component ${componentName} is invalid`) } - // Components with a collection (e.g. UkAddressField) need flattened keys - // Components without a collection (e.g. PaymentField) store state as nested object - const componentState = - component.collection && isFormState(stateAppendage) - ? Object.fromEntries( - Object.entries(stateAppendage).map(([key, value]) => [ - `${componentName}__${key}`, - value - ]) - ) - : { [componentName]: stateAppendage } - - const pageState = page.getStateFromValidForm( - request, - state, - componentState as FormPayload - ) - const savedState = await page.mergeState(request, state, pageState) + // Store component state under the component name + const componentState = { [componentName]: stateAppendage } + + // 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) { From eaafa9fa370b96119203378db88401ea1bf82205 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Fri, 30 Jan 2026 14:13:55 +0000 Subject: [PATCH 55/70] refactor(payment): change payments structure to a single payment object --- .../engine/outputFormatters/adapter/v1.test.ts | 1 - .../outputFormatters/machine/v2.payment.test.ts | 14 ++++++-------- .../engine/outputFormatters/machine/v2.test.ts | 2 -- .../plugins/engine/outputFormatters/machine/v2.ts | 6 +++--- src/server/plugins/engine/types.ts | 2 +- src/server/plugins/engine/types/schema.test.ts | 3 +-- src/server/plugins/engine/types/schema.ts | 10 +++++++++- 7 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts index 520565169..03a9cb04e 100644 --- a/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.test.ts @@ -249,7 +249,6 @@ describe('Adapter v1 formatter', () => { exampleField: 'hello world', exampleField2: 'hello world' }, - payments: {}, repeaters: { exampleRepeat: [ { diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts b/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts index b815bcebe..7236bce69 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts @@ -91,14 +91,12 @@ describe('getPersonalisation', () => { 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' - } + payment: { + amount: 250, + createdAt: '2026-01-02T11:00:04+0000', + description: 'Payment desc', + paymentId: 'payment-id', + reference: 'payment-ref' }, repeaters: {}, files: {} diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.test.ts b/src/server/plugins/engine/outputFormatters/machine/v2.test.ts index e53458581..78703fc3d 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v2.test.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v2.test.ts @@ -233,7 +233,6 @@ describe('getPersonalisation', () => { exampleField: 'hello world', exampleField2: 'hello world' }, - payments: {}, repeaters: { exampleRepeat: [ { @@ -292,7 +291,6 @@ 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 710ee289d..7607dd46a 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v2.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v2.ts @@ -106,8 +106,8 @@ export function categoriseData(items: DetailItem[]) { string, { fileId: string; fileName: string; userDownloadLink: string }[] > - payments: Record - } = { main: {}, repeaters: {}, files: {}, payments: {} } + payment?: PaymentOutput + } = { main: {}, repeaters: {}, files: {} } items.forEach((item) => { const { name, state } = item @@ -119,7 +119,7 @@ export function categoriseData(items: DetailItem[]) { } else if (isPaymentFieldItem(item)) { const payment = extractPayment(item) if (payment) { - output.payments[name] = payment + output.payment = payment } } else { output.main[name] = item.field.getFormValueFromState(state) diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 803bc1f9e..987cc100e 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -503,7 +503,7 @@ export interface FormAdapterSubmissionMessageData { main: Record repeaters: Record[]> files: Record - payments: Record + payment?: FormAdapterPayment } export interface FormAdapterSubmissionMessagePayload { diff --git a/src/server/plugins/engine/types/schema.test.ts b/src/server/plugins/engine/types/schema.test.ts index 80b140421..a77c9257a 100644 --- a/src/server/plugins/engine/types/schema.test.ts +++ b/src/server/plugins/engine/types/schema.test.ts @@ -56,8 +56,7 @@ 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 3e28c8d57..b7d18f600 100644 --- a/src/server/plugins/engine/types/schema.ts +++ b/src/server/plugins/engine/types/schema.ts @@ -43,7 +43,15 @@ export const formAdapterSubmissionMessageDataSchema = Joi.object().keys({ main: Joi.object(), repeaters: Joi.object(), - payments: Joi.object(), + payment: Joi.object() + .keys({ + paymentId: Joi.string().required(), + reference: Joi.string().required(), + amount: Joi.number().required(), + description: Joi.string().required(), + createdAt: Joi.string().required() + }) + .optional(), files: Joi.object().pattern( Joi.string(), Joi.array().items( From 3cc88fa3b72a28cf9db8fedfb3cc63e2d79093ca Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 30 Jan 2026 15:09:01 +0000 Subject: [PATCH 56/70] Rework after review - part 1 --- jest.setup.cjs | 2 +- src/config/index.ts | 8 -------- .../plugins/engine/components/FormComponent.ts | 7 ++++--- .../engine/components/PaymentField.test.ts | 18 +++++++++--------- .../engine/routes/payment-helper.test.js | 4 ++-- src/server/plugins/payment/helper.js | 7 +++---- src/server/plugins/payment/helper.test.js | 9 ++++++++- 7 files changed, 27 insertions(+), 28 deletions(-) diff --git a/jest.setup.cjs b/jest.setup.cjs index 2686c4c7f..fad6f3680 100644 --- a/jest.setup.cjs +++ b/jest.setup.cjs @@ -13,4 +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' +process.env.PAYMENT_PROVIDER_API_KEY_TEST_formid = 'test-api-key' diff --git a/src/config/index.ts b/src/config/index.ts index 599bfb5f8..30740a137 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -260,14 +260,6 @@ export const config = convict({ nullable: true, default: undefined, env: 'ORDNANCE_SURVEY_API_KEY' - } as SchemaObj, - - paymentProviderApiKeyTest: { - doc: 'A test API key for integrating with a payment provider', - format: String, - nullable: true, - default: undefined, - env: 'PAYMENT_PROVIDER_API_KEY_TEST' } as SchemaObj }) diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index 09ad39eb0..b6bb83e4e 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -5,6 +5,7 @@ import { } from '@defra/forms-model' import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' +import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js' import { optionalText } from '~/src/server/plugins/engine/components/constants.js' import { type FormContext, @@ -179,7 +180,7 @@ export class FormComponent extends ComponentBase { getContextValueFromFormValue( value: FormValue | FormPayload - ): Item['value'] | Item['value'][] | null { + ): Item['value'] | Item['value'][] | PaymentState | null { // Filter object field values if (this.isState(value)) { const values = Object.values(value).filter(isFormValue) @@ -191,12 +192,12 @@ export class FormComponent extends ComponentBase { return value.filter(isFormValue) } - return this.isValue(value) ? (value as Item['value']) : null + return this.isValue(value) ? value : null } getContextValueFromState( state: FormSubmissionState - ): Item['value'] | Item['value'][] | null { + ): Item['value'] | Item['value'][] | PaymentState | null { const value = this.getFormValueFromState(state) return this.getContextValueFromFormValue(value) diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts index 344615a30..818b1318d 100644 --- a/src/server/plugins/engine/components/PaymentField.test.ts +++ b/src/server/plugins/engine/components/PaymentField.test.ts @@ -170,7 +170,7 @@ describe('PaymentField', () => { amount: 150, description: 'payment description', uuid: 'ee501106-4ce1-4947-91a7-7cc1a335ccd8', - formId: 'form-id', + formId: 'formid', isLivePayment: false } it('returns text from state', () => { @@ -206,7 +206,7 @@ describe('PaymentField', () => { paymentId: 'payment-id', reference: 'payment-ref', uuid: 'ee501106-4ce1-4947-91a7-7cc1a335ccd8', - formId: 'form-id', + formId: 'formid', amount: 100, description: 'Test payment description', isLivePayment: false @@ -273,7 +273,7 @@ describe('PaymentField', () => { const args = { controller: { model: { - formId: 'form-id', + formId: 'formid', basePath: 'base-path', name: 'PaymentModel' }, @@ -308,7 +308,7 @@ describe('PaymentField', () => { componentName: 'myComponent', description: 'Test payment description', failureUrl: 'http://localhost:3009/test-payment', - formId: 'form-id', + formId: 'formid', isLivePayment: false, paymentId: 'new-payment-id', reference: 'pay-ref-123', @@ -338,7 +338,7 @@ describe('PaymentField', () => { const args = { controller: { model: { - formId: 'form-id', + formId: 'formid', basePath: 'base-path', name: 'PaymentModel' }, @@ -432,7 +432,7 @@ describe('PaymentField', () => { amount: 123, description: 'Payment desc', isLivePayment: false, - formId: 'form-id' + formId: 'formid' } } } as unknown as FormContext @@ -458,7 +458,7 @@ describe('PaymentField', () => { amount: 123, description: 'Payment desc', isLivePayment: false, - formId: 'form-id' + formId: 'formid' } } } as unknown as FormContext @@ -495,7 +495,7 @@ describe('PaymentField', () => { amount: 123, description: 'Payment desc', isLivePayment: false, - formId: 'form-id' + formId: 'formid' } } } as unknown as FormContext @@ -531,7 +531,7 @@ describe('PaymentField', () => { amount: 123, description: 'Payment desc', isLivePayment: false, - formId: 'form-id' + formId: 'formid' } } } as unknown as FormContext diff --git a/src/server/plugins/engine/routes/payment-helper.test.js b/src/server/plugins/engine/routes/payment-helper.test.js index 3a1212586..d18fb3aa4 100644 --- a/src/server/plugins/engine/routes/payment-helper.test.js +++ b/src/server/plugins/engine/routes/payment-helper.test.js @@ -35,7 +35,7 @@ describe('payment helper', () => { get: jest.fn().mockReturnValueOnce({ paymentId: 'payment-id', isLivePayment: false, - formId: 'form-id' + formId: 'formid' }) } } @@ -76,7 +76,7 @@ describe('payment helper', () => { } }, session: { - formId: 'form-id', + formId: 'formid', isLivePayment: false, paymentId: 'payment-id' }, diff --git a/src/server/plugins/payment/helper.js b/src/server/plugins/payment/helper.js index 6e719624e..ce0a112c4 100644 --- a/src/server/plugins/payment/helper.js +++ b/src/server/plugins/payment/helper.js @@ -1,6 +1,5 @@ import { format } from 'date-fns' -import { config } from '~/src/config/index.js' import { PaymentService } from '~/src/server/plugins/payment/service.js' export const DEFAULT_PAYMENT_HELP_URL = @@ -8,8 +7,8 @@ export const DEFAULT_PAYMENT_HELP_URL = /** * Determine which payment API key value to use. - * If a non-live non-preview form, use the TEST API key value. - * If a live (non-preview) form, read the API key value specific to that form. + * If a draft preview form or a live preview form, read the TEST API key value specific to that form. + * If a live (non-preview) form, read the LIVE API key value specific to that form. * @param {boolean} isLivePayment - true if this is a live payment (as opposed to a test one) * @param {string} formId - id of the form * @returns {string} @@ -17,7 +16,7 @@ export const DEFAULT_PAYMENT_HELP_URL = export function getPaymentApiKey(isLivePayment, formId) { const apiKeyValue = isLivePayment ? process.env[`PAYMENT_PROVIDER_API_KEY_LIVE_${formId}`] - : config.get('paymentProviderApiKeyTest') + : process.env[`PAYMENT_PROVIDER_API_KEY_TEST_${formId}`] if (!apiKeyValue) { throw new Error( diff --git a/src/server/plugins/payment/helper.test.js b/src/server/plugins/payment/helper.test.js index a6375cb04..b1217ff4e 100644 --- a/src/server/plugins/payment/helper.test.js +++ b/src/server/plugins/payment/helper.test.js @@ -9,6 +9,7 @@ describe('getPaymentApiKey', () => { config.set('paymentProviderApiKeyTest', 'TEST-API-KEY') const formId = 'form-id' process.env['PAYMENT_PROVIDER_API_KEY_LIVE_form-id'] = 'LIVE-API-KEY' + process.env['PAYMENT_PROVIDER_API_KEY_TEST_form-id'] = 'TEST-API-KEY' it('should read test key when non-live form', () => { const apiKey = getPaymentApiKey(false, formId) @@ -20,7 +21,13 @@ describe('getPaymentApiKey', () => { expect(apiKey).toBe('LIVE-API-KEY') }) - it('should throw if key is missing', () => { + it('should throw if TEST key is missing', () => { + expect(() => getPaymentApiKey(false, 'form-id-missing')).toThrow( + 'Missing payment api key for test form id form-id-missing' + ) + }) + + it('should throw if LIVE key is missing', () => { expect(() => getPaymentApiKey(true, 'form-id-missing')).toThrow( 'Missing payment api key for live form id form-id-missing' ) From 41ec700ca5bf839868be96222a37e6f618a25f7f Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 30 Jan 2026 23:39:21 +0000 Subject: [PATCH 57/70] Stash --- .../plugins/engine/components/PaymentField.ts | 19 +++-- .../pageControllers/SummaryPageController.ts | 29 +++++++- .../plugins/engine/pageControllers/errors.ts | 69 ++++++++++++------- 3 files changed, 82 insertions(+), 35 deletions(-) diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index 5154bad73..dbd6f4d28 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -10,7 +10,10 @@ 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 { + PaymentErrorTypes, + PrePaymentError +} from '~/src/server/plugins/engine/pageControllers/errors.js' import { type AnyFormRequest, type FormContext, @@ -248,10 +251,11 @@ export class PaymentField extends FormComponent { const paymentState = this.getPaymentStateFromState(context.state) if (!paymentState) { - throw new InvalidComponentStateError( + throw new PrePaymentError( this, 'Complete the payment to continue', - { shouldResetState: true } + true, + PaymentErrorTypes.PaymentIncomplete ) } @@ -273,20 +277,21 @@ export class PaymentField extends FormComponent { } if (status.state.status !== 'capturable') { - throw new InvalidComponentStateError( + throw new PrePaymentError( this, 'Your payment authorisation has expired. Please add your payment details again.', - { shouldResetState: true, isPaymentExpired: true } + true, + PaymentErrorTypes.PaymentExpired ) } const captured = await paymentService.capturePayment(paymentId) if (!captured) { - throw new InvalidComponentStateError( + throw new PrePaymentError( this, 'There was a problem and your form was not submitted. Try submitting the form again.', - { shouldResetState: false } + false ) } diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 0a34068a0..a08e87812 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -31,7 +31,9 @@ import { import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' import { InvalidComponentStateError, - PostPaymentSubmissionError + PaymentErrorTypes, + PostPaymentSubmissionError, + PrePaymentError } from '~/src/server/plugins/engine/pageControllers/errors.js' import { buildMainRecords, @@ -243,6 +245,10 @@ export class SummaryPageController extends QuestionPageController { return this.handleInvalidComponentStateError(error, request, h) } + if (error instanceof PrePaymentError) { + return this.handlePrePaymentError(error, request, h) + } + if (error instanceof PostPaymentSubmissionError) { return this.handlePostPaymentSubmissionError(error, request, h) } @@ -260,10 +266,29 @@ export class SummaryPageController extends QuestionPageController { ) { const cacheService = getCacheService(request.server) + 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) + } + + /** + * Handles PrePaymentError during submission + */ + private async handlePrePaymentError( + error: PrePaymentError, + request: FormRequestPayload, + h: FormResponseToolkit + ) { + const cacheService = getCacheService(request.server) + if (error.shouldResetState) { await cacheService.resetComponentStates(request, error.getStateKeys()) - if (error.isPaymentExpired) { + if (error.errorType === PaymentErrorTypes.PaymentExpired) { request.yar.flash(PAYMENT_EXPIRED_NOTIFICATION, true, true) return this.proceed(request, h, error.component.page?.path) } diff --git a/src/server/plugins/engine/pageControllers/errors.ts b/src/server/plugins/engine/pageControllers/errors.ts index 92167af79..52508617e 100644 --- a/src/server/plugins/engine/pageControllers/errors.ts +++ b/src/server/plugins/engine/pageControllers/errors.ts @@ -1,5 +1,47 @@ import { type FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' +export enum PaymentErrorTypes { + PaymentExpired = 'PaymentExpired', + PaymentIncomplete = 'PaymentIncomplete' +} +export class PrePaymentError extends Error { + public readonly component: FormComponent + public readonly userMessage: string + + /** + * 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) + */ + public readonly shouldResetState: boolean + + /** + * When supplied, an "Important" notification banner will be shown based on the value. + */ + public readonly errorType: PaymentErrorTypes | undefined + + constructor( + component: FormComponent, + userMessage: string, + shouldResetState: boolean, + errorType?: PaymentErrorTypes + ) { + super('Payment capture failed') + this.name = 'PrePaymentError' + this.component = component + this.userMessage = userMessage + this.shouldResetState = shouldResetState + this.errorType = errorType + } + + getStateKeys() { + const extraStateKeys = + this.component.page?.getStateKeys(this.component) ?? [] + + return [this.component.name].concat(extraStateKeys) + } +} + /** * Thrown when form submission fails after payment has been captured. * User needs to retry or contact support for a refund. @@ -16,23 +58,6 @@ export class PostPaymentSubmissionError extends Error { } } -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 @@ -44,21 +69,13 @@ export interface InvalidComponentStateErrorOptions { 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, - options: InvalidComponentStateErrorOptions = {} - ) { + constructor(component: FormComponent, userMessage: string) { 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() { From 24a1f67469bde7d1efc7c3bf4456177f723cb9fa Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 2 Feb 2026 11:59:34 +0000 Subject: [PATCH 58/70] Rework after review --- .../engine/components/FormComponent.ts | 1 + .../engine/components/PaymentField.test.ts | 77 +++++++++++++------ .../plugins/engine/components/PaymentField.ts | 20 +++-- .../pageControllers/SummaryPageController.ts | 26 +++---- .../engine/pageControllers/errors.test.ts | 9 +-- .../plugins/engine/pageControllers/errors.ts | 26 +++++-- src/server/plugins/engine/routes/index.ts | 32 +++++--- .../plugins/engine/routes/payment.test.js | 2 + src/server/plugins/payment/service.js | 3 +- src/server/plugins/payment/types.js | 2 + 10 files changed, 136 insertions(+), 62 deletions(-) diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index b6bb83e4e..c754893b2 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -31,6 +31,7 @@ export class FormComponent extends ComponentBase { label: string isFormComponent = true + isAppendageStateSingleObject = false constructor( def: FormComponentsDef, diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts index 818b1318d..615098ee6 100644 --- a/src/server/plugins/engine/components/PaymentField.test.ts +++ b/src/server/plugins/engine/components/PaymentField.test.ts @@ -11,7 +11,7 @@ import { 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 { PaymentPreAuthError } from '~/src/server/plugins/engine/pageControllers/errors.js' import { type FormContext, type FormValue @@ -384,11 +384,9 @@ describe('PaymentField', () => { ) .catch((e: unknown) => e) - expect(error).toBeInstanceOf(InvalidComponentStateError) - expect((error as InvalidComponentStateError).component).toBe( - paymentField - ) - expect((error as InvalidComponentStateError).userMessage).toBe( + expect(error).toBeInstanceOf(PaymentPreAuthError) + expect((error as PaymentPreAuthError).component).toBe(paymentField) + expect((error as PaymentPreAuthError).userMessage).toBe( 'Complete the payment to continue' ) }) @@ -421,7 +419,9 @@ describe('PaymentField', () => { jest .mocked(get) // @ts-expect-error - partial mock - .mockResolvedValueOnce({ payload: { state: { status: 'success' } } }) + .mockResolvedValueOnce({ + payload: { amount: 100, state: { status: 'success' } } + }) await paymentField.onSubmit( mockRequest, {} as FormMetadata, @@ -429,7 +429,7 @@ describe('PaymentField', () => { state: { myComponent: { paymentId: 'payment-id', - amount: 123, + amount: 100, description: 'Payment desc', isLivePayment: false, formId: 'formid' @@ -446,7 +446,9 @@ describe('PaymentField', () => { jest .mocked(get) // @ts-expect-error - partial mock - .mockResolvedValueOnce({ payload: { state: { status: 'bad' } } }) + .mockResolvedValueOnce({ + payload: { amount: 100, state: { status: 'bad' } } + }) const error = await paymentField .onSubmit( mockRequest, @@ -455,7 +457,7 @@ describe('PaymentField', () => { state: { myComponent: { paymentId: 'payment-id', - amount: 123, + amount: 100, description: 'Payment desc', isLivePayment: false, formId: 'formid' @@ -465,11 +467,9 @@ describe('PaymentField', () => { ) .catch((e: unknown) => e) - expect(error).toBeInstanceOf(InvalidComponentStateError) - expect((error as InvalidComponentStateError).component).toBe( - paymentField - ) - expect((error as InvalidComponentStateError).userMessage).toBe( + expect(error).toBeInstanceOf(PaymentPreAuthError) + expect((error as PaymentPreAuthError).component).toBe(paymentField) + expect((error as PaymentPreAuthError).userMessage).toBe( 'Your payment authorisation has expired. Please add your payment details again.' ) }) @@ -480,7 +480,7 @@ describe('PaymentField', () => { .mocked(get) // @ts-expect-error - partial mock .mockResolvedValueOnce({ - payload: { state: { status: 'capturable' } } + payload: { amount: 100, state: { status: 'capturable' } } }) // @ts-expect-error - partial mock jest.mocked(post).mockResolvedValueOnce({ res: { statusCode: 400 } }) @@ -502,22 +502,55 @@ describe('PaymentField', () => { ) .catch((e: unknown) => e) - expect(error).toBeInstanceOf(InvalidComponentStateError) - expect((error as InvalidComponentStateError).component).toBe( - paymentField - ) - expect((error as InvalidComponentStateError).userMessage).toBe( + expect(error).toBeInstanceOf(PaymentPreAuthError) + expect((error as PaymentPreAuthError).component).toBe(paymentField) + expect((error as PaymentPreAuthError).userMessage).toBe( 'There was a problem and your form was not submitted. Try submitting the form again.' ) }) + it('should throw if amount mismatch', async () => { + const mockRequest = {} as unknown as FormRequestPayload + jest + .mocked(get) + // @ts-expect-error - partial mock + .mockResolvedValueOnce({ + payload: { amount: 50, state: { status: 'capturable' } } + }) + // @ts-expect-error - partial mock + jest.mocked(post).mockResolvedValueOnce({ res: { statusCode: 200 } }) + const error = await paymentField + .onSubmit( + mockRequest, + {} as FormMetadata, + { + state: { + myComponent: { + paymentId: 'payment-id', + amount: 123, + description: 'Payment desc', + isLivePayment: false, + formId: 'formid' + } + } + } as unknown as FormContext + ) + .catch((e: unknown) => e) + + expect(error).toBeInstanceOf(PaymentPreAuthError) + expect((error as PaymentPreAuthError).component).toBe(paymentField) + expect((error as PaymentPreAuthError).userMessage).toBe( + 'The pre-authorised payment amount is somehow different from that requested. Try adding payment details 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' } } + payload: { amount: 100, state: { status: 'capturable' } } }) // @ts-expect-error - partial mock jest.mocked(post).mockResolvedValueOnce({ res: { statusCode: 200 } }) diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index dbd6f4d28..1baa82557 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -12,7 +12,8 @@ import { type PaymentState } from '~/src/server/plugins/engine/components/Paymen import { getPluginOptions } from '~/src/server/plugins/engine/helpers.js' import { PaymentErrorTypes, - PrePaymentError + PaymentPreAuthError, + PaymentSubmissionError } from '~/src/server/plugins/engine/pageControllers/errors.js' import { type AnyFormRequest, @@ -34,6 +35,7 @@ export class PaymentField extends FormComponent { declare options: PaymentFieldComponent['options'] declare formSchema: ObjectSchema declare stateSchema: ObjectSchema + isAppendageStateSingleObject = true constructor( def: PaymentFieldComponent, @@ -92,11 +94,13 @@ export class PaymentField extends FormComponent { getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { const viewModel = super.getViewModel(payload, errors) + // Payload is pre-populated from state if a payment has already been made const paymentState = this.isPaymentState(payload[this.name] as unknown) ? (payload[this.name] as unknown as PaymentState) : undefined - const amount = this.options.amount ?? 0 + // When user initially visits the payment page, there is no payment state yet so the amount is read form the form definition. + const amount = paymentState?.amount ?? this.options.amount ?? 0 const formattedAmount = amount.toFixed(2) return { @@ -251,7 +255,7 @@ export class PaymentField extends FormComponent { const paymentState = this.getPaymentStateFromState(context.state) if (!paymentState) { - throw new PrePaymentError( + throw new PaymentPreAuthError( this, 'Complete the payment to continue', true, @@ -271,13 +275,19 @@ export class PaymentField extends FormComponent { */ const status = await paymentService.getPaymentStatus(paymentId) + PaymentSubmissionError.checkPaymentAmount( + status.amount, + this.options.amount, + this + ) + if (status.state.status === 'success') { await this.markPaymentCaptured(request, paymentState) return } if (status.state.status !== 'capturable') { - throw new PrePaymentError( + throw new PaymentPreAuthError( this, 'Your payment authorisation has expired. Please add your payment details again.', true, @@ -288,7 +298,7 @@ export class PaymentField extends FormComponent { const captured = await paymentService.capturePayment(paymentId) if (!captured) { - throw new PrePaymentError( + throw new PaymentPreAuthError( this, 'There was a problem and your form was not submitted. Try submitting the form again.', false diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index a08e87812..8446a287a 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -32,8 +32,8 @@ import { QuestionPageController } from '~/src/server/plugins/engine/pageControll import { InvalidComponentStateError, PaymentErrorTypes, - PostPaymentSubmissionError, - PrePaymentError + PaymentPreAuthError, + PaymentSubmissionError } from '~/src/server/plugins/engine/pageControllers/errors.js' import { buildMainRecords, @@ -245,12 +245,12 @@ export class SummaryPageController extends QuestionPageController { return this.handleInvalidComponentStateError(error, request, h) } - if (error instanceof PrePaymentError) { - return this.handlePrePaymentError(error, request, h) + if (error instanceof PaymentPreAuthError) { + return this.handlePaymentPreAuthError(error, request, h) } - if (error instanceof PostPaymentSubmissionError) { - return this.handlePostPaymentSubmissionError(error, request, h) + if (error instanceof PaymentSubmissionError) { + return this.handlePaymentSubmissionError(error, request, h) } throw error @@ -276,10 +276,10 @@ export class SummaryPageController extends QuestionPageController { } /** - * Handles PrePaymentError during submission + * Handles PaymentPreAuthError during submission */ - private async handlePrePaymentError( - error: PrePaymentError, + private async handlePaymentPreAuthError( + error: PaymentPreAuthError, request: FormRequestPayload, h: FormResponseToolkit ) { @@ -305,10 +305,10 @@ export class SummaryPageController extends QuestionPageController { } /** - * Handles PostPaymentSubmissionError during submission + * Handles PaymentSubmissionError during submission */ - private handlePostPaymentSubmissionError( - error: PostPaymentSubmissionError, + private handlePaymentSubmissionError( + error: PaymentSubmissionError, request: FormRequestPayload, h: FormResponseToolkit ) { @@ -385,7 +385,7 @@ export async function submitForm( ) } catch (err) { if (paymentWasCaptured) { - throw new PostPaymentSubmissionError( + throw new PaymentSubmissionError( context.referenceNumber, formMetadata.contact?.online?.url ) diff --git a/src/server/plugins/engine/pageControllers/errors.test.ts b/src/server/plugins/engine/pageControllers/errors.test.ts index c0953627e..5f116dd57 100644 --- a/src/server/plugins/engine/pageControllers/errors.test.ts +++ b/src/server/plugins/engine/pageControllers/errors.test.ts @@ -5,7 +5,7 @@ import { TextField } from '~/src/server/plugins/engine/components/TextField.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { InvalidComponentStateError, - PostPaymentSubmissionError + PaymentSubmissionError } from '~/src/server/plugins/engine/pageControllers/errors.js' import definition from '~/test/form/definitions/file-upload-basic.js' @@ -67,12 +67,9 @@ describe('InvalidComponentStateError', () => { }) }) - describe('PostPaymentSubmissionError', () => { + describe('PaymentSubmissionError', () => { it('should instantiate', () => { - const error = new PostPaymentSubmissionError( - 'reference-number', - '/help-link' - ) + const error = new PaymentSubmissionError('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 52508617e..9445818c7 100644 --- a/src/server/plugins/engine/pageControllers/errors.ts +++ b/src/server/plugins/engine/pageControllers/errors.ts @@ -2,9 +2,10 @@ import { type FormComponent } from '~/src/server/plugins/engine/components/FormC export enum PaymentErrorTypes { PaymentExpired = 'PaymentExpired', - PaymentIncomplete = 'PaymentIncomplete' + PaymentIncomplete = 'PaymentIncomplete', + PaymentAmountMismatch = 'PaymentAmountMismatch' } -export class PrePaymentError extends Error { +export class PaymentPreAuthError extends Error { public readonly component: FormComponent public readonly userMessage: string @@ -27,7 +28,7 @@ export class PrePaymentError extends Error { errorType?: PaymentErrorTypes ) { super('Payment capture failed') - this.name = 'PrePaymentError' + this.name = 'PaymentPreAuthError' this.component = component this.userMessage = userMessage this.shouldResetState = shouldResetState @@ -46,16 +47,31 @@ export class PrePaymentError extends Error { * 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 { +export class PaymentSubmissionError 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.name = 'PaymentSubmissionError' this.referenceNumber = referenceNumber this.helpLink = helpLink } + + static checkPaymentAmount( + stateAmount: number, + definitionAmount: number | undefined, + component: FormComponent + ) { + if (stateAmount !== definitionAmount) { + throw new PaymentPreAuthError( + component, + 'The pre-authorised payment amount is somehow different from that requested. Try adding payment details again.', + true, + PaymentErrorTypes.PaymentIncomplete + ) + } + } } /** diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index f62c180f5..f3caaf0eb 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -10,7 +10,10 @@ import { EXTERNAL_STATE_PAYLOAD } from '~/src/server/constants.js' import { resolveFormModel } from '~/src/server/plugins/engine/beta/form-context.js' -import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' +import { + FormComponent, + isFormState +} from '~/src/server/plugins/engine/components/FormComponent.js' import { checkFormStatus, findPage, @@ -135,8 +138,19 @@ async function importExternalComponentState( throw new Error(`State for component ${componentName} is invalid`) } - // Store component state under the component name - const componentState = { [componentName]: stateAppendage } + // Create state structure from appendage state + // Some components use a record structure with properties of the format of '__' + // e.g. UKAddressField + // Some components use a single object structure e.g. PaymentField + const componentState = + isFormState(stateAppendage) && !component.isAppendageStateSingleObject + ? Object.fromEntries( + Object.entries(stateAppendage).map(([key, value]) => [ + `${componentName}__${key}`, + value + ]) + ) + : { [componentName]: stateAppendage } // Save the external component state directly (already has correct key format) const savedState = await page.mergeState(request, state, componentState) @@ -145,14 +159,12 @@ async function importExternalComponentState( const payload = request.yar.flash(EXTERNAL_STATE_PAYLOAD) const stashedPayload = Array.isArray(payload) ? {} : (payload as FormPayload) - if (Object.keys(stashedPayload).length) { - const localState = page.getStateFromValidForm(request, savedState, { - ...stashedPayload - } as FormPayload) - return { ...savedState, ...localState } - } + const localState = page.getStateFromValidForm(request, savedState, { + ...stashedPayload, + ...componentState + } as FormPayload) - return savedState + return { ...savedState, ...localState } } export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { diff --git a/src/server/plugins/engine/routes/payment.test.js b/src/server/plugins/engine/routes/payment.test.js index a1e077ab6..9ae926297 100644 --- a/src/server/plugins/engine/routes/payment.test.js +++ b/src/server/plugins/engine/routes/payment.test.js @@ -52,6 +52,7 @@ describe('Payment routes', () => { ])('should handle payment status of $row.status', async (row) => { const paymentStatus = { paymentId: 'new-payment-id', + amount: 125, _links: { next_url: { href: '/next-url', @@ -143,6 +144,7 @@ describe('Payment routes', () => { const paymentStatus = { paymentId: 'new-payment-id', payment_id: 'new-payment-id', + amount: 125, email: 'payer@example.com', _links: { next_url: { diff --git a/src/server/plugins/payment/service.js b/src/server/plugins/payment/service.js index e04e42a7c..9247355b5 100644 --- a/src/server/plugins/payment/service.js +++ b/src/server/plugins/payment/service.js @@ -81,7 +81,8 @@ export class PaymentService { state: response.payload.state, _links: response.payload._links, email: response.payload.email, - paymentId: response.payload.payment_id + paymentId: response.payload.payment_id, + amount: response.payload.amount } } catch (err) { const error = /** @type {Error} */ (err) diff --git a/src/server/plugins/payment/types.js b/src/server/plugins/payment/types.js index 328050bbb..d58783d4a 100644 --- a/src/server/plugins/payment/types.js +++ b/src/server/plugins/payment/types.js @@ -41,6 +41,7 @@ * Response from GOV.UK Pay GET /v1/payments/{PAYMENT_ID} endpoint - not underscore in property name * @typedef {object} GetPaymentApiResponsePaymentProp * @property {string} payment_id - Unique identifier for the payment + * @property {number} amount - amount of the payment */ /** @@ -52,6 +53,7 @@ * Response returned from getPaymentStatus - subtley different from GetPaymentApiResponse * @typedef {object} GetPaymentResponsePaymentProp * @property {string} paymentId - Unique identifier for the payment - note no underscore in property name + * @property {number} amount - amount of the payment */ /** From c1f2917ce2565e54274b534d35210729f4d65ac3 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 2 Feb 2026 12:41:56 +0000 Subject: [PATCH 59/70] Sonar fix --- .../plugins/engine/pageControllers/errors.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/server/plugins/engine/pageControllers/errors.ts b/src/server/plugins/engine/pageControllers/errors.ts index 9445818c7..45ff56f2b 100644 --- a/src/server/plugins/engine/pageControllers/errors.ts +++ b/src/server/plugins/engine/pageControllers/errors.ts @@ -5,6 +5,13 @@ export enum PaymentErrorTypes { PaymentIncomplete = 'PaymentIncomplete', PaymentAmountMismatch = 'PaymentAmountMismatch' } + +function getStateKeys(component: FormComponent) { + const extraStateKeys = component.page?.getStateKeys(component) ?? [] + + return [component.name].concat(extraStateKeys) +} + export class PaymentPreAuthError extends Error { public readonly component: FormComponent public readonly userMessage: string @@ -36,10 +43,7 @@ export class PaymentPreAuthError extends Error { } getStateKeys() { - const extraStateKeys = - this.component.page?.getStateKeys(this.component) ?? [] - - return [this.component.name].concat(extraStateKeys) + return getStateKeys(this.component) } } @@ -95,9 +99,6 @@ export class InvalidComponentStateError extends Error { } getStateKeys() { - const extraStateKeys = - this.component.page?.getStateKeys(this.component) ?? [] - - return [this.component.name].concat(extraStateKeys) + return getStateKeys(this.component) } } From cb7d9eabd414e3c7097b53335be2f4c1889cbb69 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 2 Feb 2026 14:18:14 +0000 Subject: [PATCH 60/70] Lint fix --- .../plugins/engine/components/PaymentField.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index 1baa82557..f9517bf19 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -156,6 +156,26 @@ export class PaymentField extends FormComponent { return this.isValue(value) ? value : undefined } + getFormValueFromState(state: FormSubmissionState): PaymentState | undefined { + const { name } = this + + return this.getFormValue(state[name]) + } + + getContextValueFromFormValue(value: PaymentState | undefined): string | null { + if (!value) { + return null + } + + return `Reference: ${value.reference}\nAmount: ${value.amount.toFixed(2)}` + } + + getContextValueFromState(state: FormSubmissionState) { + const value = this.getFormValueFromState(state) + + return this.getContextValueFromFormValue(value) + } + /** * For error preview page that shows all possible errors on a component */ From ac6dc4f9ba78c3514ee5469d5f4e3fbb31f8cab5 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 2 Feb 2026 16:23:08 +0000 Subject: [PATCH 61/70] Reworked types for payment state --- .../engine/components/FormComponent.ts | 5 ++- .../engine/components/PaymentField.test.ts | 6 ++-- .../plugins/engine/components/PaymentField.ts | 35 ++++--------------- .../outputFormatters/human/v1.payment.test.ts | 3 +- .../machine/v2.payment.test.ts | 6 +++- src/server/plugins/engine/types.ts | 2 -- 6 files changed, 19 insertions(+), 38 deletions(-) diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index c754893b2..63a96d72e 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -5,7 +5,6 @@ import { } from '@defra/forms-model' import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' -import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js' import { optionalText } from '~/src/server/plugins/engine/components/constants.js' import { type FormContext, @@ -181,7 +180,7 @@ export class FormComponent extends ComponentBase { getContextValueFromFormValue( value: FormValue | FormPayload - ): Item['value'] | Item['value'][] | PaymentState | null { + ): Item['value'] | Item['value'][] | null { // Filter object field values if (this.isState(value)) { const values = Object.values(value).filter(isFormValue) @@ -198,7 +197,7 @@ export class FormComponent extends ComponentBase { getContextValueFromState( state: FormSubmissionState - ): Item['value'] | Item['value'][] | PaymentState | null { + ): Item['value'] | Item['value'][] | null { const value = this.getFormValueFromState(state) return this.getContextValueFromFormValue(value) diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts index 615098ee6..cd3f6b86e 100644 --- a/src/server/plugins/engine/components/PaymentField.test.ts +++ b/src/server/plugins/engine/components/PaymentField.test.ts @@ -110,7 +110,9 @@ describe('PaymentField', () => { formId: '', isLivePayment: false } - const result = collection.validate(getFormData(payment)) + const result = collection.validate( + getFormData(payment as unknown as FormValue) + ) const errors = result.errors ?? [] @@ -174,7 +176,7 @@ describe('PaymentField', () => { isLivePayment: false } it('returns text from state', () => { - const state1 = getFormState(paymentForState) + const state1 = getFormState(paymentForState as unknown as FormValue) const state2 = getFormState(null) const answer1 = getAnswer(field, state1) diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index f9517bf19..3531e9697 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -141,39 +141,16 @@ export class PaymentField extends FormComponent { return this.isPaymentState(value) } - /** - * Override base isValue to recognize PaymentState objects - * The base implementation only recognises primitives (string, number, boolean) - */ - isValue(value?: FormStateValue | FormState): value is PaymentState { - return this.isPaymentState(value) - } - - /** - * Override base getFormValue to handle PaymentState objects - */ getFormValue(value?: FormStateValue | FormState) { - return this.isValue(value) ? value : undefined - } - - getFormValueFromState(state: FormSubmissionState): PaymentState | undefined { - const { name } = this - - return this.getFormValue(state[name]) - } - - getContextValueFromFormValue(value: PaymentState | undefined): string | null { - if (!value) { - return null - } - - return `Reference: ${value.reference}\nAmount: ${value.amount.toFixed(2)}` + return this.isPaymentState(value) + ? (value as unknown as NonNullable) + : undefined } getContextValueFromState(state: FormSubmissionState) { - const value = this.getFormValueFromState(state) - - return this.getContextValueFromFormValue(value) + return this.isPaymentState(state) + ? `Reference: ${state.reference}\nAmount: ${state.amount.toFixed(2)}` + : '' } /** diff --git a/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts b/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts index 2eaaf886f..dd7e55f28 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts @@ -9,6 +9,7 @@ import { getFormSubmissionData } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js' import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' +import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js' import { FormStatus } from '~/src/server/routes/types.js' import definitionPayment from '~/test/form/definitions/payment.js' @@ -72,7 +73,7 @@ describe('v1 human formatter', () => { const contextPayment = modelPayment.getFormContext( requestPayment, - statePayment + statePayment as unknown as FormSubmissionState ) const summaryViewModelPayment = controllerPayment.getSummaryViewModel( requestPayment, diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts b/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts index 7236bce69..c16a81288 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v2.payment.test.ts @@ -8,6 +8,7 @@ import { getFormSubmissionData } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js' import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' +import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js' import { FormStatus } from '~/src/server/routes/types.js' import definition from '~/test/form/definitions/payment.js' @@ -65,7 +66,10 @@ const request = buildFormContextRequest({ app: { model } }) -const context = model.getFormContext(request, state) +const context = model.getFormContext( + request, + state as unknown as FormSubmissionState +) const pageDef = definition.pages[2] diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 987cc100e..31a656ffa 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -15,7 +15,6 @@ 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 { @@ -125,7 +124,6 @@ export type FormValue = | Item['value'][] | UploadState | RepeatListState - | PaymentState | undefined export type FormState = Partial> From 752220f328b5f7b994f3a163ec09678ccbaa6f83 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Mon, 2 Feb 2026 16:51:24 +0000 Subject: [PATCH 62/70] Corrected check and tests --- .../engine/components/PaymentField.test.ts | 42 ++++++++++++++++--- .../plugins/engine/pageControllers/errors.ts | 2 +- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts index cd3f6b86e..d426f6ac1 100644 --- a/src/server/plugins/engine/components/PaymentField.test.ts +++ b/src/server/plugins/engine/components/PaymentField.test.ts @@ -422,7 +422,7 @@ describe('PaymentField', () => { .mocked(get) // @ts-expect-error - partial mock .mockResolvedValueOnce({ - payload: { amount: 100, state: { status: 'success' } } + payload: { amount: 10000, state: { status: 'success' } } }) await paymentField.onSubmit( mockRequest, @@ -449,7 +449,7 @@ describe('PaymentField', () => { .mocked(get) // @ts-expect-error - partial mock .mockResolvedValueOnce({ - payload: { amount: 100, state: { status: 'bad' } } + payload: { amount: 10000, state: { status: 'bad' } } }) const error = await paymentField .onSubmit( @@ -482,7 +482,7 @@ describe('PaymentField', () => { .mocked(get) // @ts-expect-error - partial mock .mockResolvedValueOnce({ - payload: { amount: 100, state: { status: 'capturable' } } + payload: { amount: 10000, state: { status: 'capturable' } } }) // @ts-expect-error - partial mock jest.mocked(post).mockResolvedValueOnce({ res: { statusCode: 400 } }) @@ -517,7 +517,7 @@ describe('PaymentField', () => { .mocked(get) // @ts-expect-error - partial mock .mockResolvedValueOnce({ - payload: { amount: 50, state: { status: 'capturable' } } + payload: { amount: 5000, state: { status: 'capturable' } } }) // @ts-expect-error - partial mock jest.mocked(post).mockResolvedValueOnce({ res: { statusCode: 200 } }) @@ -552,7 +552,7 @@ describe('PaymentField', () => { .mocked(get) // @ts-expect-error - partial mock .mockResolvedValueOnce({ - payload: { amount: 100, state: { status: 'capturable' } } + payload: { amount: 10000, state: { status: 'capturable' } } }) // @ts-expect-error - partial mock jest.mocked(post).mockResolvedValueOnce({ res: { statusCode: 200 } }) @@ -575,5 +575,37 @@ describe('PaymentField', () => { expect(post).toHaveBeenCalled() }) }) + + describe('getFormValue', () => { + it('should return undefined', () => { + expect(paymentField.getFormValue({})).toBeUndefined() + }) + it('should return value', () => { + const payment = { + paymentId: 'payment-id', + amount: 123, + description: 'Payment desc', + isLivePayment: false, + formId: 'formid' + } + expect(paymentField.getFormValue(payment)).toEqual(payment) + }) + }) + + describe('isState', () => { + it('should return false if not valid state', () => { + expect(paymentField.isState({})).toBe(false) + }) + it('should return value', () => { + const payment = { + paymentId: 'payment-id', + amount: 123, + description: 'Payment desc', + isLivePayment: false, + formId: 'formid' + } + expect(paymentField.isState(payment)).toBe(true) + }) + }) }) }) diff --git a/src/server/plugins/engine/pageControllers/errors.ts b/src/server/plugins/engine/pageControllers/errors.ts index 45ff56f2b..5e796eec7 100644 --- a/src/server/plugins/engine/pageControllers/errors.ts +++ b/src/server/plugins/engine/pageControllers/errors.ts @@ -67,7 +67,7 @@ export class PaymentSubmissionError extends Error { definitionAmount: number | undefined, component: FormComponent ) { - if (stateAmount !== definitionAmount) { + if (stateAmount / 100 !== definitionAmount) { throw new PaymentPreAuthError( component, 'The pre-authorised payment amount is somehow different from that requested. Try adding payment details again.', From 3f9ec59de460f96cf7da174e424a8a34f740afbd Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 3 Feb 2026 13:50:16 +0000 Subject: [PATCH 63/70] Changed date/time format Payment options now mandatory --- package-lock.json | 12 ++++++------ src/server/plugins/engine/components/PaymentField.ts | 8 +++++--- .../engine/outputFormatters/human/v1.payment.test.ts | 4 ++-- src/server/plugins/payment/helper.js | 4 ++-- src/server/plugins/payment/helper.test.js | 2 +- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0d78afd53..9d8e0639b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2422,9 +2422,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", - "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", "dev": true, "license": "MIT", "optional": true, @@ -2434,9 +2434,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "dev": true, "license": "MIT", "optional": true, diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index 3531e9697..8b9d50c1a 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -100,7 +100,8 @@ export class PaymentField extends FormComponent { : undefined // When user initially visits the payment page, there is no payment state yet so the amount is read form the form definition. - const amount = paymentState?.amount ?? this.options.amount ?? 0 + const amount = paymentState?.amount ?? this.options.amount + const formattedAmount = amount.toFixed(2) return { @@ -205,8 +206,9 @@ export class PaymentField extends FormComponent { const uuid = randomUUID() const reference = state.$$__referenceNumber as string - const amount = options.amount ?? 0 - const description = options.description ?? '' + const amount = options.amount + + const description = options.description const slug = `/${model.basePath}` diff --git a/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts b/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts index dd7e55f28..d4f8c5129 100644 --- a/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts +++ b/src/server/plugins/engine/outputFormatters/human/v1.payment.test.ts @@ -45,7 +45,7 @@ describe('v1 human formatter', () => { isLivePayment: false, preAuth: { status: 'success', - createdAt: '2026-01-02T11:00:04+0000' + createdAt: '2026-01-02T11:02:04+0000' } } as PaymentState } @@ -137,7 +137,7 @@ describe('v1 human formatter', () => { ## Date of payment - 2 January 2026 – 11:00:04 + 2 January 2026 11:02am --- ` diff --git a/src/server/plugins/payment/helper.js b/src/server/plugins/payment/helper.js index ce0a112c4..042ca97d9 100644 --- a/src/server/plugins/payment/helper.js +++ b/src/server/plugins/payment/helper.js @@ -40,10 +40,10 @@ export function createPaymentService(isLivePayment, formId) { /** * Formats a payment date for display * @param {string} isoString - ISO date string - * @returns {string} Formatted date string (e.g., "26 January 2026 – 17:01:29") + * @returns {string} Formatted date string (e.g., "26 January 2026 5:01pm") */ export function formatPaymentDate(isoString) { - return format(new Date(isoString), 'd MMMM yyyy – HH:mm:ss') + return format(new Date(isoString), 'd MMMM yyyy h:mmaaa') } /** diff --git a/src/server/plugins/payment/helper.test.js b/src/server/plugins/payment/helper.test.js index b1217ff4e..f8c0a9322 100644 --- a/src/server/plugins/payment/helper.test.js +++ b/src/server/plugins/payment/helper.test.js @@ -37,7 +37,7 @@ describe('getPaymentApiKey', () => { describe('formatPaymentDate', () => { it('should format ISO date string to en-GB format', () => { const result = formatPaymentDate('2025-11-10T17:01:29.000Z') - expect(result).toBe('10 November 2025 – 17:01:29') + expect(result).toBe('10 November 2025 5:01pm') }) }) From c4409b32161eb61a3de6b4bccbf179ec267ae062 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 3 Feb 2026 16:18:30 +0000 Subject: [PATCH 64/70] Prevents access to summary if no payment yet --- src/server/plugins/engine/models/FormModel.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index c2aff279e..b36033b93 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -470,6 +470,15 @@ export class FormModel { } } } + + // Check Payment component - only one allowed per form + const paymentComponent = page.collection.fields.find( + (field) => field.type === ComponentType.PaymentField + ) + if (paymentComponent) { + const fieldVal = paymentComponent.getFormValueFromState(context.state) + return fieldVal === undefined + } } private fieldStateIsInvalid( From b271e1a5cea3dbdfc95353b6be3d8175b9d21581 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Tue, 3 Feb 2026 17:18:11 +0000 Subject: [PATCH 65/70] Moved payment check logic --- src/server/plugins/engine/models/FormModel.ts | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index b36033b93..df466b3af 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -29,6 +29,7 @@ import { Parser, type Value } from 'expr-eval-fork' import joi from 'joi' import { createLogger } from '~/src/server/common/helpers/logging/logger.js' +import { type ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { type ListFormComponent } from '~/src/server/plugins/engine/components/ListFormComponent.js' import {} from '~/src/server/plugins/engine/components/YesNoField.js' import { @@ -470,15 +471,6 @@ export class FormModel { } } } - - // Check Payment component - only one allowed per form - const paymentComponent = page.collection.fields.find( - (field) => field.type === ComponentType.PaymentField - ) - if (paymentComponent) { - const fieldVal = paymentComponent.getFormValueFromState(context.state) - return fieldVal === undefined - } } private fieldStateIsInvalid( @@ -529,15 +521,30 @@ export class FormModel { } } + private isUnpaidPayment( + context: FormContext, + collection: ComponentCollection + ) { + const paymentField = collection.fields.find( + (field) => field.type === ComponentType.PaymentField + ) + if (paymentField) { + const fieldVal = paymentField.getFormValueFromState(context.state) + return fieldVal === undefined + } + return false + } + private assignPaths(context: FormContext) { - for (const { keys, path } of context.relevantPages) { + for (const { keys, path, collection } of context.relevantPages) { context.paths.push(path) // Stop at page with errors if ( context.errors?.some(({ name, path }) => { return keys.includes(name) || keys.some((key) => path.includes(key)) - }) + }) || + this.isUnpaidPayment(context, collection) ) { break } From fa9f6bddec391a14d9ad51f62a6f2d0b5c39c640 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 4 Feb 2026 08:19:02 +0000 Subject: [PATCH 66/70] Handles missing payment via PaymentField state validation --- src/server/forms/payment-test.yaml | 13 +++++++++++- .../plugins/engine/components/PaymentField.ts | 5 ++++- src/server/plugins/engine/models/FormModel.ts | 20 ++----------------- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/server/forms/payment-test.yaml b/src/server/forms/payment-test.yaml index c1996a85b..39764fa96 100644 --- a/src/server/forms/payment-test.yaml +++ b/src/server/forms/payment-test.yaml @@ -3,6 +3,17 @@ schema: 2 name: Payment Test Form declaration: "

All the answers you have provided are true to the best of your knowledge.

" pages: + - title: What is your name? + path: '/person' + components: + - name: personName + title: What is your name? + type: TextField + shortDescription: Your name + options: + required: true + next: + - path: '/pay-for-your-licence' - title: A page title path: '/pay-for-your-licence' components: @@ -28,4 +39,4 @@ pages: conditions: [] sections: [] lists: [] -startPage: '/pay-for-your-licence' +startPage: '/person' diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index 8b9d50c1a..859889925 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -68,7 +68,10 @@ export class PaymentField extends FormComponent { .label(this.label) this.formSchema = paymentStateSchema - this.stateSchema = paymentStateSchema.default(null).allow(null) + // 'required()' forces the payment page to be invalid until we have valid payment state + // i.e. the user will automatically be directed back to the payment page + // if they attempt to access future pages wen no payment entered yet + this.stateSchema = paymentStateSchema.required() } /** diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index df466b3af..c2aff279e 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -29,7 +29,6 @@ import { Parser, type Value } from 'expr-eval-fork' import joi from 'joi' import { createLogger } from '~/src/server/common/helpers/logging/logger.js' -import { type ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { type ListFormComponent } from '~/src/server/plugins/engine/components/ListFormComponent.js' import {} from '~/src/server/plugins/engine/components/YesNoField.js' import { @@ -521,30 +520,15 @@ export class FormModel { } } - private isUnpaidPayment( - context: FormContext, - collection: ComponentCollection - ) { - const paymentField = collection.fields.find( - (field) => field.type === ComponentType.PaymentField - ) - if (paymentField) { - const fieldVal = paymentField.getFormValueFromState(context.state) - return fieldVal === undefined - } - return false - } - private assignPaths(context: FormContext) { - for (const { keys, path, collection } of context.relevantPages) { + for (const { keys, path } of context.relevantPages) { context.paths.push(path) // Stop at page with errors if ( context.errors?.some(({ name, path }) => { return keys.includes(name) || keys.some((key) => path.includes(key)) - }) || - this.isUnpaidPayment(context, collection) + }) ) { break } From 2b3ac7d62429f6230522ff9b4a33189a0abed591 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 4 Feb 2026 08:40:46 +0000 Subject: [PATCH 67/70] Dummy commit --- README.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 74e508cd5..d78c6de3b 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,18 @@ It is designed to be embedded in the frontend of a digital service and provide a ## Table of Contents -- [Demo of DXT](#demo-of-dxt) -- [Installation](#installation) -- [Documentation](#documentation) -- [Publishing the Package](#publishing-the-package) - - [Semantic Versioning Control](#semantic-versioning-control) - - [Major-Version Release Branches](#major-version-release-branches) - - [Manual Workflow Triggers](#manual-workflow-triggers) - - [Workflow Triggers](#workflow-triggers) - - [Safety and Consistency](#safety-and-consistency) +- [@defra/forms-engine-plugin](#defraforms-engine-plugin) + - [Table of Contents](#table-of-contents) + - [Demo of DXT](#demo-of-dxt) + - [Installation](#installation) + - [Documentation](#documentation) + - [Contributing](#contributing) + - [Publishing the package](#publishing-the-package) + - [Semantic Versioning Control](#semantic-versioning-control) + - [Major-Version Release Branches](#major-version-release-branches) + - [Manual Workflow Triggers](#manual-workflow-triggers) + - [Workflow Triggers](#workflow-triggers) + - [Safety and Consistency](#safety-and-consistency) ## Demo of DXT From 0d5e2bb95e9a09feebcc0ea81a1a59baba1403f9 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 4 Feb 2026 08:50:29 +0000 Subject: [PATCH 68/70] Typo --- src/server/plugins/engine/components/PaymentField.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index 859889925..e056ffc03 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -70,7 +70,7 @@ export class PaymentField extends FormComponent { this.formSchema = paymentStateSchema // 'required()' forces the payment page to be invalid until we have valid payment state // i.e. the user will automatically be directed back to the payment page - // if they attempt to access future pages wen no payment entered yet + // if they attempt to access future pages when no payment entered yet this.stateSchema = paymentStateSchema.required() } From fd39f05822b919be2d4759bee216664331591c99 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 4 Feb 2026 10:15:29 +0000 Subject: [PATCH 69/70] Corrected github workflow --- .github/workflows/check-pull-request.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-pull-request.yml b/.github/workflows/check-pull-request.yml index d960dd6fb..ceb69602a 100644 --- a/.github/workflows/check-pull-request.yml +++ b/.github/workflows/check-pull-request.yml @@ -193,7 +193,7 @@ jobs: analysis: name: Analysis - if: ${{ always() && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request') }} + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' runs-on: ubuntu-24.04 needs: [build, tasks] @@ -207,7 +207,7 @@ jobs: uses: actions/cache/restore@v4 with: enableCrossOsArchive: true - fail-on-cache-miss: false + fail-on-cache-miss: true key: test-unit-${{ runner.os }}-node24-${{ github.sha }} path: coverage From 9f098fe207552b2235ccdcfb89cd6b6b237098bf Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Wed, 4 Feb 2026 11:02:55 +0000 Subject: [PATCH 70/70] Bumped model version --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 14f8e717b..bfa523c95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.606", + "@defra/forms-model": "^3.0.611", "@defra/hapi-tracing": "^1.29.0", "@defra/interactive-map": "^0.0.3-alpha", "@elastic/ecs-pino-format": "^1.5.0", @@ -2312,9 +2312,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.606", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.606.tgz", - "integrity": "sha512-Oz5Mqj/lqeiNSrCOHJ9PWap+kx4IR95oBXXrBUBqSu+LRVVfdyWp0Fm5WbAlKO63ixT2gMNjJFxiG6NxhN4tug==", + "version": "3.0.611", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.611.tgz", + "integrity": "sha512-QDOP0p2BRjJSjrQ/S0Mgd6QqwvOyRH8ByhKgzST8FSkn1CF88aEblSfqe506vd4Di0aS+EXzv1AVIitgxBQJMw==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", diff --git a/package.json b/package.json index c8bc12d7c..609c65ad3 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.606", + "@defra/forms-model": "^3.0.611", "@defra/hapi-tracing": "^1.29.0", "@defra/interactive-map": "^0.0.3-alpha", "@elastic/ecs-pino-format": "^1.5.0",