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.
*