Commit 0666355bad3 for woocommerce

commit 0666355bad3edfefcb2653b73471a3f5eb1f3307
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date:   Thu Jun 25 10:31:56 2026 +0800

    Add failed order imports notice with retry to Import historical data (#65509)

    * feat: persist failed analytics order import IDs in OrdersScheduler

    * feat: record failed order IDs when analytics batch import skips an order

    * feat: clear failed analytics import record on successful order import

    * feat: reset failed import records on analytics data delete and regenerate

    * feat: expose failed import counts in analytics imports status API

    * feat: add retry-failed endpoint for analytics order imports

    * feat: register AnalyticsImports REST controller unconditionally

    * feat: add failed orders notice component for historical data import

    * feat: show failed order imports in the import historical data section

    * fix: improve accessibility and i18n notes for failed orders notice

    * chore: add changelog for failed order imports UI

    * fix: align analytics imports API mode detection with OrdersScheduler

    * fix: improve failed orders notice spacing and link to import log

    * fix: improve failed order imports retry error reporting and accuracy

    * fix: harden failed imports option read and test filter cleanup

diff --git a/plugins/woocommerce/changelog/add-65454-list-failed-order-imports b/plugins/woocommerce/changelog/add-65454-list-failed-order-imports
new file mode 100644
index 00000000000..43a12d1a542
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-65454-list-failed-order-imports
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Analytics: list failed order imports in the Import historical data section with a retry action.
diff --git a/plugins/woocommerce/client/admin/client/analytics/settings/historical-data/failed-orders-notice.tsx b/plugins/woocommerce/client/admin/client/analytics/settings/historical-data/failed-orders-notice.tsx
new file mode 100644
index 00000000000..0a442b33b0f
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/analytics/settings/historical-data/failed-orders-notice.tsx
@@ -0,0 +1,158 @@
+/**
+ * External dependencies
+ */
+import { __, _n, sprintf } from '@wordpress/i18n';
+import {
+	createInterpolateElement,
+	useCallback,
+	useEffect,
+	useState,
+} from '@wordpress/element';
+import apiFetch from '@wordpress/api-fetch';
+import { Button, Notice } from '@wordpress/components';
+import { useDispatch } from '@wordpress/data';
+import { getAdminLink } from '@woocommerce/settings';
+
+const LOG_URL_PATH =
+	'admin.php?page=wc-status&tab=logs&source=wc-analytics-order-import';
+
+interface FailedImportsStatus {
+	failed_count: number;
+	failed_overflow_count: number;
+}
+
+interface RetryFailedResponse {
+	success: boolean;
+	message: string;
+	retried_count: number;
+	pruned_count: number;
+	already_scheduled_count: number;
+	error_count: number;
+}
+
+/**
+ * Extract a user-facing message from a caught request error.
+ *
+ * `@wordpress/api-fetch` rejects with the parsed REST error object
+ * (`{ code, message }`), which is a plain object — not an `Error` instance —
+ * so narrow on the `message` property instead of the constructor.
+ */
+function getErrorMessage( err: unknown, fallback: string ): string {
+	if (
+		typeof err === 'object' &&
+		err !== null &&
+		'message' in err &&
+		typeof err.message === 'string' &&
+		err.message !== ''
+	) {
+		return err.message;
+	}
+	return fallback;
+}
+
+/**
+ * Shows a warning when some orders failed to import into analytics, with a
+ * button to schedule a re-import of just those orders.
+ *
+ * Renders nothing when there are no recorded failures or when the status
+ * request fails (the notice is an auxiliary affordance).
+ */
+function FailedOrdersNotice() {
+	const [ status, setStatus ] = useState< FailedImportsStatus | null >(
+		null
+	);
+	const [ isRetrying, setIsRetrying ] = useState( false );
+	const { createNotice } = useDispatch( 'core/notices' );
+
+	const fetchStatus = useCallback( async () => {
+		try {
+			const data = await apiFetch< FailedImportsStatus >( {
+				path: '/wc-analytics/imports/status',
+			} );
+			setStatus( data );
+		} catch ( err ) {
+			// Fail silently — the notice is an auxiliary affordance.
+		}
+	}, [] );
+
+	useEffect( () => {
+		fetchStatus();
+	}, [ fetchStatus ] );
+
+	const failedCount = status?.failed_count ?? 0;
+	const overflowCount = status?.failed_overflow_count ?? 0;
+
+	if ( failedCount === 0 ) {
+		return null;
+	}
+
+	const handleRetry = async () => {
+		setIsRetrying( true );
+		try {
+			const response = await apiFetch< RetryFailedResponse >( {
+				path: '/wc-analytics/imports/retry-failed',
+				method: 'POST',
+			} );
+			createNotice( 'success', response.message );
+			await fetchStatus();
+		} catch ( err ) {
+			createNotice(
+				'error',
+				getErrorMessage(
+					err,
+					__( 'Failed to retry order imports.', 'woocommerce' )
+				)
+			);
+		} finally {
+			setIsRetrying( false );
+		}
+	};
+
+	const logLink = (
+		<a
+			href={ getAdminLink( LOG_URL_PATH ) }
+			aria-label={ __( 'View the order import log', 'woocommerce' ) }
+		/>
+	);
+
+	const template =
+		overflowCount > 0
+			? /* translators: %d: number of failed orders currently stored (additional failures were dropped past the storage limit). <link> is a link to the order import log. */
+			  __(
+					'More than %d orders failed to import. To recover all missed orders, run the import above with "Skip previously imported customers and orders" checked. <link>View the log</link> for details.',
+					'woocommerce'
+			  )
+			: /* translators: %d: number of failed orders. <link> is a link to the order import log. */
+			  _n(
+					'%d order failed to import. <link>View the log</link> for details.',
+					'%d orders failed to import. <link>View the log</link> for details.',
+					failedCount,
+					'woocommerce'
+			  );
+
+	const message = createInterpolateElement(
+		sprintf( template, failedCount ),
+		{ link: logLink }
+	);
+
+	return (
+		<Notice
+			className="woocommerce-settings-historical-data__failed-orders"
+			status="warning"
+			isDismissible={ false }
+		>
+			<p>{ message }</p>
+			<Button
+				variant="secondary"
+				isBusy={ isRetrying }
+				disabled={ isRetrying }
+				aria-disabled={ isRetrying }
+				onClick={ handleRetry }
+			>
+				{ __( 'Retry failed imports', 'woocommerce' ) }
+			</Button>
+		</Notice>
+	);
+}
+
+export default FailedOrdersNotice;
diff --git a/plugins/woocommerce/client/admin/client/analytics/settings/historical-data/layout.js b/plugins/woocommerce/client/admin/client/analytics/settings/historical-data/layout.js
index 3b716c372ee..5f994b1fdbb 100644
--- a/plugins/woocommerce/client/admin/client/analytics/settings/historical-data/layout.js
+++ b/plugins/woocommerce/client/admin/client/analytics/settings/historical-data/layout.js
@@ -17,6 +17,7 @@ import HistoricalDataPeriodSelector from './period-selector';
 import HistoricalDataProgress from './progress';
 import HistoricalDataStatus from './status';
 import HistoricalDataSkipCheckbox from './skip-checkbox';
+import FailedOrdersNotice from './failed-orders-notice';
 import './style.scss';

 class HistoricalDataLayout extends Component {
@@ -86,6 +87,7 @@ class HistoricalDataLayout extends Component {
 								importDate={ importDate }
 								status={ status }
 							/>
+							<FailedOrdersNotice />
 						</div>
 					</div>
 				</div>
diff --git a/plugins/woocommerce/client/admin/client/analytics/settings/historical-data/style.scss b/plugins/woocommerce/client/admin/client/analytics/settings/historical-data/style.scss
index 9393018b652..3b426d84c74 100644
--- a/plugins/woocommerce/client/admin/client/analytics/settings/historical-data/style.scss
+++ b/plugins/woocommerce/client/admin/client/analytics/settings/historical-data/style.scss
@@ -111,3 +111,11 @@
 	align-items: center;
 	display: flex;
 }
+
+.woocommerce-settings-historical-data__failed-orders {
+	margin: $gap 0 0;
+
+	.components-notice__content > p {
+		margin-top: 0;
+	}
+}
diff --git a/plugins/woocommerce/client/admin/client/analytics/settings/historical-data/test/failed-orders-notice.test.tsx b/plugins/woocommerce/client/admin/client/analytics/settings/historical-data/test/failed-orders-notice.test.tsx
new file mode 100644
index 00000000000..275df366b04
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/analytics/settings/historical-data/test/failed-orders-notice.test.tsx
@@ -0,0 +1,266 @@
+/**
+ * External dependencies
+ */
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import FailedOrdersNotice from '../failed-orders-notice';
+
+jest.mock( '@wordpress/api-fetch' );
+
+const mockCreateNotice = jest.fn();
+jest.mock( '@wordpress/data', () => ( {
+	useDispatch: jest.fn( () => ( {
+		createNotice: mockCreateNotice,
+	} ) ),
+} ) );
+
+jest.mock( '@woocommerce/settings', () => ( {
+	getAdminLink: jest.fn(
+		( path: string ) => `https://example.com/wp-admin/${ path }`
+	),
+} ) );
+
+jest.mock( '@wordpress/components', () => ( {
+	Notice: ( { children }: { children: React.ReactNode } ) => (
+		<div role="status">{ children }</div>
+	),
+	Button: ( {
+		children,
+		onClick,
+		disabled,
+		'aria-disabled': ariaDisabled,
+	}: {
+		children: React.ReactNode;
+		onClick?: () => void;
+		disabled?: boolean;
+		'aria-disabled'?: boolean;
+	} ) => (
+		<button
+			onClick={ onClick }
+			disabled={ disabled }
+			aria-disabled={ ariaDisabled }
+		>
+			{ children }
+		</button>
+	),
+} ) );
+
+const mockedApiFetch = apiFetch as jest.MockedFunction< typeof apiFetch >;
+
+describe( 'FailedOrdersNotice', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+	} );
+
+	it( 'renders nothing when there are no failed orders', async () => {
+		mockedApiFetch.mockResolvedValue( {
+			failed_count: 0,
+			failed_overflow_count: 0,
+		} );
+
+		const { container } = render( <FailedOrdersNotice /> );
+
+		await waitFor( () =>
+			expect( mockedApiFetch ).toHaveBeenCalledWith( {
+				path: '/wc-analytics/imports/status',
+			} )
+		);
+		expect( container ).toBeEmptyDOMElement();
+	} );
+
+	it( 'renders nothing when the status request fails', async () => {
+		mockedApiFetch.mockRejectedValue( new Error( 'request failed' ) );
+
+		const { container } = render( <FailedOrdersNotice /> );
+
+		await waitFor( () => expect( mockedApiFetch ).toHaveBeenCalled() );
+		expect( container ).toBeEmptyDOMElement();
+	} );
+
+	it( 'shows the failed count and a retry button', async () => {
+		mockedApiFetch.mockResolvedValue( {
+			failed_count: 3,
+			failed_overflow_count: 0,
+		} );
+
+		render( <FailedOrdersNotice /> );
+
+		expect(
+			await screen.findByText( /3 orders failed to import/ )
+		).toBeInTheDocument();
+		expect(
+			screen.getByRole( 'button', { name: 'Retry failed imports' } )
+		).toBeInTheDocument();
+		expect(
+			screen.getByRole( 'link', { name: 'View the order import log' } )
+		).toHaveAttribute(
+			'href',
+			'https://example.com/wp-admin/admin.php?page=wc-status&tab=logs&source=wc-analytics-order-import'
+		);
+	} );
+
+	it( 'shows overflow guidance when the stored list overflowed', async () => {
+		mockedApiFetch.mockResolvedValue( {
+			failed_count: 1000,
+			failed_overflow_count: 5,
+		} );
+
+		render( <FailedOrdersNotice /> );
+
+		expect(
+			await screen.findByText( /More than 1000 orders failed to import/ )
+		).toBeInTheDocument();
+	} );
+
+	it( 'schedules a retry and shows a success notice', async () => {
+		mockedApiFetch.mockImplementation( ( options ) => {
+			if (
+				( options as { path?: string } ).path ===
+				'/wc-analytics/imports/retry-failed'
+			) {
+				return Promise.resolve( {
+					success: true,
+					message: 'Re-import scheduled for 3 orders.',
+					retried_count: 3,
+					pruned_count: 0,
+					already_scheduled_count: 0,
+					error_count: 0,
+				} );
+			}
+			return Promise.resolve( {
+				failed_count: 3,
+				failed_overflow_count: 0,
+			} );
+		} );
+
+		render( <FailedOrdersNotice /> );
+
+		await userEvent.click(
+			await screen.findByRole( 'button', {
+				name: 'Retry failed imports',
+			} )
+		);
+
+		await waitFor( () =>
+			expect( mockedApiFetch ).toHaveBeenCalledWith( {
+				path: '/wc-analytics/imports/retry-failed',
+				method: 'POST',
+			} )
+		);
+		await waitFor( () =>
+			expect( mockCreateNotice ).toHaveBeenCalledWith(
+				'success',
+				'Re-import scheduled for 3 orders.'
+			)
+		);
+	} );
+
+	it( 'shows the server message when the retry request rejects with a REST error object', async () => {
+		mockedApiFetch.mockImplementation( ( options ) => {
+			if (
+				( options as { path?: string } ).path ===
+				'/wc-analytics/imports/retry-failed'
+			) {
+				// @wordpress/api-fetch rejects with the parsed REST error
+				// object — a plain object, not an Error instance.
+				return Promise.reject( {
+					code: 'woocommerce_rest_analytics_retry_failed',
+					message:
+						'The failed orders could not be scheduled for re-import. Check the order import log for details.',
+				} );
+			}
+			return Promise.resolve( {
+				failed_count: 3,
+				failed_overflow_count: 0,
+			} );
+		} );
+
+		render( <FailedOrdersNotice /> );
+
+		await userEvent.click(
+			await screen.findByRole( 'button', {
+				name: 'Retry failed imports',
+			} )
+		);
+
+		await waitFor( () =>
+			expect( mockCreateNotice ).toHaveBeenCalledWith(
+				'error',
+				'The failed orders could not be scheduled for re-import. Check the order import log for details.'
+			)
+		);
+	} );
+
+	it( 'shows a fallback message when the retry rejection has no message', async () => {
+		mockedApiFetch.mockImplementation( ( options ) => {
+			if (
+				( options as { path?: string } ).path ===
+				'/wc-analytics/imports/retry-failed'
+			) {
+				return Promise.reject( { code: 'fetch_error' } );
+			}
+			return Promise.resolve( {
+				failed_count: 3,
+				failed_overflow_count: 0,
+			} );
+		} );
+
+		render( <FailedOrdersNotice /> );
+
+		await userEvent.click(
+			await screen.findByRole( 'button', {
+				name: 'Retry failed imports',
+			} )
+		);
+
+		await waitFor( () =>
+			expect( mockCreateNotice ).toHaveBeenCalledWith(
+				'error',
+				'Failed to retry order imports.'
+			)
+		);
+	} );
+
+	it( 'disables the retry button while the retry request is in flight', async () => {
+		let resolveRetry: ( value: unknown ) => void = () => {};
+		mockedApiFetch.mockImplementation( ( options ) => {
+			if (
+				( options as { path?: string } ).path ===
+				'/wc-analytics/imports/retry-failed'
+			) {
+				return new Promise( ( resolve ) => {
+					resolveRetry = resolve;
+				} );
+			}
+			return Promise.resolve( {
+				failed_count: 3,
+				failed_overflow_count: 0,
+			} );
+		} );
+
+		render( <FailedOrdersNotice /> );
+
+		const button = await screen.findByRole( 'button', {
+			name: 'Retry failed imports',
+		} );
+		await userEvent.click( button );
+
+		expect( button ).toBeDisabled();
+
+		resolveRetry( {
+			success: true,
+			message: 'Re-import scheduled for 3 orders.',
+			retried_count: 3,
+			pruned_count: 0,
+			already_scheduled_count: 0,
+			error_count: 0,
+		} );
+
+		await waitFor( () => expect( button ).not.toBeDisabled() );
+	} );
+} );
diff --git a/plugins/woocommerce/src/Admin/API/AnalyticsImports.php b/plugins/woocommerce/src/Admin/API/AnalyticsImports.php
index 29af57da626..23d487aa384 100644
--- a/plugins/woocommerce/src/Admin/API/AnalyticsImports.php
+++ b/plugins/woocommerce/src/Admin/API/AnalyticsImports.php
@@ -65,6 +65,19 @@ class AnalyticsImports extends \WC_REST_Data_Controller {
 				'schema' => array( $this, 'get_trigger_schema' ),
 			)
 		);
+
+		register_rest_route(
+			$this->namespace,
+			'/' . $this->rest_base . '/retry-failed',
+			array(
+				array(
+					'methods'             => \WP_REST_Server::CREATABLE,
+					'callback'            => array( $this, 'retry_failed_imports' ),
+					'permission_callback' => array( $this, 'permissions_check' ),
+				),
+				'schema' => array( $this, 'get_retry_failed_schema' ),
+			)
+		);
 	}

 	/**
@@ -95,11 +108,15 @@ class AnalyticsImports extends \WC_REST_Data_Controller {
 		$is_scheduled_mode = $this->is_scheduled_import_enabled();
 		$mode              = $is_scheduled_mode ? 'scheduled' : 'immediate';

+		$failed_imports = OrdersScheduler::get_failed_order_imports();
+
 		$response = array(
 			'mode'                      => $mode,
 			'last_processed_date'       => null,
 			'next_scheduled'            => null,
 			'import_in_progress_or_due' => null,
+			'failed_count'              => count( $failed_imports['ids'] ),
+			'failed_overflow_count'     => $failed_imports['overflow'],
 		);

 		// For scheduled mode, populate additional fields.
@@ -161,13 +178,167 @@ class AnalyticsImports extends \WC_REST_Data_Controller {
 		);
 	}

+	/**
+	 * Re-schedule imports for orders that previously failed.
+	 *
+	 * Order IDs whose orders no longer exist are pruned (they can never import
+	 * successfully). Orders with an import already pending are skipped and
+	 * reported separately, so repeated requests don't claim to schedule new
+	 * work. The remaining IDs stay recorded until their import succeeds, so a
+	 * retry that fails again remains visible.
+	 *
+	 * @param  \WP_REST_Request<array<string, mixed>> $request Full details about the request.
+	 * @return \WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+	 */
+	public function retry_failed_imports( $request ) {
+		$failed = OrdersScheduler::get_failed_order_imports();
+
+		if ( empty( $failed['ids'] ) ) {
+			return new WP_Error(
+				'woocommerce_rest_analytics_no_failed_imports',
+				__( 'There are no failed order imports to retry.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		$retried_count           = 0;
+		$pruned_count            = 0;
+		$already_scheduled_count = 0;
+		$error_count             = 0;
+		foreach ( $failed['ids'] as $order_id ) {
+			if ( ! wc_get_order( $order_id ) ) {
+				OrdersScheduler::clear_failed_order_import( $order_id );
+				++$pruned_count;
+				continue;
+			}
+
+			// schedule_action() silently no-ops when the same import is
+			// already pending, so check first to report an accurate count.
+			if ( OrdersScheduler::has_existing_jobs( 'import', array( $order_id ) ) ) {
+				++$already_scheduled_count;
+				continue;
+			}
+
+			try {
+				OrdersScheduler::schedule_action( 'import', array( $order_id ) );
+				++$retried_count;
+			} catch ( \Throwable $e ) {
+				// schedule_action() may run the import synchronously (e.g. when
+				// Action Scheduler is unavailable); a failing order must not
+				// abort the whole retry request.
+				++$error_count;
+				wc_get_logger()->error(
+					sprintf( 'Failed to schedule analytics re-import for order %d: %s', $order_id, $e->getMessage() ),
+					array( 'source' => 'wc-analytics-order-import' )
+				);
+			}
+		}
+
+		// Nothing was scheduled and nothing is pending: surface the failure
+		// instead of reporting success for work that didn't happen.
+		if ( 0 === $retried_count && 0 === $already_scheduled_count && $error_count > 0 ) {
+			return new WP_Error(
+				'woocommerce_rest_analytics_retry_failed',
+				__( 'The failed orders could not be scheduled for re-import. Check the order import log for details.', 'woocommerce' ),
+				array( 'status' => 500 )
+			);
+		}
+
+		if ( $retried_count > 0 ) {
+			$message = sprintf(
+				/* translators: %d: number of orders scheduled for re-import */
+				_n( 'Re-import scheduled for %d order.', 'Re-import scheduled for %d orders.', $retried_count, 'woocommerce' ),
+				$retried_count
+			);
+		} elseif ( $already_scheduled_count > 0 ) {
+			$message = __( 'Re-import is already scheduled for the previously failed orders.', 'woocommerce' );
+		} else {
+			$message = __( 'No orders were scheduled for re-import. The previously failed orders no longer exist.', 'woocommerce' );
+		}
+
+		if ( $error_count > 0 ) {
+			$message .= ' ' . sprintf(
+				/* translators: %d: number of orders that could not be scheduled for re-import */
+				_n( '%d order could not be scheduled. Check the order import log for details.', '%d orders could not be scheduled. Check the order import log for details.', $error_count, 'woocommerce' ),
+				$error_count
+			);
+		}
+
+		return rest_ensure_response(
+			array(
+				'success'                 => true,
+				'message'                 => $message,
+				'retried_count'           => $retried_count,
+				'pruned_count'            => $pruned_count,
+				'already_scheduled_count' => $already_scheduled_count,
+				'error_count'             => $error_count,
+			)
+		);
+	}
+
+	/**
+	 * Get the schema for the retry-failed endpoint, conforming to JSON Schema.
+	 *
+	 * @return array
+	 */
+	public function get_retry_failed_schema() {
+		$schema = array(
+			'$schema'    => 'https://json-schema.org/draft-04/schema#',
+			'title'      => 'analytics_import_retry_failed',
+			'type'       => 'object',
+			'properties' => array(
+				'success'                 => array(
+					'type'        => 'boolean',
+					'description' => __( 'Whether the retry was scheduled successfully.', 'woocommerce' ),
+					'context'     => array( 'view' ),
+					'readonly'    => true,
+				),
+				'message'                 => array(
+					'type'        => 'string',
+					'description' => __( 'Result message.', 'woocommerce' ),
+					'context'     => array( 'view' ),
+					'readonly'    => true,
+				),
+				'retried_count'           => array(
+					'type'        => 'integer',
+					'description' => __( 'Number of orders scheduled for re-import.', 'woocommerce' ),
+					'context'     => array( 'view' ),
+					'readonly'    => true,
+				),
+				'pruned_count'            => array(
+					'type'        => 'integer',
+					'description' => __( 'Number of failed records removed because their orders no longer exist.', 'woocommerce' ),
+					'context'     => array( 'view' ),
+					'readonly'    => true,
+				),
+				'already_scheduled_count' => array(
+					'type'        => 'integer',
+					'description' => __( 'Number of orders skipped because their re-import is already pending.', 'woocommerce' ),
+					'context'     => array( 'view' ),
+					'readonly'    => true,
+				),
+				'error_count'             => array(
+					'type'        => 'integer',
+					'description' => __( 'Number of orders that could not be scheduled for re-import.', 'woocommerce' ),
+					'context'     => array( 'view' ),
+					'readonly'    => true,
+				),
+			),
+		);
+
+		return $this->add_additional_fields_schema( $schema );
+	}
+
 	/**
 	 * Check if scheduled import is enabled.
 	 *
+	 * Delegates to OrdersScheduler so the API reflects the same mode the
+	 * scheduler actually runs in (feature flag check + legacy option fallback).
+	 *
 	 * @return bool
 	 */
 	private function is_scheduled_import_enabled() {
-		return 'yes' === get_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION, OrdersScheduler::SCHEDULED_IMPORT_OPTION_DEFAULT_VALUE );
+		return OrdersScheduler::is_scheduled_import_enabled();
 	}

 	/**
@@ -226,6 +397,18 @@ class AnalyticsImports extends \WC_REST_Data_Controller {
 					'context'     => array( 'view' ),
 					'readonly'    => true,
 				),
+				'failed_count'              => array(
+					'type'        => 'integer',
+					'description' => __( 'Number of orders that failed analytics import and are pending retry.', 'woocommerce' ),
+					'context'     => array( 'view' ),
+					'readonly'    => true,
+				),
+				'failed_overflow_count'     => array(
+					'type'        => 'integer',
+					'description' => __( 'Number of failed order IDs dropped because the stored list reached its limit.', 'woocommerce' ),
+					'context'     => array( 'view' ),
+					'readonly'    => true,
+				),
 			),
 		);

diff --git a/plugins/woocommerce/src/Admin/API/Init.php b/plugins/woocommerce/src/Admin/API/Init.php
index 5c1b10495d7..ace60c4ae1c 100644
--- a/plugins/woocommerce/src/Admin/API/Init.php
+++ b/plugins/woocommerce/src/Admin/API/Init.php
@@ -186,11 +186,12 @@ class Init {
 				'Automattic\WooCommerce\Admin\API\Reports\Customers\Stats\Controller',
 			);

-			if ( Features::is_enabled( 'analytics-scheduled-import' ) ) {
-				$analytics_controllers[] = 'Automattic\WooCommerce\Admin\API\AnalyticsImports';
-			}
+			// Registered whenever analytics is enabled (not gated on the
+			// scheduled-import feature): the status endpoint reports failed
+			// order imports in both immediate and scheduled modes.
+			$analytics_controllers[] = 'Automattic\WooCommerce\Admin\API\AnalyticsImports';

-			// The performance indicators controllerq must be registered last, after other /stats endpoints have been registered.
+			// The performance indicators controller must be registered last, after other /stats endpoints have been registered.
 			$analytics_controllers[] = 'Automattic\WooCommerce\Admin\API\Reports\PerformanceIndicators\Controller';
 		}

diff --git a/plugins/woocommerce/src/Admin/ReportsSync.php b/plugins/woocommerce/src/Admin/ReportsSync.php
index ff330c3d846..c70b227b0c6 100644
--- a/plugins/woocommerce/src/Admin/ReportsSync.php
+++ b/plugins/woocommerce/src/Admin/ReportsSync.php
@@ -83,6 +83,13 @@ class ReportsSync {
 		}

 		self::reset_import_stats( $days, $skip_existing );
+		// A full (non-windowed) import covers the orders whose failed-import
+		// records were dropped due to the storage cap, so reset the overflow
+		// counter. Windowed imports may not reach those orders, so the counter
+		// is kept to keep the UI warning accurate.
+		if ( false === $days ) {
+			OrdersScheduler::reset_failed_order_imports_overflow();
+		}
 		foreach ( self::get_schedulers() as $scheduler ) {
 			$scheduler::schedule_action( 'import_batch_init', array( $days, $skip_existing ) );
 		}
@@ -178,6 +185,7 @@ class ReportsSync {

 		// Delete import options.
 		delete_option( ImportScheduler::IMPORT_STATS_OPTION );
+		delete_option( OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION );

 		return __( 'Report table data is being deleted.', 'woocommerce' );
 	}
diff --git a/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php b/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
index 742be51c494..35cb8c249eb 100644
--- a/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
+++ b/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
@@ -84,6 +84,35 @@ class OrdersScheduler extends ImportScheduler {
 	 */
 	const PROCESS_PENDING_ORDERS_BATCH_ACTION = 'process_pending_batch';

+	/**
+	 * Option name for storing order IDs that failed analytics import.
+	 *
+	 * The skip-and-advance behavior in process_pending_batch() means a failing
+	 * order is excluded from analytics. The IDs are persisted here so the
+	 * "Import historical data" UI can surface them and offer a targeted retry.
+	 *
+	 * Shape: array( 'ids' => int[], 'overflow' => int ). 'overflow' counts IDs
+	 * dropped because the list reached FAILED_ORDER_IMPORTS_CAP.
+	 *
+	 * Updates to this option are best-effort, not atomic: a concurrent
+	 * read-modify-write (e.g. the batch processor recording a failure while a
+	 * retry request prunes an ID) can lose one of the writes. This is accepted
+	 * because the list is advisory and self-healing — a stale ID is cleared on
+	 * the order's next successful import, and every failure is also logged to
+	 * the 'wc-analytics-order-import' source. If stronger guarantees are ever
+	 * needed, store each failed ID as its own row instead.
+	 *
+	 * @var string
+	 */
+	const FAILED_ORDER_IMPORTS_OPTION = 'woocommerce_admin_analytics_failed_order_imports';
+
+	/**
+	 * Maximum number of failed order IDs to store.
+	 *
+	 * @var int
+	 */
+	const FAILED_ORDER_IMPORTS_CAP = 1000;
+
 	/**
 	 * Attach order lookup update hooks.
 	 *
@@ -383,6 +412,9 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )

 		ReportsCache::invalidate();

+		// A successful import means the order is no longer missing from analytics.
+		self::clear_failed_order_import( $order_id );
+
 		/**
 		 * Fires after an order or refund has been imported into Analytics lookup tables
 		 * and the reports cache has been invalidated.
@@ -556,6 +588,7 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
 				// Log the failure and advance the cursor past the failing order so that
 				// it is skipped on the next run rather than blocking the entire pipeline.
 				static::log_import_error( $order->id, $e, $context );
+				static::record_failed_order_import( $order->id );
 				$cursor_date = $order->date_updated_gmt;
 				$cursor_id   = $order->id;
 			}
@@ -747,6 +780,119 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
 		return apply_filters( 'woocommerce_analytics_is_test_order', $is_test, $check_order );
 	}

+	/**
+	 * Get the recorded failed order imports.
+	 *
+	 * @internal
+	 * @since 11.0.0
+	 * @return array Array with 'ids' (int[]) and 'overflow' (int) keys.
+	 */
+	public static function get_failed_order_imports(): array {
+		$value = get_option( self::FAILED_ORDER_IMPORTS_OPTION, array() );
+		if ( ! is_array( $value ) ) {
+			$value = array();
+		}
+
+		return array(
+			'ids'      => isset( $value['ids'] ) && is_array( $value['ids'] ) ? array_map( 'absint', $value['ids'] ) : array(),
+			'overflow' => isset( $value['overflow'] ) ? absint( $value['overflow'] ) : 0,
+		);
+	}
+
+	/**
+	 * Record an order ID that failed analytics import.
+	 *
+	 * Deduplicates IDs. When the list exceeds FAILED_ORDER_IMPORTS_CAP, the
+	 * oldest ID is dropped and the overflow counter is incremented.
+	 *
+	 * @internal
+	 * @since 11.0.0
+	 * @param int $order_id Order or refund ID that failed to import.
+	 * @return void
+	 */
+	public static function record_failed_order_import( $order_id ): void {
+		$order_id = absint( $order_id );
+		if ( ! $order_id ) {
+			return;
+		}
+
+		$failed = self::get_failed_order_imports();
+		if ( in_array( $order_id, $failed['ids'], true ) ) {
+			return;
+		}
+
+		$failed['ids'][] = $order_id;
+		$ids_count       = count( $failed['ids'] );
+		while ( $ids_count > self::FAILED_ORDER_IMPORTS_CAP ) {
+			array_shift( $failed['ids'] );
+			++$failed['overflow'];
+			--$ids_count;
+		}
+
+		update_option( self::FAILED_ORDER_IMPORTS_OPTION, $failed, false );
+	}
+
+	/**
+	 * Remove an order ID from the failed imports list.
+	 *
+	 * Called after a successful import so the list always reflects orders
+	 * that have not been imported since their last failure.
+	 *
+	 * @internal
+	 * @since 11.0.0
+	 * @param int $order_id Order or refund ID to remove.
+	 * @return void
+	 */
+	public static function clear_failed_order_import( $order_id ): void {
+		$order_id = absint( $order_id );
+		if ( ! $order_id ) {
+			return;
+		}
+
+		$failed = self::get_failed_order_imports();
+		$index  = array_search( $order_id, $failed['ids'], true );
+
+		if ( false === $index ) {
+			return;
+		}
+
+		unset( $failed['ids'][ $index ] );
+		$failed['ids'] = array_values( $failed['ids'] );
+
+		if ( empty( $failed['ids'] ) && 0 === $failed['overflow'] ) {
+			delete_option( self::FAILED_ORDER_IMPORTS_OPTION );
+			return;
+		}
+
+		update_option( self::FAILED_ORDER_IMPORTS_OPTION, $failed, false );
+	}
+
+	/**
+	 * Reset the failed order imports overflow counter.
+	 *
+	 * Called when a full (non-windowed) historical import starts, since that
+	 * import covers the orders whose IDs were dropped from the list. Windowed
+	 * imports must not reset the counter.
+	 *
+	 * @internal
+	 * @since 11.0.0
+	 * @return void
+	 */
+	public static function reset_failed_order_imports_overflow(): void {
+		$failed = self::get_failed_order_imports();
+		if ( 0 === $failed['overflow'] ) {
+			return;
+		}
+
+		$failed['overflow'] = 0;
+		if ( empty( $failed['ids'] ) ) {
+			delete_option( self::FAILED_ORDER_IMPORTS_OPTION );
+			return;
+		}
+
+		update_option( self::FAILED_ORDER_IMPORTS_OPTION, $failed, false );
+	}
+
 	/**
 	 * Delete a batch of orders.
 	 *
@@ -776,9 +922,11 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
 	 * import is supported (returns false). When enabled, checks the option value.
 	 *
 	 * @internal
+	 * @since 10.5.0 Introduced as a private method.
+	 * @since 11.0.0 Made public.
 	 * @return bool
 	 */
-	private static function is_scheduled_import_enabled(): bool {
+	public static function is_scheduled_import_enabled(): bool {
 		if ( ! Features::is_enabled( 'analytics-scheduled-import' ) ) {
 			// If the feature is disabled, only immediate import is supported.
 			return false;
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/AnalyticsImportsTest.php b/plugins/woocommerce/tests/php/src/Admin/API/AnalyticsImportsTest.php
index 334b5f41273..72fdfac3719 100644
--- a/plugins/woocommerce/tests/php/src/Admin/API/AnalyticsImportsTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/API/AnalyticsImportsTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Tests\Admin\API;

+use Automattic\WooCommerce\Admin\Features\Features;
 use Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler;
 use WC_REST_Unit_Test_Case;
 use WP_REST_Request;
@@ -48,6 +49,10 @@ class AnalyticsImportsTest extends WC_REST_Unit_Test_Case {
 	public function setUp(): void {
 		parent::setUp();

+		// Enable the analytics-scheduled-import feature so is_scheduled_import_enabled()
+		// delegates correctly (matches OrdersScheduler's own behaviour).
+		Features::enable( 'analytics-scheduled-import' );
+
 		// Create test users.
 		$this->admin_user = $this->factory->user->create(
 			array(
@@ -78,6 +83,8 @@ class AnalyticsImportsTest extends WC_REST_Unit_Test_Case {
 		$this->clear_scheduled_actions();
 		delete_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION );
 		delete_option( OrdersScheduler::LAST_PROCESSED_ORDER_DATE_OPTION );
+		delete_option( OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION );
+		Features::disable( 'analytics-scheduled-import' );
 		parent::tearDown();
 	}

@@ -87,6 +94,7 @@ class AnalyticsImportsTest extends WC_REST_Unit_Test_Case {
 	private function clear_scheduled_actions() {
 		$hook = OrdersScheduler::get_action( OrdersScheduler::PROCESS_PENDING_ORDERS_BATCH_ACTION );
 		as_unschedule_all_actions( $hook );
+		as_unschedule_all_actions( OrdersScheduler::get_action( 'import' ) );
 	}

 	/**
@@ -271,4 +279,186 @@ class AnalyticsImportsTest extends WC_REST_Unit_Test_Case {

 		$this->assertSame( 200, $response->get_status() );
 	}
+
+	/**
+	 * @testdox Retry-failed schedules an import action per failed order and keeps IDs until success.
+	 */
+	public function test_retry_failed_schedules_import_actions(): void {
+		wp_set_current_user( $this->admin_user );
+		$order = \WC_Helper_Order::create_order();
+		OrdersScheduler::record_failed_order_import( $order->get_id() );
+		// Saving the order schedules an immediate-mode import action; clear it
+		// so the test verifies the endpoint scheduled the action itself.
+		$this->clear_scheduled_actions();
+
+		$request  = new WP_REST_Request( 'POST', self::ENDPOINT . '/retry-failed' );
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertTrue( $data['success'] );
+		$this->assertSame( 1, $data['retried_count'] );
+
+		$hook = OrdersScheduler::get_action( 'import' );
+		$this->assertNotFalse(
+			as_next_scheduled_action( $hook, array( $order->get_id() ), OrdersScheduler::$group ),
+			'A single-order import action should be scheduled'
+		);
+
+		$failed = OrdersScheduler::get_failed_order_imports();
+		$this->assertContains( $order->get_id(), $failed['ids'], 'ID stays recorded until the import succeeds' );
+	}
+
+	/**
+	 * @testdox Retry-failed prunes orders that no longer exist.
+	 */
+	public function test_retry_failed_prunes_deleted_orders(): void {
+		wp_set_current_user( $this->admin_user );
+		OrdersScheduler::record_failed_order_import( 99999999 );
+
+		$request  = new WP_REST_Request( 'POST', self::ENDPOINT . '/retry-failed' );
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 0, $data['retried_count'] );
+		$this->assertTrue( $data['success'] );
+		$this->assertSame( 1, $data['pruned_count'] );
+
+		$failed = OrdersScheduler::get_failed_order_imports();
+		$this->assertNotContains( 99999999, $failed['ids'], 'Deleted orders can never succeed and should be pruned' );
+	}
+
+	/**
+	 * @testdox Retry-failed returns an error when there are no failed orders.
+	 */
+	public function test_retry_failed_returns_error_when_no_failed_orders(): void {
+		wp_set_current_user( $this->admin_user );
+
+		$request  = new WP_REST_Request( 'POST', self::ENDPOINT . '/retry-failed' );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 400, $response->get_status() );
+	}
+
+	/**
+	 * @testdox Retry-failed requires the manage_woocommerce capability.
+	 */
+	public function test_retry_failed_requires_permission(): void {
+		wp_set_current_user( $this->customer_user );
+
+		$request  = new WP_REST_Request( 'POST', self::ENDPOINT . '/retry-failed' );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 403, $response->get_status() );
+	}
+
+	/**
+	 * @testdox Retry-failed does not schedule duplicate import actions on repeated requests.
+	 */
+	public function test_retry_failed_is_idempotent_for_pending_actions(): void {
+		wp_set_current_user( $this->admin_user );
+		$order = \WC_Helper_Order::create_order();
+		OrdersScheduler::record_failed_order_import( $order->get_id() );
+		// Saving the order schedules an immediate-mode import action; clear it
+		// so the first request is the one that schedules the action.
+		$this->clear_scheduled_actions();
+
+		$request         = new WP_REST_Request( 'POST', self::ENDPOINT . '/retry-failed' );
+		$first_response  = $this->server->dispatch( $request );
+		$second_response = $this->server->dispatch( $request );
+
+		$pending = WC()->queue()->search(
+			array(
+				'hook'     => OrdersScheduler::get_action( 'import' ),
+				'search'   => '[' . $order->get_id() . ']',
+				'status'   => 'pending',
+				'per_page' => 10,
+			)
+		);
+		$this->assertCount( 1, $pending, 'Repeated retry requests must not duplicate pending import actions' );
+
+		// The response must not claim new work was scheduled on the second request.
+		$this->assertSame( 1, $first_response->get_data()['retried_count'] );
+		$this->assertSame( 0, $second_response->get_data()['retried_count'] );
+		$this->assertSame( 1, $second_response->get_data()['already_scheduled_count'] );
+		$this->assertSame(
+			'Re-import is already scheduled for the previously failed orders.',
+			$second_response->get_data()['message']
+		);
+	}
+
+	/**
+	 * @testdox Retry-failed returns an error when no order could be scheduled for re-import.
+	 */
+	public function test_retry_failed_surfaces_scheduling_errors(): void {
+		wp_set_current_user( $this->admin_user );
+		$order = \WC_Helper_Order::create_order();
+		OrdersScheduler::record_failed_order_import( $order->get_id() );
+		// Saving the order schedules an immediate-mode import action; clear it
+		// so the endpoint reaches the (synchronous, throwing) scheduling path.
+		$this->clear_scheduled_actions();
+
+		// Force schedule_action() to run the import synchronously, and make
+		// the import itself throw, so the scheduling attempt errors.
+		add_filter( 'woocommerce_analytics_disable_action_scheduling', '__return_true' );
+		$throwing_filter = function () {
+			throw new \DivisionByZeroError( 'Division by zero' );
+		};
+		add_filter( 'woocommerce_analytics_is_test_order', $throwing_filter );
+
+		try {
+			$request  = new WP_REST_Request( 'POST', self::ENDPOINT . '/retry-failed' );
+			$response = $this->server->dispatch( $request );
+		} finally {
+			remove_filter( 'woocommerce_analytics_is_test_order', $throwing_filter );
+			remove_filter( 'woocommerce_analytics_disable_action_scheduling', '__return_true' );
+		}
+
+		$this->assertSame( 500, $response->get_status(), 'A fully failed retry must not report success' );
+		$this->assertSame( 'woocommerce_rest_analytics_retry_failed', $response->get_data()['code'] );
+
+		$failed = OrdersScheduler::get_failed_order_imports();
+		$this->assertContains( $order->get_id(), $failed['ids'], 'Orders that failed again stay recorded' );
+	}
+
+	/**
+	 * @testdox Status endpoint includes failed import counts in both modes.
+	 */
+	public function test_status_includes_failed_counts(): void {
+		wp_set_current_user( $this->admin_user );
+		update_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION, 'no' );
+		update_option(
+			OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION,
+			array(
+				'ids'      => array( 11, 22 ),
+				'overflow' => 3,
+			),
+			false
+		);
+
+		$request  = new WP_REST_Request( 'GET', self::ENDPOINT . '/status' );
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 2, $data['failed_count'] );
+		$this->assertSame( 3, $data['failed_overflow_count'] );
+	}
+
+	/**
+	 * @testdox Status endpoint reports zero failed imports for a malformed option value.
+	 */
+	public function test_status_handles_malformed_failed_imports_option(): void {
+		wp_set_current_user( $this->admin_user );
+		update_option( OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION, 'yes', false );
+
+		$request  = new WP_REST_Request( 'GET', self::ENDPOINT . '/status' );
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 0, $data['failed_count'] );
+		$this->assertSame( 0, $data['failed_overflow_count'] );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Admin/ReportsSyncTest.php b/plugins/woocommerce/tests/php/src/Admin/ReportsSyncTest.php
new file mode 100644
index 00000000000..c09b5071b1d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/ReportsSyncTest.php
@@ -0,0 +1,91 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin;
+
+use Automattic\WooCommerce\Admin\ReportsSync;
+use Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the ReportsSync class.
+ */
+class ReportsSyncTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		ReportsSync::clear_queued_actions();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		ReportsSync::clear_queued_actions();
+		delete_option( OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox delete_report_data deletes the failed order imports option.
+	 */
+	public function test_delete_report_data_clears_failed_order_imports(): void {
+		update_option(
+			OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION,
+			array(
+				'ids'      => array( 11 ),
+				'overflow' => 3,
+			),
+			false
+		);
+
+		ReportsSync::delete_report_data();
+
+		$this->assertFalse( get_option( OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION ) );
+	}
+
+	/**
+	 * @testdox regenerate_report_data resets the failed imports overflow counter but keeps stored IDs.
+	 */
+	public function test_regenerate_report_data_resets_overflow(): void {
+		update_option(
+			OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION,
+			array(
+				'ids'      => array( 11 ),
+				'overflow' => 3,
+			),
+			false
+		);
+
+		$result = ReportsSync::regenerate_report_data( false, false );
+
+		$this->assertNotWPError( $result, 'Import guard unexpectedly fired; ensure no import is in progress.' );
+		$failed = OrdersScheduler::get_failed_order_imports();
+		$this->assertSame( array( 11 ), $failed['ids'] );
+		$this->assertSame( 0, $failed['overflow'] );
+	}
+
+	/**
+	 * @testdox regenerate_report_data keeps the overflow counter for a windowed import.
+	 */
+	public function test_regenerate_report_data_keeps_overflow_for_windowed_import(): void {
+		update_option(
+			OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION,
+			array(
+				'ids'      => array( 11 ),
+				'overflow' => 3,
+			),
+			false
+		);
+
+		$result = ReportsSync::regenerate_report_data( 30, false );
+
+		$this->assertNotWPError( $result, 'Import guard unexpectedly fired; ensure no import is in progress.' );
+		$failed = OrdersScheduler::get_failed_order_imports();
+		$this->assertSame( array( 11 ), $failed['ids'], 'Stored failed IDs must survive a windowed import' );
+		$this->assertSame( 3, $failed['overflow'] );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php
index ca9312329be..ea162197c00 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php
@@ -36,6 +36,7 @@ class OrdersSchedulerTest extends WC_Unit_Test_Case {
 		delete_option( OrdersScheduler::LAST_PROCESSED_ORDER_ID_OPTION );
 		delete_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION );
 		delete_option( OrdersScheduler::LEGACY_IMMEDIATE_IMPORT_OPTION );
+		delete_option( OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION );

 		// Clean up any scheduled actions.
 		$this->clear_scheduled_batch_processor();
@@ -511,6 +512,210 @@ class OrdersSchedulerTest extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox get_failed_order_imports normalizes malformed or legacy option values.
+	 */
+	public function test_get_failed_order_imports_normalizes_malformed_option(): void {
+		// A scalar value (e.g. from a corrupted or manually edited option).
+		update_option( OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION, 'yes', false );
+		$failed = OrdersScheduler::get_failed_order_imports();
+		$this->assertSame( array(), $failed['ids'] );
+		$this->assertSame( 0, $failed['overflow'] );
+
+		// An array missing the 'ids' key, with a non-int overflow.
+		update_option( OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION, array( 'overflow' => '4' ), false );
+		$failed = OrdersScheduler::get_failed_order_imports();
+		$this->assertSame( array(), $failed['ids'] );
+		$this->assertSame( 4, $failed['overflow'] );
+
+		// 'ids' set to a non-array value.
+		update_option(
+			OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION,
+			array(
+				'ids'      => 'not-an-array',
+				'overflow' => 1,
+			),
+			false
+		);
+		$failed = OrdersScheduler::get_failed_order_imports();
+		$this->assertSame( array(), $failed['ids'] );
+		$this->assertSame( 1, $failed['overflow'] );
+	}
+
+	/**
+	 * @testdox record_failed_order_import stores deduplicated order IDs.
+	 */
+	public function test_record_failed_order_import_dedupes(): void {
+		OrdersScheduler::record_failed_order_import( 11 );
+		OrdersScheduler::record_failed_order_import( 22 );
+		OrdersScheduler::record_failed_order_import( 11 );
+
+		$failed = OrdersScheduler::get_failed_order_imports();
+
+		$this->assertSame( array( 11, 22 ), $failed['ids'] );
+		$this->assertSame( 0, $failed['overflow'] );
+	}
+
+	/**
+	 * @testdox record_failed_order_import ignores invalid order IDs.
+	 */
+	public function test_record_failed_order_import_ignores_invalid_ids(): void {
+		OrdersScheduler::record_failed_order_import( 0 );
+
+		$failed = OrdersScheduler::get_failed_order_imports();
+
+		$this->assertSame( array(), $failed['ids'] );
+	}
+
+	/**
+	 * @testdox record_failed_order_import drops the oldest ID and counts overflow when the cap is reached.
+	 */
+	public function test_record_failed_order_import_caps_list_and_counts_overflow(): void {
+		update_option(
+			OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION,
+			array(
+				'ids'      => range( 1, 1000 ),
+				'overflow' => 2,
+			),
+			false
+		);
+
+		OrdersScheduler::record_failed_order_import( 1001 );
+
+		$failed = OrdersScheduler::get_failed_order_imports();
+		$this->assertCount( 1000, $failed['ids'], 'List should stay at the cap' );
+		$this->assertNotContains( 1, $failed['ids'], 'Oldest ID should be dropped' );
+		$this->assertContains( 1001, $failed['ids'], 'New ID should be recorded' );
+		$this->assertSame( 3, $failed['overflow'] );
+	}
+
+	/**
+	 * @testdox clear_failed_order_import removes a recorded ID.
+	 */
+	public function test_clear_failed_order_import_removes_id(): void {
+		OrdersScheduler::record_failed_order_import( 11 );
+		OrdersScheduler::record_failed_order_import( 22 );
+
+		OrdersScheduler::clear_failed_order_import( 11 );
+
+		$failed = OrdersScheduler::get_failed_order_imports();
+		$this->assertSame( array( 22 ), $failed['ids'] );
+	}
+
+	/**
+	 * @testdox clear_failed_order_import deletes the option when nothing is left.
+	 */
+	public function test_clear_failed_order_import_deletes_option_when_empty(): void {
+		OrdersScheduler::record_failed_order_import( 11 );
+
+		OrdersScheduler::clear_failed_order_import( 11 );
+
+		$this->assertFalse( get_option( OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION ) );
+	}
+
+	/**
+	 * @testdox reset_failed_order_imports_overflow resets the counter but keeps stored IDs.
+	 */
+	public function test_reset_failed_order_imports_overflow(): void {
+		update_option(
+			OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION,
+			array(
+				'ids'      => array( 5 ),
+				'overflow' => 7,
+			),
+			false
+		);
+
+		OrdersScheduler::reset_failed_order_imports_overflow();
+
+		$failed = OrdersScheduler::get_failed_order_imports();
+		$this->assertSame( array( 5 ), $failed['ids'] );
+		$this->assertSame( 0, $failed['overflow'] );
+	}
+
+	/**
+	 * @testdox clear_failed_order_import preserves the option when overflow is non-zero after clearing all IDs.
+	 */
+	public function test_clear_failed_order_import_keeps_option_when_overflow_remains(): void {
+		update_option(
+			OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION,
+			array(
+				'ids'      => array( 11 ),
+				'overflow' => 3,
+			),
+			false
+		);
+
+		OrdersScheduler::clear_failed_order_import( 11 );
+
+		$failed = OrdersScheduler::get_failed_order_imports();
+		$this->assertSame( array(), $failed['ids'] );
+		$this->assertSame( 3, $failed['overflow'] );
+	}
+
+	/**
+	 * @testdox reset_failed_order_imports_overflow deletes the option when IDs are empty after reset.
+	 */
+	public function test_reset_failed_order_imports_overflow_deletes_option_when_ids_empty(): void {
+		update_option(
+			OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION,
+			array(
+				'ids'      => array(),
+				'overflow' => 5,
+			),
+			false
+		);
+
+		OrdersScheduler::reset_failed_order_imports_overflow();
+
+		$this->assertFalse( get_option( OrdersScheduler::FAILED_ORDER_IMPORTS_OPTION ) );
+	}
+
+	/**
+	 * @testdox process_pending_batch records the ID of an order that fails to import.
+	 */
+	public function test_process_pending_batch_records_failed_order(): void {
+		global $wpdb;
+		// Anchor the cursor just before test orders so existing DB orders are excluded.
+		$cursor_id = (int) $wpdb->get_var( "SELECT MAX(id) FROM {$wpdb->prefix}wc_orders" );
+
+		$order = \WC_Helper_Order::create_order();
+		$order->set_status( 'completed' );
+		$order->save();
+
+		$throwing_filter = function ( $is_test, $checked_order ) use ( $order ) {
+			if ( $checked_order instanceof \WC_Abstract_Order && $checked_order->get_id() === $order->get_id() ) {
+				throw new \DivisionByZeroError( 'Division by zero' );
+			}
+
+			return $is_test;
+		};
+
+		OrdersScheduler::clear_queued_actions();
+		add_filter( 'woocommerce_analytics_is_test_order', $throwing_filter, 10, 2 );
+		OrdersScheduler::process_pending_batch( '2000-01-01 00:00:00', $cursor_id );
+		remove_filter( 'woocommerce_analytics_is_test_order', $throwing_filter, 10 );
+
+		$failed = OrdersScheduler::get_failed_order_imports();
+		$this->assertContains( $order->get_id(), $failed['ids'] );
+	}
+
+	/**
+	 * @testdox import clears the failed record for an order that imports successfully.
+	 */
+	public function test_import_clears_failed_order_on_success(): void {
+		$order = \WC_Helper_Order::create_order();
+		$order->set_status( 'completed' );
+		$order->save();
+
+		OrdersScheduler::record_failed_order_import( $order->get_id() );
+
+		OrdersScheduler::import( $order->get_id() );
+
+		$failed = OrdersScheduler::get_failed_order_imports();
+		$this->assertNotContains( $order->get_id(), $failed['ids'] );
+	}
+
 	/**
 	 * Clear any scheduled batch processor actions.
 	 *