From ad875eb875c48b6bc06748cfc14365549c3e16b6 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 28 Apr 2026 14:18:06 +0100 Subject: [PATCH] fix(payment): source GOV.UK Pay email from CYA confirmation field (DF-832) DF-832 requires the email pre-populated on GOV.UK Pay to be the confirmation email the user provides on the CYA page, not a designer-configured form question. Drop the options.emailField lookup and read state.userConfirmationEmailAddress instead. --- .../engine/components/PaymentField.test.ts | 96 +++++++++++++++---- .../plugins/engine/components/PaymentField.ts | 11 +-- 2 files changed, 80 insertions(+), 27 deletions(-) diff --git a/src/server/plugins/engine/components/PaymentField.test.ts b/src/server/plugins/engine/components/PaymentField.test.ts index 533c42f4a..7ac98b949 100644 --- a/src/server/plugins/engine/components/PaymentField.test.ts +++ b/src/server/plugins/engine/components/PaymentField.test.ts @@ -952,26 +952,24 @@ describe('PaymentField', () => { }) describe('dispatcher with email prepopulation', () => { - it('should pass valid email to createPayment', async () => { - const emailDef = { - title: 'Payment with email', + it('should pass state.userConfirmationEmailAddress to createPayment', async () => { + const def = { + title: 'Payment', name: 'myPayment', type: ComponentType.PaymentField, options: { amount: 50, - description: 'Test payment', - emailField: 'userEmail' + description: 'Test payment' } } satisfies PaymentFieldComponent - const mockYarSet = jest.fn() const mockRequest = { server: { plugins: { 'forms-engine-plugin': { baseUrl: 'base-url' } } }, - yar: { set: mockYarSet } + yar: { set: jest.fn() } } as unknown as FormRequestPayload const mockH = { redirect: jest @@ -988,10 +986,10 @@ describe('PaymentField', () => { }, getState: jest.fn().mockResolvedValueOnce({ $$__referenceNumber: 'ref-123', - userEmail: 'test@example.com' + userConfirmationEmailAddress: 'cya@example.com' }) }, - component: emailDef, + component: def, sourceUrl: 'http://localhost:3009/test', isLive: false, isPreview: true @@ -1008,26 +1006,87 @@ describe('PaymentField', () => { 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' + email: 'cya@example.com' + }) + }) + ) + }) + + it('should not pass email when state.userConfirmationEmailAddress is missing', async () => { + const def = { + title: 'Payment', + name: 'myPayment', + type: ComponentType.PaymentField, + options: { + amount: 50, + description: 'Test payment' + } + } 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' + }) + }, + component: def, + 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(postJson).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + payload: expect.not.objectContaining({ + email: expect.anything() }) }) ) }) - it('should not pass email when emailField value is empty', async () => { - const emailDef = { - title: 'Payment with empty email', + it('should not pass email when state.userConfirmationEmailAddress is not a string', async () => { + const def = { + title: 'Payment', name: 'myPayment', type: ComponentType.PaymentField, options: { amount: 50, - description: 'Test payment', - emailField: 'userEmail' + description: 'Test payment' } } satisfies PaymentFieldComponent @@ -1054,10 +1113,10 @@ describe('PaymentField', () => { }, getState: jest.fn().mockResolvedValueOnce({ $$__referenceNumber: 'ref-123', - userEmail: '' + userConfirmationEmailAddress: { not: 'a string' } }) }, - component: emailDef, + component: def, sourceUrl: 'http://localhost:3009/test', isLive: false, isPreview: true @@ -1074,7 +1133,6 @@ describe('PaymentField', () => { await PaymentField.dispatcher(mockRequest, mockH, args) - // Verify createPayment was called WITHOUT an email field expect(postJson).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ diff --git a/src/server/plugins/engine/components/PaymentField.ts b/src/server/plugins/engine/components/PaymentField.ts index f2e7d76cd..bb2815805 100644 --- a/src/server/plugins/engine/components/PaymentField.ts +++ b/src/server/plugins/engine/components/PaymentField.ts @@ -270,15 +270,10 @@ export class PaymentField extends FormComponent { const payCallbackUrl = `${baseUrl}/payment-callback?uuid=${uuid}` const paymentPageUrl = args.sourceUrl - // 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 userConfirmationEmail = state.userConfirmationEmailAddress + if (typeof userConfirmationEmail === 'string' && userConfirmationEmail) { + prefilledEmail = userConfirmationEmail } const amountInPence = Math.round(resolvedAmount * 100)