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.