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.
*