Commit a7507226661 for woocommerce
commit a75072266611fc5a7b97fa6e4e5d05b4d4cb5ebf
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date: Fri Jun 26 12:11:40 2026 +0100
Harden embedded KYC loader failures (#65863)
* fix: harden embedded KYC loader failures
* Refine embedded KYC failure handling
* Improve embedded KYC failure accessibility
* Harden embedded KYC failure tests
diff --git a/plugins/woocommerce/changelog/fix-embedded-kyc-loader-failure-hardening b/plugins/woocommerce/changelog/fix-embedded-kyc-loader-failure-hardening
new file mode 100644
index 00000000000..b8bbd899b70
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-embedded-kyc-loader-failure-hardening
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent embedded WooPayments business verification from hanging when the Stripe loader fails.
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/components/embedded/index.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/components/embedded/index.tsx
index 8e25452f73b..6821bc40118 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/components/embedded/index.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/components/embedded/index.tsx
@@ -19,7 +19,12 @@ import { __ } from '@wordpress/i18n';
*/
import { createEmbeddedKycSession } from '../../utils/actions';
import appearance from './appearance';
-import { OnboardingFields } from '../../types';
+import {
+ type EmbeddedAccountInitializationFailure,
+ EmbeddedKycSession,
+ EmbeddedKycSessionCreateResult,
+ OnboardingFields,
+} from '../../types';
import BannerNotice from '../../../../components/banner-notice';
import { useOnboardingContext } from '../../../../data/onboarding-context';
@@ -32,9 +37,75 @@ interface EmbeddedAccountOnboardingProps extends EmbeddedComponentProps {
onboardingData: OnboardingFields;
onExit: () => void;
onStepChange?: ( step: string ) => void;
+ onInitializationError?: (
+ failure: EmbeddedAccountInitializationFailure
+ ) => void;
collectPayoutRequirements?: boolean;
}
+const defaultLocale = 'en-US';
+const genericInitializationError = __(
+ 'Unable to start the business verification session. If this problem persists, please contact support.',
+ 'woocommerce'
+);
+
+const isObjectRecord = ( value: unknown ): value is Record< string, unknown > =>
+ !! value && typeof value === 'object' && ! Array.isArray( value );
+
+const getObjectKeys = ( value: unknown ): string[] =>
+ isObjectRecord( value ) ? Object.keys( value ).sort() : [];
+
+const isNonEmptyString = ( value: unknown ): value is string =>
+ typeof value === 'string' && value.trim() !== '';
+
+const normalizeLocale = ( value: unknown ): string =>
+ isNonEmptyString( value )
+ ? value.trim().replace( /_/g, '-' )
+ : defaultLocale;
+
+const validateEmbeddedKycSessionCreateResult = (
+ result: unknown
+):
+ | { result: EmbeddedKycSessionCreateResult }
+ | { error: EmbeddedAccountInitializationFailure } => {
+ const session = isObjectRecord( result ) ? result.session : undefined;
+ const receivedKeys = isObjectRecord( session )
+ ? getObjectKeys( session )
+ : getObjectKeys( result );
+
+ if ( ! isObjectRecord( session ) ) {
+ return {
+ error: {
+ reason: 'bad_session',
+ message: genericInitializationError,
+ receivedKeys,
+ },
+ };
+ }
+
+ if (
+ ! isNonEmptyString( session.clientSecret ) ||
+ ! isNonEmptyString( session.publishableKey )
+ ) {
+ return {
+ error: {
+ reason: 'bad_session',
+ message: genericInitializationError,
+ receivedKeys,
+ },
+ };
+ }
+
+ const normalizedSession: EmbeddedKycSession = {
+ ...( session as unknown as EmbeddedKycSession ),
+ clientSecret: session.clientSecret,
+ publishableKey: session.publishableKey,
+ locale: normalizeLocale( session.locale ),
+ };
+
+ return { result: { session: normalizedSession } };
+};
+
/**
* Hook to initialize Stripe Connect.
*
@@ -47,9 +118,9 @@ const useInitializeStripe = ( onboardingData: OnboardingFields ) => {
useState< StripeConnectInstance | null >( null );
const { currentStep, sessionEntryPoint: onboardingSource } =
useOnboardingContext();
- const [ initializationError, setInitializationError ] = useState<
- string | null
- >( null );
+ const kycSessionUrl = currentStep?.actions?.kyc_session?.href ?? '';
+ const [ initializationError, setInitializationError ] =
+ useState< EmbeddedAccountInitializationFailure | null >( null );
const [ loading, setLoading ] = useState< boolean >( true );
useEffect( () => {
@@ -57,21 +128,20 @@ const useInitializeStripe = ( onboardingData: OnboardingFields ) => {
try {
const accountSession = await createEmbeddedKycSession(
onboardingData,
- currentStep?.actions?.kyc_session?.href ?? '',
+ kycSessionUrl,
onboardingSource
);
- const { clientSecret, publishableKey } = accountSession.session;
-
- if ( ! publishableKey ) {
- throw new Error(
- __(
- 'Unable to start the business verification session. If this problem persists, please contact support.',
- 'woocommerce'
- )
- );
+ const validation =
+ validateEmbeddedKycSessionCreateResult( accountSession );
+ if ( 'error' in validation ) {
+ setInitializationError( validation.error );
+ return;
}
+ const { clientSecret, publishableKey, locale } =
+ validation.result.session;
+
const instance = loadConnectAndInitialize( {
publishableKey,
fetchClientSecret: async () => clientSecret,
@@ -79,26 +149,22 @@ const useInitializeStripe = ( onboardingData: OnboardingFields ) => {
overlays: 'drawer',
...appearance,
},
- locale: accountSession.session.locale.replace( '_', '-' ),
+ locale,
} );
setStripeConnectInstance( instance );
} catch ( err ) {
- setInitializationError(
- err instanceof Error
- ? err.message
- : __(
- 'Unable to start the business verification session. If this problem persists, please contact support.',
- 'woocommerce'
- )
- );
+ setInitializationError( {
+ reason: 'init_error',
+ message: genericInitializationError,
+ } );
} finally {
setLoading( false );
}
};
initializeStripe();
- }, [ onboardingData ] );
+ }, [ kycSessionUrl, onboardingData, onboardingSource ] );
return { stripeConnectInstance, initializationError, loading };
};
@@ -112,6 +178,7 @@ const useInitializeStripe = ( onboardingData: OnboardingFields ) => {
* @param onLoaderStart - Callback function when the onboarding loader starts.
* @param onLoadError - Callback function when the onboarding load error occurs.
* @param [onStepChange] - Callback function when the onboarding step changes.
+ * @param [onInitializationError] - Callback function when the onboarding initialization fails.
* @param [collectPayoutRequirements=false] - Whether to collect payout requirements.
*
* @return Rendered Account Onboarding component.
@@ -124,16 +191,23 @@ export const EmbeddedAccountOnboarding: React.FC<
onLoaderStart,
onLoadError,
onStepChange,
+ onInitializationError,
collectPayoutRequirements = false,
} ) => {
const { stripeConnectInstance, initializationError } =
useInitializeStripe( onboardingData );
+ useEffect( () => {
+ if ( initializationError ) {
+ onInitializationError?.( initializationError );
+ }
+ }, [ initializationError, onInitializationError ] );
+
return (
<>
- { initializationError && (
+ { initializationError && ! onInitializationError && (
<BannerNotice status="error">
- { initializationError }
+ { initializationError.message }
</BannerNotice>
) }
{ stripeConnectInstance && (
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/sections/embedded-kyc.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/sections/embedded-kyc.tsx
index 62ce9cae177..0e493501051 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/sections/embedded-kyc.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/sections/embedded-kyc.tsx
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import React, { useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
import { __ } from '@wordpress/i18n';
import { LoadError } from '@stripe/connect-js';
@@ -14,6 +14,7 @@ import BannerNotice from '../../../components/banner-notice';
import { useBusinessVerificationContext } from '../data/business-verification-context';
import { finalizeEmbeddedKycSession } from '../utils/actions';
import { EmbeddedAccountOnboarding } from '../components/embedded';
+import { type EmbeddedAccountInitializationFailure } from '../types';
import { recordPaymentsOnboardingEvent } from '~/settings-payments/utils';
interface Props {
@@ -21,6 +22,75 @@ interface Props {
collectPayoutRequirements?: boolean;
}
+type EmbeddedKycLoadFailureReason =
+ | 'timeout'
+ | 'load_error'
+ | 'bad_session'
+ | 'init_error';
+
+type EmbeddedKycLoadFailure = {
+ reason: EmbeddedKycLoadFailureReason;
+ message?: string;
+ errorType?: string;
+ receivedKeys?: string[];
+};
+
+const embeddedKycLoadTimeoutMs = 20000;
+const embeddedKycTroubleshootingUrl =
+ 'https://woocommerce.com/document/woopayments/startup-guide/#requirements';
+const embeddedKycFailureMessage = __(
+ "We couldn't load this step. This can happen when your site's security or server settings block a required connection to Stripe. Check the setup requirements, or contact support if the error persists.",
+ 'woocommerce'
+);
+const embeddedKycHttpsFailureMessage = __(
+ 'Payment activation through our financial partner requires HTTPS and cannot be completed.',
+ 'woocommerce'
+);
+const embeddedKycLoadingMessage = __( 'Loading onboarding…', 'woocommerce' );
+const embeddedKycFinalizingMessage = __(
+ 'Finalizing onboarding…',
+ 'woocommerce'
+);
+
+const getFailureTrackingDetails = (
+ failure: EmbeddedKycLoadFailure
+): Record< string, string > => {
+ const details: Record< string, string > = {};
+
+ if ( failure.errorType ) {
+ details.error_type = failure.errorType;
+ }
+
+ if ( failure.message && failure.reason !== 'init_error' ) {
+ details.error_message = failure.message;
+ }
+
+ if ( failure.receivedKeys ) {
+ details.received_keys = failure.receivedKeys.join( ',' ) || 'none';
+ }
+
+ return details;
+};
+
+const getFailureNoticeMessage = ( failure: EmbeddedKycLoadFailure ) => {
+ if (
+ failure.reason === 'load_error' &&
+ failure.errorType === 'invalid_request_error'
+ ) {
+ return embeddedKycHttpsFailureMessage;
+ }
+
+ return embeddedKycFailureMessage;
+};
+
+const getFailureNoticeStatus = (
+ failure: EmbeddedKycLoadFailure
+): 'error' | 'warning' =>
+ failure.reason === 'load_error' &&
+ failure.errorType === 'invalid_request_error'
+ ? 'warning'
+ : 'error';
+
const EmbeddedKyc: React.FC< Props > = ( {
collectPayoutRequirements = false,
} ) => {
@@ -32,9 +102,59 @@ const EmbeddedKyc: React.FC< Props > = ( {
} = useOnboardingContext();
const [ finalizingSession, setFinalizingSession ] = useState( false );
const [ loading, setLoading ] = useState( true );
- const [ loadError, setLoadError ] = useState< LoadError | null >( null );
+ const [ loadFailure, setLoadFailure ] =
+ useState< EmbeddedKycLoadFailure | null >( null );
+ const loadFailureRef = useRef( false );
+ const loadFailureNoticeRef = useRef< HTMLDivElement >( null );
const fallbackUrl = currentStep?.actions?.kyc_fallback?.href ?? '';
+ const failEmbeddedKycLoad = useCallback(
+ ( failure: EmbeddedKycLoadFailure ) => {
+ if ( loadFailureRef.current ) {
+ return;
+ }
+
+ loadFailureRef.current = true;
+ setLoading( false );
+ setLoadFailure( failure );
+ recordPaymentsOnboardingEvent(
+ 'woopayments_onboarding_modal_kyc_load_error',
+ {
+ reason: failure.reason,
+ collect_payout_requirements: collectPayoutRequirements,
+ source: onboardingSource,
+ ...getFailureTrackingDetails( failure ),
+ }
+ );
+ },
+ [ collectPayoutRequirements, onboardingSource ]
+ );
+
+ useEffect( () => {
+ if ( ! loading || loadFailure || finalizingSession ) {
+ return;
+ }
+
+ const timerId = window.setTimeout( () => {
+ failEmbeddedKycLoad( { reason: 'timeout' } );
+ }, embeddedKycLoadTimeoutMs );
+
+ return () => window.clearTimeout( timerId );
+ }, [ failEmbeddedKycLoad, finalizingSession, loadFailure, loading ] );
+
+ useEffect( () => {
+ if ( ! loadFailure ) {
+ return;
+ }
+
+ const notice = loadFailureNoticeRef.current;
+ const activeElement = notice?.ownerDocument.activeElement ?? null;
+
+ if ( notice && ! notice.contains( activeElement ) ) {
+ notice.focus();
+ }
+ }, [ loadFailure ] );
+
const handleStepChange = ( step: string ) => {
recordPaymentsOnboardingEvent(
'woopayments_onboarding_modal_kyc_step_change',
@@ -66,6 +186,10 @@ const EmbeddedKyc: React.FC< Props > = ( {
};
const handleLoaderStart = () => {
+ if ( loadFailureRef.current ) {
+ return;
+ }
+
recordPaymentsOnboardingEvent(
'woopayments_onboarding_modal_kyc_started_loading',
{
@@ -78,75 +202,76 @@ const EmbeddedKyc: React.FC< Props > = ( {
};
const handleLoadError = ( err: LoadError ) => {
- recordPaymentsOnboardingEvent(
- 'woopayments_onboarding_modal_kyc_load_error',
- {
- error_type: err.error.type,
- error_message: err.error.message || 'no_message',
- collect_payout_requirements: collectPayoutRequirements,
- source: onboardingSource,
- }
- );
-
- setLoadError( err );
+ failEmbeddedKycLoad( {
+ reason: 'load_error',
+ errorType: err.error.type,
+ message: err.error.message || 'no_message',
+ } );
};
+ const handleInitializationError = useCallback(
+ ( failure: EmbeddedAccountInitializationFailure ) => {
+ failEmbeddedKycLoad( failure );
+ },
+ [ failEmbeddedKycLoad ]
+ );
+
return (
<>
- { loadError &&
- ( loadError.error.type === 'invalid_request_error' ? (
+ { loadFailure && (
+ <div ref={ loadFailureNoticeRef } tabIndex={ -1 }>
<BannerNotice
- className={ 'woopayments-banner-notice--embedded-kyc' }
- status="warning"
+ className="woopayments-banner-notice--embedded-kyc"
+ status={ getFailureNoticeStatus( loadFailure ) }
isDismissible={ false }
actions={ [
{
- label: 'Learn more',
+ label: __( 'Learn more', 'woocommerce' ),
variant: 'primary',
- url: 'https://woocommerce.com/document/woopayments/startup-guide/#requirements',
+ url: embeddedKycTroubleshootingUrl,
urlTarget: '_blank',
},
{
- label: 'Cancel',
+ label: __( 'Cancel', 'woocommerce' ),
variant: 'link',
url: fallbackUrl,
},
] }
>
- { __(
- 'Payment activation through our financial partner requires HTTPS and cannot be completed.',
- 'woocommerce'
- ) }
- </BannerNotice>
- ) : (
- <BannerNotice
- className={ 'woopayments-banner-notice--embedded-kyc' }
- status="error"
- isDismissible={ false }
- >
- { loadError.error.message }
+ { getFailureNoticeMessage( loadFailure ) }
</BannerNotice>
- ) ) }
+ </div>
+ ) }
{ loading && (
- <div className="embedded-kyc-loader-wrapper padded">
+ <div
+ className="embedded-kyc-loader-wrapper padded"
+ role="status"
+ >
+ <span className="screen-reader-text">
+ { embeddedKycLoadingMessage }
+ </span>
<StripeSpinner />
</div>
) }
{ finalizingSession && (
- <div className="embedded-kyc-loader-wrapper">
+ <div className="embedded-kyc-loader-wrapper" role="status">
+ <span className="screen-reader-text">
+ { embeddedKycFinalizingMessage }
+ </span>
<StripeSpinner />
</div>
) }
- {
+ { ! loadFailure && (
<EmbeddedAccountOnboarding
onExit={ handleOnExit }
onStepChange={ handleStepChange }
onLoaderStart={ handleLoaderStart }
onLoadError={ handleLoadError }
+ onInitializationError={ handleInitializationError }
onboardingData={ data }
collectPayoutRequirements={ collectPayoutRequirements }
/>
- }
+ ) }
</>
);
};
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/test/embedded-account-onboarding.test.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/test/embedded-account-onboarding.test.tsx
new file mode 100644
index 00000000000..6fdf656c757
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/test/embedded-account-onboarding.test.tsx
@@ -0,0 +1,210 @@
+/**
+ * External dependencies
+ */
+import { loadConnectAndInitialize } from '@stripe/connect-js';
+import { render, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import { EmbeddedAccountOnboarding } from '../components/embedded';
+import { createEmbeddedKycSession } from '../utils/actions';
+import { useOnboardingContext } from '../../../data/onboarding-context';
+import type { EmbeddedKycSessionCreateResult } from '../types';
+
+jest.mock( '@stripe/connect-js', () => ( {
+ loadConnectAndInitialize: jest.fn( () => ( {
+ mockStripeConnectInstance: true,
+ } ) ),
+} ) );
+
+jest.mock( '@stripe/react-connect-js', () => ( {
+ ConnectComponentsProvider: ( {
+ children,
+ }: {
+ children: React.ReactNode;
+ } ) => <div data-testid="connect-components-provider">{ children }</div>,
+ ConnectAccountOnboarding: () => (
+ <div data-testid="connect-account-onboarding" />
+ ),
+} ) );
+
+jest.mock( '../utils/actions', () => ( {
+ createEmbeddedKycSession: jest.fn(),
+} ) );
+
+jest.mock( '../../../data/onboarding-context', () => ( {
+ useOnboardingContext: jest.fn(),
+} ) );
+
+const mockCreateEmbeddedKycSession =
+ createEmbeddedKycSession as jest.MockedFunction<
+ typeof createEmbeddedKycSession
+ >;
+const mockLoadConnectAndInitialize =
+ loadConnectAndInitialize as jest.MockedFunction<
+ typeof loadConnectAndInitialize
+ >;
+const mockUseOnboardingContext = useOnboardingContext as jest.Mock;
+
+const createSession = (
+ overrides: Partial< EmbeddedKycSessionCreateResult[ 'session' ] > = {}
+): EmbeddedKycSessionCreateResult => ( {
+ session: {
+ clientSecret: 'test-secret',
+ publishableKey: 'test-key',
+ locale: 'en_US',
+ expiresAt: 1234567890,
+ accountId: 'acct_test',
+ isLive: false,
+ accountCreated: true,
+ ...overrides,
+ },
+} );
+
+const renderEmbeddedAccountOnboarding = (
+ overrides: Partial<
+ React.ComponentProps< typeof EmbeddedAccountOnboarding >
+ > = {}
+) => {
+ return render(
+ <EmbeddedAccountOnboarding
+ onboardingData={ {} }
+ onExit={ jest.fn() }
+ { ...overrides }
+ />
+ );
+};
+
+describe( 'EmbeddedAccountOnboarding', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ mockUseOnboardingContext.mockReturnValue( {
+ currentStep: {
+ actions: {
+ kyc_session: {
+ href: 'https://example.com/session',
+ },
+ },
+ },
+ sessionEntryPoint: 'settings',
+ } );
+ mockCreateEmbeddedKycSession.mockResolvedValue( createSession() );
+ } );
+
+ it( 'notifies the parent when the KYC session shape is invalid', async () => {
+ const mockOnInitializationError = jest.fn();
+ mockCreateEmbeddedKycSession.mockResolvedValue( {
+ session: {
+ unexpected: 'value',
+ },
+ } as never );
+
+ renderEmbeddedAccountOnboarding( {
+ onInitializationError: mockOnInitializationError,
+ } );
+
+ await waitFor( () =>
+ expect( mockOnInitializationError ).toHaveBeenCalledWith( {
+ reason: 'bad_session',
+ message:
+ 'Unable to start the business verification session. If this problem persists, please contact support.',
+ receivedKeys: [ 'unexpected' ],
+ } )
+ );
+ expect(
+ screen.queryByTestId( 'connect-account-onboarding' )
+ ).not.toBeInTheDocument();
+ expect( mockLoadConnectAndInitialize ).not.toHaveBeenCalled();
+ } );
+
+ it( 'notifies the parent when initialization throws unexpectedly', async () => {
+ const mockOnInitializationError = jest.fn();
+ mockCreateEmbeddedKycSession.mockRejectedValue(
+ new Error( 'Network unavailable.' )
+ );
+
+ renderEmbeddedAccountOnboarding( {
+ onInitializationError: mockOnInitializationError,
+ } );
+
+ await waitFor( () =>
+ expect( mockOnInitializationError ).toHaveBeenCalledWith( {
+ reason: 'init_error',
+ message:
+ 'Unable to start the business verification session. If this problem persists, please contact support.',
+ } )
+ );
+ expect(
+ screen.queryByTestId( 'connect-account-onboarding' )
+ ).not.toBeInTheDocument();
+ } );
+
+ it.each( [ undefined, '', 42 ] )(
+ 'defaults the locale when the KYC session returns %p',
+ async ( locale ) => {
+ mockCreateEmbeddedKycSession.mockResolvedValue(
+ createSession( {
+ locale: locale as never,
+ } )
+ );
+
+ renderEmbeddedAccountOnboarding();
+
+ expect(
+ await screen.findByTestId( 'connect-account-onboarding' )
+ ).toBeInTheDocument();
+ expect( mockLoadConnectAndInitialize ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ locale: 'en-US',
+ } )
+ );
+ }
+ );
+
+ it.each( [
+ [ 'en_US', 'en-US' ],
+ [ ' sr_Latn_RS ', 'sr-Latn-RS' ],
+ ] )( 'normalizes locale %p to %p', async ( locale, expectedLocale ) => {
+ mockCreateEmbeddedKycSession.mockResolvedValue(
+ createSession( { locale } )
+ );
+
+ renderEmbeddedAccountOnboarding();
+
+ expect(
+ await screen.findByTestId( 'connect-account-onboarding' )
+ ).toBeInTheDocument();
+ expect( mockLoadConnectAndInitialize ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ locale: expectedLocale,
+ } )
+ );
+ } );
+
+ it( 'passes session credentials through to Stripe for a valid session', async () => {
+ renderEmbeddedAccountOnboarding();
+
+ expect(
+ await screen.findByTestId( 'connect-account-onboarding' )
+ ).toBeInTheDocument();
+
+ const [ firstInitializeCall ] = mockLoadConnectAndInitialize.mock.calls;
+ const initializeOptions = firstInitializeCall?.[ 0 ];
+
+ if ( ! initializeOptions ) {
+ throw new Error( 'Expected Stripe initialization options.' );
+ }
+
+ expect( initializeOptions ).toEqual(
+ expect.objectContaining( {
+ publishableKey: 'test-key',
+ locale: 'en-US',
+ } )
+ );
+ await expect( initializeOptions.fetchClientSecret() ).resolves.toBe(
+ 'test-secret'
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/test/embedded-kyc.test.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/test/embedded-kyc.test.tsx
new file mode 100644
index 00000000000..cd12cc8fc83
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/test/embedded-kyc.test.tsx
@@ -0,0 +1,316 @@
+/**
+ * External dependencies
+ */
+import { act, render, screen } from '@testing-library/react';
+import React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import EmbeddedKyc from '../sections/embedded-kyc';
+import { useOnboardingContext } from '../../../data/onboarding-context';
+import { useBusinessVerificationContext } from '../data/business-verification-context';
+import { recordPaymentsOnboardingEvent } from '~/settings-payments/utils';
+
+type MockEmbeddedAccountOnboardingProps = {
+ onLoaderStart?: ( value: { elementTagName: string } ) => void;
+ onLoadError?: ( value: {
+ error: { type: string; message?: string };
+ elementTagName: string;
+ } ) => void;
+ onInitializationError?: ( failure: {
+ reason: 'bad_session' | 'init_error';
+ message: string;
+ receivedKeys?: string[];
+ } ) => void;
+ [ key: string ]: unknown;
+};
+
+let mockEmbeddedAccountOnboardingProps: MockEmbeddedAccountOnboardingProps;
+
+jest.mock( '../components/embedded', () => ( {
+ EmbeddedAccountOnboarding: (
+ props: MockEmbeddedAccountOnboardingProps
+ ) => {
+ mockEmbeddedAccountOnboardingProps = props;
+ return <div data-testid="embedded-account-onboarding" />;
+ },
+} ) );
+
+jest.mock( '../../../data/onboarding-context', () => ( {
+ useOnboardingContext: jest.fn(),
+} ) );
+
+jest.mock( '../data/business-verification-context', () => ( {
+ useBusinessVerificationContext: jest.fn(),
+} ) );
+
+jest.mock( '../../../components/stripe-spinner', () => ( {
+ __esModule: true,
+ default: () => <div data-testid="stripe-spinner" />,
+} ) );
+
+jest.mock( '../utils/actions', () => ( {
+ finalizeEmbeddedKycSession: jest.fn(),
+} ) );
+
+jest.mock( '~/settings-payments/utils', () => ( {
+ recordPaymentsOnboardingEvent: jest.fn(),
+} ) );
+
+const mockUseOnboardingContext = useOnboardingContext as jest.Mock;
+const mockUseBusinessVerificationContext =
+ useBusinessVerificationContext as jest.Mock;
+const mockRecordPaymentsOnboardingEvent =
+ recordPaymentsOnboardingEvent as jest.MockedFunction<
+ typeof recordPaymentsOnboardingEvent
+ >;
+
+const mockContexts = () => {
+ mockUseBusinessVerificationContext.mockReturnValue( { data: {} } );
+ mockUseOnboardingContext.mockReturnValue( {
+ currentStep: {
+ actions: {
+ kyc_fallback: {
+ href: 'https://example.com/fallback',
+ },
+ kyc_session_finish: {
+ href: 'https://example.com/session/finish',
+ },
+ },
+ },
+ navigateToNextStep: jest.fn(),
+ sessionEntryPoint: 'settings',
+ } );
+};
+
+const getFailureNotice = (): HTMLElement => {
+ const notice = screen
+ .getByRole( 'link', { name: 'Learn more' } )
+ .closest< HTMLElement >( '[tabindex="-1"]' );
+
+ if ( ! notice ) {
+ throw new Error( 'Expected focused failure notice.' );
+ }
+
+ return notice;
+};
+
+describe( 'EmbeddedKyc', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ jest.useRealTimers();
+ mockEmbeddedAccountOnboardingProps = {};
+ mockContexts();
+ } );
+
+ afterEach( () => {
+ jest.useRealTimers();
+ } );
+
+ it( 'announces the loading state while waiting for the embedded loader', () => {
+ render( <EmbeddedKyc /> );
+
+ expect( screen.getByRole( 'status' ) ).toHaveTextContent(
+ 'Loading onboarding…'
+ );
+ } );
+
+ it( 'shows a unified error when the embedded loader does not start before the timeout', () => {
+ jest.useFakeTimers();
+
+ render( <EmbeddedKyc /> );
+
+ act( () => {
+ jest.advanceTimersByTime( 20000 );
+ } );
+
+ const notice = getFailureNotice();
+
+ expect( notice ).toHaveTextContent(
+ "We couldn't load this step. This can happen when your site's security or server settings block a required connection to Stripe. Check the setup requirements, or contact support if the error persists."
+ );
+ expect( notice ).toHaveFocus();
+ expect(
+ screen.getByRole( 'link', { name: 'Learn more' } )
+ ).toHaveAttribute(
+ 'href',
+ 'https://woocommerce.com/document/woopayments/startup-guide/#requirements'
+ );
+ expect(
+ screen.queryByTestId( 'embedded-account-onboarding' )
+ ).not.toBeInTheDocument();
+ expect( mockRecordPaymentsOnboardingEvent ).toHaveBeenCalledWith(
+ 'woopayments_onboarding_modal_kyc_load_error',
+ {
+ reason: 'timeout',
+ collect_payout_requirements: false,
+ source: 'settings',
+ }
+ );
+ } );
+
+ it( 'clears the timeout when the embedded loader starts', () => {
+ jest.useFakeTimers();
+
+ render( <EmbeddedKyc collectPayoutRequirements /> );
+
+ act( () => {
+ mockEmbeddedAccountOnboardingProps.onLoaderStart?.( {
+ elementTagName: 'connect-account-onboarding',
+ } );
+ } );
+ act( () => {
+ jest.advanceTimersByTime( 20000 );
+ } );
+
+ expect(
+ screen.queryByRole( 'link', { name: 'Learn more' } )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByTestId( 'embedded-account-onboarding' )
+ ).toBeInTheDocument();
+ expect( mockRecordPaymentsOnboardingEvent ).toHaveBeenCalledWith(
+ 'woopayments_onboarding_modal_kyc_started_loading',
+ {
+ collect_payout_requirements: true,
+ source: 'settings',
+ }
+ );
+ expect( mockRecordPaymentsOnboardingEvent ).not.toHaveBeenCalledWith(
+ 'woopayments_onboarding_modal_kyc_load_error',
+ expect.anything()
+ );
+ } );
+
+ it( 'shows a unified error when Stripe reports a load error', () => {
+ render( <EmbeddedKyc /> );
+
+ act( () => {
+ mockEmbeddedAccountOnboardingProps.onLoadError?.( {
+ error: {
+ type: 'api_connection_error',
+ message: 'Stripe failed to load.',
+ },
+ elementTagName: 'connect-account-onboarding',
+ } );
+ } );
+
+ expect(
+ screen.getByRole( 'link', { name: 'Learn more' } )
+ ).toBeInTheDocument();
+ expect( mockRecordPaymentsOnboardingEvent ).toHaveBeenCalledWith(
+ 'woopayments_onboarding_modal_kyc_load_error',
+ {
+ reason: 'load_error',
+ error_type: 'api_connection_error',
+ error_message: 'Stripe failed to load.',
+ collect_payout_requirements: false,
+ source: 'settings',
+ }
+ );
+ } );
+
+ it( 'shows HTTPS-specific copy when Stripe reports an invalid request', () => {
+ render( <EmbeddedKyc /> );
+
+ act( () => {
+ mockEmbeddedAccountOnboardingProps.onLoadError?.( {
+ error: {
+ type: 'invalid_request_error',
+ message: 'This application requires HTTPS.',
+ },
+ elementTagName: 'connect-account-onboarding',
+ } );
+ } );
+
+ expect( getFailureNotice() ).toHaveTextContent(
+ 'Payment activation through our financial partner requires HTTPS and cannot be completed.'
+ );
+ expect( mockRecordPaymentsOnboardingEvent ).toHaveBeenCalledWith(
+ 'woopayments_onboarding_modal_kyc_load_error',
+ {
+ reason: 'load_error',
+ error_type: 'invalid_request_error',
+ error_message: 'This application requires HTTPS.',
+ collect_payout_requirements: false,
+ source: 'settings',
+ }
+ );
+ } );
+
+ it( 'shows a unified error when initialization fails', () => {
+ render( <EmbeddedKyc /> );
+
+ act( () => {
+ mockEmbeddedAccountOnboardingProps.onInitializationError?.( {
+ reason: 'bad_session',
+ message: 'Unable to start onboarding.',
+ receivedKeys: [ 'unexpected' ],
+ } );
+ } );
+
+ expect(
+ screen.getByRole( 'link', { name: 'Learn more' } )
+ ).toBeInTheDocument();
+ expect( mockRecordPaymentsOnboardingEvent ).toHaveBeenCalledWith(
+ 'woopayments_onboarding_modal_kyc_load_error',
+ {
+ reason: 'bad_session',
+ error_message: 'Unable to start onboarding.',
+ received_keys: 'unexpected',
+ collect_payout_requirements: false,
+ source: 'settings',
+ }
+ );
+ } );
+
+ it( 'omits initialization error messages from analytics', () => {
+ render( <EmbeddedKyc /> );
+
+ act( () => {
+ mockEmbeddedAccountOnboardingProps.onInitializationError?.( {
+ reason: 'init_error',
+ message: 'Network failed for https://internal.example/token.',
+ } );
+ } );
+
+ expect( getFailureNotice() ).toHaveTextContent(
+ "We couldn't load this step. This can happen when your site's security or server settings block a required connection to Stripe. Check the setup requirements, or contact support if the error persists."
+ );
+ expect( mockRecordPaymentsOnboardingEvent ).toHaveBeenCalledWith(
+ 'woopayments_onboarding_modal_kyc_load_error',
+ {
+ reason: 'init_error',
+ collect_payout_requirements: false,
+ source: 'settings',
+ }
+ );
+ } );
+
+ it( 'keeps the unified error when the loader starts after the timeout', () => {
+ jest.useFakeTimers();
+
+ render( <EmbeddedKyc /> );
+ const onLoaderStart = mockEmbeddedAccountOnboardingProps.onLoaderStart;
+
+ act( () => {
+ jest.advanceTimersByTime( 20000 );
+ onLoaderStart?.( {
+ elementTagName: 'connect-account-onboarding',
+ } );
+ } );
+
+ expect(
+ screen.getByRole( 'link', { name: 'Learn more' } )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByTestId( 'embedded-account-onboarding' )
+ ).not.toBeInTheDocument();
+ expect( mockRecordPaymentsOnboardingEvent ).toHaveBeenCalledTimes( 1 );
+ expect( mockRecordPaymentsOnboardingEvent ).toHaveBeenCalledWith(
+ 'woopayments_onboarding_modal_kyc_load_error',
+ expect.objectContaining( { reason: 'timeout' } )
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/types.ts b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/types.ts
index e1b95b94282..6e5c0ab8e8f 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/types.ts
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/business-verification/types.ts
@@ -63,6 +63,16 @@ export interface EmbeddedKycSessionCreateResult {
session: EmbeddedKycSession;
}
+export type EmbeddedAccountInitializationFailureReason =
+ | 'bad_session'
+ | 'init_error';
+
+export interface EmbeddedAccountInitializationFailure {
+ reason: EmbeddedAccountInitializationFailureReason;
+ message: string;
+ receivedKeys?: string[];
+}
+
/**
* Finalize embedded KYC session response.
*/