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() );
+ }
}