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.