Commit 1e45388ab9f for woocommerce

commit 1e45388ab9f44e46313d7371cefa1778505f6834
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date:   Thu Nov 12 10:45:13 2026 +0800

    Fix fractional product counts and empty prices in analytics events (#70800)

    * Send whole-number products_count and numeric pp in analytics events

    * Test the order confirmation products_count

    * Add changelog entry for analytics event prop types

    * Keep pp a numeric string so PHP 7.4 locales cannot change it

    * Send pp as a locale-independent string and test the products_count wiring

    * Reuse get_order_items_count() in product_purchase

    * Spell out the products_count definition change in the changelog

    * Send pp as a string for an int price too and drop the helper count tests

diff --git a/packages/php/woocommerce-analytics/changelog/fix-analytics-event-prop-types b/packages/php/woocommerce-analytics/changelog/fix-analytics-event-prop-types
new file mode 100644
index 00000000000..18424ced52d
--- /dev/null
+++ b/packages/php/woocommerce-analytics/changelog/fix-analytics-event-prop-types
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Count each product once in the products_count analytics property, regardless of quantity, and omit the price property for products without a price.
diff --git a/packages/php/woocommerce-analytics/src/class-universal.php b/packages/php/woocommerce-analytics/src/class-universal.php
index d71fae6d144..f02dc228353 100644
--- a/packages/php/woocommerce-analytics/src/class-universal.php
+++ b/packages/php/woocommerce-analytics/src/class-universal.php
@@ -363,11 +363,7 @@ class Universal {
 			// @phan-suppress-next-line PhanUndeclaredMethod -- Checked before being called. See also https://github.com/phan/phan/issues/1204.
 			$product_id = is_callable( array( $order_item, 'get_product_id' ) ) ? $order_item->get_product_id() : -1;

-			$order_items       = $order->get_items();
-			$order_items_count = 0;
-			if ( is_array( $order_items ) ) {
-				$order_items_count = count( $order_items );
-			}
+			$order_items_count   = $this->get_order_items_count( $order );
 			$order_coupons       = $order->get_coupons();
 			$order_coupons_count = 0;
 			if ( is_array( $order_coupons ) ) {
@@ -654,7 +650,7 @@ class Universal {
 					'oi'                       => $order->get_id(),
 					'order_value'              => $order->get_subtotal(),
 					'order_total'              => $order->get_total(),
-					'products_count'           => $order->get_item_count(),
+					'products_count'           => $this->get_order_items_count( $order ),
 					'total_discount'           => $order->get_discount_total(),
 					'total_shipping'           => $order->get_shipping_total(),
 					'total_tax'                => $order->get_total_tax(),
diff --git a/packages/php/woocommerce-analytics/src/class-woo-analytics-trait.php b/packages/php/woocommerce-analytics/src/class-woo-analytics-trait.php
index 481653f3709..5c921b79a5e 100644
--- a/packages/php/woocommerce-analytics/src/class-woo-analytics-trait.php
+++ b/packages/php/woocommerce-analytics/src/class-woo-analytics-trait.php
@@ -345,13 +345,20 @@ trait Woo_Analytics_Trait {
 	 * @return array
 	 */
 	public function get_product_details( $product ) {
-		return array(
+		$details = array(
 			'pi' => $product->get_id(),
 			'pn' => $product->get_title(),
 			'pc' => $this->get_product_categories_concatenated( $product ),
-			'pp' => $product->get_price(),
 			'pt' => $product->get_type(),
 		);
+
+		// Only a numeric price is sent: get_price() is '' for a product without one, and a filter can return a label.
+		$price = $product->get_price();
+		if ( is_numeric( $price ) ) {
+			$details['pp'] = (string) wc_float_to_string( $price );
+		}
+
+		return $details;
 	}

 	/**
@@ -620,7 +627,7 @@ trait Woo_Analytics_Trait {
 	}

 	/**
-	 * Get number of items in the cart
+	 * Get the number of products in the cart, not the sum of their quantities, which can be fractional.
 	 *
 	 * @return int
 	 */
@@ -629,7 +636,18 @@ trait Woo_Analytics_Trait {
 		if ( $cart === null ) {
 			return 0;
 		}
-		return $cart->get_cart_contents_count();
+		return count( $cart->get_cart() );
+	}
+
+	/**
+	 * Get the number of products in an order, counted the same way as get_cart_items_count().
+	 * Unlike WC_Order::get_item_count(), this does not sum quantities.
+	 *
+	 * @param \WC_Order $order Order.
+	 * @return int
+	 */
+	public function get_order_items_count( $order ) {
+		return count( $order->get_items() );
 	}

 	/**
diff --git a/packages/php/woocommerce-analytics/tests/php/Event_Props_Types_Test.php b/packages/php/woocommerce-analytics/tests/php/Event_Props_Types_Test.php
new file mode 100644
index 00000000000..5eb623bb30d
--- /dev/null
+++ b/packages/php/woocommerce-analytics/tests/php/Event_Props_Types_Test.php
@@ -0,0 +1,181 @@
+<?php
+/**
+ * Tests that event props fit the types the analytics pipeline stores them as.
+ *
+ * @package automattic/woocommerce-analytics
+ */
+
+namespace Automattic\Woocommerce_Analytics;
+
+use PHPUnit\Framework\Attributes\DataProvider;
+use WorDBless\BaseTestCase;
+
+/**
+ * Tests for products_count and pp.
+ */
+class Event_Props_Types_Test extends BaseTestCase {
+
+	/**
+	 * Reset the mocks and the event queue after each test.
+	 */
+	public function tear_down(): void {
+		global $wc_get_order_mock_return, $wc_is_order_received_page;
+
+		unset( $GLOBALS['wc_cart_mock'] );
+		$wc_get_order_mock_return  = false;
+		$wc_is_order_received_page = false;
+		set_query_var( 'order-received', null );
+
+		$reflection = new \ReflectionClass( WC_Analytics_Tracking::class );
+		$property   = $reflection->getProperty( 'event_queue' );
+		$property->setAccessible( true );
+		$property->setValue( null, array() );
+
+		parent::tear_down();
+	}
+
+	/**
+	 * Test that the cart and checkout events carry the cart line count.
+	 */
+	public function test_cart_checkout_event_properties_carry_cart_line_count(): void {
+		$GLOBALS['wc_cart_mock'] = $this->fractional_cart();
+
+		$properties = ( new Universal() )->get_cart_checkout_event_properties();
+
+		$this->assertSame( 2, $properties['products_count'] );
+	}
+
+	/**
+	 * Test that the order confirmation event carries the order line count.
+	 */
+	public function test_order_confirmation_view_carries_order_line_count(): void {
+		global $wc_get_order_mock_return, $wc_is_order_received_page;
+
+		$wc_get_order_mock_return  = $this->fractional_order();
+		$wc_is_order_received_page = true;
+		set_query_var( 'order-received', 123 );
+
+		( new Universal() )->capture_order_confirmation_view();
+
+		$queue = WC_Analytics_Tracking::get_event_queue();
+		$this->assertNotEmpty( $queue );
+		$event = end( $queue );
+		$this->assertSame( 'order_confirmation_view', $event['eventName'] );
+		$this->assertSame( 2, $event['props']['products_count'] );
+	}
+
+	/**
+	 * Price values and the pp prop they should produce; null means no pp prop.
+	 *
+	 * @return array
+	 */
+	public static function price_provider(): array {
+		return array(
+			'decimal price'     => array( '19.99', '19.99' ),
+			'free product'      => array( '0', '0' ),
+			'float from filter' => array( 19.99, '19.99' ),
+			'int from filter'   => array( 10, '10' ),
+			'no price'          => array( '', null ),
+			'filtered label'    => array( 'Call for price', null ),
+		);
+	}
+
+	/**
+	 * Test that pp is a numeric string, or left out when the product has no numeric price.
+	 *
+	 * @dataProvider price_provider
+	 *
+	 * @param string|float|int $price    What WC_Product::get_price() returns.
+	 * @param string|null      $expected The expected pp, or null when pp should be absent.
+	 */
+	#[DataProvider( 'price_provider' )]
+	public function test_product_details_price( $price, $expected ): void {
+		$product = new class( $price ) {
+			private $price;
+
+			public function __construct( $price ) {
+				$this->price = $price;
+			}
+
+			public function get_id() {
+				return 13;
+			}
+
+			public function get_title() {
+				return 'Cotton fabric';
+			}
+
+			public function get_price() {
+				return $this->price;
+			}
+
+			public function get_type() {
+				return 'simple';
+			}
+		};
+
+		$details = ( new Universal() )->get_product_details( $product );
+
+		if ( null === $expected ) {
+			$this->assertArrayNotHasKey( 'pp', $details );
+		} else {
+			$this->assertSame( $expected, $details['pp'] );
+		}
+	}
+
+	/**
+	 * A cart holding 1.5 yards of fabric and one pack of buttons.
+	 *
+	 * @return object
+	 */
+	private function fractional_cart() {
+		return new class() {
+			public function get_cart() {
+				return array(
+					'a' => array( 'data' => null, 'quantity' => 1.5 ),
+					'b' => array( 'data' => null, 'quantity' => 1 ),
+				);
+			}
+
+			public function get_coupons() {
+				return array();
+			}
+
+			public function get_total( $context = 'view' ) {
+				return 0;
+			}
+
+			public function get_subtotal() {
+				return 0;
+			}
+
+			public function get_shipping_total() {
+				return 0;
+			}
+
+			public function get_taxes_total() {
+				return 0;
+			}
+
+			public function get_discount_total() {
+				return 0;
+			}
+		};
+	}
+
+	/**
+	 * An order holding 1.5 yards of fabric and one pack of buttons.
+	 *
+	 * @return \WC_Order
+	 */
+	private function fractional_order() {
+		$order = new \WC_Order();
+		$order->set_items(
+			array(
+				array( 'data' => null, 'quantity' => 1.5 ),
+				array( 'data' => null, 'quantity' => 1 ),
+			)
+		);
+		return $order;
+	}
+}
diff --git a/packages/php/woocommerce-analytics/tests/php/mocks/class-wc-order.php b/packages/php/woocommerce-analytics/tests/php/mocks/class-wc-order.php
index 46c3a7d5575..af6810c047a 100644
--- a/packages/php/woocommerce-analytics/tests/php/mocks/class-wc-order.php
+++ b/packages/php/woocommerce-analytics/tests/php/mocks/class-wc-order.php
@@ -94,6 +94,33 @@ if ( ! class_exists( 'WC_Order' ) ) {
 			$this->items = $items;
 		}

+		/**
+		 * Get customer ID.
+		 *
+		 * @return int
+		 */
+		public function get_customer_id() {
+			return 0;
+		}
+
+		/**
+		 * Get customer note.
+		 *
+		 * @return string
+		 */
+		public function get_customer_note() {
+			return '';
+		}
+
+		/**
+		 * Get shipping method.
+		 *
+		 * @return string
+		 */
+		public function get_shipping_method() {
+			return '';
+		}
+
 		/**
 		 * Get coupons.
 		 *
diff --git a/packages/php/woocommerce-analytics/tests/php/mocks/woocommerce-functions.php b/packages/php/woocommerce-analytics/tests/php/mocks/woocommerce-functions.php
index 379aaa63aa3..b97d7ae1f91 100644
--- a/packages/php/woocommerce-analytics/tests/php/mocks/woocommerce-functions.php
+++ b/packages/php/woocommerce-analytics/tests/php/mocks/woocommerce-functions.php
@@ -23,6 +23,14 @@ $wc_get_order_mock_return = false;
 global $wc_get_order_calls;
 $wc_get_order_calls = array();

+/**
+ * Whether is_order_received_page() reports the order received page.
+ *
+ * @var bool
+ */
+global $wc_is_order_received_page;
+$wc_is_order_received_page = false;
+
 if ( ! function_exists( 'wc_get_order' ) ) {
 	/**
 	 * Mock wc_get_order function.
@@ -44,14 +52,24 @@ if ( ! function_exists( 'WC' ) ) {
 	 * @return object Mock WooCommerce object.
 	 */
 	function WC() {
-		return new class() {
+		global $wc_cart_mock;
+		$wc       = new class() {
 			/**
 			 * Session property.
 			 *
 			 * @var object|null
 			 */
 			public $session = null;
+
+			/**
+			 * Cart property, set from the `$wc_cart_mock` global.
+			 *
+			 * @var object|null
+			 */
+			public $cart = null;
 		};
+		$wc->cart = $wc_cart_mock ?? null;
+		return $wc;
 	}
 }

@@ -66,6 +84,34 @@ if ( ! function_exists( 'is_cart' ) ) {
 	}
 }

+if ( ! function_exists( 'is_order_received_page' ) ) {
+	/**
+	 * Mock is_order_received_page function.
+	 *
+	 * @return bool The `$wc_is_order_received_page` global.
+	 */
+	function is_order_received_page() {
+		global $wc_is_order_received_page;
+		return (bool) $wc_is_order_received_page;
+	}
+}
+
+if ( ! function_exists( 'wc_float_to_string' ) ) {
+	/**
+	 * Mock wc_float_to_string function, same as WooCommerce's.
+	 *
+	 * @param mixed $float Value to format.
+	 * @return mixed A float as a string with a `.` decimal separator; anything else unchanged.
+	 */
+	function wc_float_to_string( $float ) {
+		if ( ! is_float( $float ) ) {
+			return $float;
+		}
+		$locale = localeconv();
+		return str_replace( $locale['decimal_point'], '.', strval( $float ) );
+	}
+}
+
 if ( ! function_exists( 'is_account_page' ) ) {
 	/**
 	 * Mock is_account_page function.