Commit 0a425ff294b for woocommerce
commit 0a425ff294bba35c03d2675b8010604b85037302
Author: Jan Lysý <lysyjan@users.noreply.github.com>
Date: Mon Aug 24 09:34:59 2026 +0200
Fix Revenue analytics counting refunds of never-paid orders (#67710)
* Fix Revenue analytics counting refunds of never-paid orders
* Add changelog entry for never-paid refund analytics fix
* Address review: cover mixed parent dates, broaden changelog wording
diff --git a/plugins/woocommerce/changelog/fix-wooplug-1113-revenue-tax-refunded-orders b/plugins/woocommerce/changelog/fix-wooplug-1113-revenue-tax-refunded-orders
new file mode 100644
index 00000000000..568bc75e75b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-1113-revenue-tax-refunded-orders
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Exclude refunds of never-paid or never-completed orders from Revenue analytics filtered by paid or completed date, so manually refunded failed orders no longer produce phantom returns, negative net sales and uncollected tax amounts. Applies to refunds synced after this fix; previously imported rows update on re-sync or historical data re-import.
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 0eb4cb34c47..7e325df8f4e 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
@@ -633,11 +633,16 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
}
}
/**
- * Set date_completed and date_paid the same as date_created to avoid problems
- * when they are being used to sort the data, as refunds don't have them filled
- */
- $data['date_completed'] = $data['date_created'];
- $data['date_paid'] = $data['date_created'];
+ * Refunds don't have date_completed and date_paid filled, so backfill each from
+ * date_created for sorting — but only when the parent order has that date itself.
+ * A refund of a never-paid order (e.g. a failed order manually set to "refunded")
+ * moves no money; backfilling its dates would include the refund row in reports
+ * filtered by that date while the parent row (NULL date) stays excluded, counting
+ * a one-sided negative. Mirroring the parent keeps the pair excluded together.
+ */
+ $parent_is_order = $parent_order instanceof WC_Order;
+ $data['date_completed'] = $parent_is_order && ! $parent_order->get_date_completed() ? null : $data['date_created'];
+ $data['date_paid'] = $parent_is_order && ! $parent_order->get_date_paid() ? null : $data['date_created'];
}
// Update or add the information to the DB.
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 7389d067efc..e76ec0062da 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
@@ -198,6 +198,120 @@ class DataStoreTest extends WC_Unit_Test_Case {
WC_Helper_Order::delete_order( $order->get_id() );
}
+ /**
+ * @testdox A lump-sum refund of a never-paid order stores no paid or completed date, so date-filtered reports exclude it with its parent.
+ *
+ * Regression test for https://github.com/woocommerce/woocommerce/issues/37065: a failed
+ * (never-paid) order manually set to "refunded" produced a refund stats row with
+ * date_paid and date_completed backfilled from its own creation date. The parent row
+ * (both dates NULL) was excluded from date-filtered Revenue reports while the refund row
+ * was included, so the pair no longer cancelled out: Returns, Net sales and Taxes all
+ * showed amounts for money that was never collected.
+ */
+ public function test_refund_of_never_paid_order_has_null_date_paid(): void {
+ $order = WC_Helper_Order::create_order();
+ $order->set_status( 'failed' );
+ $order->save();
+ $this->assertNull( $order->get_date_paid(), 'Fixture order must never have been paid.' );
+
+ // Setting the status to "refunded" fires wc_order_fully_refunded(), creating the lump-sum refund.
+ $order->update_status( 'refunded' );
+ $refunds = $order->get_refunds();
+ $this->assertCount( 1, $refunds, 'Marking the order refunded should create one lump-sum refund.' );
+ $refund = reset( $refunds );
+
+ OrdersStatsDataStore::sync_order( $order->get_id() );
+ OrdersStatsDataStore::sync_order( $refund->get_id() );
+
+ global $wpdb;
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT order_id, date_paid, date_completed FROM {$wpdb->prefix}wc_order_stats WHERE order_id IN (%d, %d)",
+ $order->get_id(),
+ $refund->get_id()
+ ),
+ OBJECT_K
+ );
+
+ $this->assertCount( 2, $rows, 'Both the order and its refund should have stats rows.' );
+ $this->assertNull( $rows[ $order->get_id() ]->date_paid, 'A never-paid order should carry no paid date.' );
+ $this->assertNull( $rows[ $refund->get_id() ]->date_paid, 'A refund of a never-paid order should carry no paid date.' );
+ $this->assertNull( $rows[ $refund->get_id() ]->date_completed, 'A refund of a never-completed order should carry no completed date.' );
+
+ WC_Helper_Order::delete_order( $order->get_id() );
+ }
+
+ /**
+ * @testdox A refund of a paid order keeps its own creation date as the stats row paid and completed dates.
+ */
+ public function test_refund_of_paid_order_keeps_own_date_paid(): void {
+ $order = WC_Helper_Order::create_order();
+ $order->update_status( 'completed' );
+ $this->assertNotNull( $order->get_date_paid(), 'Fixture order must have been paid.' );
+
+ $refund = wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => (float) wc_format_decimal( $order->get_total() - $order->get_total_refunded() ),
+ 'line_items' => array(),
+ )
+ );
+ $this->assertNotInstanceOf( WP_Error::class, $refund );
+
+ OrdersStatsDataStore::sync_order( $refund->get_id() );
+
+ global $wpdb;
+ $row = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT date_paid, date_completed, date_created FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d",
+ $refund->get_id()
+ )
+ );
+
+ $this->assertNotNull( $row, 'The refund should have a stats row.' );
+ $this->assertSame( $row->date_created, $row->date_paid, 'A refund of a paid order should keep its creation date as the paid date.' );
+ $this->assertSame( $row->date_created, $row->date_completed, 'A refund of a completed order should keep its creation date as the completed date.' );
+
+ WC_Helper_Order::delete_order( $order->get_id() );
+ }
+
+ /**
+ * @testdox A refund of a paid-but-never-completed order backfills only the paid date and keeps the completed date empty.
+ */
+ public function test_refund_of_paid_uncompleted_order_backfills_only_date_paid(): void {
+ $order = WC_Helper_Order::create_order();
+ $order->set_date_paid( time() );
+ $order->set_status( 'processing' );
+ $order->save();
+ $this->assertNotNull( $order->get_date_paid(), 'Fixture order must have been paid.' );
+ $this->assertNull( $order->get_date_completed(), 'Fixture order must never have been completed.' );
+
+ $refund = wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => (float) wc_format_decimal( $order->get_total() - $order->get_total_refunded() ),
+ 'line_items' => array(),
+ )
+ );
+ $this->assertNotInstanceOf( WP_Error::class, $refund );
+
+ OrdersStatsDataStore::sync_order( $refund->get_id() );
+
+ global $wpdb;
+ $row = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT date_paid, date_completed, date_created FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d",
+ $refund->get_id()
+ )
+ );
+
+ $this->assertNotNull( $row, 'The refund should have a stats row.' );
+ $this->assertSame( $row->date_created, $row->date_paid, 'A refund of a paid order should keep its creation date as the paid date.' );
+ $this->assertNull( $row->date_completed, 'A refund of a never-completed order should carry no completed date.' );
+
+ WC_Helper_Order::delete_order( $order->get_id() );
+ }
+
/**
* @testdox Deleting a refund removes its analytics rows while keeping the parent order's rows.
*