Commit a81c3b3dbd for woocommerce

commit a81c3b3dbdf6ad32d9cf8c1d6f7c21d80382c274
Author: Sam Najian <dev@najian.info>
Date:   Thu Jan 8 17:36:50 2026 +0100

    Fix issue with old/legacy label data breaking throwing exception (#62352)

    * Fix issue with old label data breaking throwing exception

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

    * Improve logging and remove duplicate order access

    * Improve logging

    * Support case where order is removed or not found

    * Move convert_legacy_tax_value_to_array to WC_Order_Item

    * Apply the fix to WC_Order_Item_Shipping and WC_Order_Item_Fee

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

    * Address PHPCS warning

    * Remove redundant phpstan ignores

    * Unify defaulting for the order is not found

    ---------

    Co-authored-by: github-actions <github-actions@github.com>

diff --git a/plugins/woocommerce/changelog/62352-wooplug-5270-array-map-issue-on-older-orders b/plugins/woocommerce/changelog/62352-wooplug-5270-array-map-issue-on-older-orders
new file mode 100644
index 0000000000..51cb143850
--- /dev/null
+++ b/plugins/woocommerce/changelog/62352-wooplug-5270-array-map-issue-on-older-orders
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix fatal error when viewing old orders with legacy tax data format in order item metadata. The fix preserves tax values and attempts to infer the tax rate ID from order context for better backwards compatibility. Applies to product, shipping, and fee order items.
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/class-wc-order-item-fee.php b/plugins/woocommerce/includes/class-wc-order-item-fee.php
index 359f7e76df..a49f5de03c 100644
--- a/plugins/woocommerce/includes/class-wc-order-item-fee.php
+++ b/plugins/woocommerce/includes/class-wc-order-item-fee.php
@@ -187,6 +187,8 @@ class WC_Order_Item_Fee extends WC_Order_Item {
 	 *
 	 * This is an array of tax ID keys with total amount values.
 	 *
+	 * @since 10.5.0 Handles legacy scalar tax values by converting to arrays.
+	 *
 	 * @param array $raw_tax_data Raw tax data.
 	 */
 	public function set_taxes( $raw_tax_data ) {
@@ -195,7 +197,29 @@ class WC_Order_Item_Fee extends WC_Order_Item {
 			'total' => array(),
 		);
 		if ( ! empty( $raw_tax_data['total'] ) ) {
-			$tax_data['total'] = array_map( 'wc_format_decimal', $raw_tax_data['total'] );
+			$total = $raw_tax_data['total'];
+
+			// Handle legacy data where total might be a float/string instead of an array.
+			if ( ! is_array( $total ) ) {
+				$order = $this->get_order();
+				$total = $this->convert_legacy_tax_value_to_array( $total, $order );
+
+				// Log legacy data format for debugging purposes.
+				wc_get_logger()->warning(
+					sprintf(
+						/* translators: %d: order item ID */
+						__( 'Order item #%d contains legacy tax data format. Tax rate ID information is unavailable.', 'woocommerce' ),
+						$this->get_id()
+					),
+					array(
+						'source'        => 'woocommerce-order-item-fee',
+						'order_item_id' => $this->get_id(),
+						'order_id'      => $order ? $order->get_id() : 0,
+					)
+				);
+			}
+
+			$tax_data['total'] = array_map( 'wc_format_decimal', $total );
 		}
 		$this->set_prop( 'taxes', $tax_data );

diff --git a/plugins/woocommerce/includes/class-wc-order-item-product.php b/plugins/woocommerce/includes/class-wc-order-item-product.php
index e8c319b37f..4de34de8e3 100644
--- a/plugins/woocommerce/includes/class-wc-order-item-product.php
+++ b/plugins/woocommerce/includes/class-wc-order-item-product.php
@@ -168,7 +168,11 @@ class WC_Order_Item_Product extends WC_Order_Item {
 	/**
 	 * Set line taxes and totals for passed in taxes.
 	 *
-	 * @param array $raw_tax_data Raw tax data.
+	 * @since 10.5.0 Handles legacy scalar tax values by converting to arrays.
+	 * When legacy data is detected, attempts to infer tax rate ID from order context.
+	 *
+	 * @param array $raw_tax_data Raw tax data. 'total' and 'subtotal' should be arrays keyed by tax rate ID,
+	 * but scalar values (floats/strings) are accepted for legacy compatibility.
 	 */
 	public function set_taxes( $raw_tax_data ) {
 		$raw_tax_data = maybe_unserialize( $raw_tax_data );
@@ -177,8 +181,38 @@ class WC_Order_Item_Product extends WC_Order_Item {
 			'subtotal' => array(),
 		);
 		if ( ! empty( $raw_tax_data['total'] ) && ! empty( $raw_tax_data['subtotal'] ) ) {
-			$tax_data['subtotal'] = array_map( 'wc_format_decimal', $raw_tax_data['subtotal'] );
-			$tax_data['total']    = array_map( 'wc_format_decimal', $raw_tax_data['total'] );
+			$subtotal = $raw_tax_data['subtotal'];
+			$total    = $raw_tax_data['total'];
+
+			// Handle legacy data where total/subtotal might be floats/strings instead of arrays.
+			// Convert scalar values to array format to preserve the tax amount.
+			$has_legacy_data = ! is_array( $subtotal ) || ! is_array( $total );
+
+			if ( $has_legacy_data ) {
+				$order = $this->get_order();
+				if ( ! is_array( $subtotal ) ) {
+					$subtotal = $this->convert_legacy_tax_value_to_array( $subtotal, $order );
+				}
+				if ( ! is_array( $total ) ) {
+					$total = $this->convert_legacy_tax_value_to_array( $total, $order );
+				}
+				// Log legacy data format for debugging purposes.
+				wc_get_logger()->warning(
+					sprintf(
+						/* translators: %d: order item ID */
+						__( 'Order item #%d contains legacy tax data format. Tax rate ID information is unavailable.', 'woocommerce' ),
+						$this->get_id()
+					),
+					array(
+						'source'        => 'woocommerce-order-item-product',
+						'order_item_id' => $this->get_id(),
+						'order_id'      => $order ? $order->get_id() : 0,
+					)
+				);
+			}
+
+			$tax_data['subtotal'] = array_map( 'wc_format_decimal', $subtotal );
+			$tax_data['total']    = array_map( 'wc_format_decimal', $total );

 			// Subtotal cannot be less than total!
 			if ( NumberUtil::array_sum( $tax_data['subtotal'] ) < NumberUtil::array_sum( $tax_data['total'] ) ) {
@@ -433,7 +467,7 @@ class WC_Order_Item_Product extends WC_Order_Item {
 			 * @since 2.7.0
 			 *
 			 * @param array                 $files Array of downloadable file data.
-			 * @param WC_Order_Item_Product $this  The order item product object.
+			 * @param WC_Order_Item_Product $item  The order item product object.
 			 * @param WC_Order              $order The order object.
 			 */
 			return apply_filters( 'woocommerce_get_item_downloads', $files, $this, $order );
diff --git a/plugins/woocommerce/includes/class-wc-order-item-shipping.php b/plugins/woocommerce/includes/class-wc-order-item-shipping.php
index cc00531933..f3ddadf2e6 100644
--- a/plugins/woocommerce/includes/class-wc-order-item-shipping.php
+++ b/plugins/woocommerce/includes/class-wc-order-item-shipping.php
@@ -131,6 +131,8 @@ class WC_Order_Item_Shipping extends WC_Order_Item {
 	 *
 	 * This is an array of tax ID keys with total amount values.
 	 *
+	 * @since 10.5.0 Handles legacy scalar tax values by converting to arrays.
+	 *
 	 * @param array $raw_tax_data Value to set.
 	 * @throws WC_Data_Exception May throw exception if data is invalid.
 	 */
@@ -140,7 +142,29 @@ class WC_Order_Item_Shipping extends WC_Order_Item {
 			'total' => array(),
 		);
 		if ( isset( $raw_tax_data['total'] ) ) {
-			$tax_data['total'] = array_map( 'wc_format_decimal', $raw_tax_data['total'] );
+			$total = $raw_tax_data['total'];
+
+			// Handle legacy data where total might be a float/string instead of an array.
+			if ( ! is_array( $total ) ) {
+				$order = $this->get_order();
+				$total = $this->convert_legacy_tax_value_to_array( $total, $order );
+
+				// Log legacy data format for debugging purposes.
+				wc_get_logger()->warning(
+					sprintf(
+						/* translators: %d: order item ID */
+						__( 'Order item #%d contains legacy tax data format. Tax rate ID information is unavailable.', 'woocommerce' ),
+						$this->get_id()
+					),
+					array(
+						'source'        => 'woocommerce-order-item-shipping',
+						'order_item_id' => $this->get_id(),
+						'order_id'      => $order ? $order->get_id() : 0,
+					)
+				);
+			}
+
+			$tax_data['total'] = array_map( 'wc_format_decimal', $total );
 		} elseif ( ! empty( $raw_tax_data ) && is_array( $raw_tax_data ) ) {
 			// Older versions just used an array.
 			$tax_data['total'] = array_map( 'wc_format_decimal', $raw_tax_data );
diff --git a/plugins/woocommerce/includes/class-wc-order-item.php b/plugins/woocommerce/includes/class-wc-order-item.php
index 0bb2f524d7..7080cb2466 100644
--- a/plugins/woocommerce/includes/class-wc-order-item.php
+++ b/plugins/woocommerce/includes/class-wc-order-item.php
@@ -664,4 +664,47 @@ class WC_Order_Item extends WC_Data implements ArrayAccess {
 		 */
 		return apply_filters( 'woocommerce_order_item_cogs_refunded_html', $html, $refunded_cost, $this, $order );
 	}
+
+	/**
+	 * Convert a legacy scalar tax value to array format.
+	 *
+	 * Legacy orders may have tax data stored as floats/strings
+	 * instead of arrays keyed by tax rate ID. This method attempts to infer the
+	 * appropriate tax rate ID from the order context.
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param float|string   $value The legacy scalar tax value.
+	 * @param WC_Order|false $order The order object, or false/null if unavailable.
+	 * @return array Tax data as array, keyed by tax rate ID (or 0 if unknown).
+	 */
+	protected function convert_legacy_tax_value_to_array( $value, $order = null ) {
+		$rate_id = 0;
+
+		// Try to infer tax rate ID from order context.
+		$tax_items = $order ? $order->get_taxes() : array();
+		if ( ! empty( $tax_items ) ) {
+			// Use the first tax rate ID from the order as a best-effort match.
+			$first_tax_item = reset( $tax_items );
+			if ( $first_tax_item ) {
+				$rate_id = $first_tax_item->get_rate_id();
+			}
+		}
+
+		$converted = array( $rate_id => $value );
+
+		/**
+		 * Filter the converted legacy tax value.
+		 *
+		 * Allows plugins to customize how legacy scalar tax values are converted
+		 * to the expected array format.
+		 *
+		 * @since 10.5.0
+		 *
+		 * @param array        $converted The converted tax data array.
+		 * @param float|string $value     The original legacy scalar value.
+		 * @param WC_Order_Item $item     The order item being processed.
+		 */
+		return apply_filters( 'woocommerce_order_item_legacy_tax_conversion', $converted, $value, $this );
+	}
 }
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 81813ff2f6..d810d08cae 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -16038,12 +16038,6 @@ parameters:
 			count: 1
 			path: includes/class-wc-order-item-meta.php

-		-
-			message: '#^@param tag must not be named \$this\. Choose a descriptive alias, for example \$instance\.$#'
-			identifier: phpDoc.parseError
-			count: 1
-			path: includes/class-wc-order-item-product.php
-
 		-
 			message: '#^Call to an undefined method WC_Data_Store\:\:get_downloads\(\)\.$#'
 			identifier: method.notFound
@@ -16182,12 +16176,6 @@ parameters:
 			count: 1
 			path: includes/class-wc-order-item-product.php

-		-
-			message: '#^PHPDoc tag @param has invalid value \(WC_Order_Item_Product \$this  The order item product object\.\)\: Unexpected token "\$this", expected variable at offset 209 on line 7$#'
-			identifier: phpDoc.parseError
-			count: 1
-			path: includes/class-wc-order-item-product.php
-
 		-
 			message: '#^Parameter \#1 \$callback of function array_map expects \(callable\(string\)\: mixed\)\|null, ''wc_round_tax_total'' given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-order-item-fee-test.php b/plugins/woocommerce/tests/php/includes/class-wc-order-item-fee-test.php
new file mode 100644
index 0000000000..0f9ae4d4cd
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/class-wc-order-item-fee-test.php
@@ -0,0 +1,299 @@
+<?php
+/**
+ * Unit tests for the WC_Order_Item_Fee class functionalities.
+ *
+ * @package WooCommerce\Tests
+ */
+
+declare( strict_types=1 );
+
+/**
+ * WC_Order_Item_Fee unit tests.
+ */
+class WC_Order_Item_Fee_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox set_taxes handles valid array format correctly.
+	 */
+	public function test_set_taxes_with_valid_array_format() {
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$item = new WC_Order_Item_Fee();
+		$item->set_order_id( $order->get_id() );
+
+		$valid_tax_data = array(
+			'total' => array(
+				1 => '24.00',
+				2 => '12.00',
+			),
+		);
+
+		$item->set_taxes( $valid_tax_data );
+		$taxes = $item->get_taxes();
+
+		$this->assertIsArray( $taxes );
+		$this->assertArrayHasKey( 'total', $taxes );
+		$this->assertIsArray( $taxes['total'] );
+		$this->assertEquals( '24.00', $taxes['total'][1] );
+		$this->assertEquals( '12.00', $taxes['total'][2] );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes handles serialized array format correctly.
+	 */
+	public function test_set_taxes_with_serialized_array_format() {
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$item = new WC_Order_Item_Fee();
+		$item->set_order_id( $order->get_id() );
+
+		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Testing legacy serialized data format.
+		$serialized_tax_data = serialize(
+			array(
+				'total' => array( 1 => '24.00' ),
+			)
+		);
+
+		$item->set_taxes( $serialized_tax_data );
+		$taxes = $item->get_taxes();
+
+		$this->assertIsArray( $taxes );
+		$this->assertEquals( '24.00', $taxes['total'][1] );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes handles legacy float values for total without fatal error.
+	 *
+	 * This test reproduces the issue where old orders may have tax data stored
+	 * as floats instead of arrays, causing:
+	 * "TypeError: array_map(): Argument #2 ($array) must be of type array, float given"
+	 *
+	 * @see https://github.com/woocommerce/woocommerce/issues/60233
+	 */
+	public function test_set_taxes_with_legacy_float_values_does_not_throw_error() {
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$item = new WC_Order_Item_Fee();
+		$item->set_order_id( $order->get_id() );
+
+		// Legacy/corrupted tax data: float instead of array.
+		// This format may exist in old orders due to data corruption or legacy data formats.
+		$legacy_tax_data = array(
+			'total' => 24.00,    // Should be array( rate_id => amount ).
+		);
+
+		// This should NOT throw a TypeError.
+		$item->set_taxes( $legacy_tax_data );
+
+		// Taxes should be converted to arrays and values preserved.
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertIsArray( $taxes['total'] );
+		$this->assertCount( 1, $taxes['total'] );
+		// The tax value should be preserved (converted from float to array).
+		// wc_format_decimal() may strip trailing zeros, so we compare as floats.
+		// Use reset() to get first value regardless of key (rate_id may be inferred from order context).
+		$this->assertEquals( 24.00, (float) reset( $taxes['total'] ) );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes handles serialized legacy float values without fatal error.
+	 *
+	 * Simulates the exact scenario from the production error where tax data
+	 * metadata contains serialized floats instead of arrays.
+	 */
+	public function test_set_taxes_with_serialized_legacy_float_values_does_not_throw_error() {
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$item = new WC_Order_Item_Fee();
+		$item->set_order_id( $order->get_id() );
+
+		// Serialized legacy data with float (as stored in wp_woocommerce_order_itemmeta).
+		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Testing legacy serialized data format.
+		$serialized_legacy_data = serialize(
+			array(
+				'total' => 144.00,
+			)
+		);
+
+		// This should NOT throw a TypeError.
+		$item->set_taxes( $serialized_legacy_data );
+
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertIsArray( $taxes['total'] );
+		$this->assertCount( 1, $taxes['total'] );
+		// The tax value should be preserved (converted from float to array).
+		// wc_format_decimal() may strip trailing zeros, so we compare as floats.
+		// Use reset() to get first value regardless of key (rate_id may be inferred from order context).
+		$this->assertEquals( 144.00, (float) reset( $taxes['total'] ) );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes handles string values for total without fatal error.
+	 */
+	public function test_set_taxes_with_string_values_does_not_throw_error() {
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$item = new WC_Order_Item_Fee();
+		$item->set_order_id( $order->get_id() );
+
+		// String value instead of array.
+		$string_tax_data = array(
+			'total' => '24.00',
+		);
+
+		// This should NOT throw a TypeError.
+		$item->set_taxes( $string_tax_data );
+
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertIsArray( $taxes['total'] );
+		$this->assertCount( 1, $taxes['total'] );
+		// String values converted to arrays and preserved.
+		// wc_format_decimal() may strip trailing zeros, so we compare as floats.
+		// Use reset() to get first value regardless of key (rate_id may be inferred from order context).
+		$this->assertEquals( 24.00, (float) reset( $taxes['total'] ) );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes infers tax rate ID from order context when handling legacy data.
+	 */
+	public function test_set_taxes_with_legacy_data_infers_rate_id_from_order() {
+		// Create a real order with a tax item.
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		// Add a tax item to the order to provide context.
+		$tax_item = new WC_Order_Item_Tax();
+		$tax_item->set_rate_id( 42 );
+		$tax_item->set_name( 'Test Tax' );
+		$tax_item->set_tax_total( 10 );
+		$order->add_item( $tax_item );
+		$order->save();
+
+		// Create a fee item associated with this order.
+		$item = new WC_Order_Item_Fee();
+		$item->set_order_id( $order->get_id() );
+
+		// Legacy tax data as float.
+		$legacy_tax_data = array(
+			'total' => 24.00,
+		);
+
+		$item->set_taxes( $legacy_tax_data );
+
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertIsArray( $taxes['total'] );
+
+		// The rate_id should be inferred from the order's tax item.
+		// Key should be 42 (the rate_id from the tax item we added).
+		$this->assertArrayHasKey( 42, $taxes['total'] );
+		$this->assertEquals( 24.00, (float) $taxes['total'][42] );
+
+		// Clean up.
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes uses rate_id 0 when order is null or false for legacy data.
+	 */
+	public function test_set_taxes_with_legacy_data_uses_rate_id_zero_when_order_is_null() {
+		// Create an item NOT associated with any order (get_order() will return false).
+		$item = new WC_Order_Item_Fee();
+
+		// Legacy tax data as float.
+		$legacy_tax_data = array(
+			'total' => 24.00,
+		);
+
+		// This should NOT throw an error even though order is null/false.
+		$item->set_taxes( $legacy_tax_data );
+
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertIsArray( $taxes['total'] );
+
+		// Without order context, the rate_id should default to 0.
+		$this->assertArrayHasKey( 0, $taxes['total'] );
+		$this->assertEquals( 24.00, (float) $taxes['total'][0] );
+	}
+
+	/**
+	 * @testdox set_taxes filter allows plugins to customize legacy tax conversion.
+	 */
+	public function test_set_taxes_legacy_conversion_filter() {
+		// Create an order - the filter is only called when an order context exists.
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$item = new WC_Order_Item_Fee();
+		$item->set_order_id( $order->get_id() );
+
+		// Add a filter to customize the conversion.
+		$filter_callback = function ( $converted, $value ) {
+			// Custom rate ID mapping.
+			unset( $converted );
+			return array( 999 => $value );
+		};
+		add_filter( 'woocommerce_order_item_legacy_tax_conversion', $filter_callback, 10, 2 );
+
+		// Legacy tax data as float.
+		$legacy_tax_data = array(
+			'total' => 50.00,
+		);
+
+		$item->set_taxes( $legacy_tax_data );
+
+		$taxes = $item->get_taxes();
+
+		// The filter should have used rate ID 999.
+		$this->assertArrayHasKey( 999, $taxes['total'] );
+		$this->assertEquals( 50.00, (float) $taxes['total'][999] );
+
+		// Clean up filter.
+		remove_filter( 'woocommerce_order_item_legacy_tax_conversion', $filter_callback );
+
+		// Clean up order.
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes handles empty/null values gracefully.
+	 */
+	public function test_set_taxes_with_empty_values() {
+		$item = new WC_Order_Item_Fee();
+
+		// Empty tax data.
+		$item->set_taxes( array() );
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertEmpty( $taxes['total'] );
+
+		// Null value.
+		$item->set_taxes( null );
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+
+		// False value.
+		$item->set_taxes( false );
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-order-item-product-test.php b/plugins/woocommerce/tests/php/includes/class-wc-order-item-product-test.php
index 14565fc974..fcfc489c91 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-order-item-product-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-order-item-product-test.php
@@ -288,4 +288,338 @@ class WC_Order_Item_Product_Test extends WC_Unit_Test_Case {
 			$this->assertEquals( $invalid_id, $error_data['variation_id'] );
 		}
 	}
+
+	/**
+	 * @testdox set_taxes handles proper array format correctly.
+	 */
+	public function test_set_taxes_with_valid_array_format() {
+		$item = new WC_Order_Item_Product();
+
+		// Valid tax data format: arrays keyed by tax rate ID.
+		$valid_tax_data = array(
+			'total'    => array(
+				1 => '24.00',
+				2 => '12.00',
+			),
+			'subtotal' => array(
+				1 => '24.00',
+				2 => '12.00',
+			),
+		);
+
+		$item->set_taxes( $valid_tax_data );
+		$taxes = $item->get_taxes();
+
+		$this->assertIsArray( $taxes );
+		$this->assertArrayHasKey( 'total', $taxes );
+		$this->assertArrayHasKey( 'subtotal', $taxes );
+		$this->assertEquals( '24.00', $taxes['total'][1] );
+		$this->assertEquals( '12.00', $taxes['total'][2] );
+	}
+
+	/**
+	 * @testdox set_taxes handles serialized array format correctly.
+	 */
+	public function test_set_taxes_with_serialized_array_format() {
+		$item = new WC_Order_Item_Product();
+
+		// Serialized tax data (as it would be stored in database).
+		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Testing legacy serialized data format.
+		$serialized_tax_data = serialize(
+			array(
+				'total'    => array( 1 => '24.00' ),
+				'subtotal' => array( 1 => '24.00' ),
+			)
+		);
+
+		$item->set_taxes( $serialized_tax_data );
+		$taxes = $item->get_taxes();
+
+		$this->assertIsArray( $taxes );
+		$this->assertEquals( '24.00', $taxes['total'][1] );
+	}
+
+	/**
+	 * @testdox set_taxes handles legacy float values for total/subtotal without fatal error.
+	 *
+	 * This test reproduces the issue from order #20924 (June 2021) where old orders
+	 * may have tax data stored as floats instead of arrays, causing:
+	 * "TypeError: array_map(): Argument #2 ($array) must be of type array, float given"
+	 *
+	 * @see https://github.com/woocommerce/woocommerce/issues/60233
+	 */
+	public function test_set_taxes_with_legacy_float_values_does_not_throw_error() {
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$item = new WC_Order_Item_Product();
+		$item->set_order_id( $order->get_id() );
+
+		// Legacy/corrupted tax data: floats instead of arrays.
+		// This format may exist in old orders due to data corruption or legacy data formats.
+		$legacy_tax_data = array(
+			'total'    => 24.00,    // Should be array( rate_id => amount ).
+			'subtotal' => 24.00,    // Should be array( rate_id => amount ).
+		);
+
+		// This should NOT throw a TypeError.
+		$item->set_taxes( $legacy_tax_data );
+
+		// Taxes should be converted to arrays and values preserved.
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertIsArray( $taxes['total'] );
+		$this->assertIsArray( $taxes['subtotal'] );
+		$this->assertCount( 1, $taxes['total'] );
+		$this->assertCount( 1, $taxes['subtotal'] );
+		// The tax value should be preserved (converted from float to array).
+		// wc_format_decimal() may strip trailing zeros, so we compare as floats.
+		// Use reset() to get first value regardless of key (rate_id may be inferred from order context).
+		$this->assertEquals( 24.00, (float) reset( $taxes['total'] ) );
+		$this->assertEquals( 24.00, (float) reset( $taxes['subtotal'] ) );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes handles serialized legacy float values without fatal error.
+	 *
+	 * Simulates the exact scenario from the production error where _line_tax_data
+	 * metadata contains serialized floats instead of arrays.
+	 */
+	public function test_set_taxes_with_serialized_legacy_float_values_does_not_throw_error() {
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$item = new WC_Order_Item_Product();
+		$item->set_order_id( $order->get_id() );
+
+		// Serialized legacy data with floats (as stored in wp_woocommerce_order_itemmeta).
+		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Testing legacy serialized data format.
+		$serialized_legacy_data = serialize(
+			array(
+				'total'    => 144.00,
+				'subtotal' => 144.00,
+			)
+		);
+
+		// This should NOT throw a TypeError.
+		$item->set_taxes( $serialized_legacy_data );
+
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertIsArray( $taxes['total'] );
+		$this->assertIsArray( $taxes['subtotal'] );
+		$this->assertCount( 1, $taxes['total'] );
+		$this->assertCount( 1, $taxes['subtotal'] );
+		// The tax value should be preserved (converted from float to array).
+		// wc_format_decimal() may strip trailing zeros, so we compare as floats.
+		// Use reset() to get first value regardless of key (rate_id may be inferred from order context).
+		$this->assertEquals( 144.00, (float) reset( $taxes['total'] ) );
+		$this->assertEquals( 144.00, (float) reset( $taxes['subtotal'] ) );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes handles mixed format (one array, one float) without fatal error.
+	 */
+	public function test_set_taxes_with_mixed_array_and_float_does_not_throw_error() {
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$item = new WC_Order_Item_Product();
+		$item->set_order_id( $order->get_id() );
+
+		// Mixed format: subtotal is array, total is float.
+		$mixed_tax_data = array(
+			'total'    => 24.00,                        // Float.
+			'subtotal' => array( 1 => '24.00' ),        // Array.
+		);
+
+		// This should NOT throw a TypeError.
+		$item->set_taxes( $mixed_tax_data );
+
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertIsArray( $taxes['total'] );
+		$this->assertIsArray( $taxes['subtotal'] );
+		$this->assertCount( 1, $taxes['total'] );
+		$this->assertCount( 1, $taxes['subtotal'] );
+		// Float total converted to array, array subtotal preserves key.
+		// wc_format_decimal() may strip trailing zeros, so we compare as floats.
+		// Use reset() to get first value regardless of key (rate_id may be inferred from order context).
+		$this->assertEquals( 24.00, (float) reset( $taxes['total'] ) );
+		// Subtotal was already an array with key 1, so it should preserve that key.
+		$this->assertEquals( 24.00, (float) $taxes['subtotal'][1] );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes handles empty/null values gracefully.
+	 */
+	public function test_set_taxes_with_empty_values() {
+		$item = new WC_Order_Item_Product();
+
+		// Empty tax data.
+		$item->set_taxes( array() );
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertEmpty( $taxes['total'] );
+		$this->assertEmpty( $taxes['subtotal'] );
+
+		// Null value.
+		$item->set_taxes( null );
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+
+		// False value.
+		$item->set_taxes( false );
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+	}
+
+	/**
+	 * @testdox set_taxes handles string values for total/subtotal without fatal error.
+	 */
+	public function test_set_taxes_with_string_values_does_not_throw_error() {
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$item = new WC_Order_Item_Product();
+		$item->set_order_id( $order->get_id() );
+
+		// String values instead of arrays.
+		$string_tax_data = array(
+			'total'    => '24.00',
+			'subtotal' => '24.00',
+		);
+
+		// This should NOT throw a TypeError.
+		$item->set_taxes( $string_tax_data );
+
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertIsArray( $taxes['total'] );
+		$this->assertIsArray( $taxes['subtotal'] );
+		$this->assertCount( 1, $taxes['total'] );
+		$this->assertCount( 1, $taxes['subtotal'] );
+		// String values converted to arrays and preserved.
+		// wc_format_decimal() may strip trailing zeros, so we compare as floats.
+		// Use reset() to get first value regardless of key (rate_id may be inferred from order context).
+		$this->assertEquals( 24.00, (float) reset( $taxes['total'] ) );
+		$this->assertEquals( 24.00, (float) reset( $taxes['subtotal'] ) );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes infers tax rate ID from order context when handling legacy data.
+	 */
+	public function test_set_taxes_with_legacy_data_infers_rate_id_from_order() {
+		// Create a real order with a tax item.
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		// Add a tax item to the order to provide context.
+		$tax_item = new WC_Order_Item_Tax();
+		$tax_item->set_rate_id( 42 );
+		$tax_item->set_name( 'Test Tax' );
+		$tax_item->set_tax_total( 10 );
+		$order->add_item( $tax_item );
+		$order->save();
+
+		// Create a product item associated with this order.
+		$item = new WC_Order_Item_Product();
+		$item->set_order_id( $order->get_id() );
+
+		// Legacy tax data as float.
+		$legacy_tax_data = array(
+			'total'    => 24.00,
+			'subtotal' => 24.00,
+		);
+
+		$item->set_taxes( $legacy_tax_data );
+
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertIsArray( $taxes['total'] );
+
+		// The rate_id should be inferred from the order's tax item.
+		// Key should be 42 (the rate_id from the tax item we added).
+		$this->assertArrayHasKey( 42, $taxes['total'] );
+		$this->assertEquals( 24.00, (float) $taxes['total'][42] );
+
+		// Clean up.
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes uses rate_id 0 when order is null or false for legacy data.
+	 */
+	public function test_set_taxes_with_legacy_data_uses_rate_id_zero_when_order_is_null() {
+		// Create an item NOT associated with any order (get_order() will return false).
+		$item = new WC_Order_Item_Product();
+
+		// Legacy tax data as float.
+		$legacy_tax_data = array(
+			'total'    => 24.00,
+			'subtotal' => 24.00,
+		);
+
+		// This should NOT throw an error even though order is null/false.
+		$item->set_taxes( $legacy_tax_data );
+
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertIsArray( $taxes['total'] );
+		$this->assertIsArray( $taxes['subtotal'] );
+
+		// Without order context, the rate_id should default to 0.
+		$this->assertArrayHasKey( 0, $taxes['total'] );
+		$this->assertArrayHasKey( 0, $taxes['subtotal'] );
+		$this->assertEquals( 24.00, (float) $taxes['total'][0] );
+		$this->assertEquals( 24.00, (float) $taxes['subtotal'][0] );
+	}
+
+	/**
+	 * @testdox set_taxes filter allows plugins to customize legacy tax conversion.
+	 */
+	public function test_set_taxes_legacy_conversion_filter() {
+		// Create an order - the filter is only called when an order context exists.
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$item = new WC_Order_Item_Product();
+		$item->set_order_id( $order->get_id() );
+
+		// Add a filter to customize the conversion.
+		$filter_callback = function ( $converted, $value ) {
+			// Custom rate ID mapping - we only need $value to create our custom conversion.
+			unset( $converted );
+			return array( 999 => $value );
+		};
+		add_filter( 'woocommerce_order_item_legacy_tax_conversion', $filter_callback, 10, 2 );
+
+		// Legacy tax data as float.
+		$legacy_tax_data = array(
+			'total'    => 50.00,
+			'subtotal' => 50.00,
+		);
+
+		$item->set_taxes( $legacy_tax_data );
+
+		$taxes = $item->get_taxes();
+
+		// The filter should have used rate ID 999.
+		$this->assertArrayHasKey( 999, $taxes['total'] );
+		$this->assertEquals( 50.00, (float) $taxes['total'][999] );
+
+		// Clean up filter.
+		remove_filter( 'woocommerce_order_item_legacy_tax_conversion', $filter_callback );
+
+		// Clean up order.
+		$order->delete( true );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-order-item-shipping-test.php b/plugins/woocommerce/tests/php/includes/class-wc-order-item-shipping-test.php
new file mode 100644
index 0000000000..94fd035ca7
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/class-wc-order-item-shipping-test.php
@@ -0,0 +1,326 @@
+<?php
+/**
+ * Unit tests for the WC_Order_Item_Shipping class functionalities.
+ *
+ * @package WooCommerce\Tests
+ */
+
+declare( strict_types=1 );
+
+/**
+ * WC_Order_Item_Shipping unit tests.
+ */
+class WC_Order_Item_Shipping_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox set_taxes handles valid array format correctly.
+	 */
+	public function test_set_taxes_with_valid_array_format() {
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$item = new WC_Order_Item_Shipping();
+		$item->set_order_id( $order->get_id() );
+
+		$valid_tax_data = array(
+			'total' => array(
+				1 => '24.00',
+				2 => '12.00',
+			),
+		);
+
+		$item->set_taxes( $valid_tax_data );
+		$taxes = $item->get_taxes();
+
+		$this->assertIsArray( $taxes );
+		$this->assertArrayHasKey( 'total', $taxes );
+		$this->assertIsArray( $taxes['total'] );
+		$this->assertEquals( '24.00', $taxes['total'][1] );
+		$this->assertEquals( '12.00', $taxes['total'][2] );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes handles serialized array format correctly.
+	 */
+	public function test_set_taxes_with_serialized_array_format() {
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$item = new WC_Order_Item_Shipping();
+		$item->set_order_id( $order->get_id() );
+
+		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Testing legacy serialized data format.
+		$serialized_tax_data = serialize(
+			array(
+				'total' => array( 1 => '24.00' ),
+			)
+		);
+
+		$item->set_taxes( $serialized_tax_data );
+		$taxes = $item->get_taxes();
+
+		$this->assertIsArray( $taxes );
+		$this->assertEquals( '24.00', $taxes['total'][1] );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes handles legacy float values for total without fatal error.
+	 *
+	 * This test reproduces the issue where old orders may have tax data stored
+	 * as floats instead of arrays, causing:
+	 * "TypeError: array_map(): Argument #2 ($array) must be of type array, float given"
+	 *
+	 * @see https://github.com/woocommerce/woocommerce/issues/60233
+	 */
+	public function test_set_taxes_with_legacy_float_values_does_not_throw_error() {
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$item = new WC_Order_Item_Shipping();
+		$item->set_order_id( $order->get_id() );
+
+		// Legacy/corrupted tax data: float instead of array.
+		// This format may exist in old orders due to data corruption or legacy data formats.
+		$legacy_tax_data = array(
+			'total' => 24.00,    // Should be array( rate_id => amount ).
+		);
+
+		// This should NOT throw a TypeError.
+		$item->set_taxes( $legacy_tax_data );
+
+		// Taxes should be converted to arrays and values preserved.
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertIsArray( $taxes['total'] );
+		$this->assertCount( 1, $taxes['total'] );
+		// The tax value should be preserved (converted from float to array).
+		// wc_format_decimal() may strip trailing zeros, so we compare as floats.
+		// Use reset() to get first value regardless of key (rate_id may be inferred from order context).
+		$this->assertEquals( 24.00, (float) reset( $taxes['total'] ) );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes handles serialized legacy float values without fatal error.
+	 *
+	 * Simulates the exact scenario from the production error where tax data
+	 * metadata contains serialized floats instead of arrays.
+	 */
+	public function test_set_taxes_with_serialized_legacy_float_values_does_not_throw_error() {
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$item = new WC_Order_Item_Shipping();
+		$item->set_order_id( $order->get_id() );
+
+		// Serialized legacy data with float (as stored in wp_woocommerce_order_itemmeta).
+		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Testing legacy serialized data format.
+		$serialized_legacy_data = serialize(
+			array(
+				'total' => 144.00,
+			)
+		);
+
+		// This should NOT throw a TypeError.
+		$item->set_taxes( $serialized_legacy_data );
+
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertIsArray( $taxes['total'] );
+		$this->assertCount( 1, $taxes['total'] );
+		// The tax value should be preserved (converted from float to array).
+		// wc_format_decimal() may strip trailing zeros, so we compare as floats.
+		// Use reset() to get first value regardless of key (rate_id may be inferred from order context).
+		$this->assertEquals( 144.00, (float) reset( $taxes['total'] ) );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes handles string values for total without fatal error.
+	 */
+	public function test_set_taxes_with_string_values_does_not_throw_error() {
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$item = new WC_Order_Item_Shipping();
+		$item->set_order_id( $order->get_id() );
+
+		// String value instead of array.
+		$string_tax_data = array(
+			'total' => '24.00',
+		);
+
+		// This should NOT throw a TypeError.
+		$item->set_taxes( $string_tax_data );
+
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertIsArray( $taxes['total'] );
+		$this->assertCount( 1, $taxes['total'] );
+		// String values converted to arrays and preserved.
+		// wc_format_decimal() may strip trailing zeros, so we compare as floats.
+		// Use reset() to get first value regardless of key (rate_id may be inferred from order context).
+		$this->assertEquals( 24.00, (float) reset( $taxes['total'] ) );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes infers tax rate ID from order context when handling legacy data.
+	 */
+	public function test_set_taxes_with_legacy_data_infers_rate_id_from_order() {
+		// Create a real order with a tax item.
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		// Add a tax item to the order to provide context.
+		$tax_item = new WC_Order_Item_Tax();
+		$tax_item->set_rate_id( 42 );
+		$tax_item->set_name( 'Test Tax' );
+		$tax_item->set_tax_total( 10 );
+		$order->add_item( $tax_item );
+		$order->save();
+
+		// Create a shipping item associated with this order.
+		$item = new WC_Order_Item_Shipping();
+		$item->set_order_id( $order->get_id() );
+
+		// Legacy tax data as float.
+		$legacy_tax_data = array(
+			'total' => 24.00,
+		);
+
+		$item->set_taxes( $legacy_tax_data );
+
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertIsArray( $taxes['total'] );
+
+		// The rate_id should be inferred from the order's tax item.
+		// Key should be 42 (the rate_id from the tax item we added).
+		$this->assertArrayHasKey( 42, $taxes['total'] );
+		$this->assertEquals( 24.00, (float) $taxes['total'][42] );
+
+		// Clean up.
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes uses rate_id 0 when order is null or false for legacy data.
+	 */
+	public function test_set_taxes_with_legacy_data_uses_rate_id_zero_when_order_is_null() {
+		// Create an item NOT associated with any order (get_order() will return false).
+		$item = new WC_Order_Item_Shipping();
+
+		// Legacy tax data as float.
+		$legacy_tax_data = array(
+			'total' => 24.00,
+		);
+
+		// This should NOT throw an error even though order is null/false.
+		$item->set_taxes( $legacy_tax_data );
+
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertIsArray( $taxes['total'] );
+
+		// Without order context, the rate_id should default to 0.
+		$this->assertArrayHasKey( 0, $taxes['total'] );
+		$this->assertEquals( 24.00, (float) $taxes['total'][0] );
+	}
+
+	/**
+	 * @testdox set_taxes filter allows plugins to customize legacy tax conversion.
+	 */
+	public function test_set_taxes_legacy_conversion_filter() {
+		// Create an order - the filter is only called when an order context exists.
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$item = new WC_Order_Item_Shipping();
+		$item->set_order_id( $order->get_id() );
+
+		// Add a filter to customize the conversion.
+		$filter_callback = function ( $converted, $value ) {
+			// Custom rate ID mapping.
+			unset( $converted );
+			return array( 999 => $value );
+		};
+		add_filter( 'woocommerce_order_item_legacy_tax_conversion', $filter_callback, 10, 2 );
+
+		// Legacy tax data as float.
+		$legacy_tax_data = array(
+			'total' => 50.00,
+		);
+
+		$item->set_taxes( $legacy_tax_data );
+
+		$taxes = $item->get_taxes();
+
+		// The filter should have used rate ID 999.
+		$this->assertArrayHasKey( 999, $taxes['total'] );
+		$this->assertEquals( 50.00, (float) $taxes['total'][999] );
+
+		// Clean up filter.
+		remove_filter( 'woocommerce_order_item_legacy_tax_conversion', $filter_callback );
+
+		// Clean up order.
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes handles older format where raw_tax_data is directly an array.
+	 */
+	public function test_set_taxes_with_older_array_format() {
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$item = new WC_Order_Item_Shipping();
+		$item->set_order_id( $order->get_id() );
+
+		// Older format: raw_tax_data is directly an array (not nested under 'total').
+		$older_format_data = array(
+			1 => '24.00',
+			2 => '12.00',
+		);
+
+		$item->set_taxes( $older_format_data );
+		$taxes = $item->get_taxes();
+
+		$this->assertIsArray( $taxes );
+		$this->assertIsArray( $taxes['total'] );
+		$this->assertEquals( '24.00', $taxes['total'][1] );
+		$this->assertEquals( '12.00', $taxes['total'][2] );
+
+		$order->delete( true );
+	}
+
+	/**
+	 * @testdox set_taxes handles empty/null values gracefully.
+	 */
+	public function test_set_taxes_with_empty_values() {
+		$item = new WC_Order_Item_Shipping();
+
+		// Empty tax data.
+		$item->set_taxes( array() );
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+		$this->assertEmpty( $taxes['total'] );
+
+		// Null value.
+		$item->set_taxes( null );
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+
+		// False value.
+		$item->set_taxes( false );
+		$taxes = $item->get_taxes();
+		$this->assertIsArray( $taxes );
+	}
+}