Commit 342cbf4662 for woocommerce

commit 342cbf4662f6ef5d8cb24e2876b58eabea77bb1e
Author: Néstor Soriano <konamiman@konamiman.com>
Date:   Fri Feb 6 10:58:41 2026 +0100

    Fix: get order(s) REST API endpoints failing when invalid coupon metadata is stored (#63010)

diff --git a/plugins/woocommerce/changelog/pr-63010 b/plugins/woocommerce/changelog/pr-63010
new file mode 100644
index 0000000000..2b62986421
--- /dev/null
+++ b/plugins/woocommerce/changelog/pr-63010
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix: get order(s) REST API endpoints failing when invalid coupon metadata is stored
diff --git a/plugins/woocommerce/includes/class-wc-coupon.php b/plugins/woocommerce/includes/class-wc-coupon.php
index b14b7d79c4..fb083847b9 100644
--- a/plugins/woocommerce/includes/class-wc-coupon.php
+++ b/plugins/woocommerce/includes/class-wc-coupon.php
@@ -1292,19 +1292,89 @@ class WC_Coupon extends WC_Legacy_Coupon {
 		return wp_json_encode( $info );
 	}

+	/**
+	 * Parse short info JSON into an array of coupon properties without validation.
+	 *
+	 * @param string $info JSON string as returned by 'get_short_info'.
+	 * @return array {
+	 *     Parsed coupon properties.
+	 *
+	 *     `@type` int    $id            Coupon ID.
+	 *     `@type` string $code          Coupon code.
+	 *     `@type` string $discount_type Discount type ('fixed_cart', 'percent', etc.).
+	 *     `@type` float  $amount        Discount amount.
+	 *     `@type` bool   $free_shipping Whether free shipping is enabled.
+	 * }
+	 */
+	private static function parse_short_info( string $info ): array {
+		$data = json_decode( $info, true );
+
+		if ( ! is_array( $data ) ) {
+			$data = array();
+		}
+
+		return array(
+			'id'            => $data[0] ?? 0,
+			'code'          => $data[1] ?? '',
+			'discount_type' => $data[2] ?? 'fixed_cart',
+			'amount'        => (float) ( $data[3] ?? 0 ),
+			'free_shipping' => (bool) ( $data[4] ?? false ),
+		);
+	}
+
 	/**
 	 * Sets the coupon parameters from a reapply information set generated with 'get_short_info'.
 	 *
 	 * @param string $info JSON string with reapply information as returned by 'get_short_info'.
 	 */
 	public function set_short_info( string $info ) {
-		$info = json_decode( $info, true );
+		$data = self::parse_short_info( $info );
+
+		$this->set_id( $data['id'] );
+		$this->set_code( $data['code'] );
+		$this->set_discount_type_core( $data['discount_type'], false );
+		$this->set_amount( $data['amount'] );
+		$this->set_free_shipping( $data['free_shipping'] );
+	}
+
+	/**
+	 * Create a WC_Coupon instance from an order's coupon line item without validation.
+	 *
+	 * This is useful for read-only contexts (e.g., REST API responses) where the stored
+	 * data should be returned even if it contains invalid values.
+	 *
+	 * @since 10.6.0
+	 *
+	 * @param \WC_Order_Item_Coupon $order_item The coupon line item from an order.
+	 * @return self A WC_Coupon instance populated with the stored data.
+	 */
+	public static function from_order_item( \WC_Order_Item_Coupon $order_item ): self {
+		$coupon_info = $order_item->get_meta( 'coupon_info', true );
+		if ( is_string( $coupon_info ) && '' !== $coupon_info ) {
+			$data = self::parse_short_info( $coupon_info );
+		} else {
+			$coupon_meta = $order_item->get_meta( 'coupon_data', true );
+			if ( is_object( $coupon_meta ) || is_array( $coupon_meta ) ) {
+				$coupon_meta = (array) $coupon_meta;
+				$data        = array(
+					'id'            => 0,
+					'code'          => '',
+					'discount_type' => $coupon_meta['discount_type'] ?? 'fixed_cart',
+					'amount'        => (float) ( $coupon_meta['amount'] ?? 0 ),
+					'free_shipping' => (bool) ( $coupon_meta['free_shipping'] ?? false ),
+				);
+			} else {
+				return new self();
+			}
+		}

-		$this->set_id( $info[0] ?? 0 );
-		$this->set_code( $info[1] ?? '' );
-		$this->set_discount_type_core( $info[2] ?? 'fixed_cart', false );
-		$this->set_amount( $info[3] ?? 0 );
-		$this->set_free_shipping( $info[4] ?? false );
+		$coupon = new self();
+		$coupon->set_id( $data['id'] );
+		$coupon->set_code( $data['code'] );
+		$coupon->set_discount_type_core( $data['discount_type'], false );
+		$coupon->set_prop( 'amount', $data['amount'] );
+		$coupon->set_free_shipping( $data['free_shipping'] );
+		return $coupon;
 	}

 	/**
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
index 2cda8deb6a..98f524765e 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
@@ -294,21 +294,10 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {

 		// Add additional applied coupon information.
 		if ( $item instanceof WC_Order_Item_Coupon ) {
-			$temp_coupon = new WC_Coupon();
-			$coupon_info = $item->get_meta( 'coupon_info', true );
-			if ( $coupon_info ) {
-				$temp_coupon->set_short_info( $coupon_info );
-			} else {
-				$coupon_meta = $item->get_meta( 'coupon_data', true );
-				if ( $coupon_meta ) {
-					$temp_coupon->set_props( (array) $coupon_meta );
-
-				}
-			}
-
-			$data['discount_type']  = $temp_coupon->get_discount_type();
-			$data['nominal_amount'] = (float) $temp_coupon->get_amount();
-			$data['free_shipping']  = $temp_coupon->get_free_shipping();
+			$coupon                 = WC_Coupon::from_order_item( $item );
+			$data['discount_type']  = $coupon->get_discount_type();
+			$data['nominal_amount'] = (float) $coupon->get_amount();
+			$data['free_shipping']  = $coupon->get_free_shipping();
 		}

 		$data['meta_data'] = array_map(
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderCouponSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderCouponSchema.php
index 9a6af62c35..4150010ce3 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderCouponSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderCouponSchema.php
@@ -11,9 +11,9 @@ namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema;

 defined( 'ABSPATH' ) || exit;

+use WC_Coupon;
 use WC_Order_Item_Coupon;
 use WP_REST_Request;
-use WC_Coupon;

 /**
  * OrderCouponSchema class.
@@ -94,29 +94,18 @@ class OrderCouponSchema extends AbstractLineItemSchema {
 	 * @return array
 	 */
 	public function get_item_response( $order_item, WP_REST_Request $request, array $include_fields = array() ): array {
-		$dp          = is_null( $request['num_decimals'] ) ? wc_get_price_decimals() : absint( $request['num_decimals'] );
-		$temp_coupon = new WC_Coupon();
-		$coupon_info = $order_item->get_meta( 'coupon_info', true );
-		if ( $coupon_info ) {
-			$temp_coupon->set_short_info( $coupon_info );
-		} else {
-			$coupon_meta = $order_item->get_meta( 'coupon_data', true );
-			if ( $coupon_meta ) {
-				$temp_coupon->set_props( (array) $coupon_meta );
-			}
-		}
+		$dp     = is_null( $request['num_decimals'] ) ? wc_get_price_decimals() : absint( $request['num_decimals'] );
+		$coupon = WC_Coupon::from_order_item( $order_item );

-		$data = array(
+		return array(
 			'id'             => $order_item->get_id(),
 			'code'           => $order_item->get_code(),
 			'discount'       => wc_format_decimal( $order_item->get_discount(), $dp ),
 			'discount_tax'   => wc_format_decimal( $order_item->get_discount_tax(), $dp ),
-			'discount_type'  => $temp_coupon->get_discount_type(),
-			'nominal_amount' => (float) $temp_coupon->get_amount(),
-			'free_shipping'  => $temp_coupon->get_free_shipping(),
+			'discount_type'  => $coupon->get_discount_type(),
+			'nominal_amount' => (float) $coupon->get_amount(),
+			'free_shipping'  => $coupon->get_free_shipping(),
 			'meta_data'      => $this->prepare_meta_data( $order_item ),
 		);
-
-		return $data;
 	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-coupon-test.php b/plugins/woocommerce/tests/php/includes/class-wc-coupon-test.php
index f7a781843e..8a411adbe0 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-coupon-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-coupon-test.php
@@ -6,6 +6,188 @@
  * See also ../../legacy/unit-tests/coupon/coupon.php for other related tests.
  */
 class WC_Coupon_Tests extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox set_short_info validates amount and throws exception for invalid values.
+	 */
+	public function test_set_short_info_validates_amount(): void {
+		$coupon = new WC_Coupon();
+		$info   = wp_json_encode( array( 1, 'CODE', 'percent', 150.0 ) );
+
+		$this->expectException( \WC_Data_Exception::class );
+
+		$coupon->set_short_info( $info );
+	}
+
+	/**
+	 * @testdox from_order_item returns a coupon with correct data from coupon_info meta.
+	 */
+	public function test_from_order_item_with_coupon_info(): void {
+		$order_item = $this->createMock( WC_Order_Item_Coupon::class );
+		$order_item->method( 'get_meta' )
+			->willReturnCallback(
+				function ( $key ) {
+					if ( 'coupon_info' === $key ) {
+						return wp_json_encode( array( 123, 'TESTCODE', 'percent', 25.5, true ) );
+					}
+					return '';
+				}
+			);
+
+		$coupon = WC_Coupon::from_order_item( $order_item );
+
+		$this->assertSame( 123, $coupon->get_id() );
+		$this->assertSame( 'testcode', $coupon->get_code() ); // WC_Coupon lowercases codes.
+		$this->assertSame( 'percent', $coupon->get_discount_type() );
+		$this->assertSame( 25.5, (float) $coupon->get_amount() );
+		$this->assertTrue( $coupon->get_free_shipping() );
+	}
+
+	/**
+	 * @testdox from_order_item returns fixed_cart as default discount type when type is null.
+	 */
+	public function test_from_order_item_uses_fixed_cart_as_default_discount_type(): void {
+		$order_item = $this->createMock( WC_Order_Item_Coupon::class );
+		$order_item->method( 'get_meta' )
+			->willReturnCallback(
+				function ( $key ) {
+					if ( 'coupon_info' === $key ) {
+						return wp_json_encode( array( 1, 'CODE', null, 10.0 ) );
+					}
+					return '';
+				}
+			);
+
+		$coupon = WC_Coupon::from_order_item( $order_item );
+
+		$this->assertSame( 'fixed_cart', $coupon->get_discount_type() );
+	}
+
+	/**
+	 * @testdox from_order_item returns false for free_shipping when not present in JSON.
+	 */
+	public function test_from_order_item_defaults_free_shipping_to_false(): void {
+		$order_item = $this->createMock( WC_Order_Item_Coupon::class );
+		$order_item->method( 'get_meta' )
+			->willReturnCallback(
+				function ( $key ) {
+					if ( 'coupon_info' === $key ) {
+						return wp_json_encode( array( 1, 'CODE', 'percent', 10.0 ) );
+					}
+					return '';
+				}
+			);
+
+		$coupon = WC_Coupon::from_order_item( $order_item );
+
+		$this->assertFalse( $coupon->get_free_shipping() );
+	}
+
+	/**
+	 * @testdox from_order_item returns a coupon with correct data from legacy coupon_data meta.
+	 */
+	public function test_from_order_item_with_legacy_coupon_data(): void {
+		$order_item = $this->createMock( WC_Order_Item_Coupon::class );
+		$order_item->method( 'get_meta' )
+			->willReturnCallback(
+				function ( $key ) {
+					if ( 'coupon_info' === $key ) {
+						return '';
+					}
+					if ( 'coupon_data' === $key ) {
+						return (object) array(
+							'discount_type' => 'fixed_cart',
+							'amount'        => 10.0,
+							'free_shipping' => false,
+						);
+					}
+					return '';
+				}
+			);
+
+		$coupon = WC_Coupon::from_order_item( $order_item );
+
+		$this->assertSame( 'fixed_cart', $coupon->get_discount_type() );
+		$this->assertSame( 10.0, (float) $coupon->get_amount() );
+		$this->assertFalse( $coupon->get_free_shipping() );
+	}
+
+	/**
+	 * @testdox from_order_item returns a default coupon when no coupon meta exists.
+	 */
+	public function test_from_order_item_with_no_meta(): void {
+		$order_item = $this->createMock( WC_Order_Item_Coupon::class );
+		$order_item->method( 'get_meta' )->willReturn( '' );
+
+		$coupon = WC_Coupon::from_order_item( $order_item );
+
+		$this->assertSame( 'fixed_cart', $coupon->get_discount_type() );
+		$this->assertSame( 0.0, (float) $coupon->get_amount() );
+		$this->assertFalse( $coupon->get_free_shipping() );
+	}
+
+	/**
+	 * @testdox from_order_item returns a default coupon when coupon_info contains malformed JSON.
+	 */
+	public function test_from_order_item_with_malformed_json(): void {
+		$order_item = $this->createMock( WC_Order_Item_Coupon::class );
+		$order_item->method( 'get_meta' )
+			->willReturnCallback(
+				function ( $key ) {
+					if ( 'coupon_info' === $key ) {
+						return 'not valid json';
+					}
+					return '';
+				}
+			);
+
+		$coupon = WC_Coupon::from_order_item( $order_item );
+
+		$this->assertSame( 'fixed_cart', $coupon->get_discount_type() );
+		$this->assertSame( 0.0, (float) $coupon->get_amount() );
+		$this->assertFalse( $coupon->get_free_shipping() );
+	}
+
+	/**
+	 * @testdox from_order_item does not validate amount, allowing invalid percentages over 100.
+	 */
+	public function test_from_order_item_allows_invalid_percentage_amounts(): void {
+		$order_item = $this->createMock( WC_Order_Item_Coupon::class );
+		$order_item->method( 'get_meta' )
+			->willReturnCallback(
+				function ( $key ) {
+					if ( 'coupon_info' === $key ) {
+						return wp_json_encode( array( 1, 'CODE', 'percent', 150.0 ) );
+					}
+					return '';
+				}
+			);
+
+		$coupon = WC_Coupon::from_order_item( $order_item );
+
+		$this->assertSame( 150.0, (float) $coupon->get_amount(), 'from_order_item should not validate amount values' );
+	}
+
+	/**
+	 * @testdox from_order_item does not validate amount, allowing negative amounts.
+	 */
+	public function test_from_order_item_allows_negative_amounts(): void {
+		$order_item = $this->createMock( WC_Order_Item_Coupon::class );
+		$order_item->method( 'get_meta' )
+			->willReturnCallback(
+				function ( $key ) {
+					if ( 'coupon_info' === $key ) {
+						return wp_json_encode( array( 1, 'CODE', 'fixed_cart', -10.0 ) );
+					}
+					return '';
+				}
+			);
+
+		$coupon = WC_Coupon::from_order_item( $order_item );
+
+		$this->assertSame( -10.0, (float) $coupon->get_amount(), 'from_order_item should not validate amount values' );
+	}
+
 	/**
 	 * If a coupon is applied to an order where one or more products have been deleted, the operation should still
 	 * succeed.