Commit 6a090df03e1 for woocommerce

commit 6a090df03e11cfb2929ecf15ebd6dc1a333e6c73
Author: Amimul Ihsan <amimulihsanmahdi@gmail.com>
Date:   Thu Aug 20 01:25:58 2026 +0600

    Fix: validate minimum spend does not exceed maximum in WC_Coupon::set_minimum_amount() (#67703)

    * Fix coupon minimum and maximum spend validation

    * Fix REST V2 test: use WC_REST_Unit_Test_Case and lowercase @testdox

    * Fix lint and test issues: closing braces, @param tags, getErrorCode assertion

    * Fix coupon validation test assertions

diff --git a/plugins/woocommerce/changelog/fix-coupon-minimum-amount-validation b/plugins/woocommerce/changelog/fix-coupon-minimum-amount-validation
new file mode 100644
index 00000000000..e478d2c1309
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-coupon-minimum-amount-validation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Validate minimum spend amount does not exceed maximum spend amount in WC_Coupon::set_minimum_amount().
diff --git a/plugins/woocommerce/includes/class-wc-coupon.php b/plugins/woocommerce/includes/class-wc-coupon.php
index c5af95b0faa..67e36d79dfd 100644
--- a/plugins/woocommerce/includes/class-wc-coupon.php
+++ b/plugins/woocommerce/includes/class-wc-coupon.php
@@ -803,6 +803,9 @@ class WC_Coupon extends WC_Legacy_Coupon {
 	 * @return void
 	 */
 	public function set_minimum_amount( $amount ) {
+		if ( (float) $this->get_maximum_amount() && (float) $amount > (float) $this->get_maximum_amount() ) {
+			$this->error( 'coupon_invalid_minimum_amount', __( 'Invalid minimum spend value.', 'woocommerce' ) );
+		}
 		$this->set_prop( 'minimum_amount', wc_format_decimal( $amount ) );
 	}

@@ -1458,4 +1461,85 @@ class WC_Coupon extends WC_Legacy_Coupon {
 				return array();
 		}
 	}
+
+	/**
+	 * Set a collection of props in one go.
+	 *
+	 * Overrides the base implementation to validate the minimum_amount /
+	 * maximum_amount pair as a unit when both are supplied in the same call.
+	 * This avoids false rejections caused by stale-value comparisons in the
+	 * individual setter guards, which fire sequentially and each see the old
+	 * stored value for the other field.
+	 *
+	 * Only the amount pair receives this joint validation.  All other props
+	 * in the same call are still applied using the normal partial-application
+	 * semantics of the parent: each setter runs independently, and a failure
+	 * in one does not roll back props that were already applied.
+	 *
+	 * When only one of the two amounts is supplied, the individual setter
+	 * guard still applies.  When neither is supplied, this delegates entirely
+	 * to the parent.
+	 *
+	 * @since 11.1.0
+	 * @param array  $props   Key/value pairs of properties to set.
+	 * @param string $context Operation context ('set', 'edit', 'view').
+	 * @return bool|WP_Error True on success, WP_Error on failure.
+	 */
+	public function set_props( $props, $context = 'set' ) {
+		$has_minimum = array_key_exists( 'minimum_amount', $props );
+		$has_maximum = array_key_exists( 'maximum_amount', $props );
+
+		// When only one amount or neither is supplied, the parent sequential
+		// setter calls (and their individual guards) handle everything.
+		if ( ! $has_minimum || ! $has_maximum ) {
+			return parent::set_props( $props, $context );
+		}
+
+		// Both amounts are being set in the same call.  Pre-validate the
+		// projected final pair before touching any property so that a valid
+		// simultaneous update (e.g. raising 100/200 to 250/300) is never
+		// rejected due to intermediate stale-value comparisons.
+		$projected_min = wc_format_decimal( $props['minimum_amount'] );
+		$projected_max = wc_format_decimal( $props['maximum_amount'] );
+		$min_float     = (float) $projected_min;
+		$max_float     = (float) $projected_max;
+
+		// All props except the two amounts are still processed by the parent.
+		$remaining = $props;
+		unset( $remaining['minimum_amount'], $remaining['maximum_amount'] );
+
+		if ( $min_float && $max_float && $min_float > $max_float ) {
+			// Projected pair is invalid.  Return WP_Error without mutating
+			// either amount property.  Remaining props are still applied so
+			// that error aggregation behaviour is unchanged.
+			$error = new WP_Error(
+				'coupon_invalid_minimum_amount',
+				__( 'Minimum spend cannot exceed maximum spend.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+
+			if ( ! empty( $remaining ) ) {
+				$other_errors = parent::set_props( $remaining, $context );
+				if ( is_wp_error( $other_errors ) ) {
+					foreach ( $other_errors->get_error_codes() as $code ) {
+						$error->add( $code, $other_errors->get_error_message( $code ), $other_errors->get_error_data( $code ) );
+					}
+				}
+			}
+
+			return $error;
+		}
+
+		// Projected pair is valid.  Apply both amounts directly via set_prop()
+		// to bypass the individual setter guards — they would compare against
+		// the stale stored value and produce a spurious rejection.
+		$this->set_prop( 'minimum_amount', $projected_min );
+		$this->set_prop( 'maximum_amount', $projected_max );
+
+		if ( empty( $remaining ) ) {
+			return true;
+		}
+
+		return parent::set_props( $remaining, $context );
+	}
 }
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller.php
index d98bf5d73ce..8a77304accc 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller.php
@@ -292,8 +292,34 @@ class WC_REST_Coupons_V2_Controller extends WC_REST_CRUD_Controller {
 			$coupon->set_discount_type( $request['discount_type'] );
 		}

+		// When both minimum_amount and maximum_amount are present in the same
+		// request, pass them together through the coupon's set_props() so the
+		// pair is validated as a unit.  Calling the setters sequentially (as
+		// the default loop below does) causes the first setter to compare its
+		// new value against the stale stored value of the other field, which
+		// spuriously rejects valid simultaneous raises like min=100/max=200 →
+		// min=250/max=300.
+		$amount_keys_handled = false;
+		if ( ! is_null( $request['minimum_amount'] ) && ! is_null( $request['maximum_amount'] ) ) {
+			$amount_result = $coupon->set_props(
+				array(
+					'minimum_amount' => $request['minimum_amount'],
+					'maximum_amount' => $request['maximum_amount'],
+				)
+			);
+			if ( is_wp_error( $amount_result ) ) {
+				return $amount_result;
+			}
+			$amount_keys_handled = true;
+		}
+
 		// Handle all writable props.
 		foreach ( $data_keys as $key ) {
+			// minimum_amount and maximum_amount were already applied atomically above.
+			if ( $amount_keys_handled && in_array( $key, array( 'minimum_amount', 'maximum_amount' ), true ) ) {
+				continue;
+			}
+
 			$value = $request[ $key ];

 			if ( ! is_null( $value ) ) {
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 149084cde23..ecde529f337 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-coupon-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-coupon-test.php
@@ -277,4 +277,234 @@ class WC_Coupon_Tests extends WC_Unit_Test_Case {

 		$coupon->set_amount( -10.0 );
 	}
+
+	// -------------------------------------------------------------------------
+	// Direct setter validation (single-property guards).
+	// -------------------------------------------------------------------------
+
+	/**
+	 * @testdox set_minimum_amount throws exception when minimum exceeds existing maximum.
+	 */
+	public function test_set_minimum_amount_throws_when_exceeds_maximum(): void {
+		$coupon = new WC_Coupon();
+		$coupon->set_maximum_amount( '100.00' );
+
+		try {
+			$coupon->set_minimum_amount( '200.00' );
+			$this->fail( 'Expected WC_Data_Exception was not thrown.' );
+		} catch ( \WC_Data_Exception $e ) {
+			$this->assertSame( 'coupon_invalid_minimum_amount', $e->getErrorCode() );
+		}
+	}
+
+	/**
+	 * @testdox set_maximum_amount throws exception when maximum is below existing minimum.
+	 */
+	public function test_set_maximum_amount_throws_when_below_existing_minimum(): void {
+		$coupon = new WC_Coupon();
+		$coupon->set_minimum_amount( '100.00' );
+
+		try {
+			$coupon->set_maximum_amount( '50.00' );
+			$this->fail( 'Expected WC_Data_Exception was not thrown.' );
+		} catch ( \WC_Data_Exception $e ) {
+			$this->assertSame( 'coupon_invalid_maximum_amount', $e->getErrorCode() );
+		}
+	}
+
+	/**
+	 * @testdox set_minimum_amount succeeds when no maximum amount is set.
+	 */
+	public function test_set_minimum_amount_succeeds_when_no_maximum_set(): void {
+		$coupon = new WC_Coupon();
+
+		$coupon->set_minimum_amount( '200.00' );
+
+		$this->assertSame( '200.00', $coupon->get_minimum_amount() );
+	}
+
+	/**
+	 * @testdox set_minimum_amount succeeds when minimum is less than existing maximum.
+	 */
+	public function test_set_minimum_amount_succeeds_when_less_than_maximum(): void {
+		$coupon = new WC_Coupon();
+		$coupon->set_maximum_amount( '100.00' );
+
+		$coupon->set_minimum_amount( '50.00' );
+
+		$this->assertSame( '50.00', $coupon->get_minimum_amount() );
+	}
+
+	/**
+	 * @testdox set_minimum_amount succeeds when maximum amount is zero (no upper limit).
+	 */
+	public function test_set_minimum_amount_succeeds_when_maximum_is_zero(): void {
+		$coupon = new WC_Coupon();
+		$coupon->set_maximum_amount( '0' );
+
+		$coupon->set_minimum_amount( '999.00' );
+
+		$this->assertSame( '999.00', $coupon->get_minimum_amount() );
+	}
+
+	/**
+	 * @testdox set_minimum_amount succeeds when minimum equals maximum (boundary is inclusive).
+	 */
+	public function test_set_minimum_amount_succeeds_when_equal_to_maximum(): void {
+		$coupon = new WC_Coupon();
+		$coupon->set_maximum_amount( '100.00' );
+
+		$coupon->set_minimum_amount( '100.00' );
+
+		$this->assertSame( '100.00', $coupon->get_minimum_amount() );
+	}
+
+	// -------------------------------------------------------------------------
+	// Atomic set_props() validation (both amounts supplied together).
+	// -------------------------------------------------------------------------
+
+	/**
+	 * @testdox set_props allows raising both minimum and maximum when new minimum exceeds old maximum.
+	 */
+	public function test_set_props_allows_raising_both_minimum_and_maximum_together(): void {
+		$coupon = new WC_Coupon();
+		$coupon->set_minimum_amount( '100.00' );
+		$coupon->set_maximum_amount( '200.00' );
+
+		$result = $coupon->set_props(
+			array(
+				'minimum_amount' => '250.00',
+				'maximum_amount' => '300.00',
+			)
+		);
+
+		$this->assertTrue( $result );
+		$this->assertSame( '250.00', $coupon->get_minimum_amount() );
+		$this->assertSame( '300.00', $coupon->get_maximum_amount() );
+	}
+
+	/**
+	 * @testdox set_props allows lowering both minimum and maximum together.
+	 */
+	public function test_set_props_allows_lowering_both_minimum_and_maximum_together(): void {
+		$coupon = new WC_Coupon();
+		$coupon->set_minimum_amount( '100.00' );
+		$coupon->set_maximum_amount( '200.00' );
+
+		$result = $coupon->set_props(
+			array(
+				'minimum_amount' => '50.00',
+				'maximum_amount' => '75.00',
+			)
+		);
+
+		$this->assertTrue( $result );
+		$this->assertSame( '50.00', $coupon->get_minimum_amount() );
+		$this->assertSame( '75.00', $coupon->get_maximum_amount() );
+	}
+
+	/**
+	 * @testdox set_props rejects an invalid min/max pair and leaves both properties unchanged.
+	 */
+	public function test_set_props_rejects_invalid_pair_without_mutating_either_property(): void {
+		$coupon = new WC_Coupon();
+		$coupon->set_minimum_amount( '100.00' );
+		$coupon->set_maximum_amount( '200.00' );
+
+		$result = $coupon->set_props(
+			array(
+				'minimum_amount' => '300.00',
+				'maximum_amount' => '150.00',
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'coupon_invalid_minimum_amount', $result->get_error_code() );
+		$this->assertSame( '100.00', $coupon->get_minimum_amount(), 'minimum_amount must not be mutated on failure' );
+		$this->assertSame( '200.00', $coupon->get_maximum_amount(), 'maximum_amount must not be mutated on failure' );
+	}
+
+	/**
+	 * @testdox set_props rejects a minimum-only update that would exceed the existing maximum.
+	 */
+	public function test_set_props_rejects_minimum_only_invalid_update(): void {
+		$coupon = new WC_Coupon();
+		$coupon->set_minimum_amount( '100.00' );
+		$coupon->set_maximum_amount( '200.00' );
+
+		$result = $coupon->set_props( array( 'minimum_amount' => '300.00' ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( '100.00', $coupon->get_minimum_amount(), 'minimum_amount must not be mutated on failure' );
+	}
+
+	/**
+	 * @testdox set_props rejects a maximum-only update that would fall below the existing minimum.
+	 */
+	public function test_set_props_rejects_maximum_only_invalid_update(): void {
+		$coupon = new WC_Coupon();
+		$coupon->set_minimum_amount( '100.00' );
+		$coupon->set_maximum_amount( '200.00' );
+
+		$result = $coupon->set_props( array( 'maximum_amount' => '50.00' ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( '200.00', $coupon->get_maximum_amount(), 'maximum_amount must not be mutated on failure' );
+	}
+
+	/**
+	 * @testdox set_props treats a zero maximum as no upper limit and allows any minimum.
+	 */
+	public function test_set_props_allows_any_minimum_when_maximum_is_zero(): void {
+		$coupon = new WC_Coupon();
+
+		$result = $coupon->set_props(
+			array(
+				'minimum_amount' => '999.00',
+				'maximum_amount' => '0',
+			)
+		);
+
+		$this->assertTrue( $result );
+		$this->assertSame( '999.00', $coupon->get_minimum_amount() );
+		$this->assertSame( '0', $coupon->get_maximum_amount() );
+	}
+
+	/**
+	 * @testdox set_props allows equal minimum and maximum (boundary is inclusive).
+	 */
+	public function test_set_props_allows_equal_minimum_and_maximum(): void {
+		$coupon = new WC_Coupon();
+
+		$result = $coupon->set_props(
+			array(
+				'minimum_amount' => '100.00',
+				'maximum_amount' => '100.00',
+			)
+		);
+
+		$this->assertTrue( $result );
+		$this->assertSame( '100.00', $coupon->get_minimum_amount() );
+		$this->assertSame( '100.00', $coupon->get_maximum_amount() );
+	}
+
+	/**
+	 * @testdox set_props applies a valid min/max pair and still returns errors from other properties.
+	 */
+	public function test_set_props_applies_valid_amounts_and_aggregates_other_errors(): void {
+		$coupon = new WC_Coupon();
+
+		$result = $coupon->set_props(
+			array(
+				'minimum_amount' => '50.00',
+				'maximum_amount' => '100.00',
+				'amount'         => '-10',
+			)
+		);
+
+		$this->assertWPError( $result );
+		// The valid min/max pair should still be applied.
+		$this->assertSame( '50.00', $coupon->get_minimum_amount() );
+		$this->assertSame( '100.00', $coupon->get_maximum_amount() );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller-tests.php
new file mode 100644
index 00000000000..b5cdcb8c917
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-coupons-v2-controller-tests.php
@@ -0,0 +1,224 @@
+<?php
+declare( strict_types = 1 );
+
+// phpcs:disable Squiz.Classes.ClassFileName.NoMatch, Squiz.Classes.ValidClassName.NotCamelCaps -- legacy conventions.
+
+/**
+ * Regression tests for coupon minimum/maximum spend validation through the
+ * V2 REST API controller.
+ *
+ * @covers WC_REST_Coupons_V2_Controller::prepare_object_for_database
+ */
+class WC_REST_Coupons_V2_Controller_Tests extends WC_REST_Unit_Test_Case {
+
+	/**
+	 * @var WC_REST_Coupons_V2_Controller System under test.
+	 */
+	private WC_REST_Coupons_V2_Controller $sut;
+
+	/**
+	 * @var int Admin user ID used for REST permission checks.
+	 */
+	private int $admin_id;
+
+	/**
+	 * @inheritDoc
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut      = new WC_REST_Coupons_V2_Controller();
+		$this->admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $this->admin_id );
+	}
+
+	// -----------------------------------------------------------------------
+	// Helpers
+	// -----------------------------------------------------------------------
+
+	/**
+	 * Create a real coupon in the database and return it.
+	 *
+	 * Sets minimum_amount first, then maximum_amount. The setter guards are
+	 * safe in this order as long as max >= min, which is always true for
+	 * valid fixture data.
+	 *
+	 * @param string $code Coupon code.
+	 * @param string $min  Minimum amount.
+	 * @param string $max  Maximum amount.
+	 * @return WC_Coupon
+	 */
+	private function make_coupon( string $code, string $min = '0', string $max = '0' ): WC_Coupon {
+		$coupon = new WC_Coupon();
+		$coupon->set_code( $code );
+		$coupon->set_minimum_amount( $min );
+		$coupon->set_maximum_amount( $max );
+		$coupon->save();
+		return $coupon;
+	}
+
+	/**
+	 * Build a PATCH REST request for an existing coupon.
+	 *
+	 * @param int   $id     Coupon post ID.
+	 * @param array $params Request parameters.
+	 * @return WP_REST_Request
+	 */
+	private function patch_request( int $id, array $params ): WP_REST_Request {
+		$request = new WP_REST_Request( 'PUT', '/wc/v2/coupons/' . $id );
+		$request->set_param( 'id', $id );
+		foreach ( $params as $key => $value ) {
+			$request->set_param( $key, $value );
+		}
+		return $request;
+	}
+
+	/**
+	 * Build a POST REST request to create a coupon.
+	 *
+	 * @param array $params Request parameters.
+	 * @return WP_REST_Request
+	 */
+	private function post_request( array $params ): WP_REST_Request {
+		$request = new WP_REST_Request( 'POST', '/wc/v2/coupons' );
+		foreach ( $params as $key => $value ) {
+			$request->set_param( $key, $value );
+		}
+		return $request;
+	}
+
+	// -----------------------------------------------------------------------
+	// Tests: valid simultaneous updates
+	// -----------------------------------------------------------------------
+
+	/**
+	 * @testdox A valid simultaneous raise of both minimum and maximum spend succeeds via REST.
+	 */
+	public function test_rest_allows_raising_both_amounts_together(): void {
+		$coupon = $this->make_coupon( 'RAISE-BOTH', '100', '200' );
+
+		$result = $this->sut->update_item(
+			$this->patch_request(
+				$coupon->get_id(),
+				array(
+					'minimum_amount' => '250',
+					'maximum_amount' => '300',
+				)
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$data = $result->get_data();
+		$this->assertSame( '250.00', $data['minimum_amount'] );
+		$this->assertSame( '300.00', $data['maximum_amount'] );
+	}
+
+	/**
+	 * @testdox A valid simultaneous decrease of both minimum and maximum spend succeeds via REST.
+	 */
+	public function test_rest_allows_lowering_both_amounts_together(): void {
+		$coupon = $this->make_coupon( 'LOWER-BOTH', '100', '200' );
+
+		$result = $this->sut->update_item(
+			$this->patch_request(
+				$coupon->get_id(),
+				array(
+					'minimum_amount' => '50',
+					'maximum_amount' => '75',
+				)
+			)
+		);
+
+		$this->assertNotWPError( $result );
+		$data = $result->get_data();
+		$this->assertSame( '50.00', $data['minimum_amount'] );
+		$this->assertSame( '75.00', $data['maximum_amount'] );
+	}
+
+	// -----------------------------------------------------------------------
+	// Tests: invalid pair rejected
+	// -----------------------------------------------------------------------
+
+	/**
+	 * @testdox Creating a coupon with minimum spend exceeding maximum spend returns a 400 error.
+	 */
+	public function test_rest_create_rejects_invalid_amount_pair(): void {
+		$result = $this->sut->create_item(
+			$this->post_request(
+				array(
+					'code'           => 'BAD-CREATE',
+					'minimum_amount' => '300',
+					'maximum_amount' => '100',
+				)
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'coupon_invalid_minimum_amount', $result->get_error_code() );
+		$error_data = $result->get_error_data();
+		$this->assertSame( 400, $error_data['status'] );
+	}
+
+	/**
+	 * @testdox Updating a coupon with an invalid amount pair returns a 400 error without persisting either amount.
+	 */
+	public function test_rest_update_rejects_invalid_amount_pair_without_mutation(): void {
+		$coupon = $this->make_coupon( 'NO-MUTATE', '50', '150' );
+		$id     = $coupon->get_id();
+
+		$result = $this->sut->update_item(
+			$this->patch_request(
+				$id,
+				array(
+					'minimum_amount' => '200',
+					'maximum_amount' => '100',
+				)
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'coupon_invalid_minimum_amount', $result->get_error_code() );
+
+		// Reload from DB and verify neither amount was written.
+		$reloaded = new WC_Coupon( $id );
+		$this->assertEquals( 50.0, (float) $reloaded->get_minimum_amount(), 'minimum_amount must not have been mutated' );
+		$this->assertEquals( 150.0, (float) $reloaded->get_maximum_amount(), 'maximum_amount must not have been mutated' );
+	}
+
+	// -----------------------------------------------------------------------
+	// Tests: single-field updates
+	// -----------------------------------------------------------------------
+
+	/**
+	 * @testdox Updating only minimum spend to a value that exceeds the stored maximum returns a 400 error.
+	 */
+	public function test_rest_minimum_only_update_rejected_when_exceeds_stored_maximum(): void {
+		$coupon = $this->make_coupon( 'MIN-ONLY-FAIL', '50', '100' );
+
+		$result = $this->sut->update_item(
+			$this->patch_request(
+				$coupon->get_id(),
+				array( 'minimum_amount' => '200' )
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'coupon_invalid_minimum_amount', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Updating only maximum spend to a value below the stored minimum returns a 400 error.
+	 */
+	public function test_rest_maximum_only_update_rejected_when_below_stored_minimum(): void {
+		$coupon = $this->make_coupon( 'MAX-ONLY-FAIL', '100', '200' );
+
+		$result = $this->sut->update_item(
+			$this->patch_request(
+				$coupon->get_id(),
+				array( 'maximum_amount' => '50' )
+			)
+		);
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'coupon_invalid_maximum_amount', $result->get_error_code() );
+	}
+}