Commit 98580220fdb for woocommerce
commit 98580220fdbbc3fb6637a580a926769de47c97f9
Author: Peter Petrov <peter.petrov89@gmail.com>
Date: Fri Aug 14 09:27:50 2026 +0300
Match truncated order statuses in Analytics status filters (#67694)
* Match truncated order statuses in Analytics status filters
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add returning-customer test for long excluded custom status
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-44554-analytics-excluded-long-custom-statuses b/plugins/woocommerce/changelog/fix-44554-analytics-excluded-long-custom-statuses
new file mode 100644
index 00000000000..bfaf5b3499c
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-44554-analytics-excluded-long-custom-statuses
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Respect excluded order statuses in Analytics reports when a custom status slug exceeds the 20-character storage limit
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
index 4683dcc090e..2891d08892d 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
@@ -882,7 +882,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$excluded_statuses = array_map( array( __CLASS__, 'normalize_order_status' ), self::get_excluded_report_order_statuses() );
$excluded_statuses_condition = '';
if ( ! empty( $excluded_statuses ) ) {
- $excluded_statuses_str = implode( "','", $excluded_statuses );
+ $excluded_statuses_str = implode( "','", array_map( 'esc_sql', $excluded_statuses ) );
$excluded_statuses_condition = "AND status NOT IN ('{$excluded_statuses_str}')";
}
diff --git a/plugins/woocommerce/src/Admin/API/Reports/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/DataStore.php
index 8f1175abd2a..2137191f632 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/DataStore.php
@@ -855,7 +855,9 @@ class DataStore extends SqlQuery implements DataStoreInterface {
*/
protected static function normalize_order_status( $status ) {
$status = trim( $status );
- return 'wc-' . $status;
+ // Status columns are varchar(20) and longer values are silently truncated
+ // on write, so truncate the same way or comparisons never match long slugs.
+ return mb_substr( 'wc-' . $status, 0, 20 );
}
/**
@@ -1386,7 +1388,9 @@ class DataStore extends SqlQuery implements DataStoreInterface {
$subqueries = array();
$excluded_statuses = array();
if ( isset( $query_args['status_is'] ) && is_array( $query_args['status_is'] ) && count( $query_args['status_is'] ) > 0 ) {
- $allowed_statuses = array_map( array( $this, 'normalize_order_status' ), esc_sql( $query_args['status_is'] ) );
+ // Escape after normalizing: truncation could otherwise cut an escape
+ // sequence in half and leave a dangling backslash in the SQL literal.
+ $allowed_statuses = array_map( 'esc_sql', array_map( array( $this, 'normalize_order_status' ), $query_args['status_is'] ) );
if ( $allowed_statuses ) {
$subqueries[] = "{$wpdb->prefix}wc_order_stats.status IN ( '" . implode( "','", $allowed_statuses ) . "' )";
}
@@ -1403,7 +1407,7 @@ class DataStore extends SqlQuery implements DataStoreInterface {
}
if ( $excluded_statuses ) {
- $subqueries[] = "{$wpdb->prefix}wc_order_stats.status NOT IN ( '" . implode( "','", $excluded_statuses ) . "' )";
+ $subqueries[] = "{$wpdb->prefix}wc_order_stats.status NOT IN ( '" . implode( "','", array_map( 'esc_sql', $excluded_statuses ) ) . "' )";
}
return implode( " $operator ", $subqueries );
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 a39e8fda798..0eb4cb34c47 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
@@ -869,7 +869,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$first_order = $oldest_orders[0];
$second_order = isset( $oldest_orders[1] ) ? $oldest_orders[1] : false;
- $excluded_statuses = self::get_excluded_report_order_statuses();
+ $excluded_statuses = array_map( array( __CLASS__, 'normalize_order_status' ), self::get_excluded_report_order_statuses() );
+ $order_status = self::normalize_order_status( $order->get_status() );
// Order is older than previous first order. Stats dates only have second resolution, so
// orders placed within the same second are ranked by ID, the tie breaker
@@ -883,7 +884,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
(int) $order->get_id() < (int) $first_order->order_id
);
- if ( $is_older && ! in_array( $order->get_status(), $excluded_statuses, true ) ) {
+ if ( $is_older && ! in_array( $order_status, $excluded_statuses, true ) ) {
self::set_customer_first_order( $customer_id, $order->get_id() );
return false;
}
@@ -896,7 +897,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
wc_string_to_datetime( $second_order->date_created ) < $order->get_date_created();
// Status has changed to an excluded status and next oldest order is now the first order.
$status_change = $second_order &&
- in_array( $order->get_status(), $excluded_statuses, true );
+ in_array( $order_status, $excluded_statuses, true );
if ( $is_first_order && ( $date_change || $status_change ) ) {
self::set_customer_first_order( $customer_id, $second_order->order_id );
return true;
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 e2bb1016847..7389d067efc 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
@@ -449,4 +449,65 @@ class DataStoreTest extends WC_Unit_Test_Case {
WC_Helper_Order::delete_order( $order_id );
}
+
+ /**
+ * @testdox Changing the first order to an excluded custom status longer than the 20-char storage limit reassigns the first-order role and marks the customer as returning.
+ */
+ public function test_returning_customer_recalculated_for_long_excluded_status(): void {
+ global $wpdb;
+
+ $long_status = 'competition-completed';
+ register_post_status( 'wc-' . $long_status, array( 'public' => true ) );
+ $add_status = function ( $statuses ) use ( $long_status ) {
+ $statuses[ 'wc-' . $long_status ] = 'Competition Completed';
+ return $statuses;
+ };
+ add_filter( 'wc_order_statuses', $add_status );
+ update_option( 'woocommerce_excluded_report_order_statuses', array( 'pending', 'failed', 'cancelled', $long_status ) );
+
+ $customer = \WC_Helper_Customer::create_customer( 'cust_long_status', 'pwd', 'long_status_customer@mail.com' );
+
+ $order_1 = WC_Helper_Order::create_order( $customer->get_id() );
+ $order_1->set_date_created( time() - 2 * HOUR_IN_SECONDS );
+ $order_1->set_status( 'processing' );
+ $order_1->save();
+
+ $order_2 = WC_Helper_Order::create_order( $customer->get_id() );
+ $order_2->set_date_created( time() - HOUR_IN_SECONDS );
+ $order_2->set_status( 'processing' );
+ $order_2->save();
+
+ OrdersStatsDataStore::sync_order( $order_1->get_id() );
+ OrdersStatsDataStore::sync_order( $order_2->get_id() );
+
+ $returning_flag = static function ( $id ) use ( $wpdb ) {
+ return $wpdb->get_var(
+ $wpdb->prepare( "SELECT returning_customer FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d", $id )
+ );
+ };
+ $this->assertSame( '0', $returning_flag( $order_1->get_id() ), 'Oldest order should start as the non-returning first order.' );
+ $this->assertSame( '1', $returning_flag( $order_2->get_id() ), 'Second order should start as returning.' );
+
+ // Core warns when saving a status longer than the 20-char column; that
+ // truncated storage is the exact scenario under test.
+ $this->setExpectedIncorrectUsage( 'Abstract_WC_Order_Data_Store_CPT::get_post_status' );
+ $order_1->set_status( $long_status );
+ $order_1->save();
+
+ // Reload so the order reports the truncated status actually stored in the database.
+ $order_1 = wc_get_order( $order_1->get_id() );
+
+ $this->assertTrue(
+ OrdersStatsDataStore::is_returning_customer( $order_1 ),
+ 'An order moved to an excluded long custom status should be reported as returning.'
+ );
+ $this->assertSame(
+ '0',
+ $returning_flag( $order_2->get_id() ),
+ 'The next oldest order should be reassigned as the customer\'s first order.'
+ );
+
+ remove_filter( 'wc_order_statuses', $add_status );
+ unset( $GLOBALS['wp_post_statuses'][ 'wc-' . $long_status ] );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Products/DataStoreTest.php b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Products/DataStoreTest.php
new file mode 100644
index 00000000000..8b54f8a9670
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Products/DataStoreTest.php
@@ -0,0 +1,122 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\API\Reports\Products;
+
+use Automattic\WooCommerce\Admin\API\Reports\Cache;
+use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore;
+use Automattic\WooCommerce\Admin\API\Reports\Products\DataStore as ProductsDataStore;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for Products report DataStore.
+ */
+class DataStoreTest extends WC_Unit_Test_Case {
+
+ /**
+ * Custom status slug that exceeds the 20-char status column once 'wc-' prefixed.
+ *
+ * @var string
+ */
+ private $long_status = 'competition-completed';
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ register_post_status( 'wc-' . $this->long_status, array( 'public' => true ) );
+ add_filter( 'wc_order_statuses', array( $this, 'add_custom_status' ) );
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ remove_filter( 'wc_order_statuses', array( $this, 'add_custom_status' ) );
+ delete_option( 'woocommerce_excluded_report_order_statuses' );
+ unset( $GLOBALS['wp_post_statuses'][ 'wc-' . $this->long_status ] );
+ parent::tearDown();
+ }
+
+ /**
+ * Register the custom status with WooCommerce.
+ *
+ * @param array $statuses Registered order statuses.
+ * @return array
+ */
+ public function add_custom_status( $statuses ) {
+ $statuses[ 'wc-' . $this->long_status ] = 'Competition Completed';
+ return $statuses;
+ }
+
+ /**
+ * Create a synced order with one product in the custom status.
+ *
+ * @return int Product ID.
+ */
+ private function create_synced_custom_status_order() {
+ // Core warns when saving a status longer than the 20-char column; that
+ // truncated storage is the exact scenario under test.
+ $this->setExpectedIncorrectUsage( 'Abstract_WC_Order_Data_Store_CPT::get_post_status' );
+
+ $product = WC_Helper_Product::create_simple_product();
+ $order = wc_create_order();
+ $order->add_product( $product, 2 );
+ $order->calculate_totals();
+ $order->set_status( $this->long_status );
+ $order->save();
+
+ OrdersStatsDataStore::sync_order( $order->get_id() );
+ ProductsDataStore::sync_order_products( $order->get_id() );
+
+ return $product->get_id();
+ }
+
+ /**
+ * Query the products report for a single product.
+ *
+ * @param int $product_id Product ID.
+ * @return object Report data.
+ */
+ private function get_product_report_data( $product_id ) {
+ Cache::invalidate();
+ $data_store = new ProductsDataStore();
+ return $data_store->get_data(
+ array(
+ 'after' => gmdate( 'Y-m-d', strtotime( '-1 day' ) ) . 'T00:00:00',
+ 'before' => gmdate( 'Y-m-d', strtotime( '+1 day' ) ) . 'T23:59:59',
+ 'products' => array( $product_id ),
+ )
+ );
+ }
+
+ /**
+ * @testdox Products report should exclude orders whose custom status is excluded, even when the status slug exceeds the 20-char storage limit.
+ */
+ public function test_excluded_long_custom_status_is_not_counted(): void {
+ $product_id = $this->create_synced_custom_status_order();
+
+ update_option( 'woocommerce_excluded_report_order_statuses', array( 'pending', 'failed', 'cancelled', $this->long_status ) );
+
+ $data = $this->get_product_report_data( $product_id );
+ $items_sold = array_sum( array_map( 'absint', array_column( $data->data, 'items_sold' ) ) );
+
+ $this->assertSame( 0, $items_sold, 'Orders in an excluded custom status should not be counted in the products report' );
+ }
+
+ /**
+ * @testdox Products report should count orders in a long custom status when it is not excluded.
+ */
+ public function test_non_excluded_long_custom_status_is_counted(): void {
+ $product_id = $this->create_synced_custom_status_order();
+
+ update_option( 'woocommerce_excluded_report_order_statuses', array( 'pending', 'failed', 'cancelled' ) );
+
+ $data = $this->get_product_report_data( $product_id );
+ $items_sold = array_sum( array_map( 'absint', array_column( $data->data, 'items_sold' ) ) );
+
+ $this->assertSame( 2, $items_sold, 'Orders in a non-excluded custom status should be counted in the products report' );
+ }
+}