Commit 6a1703b78a for woocommerce

commit 6a1703b78a0fe45f94810d8f310d0ad7ad698b22
Author: Ahmed <ahmed.el.azzabi@automattic.com>
Date:   Tue Sep 16 16:37:38 2025 +0100

    Add a v4/settings/general API route and a re-routing config for v4 => v3 (#60769)

    * Remove rest api v4 from FeaturesController

    * Use Features::is_enabled directly

    * Use Features::is_enabled and remove extra functions

    * Remove extra line

    * Add a check for class_exists

    * Add extra )

    * Fix extra param

    * Experimental - Add first version of the general settings API

    * Expose the settings-general api

    * Fix various aspects of the controller

    * Add a V4 controller to re-expose V3

    * Register settings after general

    * Remove unnecessary renaming for groups

    * Add test file for general settings route

    * Fix linting issues

    * use shop_manager

    * Add tearDown and update tests to not leak

    * Add checkbox

    * Escape currency name too

    * Better handling for multiselect

    * Validate options first, then update

    * Fix linting issues with test file

    * Another round of linting fixes

    * Add missing title and description

    * remove hard coded groups in favor of an additionalProperties field

    * add type boolean

    * Use wp_specialchars_decode instead of double-escaping currency symbols

    * Only accept integer numbers

    * Add specific checks for woocommerce_specific_allowed_countries and woocommerce_specific_ship_to_countries

    * Use strtoupper for countries

    * Fix linting errors

    * revert upper case for select

    * Remove check for intval as it breaks how numeric values are defined in WC

    * Add a separate taxes and coupons group

    * add groups order to inform the UI

    * Remove one extra space to fix linting

    * add new sectionend taxes_and_coupons_options

    * Create a new validate_country_or_state_code to use for country validation

    * Update validate_country_or_state_code code

    * Remove validation specific settings

diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-general.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-general.php
index 8ee2b3eab8..a546696eb0 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-general.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-general.php
@@ -108,6 +108,7 @@ class WC_Settings_General extends WC_Settings_Page {
 					'type'  => 'title',
 					'desc'  => __( 'This is where your business is located. Tax rates and shipping rates will use this address.', 'woocommerce' ),
 					'id'    => 'store_address',
+					'order' => 10,
 				),

 				array(
@@ -166,6 +167,7 @@ class WC_Settings_General extends WC_Settings_Page {
 					'type'  => 'title',
 					'desc'  => '',
 					'id'    => 'general_options',
+					'order' => 20,
 				),

 				array(
@@ -246,6 +248,19 @@ class WC_Settings_General extends WC_Settings_Page {

 				$address_autocomplete_preferred_provider_setting,

+				array(
+					'type' => 'sectionend',
+					'id'   => 'general_options',
+				),
+
+				array(
+					'title' => __( 'Taxes and coupons', 'woocommerce' ),
+					'type'  => 'title',
+					'desc'  => __( 'Enable taxes and coupons and configure how they are calculated.', 'woocommerce' ),
+					'id'    => 'taxes_and_coupons_options',
+					'order' => 30,
+				),
+
 				array(
 					'title'    => __( 'Enable taxes', 'woocommerce' ),
 					'desc'     => __( 'Enable tax rates and calculations', 'woocommerce' ),
@@ -279,7 +294,7 @@ class WC_Settings_General extends WC_Settings_Page {

 				array(
 					'type' => 'sectionend',
-					'id'   => 'general_options',
+					'id'   => 'taxes_and_coupons_options',
 				),

 				array(
@@ -287,6 +302,7 @@ class WC_Settings_General extends WC_Settings_Page {
 					'type'  => 'title',
 					'desc'  => __( 'The following options affect how prices are displayed on the frontend.', 'woocommerce' ),
 					'id'    => 'pricing_options',
+					'order' => 40,
 				),

 				array(
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version4/Settings/class-wc-rest-general-settings-v4-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version4/Settings/class-wc-rest-general-settings-v4-controller.php
new file mode 100644
index 0000000000..89642efbd6
--- /dev/null
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version4/Settings/class-wc-rest-general-settings-v4-controller.php
@@ -0,0 +1,703 @@
+<?php
+/**
+ * REST API General Settings controller
+ *
+ * Handles requests to the /settings/general endpoints for WooCommerce API v4.
+ *
+ * @package WooCommerce\RestApi
+ * @since   4.0.0
+ */
+
+declare(strict_types=1);
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+/**
+ * REST API General Settings controller class.
+ *
+ * @package WooCommerce\RestApi
+ * @extends WC_REST_V4_Controller
+ */
+class WC_REST_General_Settings_V4_Controller extends WC_REST_V4_Controller {
+
+	/**
+	 * Route base.
+	 *
+	 * @var string
+	 */
+	protected $rest_base = 'settings/general';
+
+	/**
+	 * WC_Settings_General instance.
+	 *
+	 * @var WC_Settings_General
+	 */
+	protected $settings_general_instance;
+
+	/**
+	 * Get the WC_Settings_General instance.
+	 *
+	 * @return WC_Settings_General
+	 */
+	private function get_settings_general_instance() {
+		if ( is_null( $this->settings_general_instance ) ) {
+			$this->settings_general_instance = new WC_Settings_General();
+		}
+		return $this->settings_general_instance;
+	}
+
+	/**
+	 * Register routes.
+	 */
+	public function register_routes() {
+		register_rest_route(
+			$this->namespace,
+			'/' . $this->rest_base,
+			array(
+				array(
+					'methods'             => WP_REST_Server::READABLE,
+					'callback'            => array( $this, 'get_item' ),
+					'permission_callback' => array( $this, 'get_item_permissions_check' ),
+				),
+				array(
+					'methods'             => WP_REST_Server::EDITABLE,
+					'callback'            => array( $this, 'update_item' ),
+					'permission_callback' => array( $this, 'update_item_permissions_check' ),
+					'args'                => $this->get_update_args(),
+				),
+				'schema' => array( $this, 'get_item_schema' ),
+			)
+		);
+	}
+
+	/**
+	 * Check permissions for reading general settings.
+	 *
+	 * @param WP_REST_Request $request Full details about the request.
+	 * @return bool|WP_Error
+	 */
+	public function get_item_permissions_check( $request ) {
+		return $this->check_permissions( $request, 'read' );
+	}
+
+	/**
+	 * Get update arguments for the endpoint.
+	 *
+	 * @return array
+	 */
+	private function get_update_args() {
+		$args = array();
+
+		// Get valid setting IDs and their types.
+		$settings = $this->get_settings_general_instance()->get_settings_for_section( '' );
+
+		foreach ( $settings as $setting ) {
+			if ( isset( $setting['id'] ) && ! in_array( $setting['type'] ?? '', array( 'title', 'sectionend' ), true ) ) {
+				$setting_id   = $setting['id'];
+				$setting_type = $setting['type'] ?? 'text';
+
+				$args[ $setting_id ] = array(
+					'description' => $setting['title'] ?? $setting_id,
+					'type'        => $this->map_wc_type_to_rest_type( $setting_type ),
+					'required'    => false,
+				);
+
+				// Add validation for specific setting types.
+				if ( 'number' === $setting_type ) {
+					$args[ $setting_id ]['minimum'] = 0;
+				}
+			}
+		}
+
+		return $args;
+	}
+
+	/**
+	 * Map WooCommerce setting types to REST API types.
+	 *
+	 * @param string $wc_type WooCommerce setting type.
+	 * @return string REST API type.
+	 */
+	private function map_wc_type_to_rest_type( $wc_type ) {
+		switch ( $wc_type ) {
+			case 'number':
+				return 'number';
+			case 'checkbox':
+				return 'boolean';
+			case 'multiselect':
+			case 'multi_select_countries':
+				return 'array';
+			default:
+				return 'string';
+		}
+	}
+
+	/**
+	 * Check permissions for updating general settings.
+	 *
+	 * @param WP_REST_Request $request Full details about the request.
+	 * @return bool|WP_Error
+	 */
+	public function update_item_permissions_check( $request ) {
+		return $this->check_permissions( $request, 'edit' );
+	}
+
+	/**
+	 * Get general settings.
+	 *
+	 * @param WP_REST_Request $request Full details about the request.
+	 * @return WP_REST_Response|WP_Error
+	 */
+	public function get_item( $request ) {
+		$settings = $this->get_general_settings_data();
+		return rest_ensure_response( $settings );
+	}
+
+	/**
+	 * Update general settings.
+	 *
+	 * @param WP_REST_Request $request Full details about the request.
+	 * @return WP_REST_Response|WP_Error
+	 */
+	public function update_item( $request ) {
+		$updated_settings = array();
+
+		// Get all parameters from the request body.
+		$params = $request->get_json_params();
+
+		if ( ! is_array( $params ) || empty( $params ) ) {
+			return new WP_Error(
+				'rest_invalid_param',
+				__( 'Invalid or empty request body.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		// Get all general settings definitions.
+		$settings           = $this->get_settings_general_instance()->get_settings_for_section( '' );
+		$settings_by_id     = array_column( $settings, null, 'id' );
+		$valid_setting_ids  = array_keys( $settings_by_id );
+		$validated_settings = array();
+
+		// Process each setting in the payload.
+		foreach ( $params as $setting_id => $setting_value ) {
+			// Sanitize the setting ID.
+			$setting_id = sanitize_text_field( $setting_id );
+
+			// Security check: only allow updating valid WooCommerce general settings.
+			if ( ! in_array( $setting_id, $valid_setting_ids, true ) ) {
+				continue;
+			}
+
+			// Sanitize the value based on the setting type.
+			$setting_definition = $settings_by_id[ $setting_id ];
+			$setting_type       = $setting_definition['type'] ?? 'text';
+			$sanitized_value    = $this->sanitize_setting_value( $setting_type, $setting_value );
+
+			// Additional validation for specific settings.
+			$validation_result = $this->validate_setting_value( $setting_id, $sanitized_value );
+			if ( is_wp_error( $validation_result ) ) {
+				return $validation_result;
+			}
+
+			// Store validated values first.
+			$validated_settings[ $setting_id ] = $sanitized_value;
+		}
+
+		// After validation loop, update all settings.
+		foreach ( $validated_settings as $setting_id => $value ) {
+			$update_result = update_option( $setting_id, $value );
+			if ( $update_result ) {
+				$updated_settings[] = $setting_id;
+			}
+		}
+
+		// Log the update if settings were changed.
+		if ( ! empty( $updated_settings ) ) {
+			/**
+			* Fires when WooCommerce settings are updated.
+			*
+			* @param array $updated_settings Array of updated settings IDs.
+			* @param string $rest_base The REST base of the settings.
+			* @since 4.0.0
+			*/
+			do_action( 'woocommerce_settings_updated', $updated_settings, $this->rest_base );
+		}
+
+		// Return updated settings.
+		$response_data = $this->get_general_settings_data();
+		return rest_ensure_response( $response_data );
+	}
+
+	/**
+	 * Validate a setting value before updating.
+	 *
+	 * @param string $setting_id Setting ID.
+	 * @param mixed  $value      Setting value.
+	 * @return bool|WP_Error True if valid, WP_Error if invalid.
+	 */
+	private function validate_setting_value( $setting_id, $value ) {
+		// Custom validation rules for specific settings.
+		switch ( $setting_id ) {
+			case 'woocommerce_price_num_decimals':
+				if ( ! is_numeric( $value ) || $value < 0 || $value > 10 ) {
+					return new WP_Error(
+						'rest_invalid_param',
+						__( 'Number of decimals must be between 0 and 10.', 'woocommerce' ),
+						array( 'status' => 400 )
+					);
+				}
+				break;
+
+			case 'woocommerce_default_country':
+				// Validate country code format (e.g., "US" or "US:CA").
+				if ( ! empty( $value ) && ! preg_match( '/^[A-Z]{2}(:[A-Z0-9]+)?$/', $value ) ) {
+					return new WP_Error(
+						'rest_invalid_param',
+						__( 'Invalid country/state format.', 'woocommerce' ),
+						array( 'status' => 400 )
+					);
+				}
+
+				if ( ! $this->validate_country_or_state_code( $value ) ) {
+					return new WP_Error(
+						'rest_invalid_param',
+						__( 'Invalid country/state format.', 'woocommerce' ),
+						array( 'status' => 400 )
+					);
+				}
+
+				break;
+
+			case 'woocommerce_allowed_countries':
+				$valid_options = array( 'all', 'all_except', 'specific' );
+				if ( ! in_array( $value, $valid_options, true ) ) {
+					return new WP_Error(
+						'rest_invalid_param',
+						__( 'Invalid selling location option.', 'woocommerce' ),
+						array( 'status' => 400 )
+					);
+				}
+
+				break;
+
+			case 'woocommerce_ship_to_countries':
+				$valid_options = array( '', 'all', 'specific', 'disabled' );
+				if ( ! in_array( $value, $valid_options, true ) ) {
+					return new WP_Error(
+						'rest_invalid_param',
+						__( 'Invalid shipping location option.', 'woocommerce' ),
+						array( 'status' => 400 )
+					);
+				}
+
+				break;
+
+			case 'woocommerce_specific_allowed_countries':
+			case 'woocommerce_specific_ship_to_countries':
+				if ( ! is_array( $value ) ) {
+					return new WP_Error(
+						'rest_invalid_param',
+						__( 'Expected an array of country codes.', 'woocommerce' ),
+						array( 'status' => 400 )
+					);
+				}
+
+				foreach ( $value as $code ) {
+					if ( ! is_string( $code ) || ! $this->validate_country_or_state_code( $code ) ) {
+						return new WP_Error(
+							'rest_invalid_param',
+							__( 'Invalid country code in list.', 'woocommerce' ),
+							array( 'status' => 400 )
+						);
+					}
+				}
+				break;
+		}
+
+		return true;
+	}
+
+	/**
+	 * Sanitize setting value based on its type.
+	 *
+	 * @param string $setting_type Setting type.
+	 * @param mixed  $value        Setting value.
+	 * @return mixed Sanitized value.
+	 */
+	private function sanitize_setting_value( $setting_type, $value ) {
+		switch ( $setting_type ) {
+			case 'text':
+				return sanitize_text_field( $value );
+
+			case 'number':
+				return is_numeric( $value ) ? floatval( $value ) : 0;
+
+			case 'select':
+			case 'single_select_country':
+				return sanitize_text_field( $value );
+
+			case 'multiselect':
+			case 'multi_select_countries':
+				if ( is_array( $value ) ) {
+					return array_map( 'sanitize_text_field', $value );
+				}
+
+				// Handle empty values and string inputs.
+				if ( empty( $value ) ) {
+					return array();
+				}
+
+				// If it's a string, convert to array (for single values).
+				return is_string( $value ) ? array( sanitize_text_field( $value ) ) : array();
+
+			case 'checkbox':
+				return wc_bool_to_string( $value );
+
+			default:
+				return sanitize_text_field( $value );
+		}
+	}
+
+	/**
+	 * Get the display order for a settings group.
+	 *
+	 * @param array $setting  Setting definition array.
+	 * @return int Display order.
+	 */
+	private function get_group_order( $setting ) {
+		if ( isset( $setting['order'] ) && is_numeric( $setting['order'] ) ) {
+			return (int) $setting['order'];
+		}
+
+		return 999;
+	}
+
+	/**
+	 * Get general settings data by transforming WC_Settings_General data into REST API format.
+	 *
+	 * @return array
+	 */
+	private function get_general_settings_data() {
+		$settings_general = $this->get_settings_general_instance();
+		$raw_settings     = $settings_general->get_settings_for_section( '' );
+
+		// Transform raw settings into grouped format.
+		$groups            = array();
+		$current_group     = null;
+		$current_group_key = null;
+
+		foreach ( $raw_settings as $setting ) {
+			$setting_type = $setting['type'] ?? '';
+
+			// Handle section titles.
+			if ( 'title' === $setting_type ) {
+				$current_group_key = $setting['id'] ?? '';
+				$current_group     = array(
+					'title'       => $setting['title'] ?? '',
+					'description' => $setting['desc'] ?? '',
+					'order'       => $this->get_group_order( $setting ),
+					'fields'      => array(),
+				);
+				continue;
+			}
+
+			// Handle section ends.
+			if ( 'sectionend' === $setting_type ) {
+				if ( $current_group && $current_group_key ) {
+					$groups[ $current_group_key ] = $current_group;
+				}
+				$current_group     = null;
+				$current_group_key = null;
+				continue;
+			}
+
+			// Skip non-field types.
+			if ( in_array( $setting_type, array( 'title', 'sectionend' ), true ) ) {
+				continue;
+			}
+
+			// Convert setting to field format.
+			if ( $current_group && isset( $setting['id'] ) ) {
+				$field = $this->transform_setting_to_field( $setting );
+				if ( $field ) {
+					$current_group['fields'][] = $field;
+				}
+			}
+		}
+
+		return array(
+			'id'          => 'general',
+			'title'       => __( 'General', 'woocommerce' ),
+			'description' => __( 'Set your store\'s address, visibility, currency, language, and timezone.', 'woocommerce' ),
+			'groups'      => $groups,
+		);
+	}
+
+	/**
+	 * Transform a WooCommerce setting into REST API field format.
+	 *
+	 * @param array $setting WooCommerce setting array.
+	 * @return array|null Transformed field or null if should be skipped.
+	 */
+	private function transform_setting_to_field( $setting ) {
+		$setting_id   = $setting['id'] ?? '';
+		$setting_type = $setting['type'] ?? 'text';
+
+		// Skip certain settings that shouldn't be exposed via REST API.
+		// This is a temporary array until designs are finalized.
+		$skip_settings = array(
+			'woocommerce_address_autocomplete_enabled',
+			'woocommerce_address_autocomplete_provider',
+		);
+
+		if ( in_array( $setting_id, $skip_settings, true ) ) {
+			return null;
+		}
+
+		$field = array(
+			'id'    => $setting_id,
+			'label' => $setting['title'] ?? $setting_id,
+			'type'  => $this->normalize_field_type( $setting_type ),
+			'value' => get_option( $setting_id, $setting['default'] ?? '' ),
+		);
+
+		// Add tip if available.
+		if ( ! empty( $setting['desc'] ) && ! empty( $setting['desc_tip'] ) ) {
+			$field['tip'] = $setting['desc'];
+		}
+
+		// Add options for select fields.
+		if ( isset( $setting['options'] ) && is_array( $setting['options'] ) ) {
+			$field['options'] = $setting['options'];
+		} else {
+			// Generate options for special field types that don't have them in the setting definition.
+			$field['options'] = $this->get_field_options( $setting_type, $setting_id );
+		}
+
+		return $field;
+	}
+
+	/**
+	 * Get options for specific field types.
+	 *
+	 * @param string $field_type Field type.
+	 * @param string $field_id Field ID.
+	 * @return array Field options.
+	 */
+	private function get_field_options( $field_type, $field_id ) {
+		switch ( $field_type ) {
+			case 'single_select_country':
+				return $this->get_country_state_options();
+
+			case 'multi_select_countries':
+				return WC()->countries->get_countries();
+
+			case 'select':
+				// Handle specific select fields that need custom options.
+				if ( 'woocommerce_currency' === $field_id ) {
+					return $this->get_currency_options();
+				}
+				break;
+		}
+
+		return array();
+	}
+
+	/**
+	 * Get country/state options for single select country field.
+	 *
+	 * @return array Country/state options.
+	 */
+	private function get_country_state_options() {
+		$countries             = WC()->countries->get_countries();
+		$states                = WC()->countries->get_states();
+		$country_state_options = array();
+
+		foreach ( $countries as $country_code => $country_name ) {
+			$country_states = $states[ $country_code ] ?? array();
+
+			if ( empty( $country_states ) ) {
+				$country_state_options[ $country_code ] = $country_name;
+			} else {
+				foreach ( $country_states as $state_code => $state_name ) {
+					$country_state_options[ $country_code . ':' . $state_code ] = $country_name . ' — ' . $state_name;
+				}
+			}
+		}
+
+		return $country_state_options;
+	}
+
+	/**
+	 * Get currency options.
+	 *
+	 * @return array Currency options.
+	 */
+	private function get_currency_options() {
+		$currency_options = array();
+		$currencies       = get_woocommerce_currencies();
+
+		foreach ( $currencies as $code => $name ) {
+			$label                     = wp_specialchars_decode( (string) $name );
+			$symbol                    = wp_specialchars_decode( (string) get_woocommerce_currency_symbol( $code ) );
+			$currency_options[ $code ] = $label . ' (' . $symbol . ') — ' . $code;
+		}
+
+		return $currency_options;
+	}
+
+	/**
+	 * Normalize WooCommerce field types to REST API field types.
+	 *
+	 * @param string $wc_type WooCommerce field type.
+	 * @return string Normalized field type.
+	 */
+	private function normalize_field_type( $wc_type ) {
+		$type_map = array(
+			'single_select_country'  => 'select',
+			'multi_select_countries' => 'multiselect',
+		);
+
+		return $type_map[ $wc_type ] ?? $wc_type;
+	}
+
+	/**
+	 * Validate country or state code.
+	 *
+	 * @param string $country_or_state Country or state code.
+	 * @return boolean Valid or not valid.
+	 */
+	private function validate_country_or_state_code( $country_or_state ) {
+		list( $country, $state ) = array_pad( explode( ':', (string) $country_or_state, 2 ), 2, '' );
+		if ( '' === $country ) {
+			return false;
+		}
+		$country_codes = array_keys( WC()->countries->get_countries() );
+		if ( ! in_array( $country, $country_codes, true ) ) {
+			return false;
+		}
+		if ( '' === $state ) {
+			return true;
+		}
+		$states_for_country = WC()->countries->get_states( $country );
+		if ( empty( $states_for_country ) ) {
+			return false;
+		}
+		return isset( $states_for_country[ $state ] );
+	}
+
+	/**
+	 * Get the schema for general settings, conforming to JSON Schema.
+	 *
+	 * @return array
+	 */
+	public function get_item_schema() {
+		$schema = array(
+			'$schema'    => 'http://json-schema.org/draft-04/schema#',
+			'title'      => 'general_settings',
+			'type'       => 'object',
+			'properties' => array(
+				'id'          => array(
+					'description' => __( 'Unique identifier for the settings group.', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => array( 'view', 'edit' ),
+					'readonly'    => true,
+				),
+				'title'       => array(
+					'description' => __( 'Settings title.', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => array( 'view', 'edit' ),
+					'readonly'    => true,
+				),
+				'description' => array(
+					'description' => __( 'Settings description.', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => array( 'view', 'edit' ),
+					'readonly'    => true,
+				),
+				'groups'      => array(
+					'description'          => __( 'Collection of setting groups.', 'woocommerce' ),
+					'type'                 => 'object',
+					'context'              => array( 'view', 'edit' ),
+					'additionalProperties' => array(
+						'type'        => 'object',
+						'description' => __( 'Settings group.', 'woocommerce' ),
+						'properties'  => array(
+							'title'       => array(
+								'description' => __( 'Group title.', 'woocommerce' ),
+								'type'        => 'string',
+								'context'     => array( 'view', 'edit' ),
+							),
+							'description' => array(
+								'description' => __( 'Group description.', 'woocommerce' ),
+								'type'        => 'string',
+								'context'     => array( 'view', 'edit' ),
+							),
+							'order'       => array(
+								'description' => __( 'Display order for the group.', 'woocommerce' ),
+								'type'        => 'integer',
+								'context'     => array( 'view', 'edit' ),
+								'readonly'    => true,
+							),
+							'fields'      => array(
+								'description' => __( 'Settings fields.', 'woocommerce' ),
+								'type'        => 'array',
+								'context'     => array( 'view', 'edit' ),
+								'items'       => $this->get_field_schema(),
+							),
+						),
+					),
+				),
+			),
+		);
+
+		return $this->add_additional_fields_schema( $schema );
+	}
+
+	/**
+	 * Get the schema for individual setting fields.
+	 *
+	 * @return array
+	 */
+	private function get_field_schema() {
+		return array(
+			'type'       => 'object',
+			'properties' => array(
+				'id'      => array(
+					'description' => __( 'Setting field ID.', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => array( 'view', 'edit' ),
+				),
+				'label'   => array(
+					'description' => __( 'Setting field label.', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => array( 'view', 'edit' ),
+				),
+				'type'    => array(
+					'description' => __( 'Setting field type.', 'woocommerce' ),
+					'type'        => 'string',
+					'enum'        => array( 'text', 'number', 'select', 'multiselect', 'checkbox' ),
+					'context'     => array( 'view', 'edit' ),
+				),
+				'value'   => array(
+					'description' => __( 'Setting field value.', 'woocommerce' ),
+					'type'        => array( 'string', 'number', 'array', 'boolean' ),
+					'context'     => array( 'view', 'edit' ),
+				),
+				'options' => array(
+					'description' => __( 'Available options for select/multiselect fields.', 'woocommerce' ),
+					'type'        => 'object',
+					'context'     => array( 'view', 'edit' ),
+				),
+				'tip'     => array(
+					'description' => __( 'Help text for the setting field.', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => array( 'view', 'edit' ),
+				),
+			),
+		);
+	}
+}
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version4/class-wc-rest-settings-v4-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version4/class-wc-rest-settings-v4-controller.php
new file mode 100644
index 0000000000..a10dbc8f11
--- /dev/null
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version4/class-wc-rest-settings-v4-controller.php
@@ -0,0 +1,32 @@
+<?php
+/**
+ * REST API Settings V4 controller.
+ *
+ * This controller extends the V3 Options settings controller to make all V3 Options settings endpoints
+ * available under the V4 namespace.
+ *
+ * @package WooCommerce\RestApi
+ * @since   8.6.0
+ */
+
+declare(strict_types=1);
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+/**
+ * REST API Settings V4 controller class.
+ *
+ * @package WooCommerce\RestApi
+ * @extends WC_REST_Setting_Options_Controller
+ */
+class WC_REST_Settings_V4_Controller extends WC_REST_Setting_Options_Controller {
+
+	/**
+	 * Endpoint namespace.
+	 *
+	 * @var string
+	 */
+	protected $namespace = 'wc/v4';
+}
diff --git a/plugins/woocommerce/includes/rest-api/Server.php b/plugins/woocommerce/includes/rest-api/Server.php
index 38b95c3816..47fef83ebf 100644
--- a/plugins/woocommerce/includes/rest-api/Server.php
+++ b/plugins/woocommerce/includes/rest-api/Server.php
@@ -213,10 +213,13 @@ class Server {
 	 */
 	protected function get_v4_controllers() {
 		return array(
-			'ping'         => 'WC_REST_Ping_V4_Controller',
-			'fulfillments' => 'WC_REST_Fulfillments_V4_Controller',
-			'products'     => 'WC_REST_Products_V4_Controller',
-			'order-notes'  => OrderNotesController::class,
+			'ping'             => 'WC_REST_Ping_V4_Controller',
+			'fulfillments'     => 'WC_REST_Fulfillments_V4_Controller',
+			'products'         => 'WC_REST_Products_V4_Controller',
+			'order-notes'      => OrderNotesController::class,
+			'settings-general' => 'WC_REST_General_Settings_V4_Controller',
+			// This is a wrapper that redirects V4 settings requests to the V3 settings controller.
+			'settings'         => 'WC_REST_Settings_V4_Controller',
 		);
 	}

diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Settings/class-wc-rest-general-settings-v4-controller-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Settings/class-wc-rest-general-settings-v4-controller-test.php
new file mode 100644
index 0000000000..7ef5ba09a1
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Settings/class-wc-rest-general-settings-v4-controller-test.php
@@ -0,0 +1,147 @@
+<?php
+/**
+ * General Settings V4 controller unit tests.
+ *
+ * @package WooCommerce\RestApi\UnitTests
+ * @since   4.0.0
+ */
+
+declare(strict_types=1);
+
+/**
+ * General Settings V4 controller unit tests.
+ *
+ * @package WooCommerce\RestApi\UnitTests
+ * @since   4.0.0
+ */
+class WC_REST_General_Settings_V4_Controller_Test extends WC_REST_Unit_Test_Case {
+
+	/**
+	 * User ID.
+	 *
+	 * @var int
+	 */
+	private $user_id;
+
+	/**
+	 * @var callable
+	 */
+	private $feature_filter;
+	/**
+	 * @var string|false
+	 */
+	private $prev_default_country;
+
+	/**
+	 * Setup.
+	 */
+	public function setUp(): void {
+		// Set up the feature flag before parent::setUp() to ensure the feature is enabled.
+		$this->feature_filter = function ( $features ) {
+			$features[] = 'rest-api-v4';
+			return $features;
+		};
+
+		add_filter( 'woocommerce_admin_features', $this->feature_filter );
+
+		parent::setUp();
+
+		// This is to reset the country after the test.
+		$this->prev_default_country = get_option( 'woocommerce_default_country' );
+
+		// Create a user with permissions.
+		$this->user_id = $this->factory->user->create(
+			array(
+				'role' => 'shop_manager',
+			)
+		);
+	}
+
+	/**
+	 * Tear down.
+	 */
+	public function tearDown(): void {
+		if ( isset( $this->feature_filter ) ) {
+			remove_filter( 'woocommerce_admin_features', $this->feature_filter );
+		}
+		if ( isset( $this->prev_default_country ) ) {
+			update_option( 'woocommerce_default_country', $this->prev_default_country );
+		}
+		parent::tearDown();
+	}
+
+	/**
+	 * Test route registration.
+	 */
+	public function test_register_routes() {
+		$routes = $this->server->get_routes();
+		$this->assertArrayHasKey( '/wc/v4/settings/general', $routes );
+	}
+
+	/**
+	 * Test getting general settings.
+	 */
+	public function test_get_item() {
+		wp_set_current_user( $this->user_id );
+		$request  = new WP_REST_Request( 'GET', '/wc/v4/settings/general' );
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals( 'general', $data['id'] );
+		$this->assertArrayHasKey( 'groups', $data );
+	}
+
+	/**
+	 * Test updating general settings.
+	 */
+	public function test_update_item() {
+		wp_set_current_user( $this->user_id );
+		$request = new WP_REST_Request( 'PUT', '/wc/v4/settings/general' );
+		$request->set_header( 'Content-Type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'woocommerce_default_country' => 'US:CA',
+				)
+			)
+		);
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals( 'US:CA', get_option( 'woocommerce_default_country' ) );
+	}
+
+	/**
+	 * Test getting general settings without permission.
+	 */
+	public function test_get_item_without_permission() {
+		wp_set_current_user( 0 );
+		$request  = new WP_REST_Request( 'GET', '/wc/v4/settings/general' );
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 401, $response->get_status() );
+	}
+
+	/**
+	 * Test updating general settings without permission.
+	 */
+	public function test_update_item_without_permission() {
+		wp_set_current_user( 0 );
+		$request = new WP_REST_Request( 'PUT', '/wc/v4/settings/general' );
+		$request->set_header( 'Content-Type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'woocommerce_default_country' => 'US:CA',
+				)
+			)
+		);
+		$response = $this->server->dispatch( $request );
+		$response->get_data();
+
+		$this->assertEquals( 401, $response->get_status() );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-general-test.php b/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-general-test.php
index 55ee73eff7..002964abce 100644
--- a/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-general-test.php
+++ b/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-general-test.php
@@ -71,6 +71,7 @@ class WC_Settings_General_Test extends WC_Settings_Unit_Test_Case {
 			'woocommerce_price_decimal_sep'            => 'text',
 			'woocommerce_price_num_decimals'           => 'number',
 			'pricing_options'                          => array( 'title', 'sectionend' ),
+			'taxes_and_coupons_options'                => array( 'title', 'sectionend' ),
 		);

 		$this->assertEquals( $expected, $setting_ids_and_types );