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.
*/