Commit 3d9dc9341cd for woocommerce
commit 3d9dc9341cd93724927d66a13f32c9a9159d0b68
Author: SH Sajal Chowdhury <72102985+shsajalchowdhury@users.noreply.github.com>
Date: Thu Jul 9 02:58:17 2026 +0600
Fix: Add filters to coupon validation methods in WC_Discounts (#64363)
* Fix: Add filters to coupon validation methods in WC_Discounts
Address review feedback from @kraftbj:
- Fix @since version to 10.9.0 (trunk is now 10.9.0-dev)
- Pass $this (WC_Discounts instance) as third argument for consistency
with sibling filters (validate_expiry_date, validate_user_usage_limit)
- Add 4th filter: woocommerce_coupon_validate_eligible_items for
short-circuiting sale/excluded checks on cart discount coupons
Fixes #38527
* Address review feedback on coupon validation filters
- Correct the version annotation on the four new filters from 10.9.0 to
11.0.0 to match current trunk.
- Remove the dead $valid assignment in validate_coupon_eligible_items and
rename its filter docblock param to $pre.
- Document the validity polarity on the three result filters and the
null/boolean short-circuit contract on
woocommerce_coupon_validate_eligible_items.
* Add unit tests for coupon validation filters
Cover the four new filters in WC_Discounts::is_coupon_valid():
- woocommerce_coupon_validate_product_ids (bypass and force-reject)
- woocommerce_coupon_validate_product_categories (bypass)
- woocommerce_coupon_validate_excluded_items (bypass)
- woocommerce_coupon_validate_eligible_items (null default, true, false short-circuit)
Each test also asserts default behavior is unchanged when no filter is attached.
* Strengthen coupon validation filter tests
Address review feedback on the new tests:
- Assert the specific rejection message so each scenario proves the intended
validator fired, not just that some error occurred. This distinguishes the
eligible_items null-default fall-through (excluded-product check) from the
filter's own short-circuit rejection.
- Add a non-boolean (0) return case for woocommerce_coupon_validate_eligible_items
to lock in the documented "only a strict null defers" tri-state contract.
* Refactor coupon validation filters to a uniform per-check pattern
Replace the woocommerce_coupon_validate_eligible_items short-circuit filter with
individual result filters on the three checks it delegated to, so every coupon
validation filter shares the same ( bool $valid, WC_Coupon, WC_Discounts ) shape:
- Add woocommerce_coupon_validate_sale_items
- Add woocommerce_coupon_validate_excluded_product_ids
- Add woocommerce_coupon_validate_excluded_product_categories
- Remove woocommerce_coupon_validate_eligible_items and its new error string
Each check now keeps its own error code (110/113/114) instead of collapsing to a
single generic 109. eligible_items returns to a plain dispatcher.
Also rename the filter docblock parameter from $this to $discounts (PHPStan does
not allow a param named $this) across all six filters, and update the unit tests
and changelog to match.
* Bump coupon validation filter since tags to 11.1.0
Trunk is on 11.1.0-dev and the PR now targets the 11.1.0 milestone, so the six
new filters ship in 11.1.0.
* Use a generic message when an excluded-items filter forces rejection
When a filter forces the excluded_product_ids or excluded_product_categories
check to fail while no cart item actually matched, the product/category list is
empty. Fall back to the generic "not applicable to selected products" message so
the shopper never sees a dangling empty list. Default (no-filter) behavior is
unchanged, since an empty list only occurs when the coupon would otherwise pass.
Document that developers can use the woocommerce_coupon_error filter to customize
the rejection message, and add test coverage for the forced-rejection path.
---------
Co-authored-by: Brandon Kraft <public@brandonkraft.com>
diff --git a/plugins/woocommerce/changelog/pr-64363 b/plugins/woocommerce/changelog/pr-64363
new file mode 100644
index 00000000000..c0f828f4e0c
--- /dev/null
+++ b/plugins/woocommerce/changelog/pr-64363
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Add coupon validation filters for product_ids, product_categories, sale_items, excluded_items, excluded_product_ids, and excluded_product_categories in WC_Discounts.
diff --git a/plugins/woocommerce/includes/class-wc-discounts.php b/plugins/woocommerce/includes/class-wc-discounts.php
index 40df4793436..c5be196095b 100644
--- a/plugins/woocommerce/includes/class-wc-discounts.php
+++ b/plugins/woocommerce/includes/class-wc-discounts.php
@@ -809,6 +809,18 @@ class WC_Discounts {
}
}
+ /**
+ * Filter the result of coupon product_ids validation.
+ *
+ * Return true to treat the coupon as valid for the cart, or false to reject it.
+ *
+ * @since 11.1.0
+ * @param bool $valid Whether the coupon is valid.
+ * @param WC_Coupon $coupon Coupon data.
+ * @param WC_Discounts $discounts The discounts instance.
+ */
+ $valid = apply_filters( 'woocommerce_coupon_validate_product_ids', $valid, $coupon, $this );
+
if ( ! $valid ) {
throw new Exception(
sprintf(
@@ -854,6 +866,18 @@ class WC_Discounts {
}
}
+ /**
+ * Filter the result of coupon product_categories validation.
+ *
+ * Return true to treat the coupon as valid for the cart, or false to reject it.
+ *
+ * @since 11.1.0
+ * @param bool $valid Whether the coupon is valid.
+ * @param WC_Coupon $coupon Coupon data.
+ * @param WC_Discounts $discounts The discounts instance.
+ */
+ $valid = apply_filters( 'woocommerce_coupon_validate_product_categories', $valid, $coupon, $this );
+
if ( ! $valid ) {
throw new Exception(
sprintf(
@@ -888,6 +912,18 @@ class WC_Discounts {
}
}
+ /**
+ * Filter the result of coupon sale_items validation.
+ *
+ * Return true to treat the coupon as valid for the cart, or false to reject it.
+ *
+ * @since 11.1.0
+ * @param bool $valid Whether the coupon is valid.
+ * @param WC_Coupon $coupon Coupon data.
+ * @param WC_Discounts $discounts The discounts instance.
+ */
+ $valid = apply_filters( 'woocommerce_coupon_validate_sale_items', $valid, $coupon, $this );
+
if ( ! $valid ) {
throw new Exception(
sprintf(
@@ -923,6 +959,18 @@ class WC_Discounts {
}
}
+ /**
+ * Filter the result of coupon excluded_items validation.
+ *
+ * Return true to treat the coupon as valid for the cart, or false to reject it.
+ *
+ * @since 11.1.0
+ * @param bool $valid Whether the coupon is valid.
+ * @param WC_Coupon $coupon Coupon data.
+ * @param WC_Discounts $discounts The discounts instance.
+ */
+ $valid = apply_filters( 'woocommerce_coupon_validate_excluded_items', $valid, $coupon, $this );
+
if ( ! $valid ) {
throw new Exception(
sprintf(
@@ -975,14 +1023,37 @@ class WC_Discounts {
}
}
- if ( ! empty( $products ) ) {
+ $valid = empty( $products );
+
+ /**
+ * Filter the result of coupon excluded_product_ids validation.
+ *
+ * Return true to treat the coupon as valid for the cart, or false to reject it. When rejecting,
+ * use the woocommerce_coupon_error filter (error code WC_Coupon::E_WC_COUPON_EXCLUDED_PRODUCTS)
+ * to customize the message shown to the shopper.
+ *
+ * @since 11.1.0
+ * @param bool $valid Whether the coupon is valid.
+ * @param WC_Coupon $coupon Coupon data.
+ * @param WC_Discounts $discounts The discounts instance.
+ */
+ $valid = apply_filters( 'woocommerce_coupon_validate_excluded_product_ids', $valid, $coupon, $this );
+
+ if ( ! $valid ) {
+ // When a filter forces rejection but no cart item matched, fall back to the generic message.
throw new Exception(
- sprintf(
- /* translators: %1$s: coupon code, %2$s: products list */
- esc_html__( 'Sorry, coupon "%1$s" is not applicable to the products: %2$s.', 'woocommerce' ),
- esc_html( $coupon->get_code() ),
- esc_html( implode( ', ', $products ) )
- ),
+ empty( $products )
+ ? sprintf(
+ /* translators: %s: coupon code */
+ esc_html__( 'Sorry, coupon "%s" is not applicable to selected products.', 'woocommerce' ),
+ esc_html( $coupon->get_code() )
+ )
+ : sprintf(
+ /* translators: %1$s: coupon code, %2$s: products list */
+ esc_html__( 'Sorry, coupon "%1$s" is not applicable to the products: %2$s.', 'woocommerce' ),
+ esc_html( $coupon->get_code() ),
+ esc_html( implode( ', ', $products ) )
+ ),
113
);
}
@@ -1023,14 +1094,37 @@ class WC_Discounts {
}
}
- if ( ! empty( $categories ) ) {
+ $valid = empty( $categories );
+
+ /**
+ * Filter the result of coupon excluded_product_categories validation.
+ *
+ * Return true to treat the coupon as valid for the cart, or false to reject it. When rejecting,
+ * use the woocommerce_coupon_error filter (error code WC_Coupon::E_WC_COUPON_EXCLUDED_CATEGORIES)
+ * to customize the message shown to the shopper.
+ *
+ * @since 11.1.0
+ * @param bool $valid Whether the coupon is valid.
+ * @param WC_Coupon $coupon Coupon data.
+ * @param WC_Discounts $discounts The discounts instance.
+ */
+ $valid = apply_filters( 'woocommerce_coupon_validate_excluded_product_categories', $valid, $coupon, $this );
+
+ if ( ! $valid ) {
+ // When a filter forces rejection but no cart item matched, fall back to the generic message.
throw new Exception(
- sprintf(
- /* translators: %1$s: coupon code, %2$s: categories list */
- esc_html__( 'Sorry, coupon "%1$s" is not applicable to the categories: %2$s.', 'woocommerce' ),
- esc_html( $coupon->get_code() ),
- esc_html( implode( ', ', array_unique( $categories ) ) )
- ),
+ empty( $categories )
+ ? sprintf(
+ /* translators: %s: coupon code */
+ esc_html__( 'Sorry, coupon "%s" is not applicable to selected products.', 'woocommerce' ),
+ esc_html( $coupon->get_code() )
+ )
+ : sprintf(
+ /* translators: %1$s: coupon code, %2$s: categories list */
+ esc_html__( 'Sorry, coupon "%1$s" is not applicable to the categories: %2$s.', 'woocommerce' ),
+ esc_html( $coupon->get_code() ),
+ esc_html( implode( ', ', array_unique( $categories ) ) )
+ ),
114
);
}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-discounts-tests.php b/plugins/woocommerce/tests/php/includes/class-wc-discounts-tests.php
index ccbd8141b10..03f4bd611f5 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-discounts-tests.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-discounts-tests.php
@@ -239,4 +239,216 @@ class WC_Discounts_Tests extends WC_Unit_Test_Case {
$this->assertWPError( $result, 'email-restricted coupon should be invalid for a non-matching customer' );
$this->assertEquals( $coupon->get_coupon_error( WC_Coupon::E_WC_COUPON_NOT_YOURS_REMOVED ), $result->get_error_message() );
}
+
+ /**
+ * @testdox The woocommerce_coupon_validate_product_ids filter can bypass or force product ID validation.
+ */
+ public function test_product_ids_validation_filter() {
+ $in_cart = WC_Helper_Product::create_simple_product();
+ $not_in_cart = WC_Helper_Product::create_simple_product();
+ WC()->cart->add_to_cart( $in_cart->get_id(), 1 );
+ $discounts = new WC_Discounts( WC()->cart );
+
+ $coupon = new WC_Coupon();
+ $coupon->set_props(
+ array(
+ 'discount_type' => 'fixed_cart',
+ 'amount' => 10,
+ 'product_ids' => array( $not_in_cart->get_id() ),
+ )
+ );
+ $coupon->save();
+
+ $result = $discounts->is_coupon_valid( $coupon );
+ $this->assertWPError( $result, 'A coupon restricted to a product not in the cart should be invalid.' );
+ $this->assertStringContainsString( 'is not applicable to selected products', $result->get_error_message(), 'The failure should come from the product_ids check.' );
+
+ add_filter( 'woocommerce_coupon_validate_product_ids', '__return_true' );
+ $this->assertTrue( $discounts->is_coupon_valid( $coupon ), 'Returning true from the filter should bypass the product ID restriction.' );
+ remove_filter( 'woocommerce_coupon_validate_product_ids', '__return_true' );
+
+ $this->assertWPError( $discounts->is_coupon_valid( $coupon ), 'Normal validation should resume once the filter is removed.' );
+
+ $valid_coupon = new WC_Coupon();
+ $valid_coupon->set_props(
+ array(
+ 'discount_type' => 'fixed_cart',
+ 'amount' => 10,
+ 'product_ids' => array( $in_cart->get_id() ),
+ )
+ );
+ $valid_coupon->save();
+
+ $this->assertTrue( $discounts->is_coupon_valid( $valid_coupon ), 'A coupon restricted to a product in the cart should be valid.' );
+ add_filter( 'woocommerce_coupon_validate_product_ids', '__return_false' );
+ $result = $discounts->is_coupon_valid( $valid_coupon );
+ $this->assertWPError( $result, 'Returning false from the filter should force rejection.' );
+ $this->assertStringContainsString( 'is not applicable to selected products', $result->get_error_message(), 'Forced rejection should surface the product_ids error.' );
+ remove_filter( 'woocommerce_coupon_validate_product_ids', '__return_false' );
+ }
+
+ /**
+ * @testdox The woocommerce_coupon_validate_product_categories filter can bypass category validation.
+ */
+ public function test_product_categories_validation_filter() {
+ $product = WC_Helper_Product::create_simple_product();
+ WC()->cart->add_to_cart( $product->get_id(), 1 );
+ $discounts = new WC_Discounts( WC()->cart );
+
+ $term = wp_insert_term( 'coupon-cat-' . uniqid(), 'product_cat' );
+ $coupon = new WC_Coupon();
+ $coupon->set_props(
+ array(
+ 'discount_type' => 'fixed_cart',
+ 'amount' => 10,
+ 'product_categories' => array( $term['term_id'] ),
+ )
+ );
+ $coupon->save();
+
+ $result = $discounts->is_coupon_valid( $coupon );
+ $this->assertWPError( $result, 'A coupon restricted to a category none of the cart items belong to should be invalid.' );
+ $this->assertStringContainsString( 'is not applicable to selected products', $result->get_error_message(), 'The failure should come from the product_categories check.' );
+
+ add_filter( 'woocommerce_coupon_validate_product_categories', '__return_true' );
+ $this->assertTrue( $discounts->is_coupon_valid( $coupon ), 'Returning true from the filter should bypass the category restriction.' );
+ remove_filter( 'woocommerce_coupon_validate_product_categories', '__return_true' );
+ }
+
+ /**
+ * @testdox The woocommerce_coupon_validate_excluded_items filter can bypass excluded item validation.
+ */
+ public function test_excluded_items_validation_filter() {
+ $product = WC_Helper_Product::create_simple_product();
+ WC()->cart->add_to_cart( $product->get_id(), 1 );
+ $discounts = new WC_Discounts( WC()->cart );
+
+ // excluded_items only runs for product-type coupons (e.g. percent).
+ $coupon = new WC_Coupon();
+ $coupon->set_props(
+ array(
+ 'discount_type' => 'percent',
+ 'amount' => 10,
+ 'excluded_product_ids' => array( $product->get_id() ),
+ )
+ );
+ $coupon->save();
+
+ $result = $discounts->is_coupon_valid( $coupon );
+ $this->assertWPError( $result, 'A product coupon whose only cart item is excluded should be invalid.' );
+ $this->assertStringContainsString( 'is not applicable to selected products', $result->get_error_message(), 'The failure should come from the excluded_items check.' );
+
+ add_filter( 'woocommerce_coupon_validate_excluded_items', '__return_true' );
+ $this->assertTrue( $discounts->is_coupon_valid( $coupon ), 'Returning true from the filter should bypass the excluded item restriction.' );
+ remove_filter( 'woocommerce_coupon_validate_excluded_items', '__return_true' );
+ }
+
+ /**
+ * @testdox The woocommerce_coupon_validate_sale_items filter can bypass sale item validation.
+ */
+ public function test_sale_items_validation_filter() {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 10 );
+ $product->set_sale_price( 5 );
+ $product->save();
+ WC()->cart->add_to_cart( $product->get_id(), 1 );
+ $discounts = new WC_Discounts( WC()->cart );
+
+ // sale_items runs for cart-type coupons that exclude sale items.
+ $coupon = new WC_Coupon();
+ $coupon->set_props(
+ array(
+ 'discount_type' => 'fixed_cart',
+ 'amount' => 10,
+ 'exclude_sale_items' => true,
+ )
+ );
+ $coupon->save();
+
+ $result = $discounts->is_coupon_valid( $coupon );
+ $this->assertWPError( $result, 'A coupon excluding sale items should be invalid when the cart holds a sale item.' );
+ $this->assertStringContainsString( 'is not valid for sale items', $result->get_error_message(), 'The failure should come from the sale_items check.' );
+
+ add_filter( 'woocommerce_coupon_validate_sale_items', '__return_true' );
+ $this->assertTrue( $discounts->is_coupon_valid( $coupon ), 'Returning true from the filter should bypass the sale item restriction.' );
+ remove_filter( 'woocommerce_coupon_validate_sale_items', '__return_true' );
+ }
+
+ /**
+ * @testdox The woocommerce_coupon_validate_excluded_product_ids filter can bypass excluded product ID validation.
+ */
+ public function test_excluded_product_ids_validation_filter() {
+ $product = WC_Helper_Product::create_simple_product();
+ WC()->cart->add_to_cart( $product->get_id(), 1 );
+ $discounts = new WC_Discounts( WC()->cart );
+
+ // excluded_product_ids runs for cart-type coupons.
+ $coupon = new WC_Coupon();
+ $coupon->set_props(
+ array(
+ 'discount_type' => 'fixed_cart',
+ 'amount' => 10,
+ 'excluded_product_ids' => array( $product->get_id() ),
+ )
+ );
+ $coupon->save();
+
+ $result = $discounts->is_coupon_valid( $coupon );
+ $this->assertWPError( $result, 'A coupon excluding the only cart product should be invalid.' );
+ $this->assertStringContainsString( 'is not applicable to the products', $result->get_error_message(), 'The failure should come from the excluded_product_ids check.' );
+
+ add_filter( 'woocommerce_coupon_validate_excluded_product_ids', '__return_true' );
+ $this->assertTrue( $discounts->is_coupon_valid( $coupon ), 'Returning true from the filter should bypass the excluded product restriction.' );
+ remove_filter( 'woocommerce_coupon_validate_excluded_product_ids', '__return_true' );
+
+ // Force-reject when no cart item matched: the generic message is used, not a dangling empty list.
+ $unmatched_coupon = new WC_Coupon();
+ $unmatched_coupon->set_props(
+ array(
+ 'discount_type' => 'fixed_cart',
+ 'amount' => 10,
+ 'excluded_product_ids' => array( $product->get_id() + 999 ),
+ )
+ );
+ $unmatched_coupon->save();
+
+ $this->assertTrue( $discounts->is_coupon_valid( $unmatched_coupon ), 'A coupon excluding a product not in the cart is valid by default.' );
+ add_filter( 'woocommerce_coupon_validate_excluded_product_ids', '__return_false' );
+ $result = $discounts->is_coupon_valid( $unmatched_coupon );
+ $this->assertWPError( $result, 'Returning false should reject even when no excluded product is in the cart.' );
+ $this->assertStringContainsString( 'is not applicable to selected products', $result->get_error_message(), 'With no matched products the generic message is used.' );
+ $this->assertStringNotContainsString( 'products: .', $result->get_error_message(), 'The rejection message should not contain an empty product list.' );
+ remove_filter( 'woocommerce_coupon_validate_excluded_product_ids', '__return_false' );
+ }
+
+ /**
+ * @testdox The woocommerce_coupon_validate_excluded_product_categories filter can bypass excluded category validation.
+ */
+ public function test_excluded_product_categories_validation_filter() {
+ $term = wp_insert_term( 'excl-cat-' . uniqid(), 'product_cat' );
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_category_ids( array( $term['term_id'] ) );
+ $product->save();
+ WC()->cart->add_to_cart( $product->get_id(), 1 );
+ $discounts = new WC_Discounts( WC()->cart );
+
+ // excluded_product_categories runs for cart-type coupons.
+ $coupon = new WC_Coupon();
+ $coupon->set_props(
+ array(
+ 'discount_type' => 'fixed_cart',
+ 'amount' => 10,
+ 'excluded_product_categories' => array( $term['term_id'] ),
+ )
+ );
+ $coupon->save();
+
+ $result = $discounts->is_coupon_valid( $coupon );
+ $this->assertWPError( $result, 'A coupon excluding the cart product\'s category should be invalid.' );
+ $this->assertStringContainsString( 'is not applicable to the categories', $result->get_error_message(), 'The failure should come from the excluded_product_categories check.' );
+
+ add_filter( 'woocommerce_coupon_validate_excluded_product_categories', '__return_true' );
+ $this->assertTrue( $discounts->is_coupon_valid( $coupon ), 'Returning true from the filter should bypass the excluded category restriction.' );
+ remove_filter( 'woocommerce_coupon_validate_excluded_product_categories', '__return_true' );
+ }
}