diff --git a/package-lock.json b/package-lock.json index 14beaa8ef..35ef0d045 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.647", + "@defra/forms-model": "^3.0.648", "@defra/hapi-tracing": "^1.29.0", "@defra/interactive-map": "^0.0.17-alpha", "@elastic/ecs-pino-format": "^1.5.0", @@ -3512,9 +3512,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.647", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.647.tgz", - "integrity": "sha512-H0zlUy51ownjQE6QnhJtm7anjcnVZrktI7yMloWUuDVhK4hN4c2Obnj5iB7QbqFEAeRxAXDjn2gjBm1JzZlx5A==", + "version": "3.0.648", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.648.tgz", + "integrity": "sha512-hbQGF09vFI8Izpal2LiWIHNQbUJ4eqkQYcIcnEWDf8UpkB7qxB3LW2BQKGStjIYw4eiUz5Qus3uSG7R2skyZqA==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", diff --git a/package.json b/package.json index c8598dd90..297e81316 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.647", + "@defra/forms-model": "^3.0.648", "@defra/hapi-tracing": "^1.29.0", "@defra/interactive-map": "^0.0.17-alpha", "@elastic/ecs-pino-format": "^1.5.0", diff --git a/src/server/forms/payment-v2-test.yaml b/src/server/forms/payment-v2-test.yaml new file mode 100644 index 000000000..dcf271167 --- /dev/null +++ b/src/server/forms/payment-v2-test.yaml @@ -0,0 +1,341 @@ +--- +# Based on "Apply for a lock and weir fishing permit" production form. +# Extended with duration and site access to demonstrate complex compound conditions. +# Pricing matrix: duration x permit type x site access +schema: 2 +name: Apply for a lock and weir fishing permit (v2 payment test) +engine: V2 +declaration: I apply for permission to fish at the sites listed on this application for the duration of the permit, subject to the normal closed seasons. +startPage: '/fishing-sites' +options: + showReferenceNumber: true +pages: + - title: Fishing sites + path: '/fishing-sites' + components: + - id: '1fb84634-86f2-477b-affa-c7ace61aec26' + type: Markdown + content: "The fishing sites include:\n\n* Buscot\n* Grafton\n* Rushey\n* Sandford\n* Abingdon\n* Benson\n* Goring\n* Hurley\n* Bell Weir\n* Molesey" + options: {} + schema: {} + name: fishingSites + next: [] + - title: Contact details + path: '/contact-details' + components: + - id: '3598ed25-1b9a-4ce6-8432-5676063b96ec' + type: TextField + title: What is your full name? + name: fullName + shortDescription: Full name + options: + required: true + schema: {} + - id: '9c4f0158-8f87-4cbd-a3a1-960166f015e4' + type: TelephoneNumberField + title: What is your phone number? + name: phoneNumber + shortDescription: Phone number + options: + required: true + schema: {} + - id: '9b83dc1e-e385-4cd3-b642-dcc247f3fc89' + type: EmailAddressField + title: What is your email address? + name: emailAddress + shortDescription: Email address + options: + required: true + next: [] + - title: '' + path: '/rod-licence-number' + components: + - id: '22405838-becd-48ab-b984-c3c886822412' + type: TextField + title: What is your rod licence number (current or previous)? + name: rodLicenceNumber + shortDescription: Rod licence number + hint: The permit must be used in conjunction with a valid rod licence. + options: + required: true + schema: {} + next: [] + - title: '' + path: '/permit-duration' + components: + - id: 'a1a1a1a1-1111-4aaa-aaaa-000000000001' + type: RadiosField + title: What duration of permit do you need? + name: permitDuration + shortDescription: Permit duration + options: + required: true + list: 'b1b1b1b1-1111-4bbb-bbbb-000000000001' + next: [] + - title: '' + path: '/what-kind-of-permit-do-you-require' + components: + - id: 'f7663ac4-61e7-4b64-a157-70f002818493' + type: RadiosField + title: What kind of permit do you require? + name: permitType + shortDescription: Permit type + options: + required: true + list: 'aac4ee00-fb82-4a37-88ad-b9e10ded92e9' + next: [] + - title: '' + path: '/site-access' + condition: 'c1c1c1c1-6666-4ccc-cccc-000000000005' + components: + - id: 'a1a1a1a1-1111-4aaa-aaaa-000000000003' + type: RadiosField + title: Which site access do you need? + name: siteAccess + shortDescription: Site access + hint: Single site permits are valid for one named site only. + options: + required: true + list: 'b1b1b1b1-1111-4bbb-bbbb-000000000002' + next: [] + - title: '' + path: '/payment-required' + components: + - id: '6522e3f7-f414-42e5-9dbf-84e5868fbbd3' + type: PaymentField + title: Payment required + name: fishingPermitPayment + options: + required: true + amount: 0 + description: Lock and weir fishing permit + emailField: emailAddress + conditionalAmounts: + # === 3-way: 12-month + type + site (most specific first) === + - condition: 'd1d1d1d1-8888-4ddd-dddd-000000000001' + amount: 38 + - condition: 'd1d1d1d1-8888-4ddd-dddd-000000000002' + amount: 25 + - condition: 'd1d1d1d1-8888-4ddd-dddd-000000000003' + amount: 24 + - condition: 'd1d1d1d1-8888-4ddd-dddd-000000000004' + amount: 16 + # === 2-way: 8-day + type (site doesn't matter) === + - condition: 'd1d1d1d1-8888-4ddd-dddd-000000000005' + amount: 12 + - condition: 'd1d1d1d1-8888-4ddd-dddd-000000000006' + amount: 8 + # === Simple: 1-day flat rate === + - condition: 'c1c1c1c1-6666-4ccc-cccc-000000000003' + amount: 5 + # === Simple: Junior free === + - condition: 'c1c1c1c1-6666-4ccc-cccc-000000000004' + amount: 0 + next: [] + - title: '' + path: '/summary' + controller: SummaryPageController +conditions: + # ================================================================= + # Simple atomic conditions + # ================================================================= + - id: 'c1c1c1c1-6666-4ccc-cccc-000000000001' + displayName: Is 12-month permit + items: + - id: 'e1e1e1e1-7777-4eee-eeee-000000000001' + componentId: 'a1a1a1a1-1111-4aaa-aaaa-000000000001' + operator: is + type: ListItemRef + value: + itemId: 'f1f1f1f1-1111-4fff-ffff-000000000001' + listId: 'b1b1b1b1-1111-4bbb-bbbb-000000000001' + - id: 'c1c1c1c1-6666-4ccc-cccc-000000000002' + displayName: Is 8-day permit + items: + - id: 'e1e1e1e1-7777-4eee-eeee-000000000002' + componentId: 'a1a1a1a1-1111-4aaa-aaaa-000000000001' + operator: is + type: ListItemRef + value: + itemId: 'f1f1f1f1-1111-4fff-ffff-000000000002' + listId: 'b1b1b1b1-1111-4bbb-bbbb-000000000001' + - id: 'c1c1c1c1-6666-4ccc-cccc-000000000003' + displayName: Is 1-day permit + items: + - id: 'e1e1e1e1-7777-4eee-eeee-000000000003' + componentId: 'a1a1a1a1-1111-4aaa-aaaa-000000000001' + operator: is + type: ListItemRef + value: + itemId: 'f1f1f1f1-1111-4fff-ffff-000000000003' + listId: 'b1b1b1b1-1111-4bbb-bbbb-000000000001' + - id: 'c1c1c1c1-6666-4ccc-cccc-000000000004' + displayName: Is Junior permit type + items: + - id: 'e1e1e1e1-7777-4eee-eeee-000000000004' + componentId: 'f7663ac4-61e7-4b64-a157-70f002818493' + operator: is + type: ListItemRef + value: + itemId: 'f1f1f1f1-2222-4fff-ffff-000000000003' + listId: 'aac4ee00-fb82-4a37-88ad-b9e10ded92e9' + - id: 'dcaa3b3d-5cbc-4be9-b5ce-b2be5c72ccd1' + displayName: Is Adult permit type + items: + - id: '13b5e86b-2eb5-4a6c-8ab6-9fdc35907f18' + componentId: 'f7663ac4-61e7-4b64-a157-70f002818493' + operator: is + type: ListItemRef + value: + itemId: 'b6034236-63cf-44af-bb6c-d5d4d3825973' + listId: 'aac4ee00-fb82-4a37-88ad-b9e10ded92e9' + - id: 'c5177318-61ec-44ec-b5c2-18c1be1f1e42' + displayName: Is Concession permit type + items: + - id: 'fb75f1be-d471-4020-ac13-f7a2246078bb' + componentId: 'f7663ac4-61e7-4b64-a157-70f002818493' + operator: is + type: ListItemRef + value: + itemId: '86baf02e-0db7-4fad-8fce-fa9a30c1b7f0' + listId: 'aac4ee00-fb82-4a37-88ad-b9e10ded92e9' + - id: 'c1c1c1c1-6666-4ccc-cccc-000000000007' + displayName: Is all sites access + items: + - id: 'e1e1e1e1-7777-4eee-eeee-000000000007' + componentId: 'a1a1a1a1-1111-4aaa-aaaa-000000000003' + operator: is + type: ListItemRef + value: + itemId: 'f1f1f1f1-3333-4fff-ffff-000000000001' + listId: 'b1b1b1b1-1111-4bbb-bbbb-000000000002' + - id: 'c1c1c1c1-6666-4ccc-cccc-000000000008' + displayName: Is single site access + items: + - id: 'e1e1e1e1-7777-4eee-eeee-000000000008' + componentId: 'a1a1a1a1-1111-4aaa-aaaa-000000000003' + operator: is + type: ListItemRef + value: + itemId: 'f1f1f1f1-3333-4fff-ffff-000000000002' + listId: 'b1b1b1b1-1111-4bbb-bbbb-000000000002' + # ================================================================= + # Page visibility: site access only shown for 12-month permits + # ================================================================= + - id: 'c1c1c1c1-6666-4ccc-cccc-000000000005' + displayName: Is 12-month (show site access page) + items: + - id: 'e1e1e1e1-7777-4eee-eeee-000000000005' + conditionId: 'c1c1c1c1-6666-4ccc-cccc-000000000001' + # ================================================================= + # 2-way compound AND: duration + type + # ================================================================= + - id: 'c1c1c1c1-6666-4ccc-cccc-000000000010' + displayName: 12-month AND Adult + coordinator: and + items: + - id: 'e1e1e1e1-7777-4eee-eeee-000000000010' + conditionId: 'c1c1c1c1-6666-4ccc-cccc-000000000001' + - id: 'e1e1e1e1-7777-4eee-eeee-000000000011' + conditionId: 'dcaa3b3d-5cbc-4be9-b5ce-b2be5c72ccd1' + - id: 'c1c1c1c1-6666-4ccc-cccc-000000000011' + displayName: 12-month AND Concession + coordinator: and + items: + - id: 'e1e1e1e1-7777-4eee-eeee-000000000012' + conditionId: 'c1c1c1c1-6666-4ccc-cccc-000000000001' + - id: 'e1e1e1e1-7777-4eee-eeee-000000000013' + conditionId: 'c5177318-61ec-44ec-b5c2-18c1be1f1e42' + - id: 'd1d1d1d1-8888-4ddd-dddd-000000000005' + displayName: 8-day AND Adult + coordinator: and + items: + - id: 'e1e1e1e1-7777-4eee-eeee-000000000014' + conditionId: 'c1c1c1c1-6666-4ccc-cccc-000000000002' + - id: 'e1e1e1e1-7777-4eee-eeee-000000000015' + conditionId: 'dcaa3b3d-5cbc-4be9-b5ce-b2be5c72ccd1' + - id: 'd1d1d1d1-8888-4ddd-dddd-000000000006' + displayName: 8-day AND Concession + coordinator: and + items: + - id: 'e1e1e1e1-7777-4eee-eeee-000000000016' + conditionId: 'c1c1c1c1-6666-4ccc-cccc-000000000002' + - id: 'e1e1e1e1-7777-4eee-eeee-000000000017' + conditionId: 'c5177318-61ec-44ec-b5c2-18c1be1f1e42' + # ================================================================= + # 3-way compound AND: duration + type + site (chains 2-way refs) + # Same pattern as "Bats chargeable use" in protected species form + # ================================================================= + - id: 'd1d1d1d1-8888-4ddd-dddd-000000000001' + displayName: 12-month AND Adult AND All sites + coordinator: and + items: + - id: 'e1e1e1e1-7777-4eee-eeee-000000000020' + conditionId: 'c1c1c1c1-6666-4ccc-cccc-000000000010' + - id: 'e1e1e1e1-7777-4eee-eeee-000000000021' + conditionId: 'c1c1c1c1-6666-4ccc-cccc-000000000007' + - id: 'd1d1d1d1-8888-4ddd-dddd-000000000002' + displayName: 12-month AND Adult AND Single site + coordinator: and + items: + - id: 'e1e1e1e1-7777-4eee-eeee-000000000022' + conditionId: 'c1c1c1c1-6666-4ccc-cccc-000000000010' + - id: 'e1e1e1e1-7777-4eee-eeee-000000000023' + conditionId: 'c1c1c1c1-6666-4ccc-cccc-000000000008' + - id: 'd1d1d1d1-8888-4ddd-dddd-000000000003' + displayName: 12-month AND Concession AND All sites + coordinator: and + items: + - id: 'e1e1e1e1-7777-4eee-eeee-000000000024' + conditionId: 'c1c1c1c1-6666-4ccc-cccc-000000000011' + - id: 'e1e1e1e1-7777-4eee-eeee-000000000025' + conditionId: 'c1c1c1c1-6666-4ccc-cccc-000000000007' + - id: 'd1d1d1d1-8888-4ddd-dddd-000000000004' + displayName: 12-month AND Concession AND Single site + coordinator: and + items: + - id: 'e1e1e1e1-7777-4eee-eeee-000000000026' + conditionId: 'c1c1c1c1-6666-4ccc-cccc-000000000011' + - id: 'e1e1e1e1-7777-4eee-eeee-000000000027' + conditionId: 'c1c1c1c1-6666-4ccc-cccc-000000000008' +sections: [] +lists: + - id: 'b1b1b1b1-1111-4bbb-bbbb-000000000001' + title: Permit durations + name: permitDurations + type: string + items: + - id: 'f1f1f1f1-1111-4fff-ffff-000000000001' + text: 12-month permit + value: 12-month + - id: 'f1f1f1f1-1111-4fff-ffff-000000000002' + text: 8-day permit + value: 8-day + - id: 'f1f1f1f1-1111-4fff-ffff-000000000003' + text: 1-day permit + value: 1-day + - id: 'aac4ee00-fb82-4a37-88ad-b9e10ded92e9' + title: Permit types + name: permitTypes + type: string + items: + - id: 'b6034236-63cf-44af-bb6c-d5d4d3825973' + text: Adult + value: Adult + - id: '86baf02e-0db7-4fad-8fce-fa9a30c1b7f0' + text: Concession (65+ or disabled) + value: Concession + - id: 'f1f1f1f1-2222-4fff-ffff-000000000003' + text: Junior (13-16 years) + value: Junior + - id: 'b1b1b1b1-1111-4bbb-bbbb-000000000002' + title: Site access + name: siteAccess + type: string + items: + - id: 'f1f1f1f1-3333-4fff-ffff-000000000001' + text: All sites + value: all-sites + - id: 'f1f1f1f1-3333-4fff-ffff-000000000002' + text: Single site + value: single-site diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts index 93df6cb3a..533c42f4a 100644 --- a/src/server/plugins/engine/components/PaymentField.test.ts +++ b/src/server/plugins/engine/components/PaymentField.test.ts @@ -15,6 +15,7 @@ import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { PaymentPreAuthError } from '~/src/server/plugins/engine/pageControllers/errors.js' import { type FormContext, + type FormState, type FormValue, type PaymentExternalArgs } from '~/src/server/plugins/engine/types.js' @@ -742,4 +743,381 @@ describe('PaymentField', () => { }) }) }) + + describe('resolveAmount', () => { + const baseOptions = { + amount: 50, + description: 'Test payment' + } satisfies PaymentFieldComponent['options'] + + const mockState = {} as FormState + + function createMockModel( + conditionResults: Record + ): FormModel { + const conditions: Record boolean }> = {} + for (const [key, value] of Object.entries(conditionResults)) { + conditions[key] = { fn: () => value } + } + return { conditions } as unknown as FormModel + } + + it('should return default amount when no conditionalAmounts', () => { + const mockModel = createMockModel({}) + const result = PaymentField.resolveAmount( + baseOptions, + mockModel, + mockState + ) + expect(result).toBe(50) + }) + + it('should return default amount when conditionalAmounts is empty', () => { + const mockModel = createMockModel({}) + const options = { ...baseOptions, conditionalAmounts: [] } + const result = PaymentField.resolveAmount(options, mockModel, mockState) + expect(result).toBe(50) + }) + + it('should return first matching condition amount', () => { + const mockModel = createMockModel({ 'cond-a': true, 'cond-b': false }) + const options = { + ...baseOptions, + conditionalAmounts: [ + { condition: 'cond-a', amount: 100 }, + { condition: 'cond-b', amount: 200 } + ] + } + const result = PaymentField.resolveAmount(options, mockModel, mockState) + expect(result).toBe(100) + }) + + it('should return second condition when first is false', () => { + const mockModel = createMockModel({ 'cond-a': true, 'cond-b': false }) + const options = { + ...baseOptions, + conditionalAmounts: [ + { condition: 'cond-b', amount: 200 }, + { condition: 'cond-a', amount: 100 } + ] + } + const result = PaymentField.resolveAmount(options, mockModel, mockState) + expect(result).toBe(100) + }) + + it('should return default when no conditions match', () => { + const mockModel = createMockModel({ 'cond-b': false }) + const options = { + ...baseOptions, + conditionalAmounts: [{ condition: 'cond-b', amount: 200 }] + } + const result = PaymentField.resolveAmount(options, mockModel, mockState) + expect(result).toBe(50) + }) + + it('should skip missing condition IDs gracefully', () => { + const mockModel = createMockModel({ 'cond-a': true }) + const options = { + ...baseOptions, + conditionalAmounts: [ + { condition: 'nonexistent', amount: 999 }, + { condition: 'cond-a', amount: 100 } + ] + } + const result = PaymentField.resolveAmount(options, mockModel, mockState) + expect(result).toBe(100) + }) + + it('should return 0 when condition resolves to zero amount', () => { + const mockModel = createMockModel({ 'cond-zero': true }) + const options = { + ...baseOptions, + conditionalAmounts: [{ condition: 'cond-zero', amount: 0 }] + } + const result = PaymentField.resolveAmount(options, mockModel, mockState) + expect(result).toBe(0) + }) + }) + + describe('dispatcher with conditional amounts', () => { + const def = { + title: 'Conditional payment', + name: 'myPayment', + type: ComponentType.PaymentField, + options: { + amount: 50, + description: 'Test payment', + conditionalAmounts: [{ condition: 'cond-zero', amount: 0 }] + } + } satisfies PaymentFieldComponent + + it('should redirect to summary when resolved amount is 0', async () => { + const mockRedirectCode = jest.fn().mockReturnValueOnce('redirected') + const mockH = { + redirect: jest.fn().mockReturnValueOnce({ code: mockRedirectCode }) + } as unknown as FormResponseToolkit + const mockRequest = { + server: { + plugins: { + 'forms-engine-plugin': { baseUrl: 'base-url' } + } + }, + yar: { set: jest.fn() } + } as unknown as FormRequestPayload + const args = { + controller: { + model: { + formId: 'formid', + basePath: 'base-path', + services: mockServices, + conditions: { + 'cond-zero': { fn: () => true } + } + }, + getState: jest.fn().mockResolvedValueOnce({ + $$__referenceNumber: 'ref-123' + }) + }, + component: def, + sourceUrl: 'http://localhost:3009/test', + isLive: false, + isPreview: true + } as unknown as PaymentExternalArgs + + const res = await PaymentField.dispatcher(mockRequest, mockH, args) + expect(res).toBe('redirected') + expect(mockH.redirect).toHaveBeenCalledWith('base-url/base-path/summary') + expect(postJson).not.toHaveBeenCalled() + }) + + it('should use resolved amount when creating payment', async () => { + const mockYarSet = jest.fn() + const mockRequest = { + server: { + plugins: { + '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 condDef = { + ...def, + options: { + ...def.options, + conditionalAmounts: [{ condition: 'cond-100', amount: 100 }] + } + } + + const args = { + controller: { + model: { + formId: 'formid', + basePath: 'base-path', + services: mockServices, + conditions: { + 'cond-100': { fn: () => true } + } + }, + getState: jest.fn().mockResolvedValueOnce({ + $$__referenceNumber: 'ref-123' + }) + }, + component: condDef, + sourceUrl: 'http://localhost:3009/test', + isLive: false, + isPreview: true + } as unknown as PaymentExternalArgs + + // @ts-expect-error - partial mock + jest.mocked(postJson).mockResolvedValueOnce({ + payload: { + state: { status: 'created' }, + payment_id: 'pay-id', + _links: { next_url: { href: '/next' } } + } + }) + + await PaymentField.dispatcher(mockRequest, mockH, args) + expect(mockYarSet).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ amount: 100 }) + ) + }) + }) + + describe('dispatcher with email prepopulation', () => { + it('should pass valid email to createPayment', async () => { + const emailDef = { + title: 'Payment with email', + name: 'myPayment', + type: ComponentType.PaymentField, + options: { + amount: 50, + description: 'Test payment', + emailField: 'userEmail' + } + } satisfies PaymentFieldComponent + + const mockYarSet = jest.fn() + const mockRequest = { + server: { + plugins: { + '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: 'formid', + basePath: 'base-path', + services: mockServices, + conditions: {} + }, + getState: jest.fn().mockResolvedValueOnce({ + $$__referenceNumber: 'ref-123', + userEmail: 'test@example.com' + }) + }, + component: emailDef, + sourceUrl: 'http://localhost:3009/test', + isLive: false, + isPreview: true + } as unknown as PaymentExternalArgs + + // @ts-expect-error - partial mock + jest.mocked(postJson).mockResolvedValueOnce({ + payload: { + state: { status: 'created' }, + payment_id: 'pay-id', + _links: { next_url: { href: '/next' } } + } + }) + + await PaymentField.dispatcher(mockRequest, mockH, args) + + // Verify createPayment was called with the email as the 7th argument + expect(postJson).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + payload: expect.objectContaining({ + email: 'test@example.com' + }) + }) + ) + }) + + it('should not pass email when emailField value is empty', async () => { + const emailDef = { + title: 'Payment with empty email', + name: 'myPayment', + type: ComponentType.PaymentField, + options: { + amount: 50, + description: 'Test payment', + emailField: 'userEmail' + } + } satisfies PaymentFieldComponent + + const mockRequest = { + server: { + plugins: { + 'forms-engine-plugin': { baseUrl: 'base-url' } + } + }, + yar: { set: jest.fn() } + } as unknown as FormRequestPayload + const mockH = { + redirect: jest + .fn() + .mockReturnValueOnce({ code: jest.fn().mockReturnValueOnce('ok') }) + } as unknown as FormResponseToolkit + const args = { + controller: { + model: { + formId: 'formid', + basePath: 'base-path', + services: mockServices, + conditions: {} + }, + getState: jest.fn().mockResolvedValueOnce({ + $$__referenceNumber: 'ref-123', + userEmail: '' + }) + }, + component: emailDef, + sourceUrl: 'http://localhost:3009/test', + isLive: false, + isPreview: true + } as unknown as PaymentExternalArgs + + // @ts-expect-error - partial mock + jest.mocked(postJson).mockResolvedValueOnce({ + payload: { + state: { status: 'created' }, + payment_id: 'pay-id', + _links: { next_url: { href: '/next' } } + } + }) + + await PaymentField.dispatcher(mockRequest, mockH, args) + + // Verify createPayment was called WITHOUT an email field + expect(postJson).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + payload: expect.not.objectContaining({ + email: expect.anything() + }) + }) + ) + }) + }) + + describe('onSubmit with conditional amounts', () => { + const def = { + title: 'Payment', + name: 'myComponent', + type: ComponentType.PaymentField, + options: { + amount: 0, + description: 'Test payment', + conditionalAmounts: [] + } + } satisfies PaymentFieldComponent + + const collection = new ComponentCollection([def], { model }) + const paymentField = collection.fields[0] as PaymentField + paymentField.model = { + services: mockServices, + conditions: {} + } as unknown as FormModel + + it('should return early when resolved amount is 0', async () => { + const mockRequest = {} as unknown as FormRequestPayload + + await paymentField.onSubmit( + mockRequest, + {} as FormMetadata, + { + state: {} + } as unknown as FormContext + ) + + expect(get).not.toHaveBeenCalled() + expect(post).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index f1c20bdbb..f2e7d76cd 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -7,6 +7,7 @@ import { import { StatusCodes } from 'http-status-codes' import joi, { type ObjectSchema } from 'joi' +import { createLogger } from '~/src/server/common/helpers/logging/logger.js' import { COMPONENT_STATE_ERROR } from '~/src/server/constants.js' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { type PaymentState } from '~/src/server/plugins/engine/components/PaymentField.types.js' @@ -14,6 +15,7 @@ import { createError, getPluginOptions } from '~/src/server/plugins/engine/helpers.js' +import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { PaymentErrorTypes, PaymentPreAuthError, @@ -38,6 +40,8 @@ import { formatCurrency } from '~/src/server/plugins/payment/helper.js' +const logger = createLogger() + export class PaymentField extends FormComponent { declare options: PaymentFieldComponent['options'] declare formSchema: ObjectSchema @@ -109,7 +113,9 @@ export class PaymentField extends FormComponent { ? (payload[this.name] as unknown as PaymentState) : undefined - // When user initially visits the payment page, there is no payment state yet so the amount is read form the form definition. + // Use payment state amount if pre-authorized, otherwise use default. + // The page controller overrides this with the resolved conditional amount + // using the full form state (which getViewModel doesn't have access to). const amount = paymentState?.amount ?? this.options.amount return { @@ -184,6 +190,37 @@ export class PaymentField extends FormComponent { } } + /** + * Resolves the payment amount from conditional amounts configuration. + * Evaluates conditions in order; first true condition wins. + * Falls back to the default options.amount. + */ + static resolveAmount( + options: PaymentFieldComponent['options'], + model: FormModel, + state: FormState + ): number { + const { conditionalAmounts } = options + + if (!conditionalAmounts?.length) { + return options.amount + } + + for (const { condition, amount } of conditionalAmounts) { + if (!model.conditions[condition]) { + logger.warn( + `[payment] Condition '${condition}' not found in form conditions. Skipping.` + ) + continue + } + if (model.conditions[condition].fn(state)) { + return amount + } + } + + return options.amount + } + /** * Dispatcher for external redirect to GOV.UK Pay */ @@ -219,7 +256,12 @@ export class PaymentField extends FormComponent { const uuid = randomUUID() const reference = state.$$__referenceNumber as string - const amount = options.amount + const resolvedAmount = PaymentField.resolveAmount(options, model, state) + + // Zero-amount safety net (page skip should prevent this, but defensive) + if (resolvedAmount === 0) { + return h.redirect(summaryUrl).code(StatusCodes.SEE_OTHER) + } const description = options.description @@ -228,14 +270,26 @@ export class PaymentField extends FormComponent { const payCallbackUrl = `${baseUrl}/payment-callback?uuid=${uuid}` const paymentPageUrl = args.sourceUrl - const amountInPence = Math.round(amount * 100) + // Prepopulate GOV.UK Pay email if emailField is configured. + // The referenced EmailAddressField validates with joi.string().email() + // at input time, so the value in state is already validated. + let prefilledEmail: string | undefined + if (options.emailField) { + const emailValue = state[options.emailField] + if (typeof emailValue === 'string' && emailValue) { + prefilledEmail = emailValue + } + } + + const amountInPence = Math.round(resolvedAmount * 100) const payment = await paymentService.createPayment( amountInPence, description, payCallbackUrl, reference, isLivePayment, - { formId, slug } + { formId, slug }, + prefilledEmail ) if (!payment) { @@ -253,7 +307,7 @@ export class PaymentField extends FormComponent { uuid, formId, reference, - amount, + amount: resolvedAmount, description, paymentId: payment.paymentId, componentName, @@ -276,6 +330,16 @@ export class PaymentField extends FormComponent { _metadata: FormMetadata, context: FormContext ): Promise { + // Zero-amount bypass — no capture needed + const resolvedAmount = PaymentField.resolveAmount( + this.options, + this.model, + context.state + ) + if (resolvedAmount === 0) { + return + } + const paymentState = this.getPaymentStateFromState(context.state) if (!paymentState) { @@ -309,7 +373,7 @@ export class PaymentField extends FormComponent { PaymentSubmissionError.checkPaymentAmount( status.amount, - this.options.amount, + resolvedAmount, this ) diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index 52ec20f4e..4faa1370a 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -56,6 +56,8 @@ export class SummaryViewModel { allowSaveAndExit = false paymentState?: PaymentState paymentDetails?: CheckAnswers + paymentRequired?: boolean + paymentPreAuthorized?: boolean constructor( request: FormContextRequest, diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts index f42fe847d..6dd4dcf22 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts @@ -28,6 +28,7 @@ import definitionConditionsBasic, { } from '~/test/form/definitions/conditions-basic.js' import definitionConditionsComplex from '~/test/form/definitions/conditions-complex.js' import definitionConditionsDates from '~/test/form/definitions/conditions-dates.js' +import definitionPaymentV2Conditional from '~/test/form/definitions/payment-v2-conditional.js' describe('QuestionPageController', () => { let page1: PageQuestion @@ -1684,3 +1685,223 @@ describe('Save and Exit functionality', () => { }) }) }) + +describe('QuestionPageController V2 - PaymentField (DF-832)', () => { + let model: FormModel + let choicePage: QuestionPageController + let paymentPage: QuestionPageController + + beforeEach(() => { + model = new FormModel(definitionPaymentV2Conditional, { + basePath: 'test' + }) + + // Page order in fixture: choice, gated, payment, summary + choicePage = /** @type {QuestionPageController} */ model.pages[0] + paymentPage = /** @type {QuestionPageController} */ model.pages[2] + }) + + describe('getNextPath', () => { + const buildRequest = (pathname: string) => { + const url = new URL(`http://example.com/test${pathname}`) + return buildFormRequest({ + method: 'get', + url, + path: url.pathname, + params: { path: pathname.replace('/', ''), slug: 'test' }, + query: {}, + app: { model } + } as FormRequest) + } + + it('skips payment pages in forward navigation (users reach via CYA)', () => { + // From the "choice" page with "yes" answered, the gated page's + // condition evaluates true. Payment comes after gated, but must + // be skipped so the walk lands on summary. + const request = buildRequest('/choice') + const context = model.getFormContext(request, { + $$__referenceNumber: 'foobar', + yesNoField: true + }) + + const next = choicePage.getNextPath(context) + + expect(next).toBe('/gated') + + // From the gated page the next real page would be the payment page, + // but forward navigation skips it and lands on summary. + const gatedPage = /** @type {QuestionPageController} */ model.pages[1] + expect(gatedPage.getNextPath(context)).toBe('/summary') + }) + + it('skips payment pages even when their condition would pass', () => { + // The payment page has no page-level condition, so only the + // isPaymentPage skip can cause it to be excluded. + const request = buildRequest('/choice') + const context = model.getFormContext(request, { + $$__referenceNumber: 'foobar', + yesNoField: false + }) + + // With "no", the gated page's condition fails so the walk from + // /choice should skip both /gated (condition) and /payment (payment) + // and land directly on the summary. + expect(choicePage.getNextPath(context)).toBe('/summary') + }) + }) + + describe('getViewModel - resolved payment amount', () => { + const buildPaymentRequest = () => { + const url = new URL('http://example.com/test/payment') + return buildFormRequest({ + method: 'get', + url, + path: url.pathname, + params: { path: 'payment', slug: 'test' }, + query: {}, + app: { model } + } as FormRequest) + } + + it('overrides displayed amount using resolveAmount with full form state', () => { + // yesNoField = false selects the £99 conditional amount. + // The payload for the payment page on its own would not know that + // — the controller must reach into context.evaluationState. + const request = buildPaymentRequest() + const context = model.getFormContext(request, { + $$__referenceNumber: 'foobar', + yesNoField: false + }) + + const viewModel = paymentPage.getViewModel(request, context) + const paymentComp = viewModel.components.find( + (c) => 'amount' in c.model && 'paymentState' in c.model + ) + + expect(paymentComp).toBeDefined() + expect(paymentComp?.model).toHaveProperty('amount', '£99.00') + }) + + it('overrides displayed amount to £0 when zero-amount condition matches', () => { + // yesNoField = true selects the £0 conditional amount (first in list) + const request = buildPaymentRequest() + const context = model.getFormContext(request, { + $$__referenceNumber: 'foobar', + yesNoField: true + }) + + const viewModel = paymentPage.getViewModel(request, context) + const paymentComp = viewModel.components.find( + (c) => 'amount' in c.model && 'paymentState' in c.model + ) + + expect(paymentComp?.model).toHaveProperty('amount', '£0.00') + }) + + it('falls back to base amount when no condition matches', () => { + // Empty state → no conditions match → base £50 + const request = buildPaymentRequest() + const context = model.getFormContext(request, { + $$__referenceNumber: 'foobar' + }) + + const viewModel = paymentPage.getViewModel(request, context) + const paymentComp = viewModel.components.find( + (c) => 'amount' in c.model && 'paymentState' in c.model + ) + + expect(paymentComp?.model).toHaveProperty('amount', '£50.00') + }) + + it('suppresses Save & Exit on payment pages', () => { + const request = buildPaymentRequest() + const context = model.getFormContext(request, { + $$__referenceNumber: 'foobar', + yesNoField: false + }) + + const viewModel = paymentPage.getViewModel(request, context) + + expect(viewModel).toHaveProperty('allowSaveAndExit', false) + }) + + it('allows Save & Exit on non-payment pages', () => { + const url = new URL('http://example.com/test/choice') + const request = buildFormRequest({ + method: 'get', + url, + path: url.pathname, + params: { path: 'choice', slug: 'test' }, + query: {}, + app: { model } + } as FormRequest) + + const context = model.getFormContext(request, { + $$__referenceNumber: 'foobar' + }) + + // The default FormModel server may not have save-and-exit plugin + // loaded, so we check via shouldShowSaveAndExit being the driver + // rather than isPaymentPage for this page. + jest.spyOn(choicePage, 'shouldShowSaveAndExit').mockReturnValue(true) + + const viewModel = choicePage.getViewModel(request, context) + + expect(viewModel).toHaveProperty('allowSaveAndExit', true) + }) + }) + + describe('getViewModel - showSubmitButton based on paymentState', () => { + const buildPaymentRequest = () => { + const url = new URL('http://example.com/test/payment') + return buildFormRequest({ + method: 'get', + url, + path: url.pathname, + params: { path: 'payment', slug: 'test' }, + query: {}, + app: { model } + } as FormRequest) + } + + it('shows submit button when no payment pre-auth captured yet', () => { + // No paymentState on form state → hasIncompletePayment is true + // → showSubmitButton is false. + const request = buildPaymentRequest() + const context = model.getFormContext(request, { + $$__referenceNumber: 'foobar', + yesNoField: false + }) + + const viewModel = paymentPage.getViewModel(request, context) + + expect(viewModel).toHaveProperty('showSubmitButton', false) + }) + + it('shows submit button when pre-auth has a captured status', () => { + // The PaymentField persists paymentId/amount/description at the top + // of its state slot, with preAuth nested under it. preAuth.status + // being present means pre-auth was captured → hasIncompletePayment + // is false → showSubmitButton is true. + const request = buildPaymentRequest() + const context = model.getFormContext(request, { + $$__referenceNumber: 'foobar', + yesNoField: false, + paymentField: { + paymentId: 'p-1', + amount: 99, + description: 'Test payment', + preAuth: { + status: 'success', + paymentId: 'p-1', + amount: 99 + } + } + } as unknown as FormSubmissionState) + + const viewModel = paymentPage.getViewModel(request, context) + + expect(viewModel).toHaveProperty('showSubmitButton', true) + }) + }) +}) diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index db6a5f33f..1b2398001 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -5,6 +5,7 @@ import { hasComponents, hasNext, hasRepeater, + isPaymentPage, type Link, type Page } from '@defra/forms-model' @@ -19,6 +20,7 @@ import { PAYMENT_EXPIRED_NOTIFICATION } from '~/src/server/constants.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { PaymentField } from '~/src/server/plugins/engine/components/PaymentField.js' import { optionalText } from '~/src/server/plugins/engine/components/constants.js' import { type BackLink } from '~/src/server/plugins/engine/components/types.js' import { @@ -44,6 +46,7 @@ import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js' import { getComponentsByType } from '~/src/server/plugins/engine/validationHelpers.js' +import { formatCurrency } from '~/src/server/plugins/payment/helper.js' import { FormAction, FormStatus, @@ -182,6 +185,25 @@ export class QuestionPageController extends PageController { } } + // Override payment amount display with resolved conditional amount + // getViewModel() only has the current page's payload, not full form state. + // The full state is available via context.evaluationState. + for (const comp of components) { + if ('amount' in comp.model && 'paymentState' in comp.model) { + const paymentField = this.collection.fields.find( + (f): f is PaymentField => f instanceof PaymentField + ) + if (paymentField) { + const resolvedAmount = PaymentField.resolveAmount( + paymentField.options, + this.model, + context.evaluationState + ) + comp.model.amount = formatCurrency(resolvedAmount) + } + } + } + const hasIncompletePayment = components.some(({ model }) => { if ('paymentState' in model) { const paymentState = model.paymentState as @@ -199,7 +221,9 @@ export class QuestionPageController extends PageController { showTitle, components, errors, - allowSaveAndExit: this.shouldShowSaveAndExit(request.server), + allowSaveAndExit: + this.shouldShowSaveAndExit(request.server) && + !isPaymentPage(this.pageDef), showSubmitButton: !hasIncompletePayment } } @@ -246,6 +270,13 @@ export class QuestionPageController extends PageController { } } + // Skip payment pages in the normal page walk. + // Users reach the payment page via "Pay and submit" on CYA, + // not by navigating forward through the form. + if (isPaymentPage(page.pageDef)) { + return false + } + return true }) diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts index d9f05baea..4c704d111 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts @@ -1,5 +1,8 @@ import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' -import { SummaryPageController } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js' +import { + SummaryPageController, + submitForm +} from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js' import { buildFormRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js' import { @@ -9,6 +12,7 @@ import { } from '~/src/server/routes/types.js' import { type CacheService } from '~/src/server/services/cacheService.js' import definition from '~/test/form/definitions/basic.js' +import definitionPaymentV2Conditional from '~/test/form/definitions/payment-v2-conditional.js' describe('SummaryPageController', () => { let model: FormModel @@ -87,3 +91,342 @@ describe('SummaryPageController', () => { // Note: InvalidComponentStateError handling is comprehensively tested // in the integration test: test/form/component-state-errors.test.js }) + +describe('SummaryPageController - Payment (DF-832)', () => { + let model: FormModel + let controller: SummaryPageController + let requestPage: FormRequest + + const response = { + code: jest.fn().mockImplementation(() => response) + } + const h: FormResponseToolkit = { + redirect: jest.fn().mockReturnValue(response), + view: jest.fn(), + continue: Symbol('continue') + } + + beforeEach(() => { + model = new FormModel(definitionPaymentV2Conditional, { + basePath: 'test' + }) + + controller = model.pages.find( + (p) => p.path === '/summary' + ) as SummaryPageController + + requestPage = buildFormRequest({ + method: 'get', + url: new URL('http://example.com/test/summary'), + path: '/test/summary', + params: { + path: 'summary', + slug: 'test' + }, + query: {}, + app: { model } + } as FormRequest) + }) + + describe('GET handler: paymentComplete=true', () => { + it('auto-submits without going through reconcile/view when paymentComplete=true', async () => { + const state: FormSubmissionState = { + $$__referenceNumber: 'foobar', + yesNoField: false + } + const request = { + ...requestPage, + query: { paymentComplete: 'true' } + } as unknown as FormRequest + + const context = model.getFormContext(request, state) + + const handleFormSubmitSpy = jest + .spyOn(controller, 'handleFormSubmit') + .mockResolvedValue( + undefined as unknown as ReturnType + ) + + const getHandler = controller.makeGetRouteHandler() + await getHandler(request, context, h) + + expect(handleFormSubmitSpy).toHaveBeenCalledTimes(1) + // The GET handler should short-circuit before rendering the view + expect(h.view).not.toHaveBeenCalled() + }) + + it('follows the normal CYA render path when paymentComplete is absent', async () => { + const cacheService = { + resetComponentStates: jest.fn() + } as unknown as CacheService + + const state: FormSubmissionState = { + $$__referenceNumber: 'foobar', + yesNoField: false + } + const request = { + ...requestPage, + server: { + plugins: { + 'forms-engine-plugin': { cacheService } + } + } + } as unknown as FormRequest + + const context = model.getFormContext(request, state) + + jest + .spyOn(controller, 'handleFormSubmit') + .mockResolvedValue( + undefined as unknown as ReturnType + ) + // Avoid hitting hasMissingNotificationEmail's real implementation + jest + .spyOn( + controller as unknown as { + hasMissingNotificationEmail: () => Promise + }, + 'hasMissingNotificationEmail' + ) + .mockResolvedValue(false) + + const getHandler = controller.makeGetRouteHandler() + await getHandler(request, context, h) + + expect(controller.handleFormSubmit).not.toHaveBeenCalled() + expect(h.view).toHaveBeenCalledTimes(1) + }) + }) + + describe('reconcilePaymentState', () => { + it('resets payment component state when resolved amount has changed since pre-auth', async () => { + const cacheService = { + resetComponentStates: jest.fn().mockResolvedValue(undefined) + } as unknown as CacheService + + // yesNoField=false resolves to £99 (see fixture), but the stored + // pre-auth is at £50 (the base amount). This mismatch should + // trigger a reset of paymentField's stored state. + const state: FormSubmissionState = { + $$__referenceNumber: 'foobar', + yesNoField: false, + paymentField: { + paymentId: 'stale-id', + amount: 50, + description: 'Test payment', + preAuth: { status: 'success' } + } + } as unknown as FormSubmissionState + + const request = { + ...requestPage, + server: { + plugins: { + 'forms-engine-plugin': { cacheService } + } + } + } as unknown as FormRequest + + const context = model.getFormContext(request, state) + + jest + .spyOn( + controller as unknown as { + hasMissingNotificationEmail: () => Promise + }, + 'hasMissingNotificationEmail' + ) + .mockResolvedValue(false) + + const getHandler = controller.makeGetRouteHandler() + await getHandler(request, context, h) + + expect(cacheService.resetComponentStates).toHaveBeenCalledWith(request, [ + 'paymentField' + ]) + }) + + it('does not reset payment state when stored amount matches resolved amount', async () => { + const cacheService = { + resetComponentStates: jest.fn() + } as unknown as CacheService + + // yesNoField=false resolves to £99; stored pre-auth also at £99. + const state: FormSubmissionState = { + $$__referenceNumber: 'foobar', + yesNoField: false, + paymentField: { + paymentId: 'fresh-id', + amount: 99, + description: 'Test payment', + preAuth: { status: 'success' } + } + } as unknown as FormSubmissionState + + const request = { + ...requestPage, + server: { + plugins: { + 'forms-engine-plugin': { cacheService } + } + } + } as unknown as FormRequest + + const context = model.getFormContext(request, state) + + jest + .spyOn( + controller as unknown as { + hasMissingNotificationEmail: () => Promise + }, + 'hasMissingNotificationEmail' + ) + .mockResolvedValue(false) + + const getHandler = controller.makeGetRouteHandler() + await getHandler(request, context, h) + + expect(cacheService.resetComponentStates).not.toHaveBeenCalled() + }) + + it('does nothing when no paymentState is stored yet', async () => { + const cacheService = { + resetComponentStates: jest.fn() + } as unknown as CacheService + + const state: FormSubmissionState = { + $$__referenceNumber: 'foobar', + yesNoField: false + } + + const request = { + ...requestPage, + server: { + plugins: { + 'forms-engine-plugin': { cacheService } + } + } + } as unknown as FormRequest + + const context = model.getFormContext(request, state) + + jest + .spyOn( + controller as unknown as { + hasMissingNotificationEmail: () => Promise + }, + 'hasMissingNotificationEmail' + ) + .mockResolvedValue(false) + + const getHandler = controller.makeGetRouteHandler() + await getHandler(request, context, h) + + expect(cacheService.resetComponentStates).not.toHaveBeenCalled() + }) + }) + + describe('submitForm - hasPaymentBeenCaptured', () => { + /** + * Builds the set of stubs submitForm needs. For `captured: true`, we + * set paymentState.capture.status=success so PaymentField.onSubmit + * short-circuits and finaliseComponents passes. For `captured: false`, + * we pick the zero-amount branch (yesNoField=true) so onSubmit + * bypasses payment capture entirely — that way finaliseComponents + * still succeeds but no captured payment exists when submitForm + * evaluates hasPaymentBeenCaptured. + */ + function buildSubmitHarness({ captured }: { captured: boolean }) { + const state: FormSubmissionState = captured + ? ({ + $$__referenceNumber: 'foobar', + yesNoField: false, + paymentField: { + paymentId: 'p-1', + amount: 99, + description: 'Test payment', + reference: 'ref-1', + preAuth: { status: 'success' }, + capture: { status: 'success' } + } + } as unknown as FormSubmissionState) + : ({ + $$__referenceNumber: 'foobar', + yesNoField: true + } as FormSubmissionState) + + const outputSubmit = jest.fn() + + model.services = { + ...model.services, + formSubmissionService: { + ...model.services.formSubmissionService, + submit: jest.fn().mockResolvedValue({ data: { reference: 'r' } }) + }, + outputService: { + ...model.services.outputService, + submit: outputSubmit + } + } as typeof model.services + + const request = { + ...requestPage, + params: { ...requestPage.params, path: 'summary' }, + yar: { id: 'session-id' }, + logger: { info: jest.fn(), error: jest.fn() } + } as unknown as FormRequestPayload + + const context = model.getFormContext(request, state) + + const viewModel = { + context, + details: [] + } as unknown as Parameters[3] + + const formMetadata = { + contact: { online: { url: '/help' } } + } as unknown as Parameters[1] + + return { request, context, viewModel, formMetadata, outputSubmit } + } + + it('re-throws as PaymentSubmissionError when outputService fails and a payment has been captured', async () => { + const { request, context, viewModel, formMetadata, outputSubmit } = + buildSubmitHarness({ captured: true }) + + outputSubmit.mockRejectedValue(new Error('downstream boom')) + + await expect( + submitForm( + context, + formMetadata, + request, + viewModel, + model, + 'notify@example.com' + ) + ).rejects.toMatchObject({ + name: 'PaymentSubmissionError' + }) + }) + + it('re-throws the original error when no payment has been captured', async () => { + const { request, context, viewModel, formMetadata, outputSubmit } = + buildSubmitHarness({ captured: false }) + + const err = new Error('downstream boom') + outputSubmit.mockRejectedValue(err) + + await expect( + submitForm( + context, + formMetadata, + request, + viewModel, + model, + 'notify@example.com' + ) + ).rejects.toBe(err) + }) + }) +}) diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 22fdcd25b..2863e51ba 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -6,6 +6,7 @@ import { } from '@defra/forms-model' import Boom from '@hapi/boom' import { type RouteOptions } from '@hapi/hapi' +import { StatusCodes } from 'http-status-codes' import { COMPONENT_STATE_ERROR, @@ -65,6 +66,16 @@ export class SummaryPageController extends QuestionPageController { * The controller which is used when Page["controller"] is defined as "./pages/summary.js" */ + /** + * Finds the PaymentField component across all pages in the model. + * Payment pages are skipped in the normal page walk + */ + private findPaymentField(): PaymentField | undefined { + return this.model.pages + .flatMap((page) => page.collection.fields) + .find((field): field is PaymentField => field instanceof PaymentField) + } + constructor(model: FormModel, pageDef: Page) { super(model, pageDef) this.viewName = 'summary' @@ -85,19 +96,34 @@ export class SummaryPageController extends QuestionPageController { const { query } = request const { payload, errors, state } = context - const paymentField = context.relevantPages - .flatMap((page) => page.collection.fields) - .find((field): field is PaymentField => field instanceof PaymentField) + const paymentField = this.findPaymentField() if (paymentField) { + const resolvedAmount = PaymentField.resolveAmount( + paymentField.options, + this.model, + state + ) const paymentState = paymentField.getPaymentStateFromState(state) - if (paymentState) { + + if (paymentState?.amount === resolvedAmount) { viewModel.paymentState = paymentState viewModel.paymentDetails = this.buildPaymentDetails( paymentField, paymentState ) } + + if (resolvedAmount > 0) { + viewModel.paymentRequired = true + } + if ( + paymentState && + paymentState.preAuth?.status === 'success' && + paymentState.amount === resolvedAmount + ) { + viewModel.paymentPreAuthorized = true + } } const components = this.collection.getViewModel(payload, errors, query) @@ -157,6 +183,18 @@ export class SummaryPageController extends QuestionPageController { ) => { const { viewName } = this + // After GOV.UK Pay callback, auto-submit the form instead of + // showing CYA again. The payment is already pre-authorized. + if (request.query.paymentComplete === 'true') { + return this.handleFormSubmit( + request as unknown as FormRequestPayload, + context, + h + ) + } + + await this.reconcilePaymentState(request, context) + const viewModel = this.getSummaryViewModel(request, context) viewModel.hasMissingNotificationEmail = @@ -166,6 +204,33 @@ export class SummaryPageController extends QuestionPageController { } } + /** + * Checks if the resolved payment amount has changed since pre-auth + * and invalidates stale payment state if so. + */ + private async reconcilePaymentState( + request: FormRequest, + context: FormContext + ) { + const paymentField = this.findPaymentField() + + if (!paymentField) { + return + } + + const resolvedAmount = PaymentField.resolveAmount( + paymentField.options, + this.model, + context.state + ) + const paymentState = paymentField.getPaymentStateFromState(context.state) + + if (paymentState && paymentState.amount !== resolvedAmount) { + const cacheService = getCacheService(request.server) + await cacheService.resetComponentStates(request, [paymentField.name]) + } + } + /** * Returns an async function. This is called in plugin.ts when there is a POST request at `/{id}/{path*}`. * If a form is incomplete, a user will be redirected to the start page. @@ -293,6 +358,18 @@ export class SummaryPageController extends QuestionPageController { } } + // Payment page is skipped in the page walk, so proceed() would redirect + // to an earlier page. For PaymentIncomplete, redirect directly to the + // payment page using its href. + if ( + error.errorType === PaymentErrorTypes.PaymentIncomplete && + error.component.page + ) { + return h + .redirect(error.component.page.getHref(error.component.page.path)) + .code(StatusCodes.SEE_OTHER) + } + const govukError = createError(error.component.name, error.userMessage) request.yar.flash(COMPONENT_STATE_ERROR, govukError, true) @@ -345,9 +422,9 @@ export async function submitForm( model: FormModel, emailAddress: string ) { - await finaliseComponents(request, formMetadata, context) + await finaliseComponents(request, formMetadata, context, model) - const paymentWasCaptured = hasPaymentBeenCaptured(context) + const paymentWasCaptured = hasPaymentBeenCaptured(context, model) const formStatus = checkFormStatus(request.params) const logTags = ['submit', 'submissionApi'] @@ -395,8 +472,11 @@ export async function submitForm( /** * Checks if any payment component has been captured */ -function hasPaymentBeenCaptured(context: FormContext): boolean { - for (const page of context.relevantPages) { +function hasPaymentBeenCaptured( + context: FormContext, + model: FormModel +): boolean { + for (const page of model.pages) { for (const field of page.collection.fields) { if (field instanceof PaymentField) { const paymentState = field.getPaymentStateFromState(context.state) @@ -419,17 +499,19 @@ function hasPaymentBeenCaptured(context: FormContext): boolean { async function finaliseComponents( request: FormRequestPayload, metadata: FormMetadata, - context: FormContext + context: FormContext, + model: FormModel ) { - const relevantFields = context.relevantPages.flatMap( - (page) => page.collection.fields - ) + // Get fields from relevant pages (normal components) + // plus PaymentField from all pages (payment page is skipped in the page walk) + const allFields = new Set([ + ...context.relevantPages.flatMap((page) => page.collection.fields), + ...model.pages + .flatMap((page) => page.collection.fields) + .filter((field) => field instanceof PaymentField) + ]) - for (const component of relevantFields) { - /* - Each component will throw InvalidComponent if its state is invalid, which is handled - by handleFormSubmit - */ + for (const component of allFields) { await component.onSubmit(request, metadata, context) } } diff --git a/src/server/plugins/engine/pageControllers/errors.ts b/src/server/plugins/engine/pageControllers/errors.ts index 5e796eec7..ef9986cac 100644 --- a/src/server/plugins/engine/pageControllers/errors.ts +++ b/src/server/plugins/engine/pageControllers/errors.ts @@ -64,10 +64,10 @@ export class PaymentSubmissionError extends Error { static checkPaymentAmount( stateAmount: number, - definitionAmount: number | undefined, + expectedAmount: number | undefined, component: FormComponent ) { - if (stateAmount / 100 !== definitionAmount) { + if (stateAmount / 100 !== expectedAmount) { throw new PaymentPreAuthError( component, 'The pre-authorised payment amount is somehow different from that requested. Try adding payment details again.', diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 56f908468..ef324cd6b 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -1,3 +1,4 @@ +import { isPaymentPage } from '@defra/forms-model' import Boom from '@hapi/boom' import { type ResponseObject, @@ -102,8 +103,14 @@ export async function redirectOrMakeHandler( return proceed(request, h, resumeInRepeaterUrl) } - // Return handler for relevant pages or preview URL direct access - if (relevantPath.startsWith(page.path) || context.isForceAccess) { + // Return handler for relevant pages, payment pages, or preview URL direct access. + // Payment pages are skipped in the normal page walk but must render when the user + // is redirected there from CYA "Pay and submit". + if ( + relevantPath.startsWith(page.path) || + isPaymentPage(page.pageDef) || + context.isForceAccess + ) { return makeHandler(page, context) } diff --git a/src/server/plugins/engine/routes/payment.js b/src/server/plugins/engine/routes/payment.js index 0d12969c5..692f623fe 100644 --- a/src/server/plugins/engine/routes/payment.js +++ b/src/server/plugins/engine/routes/payment.js @@ -103,7 +103,13 @@ function logPaymentFailure(session, paymentStatus) { function handlePaymentSuccess(request, h, session, sessionKey, paymentStatus) { flashComponentState(request, session, paymentStatus) request.yar.clear(sessionKey) - return h.redirect(session.returnUrl).code(StatusCodes.SEE_OTHER) + + // Append paymentComplete flag so the summary page auto-submits + // instead of showing CYA again + const separator = session.returnUrl.includes('?') ? '&' : '?' + const returnUrl = `${session.returnUrl}${separator}paymentComplete=true` + + return h.redirect(returnUrl).code(StatusCodes.SEE_OTHER) } /** diff --git a/src/server/plugins/engine/routes/payment.test.js b/src/server/plugins/engine/routes/payment.test.js index 9ae926297..6ba6eda5d 100644 --- a/src/server/plugins/engine/routes/payment.test.js +++ b/src/server/plugins/engine/routes/payment.test.js @@ -41,8 +41,14 @@ describe('Payment routes', () => { const sessionKey = 'session-key' test.each([ - { status: 'capturable', finalUrl: 'http://host.com/return-url' }, - { status: 'success', finalUrl: 'http://host.com/return-url' }, + { + status: 'capturable', + finalUrl: 'http://host.com/return-url?paymentComplete=true' + }, + { + status: 'success', + finalUrl: 'http://host.com/return-url?paymentComplete=true' + }, { status: 'cancelled', finalUrl: 'http://host.com/failure-url' }, { status: 'failed', finalUrl: 'http://host.com/failure-url' }, { status: 'error', finalUrl: 'http://host.com/failure-url' }, @@ -169,7 +175,9 @@ describe('Payment routes', () => { const { response } = await renderResponse(server, options) expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) - expect(response.headers.location).toBe('http://host.com/return-url') + expect(response.headers.location).toBe( + 'http://host.com/return-url?paymentComplete=true' + ) }) }) }) diff --git a/src/server/plugins/engine/services/localFormsService.js b/src/server/plugins/engine/services/localFormsService.js index c3fbfaef4..8bf8c6680 100644 --- a/src/server/plugins/engine/services/localFormsService.js +++ b/src/server/plugins/engine/services/localFormsService.js @@ -65,5 +65,12 @@ export const formsService = async () => { slug: 'payment-test' }) + await loader.addForm('src/server/forms/payment-v2-test.yaml', { + ...metadata, + id: 'c3d4e5f6-a7b8-9012-cdef-012345678901', + title: 'Apply for a lock and weir fishing permit (v2 payment test)', + slug: 'payment-v2-test' + }) + return loader.toFormsService() } diff --git a/src/server/plugins/engine/views/summary.html b/src/server/plugins/engine/views/summary.html index 100c952dc..0f01e2bac 100644 --- a/src/server/plugins/engine/views/summary.html +++ b/src/server/plugins/engine/views/summary.html @@ -73,9 +73,10 @@

Declaration

{% set isDeclaration = declaration or components | length %} + {% set paymentPending = paymentRequired and not paymentState %} {{ govukButton({ - text: "Accept and submit" if isDeclaration else "Submit", + text: "Pay and submit" if paymentPending else ("Accept and submit" if isDeclaration else "Submit"), name: "action", value: "send", preventDoubleClick: true diff --git a/src/server/plugins/payment/service.js b/src/server/plugins/payment/service.js index ed9bee809..32ae24c8b 100644 --- a/src/server/plugins/payment/service.js +++ b/src/server/plugins/payment/service.js @@ -41,6 +41,7 @@ export class PaymentService { * @param {string} reference * @param {boolean} isLivePayment * @param {{ formId: string, slug: string } | undefined } metadata + * @param {string} [email] - optional email to prepopulate on GOV.UK Pay */ async createPayment( amount, @@ -48,17 +49,26 @@ export class PaymentService { returnUrl, reference, isLivePayment, - metadata + metadata, + email ) { try { - const response = await this.postToPayProvider({ + /** @type {CreatePaymentRequest} */ + const payload = { amount, description, reference, metadata, return_url: returnUrl, delayed_capture: true - }) + } + + // Prepopulate email on GOV.UK Pay if provided + if (email) { + payload.email = email + } + + const response = await this.postToPayProvider(payload) logger.info( buildPaymentInfo( diff --git a/src/server/plugins/payment/types.js b/src/server/plugins/payment/types.js index d58783d4a..6c9fc88fc 100644 --- a/src/server/plugins/payment/types.js +++ b/src/server/plugins/payment/types.js @@ -20,6 +20,7 @@ * @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 + * @property {string} [email] - Email to prepopulate on GOV.UK Pay (max 254 chars) */ /** diff --git a/test/form/definitions/payment-v2-conditional.js b/test/form/definitions/payment-v2-conditional.js new file mode 100644 index 000000000..8a69cd924 --- /dev/null +++ b/test/form/definitions/payment-v2-conditional.js @@ -0,0 +1,114 @@ +import { + ComponentType, + ConditionType, + ControllerPath, + ControllerType, + Engine, + OperatorName, + SchemaVersion +} from '@defra/forms-model' + +export default /** @satisfies {FormDefinition} */ ({ + name: 'Payment V2 conditional amounts', + schema: SchemaVersion.V2, + engine: Engine.V2, + startPage: '/choice', + pages: /** @type {const} */ ([ + { + title: 'Choice', + path: '/choice', + components: [ + { + id: '11111111-1111-4111-8111-111111111111', + name: 'yesNoField', + title: 'Yes or no?', + type: ComponentType.YesNoField, + options: {} + } + ], + next: [] + }, + { + title: 'Gated', + path: '/gated', + condition: 'c1000000-0000-4000-8000-000000000001', + components: [ + { + id: '22222222-2222-4222-8222-222222222222', + name: 'textField', + title: 'A question', + type: ComponentType.TextField, + options: {}, + schema: {} + } + ], + next: [] + }, + { + title: 'Payment', + path: '/payment', + components: [ + { + id: '33333333-3333-4333-8333-333333333333', + type: ComponentType.PaymentField, + name: 'paymentField', + title: 'Payment required', + options: { + amount: 50, + description: 'Test payment', + conditionalAmounts: [ + { + condition: 'c1000000-0000-4000-8000-000000000001', + amount: 0 + }, + { + condition: 'c1000000-0000-4000-8000-000000000002', + amount: 99 + } + ] + } + } + ], + next: [] + }, + { + title: '', + path: ControllerPath.Summary, + controller: ControllerType.Summary + } + ]), + lists: [], + sections: [], + conditions: [ + { + id: 'c1000000-0000-4000-8000-000000000001', + displayName: 'Yes selected', + items: [ + { + id: 'e1000000-0000-4000-8000-000000000001', + componentId: '11111111-1111-4111-8111-111111111111', + operator: OperatorName.Is, + type: ConditionType.BooleanValue, + value: true + } + ] + }, + { + id: 'c1000000-0000-4000-8000-000000000002', + displayName: 'No selected', + items: [ + { + id: 'e1000000-0000-4000-8000-000000000002', + componentId: '11111111-1111-4111-8111-111111111111', + operator: OperatorName.Is, + type: ConditionType.BooleanValue, + value: false + } + ] + } + ] +}) + +/** + * @import { FormDefinition } from '@defra/forms-model' + */