Commit 7c970fd1177 for woocommerce

commit 7c970fd1177e98bfdd5a22e66a9cd7da44928bf5
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date:   Thu Nov 5 11:21:52 2026 +0000

    Preserve original order item metadata during refunds (#70229)

    * Fix refund item metadata modifying original order items

    * Add changelog entry for refund item metadata fix

diff --git a/plugins/woocommerce/changelog/33545-preserve-original-item-meta-on-refund b/plugins/woocommerce/changelog/33545-preserve-original-item-meta-on-refund
new file mode 100644
index 00000000000..e197d3feb5b
--- /dev/null
+++ b/plugins/woocommerce/changelog/33545-preserve-original-item-meta-on-refund
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve original order item metadata when refund item metadata is added, updated, or deleted.
diff --git a/plugins/woocommerce/includes/wc-order-functions.php b/plugins/woocommerce/includes/wc-order-functions.php
index 50fc95bef22..6da2d728f75 100644
--- a/plugins/woocommerce/includes/wc-order-functions.php
+++ b/plugins/woocommerce/includes/wc-order-functions.php
@@ -649,6 +649,12 @@ function wc_create_refund( $args = array() ) {
 				$class         = get_class( $item );
 				$refunded_item = new $class( $item );
 				$refunded_item->set_id( 0 );
+
+				// Copied metadata needs new IDs so refund changes cannot update or delete the original item's metadata.
+				foreach ( $refunded_item->get_meta_data() as $meta ) {
+					$meta->id = 0;
+				}
+
 				$refunded_item->add_meta_data( '_refunded_item_id', $item_id, true );
 				$refunded_item->set_total( wc_format_refund_total( $refund_total ) );
 				$refunded_item->set_taxes(
diff --git a/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
index d8d0961b026..281da934c66 100644
--- a/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
@@ -744,6 +744,123 @@ class WC_Order_Functions_Test extends \WC_Unit_Test_Case {
 		$customer->delete();
 	}

+	/**
+	 * @testdox Refunding an item copies its metadata without allowing refund hooks to change the original metadata.
+	 * @dataProvider data_provider_refund_item_metadata
+	 *
+	 * @param string $item_class Order item class.
+	 * @param string $operation  Metadata operation performed by the new order item hook.
+	 */
+	public function test_wc_create_refund_preserves_original_item_metadata( string $item_class, string $operation ): void {
+		$order = wc_create_order();
+		$item  = new $item_class();
+		$item->set_name( 'Refund metadata test' );
+		$item->set_total( 10 );
+		$item->add_meta_data( 'foo', 'bar', true );
+		$item->add_meta_data( 'duplicate', 'first' );
+		$item->add_meta_data( 'duplicate', 'second' );
+		$item->add_meta_data(
+			'structured',
+			array(
+				'nested' => array(
+					'zero'  => 0,
+					'empty' => '',
+				),
+			)
+		);
+		$order->add_item( $item );
+		$order->calculate_totals( false );
+		$item->read_meta_data( true );
+
+		$original_meta = array_map(
+			static function ( $meta ) {
+				return $meta->get_data();
+			},
+			$item->get_meta_data()
+		);
+		$callback      = static function ( $item_id, $new_item ) use ( $operation ) {
+			switch ( $operation ) {
+				case 'add':
+					$new_item->add_meta_data( 'foo', 'refund value', true );
+					break;
+				case 'update':
+					$new_item->update_meta_data( 'foo', 'refund value' );
+					break;
+				case 'delete':
+					$new_item->delete_meta_data( 'foo' );
+					break;
+			}
+			$new_item->save_meta_data();
+		};
+
+		add_action( 'woocommerce_new_order_item', $callback, 10, 2 );
+		try {
+			$refund = wc_create_refund(
+				array(
+					'order_id'   => $order->get_id(),
+					'amount'     => 10,
+					'line_items' => array(
+						$item->get_id() => array(
+							'qty'          => 1,
+							'refund_total' => 10,
+						),
+					),
+				)
+			);
+		} finally {
+			remove_action( 'woocommerce_new_order_item', $callback, 10 );
+		}
+
+		$this->assertNotWPError( $refund, 'The refund should be created successfully.' );
+		$item->read_meta_data( true );
+		$this->assertSame(
+			$original_meta,
+			array_map(
+				static function ( $meta ) {
+					return $meta->get_data();
+				},
+				$item->get_meta_data()
+			),
+			'The original metadata IDs, keys, and values should remain unchanged in the database.'
+		);
+
+		$refund_items = $refund->get_items( $item->get_type() );
+		$this->assertCount( 1, $refund_items, 'There should be one refunded item.' );
+		$refund_item = reset( $refund_items );
+		$refund_item->read_meta_data( true );
+		$this->assertSame( $item->get_id(), (int) $refund_item->get_meta( '_refunded_item_id' ), 'The refund should reference the original item.' );
+		$this->assertSame(
+			'copy' === $operation ? 'bar' : ( 'delete' === $operation ? '' : 'refund value' ),
+			$refund_item->get_meta( 'foo' ),
+			'The refund metadata should reflect the hook operation.'
+		);
+		$this->assertSame(
+			array( 'first', 'second' ),
+			array_values( wp_list_pluck( $refund_item->get_meta( 'duplicate', false ), 'value' ) ),
+			'Copying metadata should preserve repeated keys.'
+		);
+		$this->assertSame( $item->get_meta( 'structured' ), $refund_item->get_meta( 'structured' ), 'Structured metadata should be copied unchanged.' );
+		$this->assertEmpty(
+			array_intersect( wp_list_pluck( $original_meta, 'id' ), wp_list_pluck( $refund_item->get_meta_data(), 'id' ) ),
+			'The refund metadata should have its own database IDs.'
+		);
+	}
+
+	/**
+	 * Provide refundable item types and metadata operations.
+	 *
+	 * @return array
+	 */
+	public static function data_provider_refund_item_metadata(): array {
+		$cases = array();
+		foreach ( array( WC_Order_Item_Product::class, WC_Order_Item_Fee::class, WC_Order_Item_Shipping::class ) as $item_class ) {
+			foreach ( array( 'copy', 'add', 'update', 'delete' ) as $operation ) {
+				$cases[ $item_class . '-' . $operation ] = array( $item_class, $operation );
+			}
+		}
+		return $cases;
+	}
+
 	/**
 	 * @testdox Should record the logged-in user who issued the refund.
 	 */