Commit 4b33473b8d5 for woocommerce

commit 4b33473b8d5233e2d29d1a659cb2e9dc90693499
Author: Neil Carlo Sucuangco <necafasu@gmail.com>
Date:   Tue May 26 17:30:39 2026 +0800

    Fix analytics for lump-sum full refunds (#64106)

    * Fix analytics for lump-sum full refunds

    Detect lump-sum/full-order refunds and use the parent order's product net values when recording order stats. Adds a should_split_full_refund_using_parent_order helper to DataStore to identify refunds that lack line items but match the order total, updates the refund handling condition to use parent amounts, and includes a unit test validating that a lump-sum full refund without _refund_type stores the parent product net in analytics. Also adds a brief changelog entry.

    * Add cart tax to order in DataStoreTest

    Set a cart tax of 5.00 and adjust the order total to 55.00 (then save) in the DataStoreTest setup. This ensures the test covers stripping both tax and shipping from net totals, not just shipping.

    * Update DataStoreTest.php

    * Update DataStore.php

    * adjustments

    * Linting

    ---------

    Co-authored-by: Albert Juhé Lluveras <contact@albertjuhe.com>

diff --git a/plugins/woocommerce/changelog/fix-analytics-lump-sum-refund-net-sales b/plugins/woocommerce/changelog/fix-analytics-lump-sum-refund-net-sales
new file mode 100644
index 00000000000..977a394987c
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-analytics-lump-sum-refund-net-sales
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix Analytics order stats for lump-sum full refunds (e.g. order status set to Refunded) so net revenue uses product net, not the full refund total.
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 72a4c7fac1d..86954b0fb0c 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
@@ -594,13 +594,18 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {

 		if ( 'shop_order_refund' === $order->get_type() ) {
 			$parent_order = wc_get_order( $order->get_parent_id() );
-			if ( $parent_order ) {
+			// Refunds attach to the original order. Skip if the parent is another refund.
+			if ( $parent_order && ! $parent_order instanceof WC_Order_Refund ) {
 				$data['parent_id'] = $parent_order->get_id();
 				$data['status']    = self::normalize_order_status( $parent_order->get_status() );

 				$refund_type               = $order->get_meta( '_refund_type' );
 				$uses_new_full_refund_data = OrderUtil::uses_new_full_refund_data();
-				if ( 'full' === $refund_type && $uses_new_full_refund_data ) {
+				$use_parent_refund_amounts = $uses_new_full_refund_data && (
+					'full' === $refund_type
+					|| self::should_split_full_refund_using_parent_order( $order, $parent_order )
+				);
+				if ( $use_parent_refund_amounts ) {
 					$data['num_items_sold'] = -1 * self::get_num_items_sold( $parent_order );
 					$data['tax_total']      = -1 * $parent_order->get_total_tax();
 					$data['net_total']      = -1 * self::get_net_total( $parent_order );
@@ -696,6 +701,34 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		return (float) $net_total;
 	}

+	/**
+	 * Whether this refund is a single lump-sum refund for the full order (e.g. status set to refunded without line items).
+	 *
+	 * @param WC_Order_Refund|OrderRefund|Order $refund        Refund order.
+	 * @param WC_Order|Order                    $parent_order Parent order (not a refund).
+	 * @return bool
+	 */
+	protected static function should_split_full_refund_using_parent_order( $refund, $parent_order ) {
+		// The parent must be the original order, not another refund.
+		if ( ! $parent_order instanceof WC_Order || 'shop_order_refund' === $parent_order->get_type() ) {
+			return false;
+		}
+
+		if ( self::get_num_items_sold( $refund ) > 0 ) {
+			return false;
+		}
+
+		$parent_refunds = $parent_order->get_refunds();
+		if ( 1 !== count( $parent_refunds ) ) {
+			return false;
+		}
+
+		$refund_total = wc_format_decimal( abs( (float) $refund->get_total() ) );
+		$order_total  = wc_format_decimal( (float) $parent_order->get_total() );
+
+		return $refund_total === $order_total;
+	}
+
 	/**
 	 * Check if the wc_order_stats table has the fulfillment_status column.
 	 *
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
new file mode 100644
index 00000000000..f43801858e3
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/Stats/DataStoreTest.php
@@ -0,0 +1,120 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\API\Reports\Orders\Stats;
+
+use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore;
+use Automattic\WooCommerce\Caches\OrderCache;
+use Automattic\WooCommerce\Utilities\OrderUtil;
+use WC_Helper_Order;
+use WC_Unit_Test_Case;
+use WP_Error;
+
+/**
+ * Tests for Orders Stats DataStore.
+ */
+class DataStoreTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Previous woocommerce_db_version for restore.
+	 *
+	 * @var mixed
+	 */
+	private $previous_db_version;
+
+	/**
+	 * Previous woocommerce_analytics_uses_old_full_refund_data for restore.
+	 *
+	 * @var mixed
+	 */
+	private $previous_old_full_refund_flag;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->previous_db_version           = get_option( 'woocommerce_db_version' );
+		$this->previous_old_full_refund_flag = get_option( 'woocommerce_analytics_uses_old_full_refund_data' );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		if ( false !== $this->previous_db_version ) {
+			update_option( 'woocommerce_db_version', $this->previous_db_version );
+		} else {
+			delete_option( 'woocommerce_db_version' );
+		}
+		if ( false !== $this->previous_old_full_refund_flag ) {
+			update_option( 'woocommerce_analytics_uses_old_full_refund_data', $this->previous_old_full_refund_flag );
+		} else {
+			delete_option( 'woocommerce_analytics_uses_old_full_refund_data' );
+		}
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Lump-sum full refund without _refund_type stores parent product net in order stats.
+	 */
+	public function test_lump_sum_full_refund_without_refund_type_uses_parent_net_total(): void {
+		update_option( 'woocommerce_db_version', '10.2.0' );
+		update_option( 'woocommerce_analytics_uses_old_full_refund_data', 'no' );
+
+		$order = WC_Helper_Order::create_order();
+		// Add cart tax so we assert tax and shipping are both stripped from net, not only shipping.
+		$order->set_cart_tax( 5.00 );
+		$order->set_total( 55.00 );
+		$order->save();
+		$order->update_status( 'completed' );
+
+		$remaining = (float) wc_format_decimal( $order->get_total() - $order->get_total_refunded() );
+		$refund    = wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => $remaining,
+				'line_items' => array(),
+			)
+		);
+
+		$this->assertNotInstanceOf( WP_Error::class, $refund );
+
+		global $wpdb;
+		if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+			$wpdb->delete(
+				$wpdb->prefix . 'wc_orders_meta',
+				array(
+					'order_id' => $refund->get_id(),
+					'meta_key' => '_refund_type', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Test fixture: clear refund type meta.
+				),
+				array( '%d', '%s' )
+			);
+		} else {
+			delete_post_meta( $refund->get_id(), '_refund_type' );
+		}
+
+		if ( OrderUtil::orders_cache_usage_is_enabled() ) {
+			wc_get_container()->get( OrderCache::class )->remove( $refund->get_id() );
+		}
+
+		$refund_after_clear = wc_get_order( $refund->get_id() );
+		$this->assertInstanceOf( \WC_Order_Refund::class, $refund_after_clear );
+		$this->assertEmpty( $refund_after_clear->get_meta( '_refund_type', true ) );
+
+		OrdersStatsDataStore::sync_order( $refund->get_id() );
+
+		$net_total = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT net_total FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d",
+				$refund->get_id()
+			)
+		);
+
+		$expected_net = -1 * ( $order->get_total() - $order->get_total_tax() - $order->get_shipping_total() );
+
+		$this->assertEqualsWithDelta( $expected_net, $net_total, 0.02 );
+
+		WC_Helper_Order::delete_order( $order->get_id() );
+	}
+}