Commit 040ba8e41d5 for woocommerce
commit 040ba8e41d5ebb5355ff34b86cf549e4134e470e
Author: Bruna <bruna.filippozzi@automattic.com>
Date: Mon Aug 10 17:01:55 2026 +0200
Fix trashed orders and deleted refunds still appearing in analytics reports (#64157)
* Fix trashed orders still appearing in analytics reports
Hook woocommerce_trash_order and woocommerce_untrash_order to
OrdersScheduler::import() so the wp_wc_order_stats table stays in sync.
trash_order() bypasses woocommerce_update_order (which normally triggers
the analytics sync), and the batch processor explicitly excludes trash
status orders from its queries, so these hooks are the only way to keep
analytics accurate when orders are trashed or restored.
Fixes woocommerce/woocommerce#44371
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Strengthen untrash test assertion to check exact restored status
assertNotSame('wc-trash') could pass on null/empty if sync fails.
Use assertSame('wc-completed') to verify the correct status is restored.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: add diagnostic assertions to OrdersSchedulerTest
Temporary diagnostic commit to understand why wc_order_stats row
disappears after trashing an order. Tracks whether hooks fire,
whether delete_post fires, and tests explicit import after trash.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: save order ID before delete() in OrdersSchedulerTest
WC_Abstract_Data::delete() unconditionally calls set_id(0) even for
soft deletes (trash). The tests were using $order->get_id() after
delete(false), which returned 0, making the DB query find no rows.
Fix: capture $order_id before calling delete(), and reload the order
from DB before untrashing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: handle untrash timing in OrdersScheduler
woocommerce_untrash_order fires before the status is restored in the DB,
so import() reads the stale trash status. Replace the import() handler
with sync_on_order_untrash() that uses the previous_status argument to
directly update wc_order_stats.
Also add untrashed_post handler for CPT-based stores, which do not fire
woocommerce_untrash_order at all.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: fix doc comment capitalization and alignment warning
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Skip post-untrash sync when HPOS is authoritative
When HPOS is the active data store and CPT sync is on, both
woocommerce_untrash_order and untrashed_post fire for the same
order. The first call already updates wp_wc_order_stats with the
correct previous_status; the second call would re-import the
order from the DB, performing a redundant write and a second
ReportsCache::invalidate().
Guard maybe_import_on_post_untrash with
OrderUtil::custom_orders_table_usage_is_enabled() so the CPT
hook is a no-op when HPOS owns the data, leaving the existing
sync_on_order_untrash callback as the single source of truth.
Add a focused regression test that plants a sentinel value in
wp_wc_order_stats and asserts maybe_import_on_post_untrash does
not overwrite it when HPOS is authoritative. The test markSkips
when HPOS is disabled (CPT-only configuration), where the hook
is still required.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Fix untrash test to route by storage mode instead of method_exists
WC_Data_Store proxies method calls via __call, so method_exists() never
detects untrash_order and the test always fell back to wp_untrash_post(),
which under HPOS only untrashes the placeholder post. Route by
OrderUtil::custom_orders_table_usage_is_enabled() instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01An5Xfr6sHQgviEpVKi3tmi
* Rework trash/untrash analytics sync to use scheduled import pipeline
Route woocommerce_trash_order/woocommerce_untrash_order through
possibly_schedule_import like every other order event, and add
trashed_post/untrashed_post listeners for CPT stores whose admin UI
never fires the data-store hooks. Include trashed orders in the
incremental batch cursor queries so scheduled import mode picks up
the transition; imports read the order's current status at run time,
which also removes the need for the bespoke untrash handlers.
* Remove deleted refunds from analytics lookup tables under HPOS
OrdersTableRefundDataStore::delete() fired no hooks, so deleting a
refund left its wc_order_stats and lookup rows orphaned, permanently
skewing Revenue, Orders and Products reports. Fire the same
woocommerce_delete_order_refund hook the legacy CPT store has fired
since 3.0.0, and add an analytics listener that removes the refund's
stats row and cascades lookup-table cleanup. The listener no-ops when
the row is already gone, so the CPT and HPOS-with-sync paths that
clean up via delete_post are unaffected.
* Update changelog entry for refund deletion analytics cleanup
* Sync untrashed orders from the status transition, not the untrash hook
woocommerce_untrash_order fires before OrdersTableDataStore::untrash_order()
persists the restored status. Scheduling the analytics import from it is fine
while Action Scheduler defers the work, but SchedulerTraits::schedule_action()
falls back to running the callback inline when the Action Scheduler tables are
unavailable or scheduling is disabled via the
woocommerce_analytics_disable_action_scheduling filter. On that path the import
reads the order while it is still trashed and writes wc-trash to wc_order_stats.
Nothing corrected it afterwards: OrdersTableDataStore::update() deliberately
suppresses woocommerce_update_order for trash transitions, so the restored order
stayed invisible in Analytics until an unrelated edit re-synced it.
Drive the import off woocommerce_order_status_changed instead, guarded on a
'trash' from-status. WC_Order::save() runs status_transition() after persisting,
so the new signal is accurate in both the scheduled and the synchronous paths.
The trash direction is unchanged — woocommerce_trash_order already fires after
trash_order() persists — and trashed_post/untrashed_post still cover CPT stores
whose admin UI never loads an order object.
Reported by @chihsuan in review of #64157.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoyaUuaNPLFvkT848QsSRg
* Run the refund analytics cleanup even without a stats row
delete_refund() bailed out when the refund had no wc_order_stats row, on the
assumption that the absence of a stats row meant there was nothing left to clean
up. That does not hold: OrdersScheduler::import() syncs the stats, product,
coupon, tax and customer lookups as five independent, non-transactional steps
and does not stop when an earlier one fails, so a partial import can leave
product/coupon/tax lookup rows behind without a stats row. Gating the cascade on
the stats row orphaned those rows permanently and kept skewing the Products
report.
Always delete and always fire woocommerce_analytics_delete_order_stats, reading
the customer ID with get_var so a missing row yields 0. That is safe on every
listener: CustomersDataStore::sync_on_order_delete() returns early on customer
ID 0, and the products, coupons and taxes listeners delete by ID and are
idempotent, so the CPT delete_post path that already cleaned up simply re-runs a
no-op.
Reported by @chihsuan in review of #64157.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoyaUuaNPLFvkT848QsSRg
* Skip analytics import for trashed orders that were never imported
Relaxing the batch cursor so it no longer excludes trashed orders means an order
created and trashed between two batch runs now reaches import() for the first
time while trashed. import() writes the stats row via
Order::get_report_customer_id(), which calls
CustomersDataStore::get_or_create_customer_from_order() and inserts a
wc_customer_lookup row as a side effect, firing woocommerce_analytics_new_customer.
Every report that joins wc_order_stats correctly excludes the trashed order, but
Analytics > Customers counts the lookup table directly, so the customer stayed
visible with no countable orders. Nothing removed it either, because the order
simply stayed in the trash — trashing a same-day test or fraud order permanently
inflated the customer count.
Skip the import when the record is trashed and has no stats row: it was never in
Analytics, so there is nothing to sync. Records imported before being trashed
still have a stats row, so their trashed status syncs as before, and a restored
order imports normally once its status is no longer trash.
Reported by @chihsuan in review of #64157.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoyaUuaNPLFvkT848QsSRg
* Limit the refund analytics cleanup to HPOS-authoritative storage
delete_refund() ran on every storage mode, but under CPT the cascade had already
run by the time it fired: WC_Order_Refund_Data_Store_CPT::delete() calls
wp_delete_post() — firing delete_post, and with it delete_order() and the whole
cleanup — before firing woocommerce_delete_order_refund. The public
woocommerce_analytics_delete_order_stats hook and its per-table sub-hooks
therefore fired twice for a single CPT refund deletion. Core absorbs that because
every listener deletes by ID, but non-idempotent third-party listeners would see
duplicate events.
Return early unless HPOS is authoritative. Nothing is lost on the CPT side:
delete_order() has no stats-row guard, so it clears the auxiliary lookup rows
whether or not a stats row survived the import — the same partial-import orphan
case the previous commit set out to handle. A test asserts exactly that, so the
assumption behind this gate fails loudly if it ever stops holding.
Under HPOS-with-sync the ordering is reversed — the HPOS record is deleted before
the backup post, so delete_order()'s OrderUtil::is_order() guard short-circuits —
which is why this remains the only cleanup that runs there.
Reported by @chihsuan in review of #64157.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoyaUuaNPLFvkT848QsSRg
* Route trash and untrash signals to one handler per storage mode
The CPT data store emits both signals for a single operation: delete() calls
wp_trash_post() and then fires woocommerce_trash_order, and untrash_order()
calls wp_untrash_post() and then saves the status transition. trashed_post and
untrashed_post were already guarded to CPT, but woocommerce_trash_order and the
status-transition listener were not guarded to HPOS, so under CPT both signals
scheduled an import for the same transition.
The Action Scheduler queue deduplicates that in the scheduled path and the import
is an idempotent REPLACE in the synchronous one, so no data was wrong. But the
work ran twice, and in the synchronous path the public
woocommerce_order_scheduler_after_import_order hook fired twice for one
transition, which third-party listeners can observe.
Give each store exactly one signal: the Woo-side hooks for HPOS, the post-side
hooks for CPT. New tests assert one import per CPT trash and per CPT untrash, and
that CPT restores still sync the restored status when scheduling falls back to
running inline — the path CPT now depends on exclusively.
Reported by @chihsuan in review of #64157.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoyaUuaNPLFvkT848QsSRg
* Pin behaviour when a trashed order was only partially imported
Review follow-up. The skip guard tests for a missing stats row, which does not
strictly prove the record was never imported: a sync that fails after
Order::get_report_customer_id() has run leaves a customer row, and possibly
product lookup rows, without one.
Skipping is still correct in that state. import() only creates and updates rows,
so running it would not clean the remnant up — it would only add a wc-trash stats
row that every report already filters out. Measured both ways on a partially
imported order: customer and lookup counts are identical, and no new customer is
created either way.
Corrects the comment, which claimed the guard identified records that were never
imported, and adds a test covering the partial-import state so the reasoning is
pinned rather than assumed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoyaUuaNPLFvkT848QsSRg
* Condense comments and docblocks
Trim the explanatory comments added across this branch to the reasoning that is
not already evident from the code. Net -65 lines, no behaviour change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoyaUuaNPLFvkT848QsSRg
---------
Co-authored-by: Bruna <bruberries@MacBook-Pro-9.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Greg <71906536+zhongruige@users.noreply.github.com>
Co-authored-by: Bruna Filippozzi <bruna.filippozzi@a8c.com>
Co-authored-by: Brandon Kraft <public@brandonkraft.com>
Co-authored-by: Lourens Schep <lourensschep@gmail.com>
diff --git a/plugins/woocommerce/changelog/fix-trashed-orders-analytics-sync b/plugins/woocommerce/changelog/fix-trashed-orders-analytics-sync
new file mode 100644
index 00000000000..a66b50a85fb
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-trashed-orders-analytics-sync
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Sync trashed and untrashed order status to analytics so trashed orders no longer appear in reports, and remove deleted refunds from analytics lookup tables under HPOS.
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 26788359d13..3d9c22612a7 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
@@ -146,6 +146,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
public static function init() {
add_action( 'woocommerce_before_delete_order', array( __CLASS__, 'delete_order' ) );
add_action( 'delete_post', array( __CLASS__, 'delete_order' ) );
+ add_action( 'woocommerce_delete_order_refund', array( __CLASS__, 'delete_refund' ) );
}
/**
@@ -687,6 +688,54 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
ReportsCache::invalidate();
}
+ /**
+ * Deletes the refund stats when a refund is deleted.
+ *
+ * This hook fires after the refund is gone, so delete_order() cannot be reused —
+ * its is_order() guard and wc_get_order() call both need the record. The customer
+ * ID comes from the stats row instead.
+ *
+ * HPOS only. CPT deletes the post first, so delete_post has already run the whole
+ * cascade; repeating it here would fire the public hooks twice for one deletion.
+ * Under HPOS-with-sync the HPOS record goes first, so delete_order() short-circuits
+ * and this is the only cleanup that runs.
+ *
+ * The cascade runs even without a stats row: imports are not atomic, so lookup rows
+ * can outlive one, and skipping would orphan them.
+ *
+ * @internal
+ * @since 11.1.0
+ * @param int $refund_id Refund ID.
+ */
+ public static function delete_refund( $refund_id ): void {
+ global $wpdb;
+
+ if ( ! OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ return;
+ }
+
+ $refund_id = (int) $refund_id;
+ $table_name = self::get_db_table_name();
+
+ // A missing row yields customer ID 0, on which the customer cleanup no-ops.
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $customer_id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT customer_id FROM {$table_name} WHERE order_id = %d", $refund_id ) );
+
+ $wpdb->delete( $table_name, array( 'order_id' => $refund_id ) );
+
+ /**
+ * Fires when orders stats are deleted.
+ *
+ * @param int $order_id Order ID.
+ * @param int $customer_id Customer ID.
+ *
+ * @since 4.0.0
+ */
+ do_action( 'woocommerce_analytics_delete_order_stats', $refund_id, absint( $customer_id ) );
+
+ ReportsCache::invalidate();
+ }
+
/**
* Calculation methods.
diff --git a/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php b/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
index 129281d9b64..001007b5ed8 100644
--- a/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
+++ b/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
@@ -14,6 +14,7 @@ use Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore as OrderDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Products\DataStore as ProductsDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Taxes\DataStore as TaxesDataStore;
+use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Utilities\OrderUtil;
@@ -131,6 +132,13 @@ class OrdersScheduler extends ImportScheduler {
add_filter( 'woocommerce_create_order', array( __CLASS__, 'possibly_schedule_import' ) );
add_action( 'woocommerce_refund_created', array( __CLASS__, 'possibly_schedule_import' ) );
add_action( 'woocommerce_schedule_import', array( __CLASS__, 'possibly_schedule_import' ) );
+
+ // Trash and untrash bypass woocommerce_update_order. CPT emits both the Woo
+ // and the post hooks for one operation, so each handler takes a single store.
+ add_action( 'woocommerce_trash_order', array( __CLASS__, 'maybe_schedule_import_on_trash' ) );
+ add_action( 'woocommerce_order_status_changed', array( __CLASS__, 'maybe_schedule_import_on_untrash' ), 10, 2 );
+ add_action( 'trashed_post', array( __CLASS__, 'maybe_schedule_import_on_post_trash_change' ) );
+ add_action( 'untrashed_post', array( __CLASS__, 'maybe_schedule_import_on_post_trash_change' ) );
}
// Watch for changes to the scheduled import option.
@@ -391,6 +399,14 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
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
+ // stats row, so their status still syncs.
+ if ( OrderStatus::TRASH === $order->get_status() && ! self::has_order_stats_row( $order_id ) ) {
+ return;
+ }
+
$results = array(
OrdersStatsDataStore::sync_order( $order_id ),
ProductsDataStore::sync_order_products( $order_id ),
@@ -422,6 +438,81 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
do_action( 'woocommerce_order_scheduler_after_import_order', $order_id );
}
+ /**
+ * Check whether an order or refund already has a row in the order stats table.
+ *
+ * @param int $order_id Order or refund ID.
+ * @return bool
+ */
+ private static function has_order_stats_row( $order_id ): bool {
+ global $wpdb;
+
+ $table_name = OrdersStatsDataStore::get_db_table_name();
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT order_id FROM {$table_name} WHERE order_id = %d LIMIT 1", $order_id ) );
+ }
+
+ /**
+ * Schedule an analytics import when a CPT-based order is trashed or untrashed.
+ *
+ * The CPT admin UI trashes via wp_trash_post(), which fires no Woo-side signal.
+ * HPOS has its own handlers, so it is skipped here to avoid a duplicate import.
+ *
+ * @internal
+ * @since 11.1.0
+ * @param int $post_id Post ID.
+ */
+ public static function maybe_schedule_import_on_post_trash_change( $post_id ): void {
+ if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ return;
+ }
+
+ self::possibly_schedule_import( $post_id );
+ }
+
+ /**
+ * Schedule an analytics import when an HPOS order is trashed.
+ *
+ * CPT fires this hook too, on top of trashed_post, so it is handled there instead.
+ *
+ * @internal
+ * @since 11.1.0
+ * @param int $order_id Order ID.
+ */
+ public static function maybe_schedule_import_on_trash( $order_id ): void {
+ if ( ! OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ return;
+ }
+
+ self::possibly_schedule_import( $order_id );
+ }
+
+ /**
+ * Driven off the status transition rather than woocommerce_untrash_order, which
+ * fires before the restored status is saved — so an import running synchronously
+ * would record wc-trash, and nothing corrects it because the data store suppresses
+ * woocommerce_update_order for trash transitions.
+ *
+ * CPT reaches this via its own save, so it is handled by untrashed_post instead.
+ *
+ * @internal
+ * @since 11.1.0
+ * @param int $order_id Order ID.
+ * @param string $from_status Status the order transitioned from.
+ */
+ public static function maybe_schedule_import_on_untrash( $order_id, $from_status ): void {
+ if ( OrderStatus::TRASH !== $from_status ) {
+ return;
+ }
+
+ if ( ! OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ return;
+ }
+
+ self::possibly_schedule_import( $order_id );
+ }
+
/**
* Schedule recurring batch processor for order imports.
*
@@ -684,7 +775,7 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
"SELECT id, date_updated_gmt
FROM {$orders_table}
WHERE type IN ('shop_order', 'shop_order_refund')
- AND status NOT IN ('wc-auto-draft', 'auto-draft', 'trash')
+ AND status NOT IN ('wc-auto-draft', 'auto-draft')
AND (
date_updated_gmt > %s
OR (date_updated_gmt = %s AND id > %d)
@@ -720,7 +811,7 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
"SELECT ID as id, post_modified_gmt as date_updated_gmt
FROM {$wpdb->posts}
WHERE post_type IN ('shop_order', 'shop_order_refund')
- AND post_status NOT IN ('wc-auto-draft', 'auto-draft', 'trash')
+ AND post_status NOT IN ('wc-auto-draft', 'auto-draft')
AND (
post_modified_gmt > %s
OR (post_modified_gmt = %s AND ID > %d)
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableRefundDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableRefundDataStore.php
index 85bed5fc1d3..23f9fdfda7d 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableRefundDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableRefundDataStore.php
@@ -96,6 +96,14 @@ class OrdersTableRefundDataStore extends OrdersTableDataStore {
$this->handle_order_deletion_with_sync_disabled( $refund_id );
}
}
+
+ /**
+ * Fires when a refund is deleted.
+ *
+ * @param int $refund_id The refund ID.
+ * @since 3.0.0
+ */
+ do_action( 'woocommerce_delete_order_refund', $refund_id );
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreTest.php b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreTest.php
index 4d901105696..e2bb1016847 100644
--- a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreTest.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Tests\Admin\API\Reports\Orders\Stats;
use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore;
use Automattic\WooCommerce\Caches\OrderCache;
+use Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Helper_Order;
use WC_Unit_Test_Case;
@@ -196,4 +197,256 @@ class DataStoreTest extends WC_Unit_Test_Case {
WC_Helper_Order::delete_order( $order->get_id() );
}
+
+ /**
+ * @testdox Deleting a refund removes its analytics rows while keeping the parent order's rows.
+ *
+ * Regression test for HPOS refund deletion leaving orphaned analytics rows:
+ * OrdersTableRefundDataStore::delete() fired no hooks, so the refund's
+ * wc_order_stats and wc_order_product_lookup rows survived the deletion and
+ * permanently skewed Revenue, Orders and Products reports.
+ *
+ * @see https://github.com/woocommerce/woocommerce/issues/48955
+ */
+ public function test_deleting_refund_removes_analytics_rows(): void {
+ global $wpdb;
+
+ $order = WC_Helper_Order::create_order();
+ $order->update_status( 'completed' );
+ $order_id = $order->get_id();
+
+ $items = array_values( $order->get_items() );
+ $refund = wc_create_refund(
+ array(
+ 'order_id' => $order_id,
+ 'amount' => 10,
+ 'line_items' => array(
+ $items[0]->get_id() => array(
+ 'qty' => 1,
+ 'refund_total' => 10,
+ ),
+ ),
+ )
+ );
+ $this->assertNotInstanceOf( WP_Error::class, $refund );
+ $refund_id = $refund->get_id();
+
+ // Import both records, as the woocommerce_update_order and
+ // woocommerce_refund_created flows would.
+ OrdersScheduler::import( $order_id );
+ OrdersScheduler::import( $refund_id );
+
+ $stats_rows = static function ( $id ) use ( $wpdb ) {
+ return (int) $wpdb->get_var(
+ $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d", $id )
+ );
+ };
+ $lookup_rows = static function ( $id ) use ( $wpdb ) {
+ return (int) $wpdb->get_var(
+ $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_product_lookup WHERE order_id = %d", $id )
+ );
+ };
+
+ $this->assertSame( 1, $stats_rows( $order_id ), 'Parent order should have a stats row after import.' );
+ $this->assertSame( 1, $stats_rows( $refund_id ), 'Refund should have a stats row after import.' );
+ $this->assertGreaterThan( 0, $lookup_rows( $refund_id ), 'Refund should have product lookup rows after import.' );
+
+ // Delete the refund the way the admin UI and REST API do.
+ $refund->delete( true );
+
+ $this->assertSame( 0, $stats_rows( $refund_id ), 'Deleting a refund should remove its stats row.' );
+ $this->assertSame( 0, $lookup_rows( $refund_id ), 'Deleting a refund should remove its product lookup rows.' );
+ $this->assertSame( 1, $stats_rows( $order_id ), 'Deleting a refund should keep the parent order stats row.' );
+
+ WC_Helper_Order::delete_order( $order_id );
+ }
+
+ /**
+ * @testdox delete_refund still runs the cleanup cascade when no stats row exists.
+ *
+ * Imports are not atomic, so lookup rows can outlive the stats row. Gating on it
+ * would orphan them, so the cascade runs regardless, with a customer ID of 0.
+ */
+ public function test_delete_refund_runs_cascade_when_no_stats_row_exists(): void {
+ if ( ! OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ $this->markTestSkipped( 'delete_refund() only runs when HPOS is authoritative; CPT is cleaned via delete_post.' );
+ }
+
+ $fired = 0;
+ $seen_ids = array();
+ $seen_customer = null;
+ $callback = function ( $order_id, $customer_id ) use ( &$fired, &$seen_ids, &$seen_customer ) {
+ ++$fired;
+ $seen_ids[] = $order_id;
+ $seen_customer = $customer_id;
+ };
+ add_action( 'woocommerce_analytics_delete_order_stats', $callback, 10, 2 );
+
+ OrdersStatsDataStore::delete_refund( 987654321 );
+
+ remove_action( 'woocommerce_analytics_delete_order_stats', $callback, 10 );
+
+ $this->assertSame( 1, $fired, 'delete_refund should fire the delete-stats cascade even when no stats row exists.' );
+ $this->assertSame( array( 987654321 ), $seen_ids, 'The cascade should carry the refund ID.' );
+ $this->assertSame( 0, $seen_customer, 'A missing stats row should yield customer ID 0.' );
+ }
+
+ /**
+ * @testdox delete_refund removes orphaned product lookup rows left by a partial import.
+ *
+ * Stats and product lookups sync in separate, non-transactional steps, so the
+ * lookup rows can outlive a failed stats sync.
+ */
+ public function test_delete_refund_removes_orphaned_lookup_rows_without_stats_row(): void {
+ global $wpdb;
+
+ if ( ! OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ $this->markTestSkipped( 'delete_refund() only runs when HPOS is authoritative; the CPT equivalent is covered below.' );
+ }
+
+ $refund_id = 987654322;
+
+ // Simulate a partial import: product lookup row present, stats row absent.
+ $wpdb->insert(
+ $wpdb->prefix . 'wc_order_product_lookup',
+ array(
+ 'order_item_id' => 987654322,
+ 'order_id' => $refund_id,
+ 'product_id' => 1,
+ 'variation_id' => 0,
+ 'customer_id' => 0,
+ 'date_created' => '2026-01-01 00:00:00',
+ 'product_qty' => -1,
+ 'product_net_revenue' => -10,
+ 'product_gross_revenue' => -10,
+ )
+ );
+
+ $lookup_rows = static function ( $id ) use ( $wpdb ) {
+ return (int) $wpdb->get_var(
+ $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_product_lookup WHERE order_id = %d", $id )
+ );
+ };
+ $stats_rows = static function ( $id ) use ( $wpdb ) {
+ return (int) $wpdb->get_var(
+ $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d", $id )
+ );
+ };
+
+ $this->assertSame( 1, $lookup_rows( $refund_id ), 'Fixture should leave a product lookup row.' );
+ $this->assertSame( 0, $stats_rows( $refund_id ), 'Fixture should leave no stats row.' );
+
+ OrdersStatsDataStore::delete_refund( $refund_id );
+
+ $this->assertSame( 0, $lookup_rows( $refund_id ), 'Deleting a refund should clear orphaned product lookup rows.' );
+ }
+
+ /**
+ * @testdox Deleting a CPT refund fires the analytics delete cascade exactly once.
+ *
+ * The CPT store deletes the post — running the cascade via delete_post — before
+ * firing woocommerce_delete_order_refund, so delete_refund() must stand down or
+ * listeners see two events for one deletion.
+ */
+ public function test_deleting_cpt_refund_fires_delete_cascade_once(): void {
+ if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ $this->markTestSkipped( 'Test requires CPT to be the authoritative store.' );
+ }
+
+ $order = WC_Helper_Order::create_order();
+ $order->update_status( 'completed' );
+ $order_id = $order->get_id();
+
+ $items = array_values( $order->get_items() );
+ $refund = wc_create_refund(
+ array(
+ 'order_id' => $order_id,
+ 'amount' => 10,
+ 'line_items' => array(
+ $items[0]->get_id() => array(
+ 'qty' => 1,
+ 'refund_total' => 10,
+ ),
+ ),
+ )
+ );
+ $this->assertNotInstanceOf( WP_Error::class, $refund );
+ $refund_id = $refund->get_id();
+
+ OrdersScheduler::import( $order_id );
+ OrdersScheduler::import( $refund_id );
+
+ $fired = 0;
+ $callback = function ( $deleted_id ) use ( &$fired, $refund_id ) {
+ if ( (int) $deleted_id === $refund_id ) {
+ ++$fired;
+ }
+ };
+ add_action( 'woocommerce_analytics_delete_order_stats', $callback );
+
+ $refund->delete( true );
+
+ remove_action( 'woocommerce_analytics_delete_order_stats', $callback );
+
+ $this->assertSame( 1, $fired, 'The delete cascade should fire exactly once per CPT refund deletion.' );
+
+ WC_Helper_Order::delete_order( $order_id );
+ }
+
+ /**
+ * @testdox Deleting a CPT refund clears orphaned lookup rows left by a partial import.
+ *
+ * This is what makes the HPOS gate safe: delete_order() has no stats-row guard, so
+ * it clears the lookup rows whether or not a stats row survived.
+ */
+ public function test_deleting_cpt_refund_clears_orphaned_lookup_rows(): void {
+ global $wpdb;
+
+ if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ $this->markTestSkipped( 'Test requires CPT to be the authoritative store.' );
+ }
+
+ $order = WC_Helper_Order::create_order();
+ $order->update_status( 'completed' );
+ $order_id = $order->get_id();
+
+ $items = array_values( $order->get_items() );
+ $refund = wc_create_refund(
+ array(
+ 'order_id' => $order_id,
+ 'amount' => 10,
+ 'line_items' => array(
+ $items[0]->get_id() => array(
+ 'qty' => 1,
+ 'refund_total' => 10,
+ ),
+ ),
+ )
+ );
+ $this->assertNotInstanceOf( WP_Error::class, $refund );
+ $refund_id = $refund->get_id();
+
+ OrdersScheduler::import( $order_id );
+ OrdersScheduler::import( $refund_id );
+
+ // Simulate a partial import: drop the stats row but leave the lookup rows.
+ $wpdb->delete( $wpdb->prefix . 'wc_order_stats', array( 'order_id' => $refund_id ) );
+
+ $lookup_rows = static function ( $id ) use ( $wpdb ) {
+ return (int) $wpdb->get_var(
+ $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_product_lookup WHERE order_id = %d", $id )
+ );
+ };
+ $this->assertGreaterThan( 0, $lookup_rows( $refund_id ), 'Fixture should leave orphaned product lookup rows.' );
+
+ $refund->delete( true );
+
+ $this->assertSame(
+ 0,
+ $lookup_rows( $refund_id ),
+ 'delete_post -> delete_order() should clear orphaned lookup rows under CPT, so gating delete_refund() to HPOS loses nothing.'
+ );
+
+ WC_Helper_Order::delete_order( $order_id );
+ }
}
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 011bbb9592b..9974d22116d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php
@@ -5,6 +5,7 @@ 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\Utilities\OrderUtil;
use WC_Unit_Test_Case;
/**
@@ -510,6 +511,471 @@ class OrdersSchedulerTest extends WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox Trashing an order schedules an import that syncs the trash status to wp_wc_order_stats.
+ *
+ * @see https://github.com/woocommerce/woocommerce/issues/44371
+ */
+ public function test_trash_order_schedules_import_that_syncs_status_to_order_stats(): void {
+ global $wpdb;
+
+ // Immediate import mode: trash/untrash schedule a per-order import action.
+ update_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION, 'no' );
+
+ // Create and import a completed order.
+ $order = \WC_Helper_Order::create_order();
+ $order->set_status( 'completed' );
+ $order->save();
+ $order_id = $order->get_id();
+ OrdersScheduler::import( $order_id );
+
+ // Verify the order stats row has wc-completed status.
+ $status_before = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT status FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d",
+ $order_id
+ )
+ );
+ $this->assertSame( 'wc-completed', $status_before );
+
+ // Trash the order. Note: delete() sets the object's ID to 0, so we use $order_id.
+ $order->delete( false );
+
+ // The trash hook should have scheduled an import for this order.
+ $this->assertTrue(
+ as_has_scheduled_action( OrdersScheduler::get_action( 'import' ), array( $order_id ), OrdersScheduler::$group ),
+ 'Trashing an order should schedule a wc-admin_import_orders action.'
+ );
+
+ \WC_Helper_Queue::run_all_pending( OrdersScheduler::$group );
+
+ // Verify the order stats row now has wc-trash status.
+ $status_after = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT status FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d",
+ $order_id
+ )
+ );
+ $this->assertSame( 'wc-trash', $status_after );
+ }
+
+ /**
+ * @testdox Untrashing an order schedules an import that restores the status in wp_wc_order_stats.
+ *
+ * @see https://github.com/woocommerce/woocommerce/issues/44371
+ */
+ public function test_untrash_order_schedules_import_that_restores_status_in_order_stats(): void {
+ global $wpdb;
+
+ update_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION, 'no' );
+
+ // Create, complete, import, then trash an order and sync the trash status.
+ $order = \WC_Helper_Order::create_order();
+ $order->set_status( 'completed' );
+ $order->save();
+ $order_id = $order->get_id();
+ OrdersScheduler::import( $order_id );
+ $order->delete( false );
+ \WC_Helper_Queue::run_all_pending( OrdersScheduler::$group );
+
+ // Verify the order is trashed in stats.
+ // Note: delete() sets the object's ID to 0, so we use $order_id.
+ $status_trashed = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT status FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d",
+ $order_id
+ )
+ );
+ $this->assertSame( 'wc-trash', $status_trashed );
+
+ // Untrash the order — reload since delete() cleared the in-memory ID.
+ // WC_Data_Store proxies via __call, so method_exists() can't detect
+ // untrash_order; route by the authoritative storage mode instead.
+ $order = wc_get_order( $order_id );
+ if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ $order->get_data_store()->untrash_order( $order );
+ } else {
+ wp_untrash_post( $order_id );
+ }
+
+ // The untrash hook schedules an import, which runs only after the
+ // restored status has been persisted.
+ \WC_Helper_Queue::run_all_pending( OrdersScheduler::$group );
+
+ // Verify the order stats row is restored.
+ $status_restored = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT status FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d",
+ $order_id
+ )
+ );
+ $this->assertSame( 'wc-completed', $status_restored );
+ }
+
+ /**
+ * @testdox Untrashing syncs the restored status even when action scheduling runs synchronously.
+ *
+ * woocommerce_untrash_order fires before the restored status is saved, so an inline
+ * import recorded wc-trash — and nothing corrected it, since the data store
+ * suppresses woocommerce_update_order for trash transitions.
+ *
+ * @see https://github.com/woocommerce/woocommerce/issues/44371
+ */
+ public function test_untrash_syncs_restored_status_when_action_scheduling_is_synchronous(): void {
+ global $wpdb;
+
+ if ( ! OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ $this->markTestSkipped( 'Only HPOS fires the untrash hook before persisting; the CPT path is covered via untrashed_post.' );
+ }
+
+ update_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION, 'no' );
+
+ // Force schedule_action() down its synchronous fallback.
+ add_filter( 'woocommerce_analytics_disable_action_scheduling', '__return_true' );
+
+ $read_status = static function ( $id ) use ( $wpdb ) {
+ return $wpdb->get_var(
+ $wpdb->prepare( "SELECT status FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d", $id )
+ );
+ };
+
+ try {
+ $order = \WC_Helper_Order::create_order();
+ $order->set_status( 'completed' );
+ $order->save();
+ $order_id = $order->get_id();
+ OrdersScheduler::import( $order_id );
+
+ // Trash. woocommerce_trash_order fires after the status is persisted, so the
+ // synchronous import records it correctly without any queue run.
+ $order->delete( false );
+ $this->assertSame( 'wc-trash', $read_status( $order_id ), 'Trashing should sync wc-trash synchronously.' );
+
+ // Restore. Note delete() cleared the in-memory ID, so reload first.
+ $order = wc_get_order( $order_id );
+ $order->get_data_store()->untrash_order( $order );
+
+ // Deliberately no queue run: the import already ran inline, and it must have
+ // seen the restored status rather than the trashed one.
+ $this->assertSame(
+ 'wc-completed',
+ $read_status( $order_id ),
+ 'Untrashing should sync the restored status, not wc-trash, on the synchronous path.'
+ );
+ } finally {
+ remove_filter( 'woocommerce_analytics_disable_action_scheduling', '__return_true' );
+ }
+ }
+
+ /**
+ * @testdox maybe_schedule_import_on_untrash ignores transitions that are not restores.
+ */
+ public function test_maybe_schedule_import_on_untrash_ignores_non_trash_transitions(): void {
+ if ( ! OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ $this->markTestSkipped( 'The status-transition listener is HPOS-only; CPT restores are covered by untrashed_post.' );
+ }
+
+ update_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION, 'no' );
+
+ $order = \WC_Helper_Order::create_order();
+ $order->set_status( 'completed' );
+ $order->save();
+
+ $this->clear_scheduled_import( $order->get_id() );
+
+ OrdersScheduler::maybe_schedule_import_on_untrash( $order->get_id(), 'pending' );
+
+ $this->assertFalse(
+ $this->is_import_scheduled( $order->get_id() ),
+ 'A transition that does not come from trash should not schedule an import.'
+ );
+
+ OrdersScheduler::maybe_schedule_import_on_untrash( $order->get_id(), 'trash' );
+
+ $this->assertTrue(
+ $this->is_import_scheduled( $order->get_id() ),
+ 'A transition out of trash should schedule an import.'
+ );
+
+ $this->clear_scheduled_import( $order->get_id() );
+ }
+
+ /**
+ * @testdox Under CPT, trashing and restoring each schedule exactly one import.
+ *
+ * CPT emits both the post hook and the Woo-side signal per operation, so acting on
+ * both would import twice and fire the public post-import hook twice.
+ */
+ public function test_cpt_trash_and_untrash_each_schedule_a_single_import(): void {
+ if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ $this->markTestSkipped( 'Test requires CPT to be the authoritative store.' );
+ }
+
+ update_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION, 'no' );
+
+ // Run inline so each import is observable. Must precede the fixture: otherwise
+ // saving queues a real action and has_existing_jobs() suppresses what we count.
+ add_filter( 'woocommerce_analytics_disable_action_scheduling', '__return_true' );
+
+ $order = \WC_Helper_Order::create_order();
+ $order->set_status( 'completed' );
+ $order->save();
+ $order_id = $order->get_id();
+ OrdersScheduler::import( $order_id );
+
+ $imports = 0;
+ $callback = function ( $imported_id ) use ( &$imports, $order_id ) {
+ if ( (int) $imported_id === $order_id ) {
+ ++$imports;
+ }
+ };
+ add_action( 'woocommerce_order_scheduler_after_import_order', $callback );
+
+ try {
+ $order->delete( false );
+ $this->assertSame( 1, $imports, 'Trashing a CPT order should schedule exactly one import.' );
+
+ $imports = 0;
+ wp_untrash_post( $order_id );
+ $this->assertSame( 1, $imports, 'Restoring a CPT order should schedule exactly one import.' );
+ } finally {
+ remove_filter( 'woocommerce_analytics_disable_action_scheduling', '__return_true' );
+ remove_action( 'woocommerce_order_scheduler_after_import_order', $callback );
+ }
+ }
+
+ /**
+ * @testdox Under CPT, restoring syncs the status even when scheduling runs synchronously.
+ *
+ * Companion to the HPOS synchronous-untrash test: once the status-transition
+ * listener is HPOS-only, CPT restores depend entirely on untrashed_post, which
+ * fires after wp_untrash_post() has persisted the restored status.
+ */
+ public function test_cpt_untrash_syncs_restored_status_when_scheduling_is_synchronous(): void {
+ global $wpdb;
+
+ if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ $this->markTestSkipped( 'Test requires CPT to be the authoritative store.' );
+ }
+
+ update_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION, 'no' );
+ add_filter( 'woocommerce_analytics_disable_action_scheduling', '__return_true' );
+
+ $read_status = static function ( $id ) use ( $wpdb ) {
+ return $wpdb->get_var(
+ $wpdb->prepare( "SELECT status FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d", $id )
+ );
+ };
+
+ try {
+ $order = \WC_Helper_Order::create_order();
+ $order->set_status( 'completed' );
+ $order->save();
+ $order_id = $order->get_id();
+ OrdersScheduler::import( $order_id );
+
+ $order->delete( false );
+ $this->assertSame( 'wc-trash', $read_status( $order_id ), 'Trashing should sync wc-trash synchronously under CPT.' );
+
+ wp_untrash_post( $order_id );
+ $this->assertSame(
+ 'wc-completed',
+ $read_status( $order_id ),
+ 'Restoring should sync the restored status under CPT on the synchronous path.'
+ );
+ } finally {
+ remove_filter( 'woocommerce_analytics_disable_action_scheduling', '__return_true' );
+ }
+ }
+
+ /**
+ * @testdox maybe_schedule_import_on_post_trash_change is a no-op when HPOS is the authoritative store.
+ *
+ * When HPOS is active the woocommerce_trash_order/woocommerce_untrash_order hooks
+ * already schedule the import. The trashed_post/untrashed_post hooks then fire for
+ * the same order, so this handler must early-return to avoid a redundant import.
+ *
+ * @see https://github.com/woocommerce/woocommerce/issues/44371
+ */
+ public function test_maybe_schedule_import_on_post_trash_change_skips_when_hpos_active(): void {
+ if ( ! OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ $this->markTestSkipped( 'Test requires HPOS to be the authoritative store.' );
+ }
+
+ update_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION, 'no' );
+
+ $order = \WC_Helper_Order::create_order();
+ $order_id = $order->get_id();
+
+ // Creating the order schedules an import of its own; clear the queue so
+ // the assertion below isolates this handler.
+ \WC_Helper_Queue::cancel_all_pending();
+
+ OrdersScheduler::maybe_schedule_import_on_post_trash_change( $order_id );
+
+ $this->assertFalse(
+ as_has_scheduled_action( OrdersScheduler::get_action( 'import' ), array( $order_id ), OrdersScheduler::$group ),
+ 'maybe_schedule_import_on_post_trash_change should not schedule an import when HPOS is the authoritative store.'
+ );
+ }
+
+ /**
+ * @testdox process_pending_batch picks up trashed orders and syncs wc-trash to wp_wc_order_stats.
+ *
+ * In scheduled import mode no per-order action is created on trash; the batch
+ * cursor query must include trashed orders so the transition is synced.
+ *
+ * @see https://github.com/woocommerce/woocommerce/issues/44371
+ */
+ public function test_process_pending_batch_picks_up_trashed_order(): void {
+ global $wpdb;
+
+ // Scheduled import mode is set in setUp().
+ $order = \WC_Helper_Order::create_order();
+ $order->set_status( 'completed' );
+ $order->save();
+ $order_id = $order->get_id();
+ OrdersScheduler::import( $order_id );
+
+ // Trash the order. Note: delete() sets the object's ID to 0, so we use $order_id.
+ $order->delete( false );
+
+ OrdersScheduler::process_pending_batch( '2020-01-01 00:00:00', 0 );
+
+ $status_after = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT status FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d",
+ $order_id
+ )
+ );
+ $this->assertSame( 'wc-trash', $status_after );
+ }
+
+ /**
+ * @testdox A trashed order that was never imported does not create an analytics customer.
+ *
+ * An order created and trashed between batch runs reaches import() for the first
+ * time while trashed, and the stats sync creates a wc_customer_lookup row as a side
+ * effect. Reports join wc_order_stats and hide the order, but Analytics > Customers
+ * counts the lookup table directly, so the customer would linger with no orders.
+ *
+ * @see https://github.com/woocommerce/woocommerce/pull/64157
+ */
+ public function test_never_imported_trashed_order_does_not_create_customer(): void {
+ global $wpdb;
+
+ // Scheduled import mode is set in setUp().
+ $order = \WC_Helper_Order::create_order();
+ $order->set_status( 'completed' );
+ $order->set_billing_email( 'ghost-64157@example.com' );
+ $order->save();
+ $order_id = $order->get_id();
+
+ $customers_before = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_customer_lookup" );
+
+ $new_customer_fired = 0;
+ $callback = function () use ( &$new_customer_fired ) {
+ ++$new_customer_fired;
+ };
+ add_action( 'woocommerce_analytics_new_customer', $callback );
+
+ // Trash without ever importing, then let the batch pick it up.
+ $order->delete( false );
+ OrdersScheduler::process_pending_batch( '2020-01-01 00:00:00', 0 );
+
+ remove_action( 'woocommerce_analytics_new_customer', $callback );
+
+ $customers_after = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_customer_lookup" );
+
+ $this->assertSame( 0, $new_customer_fired, 'Importing a never-imported trashed order should not create an analytics customer.' );
+ $this->assertSame( $customers_before, $customers_after, 'The customer lookup table should be unchanged.' );
+ $this->assertSame(
+ 0,
+ (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d", $order_id ) ),
+ 'A never-imported trashed order should not gain a stats row.'
+ );
+ }
+
+ /**
+ * @testdox Trashing a partially imported order does not add an analytics customer.
+ *
+ * A missing stats row does not prove the record was never imported. Skipping is
+ * still right: import() only creates and updates, so it would not clear the remnant.
+ */
+ public function test_trashing_partially_imported_order_adds_no_customer(): void {
+ global $wpdb;
+
+ update_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION, 'no' );
+
+ $order = \WC_Helper_Order::create_order();
+ $order->set_status( 'completed' );
+ $order->set_billing_email( 'partial-64157@example.com' );
+ $order->save();
+ $order_id = $order->get_id();
+
+ // Import fully, then simulate a partial import by dropping only the stats row.
+ OrdersScheduler::import( $order_id );
+ $wpdb->delete( $wpdb->prefix . 'wc_order_stats', array( 'order_id' => $order_id ) );
+
+ $customers_before = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_customer_lookup" );
+
+ $new_customer_fired = 0;
+ $callback = function () use ( &$new_customer_fired ) {
+ ++$new_customer_fired;
+ };
+ add_action( 'woocommerce_analytics_new_customer', $callback );
+
+ $order->delete( false );
+ OrdersScheduler::import( $order_id );
+
+ remove_action( 'woocommerce_analytics_new_customer', $callback );
+
+ $this->assertSame( 0, $new_customer_fired, 'Trashing a partially imported order should not create another analytics customer.' );
+ $this->assertSame(
+ $customers_before,
+ (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_customer_lookup" ),
+ 'The customer lookup table should be unchanged.'
+ );
+ }
+
+ /**
+ * @testdox A trashed order that was never imported is imported normally once restored.
+ */
+ public function test_never_imported_trashed_order_imports_after_restore(): void {
+ global $wpdb;
+
+ update_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION, 'no' );
+
+ $order = \WC_Helper_Order::create_order();
+ $order->set_status( 'completed' );
+ $order->save();
+ $order_id = $order->get_id();
+
+ // Trash without importing; the skip guard should leave no stats row.
+ $order->delete( false );
+ OrdersScheduler::import( $order_id );
+ $this->assertSame(
+ 0,
+ (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d", $order_id ) ),
+ 'The trashed, never-imported order should have been skipped.'
+ );
+
+ // Restore it: the status is no longer trash, so it imports as usual.
+ $order = wc_get_order( $order_id );
+ if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ $order->get_data_store()->untrash_order( $order );
+ } else {
+ wp_untrash_post( $order_id );
+ }
+ OrdersScheduler::import( $order_id );
+
+ $this->assertSame(
+ 'wc-completed',
+ $wpdb->get_var( $wpdb->prepare( "SELECT status FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d", $order_id ) ),
+ 'A restored order should import normally.'
+ );
+ }
+
/**
* @testdox get_failed_order_imports normalizes malformed or legacy option values.
*/
@@ -735,4 +1201,30 @@ class OrdersSchedulerTest extends WC_Unit_Test_Case {
$action_hook = OrdersScheduler::get_action( 'process_pending_batch' );
return function_exists( 'as_has_scheduled_action' ) ? as_has_scheduled_action( $action_hook, array(), OrdersScheduler::$group ) : (bool) as_next_scheduled_action( $action_hook, array(), OrdersScheduler::$group );
}
+
+ /**
+ * Clear any scheduled single-order import actions for an order.
+ *
+ * @param int $order_id Order ID.
+ * @return void
+ */
+ private function clear_scheduled_import( int $order_id ): void {
+ $action_hook = OrdersScheduler::get_action( 'import' );
+ if ( function_exists( 'as_unschedule_all_actions' ) ) {
+ as_unschedule_all_actions( $action_hook, array( $order_id ), OrdersScheduler::$group );
+ }
+ }
+
+ /**
+ * Check whether a single-order import action is scheduled for an order.
+ *
+ * @param int $order_id Order ID.
+ * @return bool
+ */
+ private function is_import_scheduled( int $order_id ): bool {
+ $action_hook = OrdersScheduler::get_action( 'import' );
+ return function_exists( 'as_has_scheduled_action' )
+ ? as_has_scheduled_action( $action_hook, array( $order_id ), OrdersScheduler::$group )
+ : (bool) as_next_scheduled_action( $action_hook, array( $order_id ), OrdersScheduler::$group );
+ }
}