Commit a5826220f9e for woocommerce

commit a5826220f9e4e5b34ac0825b5fd21b6389b12b88
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Thu Mar 5 13:44:49 2026 +0300

    [WOOPRD-2912] Fix duplicate fulfillment order notes by tracking property changes                                                                                                           (#63544)

    * fix: remove redundant save_meta_data() call in OrderFulfillmentsRestController

    The update_fulfillment() method calls $fulfillment->save() followed by
    $fulfillment->save_meta_data(). The DataStore's update() method already
    calls save_meta_data() internally before firing the
    woocommerce_fulfillment_after_update hook. The redundant call triggers
    the hook a second time, causing FulfillmentOrderNotes to generate a
    duplicate "Fulfillment #X updated" order note (since it clears its
    previous_statuses tracking after the first fire).

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

    * Enhance fulfillment update process by tracking property changes

    * Remove redundant escape characters from changelog

    * test: enhance fulfillment update hook to include changed properties and old state

    * Update hook documentation

    ---------

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

diff --git a/plugins/woocommerce/changelog/63544-fix-wooprd-2912-remove-duplicate-save-meta-data b/plugins/woocommerce/changelog/63544-fix-wooprd-2912-remove-duplicate-save-meta-data
new file mode 100644
index 00000000000..486b03e27b2
--- /dev/null
+++ b/plugins/woocommerce/changelog/63544-fix-wooprd-2912-remove-duplicate-save-meta-data
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix duplicate "Fulfillment updated" order notes caused by redundant save_meta_data() call in OrderFulfillmentsRestController.
diff --git a/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md b/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md
index e1483608970..e631dbc512b 100644
--- a/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md
+++ b/plugins/woocommerce/client/admin/docs/features/fulfillments-hooks.md
@@ -58,14 +58,19 @@ Fired after a fulfillment is successfully updated in the database.
 **Parameters:**

 -   `$data` (Fulfillment) - The updated fulfillment object
+-   `$changed_props` (array) - List of tracked property keys that changed (e.g. `'status'`, `'items'`, `'_tracking_number'`, `'_tracking_url'`, `'_shipping_provider'`)
+-   `$old_state` (array) - Snapshot of tracked property values before the update

-**Purpose:** Allows plugins to perform actions after a fulfillment is updated.
+**Purpose:** Allows plugins to perform actions after a fulfillment is updated. Only tracked properties (status, items, tracking number, tracking URL, shipping provider) are compared; changes to other metadata do not appear in `$changed_props`.

 ```php
-add_action( 'woocommerce_fulfillment_after_update', 'sync_fulfillment_changes' );
+add_action( 'woocommerce_fulfillment_after_update', 'sync_fulfillment_changes', 10, 3 );

-function sync_fulfillment_changes( $fulfillment ) {
-    // Sync changes to external fulfillment service
+function sync_fulfillment_changes( $fulfillment, $changed_props, $old_state ) {
+    // Only sync when tracking info changes
+    if ( in_array( '_tracking_number', $changed_props, true ) ) {
+        // Sync tracking to external service
+    }
 }
 ```

diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php
index 4b7bc8b003a..62d7e82e968 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php
@@ -24,6 +24,11 @@ if ( ! defined( 'ABSPATH' ) ) {
  */
 class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data_Store_Interface, FulfillmentsDataStoreInterface {

+	/**
+	 * Tracked meta keys for detecting meaningful fulfillment property changes.
+	 */
+	private const TRACKED_META_KEYS = array( '_tracking_number', '_tracking_url', '_shipping_provider' );
+
 	/**
 	 * Method to create a new fulfillment in the database.
 	 *
@@ -188,6 +193,9 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data

 		$this->validate_items( $data );

+		// Snapshot tracked properties before the update so we can detect changes.
+		$old_state = $this->snapshot_tracked_state( $data_id );
+
 		/**
 		 * Filter to modify the fulfillment data before it is updated.
 		 *
@@ -253,14 +261,20 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
 		$data->set_object_read( true );

 		if ( ! doing_action( 'woocommerce_fulfillment_after_update' ) ) {
+			$changed_props = $this->compute_changed_props( $data, $old_state );
+
 			/**
 			 * Action to perform after a fulfillment is updated.
 			 *
-			 * @param Fulfillment $data The fulfillment object that was updated.
+			 * @param Fulfillment $data          The fulfillment object that was updated.
+			 * @param array       $changed_props List of tracked property keys that changed
+			 *                                   (e.g. 'status', 'items', '_tracking_number').
+			 * @param array       $old_state     Snapshot of tracked property values before the update.
 			 *
 			 * @since 10.1.0
+			 * @since 10.7.0 Added $changed_props and $old_state parameters.
 			 */
-			do_action( 'woocommerce_fulfillment_after_update', $data );
+			do_action( 'woocommerce_fulfillment_after_update', $data, $changed_props, $old_state );
 		}

 		if ( $is_fulfill_action && ! doing_action( 'woocommerce_fulfillment_after_fulfill' ) ) {
@@ -275,6 +289,58 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
 		}
 	}

+	/**
+	 * Snapshot tracked properties of a fulfillment from the database.
+	 *
+	 * @param int $fulfillment_id The fulfillment ID.
+	 * @return array The snapshot of tracked property values.
+	 */
+	private function snapshot_tracked_state( int $fulfillment_id ): array {
+		$old = new Fulfillment( (string) $fulfillment_id );
+
+		$state = array(
+			'status' => $old->get_status() ?? 'unfulfilled',
+			'items'  => $old->get_items(),
+		);
+
+		foreach ( self::TRACKED_META_KEYS as $key ) {
+			$value         = $old->get_meta( $key, true );
+			$state[ $key ] = is_string( $value ) ? $value : '';
+		}
+
+		return $state;
+	}
+
+	/**
+	 * Compute which tracked properties changed between the old state and the updated fulfillment.
+	 *
+	 * @param Fulfillment $fulfillment The updated fulfillment object.
+	 * @param array       $old_state   The previous state snapshot.
+	 * @return array List of changed property keys (e.g. 'status', 'items', '_tracking_number').
+	 */
+	private function compute_changed_props( Fulfillment $fulfillment, array $old_state ): array {
+		$changed = array();
+
+		$new_status = $fulfillment->get_status() ?? 'unfulfilled';
+		if ( $new_status !== $old_state['status'] ) {
+			$changed[] = 'status';
+		}
+
+		if ( $fulfillment->get_items() !== $old_state['items'] ) {
+			$changed[] = 'items';
+		}
+
+		foreach ( self::TRACKED_META_KEYS as $key ) {
+			$new_value = $fulfillment->get_meta( $key, true );
+			$new_value = is_string( $new_value ) ? $new_value : '';
+			if ( $new_value !== $old_state[ $key ] ) {
+				$changed[] = $key;
+			}
+		}
+
+		return $changed;
+	}
+
 	/**
 	 * Method to delete a fulfillment from the database.
 	 *
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php
index a9cd04018aa..2ea9aaf1ee9 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentOrderNotes.php
@@ -23,20 +23,12 @@ use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
  */
 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_update', array( $this, 'add_fulfillment_updated_note' ), 10, 3 );
 		add_action( 'woocommerce_fulfillment_after_delete', array( $this, 'add_fulfillment_deleted_note' ), 10, 1 );
 	}

@@ -94,49 +86,34 @@ class FulfillmentOrderNotes {
 	}

 	/**
-	 * Capture the previous status of a fulfillment before update.
+	 * Add an order note when a fulfillment is updated.
 	 *
-	 * This is hooked into `woocommerce_fulfillment_before_update` to record
-	 * the old status so we can detect status changes in the after_update hook.
+	 * Only adds a note when tracked properties change (status, items,
+	 * tracking number, tracking URL, shipping provider). If the status
+	 * changed, a dedicated status change note is added instead.
 	 *
-	 * @param Fulfillment $fulfillment The fulfillment object.
-	 * @return Fulfillment The unmodified fulfillment object.
+	 * @param Fulfillment $fulfillment   The fulfillment object.
+	 * @param array       $changed_props List of tracked property keys that changed.
+	 * @param array       $old_state     Snapshot of tracked property values before the update.
 	 */
-	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';
+	public function add_fulfillment_updated_note( Fulfillment $fulfillment, array $changed_props = array(), array $old_state = array() ): void {
+		if ( empty( $changed_props ) ) {
+			return;
 		}
-		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 ) {
+		// If status changed, add a dedicated status change note.
+		if ( in_array( 'status', $changed_props, true ) ) {
+			$old_status = $old_state['status'] ?? 'unfulfilled';
+			$new_status = $fulfillment->get_status() ?? 'unfulfilled';
 			$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 );

diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
index 9d7cffed2c7..5773bf432bf 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
@@ -360,7 +360,6 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
 				}
 			}
 			$fulfillment->save();
-			$fulfillment->save_meta_data();

 			if ( $notify_customer ) {
 				if ( ! $previous_state && $next_state ) {
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreHookTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreHookTest.php
index 2c76c8a518c..f33718ee7f6 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreHookTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreHookTest.php
@@ -217,19 +217,26 @@ class FulfillmentsDataStoreHookTest extends WC_Unit_Test_Case {
 		$fulfillment->save();
 		$this->assertNotNull( $fulfillment->get_id(), 'Fulfillment ID should not be null.' );

-		$hook_called = false;
+		$hook_called          = false;
+		$received_fulfillment = null;
+		$received_changed     = null;
+		$received_old_state   = null;
 		add_action(
 			'woocommerce_fulfillment_after_update',
-			function ( $fulfillment ) use ( &$hook_called, &$received_fulfillment ) {
+			function ( $fulfillment, $changed_props, $old_state ) use ( &$hook_called, &$received_fulfillment, &$received_changed, &$received_old_state ) {
 				$received_fulfillment = $fulfillment;
+				$received_changed     = $changed_props;
+				$received_old_state   = $old_state;
 				$hook_called          = true;
-				return $fulfillment;
 			},
 			10,
-			2
+			3
 		);

-		// Add a modification to the saved fulfillment, so we can see the difference.
+		$old_status = $fulfillment->get_status();
+
+		// Modify a tracked property so changed_props is populated.
+		$fulfillment->set_status( 'fulfilled' );
 		$fulfillment->add_meta_data( 'test_meta_update', 'test_meta_value' );

 		$this->store->update( $fulfillment );
@@ -245,6 +252,13 @@ class FulfillmentsDataStoreHookTest extends WC_Unit_Test_Case {
 		$this->assertEquals( $received_fulfillment->get_meta( 'test_meta_key_2' ), $fulfillment->get_meta( 'test_meta_key_2' ) );
 		$this->assertEquals( $received_fulfillment->get_meta( 'test_meta_update' ), $fulfillment->get_meta( 'test_meta_update' ) );
 		$this->assertEquals( $received_fulfillment->get_items(), $fulfillment->get_items() );
+
+		// Verify changed_props and old_state are passed correctly.
+		$this->assertIsArray( $received_changed );
+		$this->assertContains( 'status', $received_changed );
+		$this->assertIsArray( $received_old_state );
+		$this->assertArrayHasKey( 'status', $received_old_state );
+		$this->assertEquals( $old_status, $received_old_state['status'] );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php
index 305846f77ac..524e7d31373 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentOrderNotesTest.php
@@ -69,7 +69,6 @@ class FulfillmentOrderNotesTest extends \WC_Unit_Test_Case {
 	 */
 	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' ) );
 	}