Commit caa876cd9ce for woocommerce

commit caa876cd9ce8b83240b7de5395cac1153d56d083
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date:   Thu Jun 25 10:14:44 2026 +0800

    fix: prevent analytics division by zero on monetary-only refunds with decimal quantities (#65747)

    * fix: prevent analytics division by zero on monetary-only refunds with decimal quantities

    * fix: update phpstan baseline and align guard comments to file style

    * test: guarantee filter cleanup in monetary-only refund regression test

    * test: make float-quantity refund test deterministic and fix OrderTraits param docs

    * revert: keep order_items_count PHPDoc as int; param is always int via absint

diff --git a/plugins/woocommerce/changelog/fix-wooplug-6639-analytics-division-by-zero b/plugins/woocommerce/changelog/fix-wooplug-6639-analytics-division-by-zero
new file mode 100644
index 00000000000..361de1bb62c
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-6639-analytics-division-by-zero
@@ -0,0 +1,3 @@
+Significance: patch
+Type: fix
+Comment: Analytics: prevent a Division by zero error when syncing monetary-only refunds on stores that use decimal item quantities.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index d746c453800..2b0f0b79b16 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -47722,13 +47722,13 @@ parameters:
 			path: src/Admin/Notes/Notes.php

 		-
-			message: '#^Binary operation "/" between 0\|float and int\<min, \-1\>\|int\<1, max\>\|string results in an error\.$#'
+			message: '#^Binary operation "/" between 0\|float and int\<min, \-1\>\|int\<1, max\>\|non\-falsy\-string results in an error\.$#'
 			identifier: binaryOp.invalid
 			count: 1
 			path: src/Admin/Overrides/Order.php

 		-
-			message: '#^Binary operation "/" between float and int\<min, \-1\>\|int\<1, max\>\|string results in an error\.$#'
+			message: '#^Binary operation "/" between float and int\<min, \-1\>\|int\<1, max\>\|non\-falsy\-string results in an error\.$#'
 			identifier: binaryOp.invalid
 			count: 1
 			path: src/Admin/Overrides/Order.php
@@ -47818,13 +47818,13 @@ parameters:
 			path: src/Admin/Overrides/Order.php

 		-
-			message: '#^Binary operation "/" between 0\|float and int\<min, \-1\>\|int\<1, max\>\|string results in an error\.$#'
+			message: '#^Binary operation "/" between 0\|float and int\<min, \-1\>\|int\<1, max\>\|non\-falsy\-string results in an error\.$#'
 			identifier: binaryOp.invalid
 			count: 1
 			path: src/Admin/Overrides/OrderRefund.php

 		-
-			message: '#^Binary operation "/" between float and int\<min, \-1\>\|int\<1, max\>\|string results in an error\.$#'
+			message: '#^Binary operation "/" between float and int\<min, \-1\>\|int\<1, max\>\|non\-falsy\-string results in an error\.$#'
 			identifier: binaryOp.invalid
 			count: 1
 			path: src/Admin/Overrides/OrderRefund.php
diff --git a/plugins/woocommerce/src/Admin/Overrides/OrderTraits.php b/plugins/woocommerce/src/Admin/Overrides/OrderTraits.php
index c0a4710494c..30d11f0581c 100644
--- a/plugins/woocommerce/src/Admin/Overrides/OrderTraits.php
+++ b/plugins/woocommerce/src/Admin/Overrides/OrderTraits.php
@@ -34,7 +34,10 @@ trait OrderTraits {
 		// For example, if 2 items are refunded from an order with 4 items. The remaining 2 items should have the shipping fee of the refunded items distributed to them.
 		$order_items = null !== $order_items_count ? $order_items_count : $this->get_item_count();

-		if ( 0 === $order_items ) {
+		// Bail when there are no items to distribute the shipping across, to avoid dividing by zero.
+		// A loose check is used so a float 0.0 count is also treated as zero. Stores that allow decimal
+		// quantities (e.g. some Point of Sale plugins) make the item count a float, which a strict 0 === check would miss.
+		if ( ! $order_items ) {
 			return 0;
 		}

@@ -65,7 +68,10 @@ trait OrderTraits {
 		// For example, if 2 items are refunded from an order with 4 items. The remaining 2 items should have the shipping tax of the refunded items distributed to them.
 		$order_items = null !== $order_items_count ? $order_items_count : $this->get_item_count();

-		if ( 0 === $order_items ) {
+		// Bail when there are no items to distribute the shipping tax across, to avoid dividing by zero.
+		// A loose check is used so a float 0.0 count is also treated as zero. Stores that allow decimal
+		// quantities (e.g. some Point of Sale plugins) make the item count a float, which a strict 0 === check would miss.
+		if ( ! $order_items ) {
 			return 0;
 		}

diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-products.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-products.php
index bd8ef9b975b..8b7c426635c 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-products.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-products.php
@@ -437,6 +437,69 @@ class WC_Admin_Tests_Reports_Products extends WC_Unit_Test_Case {
 		$this->assertEquals( $expected_data, $query->get_data() );
 	}

+	/**
+	 * @testdox Should not throw DivisionByZeroError when syncing a monetary-only refund (qty 0) with float quantities.
+	 *
+	 * Reproduces WOOPLUG-6639. When a store uses decimal/float stock amounts (e.g. WCPOS),
+	 * a monetary-only partial refund creates a refund line item with qty 0. The refund's
+	 * get_item_count() then returns float 0.0, which the strict `0 === $order_items` guard
+	 * in OrderTraits::get_item_shipping_amount() does not catch, causing a Division by zero.
+	 */
+	public function test_sync_monetary_only_refund_with_float_quantities() {
+		WC_Helper_Reports::reset_stats_dbs();
+
+		// Simulate a decimal-quantity setup (e.g. WCPOS), which swaps WooCommerce's default
+		// intval coercion for floatval so that stock amounts become floats. Removing intval first
+		// makes the float coercion deterministic rather than relying on same-priority filter order.
+		remove_filter( 'woocommerce_stock_amount', 'intval' );
+		add_filter( 'woocommerce_stock_amount', 'floatval' );
+
+		// Use try/finally so the filters are always restored to WooCommerce's default, even if an
+		// assertion fails or sync_order_products() throws (the regression this test guards against),
+		// to avoid leaking the float coercion into later tests.
+		try {
+			$product = new WC_Product_Simple();
+			$product->set_name( 'Test Product' );
+			$product->set_regular_price( 25 );
+			$product->save();
+
+			$order = WC_Helper_Order::create_order( 1, $product );
+			$order->set_status( OrderStatus::COMPLETED );
+			$order->set_shipping_total( 10 );
+			$order->set_total( 35 );
+			$order->save();
+
+			// Monetary-only refund: refund an amount against the line item without refunding any quantity.
+			$refund = null;
+			foreach ( $order->get_items() as $item_id => $item ) {
+				$refund = wc_create_refund(
+					array(
+						'amount'     => 5,
+						'order_id'   => $order->get_id(),
+						'line_items' => array(
+							$item_id => array(
+								'qty'          => 0,
+								'refund_total' => 5,
+							),
+						),
+					)
+				);
+				break;
+			}
+
+			$this->assertNotWPError( $refund, 'Refund creation should succeed' );
+
+			$data_store = new ProductsDataStore();
+			$result     = $data_store->sync_order_products( $refund->get_id() );
+
+			$this->assertTrue( $result, 'Syncing the refund should complete without a Division by zero error' );
+		} finally {
+			remove_filter( 'woocommerce_stock_amount', 'floatval' );
+			// Restore WooCommerce's default coercion for later tests.
+			add_filter( 'woocommerce_stock_amount', 'intval' );
+		}
+	}
+
 	/**
 	 * Tests that full refunds are reflected in product stats.
 	 *