Commit c7756939030 for woocommerce

commit c7756939030e78748b54fd09ff674126b9217081
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date:   Fri Jul 10 11:12:41 2026 +0300

    Fix NOX test account step retry loop on non-recoverable errors (#66356)

    * Fix NOX test account step retry loop on non-recoverable errors

    When the WooPayments test account initialization fails in a way the
    merchant cannot fix (e.g. Stripe rejecting the auto-generated statement
    descriptor with invalid_request_error, or the platform falling back to
    an account that requires verification and reporting success=false), the
    step was marked failed and the merchant was stuck on a Try Again loop
    that could never succeed.

    Detect these non-recoverable failures in onboarding_test_account_init():
    mark the step completed instead of failed, record a tracks event, and
    throw a dedicated error code. The test account step UI handles that code
    by showing a snackbar and advancing to the next onboarding step.

    * Add changelog entry for NOX test account non-recoverable handling

    * Address code review feedback on non-recoverable error handling

    - Log when the skipped test account step completion fails to persist,
      so drift between client navigation and stored onboarding state is
      debuggable instead of silent.
    - Rename the non-recoverable error constant to ..._ERROR_IDENTIFIERS
      since its entries are matched against both error types and codes.
    - Allow extending the identifier list via the new
      woocommerce_woopayments_onboarding_test_account_non_recoverable_errors
      filter, so the taxonomy can grow without a core release.
    - Pin the init request count in the success:false test; add tests for
      the filter and for the persistence-failure logging path.

    * Fail instead of advancing when the test account skip does not persist

    When skipping the test account step forward on a non-recoverable error,
    the completed status write could fail while the client still received
    the skip-forward error code, showing a success-flavored snackbar and
    advancing the merchant while the stored step stayed in started.

    Fall back to the regular failure handling in that window: mark the step
    failed and throw the standard client API error so the merchant gets the
    retry UI, and a later attempt can complete the skip. Also broaden the
    record_onboarding_step_completed() docblock to cover syncing state
    after failed operations, and turn the non-recoverable matcher test into
    a data provider covering error type/code in nested data and top-level
    positions.

diff --git a/plugins/woocommerce/changelog/fix-59437-nox-test-account-non-recoverable b/plugins/woocommerce/changelog/fix-59437-nox-test-account-non-recoverable
new file mode 100644
index 00000000000..95f0f2596b0
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-59437-nox-test-account-non-recoverable
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+NOX onboarding: automatically move to the next step and show a notice when the test account creation fails with a non-recoverable error, instead of a retry loop.
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/test-account/index.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/test-account/index.tsx
index fa07204b837..ae37e979b7f 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/test-account/index.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/test-account/index.tsx
@@ -21,6 +21,8 @@ import './style.scss';
 const TEST_ACCOUNT_ERROR_CODES = {
 	ACCOUNT_ALREADY_EXISTS:
 		'woocommerce_woopayments_test_account_already_exists',
+	NON_RECOVERABLE_ERROR:
+		'woocommerce_woopayments_onboarding_test_account_non_recoverable_error',
 };

 interface StepCheckResponse {
@@ -95,6 +97,7 @@ const TestAccountStep = () => {
 		setJustCompletedStepId,
 		sessionEntryPoint,
 		setSnackbar,
+		navigateToNextStep,
 	} = useOnboardingContext();

 	// Component State.
@@ -254,6 +257,24 @@ const TestAccountStep = () => {
 						}
 					} )
 					.catch( ( error ) => {
+						if (
+							error?.code ===
+							TEST_ACCOUNT_ERROR_CODES.NON_RECOVERABLE_ERROR
+						) {
+							// The test account could not be created and retrying can't fix it.
+							// The backend already marked the step completed, so notify the
+							// merchant and move them forward to the next step.
+							setSnackbar( {
+								show: true,
+								message: __(
+									"We couldn't create a test account, so we're taking you to the next step.",
+									'woocommerce'
+								),
+							} );
+							navigateToNextStep();
+							return;
+						}
+
 						setErrorCode( error?.code || '' );
 						setErrorMessage( error.message );
 						setStatus( 'error' );
@@ -407,6 +428,8 @@ const TestAccountStep = () => {
 		retryCounter,
 		pollingPhase,
 		setJustCompletedStepId,
+		setSnackbar,
+		navigateToNextStep,
 	] );

 	const getPhaseMessage = ( phase: number ) => {
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/test-account/test/index.test.tsx b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/test-account/test/index.test.tsx
new file mode 100644
index 00000000000..0ed0f5a5cfd
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/onboarding/providers/woopayments/steps/test-account/test/index.test.tsx
@@ -0,0 +1,138 @@
+/**
+ * External dependencies
+ */
+import { render, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import { useOnboardingContext } from '../../../data/onboarding-context';
+import TestAccountStep from '../index';
+
+jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+
+jest.mock( '../../../data/onboarding-context', () => ( {
+	useOnboardingContext: jest.fn(),
+} ) );
+
+jest.mock( '../../../components/header', () => ( {
+	__esModule: true,
+	// eslint-disable-next-line @typescript-eslint/no-unused-vars -- onClose is required by the component interface.
+	default: ( { onClose }: { onClose: () => void } ) => (
+		<div data-testid="step-header">Header</div>
+	),
+} ) );
+
+jest.mock( '@woocommerce/onboarding', () => {
+	const MockLoader = ( { children }: { children: React.ReactNode } ) => (
+		<div data-testid="loader">{ children }</div>
+	);
+	MockLoader.Layout = ( { children }: { children: React.ReactNode } ) => (
+		<div>{ children }</div>
+	);
+	MockLoader.Illustration = ( {
+		children,
+	}: {
+		children: React.ReactNode;
+	} ) => <div>{ children }</div>;
+	MockLoader.Title = ( { children }: { children: React.ReactNode } ) => (
+		<div>{ children }</div>
+	);
+	// eslint-disable-next-line @typescript-eslint/no-unused-vars -- progress is required by the component interface.
+	MockLoader.ProgressBar = ( { progress }: { progress: number } ) => (
+		<div data-testid="progress-bar" />
+	);
+	MockLoader.Sequence = ( { children }: { children: React.ReactNode } ) => (
+		<div>{ children }</div>
+	);
+	return { Loader: MockLoader };
+} );
+
+jest.mock( '@woocommerce/navigation', () => ( {
+	navigateTo: jest.fn(),
+	getNewPath: jest.fn( () => '' ),
+} ) );
+
+jest.mock( '~/settings-payments/utils', () => ( {
+	recordPaymentsOnboardingEvent: jest.fn(),
+} ) );
+
+jest.mock( '~/settings-payments/components/modals', () => ( {
+	WooPaymentsResetAccountModal: () => null,
+} ) );
+
+jest.mock( '~/utils/admin-settings', () => ( {
+	WC_ASSET_URL: '',
+} ) );
+
+const mockUseOnboardingContext = useOnboardingContext as jest.Mock;
+const mockApiFetch = apiFetch as jest.MockedFunction< typeof apiFetch >;
+
+const createMockContext = ( overrides: Record< string, unknown > = {} ) => ( {
+	currentStep: {
+		id: 'test_account',
+		status: 'not_started',
+		actions: {
+			init: { href: 'https://example.com/init' },
+			check: { href: 'https://example.com/check' },
+		},
+	},
+	closeModal: jest.fn(),
+	setJustCompletedStepId: jest.fn(),
+	sessionEntryPoint: 'settings',
+	setSnackbar: jest.fn(),
+	navigateToNextStep: jest.fn(),
+	...overrides,
+} );
+
+describe( 'TestAccountStep', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+	} );
+
+	it( 'advances to the next step and shows a snackbar on a non-recoverable init error', async () => {
+		const context = createMockContext();
+		mockUseOnboardingContext.mockReturnValue( context );
+		mockApiFetch.mockRejectedValue( {
+			code: 'woocommerce_woopayments_onboarding_test_account_non_recoverable_error',
+			message:
+				'A test account could not be created, but onboarding can continue without it.',
+		} );
+
+		render( <TestAccountStep /> );
+
+		await waitFor( () => {
+			expect( context.navigateToNextStep ).toHaveBeenCalled();
+		} );
+		expect( context.setSnackbar ).toHaveBeenCalledWith(
+			expect.objectContaining( { show: true } )
+		);
+		expect(
+			screen.queryByRole( 'button', { name: /try again/i } )
+		).not.toBeInTheDocument();
+	} );
+
+	it( 'shows the error notice with a retry action for other init errors', async () => {
+		const context = createMockContext();
+		mockUseOnboardingContext.mockReturnValue( context );
+		mockApiFetch.mockRejectedValue( {
+			code: 'some_other_error',
+			message: 'Something went wrong.',
+		} );
+
+		render( <TestAccountStep /> );
+
+		expect(
+			await screen.findByText( 'Something went wrong.', {
+				selector: 'p',
+			} )
+		).toBeInTheDocument();
+		expect(
+			screen.getByRole( 'button', { name: /try again/i } )
+		).toBeInTheDocument();
+		expect( context.navigateToNextStep ).not.toHaveBeenCalled();
+		expect( context.setSnackbar ).not.toHaveBeenCalled();
+	} );
+} );
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsService.php b/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsService.php
index 9bac46517d0..e423768242c 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsService.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsService.php
@@ -67,6 +67,18 @@ class WooPaymentsService {
 	 */
 	const ONBOARDING_STEP_STATUS_BLOCKED = 'blocked';

+	/**
+	 * Error identifiers for which retrying the test account initialization cannot succeed.
+	 *
+	 * Each entry is matched against both the error type and the error code reported by the
+	 * WooPayments extension, since either field can carry the identifying value.
+	 *
+	 * These are errors the merchant has no way of fixing on their end (e.g. Stripe rejecting
+	 * data that WooCommerce/WooPayments generates automatically), so instead of trapping the
+	 * merchant in a retry loop, we skip the step forward and let onboarding proceed.
+	 */
+	const ONBOARDING_TEST_ACCOUNT_NON_RECOVERABLE_ERROR_IDENTIFIERS = array( 'invalid_request_error' );
+
 	const ACTION_TYPE_REST     = 'REST';
 	const ACTION_TYPE_REDIRECT = 'REDIRECT';

@@ -478,8 +490,10 @@ class WooPaymentsService {
 	/**
 	 * Record an onboarding step as completed without re-checking whether a new onboarding action is allowed.
 	 *
-	 * This is for internal use only, by callers that have already committed an account mutation and
-	 * just need to sync the resulting NOX onboarding state. Unlike mark_onboarding_step_completed(),
+	 * This is for internal use only, by callers that just need to sync the NOX onboarding state
+	 * with the outcome of an already-settled operation — whether it succeeded (e.g. a committed
+	 * account mutation) or failed in a way that resolves the step (e.g. a non-recoverable
+	 * initialization error that skips the step forward). Unlike mark_onboarding_step_completed(),
 	 * it deliberately skips check_if_onboarding_step_action_is_acceptable() — including the shared
 	 * onboarding lock check — because the state change is the consequence of work that already
 	 * happened, not a new user action. Re-checking the lock here would let a concurrent request that
@@ -528,6 +542,117 @@ class WooPaymentsService {
 		return $result;
 	}

+	/**
+	 * Determine if a test account initialization error is non-recoverable for the merchant.
+	 *
+	 * The WooPayments extension surfaces the underlying error details in the REST error response
+	 * body, which Utils::rest_endpoint_post_request() stores as the WP_Error data.
+	 *
+	 * @param WP_Error $error The error returned by the test account initialization request.
+	 *
+	 * @return bool Whether the error is non-recoverable.
+	 */
+	private function is_test_account_init_error_non_recoverable( WP_Error $error ): bool {
+		$error_data = $error->get_error_data();
+		if ( ! is_array( $error_data ) ) {
+			return false;
+		}
+
+		$error_type = $error_data['data']['error_type'] ?? $error_data['error_type'] ?? null;
+		$error_code = $error_data['data']['error_code'] ?? $error_data['error_code'] ?? null;
+
+		/**
+		 * Filters the error identifiers for which retrying the WooPayments test account
+		 * initialization cannot succeed.
+		 *
+		 * Each identifier is matched against both the error type and the error code reported
+		 * by the WooPayments extension. When an initialization error matches, the test account
+		 * onboarding step is skipped forward instead of being marked as failed.
+		 *
+		 * @param string[] $identifiers The non-recoverable error identifiers.
+		 * @param WP_Error $error       The error returned by the test account initialization request.
+		 *
+		 * @since 11.1.0
+		 */
+		$non_recoverable_identifiers = (array) apply_filters(
+			'woocommerce_woopayments_onboarding_test_account_non_recoverable_errors',
+			self::ONBOARDING_TEST_ACCOUNT_NON_RECOVERABLE_ERROR_IDENTIFIERS,
+			$error
+		);
+
+		return in_array( $error_type, $non_recoverable_identifiers, true ) ||
+			in_array( $error_code, $non_recoverable_identifiers, true );
+	}
+
+	/**
+	 * Skip the test account onboarding step forward due to a non-recoverable failure.
+	 *
+	 * Marks the step completed (so onboarding can proceed), records an event, and throws
+	 * a dedicated exception so the client can inform the merchant and advance.
+	 *
+	 * Uses the internal record method (not mark_onboarding_step_completed()) because the
+	 * onboarding lock was already released and this state change is the consequence of the
+	 * failed initialization, not a new user action.
+	 *
+	 * @param string      $location The location for which we are onboarding.
+	 *                              This is an ISO 3166-1 alpha-2 country code.
+	 * @param string|null $source   The source for the current onboarding flow.
+	 * @param array       $context  Additional event context (e.g. reason, error code).
+	 *
+	 * @return void
+	 * @throws ApiException Always. With a dedicated error code signaling the non-recoverable failure
+	 *                      when the step completion was persisted, or with the regular client API
+	 *                      error code when it wasn't — so the client offers a retry instead of
+	 *                      advancing a merchant whose stored onboarding state didn't move.
+	 */
+	private function skip_onboarding_test_account_step( string $location, ?string $source, array $context = array() ): void {
+		$step_recorded = $this->record_onboarding_step_completed( self::ONBOARDING_STEP_TEST_ACCOUNT, $location, false, $source );
+		if ( ! $step_recorded ) {
+			// Leave a trail before falling back to the regular failure handling.
+			$this->proxy->call_function( 'wc_get_logger' )->error(
+				'Failed to record the test account onboarding step as completed while skipping it forward.',
+				array(
+					'source'   => 'settings-payments',
+					'location' => $location,
+				)
+			);
+
+			// Without the completed status persisted, advancing the client would desync it from
+			// the stored onboarding state. Fail the step instead: the client shows the regular
+			// error with a retry, and a later attempt can complete the skip.
+			$this->mark_onboarding_step_failed(
+				self::ONBOARDING_STEP_TEST_ACCOUNT,
+				$location,
+				array(
+					'code'    => 'skip_forward_not_persisted',
+					'message' => esc_html__( 'Failed to record the onboarding progress.', 'woocommerce' ),
+					'context' => $context,
+				)
+			);
+
+			throw new ApiException(
+				'woocommerce_woopayments_onboarding_client_api_error',
+				esc_html__( 'Failed to initialize the test account.', 'woocommerce' ),
+				(int) WP_Http::FAILED_DEPENDENCY
+			);
+		}
+
+		$this->record_event(
+			self::EVENT_PREFIX . 'onboarding_test_account_skipped',
+			$location,
+			array_merge(
+				array( 'source' => $this->validate_onboarding_source( $source ) ),
+				$context
+			)
+		);
+
+		throw new ApiException(
+			'woocommerce_woopayments_onboarding_test_account_non_recoverable_error',
+			esc_html__( 'A test account could not be created, but onboarding can continue without it.', 'woocommerce' ),
+			(int) WP_Http::FAILED_DEPENDENCY
+		);
+	}
+
 	/**
 	 * Cleans an onboarding step progress.
 	 *
@@ -1036,6 +1161,19 @@ class WooPaymentsService {
 		$this->clear_onboarding_lock();

 		if ( is_wp_error( $response ) ) {
+			// If the failure is non-recoverable from the merchant's perspective, don't trap
+			// them in a retry loop they can't win. Skip the step forward so onboarding can proceed.
+			if ( $this->is_test_account_init_error_non_recoverable( $response ) ) {
+				$this->skip_onboarding_test_account_step(
+					$location,
+					$source,
+					array(
+						'reason'     => 'non_recoverable_error',
+						'error_code' => $response->get_error_code(),
+					)
+				);
+			}
+
 			// Mark the onboarding step as failed.
 			$this->mark_onboarding_step_failed(
 				self::ONBOARDING_STEP_TEST_ACCOUNT,
@@ -1055,7 +1193,7 @@ class WooPaymentsService {
 			);
 		}

-		if ( ! is_array( $response ) || empty( $response['success'] ) ) {
+		if ( ! is_array( $response ) || ! array_key_exists( 'success', $response ) ) {
 			// Mark the onboarding step as failed.
 			$this->mark_onboarding_step_failed(
 				self::ONBOARDING_STEP_TEST_ACCOUNT,
@@ -1076,6 +1214,17 @@ class WooPaymentsService {
 			);
 		}

+		if ( empty( $response['success'] ) ) {
+			// The extension reported that a test account could not be created (e.g. the platform
+			// fell back to an account that requires verification). Retrying cannot succeed since
+			// an account exists by now. Skip the step forward so onboarding can proceed.
+			$this->skip_onboarding_test_account_step(
+				$location,
+				$source,
+				array( 'reason' => 'test_account_not_created' )
+			);
+		}
+
 		// Record an event for the test account being initialized.
 		$payment_methods_enabled  = array();
 		$payment_methods_disabled = array();
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsServiceTest.php
index 1f22d615559..b5f8eddc3d4 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsServiceTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/PaymentsProviders/WooPayments/WooPaymentsServiceTest.php
@@ -7229,6 +7229,319 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
 		$this->sut->onboarding_test_account_init( $location );
 	}

+	/**
+	 * @testdox Test account init skips the step forward when the initialization error is non-recoverable.
+	 *
+	 * @dataProvider provider_onboarding_test_account_init_non_recoverable_error_data
+	 *
+	 * @param array $error_data The WP_Error data attached to the initialization error.
+	 *
+	 * @return void
+	 */
+	public function test_onboarding_test_account_init_skips_step_on_non_recoverable_error( array $error_data ) {
+		$location = 'US';
+
+		// Arrange the WPCOM connection.
+		// Make it working.
+		$this->mock_wpcom_connection_manager
+			->expects( $this->any() )
+			->method( 'is_connected' )
+			->willReturn( true );
+		$this->mock_wpcom_connection_manager
+			->expects( $this->any() )
+			->method( 'has_connected_owner' )
+			->willReturn( true );
+
+		// Arrange the NOX profile.
+		$stored_profile          = array();
+		$updated_stored_profiles = array();
+		$this->mockable_proxy->register_function_mocks(
+			array(
+				'get_option'    => function ( $option_name, $default_value = null ) use ( &$updated_stored_profiles, $stored_profile ) {
+					if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+						return ! empty( $updated_stored_profiles ) ? end( $updated_stored_profiles ) : $stored_profile;
+					}
+
+					return $default_value;
+				},
+				'update_option' => function ( $option_name, $value ) use ( &$updated_stored_profiles ) {
+					if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+						$updated_stored_profiles[] = $value;
+					}
+
+					return true;
+				},
+			)
+		);
+
+		// Arrange the REST API request to fail with a non-recoverable error.
+		// The error shape mimics what Utils::rest_endpoint_post_request() produces when the
+		// WooPayments extension surfaces the underlying error details in the REST error response.
+		$this->mockable_proxy->register_static_mocks(
+			array(
+				Utils::class => array(
+					'rest_endpoint_post_request' => function ( string $endpoint, array $params = array() ) use ( $error_data ) {
+						// Avoid parameter not used PHPCS errors.
+						unset( $params );
+						if ( '/wc/v3/payments/onboarding/test_drive_account/init' === $endpoint ) {
+							return new WP_Error(
+								'woocommerce_settings_payments_rest_error',
+								"REST request POST failed with: (bad_request) The statement descriptor matches a common term or website URL, and can't be used.",
+								$error_data
+							);
+						}
+
+						throw new \Exception( esc_html( 'POST endpoint response is not mocked: ' . $endpoint ) );
+					},
+				),
+			)
+		);
+
+		try {
+			$this->sut->onboarding_test_account_init( $location );
+			$this->fail( 'Expected ApiException was not thrown.' );
+		} catch ( ApiException $e ) {
+			$this->assertSame(
+				'woocommerce_woopayments_onboarding_test_account_non_recoverable_error',
+				$e->getErrorCode(),
+				'A dedicated error code should signal the non-recoverable failure to the client.'
+			);
+		}
+
+		$this->assertNotEmpty( $updated_stored_profiles );
+		$final_profile = end( $updated_stored_profiles );
+		$statuses      = $final_profile['onboarding'][ $location ]['steps'][ WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT ]['statuses'];
+		$this->assertArrayHasKey( WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED, $statuses, 'The step should be marked completed so onboarding can proceed.' );
+		$this->assertArrayNotHasKey( WooPaymentsService::ONBOARDING_STEP_STATUS_FAILED, $statuses, 'The step should not be marked failed.' );
+	}
+
+	/**
+	 * Data provider for the non-recoverable initialization error shapes.
+	 *
+	 * The shapes mimic what Utils::rest_endpoint_post_request() produces, depending on where
+	 * the WooPayments extension surfaces the error details in the REST error response.
+	 *
+	 * @return array<string, array<array>>
+	 */
+	public function provider_onboarding_test_account_init_non_recoverable_error_data(): array {
+		return array(
+			'error type and code in nested data' => array(
+				array(
+					'code'    => 'bad_request',
+					'message' => "The statement descriptor matches a common term or website URL, and can't be used.",
+					'data'    => array(
+						'status'     => 400,
+						'error_type' => 'invalid_request_error',
+						'error_code' => 'invalid_request_error',
+					),
+				),
+			),
+			'error code only in nested data'     => array(
+				array(
+					'code'    => 'bad_request',
+					'message' => "The statement descriptor matches a common term or website URL, and can't be used.",
+					'data'    => array(
+						'status'     => 400,
+						'error_code' => 'invalid_request_error',
+					),
+				),
+			),
+			'error type only in nested data'     => array(
+				array(
+					'code'    => 'bad_request',
+					'message' => "The statement descriptor matches a common term or website URL, and can't be used.",
+					'data'    => array(
+						'status'     => 400,
+						'error_type' => 'invalid_request_error',
+					),
+				),
+			),
+			'error type at top level'            => array(
+				array(
+					'error_type' => 'invalid_request_error',
+				),
+			),
+			'error code at top level'            => array(
+				array(
+					'error_code' => 'invalid_request_error',
+				),
+			),
+		);
+	}
+
+	/**
+	 * @testdox Test account init treats filter-added error identifiers as non-recoverable.
+	 *
+	 * @return void
+	 */
+	public function test_onboarding_test_account_init_non_recoverable_errors_are_filterable() {
+		$location = 'US';
+
+		// Arrange the WPCOM connection.
+		// Make it working.
+		$this->mock_wpcom_connection_manager
+			->expects( $this->any() )
+			->method( 'is_connected' )
+			->willReturn( true );
+		$this->mock_wpcom_connection_manager
+			->expects( $this->any() )
+			->method( 'has_connected_owner' )
+			->willReturn( true );
+
+		// Arrange the NOX profile.
+		$stored_profile          = array();
+		$updated_stored_profiles = array();
+		$this->mockable_proxy->register_function_mocks(
+			array(
+				'get_option'    => function ( $option_name, $default_value = null ) use ( &$updated_stored_profiles, $stored_profile ) {
+					if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+						return ! empty( $updated_stored_profiles ) ? end( $updated_stored_profiles ) : $stored_profile;
+					}
+
+					return $default_value;
+				},
+				'update_option' => function ( $option_name, $value ) use ( &$updated_stored_profiles ) {
+					if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+						$updated_stored_profiles[] = $value;
+					}
+
+					return true;
+				},
+			)
+		);
+
+		// Arrange the REST API request to fail with an error type that is only
+		// non-recoverable because the filter below adds it to the list.
+		$this->mockable_proxy->register_static_mocks(
+			array(
+				Utils::class => array(
+					'rest_endpoint_post_request' => function ( string $endpoint, array $params = array() ) {
+						// Avoid parameter not used PHPCS errors.
+						unset( $params );
+						if ( '/wc/v3/payments/onboarding/test_drive_account/init' === $endpoint ) {
+							return new WP_Error(
+								'woocommerce_settings_payments_rest_error',
+								'REST request POST failed with: (bad_request) Some custom platform failure.',
+								array(
+									'code'    => 'bad_request',
+									'message' => 'Some custom platform failure.',
+									'data'    => array(
+										'status'     => 400,
+										'error_type' => 'custom_blocker_error',
+									),
+								)
+							);
+						}
+
+						throw new \Exception( esc_html( 'POST endpoint response is not mocked: ' . $endpoint ) );
+					},
+				),
+			)
+		);
+
+		add_filter(
+			'woocommerce_woopayments_onboarding_test_account_non_recoverable_errors',
+			function ( array $identifiers ) {
+				$identifiers[] = 'custom_blocker_error';
+
+				return $identifiers;
+			}
+		);
+
+		try {
+			$this->sut->onboarding_test_account_init( $location );
+			$this->fail( 'Expected ApiException was not thrown.' );
+		} catch ( ApiException $e ) {
+			$this->assertSame(
+				'woocommerce_woopayments_onboarding_test_account_non_recoverable_error',
+				$e->getErrorCode(),
+				'The filter-added error identifier should be treated as non-recoverable.'
+			);
+		}
+
+		$this->assertNotEmpty( $updated_stored_profiles );
+		$final_profile = end( $updated_stored_profiles );
+		$statuses      = $final_profile['onboarding'][ $location ]['steps'][ WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT ]['statuses'];
+		$this->assertArrayHasKey( WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED, $statuses, 'The step should be marked completed so onboarding can proceed.' );
+		$this->assertArrayNotHasKey( WooPaymentsService::ONBOARDING_STEP_STATUS_FAILED, $statuses, 'The step should not be marked failed.' );
+	}
+
+	/**
+	 * @testdox Test account init marks the step failed when the response is malformed.
+	 *
+	 * @return void
+	 */
+	public function test_onboarding_test_account_init_throws_on_malformed_response() {
+		$location = 'US';
+
+		// Arrange the WPCOM connection.
+		// Make it working.
+		$this->mock_wpcom_connection_manager
+			->expects( $this->any() )
+			->method( 'is_connected' )
+			->willReturn( true );
+		$this->mock_wpcom_connection_manager
+			->expects( $this->any() )
+			->method( 'has_connected_owner' )
+			->willReturn( true );
+
+		// Arrange the NOX profile.
+		$stored_profile          = array();
+		$updated_stored_profiles = array();
+		$this->mockable_proxy->register_function_mocks(
+			array(
+				'get_option'    => function ( $option_name, $default_value = null ) use ( &$updated_stored_profiles, $stored_profile ) {
+					if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+						return ! empty( $updated_stored_profiles ) ? end( $updated_stored_profiles ) : $stored_profile;
+					}
+
+					return $default_value;
+				},
+				'update_option' => function ( $option_name, $value ) use ( &$updated_stored_profiles ) {
+					if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+						$updated_stored_profiles[] = $value;
+					}
+
+					return true;
+				},
+			)
+		);
+
+		// Arrange the REST API request to return a response without a `success` entry.
+		$this->mockable_proxy->register_static_mocks(
+			array(
+				Utils::class => array(
+					'rest_endpoint_post_request' => function ( string $endpoint, array $params = array() ) {
+						// Avoid parameter not used PHPCS errors.
+						unset( $params );
+						if ( '/wc/v3/payments/onboarding/test_drive_account/init' === $endpoint ) {
+							return array( 'status' => 'ok' );
+						}
+
+						throw new \Exception( esc_html( 'POST endpoint response is not mocked: ' . $endpoint ) );
+					},
+				),
+			)
+		);
+
+		try {
+			$this->sut->onboarding_test_account_init( $location );
+			$this->fail( 'Expected ApiException was not thrown.' );
+		} catch ( ApiException $e ) {
+			$this->assertSame(
+				'woocommerce_woopayments_onboarding_client_api_error',
+				$e->getErrorCode(),
+				'A malformed response should keep the existing failure behavior.'
+			);
+		}
+
+		$this->assertNotEmpty( $updated_stored_profiles );
+		$final_profile = end( $updated_stored_profiles );
+		$statuses      = $final_profile['onboarding'][ $location ]['steps'][ WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT ]['statuses'];
+		$this->assertArrayHasKey( WooPaymentsService::ONBOARDING_STEP_STATUS_FAILED, $statuses, 'The step should be marked failed on a malformed response.' );
+		$this->assertArrayNotHasKey( WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED, $statuses, 'The step should not be marked completed.' );
+	}
+
 	/**
 	 * Test that error sanitization moves extra keys to context when storing a step error.
 	 *
@@ -7780,11 +8093,11 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * Test onboarding_test_account_init that throws an exception when the REST API call doesn't return success.
+	 * @testdox Test account init skips the step forward when the extension reports the test account was not created.
 	 *
 	 * @return void
 	 */
-	public function test_onboarding_test_account_init_throws_on_not_successful() {
+	public function test_onboarding_test_account_init_skips_step_when_test_account_not_created() {
 		$location = 'US';

 		// Arrange the WPCOM connection.
@@ -7799,28 +8112,20 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
 			->willReturn( true );

 		// Arrange the NOX profile.
-		$step_id                 = WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT;
 		$stored_profile          = array();
 		$updated_stored_profiles = array();
 		$this->mockable_proxy->register_function_mocks(
 			array(
-				'get_option'    => function ( $option_name, $default_value = null ) use ( $stored_profile ) {
+				'get_option'    => function ( $option_name, $default_value = null ) use ( &$updated_stored_profiles, $stored_profile ) {
 					if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
-						return $stored_profile;
+						return ! empty( $updated_stored_profiles ) ? end( $updated_stored_profiles ) : $stored_profile;
 					}

 					return $default_value;
 				},
-				'update_option' => function ( $option_name, $value ) use ( $stored_profile, &$updated_stored_profiles ) {
+				'update_option' => function ( $option_name, $value ) use ( &$updated_stored_profiles ) {
 					if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
 						$updated_stored_profiles[] = $value;
-
-						// Mimic the behavior of the original function.
-						if ( $value === $stored_profile || maybe_serialize( $value ) === maybe_serialize( $stored_profile ) ) {
-							return false;
-						}
-
-						return true;
 					}

 					return true;
@@ -7848,12 +8153,135 @@ class WooPaymentsServiceTest extends WC_Unit_Test_Case {
 			)
 		);

-		// Assert.
-		$this->expectException( \Exception::class );
-		$this->expectExceptionMessage( esc_html__( 'Failed to initialize the test account.', 'woocommerce' ) );
+		try {
+			$this->sut->onboarding_test_account_init( $location );
+			$this->fail( 'Expected ApiException was not thrown.' );
+		} catch ( ApiException $e ) {
+			$this->assertSame(
+				'woocommerce_woopayments_onboarding_test_account_non_recoverable_error',
+				$e->getErrorCode(),
+				'A dedicated error code should signal the non-recoverable failure to the client.'
+			);
+		}

-		// Act.
-		$this->sut->onboarding_test_account_init( $location );
+		$this->assertCount( 1, $requests_made, 'The test account initialization request should be made exactly once.' );
+
+		$this->assertNotEmpty( $updated_stored_profiles );
+		$final_profile = end( $updated_stored_profiles );
+		$statuses      = $final_profile['onboarding'][ $location ]['steps'][ WooPaymentsService::ONBOARDING_STEP_TEST_ACCOUNT ]['statuses'];
+		$this->assertArrayHasKey( WooPaymentsService::ONBOARDING_STEP_STATUS_COMPLETED, $statuses, 'The step should be marked completed so onboarding can proceed.' );
+		$this->assertArrayNotHasKey( WooPaymentsService::ONBOARDING_STEP_STATUS_FAILED, $statuses, 'The step should not be marked failed.' );
+	}
+
+	/**
+	 * @testdox Test account init falls back to the failure path when the skipped step completion fails to persist.
+	 *
+	 * @return void
+	 */
+	public function test_onboarding_test_account_init_skip_step_falls_back_to_failure_when_completion_not_persisted() {
+		$location = 'US';
+
+		// Arrange the WPCOM connection.
+		// Make it working.
+		$this->mock_wpcom_connection_manager
+			->expects( $this->any() )
+			->method( 'is_connected' )
+			->willReturn( true );
+		$this->mock_wpcom_connection_manager
+			->expects( $this->any() )
+			->method( 'has_connected_owner' )
+			->willReturn( true );
+
+		// Arrange a logger that captures error calls.
+		$logged_errors = array();
+		$mock_logger   = new class( $logged_errors ) {
+			/**
+			 * The captured error calls.
+			 *
+			 * @var array
+			 */
+			public $errors;
+
+			/**
+			 * Constructor.
+			 *
+			 * @param array $errors The captured error calls, by reference.
+			 */
+			public function __construct( array &$errors ) {
+				$this->errors = &$errors;
+			}
+
+			/**
+			 * Capture an error log call.
+			 *
+			 * @param string $message The log message.
+			 * @param array  $context The log context.
+			 *
+			 * @return void
+			 */
+			public function error( string $message, array $context = array() ) {
+				$this->errors[] = array(
+					'message' => $message,
+					'context' => $context,
+				);
+			}
+		};
+
+		// Arrange the NOX profile so that persisting the profile fails.
+		$this->mockable_proxy->register_function_mocks(
+			array(
+				'get_option'    => function ( $option_name, $default_value = null ) {
+					if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+						return array();
+					}
+
+					return $default_value;
+				},
+				'update_option' => function ( $option_name, $value ) {
+					// Avoid parameter not used PHPCS errors.
+					unset( $value );
+					if ( WooPaymentsService::NOX_PROFILE_OPTION_KEY === $option_name ) {
+						return false;
+					}
+
+					return true;
+				},
+				'wc_get_logger' => function () use ( $mock_logger ) {
+					return $mock_logger;
+				},
+			)
+		);
+
+		// Arrange the REST API request to report that the test account was not created.
+		$this->mockable_proxy->register_static_mocks(
+			array(
+				Utils::class => array(
+					'rest_endpoint_post_request' => function ( string $endpoint, array $params = array() ) {
+						// Avoid parameter not used PHPCS errors.
+						unset( $params );
+						if ( '/wc/v3/payments/onboarding/test_drive_account/init' === $endpoint ) {
+							return array( 'success' => false );
+						}
+
+						throw new \Exception( esc_html( 'POST endpoint response is not mocked: ' . $endpoint ) );
+					},
+				),
+			)
+		);
+
+		try {
+			$this->sut->onboarding_test_account_init( $location );
+			$this->fail( 'Expected ApiException was not thrown.' );
+		} catch ( ApiException $e ) {
+			$this->assertSame(
+				'woocommerce_woopayments_onboarding_client_api_error',
+				$e->getErrorCode(),
+				'Without the completion persisted, the client should see the regular failure (and retry) instead of being advanced.'
+			);
+		}
+
+		$this->assertNotEmpty( $logged_errors, 'A persistence failure of the skipped step completion should be logged.' );
+		$this->assertStringContainsString( 'Failed to record the test account onboarding step', $logged_errors[0]['message'] );
 	}

 	/**