Commit d8430f0d6c7 for woocommerce
commit d8430f0d6c7a24c7de6cf8c2eecd7130a15c734e
Author: louwie17 <lourensschep@gmail.com>
Date: Mon Jun 15 13:25:31 2026 +0200
Add per-period refunds field to /wc/v3/reports/sales (#65467)
* Add per-period refunds to legacy sales report
The /wc/v[1-3]/reports/sales endpoint returns per-day buckets without a
refunds field, so consumers can't compute net sales per period — the
top-level total_refunds gives the aggregate but not the breakdown. The
admin chart has historically subtracted refunds in JS for the same
reason. Surface the per-period total as an additive refunds string so
external callers can do the same.
Per-day sales semantics are intentionally unchanged (still pre-refund
gross) to avoid a silent breaking change for legacy consumers — net
per-day is now derivable as sales - refunds. The v2/v3 controllers
inherit from v1, so the fix propagates automatically.
Closes #27552. Closes #42694.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Scope per-period refunds field to v3 endpoint only
The previous commit added the refunds field to the shared v1 base
controller, which propagated to all three namespaces via inheritance.
Tighten scope: leave v1/v2 untouched (they're in maintenance mode) and
override prepare_item_for_response + get_item_schema in the v3
controller instead. The v3 override calls parent, then patches in the
refunds bucket — about 25 lines of net new logic plus a typed totals
schema.
Tests moved to Version3/ and extended to assert v1/v2 remain unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Remove v1/v2 scope-guard test
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address review feedback on sales report refunds field
- Add declare(strict_types = 1) to the test file per project convention.
- Rename the same-day refund test to clarify the sum assertion holds for
the in-range scenario only — refund_lines and full_refunds can diverge
when refunds straddle the report range boundary.
- Document that divergence in the v3 reports doc and fix the example
total_refunds value so it matches the per-period refunds bucket.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Fix lint issues
* Replace current_time('timestamp') with current_datetime() in tests
WordPress.DateTime.CurrentTimeTimestamp.Requested flags current_time()
with a 'timestamp' type because it returns a local-time-as-fake-Unix
value rather than a real UTC timestamp. current_datetime() returns a
DateTimeImmutable in wp_timezone(), which keeps the local-time semantics
the controller buckets by without the deprecated pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Fix lint and phpstan failures in sales report refunds
Add phpcs:ignore for date_default_timezone_set in the timezone test and
annotate the report var so get_report_data() typechecks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fixed lint
* Fix sales report API test and revert unrelated products controller
The e2e API test 'can view sales reports' asserted the per-day totals
bucket as a literal object, which failed once the new `refunds` field
was added to v3. Add `refunds: expect.any(String)` to match the field
introduced in this PR.
Also revert plugins/woocommerce/includes/rest-api/Controllers/Version3/
class-wc-rest-products-controller.php to trunk. An earlier reformatter
pass moved several `// WPCS: slow query ok.` suppressions to their own
line, breaking the inline suppression and surfacing pre-existing slow-
query warnings. The file is unrelated to this PR's scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
diff --git a/docs/apis/rest-api/v3/reports.mdx b/docs/apis/rest-api/v3/reports.mdx
index e004735b1ca..7723af344e0 100644
--- a/docs/apis/rest-api/v3/reports.mdx
+++ b/docs/apis/rest-api/v3/reports.mdx
@@ -309,7 +309,7 @@ woocommerce.get("reports/sales", query).parsed_response
"total_items": 6,
"total_tax": "0.00",
"total_shipping": "10.00",
- "total_refunds": 0,
+ "total_refunds": "10.00",
"total_discount": "0.00",
"totals_grouped_by": "day",
"totals": {
@@ -320,6 +320,7 @@ woocommerce.get("reports/sales", query).parsed_response
"tax": "0.00",
"shipping": "10.00",
"discount": "0.00",
+ "refunds": "10.00",
"customers": 0
},
"2016-05-04": {
@@ -329,6 +330,7 @@ woocommerce.get("reports/sales", query).parsed_response
"tax": "0.00",
"shipping": "0.00",
"discount": "0.00",
+ "refunds": "0.00",
"customers": 0
}
},
@@ -347,6 +349,10 @@ woocommerce.get("reports/sales", query).parsed_response
</TabItem>
</Tabs>
+:::note Per-period `refunds` vs `total_refunds`
+Per-period `refunds` and the top-level `total_refunds` come from different queries and will not always agree. Per-period `refunds` sum each refund record by its own date inside the report range. `total_refunds` instead counts the parent order's full total for any refunded-status order that has *any* refund record falling in the range. When an order's refunds straddle the range boundary (e.g. a partial refund last month and another this month on the same order that ultimately becomes refunded), the two values can diverge. For per-day net sales, prefer `sales - refunds` per bucket.
+:::
+
#### Sales report properties
| Attribute | Type | Description |
diff --git a/plugins/woocommerce/changelog/fix-wooplug-104-sales-report-refunds b/plugins/woocommerce/changelog/fix-wooplug-104-sales-report-refunds
new file mode 100644
index 00000000000..b12f24b4a51
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-104-sales-report-refunds
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Add per-period `refunds` field to the legacy REST sales report so consumers can compute net sales per day or month (fixes #27552, #42694).
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-report-sales-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-report-sales-controller.php
index 28fd0e9d102..254a5752d79 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-report-sales-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-report-sales-controller.php
@@ -24,4 +24,131 @@ class WC_REST_Report_Sales_Controller extends WC_REST_Report_Sales_V2_Controller
* @var string
*/
protected $namespace = 'wc/v3';
+
+ /**
+ * Prepare a report sales object for serialization.
+ *
+ * Extends the v2 response with a per-period `refunds` field inside each
+ * `totals[date]` bucket so consumers can compute net sales per period
+ * without a second request. Top-level `total_refunds` and per-period
+ * `sales` semantics are unchanged.
+ *
+ * @param null $_ Unused.
+ * @param WP_REST_Request<array<string, mixed>> $request Request object.
+ * @return WP_REST_Response
+ */
+ public function prepare_item_for_response( $_, $request ) {
+ $response = parent::prepare_item_for_response( $_, $request );
+ $data = $response->get_data();
+
+ if ( ! isset( $data['totals'] ) || ! is_array( $data['totals'] ) ) {
+ return $response;
+ }
+
+ // Initialise the refunds bucket on every period so consumers get a
+ // stable shape (decimal string) even on periods with no refunds.
+ foreach ( $data['totals'] as $time => $bucket ) {
+ $data['totals'][ $time ]['refunds'] = wc_format_decimal( 0.00, 2 );
+ }
+
+ // `$this->report` is a WC_Report_Sales_By_Date (the v1 base annotates
+ // it as the abstract WC_Admin_Report). Annotate locally so the call to
+ // the concrete `get_report_data()` typechecks.
+ /** @var WC_Report_Sales_By_Date $report */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
+ $report = $this->report;
+ $report_data = $report->get_report_data();
+ if ( ! empty( $report_data->refund_lines ) ) {
+ foreach ( $report_data->refund_lines as $refund ) {
+ // Match the bucket key format used by the parent's sales /
+ // orders / items / coupons loops (local time, not UTC) so
+ // refunds line up with their corresponding sales row.
+ // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date -- Match adjacent loops in v1 base controller.
+ $time = ( 'day' === $this->report->chart_groupby ) ? date( 'Y-m-d', strtotime( $refund->post_date ) ) : date( 'Y-m', strtotime( $refund->post_date ) );
+
+ if ( ! isset( $data['totals'][ $time ] ) ) {
+ continue;
+ }
+
+ $data['totals'][ $time ]['refunds'] = wc_format_decimal( (float) $data['totals'][ $time ]['refunds'] + (float) $refund->total_refund, 2 );
+ }
+ }
+
+ $response->set_data( $data );
+ return $response;
+ }
+
+ /**
+ * Get the Report's schema, conforming to JSON Schema.
+ *
+ * Extends the v2 schema with the per-period `refunds` field and replaces
+ * the previously incorrect `totals` typing (`array` of `array`) with the
+ * actual object-of-objects shape so the schema reflects reality.
+ *
+ * @return array
+ */
+ public function get_item_schema() {
+ $schema = parent::get_item_schema();
+
+ $schema['properties']['totals'] = array(
+ 'description' => __( 'Totals.', 'woocommerce' ),
+ 'type' => 'object',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ 'additionalProperties' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'sales' => array(
+ 'description' => __( 'Gross sales in the period.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'orders' => array(
+ 'description' => __( 'Number of orders in the period.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'items' => array(
+ 'description' => __( 'Number of items sold in the period.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'tax' => array(
+ 'description' => __( 'Tax charged in the period.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'shipping' => array(
+ 'description' => __( 'Shipping charged in the period.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'discount' => array(
+ 'description' => __( 'Discounts applied in the period.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'refunds' => array(
+ 'description' => __( 'Refunds issued in the period.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'customers' => array(
+ 'description' => __( 'New customers in the period.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ ),
+ ),
+ );
+
+ return $this->add_additional_fields_schema( $schema );
+ }
}
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/api-tests/reports/reports-crud.test.ts b/plugins/woocommerce/tests/e2e-pw/tests/api-tests/reports/reports-crud.test.ts
index 6feb082ab04..dc01177e9fa 100644
--- a/plugins/woocommerce/tests/e2e-pw/tests/api-tests/reports/reports-crud.test.ts
+++ b/plugins/woocommerce/tests/e2e-pw/tests/api-tests/reports/reports-crud.test.ts
@@ -152,6 +152,7 @@ test.describe( 'Reports API tests', () => {
tax: expect.any( String ),
shipping: expect.any( String ),
discount: expect.any( String ),
+ refunds: expect.any( String ),
customers: expect.any( Number ),
},
} ),
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-report-sales-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-report-sales-controller-tests.php
new file mode 100644
index 00000000000..d4bfbb5fd50
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-report-sales-controller-tests.php
@@ -0,0 +1,232 @@
+<?php
+
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+
+/**
+ * Tests for WC_REST_Report_Sales_Controller (v3), focused on the per-period
+ * `refunds` field added to fix WOOPLUG-104 / GH #27552.
+ */
+class WC_REST_Report_Sales_Controller_Tests extends WC_REST_Unit_Test_Case {
+
+ /**
+ * Stores the previous HPOS state. The legacy sales report queries posts directly.
+ *
+ * @var bool
+ */
+ private static $hpos_prev_state;
+
+ /**
+ * Disable HPOS before tests — legacy sales report queries posts directly.
+ */
+ public static function setUpBeforeClass(): void {
+ parent::setUpBeforeClass();
+ include_once WC()->plugin_path() . '/includes/admin/reports/class-wc-admin-report.php';
+ include_once WC()->plugin_path() . '/includes/admin/reports/class-wc-report-sales-by-date.php';
+ self::$hpos_prev_state = \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
+ OrderHelper::toggle_cot_feature_and_usage( false );
+ }
+
+ /**
+ * Restore HPOS state after tests.
+ */
+ public static function tearDownAfterClass(): void {
+ OrderHelper::toggle_cot_feature_and_usage( self::$hpos_prev_state );
+ parent::tearDownAfterClass();
+ }
+
+ /**
+ * Set up the test user and clear cached report data.
+ *
+ * WC_Admin_Report holds its query cache in a protected static array
+ * (`$cached_results`) that survives transactional rollback, so each
+ * test must reset it explicitly to avoid leakage across tests.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ wp_set_current_user(
+ $this->factory->user->create( array( 'role' => 'administrator' ) )
+ );
+ delete_transient( 'wc_report_sales_by_date' );
+
+ $reflection = new ReflectionClass( WC_Admin_Report::class );
+ foreach ( array( 'cached_results', 'transients_to_update' ) as $property_name ) {
+ $property = $reflection->getProperty( $property_name );
+ $property->setAccessible( true );
+ $property->setValue( null, array() );
+ }
+ }
+
+ /**
+ * Helper: invoke the v3 controller and return the response body as an array.
+ *
+ * @param string $period Period to request.
+ * @return array
+ */
+ private function get_report( string $period = 'month' ): array {
+ $request = new WP_REST_Request( 'GET', '/wc/v3/reports/sales' );
+ $request->set_param( 'period', $period );
+
+ $controller = new WC_REST_Report_Sales_Controller();
+ $response = $controller->prepare_item_for_response( null, $request );
+ $this->assertInstanceOf( WP_REST_Response::class, $response );
+
+ return $response->get_data();
+ }
+
+ /**
+ * @testdox Should populate per-day refunds when an order is refunded on the same day.
+ *
+ * The sum assertion holds for this scenario (single same-day refund inside the range), but
+ * is not a general invariant: `total_refunds` comes from a different query (`full_refunds`)
+ * that counts a refunded-status order's full parent total whenever any of its refund posts
+ * falls in the range, while per-period `refunds` come from `refund_lines` (per-refund-post
+ * amounts). The two can diverge when refunds straddle the report range boundary.
+ */
+ public function test_refunds_field_populated_for_same_day_refund_in_range(): void {
+ $order = WC_Helper_Order::create_order();
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ wc_create_refund(
+ array(
+ 'amount' => 7,
+ 'order_id' => $order->get_id(),
+ )
+ );
+
+ $data = $this->get_report( 'month' );
+ $today = current_datetime()->format( 'Y-m-d' );
+
+ $this->assertArrayHasKey( $today, $data['totals'], 'Today\'s bucket should exist in the response.' );
+ $this->assertArrayHasKey( 'refunds', $data['totals'][ $today ], 'Per-day record should expose a refunds field.' );
+ $this->assertSame( '7.00', $data['totals'][ $today ]['refunds'], 'Today\'s refunds should equal the refund amount.' );
+ $this->assertSame( $data['total_refunds'], (float) array_sum( wp_list_pluck( $data['totals'], 'refunds' ) ), 'Per-period refunds should sum to top-level total_refunds in this same-day scenario.' );
+ }
+
+ /**
+ * @testdox Should attribute the refund to the refund date when it differs from the order date.
+ */
+ public function test_refunds_attributed_to_refund_date_not_order_date(): void {
+ $order = WC_Helper_Order::create_order();
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $refund = wc_create_refund(
+ array(
+ 'amount' => 5,
+ 'order_id' => $order->get_id(),
+ )
+ );
+
+ $yesterday = current_datetime()->modify( '-1 day' )->format( 'Y-m-d' );
+ $backdated_string = $yesterday . ' 12:00:00';
+ wp_update_post(
+ array(
+ 'ID' => $refund->get_id(),
+ 'post_date' => $backdated_string,
+ 'post_date_gmt' => $backdated_string,
+ )
+ );
+
+ $data = $this->get_report( 'month' );
+ $today = current_datetime()->format( 'Y-m-d' );
+
+ $this->assertArrayHasKey( $yesterday, $data['totals'], 'Yesterday\'s bucket should exist in the response.' );
+ $this->assertSame( '5.00', $data['totals'][ $yesterday ]['refunds'], 'Refund should be attributed to the refund date.' );
+ $this->assertSame( '0.00', $data['totals'][ $today ]['refunds'], 'Order date bucket should not include the refund.' );
+ }
+
+ /**
+ * @testdox Should expose refunds as a formatted decimal string of "0.00" on days with no refunds.
+ */
+ public function test_refunds_is_zero_string_on_days_without_refunds(): void {
+ $order = WC_Helper_Order::create_order();
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $data = $this->get_report( 'month' );
+
+ foreach ( $data['totals'] as $period => $bucket ) {
+ $this->assertArrayHasKey( 'refunds', $bucket, "Bucket $period should always include a refunds key." );
+ $this->assertSame( '0.00', $bucket['refunds'], "Bucket $period should have refunds = '0.00' when no refund occurred." );
+ }
+ }
+
+ /**
+ * @testdox Should bucket refunds by local time, matching the sales/orders bucketing, in a non-UTC site.
+ *
+ * Regression for the day-mismatch hazard: if the controller's refund loop bucketed by UTC
+ * (gmdate) while the sales/orders/items/coupons loops bucket by local time (date), a refund
+ * placed near midnight local time would line up under a different day than its order — making
+ * per-row net sales (sales - refunds) wrong.
+ */
+ public function test_refunds_bucketed_by_local_time_in_non_utc_site(): void {
+ $previous_php_tz = date_default_timezone_get();
+ $previous_wp_tz = get_option( 'timezone_string' );
+
+ // Pacific/Auckland is UTC+12 (or +13 in DST) — large enough that a local-time-of-02:00
+ // is the previous calendar day in UTC, surfacing any date()/gmdate() mismatch.
+ update_option( 'timezone_string', 'Pacific/Auckland' );
+ // phpcs:ignore WordPress.DateTime.RestrictedFunctions.timezone_change_date_default_timezone_set -- Need to change the PHP timezone to exercise local-vs-UTC date bucketing.
+ date_default_timezone_set( 'Pacific/Auckland' );
+
+ try {
+ $order = WC_Helper_Order::create_order();
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ $refund = wc_create_refund(
+ array(
+ 'amount' => 3,
+ 'order_id' => $order->get_id(),
+ )
+ );
+
+ // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date -- Need local-zone date for the assertion.
+ $local_today = date( 'Y-m-d' );
+ $local_post_date = $local_today . ' 02:00:00';
+ wp_update_post(
+ array(
+ 'ID' => $refund->get_id(),
+ 'post_date' => $local_post_date,
+ )
+ );
+
+ $data = $this->get_report( 'month' );
+
+ $this->assertArrayHasKey(
+ $local_today,
+ $data['totals'],
+ 'Local-time today should exist as a bucket key.'
+ );
+ $this->assertSame(
+ '3.00',
+ $data['totals'][ $local_today ]['refunds'],
+ 'Refund must bucket by local time so it lines up with sales/orders in the same row.'
+ );
+ } finally {
+ // phpcs:ignore WordPress.DateTime.RestrictedFunctions.timezone_change_date_default_timezone_set -- Restore the original PHP timezone.
+ date_default_timezone_set( $previous_php_tz );
+ update_option( 'timezone_string', $previous_wp_tz );
+ }
+ }
+
+ /**
+ * @testdox Should advertise refunds in the totals schema as a decimal string property.
+ */
+ public function test_schema_describes_refunds_field(): void {
+ $controller = new WC_REST_Report_Sales_Controller();
+ $schema = $controller->get_item_schema();
+
+ $this->assertSame( 'object', $schema['properties']['totals']['type'], 'totals should be an object, not an array.' );
+ $this->assertArrayHasKey( 'additionalProperties', $schema['properties']['totals'], 'totals should describe per-period buckets via additionalProperties.' );
+
+ $bucket_schema = $schema['properties']['totals']['additionalProperties'];
+ $this->assertSame( 'object', $bucket_schema['type'] );
+ $this->assertArrayHasKey( 'refunds', $bucket_schema['properties'], 'Per-period bucket should declare a refunds property.' );
+ $this->assertSame( 'string', $bucket_schema['properties']['refunds']['type'], 'refunds should be a string (decimal).' );
+ }
+}