Commit 440d3aff26f for woocommerce

commit 440d3aff26f5976bf33a76a6077fab5e198a1d69
Author: louwie17 <lourensschep@gmail.com>
Date:   Fri Jul 10 11:38:35 2026 +0200

    Fix Revenue report double-counting returns on partial-then-full refunds (#66320)

    When an order received a partial refund followed by a full refund, the
    full-refund row in wc_order_stats stored -1 x the entire parent order
    total, ignoring what the earlier partial refund had already recorded.
    The Revenue report then summed both, over-counting returns by the
    partial refund amount.

    - Stats/DataStore::update(): the full-refund row starts from the parent
      totals (so a lone full refund still zeroes net/tax/shipping, preserving
      the behavior from #58744) and then subtracts what prior refunds already
      booked, via $parent_order->get_refunds(), so it records only the
      remaining, not-yet-refunded portion.
    - Stats/DataStore::assign_report_columns(): detect refund rows by the sign
      of net + tax + shipping, not net_total alone, so an incremental
      full-refund row whose net_total is 0 (net already covered by a prior
      partial) is still counted for its tax/shipping.

    Adds a regression test covering the partial-then-full scenario.

    Closes #66217


    Claude-Session: https://claude.ai/code/session_019epTeSCKk8qRToXsNMHZtz

    Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/66217-fix-revenue-report-refund-double-count b/plugins/woocommerce/changelog/66217-fix-revenue-report-refund-double-count
new file mode 100644
index 00000000000..76878f94f46
--- /dev/null
+++ b/plugins/woocommerce/changelog/66217-fix-revenue-report-refund-double-count
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix the Revenue report double-counting returns when an order has a partial refund followed by a full refund.
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 86954b0fb0c..b5aa835028d 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
@@ -113,7 +113,10 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 	protected function assign_report_columns() {
 		$table_name = self::get_db_table_name();
 		// Avoid ambiguous columns in SQL query.
-		$refunds = "ABS( SUM( CASE WHEN {$table_name}.net_total < 0 THEN {$table_name}.net_total + {$table_name}.tax_total + {$table_name}.shipping_total ELSE 0 END ) )";
+		// Identify refund rows by the sign of the whole row (net + tax + shipping), not net alone:
+		// when a prior partial refund already covered the net portion, an incremental full-refund
+		// row can have net_total = 0 while still carrying negative tax or shipping.
+		$refunds = "ABS( SUM( CASE WHEN ( {$table_name}.net_total + {$table_name}.tax_total + {$table_name}.shipping_total ) < 0 THEN {$table_name}.net_total + {$table_name}.tax_total + {$table_name}.shipping_total ELSE 0 END ) )";
 		if ( ! OrderUtil::uses_new_full_refund_data() ) {
 			$refunds = "ABS( SUM( CASE WHEN {$table_name}.net_total < 0 THEN {$table_name}.net_total ELSE 0 END ) )";
 		}
@@ -606,10 +609,26 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 					|| self::should_split_full_refund_using_parent_order( $order, $parent_order )
 				);
 				if ( $use_parent_refund_amounts ) {
+					// A full refund derives its amounts from the parent order so that net, tax
+					// and shipping each zero out — a lump-sum refund carries no line items of its
+					// own, so the parent is the only reliable source for that split.
 					$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 );
 					$data['shipping_total'] = -1 * $parent_order->get_shipping_total();
+
+					// If earlier refunds already booked part of the order, subtract what they
+					// recorded so this row only captures the remaining, not-yet-refunded portion.
+					// With no prior refunds this loop is skipped, leaving the totals above intact.
+					foreach ( $parent_order->get_refunds() as $prior_refund ) {
+						if ( $prior_refund->get_id() === $order->get_id() ) {
+							continue;
+						}
+						$data['num_items_sold'] -= self::get_num_items_sold( $prior_refund );
+						$data['tax_total']      -= (float) $prior_refund->get_total_tax();
+						$data['net_total']      -= self::get_net_total( $prior_refund );
+						$data['shipping_total'] -= (float) $prior_refund->get_shipping_total();
+					}
 				}
 			}
 			/**
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
index f43801858e3..4d901105696 100644
--- 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
@@ -117,4 +117,83 @@ class DataStoreTest extends WC_Unit_Test_Case {

 		WC_Helper_Order::delete_order( $order->get_id() );
 	}
+
+	/**
+	 * @testdox A partial refund followed by a full refund does not double-count the returns amount.
+	 *
+	 * Regression test for https://github.com/woocommerce/woocommerce/issues/66217: the full-refund
+	 * row used to store the whole parent order total again, ignoring the amount an earlier partial
+	 * refund had already recorded, so the Revenue report over-counted returns.
+	 */
+	public function test_partial_then_full_refund_does_not_double_count_returns(): void {
+		update_option( 'woocommerce_db_version', '10.2.0' );
+		update_option( 'woocommerce_analytics_uses_old_full_refund_data', 'no' );
+
+		// Order: net 40 (4 x $10 product) + tax 5 + shipping 10 = 55 gross.
+		$order = WC_Helper_Order::create_order();
+		$order->set_cart_tax( 5.00 );
+		$order->set_total( 55.00 );
+		$order->save();
+		$order->update_status( 'completed' );
+
+		$product_item_id = array_key_first( $order->get_items() );
+
+		// Partial refund: 2 of the 4 product units ($20) with a real line-item breakdown.
+		$partial = wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => 20.00,
+				'line_items' => array(
+					$product_item_id => array(
+						'qty'          => 2,
+						'refund_total' => 20.00,
+					),
+				),
+			)
+		);
+		$this->assertNotInstanceOf( WP_Error::class, $partial );
+
+		// Full refund of the remainder as a lump sum (no line items), flagged as a full refund.
+		$remaining = (float) wc_format_decimal( $order->get_total() - $order->get_total_refunded() );
+		$full      = wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => $remaining,
+				'line_items' => array(),
+			)
+		);
+		$this->assertNotInstanceOf( WP_Error::class, $full );
+		$full->update_meta_data( '_refund_type', 'full' );
+		$full->save_meta_data();
+		if ( OrderUtil::orders_cache_usage_is_enabled() ) {
+			wc_get_container()->get( OrderCache::class )->remove( $full->get_id() );
+		}
+
+		OrdersStatsDataStore::sync_order( $order->get_id() );
+		OrdersStatsDataStore::sync_order( $partial->get_id() );
+		OrdersStatsDataStore::sync_order( $full->get_id() );
+
+		global $wpdb;
+
+		// Reported returns = absolute gross (net + tax + shipping) summed over the refund rows.
+		$returns = (float) $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT ABS( SUM( net_total + tax_total + shipping_total ) ) FROM {$wpdb->prefix}wc_order_stats WHERE parent_id = %d",
+				$order->get_id()
+			)
+		);
+		// Once (the order gross), not the partial refund counted twice ($75 before the fix).
+		$this->assertEqualsWithDelta( 55.00, $returns, 0.02 );
+
+		// The net portion across refunds should reconstruct the order net exactly once.
+		$refunded_net = (float) $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT SUM( net_total ) FROM {$wpdb->prefix}wc_order_stats WHERE parent_id = %d",
+				$order->get_id()
+			)
+		);
+		$this->assertEqualsWithDelta( -40.00, $refunded_net, 0.02 );
+
+		WC_Helper_Order::delete_order( $order->get_id() );
+	}
 }