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 ) );
+ }
+}