diff --git a/jest.setup.cjs b/jest.setup.cjs index d9e1df3b6..2686c4c7f 100644 --- a/jest.setup.cjs +++ b/jest.setup.cjs @@ -13,3 +13,4 @@ process.env.UPLOADER_BUCKET_NAME = 'dummy-bucket' process.env.GOOGLE_ANALYTICS_TRACKING_ID = 'G-123456789' process.env.SUBMISSION_EMAIL_ADDRESS = 'dummy@defra.gov.uk' process.env.ORDNANCE_SURVEY_API_KEY = 'dummy' +process.env.PAYMENT_PROVIDER_API_KEY_TEST = 'test-api-key' diff --git a/package-lock.json b/package-lock.json index da22096df..126aba582 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3935,9 +3935,9 @@ } }, "node_modules/@jest/reporters/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -9325,9 +9325,9 @@ } }, "node_modules/expr-eval-fork": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/expr-eval-fork/-/expr-eval-fork-3.0.0.tgz", - "integrity": "sha512-29S+IZ2g8qSk5q7gOUYozO7zi4mj/sCVo+HB2h0f0ER4ZCZr9b/+5SWIedvV0SHq3IxBW2/TJrPn77YxMsoVwg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/expr-eval-fork/-/expr-eval-fork-3.0.1.tgz", + "integrity": "sha512-JRex9aykIt6AqhcQK+u1bFcBy2f+muwJoGCtAZmOC0yrktaCegtH42sLnZdNsD2/Ko9j+3pLWi4nIkNQez02bg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -11407,9 +11407,9 @@ } }, "node_modules/jest-config/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -11977,9 +11977,9 @@ } }, "node_modules/jest-runtime/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -12773,9 +12773,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.clonedeep": { diff --git a/src/server/constants.js b/src/server/constants.js index 301539c14..b9e68652b 100644 --- a/src/server/constants.js +++ b/src/server/constants.js @@ -3,3 +3,4 @@ export const FORM_PREFIX = '' export const EXTERNAL_STATE_PAYLOAD = 'EXTERNAL_STATE_PAYLOAD' export const EXTERNAL_STATE_APPENDAGE = 'EXTERNAL_STATE_APPENDAGE' export const COMPONENT_STATE_ERROR = 'COMPONENT_STATE_ERROR' +export const PAYMENT_EXPIRED_NOTIFICATION = 'PAYMENT_EXPIRED_NOTIFICATION' diff --git a/src/server/forms/payment-test.yaml b/src/server/forms/payment-test.yaml index 3f01dc2ce..c1996a85b 100644 --- a/src/server/forms/payment-test.yaml +++ b/src/server/forms/payment-test.yaml @@ -1,4 +1,5 @@ --- +schema: 2 name: Payment Test Form declaration: "

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

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

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

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

Total amount:

-

£{{ amount }}

+

£{{ amount }}

- {% if paymentState and paymentState.preAuth and paymentState.preAuth.status == 'success' %} - {# Payment pre-authorised - show confirmation #} -

- Payment ready -

-

- Reference: {{ paymentState.reference }} -

- {{ govukButton({ - text: "Use different payment details", - attributes: { - name: "action", - value: "external-" + model.name - }, - classes: "govuk-button--secondary govuk-!-margin-bottom-0" - }) }} - {% else %} - {# No payment yet - show button to initiate #} - {{ govukButton({ - text: "Add payment details", - attributes: { - name: "action", - value: "external-" + model.name - }, - classes: "govuk-!-margin-bottom-0" - }) }} - {% endif %} + {{ govukButton({ + text: "Add payment details", + attributes: { + name: "action", + value: "external-" + model.name + }, + classes: "govuk-!-margin-bottom-0" + }) }} {% endmacro %} diff --git a/src/server/plugins/engine/views/index.html b/src/server/plugins/engine/views/index.html index c5cc8d8f3..2c142e569 100644 --- a/src/server/plugins/engine/views/index.html +++ b/src/server/plugins/engine/views/index.html @@ -1,6 +1,7 @@ {% extends baseLayoutPath %} {% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} +{% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %} {% from "partials/components.html" import componentList with context %} {% block content %} @@ -10,7 +11,14 @@ {% include "partials/preview-banner.html" %} {% endif %} - {% if errors | length %} + {% if showPaymentExpiredNotification %} + {{ govukNotificationBanner({ + titleText: "Important", + html: '

Your payment has been cancelled

Your payment details were deleted because the form was inactive for 5 days.

Add your payment details again.

' + }) }} + {% endif %} + + {% if errors | length and not showPaymentExpiredNotification %} {{ govukErrorSummary({ titleText: "There is a problem", errorList: checkErrorTemplates(errors) diff --git a/src/server/plugins/engine/views/summary.html b/src/server/plugins/engine/views/summary.html index 765c52487..100c952dc 100644 --- a/src/server/plugins/engine/views/summary.html +++ b/src/server/plugins/engine/views/summary.html @@ -3,6 +3,7 @@ {% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} {% from "govuk/components/summary-list/macro.njk" import govukSummaryList %} {% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %} {% from "partials/components.html" import componentList with context %} {% from "govuk/components/input/macro.njk" import govukInput %} @@ -13,6 +14,14 @@ {% include "partials/preview-banner.html" %} {% endif %} + {% if paymentState and paymentState.preAuth and paymentState.preAuth.status == 'success' %} + {{ govukNotificationBanner({ + type: "success", + titleText: "Success", + html: "

We have your payment details

Your payment is on hold. We will charge you when you submit the form.

" + }) }} + {% endif %} + {% if errors %} {{ govukErrorSummary({ titleText: "There is a problem", @@ -41,6 +50,13 @@

{% endif %} {% endfor %} + {% if paymentDetails %} +

+ {{ paymentDetails.title.text }} +

+ {{ govukSummaryList(paymentDetails.summaryList) }} + {% endif %} +
@@ -59,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 diff --git a/src/server/plugins/payment/helper.js b/src/server/plugins/payment/helper.js new file mode 100644 index 000000000..6e719624e --- /dev/null +++ b/src/server/plugins/payment/helper.js @@ -0,0 +1,57 @@ +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 = + 'https://www.gov.uk/government/organisations/department-for-environment-food-rural-affairs' + +/** + * 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 +} + +/** + * 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) { + return format(new Date(isoString), 'd MMMM yyyy – HH:mm:ss') +} + +/** + * 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)}` +} diff --git a/src/server/plugins/payment/helper.test.js b/src/server/plugins/payment/helper.test.js new file mode 100644 index 000000000..a6375cb04 --- /dev/null +++ b/src/server/plugins/payment/helper.test.js @@ -0,0 +1,45 @@ +import { config } from '~/src/config/index.js' +import { + formatPaymentAmount, + formatPaymentDate, + 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') + }) + + 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', () => { + 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') + }) +}) diff --git a/src/server/plugins/payment/service.js b/src/server/plugins/payment/service.js index 9689fad88..e04e42a7c 100644 --- a/src/server/plugins/payment/service.js +++ b/src/server/plugins/payment/service.js @@ -1,32 +1,49 @@ -import { config } from '~/src/config/index.js' +import { StatusCodes } from 'http-status-codes' + 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() +/** + * @param {string} apiKey + * @returns {{ Authorization: string }} + */ +function getAuthHeaders(apiKey) { + return { + Authorization: `Bearer ${apiKey}` + } +} + export class PaymentService { + /** @type {string} */ + #apiKey + + /** + * @param {string} apiKey - API key to use (global config for test value, per-form config for live value) + */ + constructor(apiKey) { + this.#apiKey = apiKey + } + /** - * 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,45 +54,103 @@ 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(this.#apiKey), + 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 { + 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( + 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(this.#apiKey) + } + ) + + const statusCode = response.res.statusCode + + if ( + statusCode === StatusCodes.OK || + statusCode === StatusCodes.NO_CONTENT + ) { + 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(this.#apiKey) } ) 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 @@ -83,7 +158,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 +166,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, 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 new file mode 100644 index 000000000..f28018db6 --- /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.paymentId).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' + */ diff --git a/src/server/plugins/payment/types.js b/src/server/plugins/payment/types.js index d3bb5ebc6..328050bbb 100644 --- a/src/server/plugins/payment/types.js +++ b/src/server/plugins/payment/types.js @@ -1,46 +1,75 @@ /** - * 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} 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 + * @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} 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 */ /** - * @typedef {object} CreatePaymentMetadata - * @property {string} formId - id of the form - * @property {string} slug - slug of the form + * @typedef {object} CreatePaymentResponse + * @property {string} payment_id - Unique identifier for the created payment + * @property {PaymentResponseState} state - Current state of the payment + * @property {{ next_url: PaymentLink }} _links - HATEOAS links 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 + * 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 */ /** - * 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 + * 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 + * @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 - payment amount - * @property {string} payment_id - unique payment id + * @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 */ 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 } } 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' + */