Commit d022dac125d for woocommerce

commit d022dac125dbb48281dc1cb3a27b8787c3d4a81e
Author: Justin P <228780+layoutd@users.noreply.github.com>
Date:   Fri Oct 30 10:15:02 2026 +0100

    Add a filter to exclude orders from the Analytics import (#69031)

    * Add a where-clauses filter to the Analytics order import queries and probe it on import

    * Refuse excluded records in the order stats data store update

    * Test the Analytics import where-clauses filter across import paths

    * Add changelog for the Analytics import where-clauses filter

    * Harden the import where-clauses filter: keep '0', escape literal percent signs, ignore failed probes

    * Leave the scheduled import cursor unchanged when the filtered cursor query fails

    * Tighten the Analytics import exclusion for refunds, held records and failed counts

    * Test refund inheritance, held-record syncing and count error logging for the import exclusion

    * Shorten the Analytics import filter changelog entry

    * Apply the held-record and refund-parent rules inside the import queries

    * Test excluded refunds against paging, the scheduled batch and held parents

    * Exclude refunds of orders whose import conditions evaluate to NULL

    * Test that a NULL import condition on an order also excludes its refunds

    * Skip the import and keep the record for retry when the exclusion check fails

    * Report a failed exclusion check as a failed stats sync, not a skip

    * Set the since tags to 11.4.0 and correct the import filter docblocks

    * Keep an order for retry when its stats row is not written, and log failed page queries

    * Tidy the import filter tests: shared helpers, data provider, assertion messages

    * Skip the page query log when the count query already failed

    * Correct the filter docblock and label the import filter test assertions

    * Add a changelog entry for retrying orders whose stats fail to save

    * Run the full import when the stats write fails, but log it and keep the order for retry

    * Note in the filter docblock that refund rows are checked too

    * Update the import() docblock and test that other syncs run when the stats write fails

    * Stop tracking failed stats writes in import(), which flagged orders an empty stats row skips on purpose

    * Apply the import conditions after the cursor range in the scheduled import queries

    * Note in the filter docblock that conditions are complete expressions and removals need a historical import

    * Test the LIKE condition through the prepared scheduled import query

    * Shorten the filter docblock

diff --git a/plugins/woocommerce/changelog/43505-analytics-import-order-where-clauses-filter b/plugins/woocommerce/changelog/43505-analytics-import-order-where-clauses-filter
new file mode 100644
index 00000000000..0e4392d7e46
--- /dev/null
+++ b/plugins/woocommerce/changelog/43505-analytics-import-order-where-clauses-filter
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a filter that lets extensions exclude orders, such as marketplace child orders, from Analytics imports.
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
index f5bbe34ae30..6b7850d343a 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
@@ -548,6 +548,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 			return -1;
 		}

+		// Same defense-in-depth for records the import conditions filter excludes. A failed check
+		// writes nothing either, and reports a failed sync rather than a skip.
+		$excluded = OrdersScheduler::is_excluded_from_import( (int) $order->get_id() );
+		if ( null === $excluded ) {
+			return false;
+		}
+		if ( $excluded ) {
+			return -1;
+		}
+
 		$format = array(
 			'%d',
 			'%d',
diff --git a/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php b/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
index e48fc96a12e..697cbf8a985 100644
--- a/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
+++ b/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
@@ -244,12 +244,20 @@ class OrdersScheduler extends ImportScheduler {
 			)";
 		}

+		$where_clause .= self::get_import_where_clause( $wpdb->posts );
+
 		$count = $wpdb->get_var(
 			"SELECT COUNT(*) FROM {$wpdb->posts}
 			WHERE post_type IN ( 'shop_order', 'shop_order_refund' )
 			AND post_status NOT IN ( 'wc-auto-draft', 'auto-draft', 'trash' )
 			{$where_clause}" // phpcs:ignore unprepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared SQL ok.
 		);
+		if ( self::log_last_db_error( 'Could not count the orders to import' ) ) {
+			return (object) array(
+				'total' => 0,
+				'ids'   => array(),
+			);
+		}

 		$order_ids = absint( $count ) > 0 ? $wpdb->get_col(
 			$wpdb->prepare(
@@ -264,6 +272,7 @@ class OrdersScheduler extends ImportScheduler {
 				$offset
 			)
 		) : array(); // phpcs:ignore unprepared SQL ok.
+		self::log_last_db_error( 'Could not query orders to import' );

 		return (object) array(
 			'total' => absint( $count ),
@@ -301,6 +310,8 @@ class OrdersScheduler extends ImportScheduler {
 				";
 		}

+		$where_clause .= self::get_import_where_clause( 'orders' );
+
 		$count = $wpdb->get_var(
 			"
 SELECT COUNT(*) FROM {$order_table} AS orders
@@ -309,6 +320,12 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
 {$where_clause}
 "
 		); // phpcs:ignore unprepared SQL ok.
+		if ( self::log_last_db_error( 'Could not count the orders to import' ) ) {
+			return (object) array(
+				'total' => 0,
+				'ids'   => array(),
+			);
+		}

 		$order_ids = absint( $count ) > 0 ? $wpdb->get_col(
 			$wpdb->prepare(
@@ -323,6 +340,7 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
 				$offset
 			)
 		) : array(); // phpcs:ignore unprepared SQL ok.
+		self::log_last_db_error( 'Could not query orders to import' );

 		return (object) array(
 			'total' => absint( $count ),
@@ -365,7 +383,8 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )

 	/**
 	 * Imports a single order or refund to update lookup tables for.
-	 * An order whose tax lookup rows could not be written stays on the failed imports list.
+	 * An order whose tax lookup rows could not be written, or whose exclusion check fails, stays on
+	 * the failed imports list.
 	 *
 	 * @internal
 	 * @param int $order_id Order or refund ID.
@@ -400,6 +419,19 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
 			return;
 		}

+		$excluded = self::is_excluded_from_import( (int) $order_id );
+		if ( null === $excluded ) {
+			// The exclusion is unknown, so neither import the record nor drop it: keep it on the list
+			// Analytics settings retries.
+			self::record_failed_order_import( $order_id );
+			return;
+		}
+		if ( $excluded ) {
+			// The record is excluded, so drop it from the list Analytics settings retries.
+			self::clear_failed_order_import( $order_id );
+			return;
+		}
+
 		// Skip trashed records Analytics does not already hold: importing one creates a
 		// wc_customer_lookup row via Order::get_report_customer_id() that nothing removes
 		// while the order stays trashed. Records trashed after being imported keep their
@@ -655,6 +687,12 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
 		// Get orders updated since the cursor position.
 		$orders = self::get_orders_since( $cursor_date, $cursor_id, $batch_size );

+		// A failed query (a broken import conditions filter, say) also returns an empty result.
+		// Leave the cursor where it is so the batch is retried once the cause is fixed.
+		if ( self::log_last_db_error( 'Could not query orders to import, leaving the cursor unchanged' ) ) {
+			return;
+		}
+
 		if ( empty( $orders ) ) {
 			$logger->info( 'No orders to process', $context );
 			// Update the cursor position to the start time of the batch so that the next batch will start from that point.
@@ -778,18 +816,20 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
 	private static function get_orders_since_from_orders_table( $cursor_date, $cursor_id, $limit ) {
 		global $wpdb;
 		$orders_table = OrdersTableDataStore::get_orders_table_name();
+		$import_where = self::get_import_where_clause( 'orders' );

 		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
 		return $wpdb->get_results(
 			$wpdb->prepare(
 				"SELECT id, date_updated_gmt
-				FROM {$orders_table}
+				FROM {$orders_table} AS orders
 				WHERE type IN ('shop_order', 'shop_order_refund')
 				AND status NOT IN ('wc-auto-draft', 'auto-draft')
 				AND (
 					date_updated_gmt > %s
 					OR (date_updated_gmt = %s AND id > %d)
 				)
+				{$import_where}
 				ORDER BY date_updated_gmt ASC, id ASC
 				LIMIT %d",
 				$cursor_date,
@@ -815,7 +855,9 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
 	 */
 	private static function get_orders_since_from_posts_table( $cursor_date, $cursor_id, $limit ) {
 		global $wpdb;
+		$import_where = self::get_import_where_clause( $wpdb->posts );

+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
 		return $wpdb->get_results(
 			$wpdb->prepare(
 				"SELECT ID as id, post_modified_gmt as date_updated_gmt
@@ -826,6 +868,7 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
 					post_modified_gmt > %s
 					OR (post_modified_gmt = %s AND ID > %d)
 				)
+				{$import_where}
 				ORDER BY post_modified_gmt ASC, ID ASC
 				LIMIT %d",
 				$cursor_date,
@@ -834,6 +877,7 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
 				$limit
 			)
 		);
+		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
 	}

 	/**
@@ -878,6 +922,153 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
 		return apply_filters( 'woocommerce_analytics_is_test_order', $is_test, $check_order );
 	}

+	/**
+	 * Build the WHERE fragment that applies the import conditions filter to an Analytics order import query.
+	 *
+	 * A record is kept when Analytics already holds it, so it keeps syncing, or when it passes the
+	 * conditions and, for a refund, its parent is not an excluded order Analytics does not hold.
+	 * Every import query shares this fragment, so what is counted is what gets imported.
+	 *
+	 * @param string $table_alias Table name or alias the query uses for the orders or posts table.
+	 * @return string Empty string while the filter adds nothing, or a condition starting with ' AND '.
+	 */
+	private static function get_import_where_clause( string $table_alias ): string {
+		global $wpdb;
+
+		if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+			$table_name = OrdersTableDataStore::get_orders_table_name();
+			$columns    = array( 'id', 'type', 'parent_order_id' );
+		} else {
+			$table_name = $wpdb->posts;
+			$columns    = array( 'ID', 'post_type', 'post_parent' );
+		}
+		list( $id, $type, $parent ) = $columns;
+
+		$conditions = self::get_import_conditions( $table_alias, $table_name );
+		if ( '' === $conditions ) {
+			return '';
+		}
+
+		$parent_conditions = self::get_import_conditions( 'parent_order', $table_name );
+		$stats_table       = OrdersStatsDataStore::get_db_table_name();
+
+		// Some callers interpolate this fragment into $wpdb->prepare(), so a literal percent sign
+		// (a LIKE pattern, say) must not read as a placeholder. $wpdb->query() restores it.
+		// A parent whose conditions come out NULL counts as excluded, as it does in the per-record probe.
+		return $wpdb->add_placeholder_escape(
+			" AND (
+				EXISTS ( SELECT 1 FROM {$stats_table} AS held WHERE held.order_id = {$table_alias}.{$id} )
+				OR (
+					( {$conditions} )
+					AND (
+						{$table_alias}.{$type} <> 'shop_order_refund'
+						OR NOT EXISTS (
+							SELECT 1 FROM {$table_name} AS parent_order
+							WHERE parent_order.{$id} = {$table_alias}.{$parent}
+							AND NOT COALESCE( ( " . ( '' === $parent_conditions ? '1' : $parent_conditions ) . " ), 0 )
+							AND NOT EXISTS ( SELECT 1 FROM {$stats_table} AS parent_held WHERE parent_held.order_id = parent_order.{$id} )
+						)
+					)
+				)
+			)"
+		);
+	}
+
+	/**
+	 * Collect the conditions extensions add to the Analytics order import, for one table alias.
+	 *
+	 * @param string $table_alias Alias to prefix column names with.
+	 * @param string $table_name  Table the alias stands for.
+	 * @return string Empty string, or the conditions joined with AND.
+	 */
+	private static function get_import_conditions( string $table_alias, string $table_name ): string {
+		/**
+		 * Filters the SQL conditions that decide which orders and refunds Analytics imports.
+		 *
+		 * Each condition is a complete raw SQL expression: bind values with $wpdb->prepare() and prefix columns with
+		 * $table_alias. Refund rows are checked too, so guard order-only conditions with the type column. The filter
+		 * runs again with the alias `parent_order`, because a refund inherits the exclusion of its parent order.
+		 * After changing the conditions, delete and re-import historical data to apply them to past records.
+		 *
+		 * @since 11.4.0
+		 *
+		 * @param string[] $clauses     SQL conditions, joined with AND. Empty by default.
+		 * @param string   $table_alias Alias to prefix column names with.
+		 * @param string   $table_name  The orders table under HPOS, otherwise the posts table.
+		 */
+		$clauses = apply_filters( 'woocommerce_analytics_orders_import_where_clauses', array(), $table_alias, $table_name );
+		$clauses = array_filter(
+			array_map( 'trim', array_filter( is_array( $clauses ) ? $clauses : array(), 'is_string' ) ),
+			static function ( $clause ) {
+				// Keep '0': it is a valid condition that excludes every record.
+				return '' !== $clause;
+			}
+		);
+
+		return $clauses ? '( ' . implode( ' ) AND ( ', $clauses ) . ' )' : '';
+	}
+
+	/**
+	 * Whether the `woocommerce_analytics_orders_import_where_clauses` filter leaves a record out of the import.
+	 *
+	 * Applies the same rules as the import queries: a record Analytics already holds is never excluded,
+	 * and a refund inherits the exclusion of a parent order Analytics does not hold.
+	 *
+	 * @internal
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param int $order_id Order or refund ID.
+	 * @return bool|null Whether the record is excluded, or null when the check failed (the error is logged).
+	 */
+	public static function is_excluded_from_import( int $order_id ): ?bool {
+		global $wpdb;
+
+		if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+			$from        = OrdersTableDataStore::get_orders_table_name() . ' AS orders';
+			$table_alias = 'orders';
+			$id_column   = 'orders.id';
+		} else {
+			$from        = $wpdb->posts;
+			$table_alias = $wpdb->posts;
+			$id_column   = "{$wpdb->posts}.ID";
+		}
+
+		$where = self::get_import_where_clause( $table_alias );
+		if ( '' === $where ) {
+			return false;
+		}
+
+		// Evaluate the rules on the record's own row: 1 keeps it, 0 excludes it, and no row means
+		// the record is not in this table, so the conditions cannot exclude it.
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names and filtered SQL conditions are interpolated; the ID is prepared.
+		$kept = $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE( ( 1 {$where} ), 0 ) FROM {$from} WHERE {$id_column} = %d LIMIT 1", $order_id ) );
+
+		if ( self::log_last_db_error( "Could not check whether record #{$order_id} is excluded from the import" ) ) {
+			return null;
+		}
+
+		return null !== $kept && ! (int) $kept;
+	}
+
+	/**
+	 * Log the last database error, if there is one, to the import log.
+	 *
+	 * @param string $message What failed; the database error is appended.
+	 * @return bool Whether an error was logged.
+	 */
+	private static function log_last_db_error( string $message ): bool {
+		global $wpdb;
+
+		if ( '' === $wpdb->last_error ) {
+			return false;
+		}
+
+		wc_get_logger()->error( "{$message}: {$wpdb->last_error}", array( 'source' => 'wc-analytics-order-import' ) );
+
+		return true;
+	}
+
 	/**
 	 * Get the recorded failed order imports.
 	 *
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 797c82ca3d8..9059ffbb4bd 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php
@@ -6,6 +6,9 @@ namespace Automattic\WooCommerce\Tests\Internal\Admin\Schedulers;
 use Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler;
 use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore;
 use Automattic\WooCommerce\Admin\API\Reports\Taxes\DataStore as TaxesDataStore;
+use Automattic\WooCommerce\Enums\OrderInternalStatus;
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
 use Automattic\WooCommerce\Utilities\OrderUtil;
 use WC_Unit_Test_Case;

@@ -15,6 +18,7 @@ use WC_Unit_Test_Case;
  * @class OrdersSchedulerTest
  */
 class OrdersSchedulerTest extends WC_Unit_Test_Case {
+	use LoggerSpyTrait;

 	/**
 	 * Set up the test environment.
@@ -44,6 +48,421 @@ class OrdersSchedulerTest extends WC_Unit_Test_Case {
 		delete_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION );
 	}

+	/**
+	 * Hook the import filter that keeps child orders out of Analytics, for the active order storage.
+	 */
+	private function exclude_child_orders_from_import(): void {
+		add_filter(
+			'woocommerce_analytics_orders_import_where_clauses',
+			function ( $clauses, $table_alias ) {
+				$clauses[] = OrderUtil::custom_orders_table_usage_is_enabled()
+					? "NOT ( {$table_alias}.type = 'shop_order' AND {$table_alias}.parent_order_id <> 0 )"
+					: "NOT ( {$table_alias}.post_type = 'shop_order' AND {$table_alias}.post_parent <> 0 )";
+				return $clauses;
+			},
+			10,
+			2
+		);
+	}
+
+	/**
+	 * Hook the import filter to add one fixed condition.
+	 *
+	 * @param string $condition SQL condition.
+	 * @return callable The filter callback, for tests that remove it before a retry.
+	 */
+	private function add_import_condition( string $condition ): callable {
+		$callback = function ( $clauses ) use ( $condition ) {
+			$clauses[] = $condition;
+			return $clauses;
+		};
+		add_filter( 'woocommerce_analytics_orders_import_where_clauses', $callback );
+
+		return $callback;
+	}
+
+	/**
+	 * Refund 1 of an order.
+	 *
+	 * @param int $order_id Order ID.
+	 * @return int Refund ID.
+	 */
+	private function create_refund( int $order_id ): int {
+		return wc_create_refund(
+			array(
+				'order_id' => $order_id,
+				'amount'   => 1,
+			)
+		)->get_id();
+	}
+
+	/**
+	 * Create a completed parent order, a completed child order pointing at it, and a refund of the parent.
+	 *
+	 * @return int[] Parent, child and refund IDs.
+	 */
+	private function create_parent_child_and_refund(): array {
+		$parent = \WC_Helper_Order::create_order();
+		$parent->set_status( OrderStatus::COMPLETED );
+		$parent->save();
+
+		$child = \WC_Helper_Order::create_order();
+		$child->set_parent_id( $parent->get_id() );
+		$child->set_status( OrderStatus::COMPLETED );
+		$child->save();
+
+		return array( $parent->get_id(), $child->get_id(), $this->create_refund( $parent->get_id() ) );
+	}
+
+	/**
+	 * Order and refund IDs currently in the order stats table.
+	 *
+	 * @return int[]
+	 */
+	private function get_imported_order_ids(): array {
+		global $wpdb;
+
+		return array_map( 'intval', $wpdb->get_col( "SELECT order_id FROM {$wpdb->prefix}wc_order_stats" ) );
+	}
+
+	/**
+	 * Status recorded for a record in the order stats table.
+	 *
+	 * @param int $order_id Order or refund ID.
+	 * @return string|null
+	 */
+	private function get_imported_status( int $order_id ): ?string {
+		global $wpdb;
+
+		return $wpdb->get_var( $wpdb->prepare( "SELECT status FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d", $order_id ) );
+	}
+
+	/**
+	 * @testdox A refund inherits the exclusion of its parent order in every import path.
+	 */
+	public function test_refund_of_excluded_record_is_not_imported(): void {
+		list( $parent_id, $child_id, $refund_id ) = $this->create_parent_child_and_refund();
+		$child_refund_id                          = $this->create_refund( $child_id );
+		$this->exclude_child_orders_from_import();
+
+		$items = OrdersScheduler::get_items( 100, 1, false, true );
+		$ids   = array_map( 'intval', $items->ids );
+		$this->assertSame( $items->total, count( $ids ), 'The count and the page must agree.' );
+		$this->assertNotContains( $child_refund_id, $ids, 'get_items() must leave out the refund of an excluded order.' );
+		$this->assertContains( $refund_id, $ids, 'A refund of a kept order is still imported.' );
+		$this->assertTrue( OrdersScheduler::is_excluded_from_import( $child_refund_id ), 'The per-record check must exclude the refund too.' );
+
+		OrdersScheduler::import( $child_refund_id );
+		$this->assertSame( -1, OrdersStatsDataStore::update( wc_get_order( $child_refund_id ) ), 'The stats data store must skip the refund.' );
+		OrdersScheduler::process_pending_batch( '2000-01-01 00:00:00', 0 );
+
+		$imported = $this->get_imported_order_ids();
+		$this->assertNotContains( $child_refund_id, $imported, 'No import path may write the refund of an excluded order.' );
+		$this->assertNotContains( $child_id, $imported, 'The excluded order stays out.' );
+		$this->assertContains( $parent_id, $imported, 'The scheduled batch imports the kept order.' );
+		$this->assertContains( $refund_id, $imported, 'The scheduled batch imports the refund of the kept order.' );
+	}
+
+	/**
+	 * @testdox A refund is excluded along with an order whose conditions come out NULL.
+	 */
+	public function test_refund_of_order_with_null_condition_is_excluded(): void {
+		$order     = \WC_Helper_Order::create_order();
+		$refund_id = $this->create_refund( $order->get_id() );
+		add_filter(
+			'woocommerce_analytics_orders_import_where_clauses',
+			function ( $clauses, $table_alias ) {
+				// NULL for orders and true for refunds, like a comparison against an empty nullable column.
+				$column    = OrderUtil::custom_orders_table_usage_is_enabled() ? 'type' : 'post_type';
+				$clauses[] = "IF( {$table_alias}.{$column} = 'shop_order', NULL, 1 )";
+				return $clauses;
+			},
+			10,
+			2
+		);
+
+		$this->assertTrue( OrdersScheduler::is_excluded_from_import( $order->get_id() ), 'A NULL condition excludes the order.' );
+		$this->assertTrue( OrdersScheduler::is_excluded_from_import( $refund_id ), 'The refund inherits the exclusion of its order.' );
+		$this->assertNotContains( $refund_id, array_map( 'intval', OrdersScheduler::get_items( 100, 1, false, true )->ids ), 'get_items() agrees with the per-record check.' );
+	}
+
+	/**
+	 * @testdox A "skip existing" import that pages one record at a time finishes with excluded refunds present.
+	 */
+	public function test_skip_existing_import_finishes_with_excluded_refunds(): void {
+		list( , $child_id ) = $this->create_parent_child_and_refund();
+		$this->create_refund( $child_id );
+		$this->exclude_child_orders_from_import();
+
+		// ImportScheduler::import_batch() always reads page 1 while skipping existing records, so a
+		// counted record that never lands would be served again and again.
+		$rounds = 0;
+		do {
+			$items = OrdersScheduler::get_items( 1, 1, false, true );
+			foreach ( $items->ids as $id ) {
+				OrdersScheduler::import( (int) $id );
+			}
+			++$rounds;
+		} while ( $items->ids && $rounds < 10 );
+
+		$this->assertSame( 0, OrdersScheduler::get_items( 1, 1, false, true )->total, 'Every counted record was imported.' );
+	}
+
+	/**
+	 * @testdox A record imported before a condition excluded it keeps syncing through the scheduled batch.
+	 */
+	public function test_record_imported_before_exclusion_keeps_syncing(): void {
+		list( , $child_id ) = $this->create_parent_child_and_refund();
+		OrdersScheduler::import( $child_id );
+		$this->assertSame( OrderInternalStatus::COMPLETED, $this->get_imported_status( $child_id ), 'The child is imported before the condition exists.' );
+		$this->exclude_child_orders_from_import();
+
+		$child = wc_get_order( $child_id );
+		$child->set_status( OrderStatus::CANCELLED );
+		$child->save();
+		OrdersScheduler::process_pending_batch( '2000-01-01 00:00:00', 0 );
+
+		$this->assertFalse( OrdersScheduler::is_excluded_from_import( $child_id ), 'A record Analytics holds is never excluded.' );
+		$this->assertSame( OrderInternalStatus::CANCELLED, $this->get_imported_status( $child_id ), 'The status change reaches the held stats row.' );
+	}
+
+	/**
+	 * @testdox A new refund of an excluded order that Analytics holds is imported, so its sale is not overstated.
+	 */
+	public function test_new_refund_of_held_excluded_record_is_imported(): void {
+		list( , $child_id ) = $this->create_parent_child_and_refund();
+		OrdersScheduler::import( $child_id );
+		$this->exclude_child_orders_from_import();
+
+		$child_refund_id = $this->create_refund( $child_id );
+
+		$this->assertFalse( OrdersScheduler::is_excluded_from_import( $child_refund_id ), 'A refund of a held order is not excluded.' );
+		$this->assertContains( $child_refund_id, array_map( 'intval', OrdersScheduler::get_items( 100, 1, false, true )->ids ), 'get_items() counts it.' );
+
+		OrdersScheduler::import( $child_refund_id );
+
+		$this->assertContains( $child_refund_id, $this->get_imported_order_ids(), 'import() writes the new refund of the held order.' );
+	}
+
+	/**
+	 * @testdox A record with no row in the orders table is not excluded.
+	 */
+	public function test_is_excluded_from_import_ignores_missing_record(): void {
+		$this->add_import_condition( '0' );
+
+		$this->assertFalse( OrdersScheduler::is_excluded_from_import( 999999999 ), 'With no row to evaluate, the conditions cannot exclude the record.' );
+	}
+
+	/**
+	 * @testdox get_items() logs the error when the import conditions make the count query fail.
+	 */
+	public function test_get_items_logs_failed_count_query(): void {
+		global $wpdb;
+		$this->create_parent_child_and_refund();
+		$this->add_import_condition( 'no_such_column_for_this_test = 1' );
+
+		$suppress = $wpdb->suppress_errors();
+		$items    = OrdersScheduler::get_items( 100, 1, false, true );
+		$wpdb->suppress_errors( $suppress );
+
+		$this->assertSame( 0, $items->total, 'A failed count reads as nothing to import.' );
+		$this->assertSame( array(), $items->ids );
+		$this->assertLogged( 'error', 'Could not count the orders to import' );
+		$this->assertCount( 1, $this->get_logs_by_level( 'error' ), 'Only the failed count is logged, not the page query it skipped.' );
+	}
+
+	/**
+	 * @testdox get_items() applies the import conditions filter to both the count and the IDs.
+	 */
+	public function test_get_items_honors_import_where_clauses_filter(): void {
+		list( $parent_id, $child_id, $refund_id ) = $this->create_parent_child_and_refund();
+
+		$unfiltered = OrdersScheduler::get_items( 100, 1, false, true );
+		$this->exclude_child_orders_from_import();
+		$filtered = OrdersScheduler::get_items( 100, 1, false, true );
+
+		$unfiltered_ids = array_map( 'intval', $unfiltered->ids );
+		$filtered_ids   = array_map( 'intval', $filtered->ids );
+
+		$this->assertContains( $child_id, $unfiltered_ids, 'Without the filter the child order is imported as before.' );
+		$this->assertSame( $unfiltered->total - 1, $filtered->total, 'The count drops by the excluded child order.' );
+		$this->assertNotContains( $child_id, $filtered_ids );
+		$this->assertContains( $parent_id, $filtered_ids );
+		$this->assertContains( $refund_id, $filtered_ids, 'A refund keeps its order in the parent column and must survive a child-order condition.' );
+	}
+
+	/**
+	 * @testdox import() skips a record the import conditions filter excludes and imports the rest.
+	 */
+	public function test_import_skips_record_excluded_by_import_where_clauses_filter(): void {
+		list( $parent_id, $child_id, $refund_id ) = $this->create_parent_child_and_refund();
+		$this->exclude_child_orders_from_import();
+
+		OrdersScheduler::import( $child_id );
+		OrdersScheduler::import( $parent_id );
+
+		$imported = $this->get_imported_order_ids();
+		$this->assertNotContains( $child_id, $imported, 'The excluded child order is not imported.' );
+		$this->assertContains( $parent_id, $imported, 'The kept parent order is imported.' );
+		$this->assertContains( $refund_id, $imported, 'Importing the parent also syncs its refund.' );
+	}
+
+	/**
+	 * @testdox The scheduled import batch skips a record the import conditions filter excludes.
+	 */
+	public function test_process_pending_batch_skips_excluded_record(): void {
+		list( $parent_id, $child_id ) = $this->create_parent_child_and_refund();
+		$this->exclude_child_orders_from_import();
+
+		OrdersScheduler::process_pending_batch( '2000-01-01 00:00:00', 0 );
+
+		$imported = $this->get_imported_order_ids();
+		$this->assertContains( $parent_id, $imported );
+		$this->assertNotContains( $child_id, $imported );
+	}
+
+	/**
+	 * @testdox The stats data store refuses a record the import conditions filter excludes.
+	 */
+	public function test_stats_data_store_update_skips_excluded_record(): void {
+		list( , $child_id ) = $this->create_parent_child_and_refund();
+		$this->exclude_child_orders_from_import();
+
+		$result = OrdersStatsDataStore::update( wc_get_order( $child_id ) );
+
+		$this->assertSame( -1, $result, 'update() reports an excluded record as skipped (-1), not failed.' );
+		$this->assertNotContains( $child_id, $this->get_imported_order_ids() );
+	}
+
+	/**
+	 * @testdox An excluded record is dropped from the failed imports list instead of being retried forever.
+	 */
+	public function test_import_clears_failed_entry_for_excluded_record(): void {
+		list( , $child_id ) = $this->create_parent_child_and_refund();
+		OrdersScheduler::record_failed_order_import( $child_id );
+		$this->exclude_child_orders_from_import();
+
+		OrdersScheduler::import( $child_id );
+
+		$this->assertNotContains( $child_id, OrdersScheduler::get_failed_order_imports()['ids'] );
+	}
+
+	/**
+	 * @testdox A filter return that is not an array of strings is ignored.
+	 * @testWith [false]
+	 *           [[42, "  ", null]]
+	 *
+	 * @param mixed $invalid_return Value the filter returns.
+	 */
+	public function test_import_where_clauses_filter_ignores_invalid_return( $invalid_return ): void {
+		list( , $child_id ) = $this->create_parent_child_and_refund();
+		add_filter(
+			'woocommerce_analytics_orders_import_where_clauses',
+			function () use ( $invalid_return ) {
+				return $invalid_return;
+			}
+		);
+
+		$this->assertContains( $child_id, array_map( 'intval', OrdersScheduler::get_items( 100, 1, false, true )->ids ), 'get_items() ignores the return.' );
+		$this->assertFalse( OrdersScheduler::is_excluded_from_import( $child_id ), 'The per-record check ignores the return.' );
+	}
+
+	/**
+	 * @testdox A '0' condition excludes every record instead of being dropped as empty.
+	 */
+	public function test_import_where_clauses_filter_keeps_zero_condition(): void {
+		list( $parent_id ) = $this->create_parent_child_and_refund();
+		$this->add_import_condition( '0' );
+
+		$items = OrdersScheduler::get_items( 100, 1, false, true );
+
+		$this->assertSame( 0, $items->total, 'get_items() must count nothing.' );
+		$this->assertSame( array(), $items->ids );
+		$this->assertTrue( OrdersScheduler::is_excluded_from_import( $parent_id ), 'The per-record check must exclude too.' );
+	}
+
+	/**
+	 * @testdox A condition with a literal percent sign, such as a LIKE pattern, survives the prepared queries.
+	 */
+	public function test_import_where_clauses_filter_allows_like_patterns(): void {
+		list( $parent_id, $child_id, $refund_id ) = $this->create_parent_child_and_refund();
+		add_filter(
+			'woocommerce_analytics_orders_import_where_clauses',
+			function ( $clauses, $table_alias ) {
+				$column    = OrderUtil::custom_orders_table_usage_is_enabled() ? 'type' : 'post_type';
+				$clauses[] = "{$table_alias}.{$column} NOT LIKE '%shop_order_refund'"; // Starts with %s, which prepare() would read as a placeholder.
+				return $clauses;
+			},
+			10,
+			2
+		);
+
+		$items = OrdersScheduler::get_items( 100, 1, false, true );
+
+		$ids = array_map( 'intval', $items->ids );
+		$this->assertSame( $items->total, count( $ids ), 'The count and the page must agree.' );
+		$this->assertContains( $parent_id, $ids );
+		$this->assertContains( $child_id, $ids );
+		$this->assertNotContains( $refund_id, $ids, 'The LIKE condition excludes refunds in the prepared page query.' );
+		$this->assertTrue( OrdersScheduler::is_excluded_from_import( $refund_id ), 'The LIKE condition excludes refunds in the prepared probe.' );
+		$this->assertFalse( OrdersScheduler::is_excluded_from_import( $parent_id ) );
+
+		OrdersScheduler::process_pending_batch( '2000-01-01 00:00:00', 0 );
+		$imported = $this->get_imported_order_ids();
+		$this->assertContains( $parent_id, $imported, 'The prepared cursor query keeps the parent.' );
+		$this->assertNotContains( $refund_id, $imported, 'The prepared cursor query applies the LIKE condition.' );
+	}
+
+	/**
+	 * @testdox A failing exclusion check logs the error, imports nothing and keeps the record on the failed list.
+	 */
+	public function test_failed_exclusion_check_skips_import_and_keeps_record_for_retry(): void {
+		global $wpdb;
+		list( , $child_id ) = $this->create_parent_child_and_refund();
+		$callback           = $this->add_import_condition( 'no_such_column_for_this_test = 1' );
+
+		$suppress = $wpdb->suppress_errors();
+		$excluded = OrdersScheduler::is_excluded_from_import( $child_id );
+		OrdersScheduler::import( $child_id );
+		$updated = OrdersStatsDataStore::update( wc_get_order( $child_id ) );
+		$wpdb->suppress_errors( $suppress );
+		remove_filter( 'woocommerce_analytics_orders_import_where_clauses', $callback );
+
+		$this->assertNull( $excluded, 'A failed check is neither an exclusion nor a pass.' );
+		$this->assertLogged( 'error', "Could not check whether record #{$child_id} is excluded from the import" );
+		$this->assertFalse( $updated, 'The stats data store reports a failed sync and writes nothing.' );
+		$this->assertNotContains( $child_id, $this->get_imported_order_ids() );
+		$this->assertContains( $child_id, OrdersScheduler::get_failed_order_imports()['ids'], 'The record stays on the list Analytics settings retries.' );
+
+		// Once the condition is fixed, the retry imports the record.
+		OrdersScheduler::import( $child_id );
+		$this->assertContains( $child_id, $this->get_imported_order_ids() );
+		$this->assertNotContains( $child_id, OrdersScheduler::get_failed_order_imports()['ids'] );
+	}
+
+	/**
+	 * @testdox The scheduled batch leaves the cursor unchanged when the import conditions make the query fail.
+	 */
+	public function test_process_pending_batch_keeps_cursor_when_filtered_query_fails(): void {
+		global $wpdb;
+		list( $parent_id ) = $this->create_parent_child_and_refund();
+		update_option( OrdersScheduler::LAST_PROCESSED_ORDER_DATE_OPTION, '2000-01-01 00:00:00', false );
+		update_option( OrdersScheduler::LAST_PROCESSED_ORDER_ID_OPTION, 0, false );
+		$callback = $this->add_import_condition( 'no_such_column_for_this_test = 1' );
+
+		$suppress = $wpdb->suppress_errors();
+		OrdersScheduler::process_pending_batch();
+		$wpdb->suppress_errors( $suppress );
+		remove_filter( 'woocommerce_analytics_orders_import_where_clauses', $callback );
+
+		$this->assertSame( '2000-01-01 00:00:00', get_option( OrdersScheduler::LAST_PROCESSED_ORDER_DATE_OPTION ), 'The cursor must not move past orders the failed query never returned.' );
+		$this->assertNotContains( $parent_id, $this->get_imported_order_ids() );
+
+		// Once the condition is gone, the same cursor picks the orders up.
+		OrdersScheduler::process_pending_batch();
+		$this->assertContains( $parent_id, $this->get_imported_order_ids() );
+	}
+
 	/**
 	 * Test that batch processor is scheduled when called.
 	 */