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