Commit 697a22e69d9 for woocommerce

commit 697a22e69d9a5820af8ed847bccc1617aa5a8f46
Author: Miguel Gasca <miguel.gasca@automattic.com>
Date:   Fri Sep 11 19:52:14 2026 +0200

    Fix refunds being attributed to user #1 when no user is logged in (#68428)

    wc_create_refund() fell back to user ID 1 whenever get_current_user_id()
    returned 0. The fallback dates to 2014 (daf14fc57e), where it guarded against
    writing an empty post_author, and survived the move to CRUD props.

    Refunds are routinely created with nobody logged in: a gateway webhook, a REST
    call, WP-CLI, cron. Each one was recorded against whoever user 1 happens to be,
    naming that person in the admin refund row and in the REST refunded_by field.

    Pass get_current_user_id() through unchanged. WC_Order_Refund already defaults
    refunded_by to 0, and both the HPOS and CPT stores round-trip a zero, so a
    refund nobody issued now stays unattributed.

diff --git a/plugins/woocommerce/changelog/36329-fix-refund-attributed-to-user-1 b/plugins/woocommerce/changelog/36329-fix-refund-attributed-to-user-1
new file mode 100644
index 00000000000..d4745ba8633
--- /dev/null
+++ b/plugins/woocommerce/changelog/36329-fix-refund-attributed-to-user-1
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop attributing refunds to user #1 when no user is logged in. refunded_by is now 0 in REST v1-v3 and null in v4, and the admin labels these refunds as System.
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-refund.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-refund.php
index 4900f3c65c5..c9a63349bea 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-refund.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-refund.php
@@ -10,25 +10,36 @@ if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }

-$who_refunded = new WP_User( $refund->get_refunded_by() );
+$refunded_by  = $refund->get_refunded_by();
+$who_refunded = new WP_User( $refunded_by );
+
+if ( $who_refunded->exists() ) {
+	$refund_author = sprintf(
+		'<abbr class="refund_by" title="%1$s">%2$s</abbr>',
+		/* translators: 1: ID who refunded */
+		sprintf( esc_attr__( 'ID: %d', 'woocommerce' ), absint( $who_refunded->ID ) ),
+		esc_html( $who_refunded->display_name )
+	);
+} elseif ( ! $refunded_by ) {
+	// Nobody was logged in when the refund was created, so no person can be named.
+	$refund_author = esc_html__( 'System', 'woocommerce' );
+} else {
+	// A user was recorded but their account is gone, so there is no name left to show.
+	$refund_author = '';
+}
 ?>
 <tr class="refund <?php echo ( ! empty( $class ) ) ? esc_attr( $class ) : ''; ?>" data-order_refund_id="<?php echo esc_attr( $refund->get_id() ); ?>">
 	<td class="thumb"><div></div></td>

 	<td class="name">
 		<?php
-		if ( $who_refunded->exists() ) {
+		if ( $refund_author ) {
 			printf(
-				/* translators: 1: refund id 2: refund date 3: username */
+				/* translators: 1: refund id 2: refund date 3: username, or "System" when no user issued the refund */
 				esc_html__( 'Refund #%1$s - %2$s by %3$s', 'woocommerce' ),
 				esc_html( $refund->get_id() ),
 				esc_html( wc_format_datetime( $refund->get_date_created(), get_option( 'date_format' ) . ', ' . get_option( 'time_format' ) ) ),
-				sprintf(
-					'<abbr class="refund_by" title="%1$s">%2$s</abbr>',
-					/* translators: 1: ID who refunded */
-					sprintf( esc_attr__( 'ID: %d', 'woocommerce' ), absint( $who_refunded->ID ) ),
-					esc_html( $who_refunded->display_name )
-				)
+				$refund_author // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped above, where it is built.
 			);
 		} else {
 			printf(
diff --git a/plugins/woocommerce/includes/wc-order-functions.php b/plugins/woocommerce/includes/wc-order-functions.php
index 028062a4f39..8bf8b3833c2 100644
--- a/plugins/woocommerce/includes/wc-order-functions.php
+++ b/plugins/woocommerce/includes/wc-order-functions.php
@@ -588,7 +588,9 @@ function wc_create_refund( $args = array() ) {
 		$refund->set_currency( $order->get_currency() );
 		$refund->set_amount( $args['amount'] );
 		$refund->set_parent_id( absint( $args['order_id'] ) );
-		$refund->set_refunded_by( get_current_user_id() ? get_current_user_id() : 1 );
+		// A refund can be created with nobody logged in (gateway webhook, REST, WP-CLI, cron). Record no
+		// user in that case; falling back to user 1 attributes the refund to whoever that happens to be.
+		$refund->set_refunded_by( get_current_user_id() );
 		$refund->set_prices_include_tax( $order->get_prices_include_tax() );

 		if ( ! is_null( $args['reason'] ) ) {
diff --git a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/views/html-order-refund-test.php b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/views/html-order-refund-test.php
new file mode 100644
index 00000000000..a0b51e0e7a9
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/views/html-order-refund-test.php
@@ -0,0 +1,90 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for the refund row rendered inside the order items meta box.
+ *
+ * @package WooCommerce\Tests\Admin
+ */
+class Html_Order_Refund_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * Render the refund row markup for a refund.
+	 *
+	 * @param WC_Order_Refund $refund The refund to render.
+	 * @return string The rendered markup.
+	 */
+	private function render_refund_row( WC_Order_Refund $refund ): string {
+		// Both are read by the view and have no defaults of their own.
+		$order_taxes     = array();
+		$cogs_is_enabled = false;
+
+		ob_start();
+		include WC_ABSPATH . 'includes/admin/meta-boxes/views/html-order-refund.php';
+
+		return (string) ob_get_clean();
+	}
+
+	/**
+	 * Create a refund on a throwaway order, attributed to the given user.
+	 *
+	 * @param int $user_id The user to attribute the refund to.
+	 * @return WC_Order_Refund
+	 */
+	private function create_refund_attributed_to( int $user_id ): WC_Order_Refund {
+		$order = WC_Helper_Order::create_order();
+
+		$refund = new WC_Order_Refund();
+		$refund->set_amount( 10 );
+		$refund->set_parent_id( $order->get_id() );
+		$refund->set_refunded_by( $user_id );
+		$refund->save();
+
+		return $refund;
+	}
+
+	/**
+	 * @testdox Should name the user who issued the refund.
+	 */
+	public function test_names_the_refunding_user(): void {
+		$user_id = self::factory()->user->create(
+			array(
+				'role'         => 'administrator',
+				'display_name' => 'Ada Lovelace',
+			)
+		);
+
+		$markup = $this->render_refund_row( $this->create_refund_attributed_to( $user_id ) );
+
+		$this->assertStringContainsString( 'Ada Lovelace', $markup );
+	}
+
+	/**
+	 * @testdox Should attribute a refund with no recorded user to the system.
+	 *
+	 * @see https://github.com/woocommerce/woocommerce/issues/36329
+	 */
+	public function test_labels_an_unattributed_refund_as_system(): void {
+		$markup = $this->render_refund_row( $this->create_refund_attributed_to( 0 ) );
+
+		$this->assertStringContainsString( 'by System', $markup );
+	}
+
+	/**
+	 * @testdox Should not attribute a refund to the system when its user was deleted.
+	 */
+	public function test_omits_attribution_when_the_refunding_user_was_deleted(): void {
+		$user_id = self::factory()->user->create();
+		$refund  = $this->create_refund_attributed_to( $user_id );
+		wp_delete_user( $user_id );
+
+		$markup = $this->render_refund_row( $refund );
+
+		$this->assertStringNotContainsString(
+			'by System',
+			$markup,
+			'A deleted user is not the system; the refund was issued by a person whose account is gone.'
+		);
+		$this->assertStringNotContainsString( ' by ', $markup );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller-test.php
index 38933b91236..e5a35e1b1e3 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-refunds-controller-test.php
@@ -163,4 +163,39 @@ class WC_REST_Order_Refunds_Controller_Test extends WC_REST_Unit_Test_Case {

 		$this->assert_incomplete_meta_data_handled_correctly( wc_get_order( $response->get_data()['id'] ) );
 	}
+
+	/**
+	 * @testdox Should report refunded_by as 0 when no user issued the refund.
+	 *
+	 * @see https://github.com/woocommerce/woocommerce/issues/36329
+	 */
+	public function test_refunded_by_is_zero_when_no_user_issued_the_refund(): void {
+		wp_set_current_user( 1 );
+
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( '10' );
+		$product->save();
+
+		$order = wc_create_order();
+		$order->add_product( $product, 1 );
+		$order->calculate_totals();
+		$order->save();
+
+		// A refund with no recorded user, as a gateway webhook or cron run produces.
+		$refund = new WC_Order_Refund();
+		$refund->set_amount( 5 );
+		$refund->set_parent_id( $order->get_id() );
+		$refund->set_refunded_by( 0 );
+		$refund->save();
+
+		$request  = new WP_REST_Request( 'GET', '/wc/v3/orders/' . $order->get_id() . '/refunds/' . $refund->get_id() );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame(
+			0,
+			$response->get_data()['refunded_by'],
+			'An unattributed refund reports 0 rather than the id of user 1.'
+		);
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php
index f8a0509f8a5..ae6c5ddbabd 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Refunds/class-wc-rest-refunds-v4-controller-tests.php
@@ -297,6 +297,32 @@ class WC_REST_Refunds_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
 		$this->validate_response_against_schema( $response_data, $schema_properties );
 	}

+	/**
+	 * @testdox Should report refunded_by as null when no user issued the refund.
+	 *
+	 * @see https://github.com/woocommerce/woocommerce/issues/36329
+	 */
+	public function test_refunds_get_endpoint_reports_null_refunded_by_when_no_user_issued_the_refund(): void {
+		$order = $this->create_test_order();
+
+		// A refund with no recorded user, as a gateway webhook or cron run produces.
+		$refund = new WC_Order_Refund();
+		$refund->set_amount( 5 );
+		$refund->set_parent_id( $order->get_id() );
+		$refund->set_refunded_by( 0 );
+		$refund->save();
+		$this->created_refunds[] = $refund->get_id();
+
+		$request  = new WP_REST_Request( 'GET', '/wc/v4/refunds/' . $refund->get_id() );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertNull(
+			$response->get_data()['refunded_by'],
+			'An unattributed refund reports a null refunded_by rather than an object for user 1.'
+		);
+	}
+
 	/**
 	 * Test POST /wc/v4/refunds endpoint creates refund.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
index 8de6654215a..0e06b905eda 100644
--- a/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
@@ -557,4 +557,53 @@ class WC_Order_Functions_Test extends \WC_Unit_Test_Case {
 		$order->delete();
 		$customer->delete();
 	}
+
+	/**
+	 * @testdox Should record the logged-in user who issued the refund.
+	 */
+	public function test_wc_create_refund_records_the_current_user(): void {
+		$user_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $user_id );
+
+		$order  = WC_Helper_Order::create_order();
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $order->get_id(),
+				'amount'   => 10,
+			)
+		);
+
+		$this->assertNotWPError( $refund );
+		$this->assertSame( $user_id, $refund->get_refunded_by() );
+		$this->assertSame(
+			$user_id,
+			wc_get_order( $refund->get_id() )->get_refunded_by(),
+			'The refunding user should survive a round trip through the data store.'
+		);
+	}
+
+	/**
+	 * @testdox Should attribute a refund to nobody when no user is logged in, rather than to user 1.
+	 *
+	 * @see https://github.com/woocommerce/woocommerce/issues/36329
+	 */
+	public function test_wc_create_refund_records_no_user_when_nobody_is_logged_in(): void {
+		wp_set_current_user( 0 );
+
+		$order  = WC_Helper_Order::create_order();
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $order->get_id(),
+				'amount'   => 10,
+			)
+		);
+
+		$this->assertNotWPError( $refund );
+		$this->assertSame( 0, $refund->get_refunded_by() );
+		$this->assertSame(
+			0,
+			wc_get_order( $refund->get_id() )->get_refunded_by(),
+			'An unattributed refund should stay unattributed after a round trip through the data store.'
+		);
+	}
 }