Commit 9057c2deacc for woocommerce

commit 9057c2deaccd534ad4c48b4cf5ed1367deff088f
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date:   Wed Mar 18 16:34:10 2026 +0200

    fix: accept top-level fields in v4 payment gateway PUT endpoint (#63714)

    * fix: accept top-level fields in v4 payment gateway PUT endpoint

    The GET response returns `enabled`, `title`, `description`, and `order`
    as top-level fields, but the PUT endpoint only accepted these nested
    inside a required `values` parameter. This shape mismatch prevents
    `@wordpress/core-data` from tracking dirty state correctly — edits
    never match the persisted record shape, so entities appear permanently
    dirty.

    Accept these fields at the top level (matching the GET shape) while
    maintaining backwards compatibility with `values.*` for existing
    callers. Top-level params take precedence when both are present.
    The `values` parameter is no longer required; endpoint args are now
    derived from the schema.

    Refs WOOPRD-3082

    * fix(payments): use explicit endpoint args to avoid REST API sanitization

    Using get_endpoint_args_for_item_schema() caused REST API to add
    validate/sanitize callbacks to the values parameter. The multi-type
    additionalProperties schema (string|number|array|boolean) triggered
    sanitization that corrupted field values, causing strtolower() to
    receive arrays instead of strings.

    Replace with explicit arg definitions without sanitize callbacks since
    the controller handles all validation and sanitization internally.

    Also defer woocommerce_gateway_order update_option until after all
    validation passes, preventing partial writes on validation errors.

    Add tests for top-level title and order fields with DB persistence
    assertions.

    Refs WOOPRD-3082

    * test(payments): add coverage for no-op PUT on payment gateway endpoint

    All parameters are now optional, so a PUT with no params should return
    200 and leave settings unchanged. The old test asserting 400 was removed
    when required params were dropped, but the replacement behavior had no
    coverage.

    Refs WOOPLUG-6371

    * fix(test): fix no-op PUT test assertion and lint alignment

    The previous test compared get_option() before and after the PUT, but
    the option may not exist before the first PUT call in a clean CI
    environment — causing assertSame(false, array(...)) to fail.

    Compare gateway object properties (enabled, title) instead, which are
    always available. Also fix PHPCS equals sign alignment warnings.

    * test(payments): address review feedback on v4 gateway tests

    - Assert persisted state (get_option) instead of in-memory gateway
      object in the top-level enabled test
    - Add top-level description field test for complete field coverage
    - Add legacy values.enabled='yes' regression test for backwards compat

diff --git a/plugins/woocommerce/changelog/63714-fix-payment-gateway-v4-top-level-fields b/plugins/woocommerce/changelog/63714-fix-payment-gateway-v4-top-level-fields
new file mode 100644
index 00000000000..398fe7046a8
--- /dev/null
+++ b/plugins/woocommerce/changelog/63714-fix-payment-gateway-v4-top-level-fields
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+Accept top-level enabled, title, description, and order fields in the v4 payment gateway settings PUT endpoint. This aligns the PUT shape with the GET response, enabling @wordpress/core-data to track dirty state correctly. The values parameter remains supported for backwards compatibility.
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Controller.php
index 029f63a86eb..1b334c07634 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Controller.php
@@ -74,10 +74,30 @@ class Controller extends AbstractController {
 					'callback'            => array( $this, 'update_item' ),
 					'permission_callback' => array( $this, 'update_item_permissions_check' ),
 					'args'                => array(
-						'values' => array(
-							'description' => __( 'Payment gateway field values to update.', 'woocommerce' ),
+						'enabled'     => array(
+							'description' => __( 'Gateway enabled status.', 'woocommerce' ),
+							'type'        => 'boolean',
+							'required'    => false,
+						),
+						'title'       => array(
+							'description' => __( 'Gateway title.', 'woocommerce' ),
+							'type'        => 'string',
+							'required'    => false,
+						),
+						'description' => array(
+							'description' => __( 'Gateway description.', 'woocommerce' ),
+							'type'        => 'string',
+							'required'    => false,
+						),
+						'order'       => array(
+							'description' => __( 'Gateway sort order.', 'woocommerce' ),
+							'type'        => 'integer',
+							'required'    => false,
+						),
+						'values'      => array(
+							'description' => __( 'Flat key-value mapping of all setting field values.', 'woocommerce' ),
 							'type'        => 'object',
-							'required'    => true,
+							'required'    => false,
 						),
 					),
 				),
@@ -194,44 +214,43 @@ class Controller extends AbstractController {
 		// Get gateway-specific schema.
 		$schema = $this->get_schema_for_gateway( $id );

-		// Get field values from the values parameter.
+		// Get field values from the values parameter (for gateway-specific settings).
 		$params           = $request->get_params();
-		$values_to_update = $params['values'] ?? null;
-
-		if ( empty( $values_to_update ) || ! is_array( $values_to_update ) ) {
-			return new WP_Error(
-				'rest_missing_callback_param',
-				__( 'Missing parameter(s): values', 'woocommerce' ),
-				array( 'status' => 400 )
-			);
+		$values_to_update = $params['values'] ?? array();
+		if ( ! is_array( $values_to_update ) ) {
+			$values_to_update = array();
 		}

-		// Handle top-level gateway fields from within values.
+		// Handle top-level gateway fields. Accept both top-level params
+		// (core-data sends edits this way) and values.* (legacy/form saves).
+		// Top-level takes precedence when both are present.
 		$gateway->init_form_fields();

-		if ( isset( $values_to_update['enabled'] ) ) {
-			$gateway->enabled             = wc_bool_to_string( $values_to_update['enabled'] );
+		$enabled = $params['enabled'] ?? $values_to_update['enabled'] ?? null;
+		if ( null !== $enabled ) {
+			$gateway->enabled             = wc_bool_to_string( $enabled );
 			$gateway->settings['enabled'] = $gateway->enabled;
 			unset( $values_to_update['enabled'] );
 		}

-		if ( isset( $values_to_update['title'] ) ) {
-			$gateway->title             = sanitize_text_field( $values_to_update['title'] );
+		$title = $params['title'] ?? $values_to_update['title'] ?? null;
+		if ( null !== $title ) {
+			$gateway->title             = sanitize_text_field( $title );
 			$gateway->settings['title'] = $gateway->title;
 			unset( $values_to_update['title'] );
 		}

-		if ( isset( $values_to_update['description'] ) ) {
-			$gateway->description             = wp_kses_post( $values_to_update['description'] );
+		$description = $params['description'] ?? $values_to_update['description'] ?? null;
+		if ( null !== $description ) {
+			$gateway->description             = wp_kses_post( $description );
 			$gateway->settings['description'] = $gateway->description;
 			unset( $values_to_update['description'] );
 		}

-		if ( isset( $values_to_update['order'] ) ) {
-			$order                = absint( $values_to_update['order'] );
-			$gateway_order        = (array) get_option( 'woocommerce_gateway_order', array() );
-			$gateway_order[ $id ] = $order;
-			update_option( 'woocommerce_gateway_order', $gateway_order );
+		$order_to_update = null;
+		$order_value     = $params['order'] ?? $values_to_update['order'] ?? null;
+		if ( null !== $order_value ) {
+			$order_to_update = absint( $order_value );
 			unset( $values_to_update['order'] );
 		}

@@ -277,6 +296,13 @@ class Controller extends AbstractController {
 		// Save standard settings to database.
 		update_option( $gateway->get_option_key(), $gateway->settings );

+		// Save gateway order (deferred until after validation).
+		if ( null !== $order_to_update ) {
+			$gateway_order        = (array) get_option( 'woocommerce_gateway_order', array() );
+			$gateway_order[ $id ] = $order_to_update;
+			update_option( 'woocommerce_gateway_order', $gateway_order );
+		}
+
 		// Update special fields.
 		$schema->update_special_fields( $gateway, $validated_special );

diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/PaymentGatewaysSettingsControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/PaymentGatewaysSettingsControllerTest.php
index a31b238cd04..70e91e9cd7b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/PaymentGatewaysSettingsControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/PaymentGatewaysSettingsControllerTest.php
@@ -217,16 +217,158 @@ class PaymentGatewaysSettingsControllerTest extends WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * Test updating a payment gateway without values parameter.
+	 * Test updating a payment gateway with no parameters performs a no-op.
+	 *
+	 * All parameters are optional, so an empty PUT should succeed without
+	 * modifying any gateway settings.
 	 */
-	public function test_update_payment_gateway_missing_values() {
+	public function test_update_payment_gateway_with_no_params() {
+		// Arrange.
+		$gateway        = WC()->payment_gateways->payment_gateways()['bacs'];
+		$enabled_before = $gateway->enabled;
+		$title_before   = $gateway->title;
+
 		// Act.
 		$request  = new WP_REST_Request( 'PUT', self::ENDPOINT . '/bacs' );
 		$response = $this->server->dispatch( $request );

 		// Assert.
-		$this->assertSame( 400, $response->get_status() );
-		$this->assertSame( 'rest_missing_callback_param', $response->get_data()['code'] );
+		$this->assertSame( 200, $response->get_status() );
+
+		// Verify gateway state was not changed.
+		$gateway_after = WC()->payment_gateways->payment_gateways()['bacs'];
+		$this->assertSame( $enabled_before, $gateway_after->enabled );
+		$this->assertSame( $title_before, $gateway_after->title );
+	}
+
+	/**
+	 * Test updating a payment gateway with top-level enabled field.
+	 *
+	 * Core-data sends edits as top-level fields (matching the GET response shape)
+	 * rather than nested under the values parameter.
+	 */
+	public function test_update_payment_gateway_with_top_level_enabled() {
+		// Act.
+		$request = new WP_REST_Request( 'PUT', self::ENDPOINT . '/bacs' );
+		$request->set_param( 'enabled', true );
+		$response = $this->server->dispatch( $request );
+
+		// Assert.
+		$this->assertSame( 200, $response->get_status() );
+
+		$data = $response->get_data();
+		$this->assertTrue( $data['enabled'] );
+
+		// Verify persisted state.
+		$saved_settings = get_option( 'woocommerce_bacs_settings' );
+		$this->assertSame( 'yes', $saved_settings['enabled'] );
+	}
+
+	/**
+	 * Test updating a payment gateway with top-level description field.
+	 */
+	public function test_update_payment_gateway_with_top_level_description() {
+		// Act.
+		$request = new WP_REST_Request( 'PUT', self::ENDPOINT . '/bacs' );
+		$request->set_param( 'description', 'Pay via bank transfer.' );
+		$response = $this->server->dispatch( $request );
+
+		// Assert.
+		$this->assertSame( 200, $response->get_status() );
+
+		$data = $response->get_data();
+		$this->assertSame( 'Pay via bank transfer.', $data['description'] );
+
+		// Verify persisted state.
+		$saved_settings = get_option( 'woocommerce_bacs_settings' );
+		$this->assertSame( 'Pay via bank transfer.', $saved_settings['description'] );
+	}
+
+	/**
+	 * Test that legacy values.enabled with string 'yes' still works.
+	 *
+	 * Existing callers send enabled as 'yes'/'no' strings inside the values
+	 * parameter. This must remain supported for backwards compatibility.
+	 */
+	public function test_update_payment_gateway_with_legacy_yes_string() {
+		// Act.
+		$request = new WP_REST_Request( 'PUT', self::ENDPOINT . '/bacs' );
+		$request->set_param( 'values', array( 'enabled' => 'yes' ) );
+		$response = $this->server->dispatch( $request );
+
+		// Assert.
+		$this->assertSame( 200, $response->get_status() );
+
+		$data = $response->get_data();
+		$this->assertTrue( $data['enabled'] );
+
+		// Verify persisted state.
+		$saved_settings = get_option( 'woocommerce_bacs_settings' );
+		$this->assertSame( 'yes', $saved_settings['enabled'] );
+	}
+
+	/**
+	 * Test that top-level fields take precedence over values.
+	 */
+	public function test_top_level_fields_take_precedence_over_values() {
+		// Act - send enabled=true at top level and enabled=false in values.
+		$request = new WP_REST_Request( 'PUT', self::ENDPOINT . '/bacs' );
+		$request->set_param( 'enabled', true );
+		$request->set_param( 'values', array( 'enabled' => false ) );
+		$response = $this->server->dispatch( $request );
+
+		// Assert - top-level should win.
+		$this->assertSame( 200, $response->get_status() );
+
+		$data = $response->get_data();
+		$this->assertTrue( $data['enabled'] );
+
+		// Verify persisted state matches top-level value.
+		$saved_settings = get_option( 'woocommerce_bacs_settings' );
+		$this->assertSame( 'yes', $saved_settings['enabled'] );
+	}
+
+	/**
+	 * Test updating a payment gateway with top-level title field.
+	 */
+	public function test_update_payment_gateway_with_top_level_title() {
+		// Act.
+		$request = new WP_REST_Request( 'PUT', self::ENDPOINT . '/bacs' );
+		$request->set_param( 'title', 'Wire Transfer' );
+		$response = $this->server->dispatch( $request );
+
+		// Assert.
+		$this->assertSame( 200, $response->get_status() );
+
+		$data = $response->get_data();
+		$this->assertSame( 'Wire Transfer', $data['title'] );
+
+		// Verify persisted state.
+		$saved_settings = get_option( 'woocommerce_bacs_settings' );
+		$this->assertSame( 'Wire Transfer', $saved_settings['title'] );
+	}
+
+	/**
+	 * Test updating a payment gateway with top-level order field.
+	 */
+	public function test_update_payment_gateway_with_top_level_order() {
+		// Arrange.
+		delete_option( 'woocommerce_gateway_order' );
+
+		// Act.
+		$request = new WP_REST_Request( 'PUT', self::ENDPOINT . '/bacs' );
+		$request->set_param( 'order', 3 );
+		$response = $this->server->dispatch( $request );
+
+		// Assert.
+		$this->assertSame( 200, $response->get_status() );
+
+		$data = $response->get_data();
+		$this->assertSame( 3, $data['order'] );
+
+		// Verify persisted state.
+		$gateway_order = get_option( 'woocommerce_gateway_order' );
+		$this->assertSame( 3, $gateway_order['bacs'] );
 	}

 	/**