Commit 4e7edcbf02 for woocommerce

commit 4e7edcbf0228f9319b35dafd1872344f43009ec8
Author: Mike Jolley <mike.jolley@me.com>
Date:   Tue Sep 16 14:50:05 2025 +0100

    [Rest API v4] Order Notes Controller (#60898)

    * Add order notes endpoint under v4 namespace

    * changelog

    * Implement utils and improve how schema is returned in controllers

    * Fix non namespaced v4 endpoints until migrated

    * Remove cogs function change

    * Remove order link since they do not yet exist

    * Fix datetime format

    * Harden note lookup

    * Simplify permissions checks

    * Remove unused imports

    * Add get_item_response for controllers to extend

    * Remove special author handling

    * Mark utils as internal use

    * Correct collection url

    * Abstract collection params

diff --git a/plugins/woocommerce/changelog/wooprd-847-create-v4-order-notes-controller b/plugins/woocommerce/changelog/wooprd-847-create-v4-order-notes-controller
new file mode 100644
index 0000000000..6061842007
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooprd-847-create-v4-order-notes-controller
@@ -0,0 +1,5 @@
+Significance: patch
+Type: dev
+Comment: Add v4 order-notes route behind feature flag
+
+
diff --git a/plugins/woocommerce/includes/rest-api/Server.php b/plugins/woocommerce/includes/rest-api/Server.php
index c3ac50188d..38b95c3816 100644
--- a/plugins/woocommerce/includes/rest-api/Server.php
+++ b/plugins/woocommerce/includes/rest-api/Server.php
@@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Automattic\WooCommerce\RestApi\Utilities\SingletonTrait;
 use Automattic\WooCommerce\Admin\Features\Features;
+use Automattic\WooCommerce\RestApi\Routes\V4\OrderNotes\Controller as OrderNotesController;

 /**
  * Class responsible for loading the REST API and all REST API namespaces.
@@ -43,10 +44,13 @@ class Server {
 		$legacy_proxy = $container->get( LegacyProxy::class );
 		foreach ( $this->get_rest_namespaces() as $namespace => $controllers ) {
 			foreach ( $controllers as $controller_name => $controller_class ) {
-				$this->controllers[ $namespace ][ $controller_name ] =
-					$container->has( $controller_class ) ?
+				if ( 'wc/v4' === $namespace && ! str_starts_with( $controller_class, 'WC_REST_' ) ) {
+					$this->controllers[ $namespace ][ $controller_name ] = $this->get_v4_controller( $controller_name, $controller_class );
+				} else {
+					$this->controllers[ $namespace ][ $controller_name ] = $container->has( $controller_class ) ?
 					$container->get( $controller_class ) :
 					$legacy_proxy->get_instance_of( $controller_class );
+				}
 				$this->controllers[ $namespace ][ $controller_name ]->register_routes();
 			}
 		}
@@ -58,22 +62,24 @@ class Server {
 	 * @return array List of Namespaces and Main controller classes.
 	 */
 	protected function get_rest_namespaces() {
+		$namespaces = array(
+			'wc/v1'        => wc_rest_should_load_namespace( 'wc/v1' ) ? $this->get_v1_controllers() : array(),
+			'wc/v2'        => wc_rest_should_load_namespace( 'wc/v2' ) ? $this->get_v2_controllers() : array(),
+			'wc/v3'        => wc_rest_should_load_namespace( 'wc/v3' ) ? $this->get_v3_controllers() : array(),
+			'wc-telemetry' => wc_rest_should_load_namespace( 'wc-telemetry' ) ? $this->get_telemetry_controllers() : array(),
+		);
+
+		if ( wc_rest_should_load_namespace( 'wc/v4' ) && Features::is_enabled( 'rest-api-v4' ) ) {
+			$namespaces['wc/v4'] = $this->get_v4_controllers();
+		}
+
 		/**
 		 * Filter the list of REST API controllers to load.
 		 *
 		 * @since 4.5.0
 		 * @param array $controllers List of $namespace => $controllers to load.
 		 */
-		return apply_filters(
-			'woocommerce_rest_api_get_rest_namespaces',
-			array(
-				'wc/v1'        => wc_rest_should_load_namespace( 'wc/v1' ) ? $this->get_v1_controllers() : array(),
-				'wc/v2'        => wc_rest_should_load_namespace( 'wc/v2' ) ? $this->get_v2_controllers() : array(),
-				'wc/v3'        => wc_rest_should_load_namespace( 'wc/v3' ) ? $this->get_v3_controllers() : array(),
-				'wc/v4'        => ( wc_rest_should_load_namespace( 'wc/v4' ) && ( class_exists( \Automattic\WooCommerce\Admin\Features\Features::class ) && Features::is_enabled( 'rest-api-v4' ) ) ) ? $this->get_v4_controllers() : array(),
-				'wc-telemetry' => wc_rest_should_load_namespace( 'wc-telemetry' ) ? $this->get_telemetry_controllers() : array(),
-			)
-		);
+		return apply_filters( 'woocommerce_rest_api_get_rest_namespaces', $namespaces );
 	}

 	/**
@@ -210,9 +216,24 @@ class Server {
 			'ping'         => 'WC_REST_Ping_V4_Controller',
 			'fulfillments' => 'WC_REST_Fulfillments_V4_Controller',
 			'products'     => 'WC_REST_Products_V4_Controller',
+			'order-notes'  => OrderNotesController::class,
 		);
 	}

+	/**
+	 * Get instance of a V4 controller.
+	 *
+	 * @param string $identifier Controller identifier.
+	 * @param string $route Route class name.
+	 * @return object The instance of the controller.
+	 */
+	protected function get_v4_controller( $identifier, $route ) {
+		if ( isset( $this->controllers['wc/v4'][ $identifier ] ) ) {
+			return $this->controllers['wc/v4'][ $identifier ];
+		}
+		return new $route();
+	}
+
 	/**
 	 * List of controllers in the telemetry namespace.
 	 *
diff --git a/plugins/woocommerce/src/RestApi/Routes/V4/AbstractController.php b/plugins/woocommerce/src/RestApi/Routes/V4/AbstractController.php
index 4bd24bc718..4cfbfc673f 100644
--- a/plugins/woocommerce/src/RestApi/Routes/V4/AbstractController.php
+++ b/plugins/woocommerce/src/RestApi/Routes/V4/AbstractController.php
@@ -13,7 +13,10 @@ namespace Automattic\WooCommerce\RestApi\Routes\V4;

 use WP_Error;
 use WP_Http;
+use WP_REST_Server;
 use WP_REST_Controller;
+use WP_REST_Response;
+use WP_REST_Request;

 defined( 'ABSPATH' ) || exit;

@@ -47,6 +50,44 @@ abstract class AbstractController extends WP_REST_Controller {
 	 */
 	protected $schema;

+	/**
+	 * Get the schema for the current resource. This use consumed by the AbstractController to generate the item schema
+	 * after running various hooks on the response.
+	 *
+	 * This should return the full schema object, not just the properties.
+	 *
+	 * @return array The full item schema.
+	 */
+	abstract protected function get_schema(): array;
+
+	/**
+	 * Get the collection args schema.
+	 *
+	 * @return array
+	 */
+	protected function get_query_schema(): array {
+		return array();
+	}
+
+	/**
+	 * Add default context collection params and filter the result. This does not inherit from
+	 * WP_REST_Controller::get_collection_params because some endpoints do not paginate results.
+	 *
+	 * @return array
+	 */
+	public function get_collection_params() {
+		$params            = $this->get_query_schema();
+		$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
+
+		/**
+		 * Filter the collection params.
+		 *
+		 * @param array $params The collection params.
+		 * @since 10.2.0
+		 */
+		return apply_filters( $this->get_hook_prefix() . 'collection_params', $params, $this );
+	}
+
 	/**
 	 * Get item schema, conforming to JSON Schema. Extended by routes.
 	 *
@@ -54,24 +95,68 @@ abstract class AbstractController extends WP_REST_Controller {
 	 * @since 10.2.0
 	 */
 	public function get_item_schema() {
+		// Cache the schema for the route.
 		if ( null === $this->schema ) {
-			$this->schema = array(
-				'$schema'    => 'http://json-schema.org/draft-04/schema#',
-				'type'       => 'object',
-				'title'      => 'item',
-				'properties' => array(),
-			);
 			/**
 			 * Filter the item schema for this route.
 			 *
 			 * @param array $schema The item schema.
 			 * @since 10.2.0
 			 */
-			$this->schema = apply_filters( $this->get_hook_prefix() . 'item_schema', $this->add_additional_fields_schema( $this->schema ) );
+			$this->schema = apply_filters( $this->get_hook_prefix() . 'item_schema', $this->add_additional_fields_schema( $this->get_schema() ) );
 		}
 		return $this->schema;
 	}

+	/**
+	 * Get the item response.
+	 *
+	 * @param mixed           $item    WooCommerce representation of the item.
+	 * @param WP_REST_Request $request Request object.
+	 * @return array The item response.
+	 * @since 10.2.0
+	 */
+	abstract protected function get_item_response( $item, WP_REST_Request $request ): array;
+
+	/**
+	 * Prepare links for the request.
+	 *
+	 * @param mixed           $item WordPress representation of the item.
+	 * @param WP_REST_Request $request Request object.
+	 * @return array
+	 */
+	protected function prepare_links( $item, WP_REST_Request $request ): array {
+		return array();
+	}
+
+	/**
+	 * Prepares the item for the REST response. Controllers do not need to override this method as they can define a
+	 * get_item_response method to prepare items. This method will take care of filter hooks.
+	 *
+	 * @param mixed           $item    WordPress representation of the item.
+	 * @param WP_REST_Request $request Request object.
+	 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+	 * @since 10.2.0
+	 */
+	public function prepare_item_for_response( $item, $request ) {
+		$response_data = $this->get_item_response( $item, $request );
+		$response_data = $this->add_additional_fields_to_object( $response_data, $request );
+		$response_data = $this->filter_response_by_context( $response_data, $request['context'] ?? 'view' );
+
+		$response = rest_ensure_response( $response_data );
+		$response->add_links( $this->prepare_links( $item, $request ) );
+
+		/**
+		 * Filter the data for a response.
+		 *
+		 * @param WP_REST_Response $response The response object.
+		 * @param mixed           $item    WordPress representation of the item.
+		 * @param WP_REST_Request  $request  Request object.
+		 * @since 10.2.0
+		 */
+		return rest_ensure_response( apply_filters( $this->get_hook_prefix() . 'item_response', $response, $item, $request ) );
+	}
+
 	/**
 	 * Get the hook prefix for actions and filters.
 	 *
@@ -81,7 +166,7 @@ abstract class AbstractController extends WP_REST_Controller {
 	 * @since 10.2.0
 	 */
 	protected function get_hook_prefix(): string {
-		return 'woocommerce_rest_api_v4_' . $this->rest_base . '_';
+		return 'woocommerce_rest_api_v4_' . str_replace( '-', '_', $this->rest_base ) . '_';
 	}

 	/**
@@ -93,7 +178,7 @@ abstract class AbstractController extends WP_REST_Controller {
 	 * @since 10.2.0
 	 */
 	protected function get_error_prefix(): string {
-		return 'woocommerce_rest_api_v4_' . $this->rest_base . '_';
+		return 'woocommerce_rest_api_v4_' . str_replace( '-', '_', $this->rest_base ) . '_';
 	}

 	/**
diff --git a/plugins/woocommerce/src/RestApi/Routes/V4/AbstractSchema.php b/plugins/woocommerce/src/RestApi/Routes/V4/AbstractSchema.php
index 8c882106b5..92d3a55599 100644
--- a/plugins/woocommerce/src/RestApi/Routes/V4/AbstractSchema.php
+++ b/plugins/woocommerce/src/RestApi/Routes/V4/AbstractSchema.php
@@ -22,7 +22,6 @@ defined( 'ABSPATH' ) || exit;
  * @since 10.2.0
  */
 abstract class AbstractSchema {
-
 	/**
 	 * The schema item identifier.
 	 *
@@ -56,9 +55,9 @@ abstract class AbstractSchema {
 	public static function get_item_schema(): array {
 		return array(
 			'$schema'    => 'http://json-schema.org/draft-04/schema#',
-			'title'      => self::IDENTIFIER,
+			'title'      => static::IDENTIFIER,
 			'type'       => 'object',
-			'properties' => self::get_item_schema_properties(),
+			'properties' => static::get_item_schema_properties(),
 		);
 	}

diff --git a/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/Controller.php b/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/Controller.php
new file mode 100644
index 0000000000..cf48f59f47
--- /dev/null
+++ b/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/Controller.php
@@ -0,0 +1,321 @@
+<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
+/**
+ * REST API Order Notes controller
+ *
+ * Handles route registration, permissions, CRUD operations, and schema definition.
+ *
+ * @package WooCommerce\RestApi
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\RestApi\Routes\V4\OrderNotes;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\RestApi\Routes\V4\AbstractController;
+use WP_Http;
+use WP_Error;
+use WP_Comment;
+use WC_Order;
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+
+/**
+ * OrdersNotes Controller.
+ */
+class Controller extends AbstractController {
+	/**
+	 * Route base.
+	 *
+	 * @var string
+	 */
+	protected $rest_base = 'order-notes';
+
+	/**
+	 * Get the schema for the current resource. This use consumed by the AbstractController to generate the item schema
+	 * after running various hooks on the response.
+	 */
+	protected function get_schema(): array {
+		return OrderNoteSchema::get_item_schema();
+	}
+
+	/**
+	 * Get the collection args schema.
+	 *
+	 * @return array
+	 */
+	protected function get_query_schema(): array {
+		return QueryUtils::get_query_schema();
+	}
+
+	/**
+	 * Register the routes for orders.
+	 */
+	public function register_routes() {
+		register_rest_route(
+			$this->namespace,
+			'/' . $this->rest_base,
+			array(
+				'schema' => array( $this, 'get_public_item_schema' ),
+				'args'   => array(
+					'order_id' => array(
+						'description' => __( 'The order ID that notes belong to.', 'woocommerce' ),
+						'type'        => 'integer',
+						'required'    => true,
+					),
+				),
+				array(
+					'methods'             => WP_REST_Server::READABLE,
+					'callback'            => array( $this, 'get_items' ),
+					'permission_callback' => array( $this, 'get_items_permissions_check' ),
+					'args'                => $this->get_collection_params(),
+				),
+				array(
+					'methods'             => WP_REST_Server::CREATABLE,
+					'callback'            => array( $this, 'create_item' ),
+					'permission_callback' => array( $this, 'create_item_permissions_check' ),
+					'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
+				),
+			)
+		);
+		register_rest_route(
+			$this->namespace,
+			'/' . $this->rest_base . '/(?P<id>[\d]+)',
+			array(
+				'schema' => array( $this, 'get_public_item_schema' ),
+				'args'   => array(
+					'id' => array(
+						'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
+						'type'        => 'integer',
+					),
+				),
+				array(
+					'methods'             => WP_REST_Server::READABLE,
+					'callback'            => array( $this, 'get_item' ),
+					'permission_callback' => array( $this, 'get_item_permissions_check' ),
+					'args'                => array(
+						'context' => $this->get_context_param( array( 'default' => 'view' ) ),
+					),
+				),
+				array(
+					'methods'             => WP_REST_Server::DELETABLE,
+					'callback'            => array( $this, 'delete_item' ),
+					'permission_callback' => array( $this, 'delete_item_permissions_check' ),
+				),
+			)
+		);
+	}
+
+	/**
+	 * Prepare links for the request.
+	 *
+	 * @param mixed           $item WordPress representation of the item.
+	 * @param WP_REST_Request $request Request object.
+	 * @return array
+	 */
+	protected function prepare_links( $item, WP_REST_Request $request ): array {
+		return array(
+			'self'       => array(
+				'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, (int) $item->comment_ID ) ),
+			),
+			'collection' => array(
+				'href' => add_query_arg(
+					array( 'order_id' => (int) $item->comment_post_ID ),
+					rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) )
+				),
+			),
+		);
+	}
+
+	/**
+	 * Prepare a single order note item for response.
+	 *
+	 * @param WP_Comment      $note Note object.
+	 * @param WP_REST_Request $request Request object.
+	 * @return array
+	 */
+	protected function get_item_response( $note, WP_REST_Request $request ): array {
+		return array(
+			'id'               => (int) $note->comment_ID,
+			'order_id'         => (int) $note->comment_post_ID,
+			'author'           => $note->comment_author,
+			'date_created'     => wc_rest_prepare_date_response( $note->comment_date ),
+			'date_created_gmt' => wc_rest_prepare_date_response( $note->comment_date_gmt ),
+			'note'             => $note->comment_content,
+			'is_customer_note' => (bool) get_comment_meta( $note->comment_ID, 'is_customer_note', true ),
+		);
+	}
+
+	/**
+	 * Check if a given request has access to the order.
+	 *
+	 * @param  WC_Order|boolean $order The order object.
+	 * @return WP_Error|boolean
+	 */
+	protected function order_permissions_check( $order ) {
+		if ( ! $order || ! $order instanceof WC_Order ) {
+			return $this->get_route_error_response( $this->get_error_prefix() . 'invalid_id', __( 'Invalid order ID.', 'woocommerce' ), WP_Http::NOT_FOUND );
+		}
+
+		if ( ! $this->check_permissions( 'order', 'edit', (int) $order->get_id() ) ) {
+			return $this->get_route_error_response( $this->get_error_prefix() . 'cannot_edit', __( 'Sorry, you are not allowed to access notes for this order.', 'woocommerce' ), rest_authorization_required_code() );
+		}
+
+		return true;
+	}
+
+	/**
+	 * Check if a given request has access to read an item.
+	 *
+	 * @param  WP_REST_Request $request The request object.
+	 * @return WP_Error|boolean
+	 */
+	public function get_item_permissions_check( $request ) {
+		return $this->order_permissions_check( Utils::get_order_by_note_id( (int) $request['id'] ) );
+	}
+
+	/**
+	 * Check if a given request has access to read items.
+	 *
+	 * @param  WP_REST_Request $request Full details about the request.
+	 * @return WP_Error|boolean
+	 */
+	public function get_items_permissions_check( $request ) {
+		return $this->order_permissions_check( Utils::get_order_by_id( (int) $request['order_id'] ) );
+	}
+
+	/**
+	 * Check if a given request has access to create an item.
+	 *
+	 * @param  WP_REST_Request $request The request object.
+	 * @return WP_Error|boolean
+	 */
+	public function create_item_permissions_check( $request ) {
+		return $this->order_permissions_check( Utils::get_order_by_id( (int) $request['order_id'] ) );
+	}
+
+	/**
+	 * Check if a given request has access to delete an item.
+	 *
+	 * @param  WP_REST_Request $request The request object.
+	 * @return bool|WP_Error
+	 */
+	public function delete_item_permissions_check( $request ) {
+		return $this->order_permissions_check( Utils::get_order_by_note_id( (int) $request['id'] ) );
+	}
+
+	/**
+	 * Get a single item.
+	 *
+	 * @param WP_REST_Request $request Full details about the request.
+	 * @return WP_Error|WP_REST_Response
+	 */
+	public function get_item( $request ) {
+		$note = Utils::get_note_by_id( (int) $request['id'] );
+
+		if ( ! $note ) {
+			return $this->get_route_error_response( $this->get_error_prefix() . 'invalid_id', __( 'Invalid resource ID.', 'woocommerce' ), WP_Http::NOT_FOUND );
+		}
+
+		return $this->prepare_item_for_response( $note, $request );
+	}
+
+	/**
+	 * Get collection of orders.
+	 *
+	 * @param WP_REST_Request $request Full details about the request.
+	 * @return WP_Error|WP_REST_Response
+	 */
+	public function get_items( $request ) {
+		$order = Utils::get_order_by_id( (int) $request['order_id'] );
+
+		if ( ! $order ) {
+			return $this->get_route_error_response( $this->get_error_prefix() . 'invalid_id', __( 'Invalid order ID.', 'woocommerce' ), WP_Http::NOT_FOUND );
+		}
+
+		$results = QueryUtils::get_query_results( $request, $order );
+		$items   = array();
+
+		foreach ( $results as $result ) {
+			$items[] = $this->prepare_response_for_collection( $this->prepare_item_for_response( $result, $request ) );
+		}
+
+		return rest_ensure_response( $items );
+	}
+
+	/**
+	 * Create a single item.
+	 *
+	 * @param WP_REST_Request $request Full details about the request.
+	 * @return WP_Error|WP_REST_Response
+	 */
+	public function create_item( $request ) {
+		if ( ! empty( $request['id'] ) ) {
+			/* translators: %s: post type */
+			return $this->get_route_error_response( $this->get_error_prefix() . 'exists', __( 'Cannot create existing order note.', 'woocommerce' ), WP_Http::BAD_REQUEST );
+		}
+
+		$order   = Utils::get_order_by_id( (int) $request['order_id'] );
+		$note_id = $order ? $order->add_order_note( $request['note'], $request['is_customer_note'], true ) : null;
+
+		if ( ! $note_id ) {
+			return $this->get_route_error_response( $this->get_error_prefix() . 'cannot_create', __( 'Cannot create order note.', 'woocommerce' ), WP_Http::INTERNAL_SERVER_ERROR );
+		}
+
+		$note = get_comment( $note_id );
+		$this->update_additional_fields_for_object( $note, $request );
+
+		/**
+		 * Fires after a single object is created via the REST API.
+		 *
+		 * @param WP_Comment         $note    Inserted object.
+		 * @param WP_REST_Request $request   Request object.
+		 * @since 10.2.0
+		 */
+		do_action( $this->get_hook_prefix() . 'created', $note, $request );
+
+		$request->set_param( 'context', 'edit' );
+		$response = $this->prepare_item_for_response( $note, $request );
+		$response->set_status( WP_Http::CREATED );
+		$response->header( 'Location', rest_url( sprintf( '/%s/%s/%d', $this->namespace, $this->rest_base, $note_id ) ) );
+
+		return $response;
+	}
+
+	/**
+	 * Delete a single item.
+	 *
+	 * @param WP_REST_Request $request Full details about the request.
+	 * @return WP_REST_Response|WP_Error
+	 */
+	public function delete_item( $request ) {
+		$note = Utils::get_note_by_id( (int) $request['id'] );
+
+		if ( empty( $note ) ) {
+			return $this->get_route_error_response( $this->get_error_prefix() . 'invalid_id', __( 'Invalid resource ID.', 'woocommerce' ), WP_Http::NOT_FOUND );
+		}
+
+		$request->set_param( 'context', 'edit' );
+		$response = $this->prepare_item_for_response( $note, $request );
+
+		$result = wc_delete_order_note( (int) $note->comment_ID );
+
+		if ( ! $result ) {
+			return $this->get_route_error_response( $this->get_error_prefix() . 'cannot_delete', __( 'This object cannot be deleted.', 'woocommerce' ), WP_Http::INTERNAL_SERVER_ERROR );
+		}
+
+		/**
+		 * Fires after a single object is deleted or trashed via the REST API.
+		 *
+		 * @param WP_Comment         $note   The deleted or trashed object.
+		 * @param WP_REST_Response $response The response data.
+		 * @param WP_REST_Request  $request  The request sent to the API.
+		 * @since 10.2.0
+		 */
+		do_action( $this->get_hook_prefix() . 'deleted', $note, $response, $request );
+
+		return $response;
+	}
+}
diff --git a/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/OrderNoteSchema.php b/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/OrderNoteSchema.php
new file mode 100644
index 0000000000..68527e9fc9
--- /dev/null
+++ b/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/OrderNoteSchema.php
@@ -0,0 +1,86 @@
+<?php
+/**
+ * OrderNoteSchema class.
+ *
+ * @package WooCommerce\RestApi
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\RestApi\Routes\V4\OrderNotes;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\RestApi\Routes\V4\AbstractSchema;
+
+/**
+ * OrderNoteSchema class.
+ */
+class OrderNoteSchema extends AbstractSchema {
+	/**
+	 * The schema item identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'order_note';
+
+	/**
+	 * Return all properties for the item schema.
+	 *
+	 * Note that context determines under which context data should be visible. For example, edit would be the context
+	 * used when getting records with the intent of editing them. embed context allows the data to be visible when the
+	 * item is being embedded in another response.
+	 *
+	 * @return array
+	 */
+	public static function get_item_schema_properties(): array {
+		$schema = array(
+			'id'               => array(
+				'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
+				'type'        => 'integer',
+				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+				'readonly'    => true,
+			),
+			'order_id'         => array(
+				'description' => __( 'Order ID the note belongs to.', 'woocommerce' ),
+				'type'        => 'integer',
+				'context'     => self::VIEW_EDIT_CONTEXT,
+				'readonly'    => true,
+			),
+			'author'           => array(
+				'description' => __( 'Order note author.', 'woocommerce' ),
+				'type'        => 'string',
+				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+				'readonly'    => true,
+			),
+			'date_created'     => array(
+				'description' => __( "The date the order note was created, in the site's timezone.", 'woocommerce' ),
+				'type'        => 'string',
+				'format'      => 'date-time',
+				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+				'readonly'    => true,
+			),
+			'date_created_gmt' => array(
+				'description' => __( 'The date the order note was created, as GMT.', 'woocommerce' ),
+				'type'        => 'string',
+				'format'      => 'date-time',
+				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+				'readonly'    => true,
+			),
+			'note'             => array(
+				'description' => __( 'Order note content.', 'woocommerce' ),
+				'type'        => 'string',
+				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+				'required'    => true,
+			),
+			'is_customer_note' => array(
+				'description' => __( 'If true, the note will be shown to customers. If false, the note will be for admin reference only.', 'woocommerce' ),
+				'type'        => 'boolean',
+				'default'     => false,
+				'context'     => self::VIEW_EDIT_EMBED_CONTEXT,
+			),
+		);
+
+		return $schema;
+	}
+}
diff --git a/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/QueryUtils.php b/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/QueryUtils.php
new file mode 100644
index 0000000000..3d5f3470f0
--- /dev/null
+++ b/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/QueryUtils.php
@@ -0,0 +1,80 @@
+<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
+/**
+ * QueryUtils class.
+ *
+ * @package WooCommerce\RestApi
+ * @internal This file is for internal use only and should not be used by external code.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\RestApi\Routes\V4\OrderNotes;
+
+defined( 'ABSPATH' ) || exit;
+
+use WP_REST_Request;
+use WC_Order;
+
+/**
+ * QueryUtils class.
+ *
+ * @internal This class is for internal use only and should not be used by external code.
+ */
+final class QueryUtils {
+	/**
+	 * Get query schema.
+	 *
+	 * @return array
+	 */
+	public static function get_query_schema() {
+		return array(
+			'note_type' => array(
+				'default'           => 'all',
+				'description'       => __( 'Limit result to customer notes or private notes.', 'woocommerce' ),
+				'type'              => 'string',
+				'enum'              => array( 'all', 'customer', 'private' ),
+				'sanitize_callback' => 'sanitize_key',
+				'validate_callback' => 'rest_validate_request_arg',
+			),
+		);
+	}
+
+	/**
+	 * Get results of the query.
+	 *
+	 * @param WP_REST_Request $request The request object.
+	 * @param WC_Order        $order The order object.
+	 * @return array
+	 */
+	public static function get_query_results( WP_REST_Request $request, WC_Order $order ): array {
+		$args = array(
+			'post_id' => $order->get_id(),
+			'status'  => 'approve',
+			'type'    => 'order_note',
+		);
+
+		// Allow filter by order note type.
+		if ( 'customer' === $request['note_type'] ) {
+			$args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+				array(
+					'key'     => 'is_customer_note',
+					'value'   => 1,
+					'compare' => '=',
+				),
+			);
+		} elseif ( 'private' === $request['note_type'] ) {
+			$args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+				array(
+					'key'     => 'is_customer_note',
+					'compare' => 'NOT EXISTS',
+				),
+			);
+		}
+
+		remove_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments' ), 10, 1 );
+		$results = get_comments( $args );
+		add_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments' ), 10, 1 );
+
+		return (array) $results;
+	}
+}
diff --git a/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/Utils.php b/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/Utils.php
new file mode 100644
index 0000000000..0ad79747af
--- /dev/null
+++ b/plugins/woocommerce/src/RestApi/Routes/V4/OrderNotes/Utils.php
@@ -0,0 +1,64 @@
+<?php
+/**
+ * Helpers for order notes.
+ *
+ * @package WooCommerce\RestApi
+ * @internal This file is for internal use only and should not be used by external code.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\RestApi\Routes\V4\OrderNotes;
+
+defined( 'ABSPATH' ) || exit;
+
+use WP_Comment;
+use WC_Order;
+
+/**
+ * Utils class.
+ *
+ * @internal This class is for internal use only and should not be used by external code.
+ */
+final class Utils {
+	/**
+	 * Get an order by ID.
+	 *
+	 * @param int $order_id The order ID.
+	 * @return WC_Order|null
+	 */
+	public static function get_order_by_id( int $order_id ) {
+		if ( ! $order_id ) {
+			return null;
+		}
+		$order = wc_get_order( $order_id );
+		return $order && 'shop_order' === $order->get_type() ? $order : null;
+	}
+	/**
+	 * Get the parent order of a note.
+	 *
+	 * @param int|WP_Comment $note The note ID or note object.
+	 * @return WC_Order|null
+	 */
+	public static function get_order_by_note_id( $note ) {
+		$note = $note instanceof WP_Comment ? $note : self::get_note_by_id( (int) $note );
+		if ( ! $note ) {
+			return null;
+		}
+		return self::get_order_by_id( (int) $note->comment_post_ID );
+	}
+
+	/**
+	 * Get a note by ID.
+	 *
+	 * @param int $note_id The note ID.
+	 * @return WP_Comment|null
+	 */
+	public static function get_note_by_id( int $note_id ) {
+		if ( ! $note_id ) {
+			return null;
+		}
+		$note = get_comment( $note_id );
+		return $note && 'order_note' === $note->comment_type ? $note : null;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/OrderNotes/class-wc-rest-order-notes-v4-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/OrderNotes/class-wc-rest-order-notes-v4-controller-tests.php
new file mode 100644
index 0000000000..f8bcf10457
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/OrderNotes/class-wc-rest-order-notes-v4-controller-tests.php
@@ -0,0 +1,383 @@
+<?php // phpcs:ignore Generic.PHP.RequireStrictTypes.MissingDeclaration
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
+use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+
+use Automattic\WooCommerce\RestApi\Routes\V4\OrderNotes\Controller as OrderNotesController;
+
+/**
+ * class Automattic\WooCommerce\RestApi\Routes\V4\OrderNotes\Controller tests.
+ * Order Notes Controller tests for V4 REST API.
+ */
+class WC_REST_Order_Notes_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
+	use HPOSToggleTrait;
+
+	/**
+	 * Runs after each test.
+	 */
+	public function tearDown(): void {
+		parent::tearDown();
+		$this->disable_rest_api_v4_feature();
+	}
+
+	/**
+	 * Enable the REST API v4 feature.
+	 */
+	public static function enable_rest_api_v4_feature() {
+		add_filter(
+			'woocommerce_admin_features',
+			function ( $features ) {
+				$features[] = 'rest-api-v4';
+				return $features;
+			},
+		);
+	}
+
+	/**
+	 * Disable the REST API v4 feature.
+	 */
+	public static function disable_rest_api_v4_feature() {
+		add_filter(
+			'woocommerce_admin_features',
+			function ( $features ) {
+				$features = array_diff( $features, array( 'rest-api-v4' ) );
+				return $features;
+			}
+		);
+	}
+
+	/**
+	 * Setup our test server, endpoints, and user info.
+	 */
+	public function setUp(): void {
+		$this->enable_rest_api_v4_feature();
+		parent::setUp();
+		$this->endpoint = new OrderNotesController();
+		$this->user     = $this->factory->user->create(
+			array(
+				'role' => 'administrator',
+			)
+		);
+		wp_set_current_user( $this->user );
+	}
+
+	/**
+	 * Test route registration.
+	 */
+	public function test_register_routes() {
+		$routes = $this->server->get_routes();
+		$this->assertArrayHasKey( '/wc/v4/order-notes', $routes );
+		$this->assertArrayHasKey( '/wc/v4/order-notes/(?P<id>[\d]+)', $routes );
+	}
+
+	/**
+	 * Test getting all order notes.
+	 */
+	public function test_get_items() {
+		// Create an order.
+		$order = OrderHelper::create_order( $this->user );
+
+		// Add some order notes.
+		$order->add_order_note( 'Test note 1', false, false );
+		$order->add_order_note( 'Test note 2', true, false );
+		$order->add_order_note( 'Test note 3', false, false );
+
+		$request = new WP_REST_Request( 'GET', '/wc/v4/order-notes' );
+		$request->set_query_params( array( 'order_id' => $order->get_id() ) );
+		$response = $this->server->dispatch( $request );
+		$notes    = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertIsArray( $notes );
+		$this->assertGreaterThanOrEqual( 3, count( $notes ) );
+	}
+
+	/**
+	 * Test getting order notes with order filter.
+	 */
+	public function test_get_items_with_order_filter() {
+		// Create two orders.
+		$order1 = OrderHelper::create_order( $this->user );
+		$order2 = OrderHelper::create_order( $this->user );
+
+		// Add notes to both orders.
+		$order1->add_order_note( 'Order 1 note', false, false );
+		$order2->add_order_note( 'Order 2 note', false, false );
+
+		$request = new WP_REST_Request( 'GET', '/wc/v4/order-notes' );
+		$request->set_query_params( array( 'order_id' => $order1->get_id() ) );
+		$response = $this->server->dispatch( $request );
+		$notes    = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertIsArray( $notes );
+		$this->assertGreaterThanOrEqual( 1, count( $notes ) );
+
+		// All notes should belong to order 1.
+		foreach ( $notes as $note ) {
+			$this->assertEquals( $order1->get_id(), $note['order_id'] );
+		}
+	}
+
+	/**
+	 * Test getting order notes with type filter.
+	 */
+	public function test_get_items_with_type_filter() {
+		$order = OrderHelper::create_order( $this->user );
+
+		// Add different types of notes.
+		$order->add_order_note( 'Internal note', false, false );
+		$order->add_order_note( 'Customer note', true, false );
+
+		// Test internal notes filter.
+		$request = new WP_REST_Request( 'GET', '/wc/v4/order-notes' );
+		$request->set_query_params(
+			array(
+				'order_id'  => $order->get_id(),
+				'note_type' => 'private',
+			)
+		);
+		$response = $this->server->dispatch( $request );
+		$notes    = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertIsArray( $notes );
+
+		// All notes should be internal.
+		foreach ( $notes as $note ) {
+			$this->assertFalse( $note['is_customer_note'] );
+		}
+
+		// Test customer notes filter.
+		$request = new WP_REST_Request( 'GET', '/wc/v4/order-notes' );
+		$request->set_query_params(
+			array(
+				'order_id'  => $order->get_id(),
+				'note_type' => 'customer',
+			)
+		);
+		$response = $this->server->dispatch( $request );
+		$notes    = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertIsArray( $notes );
+
+		// All notes should be customer notes.
+		foreach ( $notes as $note ) {
+			$this->assertTrue( $note['is_customer_note'] );
+		}
+	}
+
+	/**
+	 * Test creating an order note.
+	 */
+	public function test_create_item() {
+		$order = OrderHelper::create_order( $this->user );
+
+		$request = new WP_REST_Request( 'POST', '/wc/v4/order-notes' );
+		$request->set_body_params(
+			array(
+				'order_id' => $order->get_id(),
+				'note'     => 'Test order note',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertArrayHasKey( 'id', $data );
+		$this->assertEquals( 'Test order note', $data['note'] );
+		$this->assertFalse( $data['is_customer_note'] );
+		$this->assertEquals( $order->get_id(), $data['order_id'] );
+	}
+
+	/**
+	 * Test creating a customer order note.
+	 */
+	public function test_create_is_customer_note() {
+		$order = OrderHelper::create_order( $this->user );
+
+		$request = new WP_REST_Request( 'POST', '/wc/v4/order-notes' );
+		$request->set_body_params(
+			array(
+				'order_id'         => $order->get_id(),
+				'note'             => 'Customer order note',
+				'is_customer_note' => true,
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 201, $response->get_status() );
+		$this->assertArrayHasKey( 'id', $data );
+		$this->assertEquals( 'Customer order note', $data['note'] );
+		$this->assertTrue( $data['is_customer_note'] );
+		$this->assertEquals( $order->get_id(), $data['order_id'] );
+	}
+
+	/**
+	 * Test creating order note with invalid order ID.
+	 */
+	public function test_create_item_invalid_order() {
+		$request = new WP_REST_Request( 'POST', '/wc/v4/order-notes' );
+		$request->set_body_params(
+			array(
+				'order_id' => 99999,
+				'note'     => 'Test order note',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 404, $response->get_status() );
+	}
+
+	/**
+	 * Test creating order note without required fields.
+	 */
+	public function test_create_item_missing_fields() {
+		$order = OrderHelper::create_order( $this->user );
+
+		$request = new WP_REST_Request( 'POST', '/wc/v4/order-notes' );
+		$request->set_body_params(
+			array(
+				'order_id' => $order->get_id(),
+				// Missing 'note' field.
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 400, $response->get_status() );
+	}
+
+	/**
+	 * Test getting a single order note.
+	 */
+	public function test_get_item() {
+		$order   = OrderHelper::create_order( $this->user );
+		$note_id = $order->add_order_note( 'Test single note', false, false );
+
+		$response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v4/order-notes/' . $note_id ) );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertEquals( $note_id, $data['id'] );
+		$this->assertEquals( 'Test single note', $data['note'] );
+		$this->assertFalse( $data['is_customer_note'] );
+		$this->assertEquals( $order->get_id(), $data['order_id'] );
+	}
+
+	/**
+	 * Test getting non-existent order note.
+	 */
+	public function test_get_item_not_found() {
+		$response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v4/order-notes/99999' ) );
+
+		$this->assertEquals( 404, $response->get_status() );
+	}
+
+	/**
+	 * Test deleting an order note.
+	 */
+	public function test_delete_item() {
+		$order   = OrderHelper::create_order( $this->user );
+		$note_id = $order->add_order_note( 'Note to delete', false, false );
+
+		$request = new WP_REST_Request( 'DELETE', '/wc/v4/order-notes/' . $note_id );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 200, $response->get_status() );
+
+		// Verify note is deleted.
+		$get_response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v4/order-notes/' . $note_id ) );
+		$this->assertEquals( 404, $get_response->get_status() );
+	}
+
+	/**
+	 * Test deleting non-existent order note.
+	 */
+	public function test_delete_item_not_found() {
+		$request = new WP_REST_Request( 'DELETE', '/wc/v4/order-notes/99999' );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 404, $response->get_status() );
+	}
+
+	/**
+	 * Test order notes schema.
+	 */
+	public function test_get_item_schema() {
+		$request  = new WP_REST_Request( 'OPTIONS', '/wc/v4/order-notes' );
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertArrayHasKey( 'schema', $data );
+		$this->assertArrayHasKey( 'properties', $data['schema'] );
+
+		$properties = $data['schema']['properties'];
+		$this->assertArrayHasKey( 'id', $properties );
+		$this->assertArrayHasKey( 'order_id', $properties );
+		$this->assertArrayHasKey( 'note', $properties );
+		$this->assertArrayHasKey( 'is_customer_note', $properties );
+		$this->assertArrayHasKey( 'author', $properties );
+		$this->assertArrayHasKey( 'date_created', $properties );
+		$this->assertArrayHasKey( 'date_created_gmt', $properties );
+	}
+
+	/**
+	 * Test order notes without permission.
+	 */
+	public function test_get_items_without_permission() {
+		wp_set_current_user( 0 );
+		$order = OrderHelper::create_order( $this->user );
+
+		$request = new WP_REST_Request( 'GET', '/wc/v4/order-notes' );
+		$request->set_query_params( array( 'order_id' => $order->get_id() ) );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 401, $response->get_status() );
+	}
+
+	/**
+	 * Test creating order note without permission.
+	 */
+	public function test_create_item_without_permission() {
+		wp_set_current_user( 0 );
+		$order = OrderHelper::create_order( $this->user );
+
+		$request = new WP_REST_Request( 'POST', '/wc/v4/order-notes' );
+		$request->set_body_params(
+			array(
+				'order_id' => $order->get_id(),
+				'note'     => 'Test order note',
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 401, $response->get_status() );
+	}
+
+	/**
+	 * Test deleting order note without permission.
+	 */
+	public function test_delete_item_without_permission() {
+		wp_set_current_user( 0 );
+		$order   = OrderHelper::create_order( $this->user );
+		$note_id = $order->add_order_note( 'Note to delete', false, false );
+
+		$request = new WP_REST_Request( 'DELETE', '/wc/v4/order-notes/' . $note_id );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 401, $response->get_status() );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/test-abstract-controller-v4.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/test-abstract-controller-v4.php
index 53b675e8cb..e5abea3374 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/test-abstract-controller-v4.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/test-abstract-controller-v4.php
@@ -77,22 +77,18 @@ class Test_Abstract_Controller_V4 extends Automattic\WooCommerce\RestApi\Routes\
 	 *
 	 * @return array
 	 */
-	public function get_item_schema(): array { // phpcs:ignore Generic.CodeAnalysis.UselessOverridingMethod.Found
-		if ( null === $this->schema ) {
-			$this->schema = array(
-				'$schema'    => 'http://json-schema.org/draft-04/schema#',
-				'type'       => 'object',
-				'title'      => Test_Abstract_Schema_V4::IDENTIFIER,
-				'properties' => Test_Abstract_Schema_V4::get_item_schema_properties(),
-			);
-			/**
-			 * Filter the item schema for this route.
-			 *
-			 * @param array $schema The item schema.
-			 * @since 10.2.0
-			 */
-			$this->schema = apply_filters( $this->get_hook_prefix() . 'item_schema', $this->add_additional_fields_schema( $this->schema ) );
-		}
-		return $this->schema;
+	protected function get_schema(): array {
+		return Test_Abstract_Schema_V4::get_item_schema();
+	}
+
+	/**
+	 * Get item response for testing.
+	 *
+	 * @param mixed           $item WordPress representation of the item.
+	 * @param WP_REST_Request $request Request object.
+	 * @return array
+	 */
+	protected function get_item_response( $item, WP_REST_Request $request ): array {
+		return array();
 	}
 }