Commit a477ddbbcbb for woocommerce

commit a477ddbbcbbac4086da2f9783876ff01c85407ac
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Wed Mar 4 23:48:21 2026 +0300

    [WOOPLUG-6368] fix: add order notes for fulfillment state changes (#63516)

    * [WOOPLUG-6368] fix: add order notes for fulfillment state changes

    * [WOOPLUG-6368] feat: add FulfillmentOrderNotes class for fulfillment lifecycle order notes

    Add order notes when fulfillments are created, updated, deleted, or
    change status. Also track order-level fulfillment status transitions.
    All notes are filterable (return null to cancel) and use the new
    FULFILLMENT note group constant.

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Fix linter issues

    * Enhance tracking information in order notes with shipping provider and URL

    * Add string translations for fulfillment statuses instead of keys

    * Address coderabbit nitpicks

    * Improve type handling in FulfillmentOrderNotes

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/63516-fix-wooplug-6368-order-fulfillments-add-order-notes-for-fulfillment-state b/plugins/woocommerce/changelog/63516-fix-wooplug-6368-order-fulfillments-add-order-notes-for-fulfillment-state
new file mode 100644
index 00000000000..83e66390365
--- /dev/null
+++ b/plugins/woocommerce/changelog/63516-fix-wooplug-6368-order-fulfillments-add-order-notes-for-fulfillment-state
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add order notes for fulfillment lifecycle events (created, updated, deleted, status changes) to improve order history tracking and auditability.
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php
new file mode 100644
index 00000000000..a9cd04018aa
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php
@@ -0,0 +1,422 @@
+<?php
+/**
+ * Fulfillment Order Notes.
+ *
+ * Adds order notes for fulfillment lifecycle events.
+ *
+ * @package WooCommerce\Admin\Features\Fulfillments
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Admin\Features\Fulfillments;
+
+use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
+
+/**
+ * FulfillmentOrderNotes class.
+ *
+ * Hooks into fulfillment lifecycle actions and adds filterable order notes
+ * for fulfillment state changes.
+ *
+ * @since 10.7.0
+ */
+class FulfillmentOrderNotes {
+
+	/**
+	 * Stores the previous status of a fulfillment before update.
+	 *
+	 * @var array<int, string>
+	 */
+	private array $previous_statuses = array();
+
+	/**
+	 * Register hooks for fulfillment order notes.
+	 */
+	public function register(): void {
+		add_action( 'woocommerce_fulfillment_after_create', array( $this, 'add_fulfillment_created_note' ), 10, 1 );
+		add_filter( 'woocommerce_fulfillment_before_update', array( $this, 'capture_previous_status' ), 10, 1 );
+		add_action( 'woocommerce_fulfillment_after_update', array( $this, 'add_fulfillment_updated_note' ), 10, 1 );
+		add_action( 'woocommerce_fulfillment_after_delete', array( $this, 'add_fulfillment_deleted_note' ), 10, 1 );
+	}
+
+	/**
+	 * Add an order note when a fulfillment is created.
+	 *
+	 * @param Fulfillment $fulfillment The fulfillment object.
+	 */
+	public function add_fulfillment_created_note( Fulfillment $fulfillment ): void {
+		$order = $fulfillment->get_order();
+		if ( ! $order instanceof \WC_Order ) {
+			return;
+		}
+
+		$items_text    = $this->format_items( $fulfillment, $order );
+		$tracking_text = $this->format_tracking( $fulfillment );
+		$status        = $fulfillment->get_status() ?? 'unfulfilled';
+		$status_label  = $this->get_fulfillment_status_label( $status );
+
+		$message = sprintf(
+			/* translators: 1: fulfillment ID, 2: fulfillment status label, 3: item list */
+			__( 'Fulfillment #%1$d created (status: %2$s). Items: %3$s.', 'woocommerce' ),
+			$fulfillment->get_id(),
+			$status_label,
+			$items_text
+		);
+
+		if ( ! empty( $tracking_text ) ) {
+			$message .= ' ' . sprintf(
+				/* translators: %s: tracking number */
+				__( 'Tracking: %s.', 'woocommerce' ),
+				$tracking_text
+			);
+		}
+
+		/**
+		 * Filters the order note message when a fulfillment is created.
+		 *
+		 * Return null to cancel the note.
+		 *
+		 * @since 10.7.0
+		 *
+		 * @param string|null  $message     The note message.
+		 * @param Fulfillment  $fulfillment The fulfillment object.
+		 * @param \WC_Order    $order       The order object.
+		 */
+		$message = apply_filters( 'woocommerce_fulfillment_created_order_note', $message, $fulfillment, $order );
+		$message = $this->normalize_note_message( $message );
+
+		if ( null === $message ) {
+			return;
+		}
+
+		$order->add_order_note( $message, 0, false, array( 'note_group' => OrderNoteGroup::FULFILLMENT ) );
+	}
+
+	/**
+	 * Capture the previous status of a fulfillment before update.
+	 *
+	 * This is hooked into `woocommerce_fulfillment_before_update` to record
+	 * the old status so we can detect status changes in the after_update hook.
+	 *
+	 * @param Fulfillment $fulfillment The fulfillment object.
+	 * @return Fulfillment The unmodified fulfillment object.
+	 */
+	public function capture_previous_status( Fulfillment $fulfillment ): Fulfillment {
+		if ( $fulfillment->get_id() > 0 ) {
+			$old_fulfillment                                   = new Fulfillment( (string) $fulfillment->get_id() );
+			$this->previous_statuses[ $fulfillment->get_id() ] = $old_fulfillment->get_status() ?? 'unfulfilled';
+		}
+		return $fulfillment;
+	}
+
+	/**
+	 * Add an order note when a fulfillment is updated.
+	 *
+	 * If the status changed, a status change note is added.
+	 * Otherwise, a general update note is added.
+	 *
+	 * @param Fulfillment $fulfillment The fulfillment object.
+	 */
+	public function add_fulfillment_updated_note( Fulfillment $fulfillment ): void {
+		$order = $fulfillment->get_order();
+		if ( ! $order instanceof \WC_Order ) {
+			return;
+		}
+
+		$fulfillment_id = $fulfillment->get_id();
+		$old_status     = $this->previous_statuses[ $fulfillment_id ] ?? null;
+		$new_status     = $fulfillment->get_status() ?? 'unfulfilled';
+
+		// If status changed, add a status change note.
+		if ( null !== $old_status && $old_status !== $new_status ) {
+			$this->add_fulfillment_status_changed_note( $fulfillment, $order, $old_status, $new_status );
+			unset( $this->previous_statuses[ $fulfillment_id ] );
+			return;
+		}
+
+		unset( $this->previous_statuses[ $fulfillment_id ] );
+
+		$items_text    = $this->format_items( $fulfillment, $order );
+		$tracking_text = $this->format_tracking( $fulfillment );
+
+		$message = sprintf(
+			/* translators: 1: fulfillment ID, 2: item list */
+			__( 'Fulfillment #%1$d updated. Items: %2$s.', 'woocommerce' ),
+			$fulfillment->get_id(),
+			$items_text
+		);
+
+		if ( ! empty( $tracking_text ) ) {
+			$message .= ' ' . sprintf(
+				/* translators: %s: tracking number */
+				__( 'Tracking: %s.', 'woocommerce' ),
+				$tracking_text
+			);
+		}
+
+		/**
+		 * Filters the order note message when a fulfillment is updated.
+		 *
+		 * Return null to cancel the note.
+		 *
+		 * @since 10.7.0
+		 *
+		 * @param string|null  $message     The note message.
+		 * @param Fulfillment  $fulfillment The fulfillment object.
+		 * @param \WC_Order    $order       The order object.
+		 */
+		$message = apply_filters( 'woocommerce_fulfillment_updated_order_note', $message, $fulfillment, $order );
+		$message = $this->normalize_note_message( $message );
+
+		if ( null === $message ) {
+			return;
+		}
+
+		$order->add_order_note( $message, 0, false, array( 'note_group' => OrderNoteGroup::FULFILLMENT ) );
+	}
+
+	/**
+	 * Add an order note when a fulfillment is deleted.
+	 *
+	 * @param Fulfillment $fulfillment The fulfillment object.
+	 */
+	public function add_fulfillment_deleted_note( Fulfillment $fulfillment ): void {
+		$order = $fulfillment->get_order();
+		if ( ! $order instanceof \WC_Order ) {
+			return;
+		}
+
+		$message = sprintf(
+			/* translators: %d: fulfillment ID */
+			__( 'Fulfillment #%d deleted.', 'woocommerce' ),
+			$fulfillment->get_id()
+		);
+
+		/**
+		 * Filters the order note message when a fulfillment is deleted.
+		 *
+		 * Return null to cancel the note.
+		 *
+		 * @since 10.7.0
+		 *
+		 * @param string|null  $message     The note message.
+		 * @param Fulfillment  $fulfillment The fulfillment object.
+		 * @param \WC_Order    $order       The order object.
+		 */
+		$message = apply_filters( 'woocommerce_fulfillment_deleted_order_note', $message, $fulfillment, $order );
+		$message = $this->normalize_note_message( $message );
+
+		if ( null === $message ) {
+			return;
+		}
+
+		$order->add_order_note( $message, 0, false, array( 'note_group' => OrderNoteGroup::FULFILLMENT ) );
+	}
+
+	/**
+	 * Add an order note when the order fulfillment status changes.
+	 *
+	 * Called from FulfillmentsManager when the `_fulfillment_status` meta changes.
+	 *
+	 * @param \WC_Order $order      The order object.
+	 * @param string    $old_status The previous fulfillment status.
+	 * @param string    $new_status The new fulfillment status.
+	 */
+	public function add_order_fulfillment_status_changed_note( \WC_Order $order, string $old_status, string $new_status ): void {
+		$old_status_label = $this->get_order_fulfillment_status_label( $old_status );
+		$new_status_label = $this->get_order_fulfillment_status_label( $new_status );
+
+		$message = sprintf(
+			/* translators: 1: old fulfillment status label, 2: new fulfillment status label */
+			__( 'Order fulfillment status changed from %1$s to %2$s.', 'woocommerce' ),
+			$old_status_label,
+			$new_status_label
+		);
+
+		/**
+		 * Filters the order note message when the order fulfillment status changes.
+		 *
+		 * Return null to cancel the note.
+		 *
+		 * @since 10.7.0
+		 *
+		 * @param string|null $message    The note message.
+		 * @param \WC_Order   $order      The order object.
+		 * @param string      $old_status The previous fulfillment status.
+		 * @param string      $new_status The new fulfillment status.
+		 */
+		$message = apply_filters( 'woocommerce_fulfillment_order_status_changed_order_note', $message, $order, $old_status, $new_status );
+		$message = $this->normalize_note_message( $message );
+
+		if ( null === $message ) {
+			return;
+		}
+
+		$order->add_order_note( $message, 0, false, array( 'note_group' => OrderNoteGroup::FULFILLMENT ) );
+	}
+
+	/**
+	 * Add a status change note for a fulfillment.
+	 *
+	 * @param Fulfillment $fulfillment The fulfillment object.
+	 * @param \WC_Order   $order       The order object.
+	 * @param string      $old_status  The previous status.
+	 * @param string      $new_status  The new status.
+	 */
+	private function add_fulfillment_status_changed_note( Fulfillment $fulfillment, \WC_Order $order, string $old_status, string $new_status ): void {
+		$old_status_label = $this->get_fulfillment_status_label( $old_status );
+		$new_status_label = $this->get_fulfillment_status_label( $new_status );
+
+		$message = sprintf(
+			/* translators: 1: fulfillment ID, 2: old status label, 3: new status label */
+			__( 'Fulfillment #%1$d status changed from %2$s to %3$s.', 'woocommerce' ),
+			$fulfillment->get_id(),
+			$old_status_label,
+			$new_status_label
+		);
+
+		/**
+		 * Filters the order note message when a fulfillment status changes.
+		 *
+		 * Return null to cancel the note.
+		 *
+		 * @since 10.7.0
+		 *
+		 * @param string|null  $message     The note message.
+		 * @param Fulfillment  $fulfillment The fulfillment object.
+		 * @param \WC_Order    $order       The order object.
+		 * @param string       $old_status  The previous status.
+		 * @param string       $new_status  The new status.
+		 */
+		$message = apply_filters( 'woocommerce_fulfillment_status_changed_order_note', $message, $fulfillment, $order, $old_status, $new_status );
+		$message = $this->normalize_note_message( $message );
+
+		if ( null === $message ) {
+			return;
+		}
+
+		$order->add_order_note( $message, 0, false, array( 'note_group' => OrderNoteGroup::FULFILLMENT ) );
+	}
+
+	/**
+	 * Format fulfillment items as a comma-separated string.
+	 *
+	 * @param Fulfillment $fulfillment The fulfillment object.
+	 * @param \WC_Order   $order       The order object.
+	 * @return string Formatted items string.
+	 */
+	private function format_items( Fulfillment $fulfillment, \WC_Order $order ): string {
+		$items       = $fulfillment->get_items();
+		$order_items = $order->get_items();
+		$parts       = array();
+
+		foreach ( $items as $item ) {
+			$item_id = isset( $item['item_id'] ) ? (int) $item['item_id'] : 0;
+			$qty     = isset( $item['qty'] ) ? (int) $item['qty'] : 0;
+			$name    = '';
+
+			foreach ( $order_items as $order_item ) {
+				if ( (int) $order_item->get_id() === $item_id ) {
+					$name = $order_item->get_name();
+					break;
+				}
+			}
+
+			if ( empty( $name ) ) {
+				$name = sprintf(
+					/* translators: %d: item ID */
+					__( 'Item #%d', 'woocommerce' ),
+					$item_id
+				);
+			}
+
+			$parts[] = sprintf( '%s x%s', $name, $qty );
+		}
+
+		return implode( ', ', $parts );
+	}
+
+	/**
+	 * Format the tracking information from a fulfillment.
+	 *
+	 * Includes the tracking number, shipping provider, and tracking URL when available.
+	 *
+	 * @param Fulfillment $fulfillment The fulfillment object.
+	 * @return string The formatted tracking information, or empty string if no tracking number is present.
+	 */
+	private function format_tracking( Fulfillment $fulfillment ): string {
+		$tracking_number   = $fulfillment->get_meta( '_tracking_number', true );
+		$shipping_provider = $fulfillment->get_meta( '_shipping_provider', true );
+		$tracking_url      = $fulfillment->get_meta( '_tracking_url', true );
+
+		if ( ! is_string( $tracking_number ) || '' === $tracking_number ) {
+			return '';
+		}
+
+		$parts = array( $tracking_number );
+
+		if ( is_string( $shipping_provider ) && '' !== $shipping_provider ) {
+			$parts[] = sprintf(
+				/* translators: %s: shipping provider name */
+				__( 'Provider: %s', 'woocommerce' ),
+				$shipping_provider
+			);
+		}
+
+		if ( is_string( $tracking_url ) && '' !== $tracking_url ) {
+			$parts[] = sprintf(
+				/* translators: %s: tracking URL */
+				__( 'URL: %s', 'woocommerce' ),
+				$tracking_url
+			);
+		}
+
+		return implode( ', ', $parts );
+	}
+
+	/**
+	 * Get the display label for a fulfillment status key.
+	 *
+	 * @param string $status The fulfillment status key.
+	 * @return string The status label, or the key itself if no label is found.
+	 */
+	private function get_fulfillment_status_label( string $status ): string {
+		$statuses = FulfillmentUtils::get_fulfillment_statuses();
+		return $statuses[ $status ]['label'] ?? $status;
+	}
+
+	/**
+	 * Get the display label for an order fulfillment status key.
+	 *
+	 * @param string $status The order fulfillment status key.
+	 * @return string The status label, or the key itself if no label is found.
+	 */
+	private function get_order_fulfillment_status_label( string $status ): string {
+		$statuses = FulfillmentUtils::get_order_fulfillment_statuses();
+		return $statuses[ $status ]['label'] ?? $status;
+	}
+
+	/**
+	 * Sanitize an order note message.
+	 *
+	 * Ensures the message is a string and strips any disallowed HTML tags.
+	 *
+	 * @param mixed $message The original message.
+	 * @return string|null The sanitized message, or null if the message is not valid.
+	 */
+	private function normalize_note_message( $message ): ?string {
+		if ( ! $message || ! is_string( $message ) ) {
+			return null;
+		}
+
+		$message = wp_kses_post( $message );
+		$message = trim( $message );
+
+		if ( '' === $message ) {
+			return null;
+		}
+
+		return $message;
+	}
+}
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php
index 48ef12a7a22..88c8255d1d1 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php
@@ -21,6 +21,13 @@ use WC_Order_Refund;
  * @package WooCommerce\Admin\Features\Fulfillments
  */
 class FulfillmentsManager {
+	/**
+	 * The fulfillment order notes instance.
+	 *
+	 * @var FulfillmentOrderNotes|null
+	 */
+	private ?FulfillmentOrderNotes $fulfillment_order_notes = null;
+
 	/**
 	 * This method registers the hooks related to fulfillments.
 	 */
@@ -31,6 +38,11 @@ class FulfillmentsManager {

 		$this->init_fulfillment_status_hooks();
 		$this->init_refund_hooks();
+
+		if ( ! $this->fulfillment_order_notes ) {
+			$this->fulfillment_order_notes = wc_get_container()->get( FulfillmentOrderNotes::class );
+		}
+		$this->fulfillment_order_notes->register();
 	}

 	/**
@@ -311,15 +323,20 @@ class FulfillmentsManager {
 	 * This method updates the fulfillment status for the order based on the fulfillments data store.
 	 */
 	private function update_fulfillment_status( $order, $fulfillments = array() ) {
-		$last_status = FulfillmentUtils::calculate_order_fulfillment_status( $order, $fulfillments );
-		if ( 'no_fulfillments' === $last_status ) {
+		$old_status = FulfillmentUtils::get_order_fulfillment_status( $order );
+		$new_status = FulfillmentUtils::calculate_order_fulfillment_status( $order, $fulfillments );
+
+		if ( 'no_fulfillments' === $new_status ) {
 			$order->delete_meta_data( '_fulfillment_status' );
 		} else {
-			// Update the fulfillment status meta data.
-			$order->update_meta_data( '_fulfillment_status', $last_status );
+			$order->update_meta_data( '_fulfillment_status', $new_status );
 		}

 		$order->save();
+
+		if ( $old_status !== $new_status && isset( $this->fulfillment_order_notes ) ) {
+			$this->fulfillment_order_notes->add_order_fulfillment_status_changed_note( $order, $old_status, $new_status );
+		}
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Orders/OrderNoteGroup.php b/plugins/woocommerce/src/Internal/Orders/OrderNoteGroup.php
index 7fd06d3b957..a9b40f3b46c 100644
--- a/plugins/woocommerce/src/Internal/Orders/OrderNoteGroup.php
+++ b/plugins/woocommerce/src/Internal/Orders/OrderNoteGroup.php
@@ -45,6 +45,13 @@ final class OrderNoteGroup {
 	 */
 	public const ORDER_UPDATE = 'order_update';

+	/**
+	 * Any note concerning fulfillments.
+	 *
+	 * @var string
+	 */
+	public const FULFILLMENT = 'fulfillment';
+
 	/**
 	 * Get the default group title for a given group.
 	 *
@@ -61,6 +68,8 @@ final class OrderNoteGroup {
 				return __( 'Email notification', 'woocommerce' );
 			case self::ERROR:
 				return __( 'Error', 'woocommerce' );
+			case self::FULFILLMENT:
+				return __( 'Fulfillment', 'woocommerce' );
 			default:
 				return __( 'Order updated', 'woocommerce' );
 		}
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php
new file mode 100644
index 00000000000..305846f77ac
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php
@@ -0,0 +1,836 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\Features\Fulfillments;
+
+use Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentOrderNotes;
+use Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsManager;
+use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use Automattic\WooCommerce\Tests\Admin\Features\Fulfillments\Helpers\FulfillmentsHelper;
+use WC_Order;
+
+/**
+ * Tests for FulfillmentOrderNotes.
+ *
+ * @testdox FulfillmentOrderNotes
+ * @since 10.7.0
+ */
+class FulfillmentOrderNotesTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * @var FulfillmentsManager
+	 */
+	private FulfillmentsManager $manager;
+
+	/**
+	 * The original value of the fulfillments feature flag before tests.
+	 *
+	 * @var string|false
+	 */
+	private static $original_fulfillments_enabled;
+
+	/**
+	 * Set up the test environment.
+	 */
+	public static function setUpBeforeClass(): void {
+		parent::setUpBeforeClass();
+		self::$original_fulfillments_enabled = get_option( 'woocommerce_feature_fulfillments_enabled' );
+		update_option( 'woocommerce_feature_fulfillments_enabled', 'yes' );
+		$controller = wc_get_container()->get( \Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsController::class );
+		$controller->register();
+		$controller->initialize_fulfillments();
+	}
+
+	/**
+	 * Tear down the test environment.
+	 */
+	public static function tearDownAfterClass(): void {
+		if ( false === self::$original_fulfillments_enabled ) {
+			delete_option( 'woocommerce_feature_fulfillments_enabled' );
+		} else {
+			update_option( 'woocommerce_feature_fulfillments_enabled', self::$original_fulfillments_enabled );
+		}
+		parent::tearDownAfterClass();
+	}
+
+	/**
+	 * Set up each test case.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->manager = wc_get_container()->get( FulfillmentsManager::class );
+		$this->manager->register();
+	}
+
+	/**
+	 * Test that order notes hooks are registered.
+	 */
+	public function test_hooks_registered(): void {
+		$this->assertNotFalse( has_action( 'woocommerce_fulfillment_after_create' ) );
+		$this->assertNotFalse( has_filter( 'woocommerce_fulfillment_before_update' ) );
+		$this->assertNotFalse( has_action( 'woocommerce_fulfillment_after_update' ) );
+		$this->assertNotFalse( has_action( 'woocommerce_fulfillment_after_delete' ) );
+	}
+
+	/**
+	 * Test that an order note is added when a fulfillment is created.
+	 */
+	public function test_note_added_on_fulfillment_creation(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$order   = OrderHelper::create_order( get_current_user_id(), $product );
+
+		$order_items = $order->get_items();
+		$first_item  = reset( $order_items );
+
+		FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_type'  => WC_Order::class,
+				'entity_id'    => $order->get_id(),
+				'status'       => 'unfulfilled',
+				'is_fulfilled' => false,
+			),
+			array(
+				'_items' => array(
+					array(
+						'item_id' => $first_item->get_id(),
+						'qty'     => 1,
+					),
+				),
+			)
+		);
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+		$this->assertNotEmpty( $notes );
+
+		$found_created_note = false;
+		foreach ( $notes as $note ) {
+			if ( str_contains( $note->content, 'created' ) && str_contains( $note->content, 'Unfulfilled' ) ) {
+				$found_created_note = true;
+				$note_group         = get_comment_meta( $note->id, 'note_group', true );
+				$this->assertSame( OrderNoteGroup::FULFILLMENT, $note_group );
+				break;
+			}
+		}
+		$this->assertTrue( $found_created_note, 'Expected a fulfillment created order note.' );
+	}
+
+	/**
+	 * Test that the created note includes item names and quantities.
+	 */
+	public function test_created_note_includes_items(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$order   = OrderHelper::create_order( get_current_user_id(), $product );
+
+		$order_items = $order->get_items();
+		$first_item  = reset( $order_items );
+
+		FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_type'  => WC_Order::class,
+				'entity_id'    => $order->get_id(),
+				'status'       => 'unfulfilled',
+				'is_fulfilled' => false,
+			),
+			array(
+				'_items' => array(
+					array(
+						'item_id' => $first_item->get_id(),
+						'qty'     => 2,
+					),
+				),
+			)
+		);
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+		$found = false;
+		foreach ( $notes as $note ) {
+			if ( str_contains( $note->content, 'created' ) && str_contains( $note->content, 'x2' ) ) {
+				$found = true;
+				break;
+			}
+		}
+		$this->assertTrue( $found, 'Expected the created note to include item list with quantities.' );
+	}
+
+	/**
+	 * Test that the created note includes tracking number when present.
+	 */
+	public function test_created_note_includes_tracking_number(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$order   = OrderHelper::create_order( get_current_user_id(), $product );
+
+		$order_items = $order->get_items();
+		$first_item  = reset( $order_items );
+
+		FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_type'  => WC_Order::class,
+				'entity_id'    => $order->get_id(),
+				'status'       => 'unfulfilled',
+				'is_fulfilled' => false,
+			),
+			array(
+				'_items'           => array(
+					array(
+						'item_id' => $first_item->get_id(),
+						'qty'     => 1,
+					),
+				),
+				'_tracking_number' => 'TRACK123456',
+			)
+		);
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+		$found = false;
+		foreach ( $notes as $note ) {
+			if ( str_contains( $note->content, 'TRACK123456' ) ) {
+				$found = true;
+				break;
+			}
+		}
+		$this->assertTrue( $found, 'Expected the created note to include the tracking number.' );
+	}
+
+	/**
+	 * Test that the created note includes shipping provider when present.
+	 */
+	public function test_created_note_includes_shipping_provider(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$order   = OrderHelper::create_order( get_current_user_id(), $product );
+
+		$order_items = $order->get_items();
+		$first_item  = reset( $order_items );
+
+		FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_type'  => WC_Order::class,
+				'entity_id'    => $order->get_id(),
+				'status'       => 'unfulfilled',
+				'is_fulfilled' => false,
+			),
+			array(
+				'_items'             => array(
+					array(
+						'item_id' => $first_item->get_id(),
+						'qty'     => 1,
+					),
+				),
+				'_tracking_number'   => 'TRACK123456',
+				'_shipping_provider' => 'fedex',
+			)
+		);
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+		$found = false;
+		foreach ( $notes as $note ) {
+			if ( str_contains( $note->content, 'TRACK123456' ) && str_contains( $note->content, 'fedex' ) ) {
+				$found = true;
+				break;
+			}
+		}
+		$this->assertTrue( $found, 'Expected the created note to include the shipping provider.' );
+	}
+
+	/**
+	 * Test that the created note includes tracking URL when present.
+	 */
+	public function test_created_note_includes_tracking_url(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$order   = OrderHelper::create_order( get_current_user_id(), $product );
+
+		$order_items = $order->get_items();
+		$first_item  = reset( $order_items );
+
+		FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_type'  => WC_Order::class,
+				'entity_id'    => $order->get_id(),
+				'status'       => 'unfulfilled',
+				'is_fulfilled' => false,
+			),
+			array(
+				'_items'           => array(
+					array(
+						'item_id' => $first_item->get_id(),
+						'qty'     => 1,
+					),
+				),
+				'_tracking_number' => 'TRACK123456',
+				'_tracking_url'    => 'https://example.com/track/TRACK123456',
+			)
+		);
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+		$found = false;
+		foreach ( $notes as $note ) {
+			if ( str_contains( $note->content, 'TRACK123456' ) && str_contains( $note->content, 'https://example.com/track/TRACK123456' ) ) {
+				$found = true;
+				break;
+			}
+		}
+		$this->assertTrue( $found, 'Expected the created note to include the tracking URL.' );
+	}
+
+	/**
+	 * Test that the updated note includes shipping provider and tracking URL.
+	 */
+	public function test_updated_note_includes_provider_and_url(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$order   = OrderHelper::create_order( get_current_user_id(), $product );
+
+		$order_items = $order->get_items();
+		$first_item  = reset( $order_items );
+
+		$fulfillment = FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_type'  => WC_Order::class,
+				'entity_id'    => $order->get_id(),
+				'status'       => 'unfulfilled',
+				'is_fulfilled' => false,
+			),
+			array(
+				'_items' => array(
+					array(
+						'item_id' => $first_item->get_id(),
+						'qty'     => 1,
+					),
+				),
+			)
+		);
+
+		// Update with tracking info (non-status change).
+		$fulfillment->update_meta_data( '_tracking_number', 'UPS999' );
+		$fulfillment->update_meta_data( '_shipping_provider', 'ups' );
+		$fulfillment->update_meta_data( '_tracking_url', 'https://ups.com/track/UPS999' );
+		$fulfillment->save();
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+		$found = false;
+		foreach ( $notes as $note ) {
+			if (
+				str_contains( $note->content, 'updated' )
+				&& str_contains( $note->content, 'UPS999' )
+				&& str_contains( $note->content, 'ups' )
+				&& str_contains( $note->content, 'https://ups.com/track/UPS999' )
+			) {
+				$found = true;
+				break;
+			}
+		}
+		$this->assertTrue( $found, 'Expected the updated note to include tracking number, provider, and URL.' );
+	}
+
+	/**
+	 * Test that an order note is added when a fulfillment status changes.
+	 */
+	public function test_note_added_on_fulfillment_status_change(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$order   = OrderHelper::create_order( get_current_user_id(), $product );
+
+		$order_items = $order->get_items();
+		$first_item  = reset( $order_items );
+
+		$fulfillment = FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_type'  => WC_Order::class,
+				'entity_id'    => $order->get_id(),
+				'status'       => 'unfulfilled',
+				'is_fulfilled' => false,
+			),
+			array(
+				'_items' => array(
+					array(
+						'item_id' => $first_item->get_id(),
+						'qty'     => 1,
+					),
+				),
+			)
+		);
+
+		// Change status to fulfilled.
+		$fulfillment->set_status( 'fulfilled' );
+		$fulfillment->save();
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+		$found = false;
+		foreach ( $notes as $note ) {
+			if ( str_contains( $note->content, 'status changed' ) && str_contains( $note->content, 'Unfulfilled' ) && str_contains( $note->content, 'Fulfilled' ) ) {
+				$found      = true;
+				$note_group = get_comment_meta( $note->id, 'note_group', true );
+				$this->assertSame( OrderNoteGroup::FULFILLMENT, $note_group );
+				break;
+			}
+		}
+		$this->assertTrue( $found, 'Expected a fulfillment status changed order note.' );
+	}
+
+	/**
+	 * Test that an order note is added when a fulfillment is updated (non-status change).
+	 */
+	public function test_note_added_on_fulfillment_update(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$order   = OrderHelper::create_order( get_current_user_id(), $product );
+
+		$order_items = $order->get_items();
+		$first_item  = reset( $order_items );
+
+		$fulfillment = FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_type'  => WC_Order::class,
+				'entity_id'    => $order->get_id(),
+				'status'       => 'unfulfilled',
+				'is_fulfilled' => false,
+			),
+			array(
+				'_items' => array(
+					array(
+						'item_id' => $first_item->get_id(),
+						'qty'     => 1,
+					),
+				),
+			)
+		);
+
+		// Update tracking number (non-status change).
+		$fulfillment->update_meta_data( '_tracking_number', 'NEWTRACK789' );
+		$fulfillment->save();
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+		$found = false;
+		foreach ( $notes as $note ) {
+			if ( str_contains( $note->content, 'updated' ) && str_contains( $note->content, 'NEWTRACK789' ) ) {
+				$found = true;
+				break;
+			}
+		}
+		$this->assertTrue( $found, 'Expected a fulfillment updated order note with tracking number.' );
+	}
+
+	/**
+	 * Test that an order note is added when a fulfillment is deleted.
+	 */
+	public function test_note_added_on_fulfillment_deletion(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$order   = OrderHelper::create_order( get_current_user_id(), $product );
+
+		$order_items = $order->get_items();
+		$first_item  = reset( $order_items );
+
+		$fulfillment = FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_type'  => WC_Order::class,
+				'entity_id'    => $order->get_id(),
+				'status'       => 'unfulfilled',
+				'is_fulfilled' => false,
+			),
+			array(
+				'_items' => array(
+					array(
+						'item_id' => $first_item->get_id(),
+						'qty'     => 1,
+					),
+				),
+			)
+		);
+
+		$fulfillment_id = $fulfillment->get_id();
+		$fulfillment->delete();
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+		$found = false;
+		foreach ( $notes as $note ) {
+			if ( str_contains( $note->content, 'deleted' ) && str_contains( $note->content, (string) $fulfillment_id ) ) {
+				$found      = true;
+				$note_group = get_comment_meta( $note->id, 'note_group', true );
+				$this->assertSame( OrderNoteGroup::FULFILLMENT, $note_group );
+				break;
+			}
+		}
+		$this->assertTrue( $found, 'Expected a fulfillment deleted order note.' );
+	}
+
+	/**
+	 * Test that an order note is added when the order fulfillment status changes.
+	 */
+	public function test_note_added_on_order_fulfillment_status_change(): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$order   = OrderHelper::create_order( get_current_user_id(), $product );
+
+		$order_items = $order->get_items();
+		$first_item  = reset( $order_items );
+
+		// Creating a fulfillment triggers order status change from no_fulfillments to unfulfilled.
+		FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_type'  => WC_Order::class,
+				'entity_id'    => $order->get_id(),
+				'status'       => 'unfulfilled',
+				'is_fulfilled' => false,
+			),
+			array(
+				'_items' => array(
+					array(
+						'item_id' => $first_item->get_id(),
+						'qty'     => 1,
+					),
+				),
+			)
+		);
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+		$found = false;
+		foreach ( $notes as $note ) {
+			if ( str_contains( $note->content, 'Order fulfillment status changed' )
+				&& str_contains( $note->content, 'No fulfillments' )
+				&& str_contains( $note->content, 'Unfulfilled' )
+			) {
+				$found = true;
+				break;
+			}
+		}
+		$this->assertTrue( $found, 'Expected an order fulfillment status changed note with status labels.' );
+	}
+
+	/**
+	 * Test that disallowed HTML tags are stripped from note messages.
+	 */
+	public function test_normalize_strips_disallowed_html(): void {
+		$malicious_filter = function () {
+			return 'Fulfillment created <script>alert("xss")</script> successfully.';
+		};
+		add_filter( 'woocommerce_fulfillment_created_order_note', $malicious_filter );
+
+		$product = \WC_Helper_Product::create_simple_product();
+		$order   = OrderHelper::create_order( get_current_user_id(), $product );
+
+		$order_items = $order->get_items();
+		$first_item  = reset( $order_items );
+
+		FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_type'  => WC_Order::class,
+				'entity_id'    => $order->get_id(),
+				'status'       => 'unfulfilled',
+				'is_fulfilled' => false,
+			),
+			array(
+				'_items' => array(
+					array(
+						'item_id' => $first_item->get_id(),
+						'qty'     => 1,
+					),
+				),
+			)
+		);
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+		$found = false;
+		foreach ( $notes as $note ) {
+			if ( str_contains( $note->content, 'Fulfillment created' ) ) {
+				$found = true;
+				$this->assertStringNotContainsString( '<script>', $note->content );
+				$this->assertStringNotContainsString( '</script>', $note->content );
+				$this->assertStringContainsString( 'successfully.', $note->content );
+				break;
+			}
+		}
+		$this->assertTrue( $found, 'Expected a sanitized fulfillment note.' );
+
+		remove_filter( 'woocommerce_fulfillment_created_order_note', $malicious_filter );
+	}
+
+	/**
+	 * Test that allowed HTML tags are preserved in note messages.
+	 */
+	public function test_normalize_preserves_allowed_html(): void {
+		$filter_with_html = function () {
+			return 'Fulfillment <strong>created</strong> with <a href="https://example.com">link</a>.';
+		};
+		add_filter( 'woocommerce_fulfillment_created_order_note', $filter_with_html );
+
+		$product = \WC_Helper_Product::create_simple_product();
+		$order   = OrderHelper::create_order( get_current_user_id(), $product );
+
+		$order_items = $order->get_items();
+		$first_item  = reset( $order_items );
+
+		FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_type'  => WC_Order::class,
+				'entity_id'    => $order->get_id(),
+				'status'       => 'unfulfilled',
+				'is_fulfilled' => false,
+			),
+			array(
+				'_items' => array(
+					array(
+						'item_id' => $first_item->get_id(),
+						'qty'     => 1,
+					),
+				),
+			)
+		);
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+		$found = false;
+		foreach ( $notes as $note ) {
+			if ( str_contains( $note->content, 'Fulfillment' ) && str_contains( $note->content, 'created' ) ) {
+				$found = true;
+				$this->assertStringContainsString( '<strong>created</strong>', $note->content );
+				$this->assertStringContainsString( '<a href="https://example.com">link</a>', $note->content );
+				break;
+			}
+		}
+		$this->assertTrue( $found, 'Expected allowed HTML to be preserved in note.' );
+
+		remove_filter( 'woocommerce_fulfillment_created_order_note', $filter_with_html );
+	}
+
+	/**
+	 * Test that returning a non-string value from a filter cancels the note.
+	 */
+	public function test_filter_returning_non_string_cancels_note(): void {
+		$non_string_filter = function () {
+			return 12345;
+		};
+		add_filter( 'woocommerce_fulfillment_created_order_note', $non_string_filter );
+
+		$product = \WC_Helper_Product::create_simple_product();
+		$order   = OrderHelper::create_order( get_current_user_id(), $product );
+
+		$order_items = $order->get_items();
+		$first_item  = reset( $order_items );
+
+		FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_type'  => WC_Order::class,
+				'entity_id'    => $order->get_id(),
+				'status'       => 'unfulfilled',
+				'is_fulfilled' => false,
+			),
+			array(
+				'_items' => array(
+					array(
+						'item_id' => $first_item->get_id(),
+						'qty'     => 1,
+					),
+				),
+			)
+		);
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+		$found_created_note = false;
+		foreach ( $notes as $note ) {
+			if ( str_contains( $note->content, 'created' ) || str_contains( $note->content, '12345' ) ) {
+				$found_created_note = true;
+				break;
+			}
+		}
+		$this->assertFalse( $found_created_note, 'Expected no fulfillment note when filter returns a non-string value.' );
+
+		remove_filter( 'woocommerce_fulfillment_created_order_note', $non_string_filter );
+	}
+
+	/**
+	 * Test that returning an empty string from a filter cancels the note.
+	 */
+	public function test_filter_returning_empty_string_cancels_note(): void {
+		$empty_filter = function () {
+			return '';
+		};
+		add_filter( 'woocommerce_fulfillment_created_order_note', $empty_filter );
+
+		$product = \WC_Helper_Product::create_simple_product();
+		$order   = OrderHelper::create_order( get_current_user_id(), $product );
+
+		$order_items = $order->get_items();
+		$first_item  = reset( $order_items );
+
+		FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_type'  => WC_Order::class,
+				'entity_id'    => $order->get_id(),
+				'status'       => 'unfulfilled',
+				'is_fulfilled' => false,
+			),
+			array(
+				'_items' => array(
+					array(
+						'item_id' => $first_item->get_id(),
+						'qty'     => 1,
+					),
+				),
+			)
+		);
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+		$found_created_note = false;
+		foreach ( $notes as $note ) {
+			if ( str_contains( $note->content, 'created' ) ) {
+				$found_created_note = true;
+				break;
+			}
+		}
+		$this->assertFalse( $found_created_note, 'Expected no fulfillment note when filter returns empty string.' );
+
+		remove_filter( 'woocommerce_fulfillment_created_order_note', $empty_filter );
+	}
+
+	/**
+	 * Test that returning whitespace-only string from a filter cancels the note.
+	 */
+	public function test_filter_returning_whitespace_only_cancels_note(): void {
+		$whitespace_filter = function () {
+			return '   ';
+		};
+		add_filter( 'woocommerce_fulfillment_created_order_note', $whitespace_filter );
+
+		$product = \WC_Helper_Product::create_simple_product();
+		$order   = OrderHelper::create_order( get_current_user_id(), $product );
+
+		$order_items = $order->get_items();
+		$first_item  = reset( $order_items );
+
+		FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_type'  => WC_Order::class,
+				'entity_id'    => $order->get_id(),
+				'status'       => 'unfulfilled',
+				'is_fulfilled' => false,
+			),
+			array(
+				'_items' => array(
+					array(
+						'item_id' => $first_item->get_id(),
+						'qty'     => 1,
+					),
+				),
+			)
+		);
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+		$found_created_note = false;
+		foreach ( $notes as $note ) {
+			if ( str_contains( $note->content, 'created' ) ) {
+				$found_created_note = true;
+				break;
+			}
+		}
+		$this->assertFalse( $found_created_note, 'Expected no fulfillment note when filter returns whitespace-only string.' );
+
+		remove_filter( 'woocommerce_fulfillment_created_order_note', $whitespace_filter );
+	}
+
+	/**
+	 * Test that returning null from a filter cancels the note.
+	 */
+	public function test_filter_returning_null_cancels_note(): void {
+		add_filter( 'woocommerce_fulfillment_created_order_note', '__return_null' );
+
+		$product = \WC_Helper_Product::create_simple_product();
+		$order   = OrderHelper::create_order( get_current_user_id(), $product );
+
+		$order_items = $order->get_items();
+		$first_item  = reset( $order_items );
+
+		FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_type'  => WC_Order::class,
+				'entity_id'    => $order->get_id(),
+				'status'       => 'unfulfilled',
+				'is_fulfilled' => false,
+			),
+			array(
+				'_items' => array(
+					array(
+						'item_id' => $first_item->get_id(),
+						'qty'     => 1,
+					),
+				),
+			)
+		);
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+		$found_created_note = false;
+		foreach ( $notes as $note ) {
+			if ( str_contains( $note->content, 'created' ) ) {
+				$found_created_note = true;
+				break;
+			}
+		}
+		$this->assertFalse( $found_created_note, 'Expected no fulfillment created note when filter returns null.' );
+
+		remove_filter( 'woocommerce_fulfillment_created_order_note', '__return_null' );
+	}
+
+	/**
+	 * Test that a filter can modify the note message.
+	 */
+	public function test_filter_modifies_note_message(): void {
+		$custom_filter = function () {
+			return 'Custom fulfillment note message';
+		};
+		add_filter( 'woocommerce_fulfillment_created_order_note', $custom_filter );
+
+		$product = \WC_Helper_Product::create_simple_product();
+		$order   = OrderHelper::create_order( get_current_user_id(), $product );
+
+		$order_items = $order->get_items();
+		$first_item  = reset( $order_items );
+
+		FulfillmentsHelper::create_fulfillment(
+			array(
+				'entity_type'  => WC_Order::class,
+				'entity_id'    => $order->get_id(),
+				'status'       => 'unfulfilled',
+				'is_fulfilled' => false,
+			),
+			array(
+				'_items' => array(
+					array(
+						'item_id' => $first_item->get_id(),
+						'qty'     => 1,
+					),
+				),
+			)
+		);
+
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+		$found = false;
+		foreach ( $notes as $note ) {
+			if ( 'Custom fulfillment note message' === $note->content ) {
+				$found = true;
+				break;
+			}
+		}
+		$this->assertTrue( $found, 'Expected the note to contain the custom filter message.' );
+
+		remove_filter( 'woocommerce_fulfillment_created_order_note', $custom_filter );
+	}
+
+	/**
+	 * Test the FULFILLMENT constant exists in OrderNoteGroup.
+	 */
+	public function test_fulfillment_order_note_group_constant(): void {
+		$this->assertSame( 'fulfillment', OrderNoteGroup::FULFILLMENT );
+		$this->assertSame( 'Fulfillment', OrderNoteGroup::get_default_group_title( OrderNoteGroup::FULFILLMENT ) );
+	}
+}