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' );
+	}
+}