Commit 58b110fd433 for woocommerce

commit 58b110fd433b96c15ca622ac9b62917d09e0e20b
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Fri May 15 13:54:37 2026 +0300

    Skip review-request email when the order has nothing reviewable (#64960)

    * Skip review-request email when nothing is reviewable

    Gate scheduling and sending on ItemEligibility::has_actionable_items()
    so the customer never receives an email that would land on the
    empty-state page (all products have reviews disabled per-product or
    site-wide, or every reviewable item is already reviewed on this order).
    The send-time check covers eligibility loss between scheduling and the
    delayed send.

    * Document empty-state fallback for direct-URL visits

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

    * Document woocommerce_review_order_eligible_items at has_actionable_items call site

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/64960-wooplug-6704-skip-scheduling-review-request-email-when-nothing-is b/plugins/woocommerce/changelog/64960-wooplug-6704-skip-scheduling-review-request-email-when-nothing-is
new file mode 100644
index 00000000000..965dd1ed542
--- /dev/null
+++ b/plugins/woocommerce/changelog/64960-wooplug-6704-skip-scheduling-review-request-email-when-nothing-is
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Skip scheduling and sending the customer review-request email when the order has no reviewable items (every product has reviews disabled per-product or site-wide, or every reviewable item is already reviewed for that order).
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-review-request.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-review-request.php
index a7e3192e637..06a6bb2cb43 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-review-request.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-review-request.php
@@ -6,6 +6,7 @@
  */

 use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility;
 use Automattic\WooCommerce\Utilities\FeaturesUtil;

 defined( 'ABSPATH' ) || exit;
@@ -141,7 +142,16 @@ if ( ! class_exists( 'WC_Email_Customer_Review_Request', false ) ) :
 				$this->object
 			);

-			return in_array( $this->object->get_status(), $eligible_statuses, true );
+			if ( ! in_array( $this->object->get_status(), $eligible_statuses, true ) ) {
+				return false;
+			}
+
+			// Eligibility can change between scheduling and sending (e.g. the
+			// admin disables site-wide reviews during the delay window, or the
+			// customer reviews everything via another entry point). Re-check at
+			// send time so the email is silently dropped instead of pointing
+			// the customer at the empty-state page.
+			return ItemEligibility::has_actionable_items( $this->object );
 		}

 		/**
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php b/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php
index 220fe10aba9..c84acd07cf2 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php
@@ -241,6 +241,56 @@ class ItemEligibility {
 		);
 	}

+	/**
+	 * Whether an order has at least one item the customer can still review.
+	 *
+	 * Walks the same eligible-items list and per-item decisions the page
+	 * renders, so the answer matches what `customer-review-order.php` would
+	 * show: items with `STATUS_SKIP` (reviews disabled on the product, or
+	 * site-wide via `woocommerce_enable_reviews`) and items already reviewed
+	 * on this order are excluded. Any remaining `STATUS_FORM` row without a
+	 * matching review counts as actionable.
+	 *
+	 * Callers in the email pipeline use this to short-circuit scheduling and
+	 * sending when the customer would otherwise land on the empty-state page.
+	 *
+	 * @since 10.9.0
+	 *
+	 * @param WC_Order $order Order being inspected.
+	 * @return bool True when at least one item is still reviewable.
+	 */
+	public static function has_actionable_items( WC_Order $order ): bool {
+		/**
+		 * Filter the eligible items considered when deciding whether the
+		 * Customer Review Request email should fire for an order.
+		 *
+		 * Same hook the page template, submission handler, and endpoint use,
+		 * so all four entry points agree on the eligible-items set.
+		 *
+		 * @since 10.9.0
+		 *
+		 * @param WC_Order_Item[] $items Order line items.
+		 * @param WC_Order        $order The order being inspected.
+		 */
+		$items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $order->get_items(), $order );
+		self::preload_for_items( $items, $order );
+
+		foreach ( $items as $item ) {
+			if ( ! $item instanceof WC_Order_Item_Product ) {
+				continue;
+			}
+			$decision = self::decide( $item, $order );
+			if ( self::STATUS_SKIP === $decision['status'] ) {
+				continue;
+			}
+			if ( ! ( $decision['comment'] instanceof WP_Comment ) ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
 	/**
 	 * Drop fully-refunded line items from the eligible-items list.
 	 *
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php b/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php
index bcd262b59b2..aacaaf51175 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php
@@ -154,6 +154,15 @@ class Scheduler {
 			return;
 		}

+		// Don't queue an email whose link would land on the empty-state page:
+		// every product on the order has reviews disabled (per-product or
+		// site-wide via `woocommerce_enable_reviews`), or every reviewable
+		// item already has a review tied to this order.
+		if ( ! ItemEligibility::has_actionable_items( $order ) ) {
+			$this->log_skip( $order_id, 'no reviewable items' );
+			return;
+		}
+
 		$when = time() + $email->get_delay_seconds();
 		as_schedule_single_action( $when, self::ACTION_HOOK, array( $order_id ) );

diff --git a/plugins/woocommerce/templates/order/customer-review-order-empty.php b/plugins/woocommerce/templates/order/customer-review-order-empty.php
index 3ffe6c7cada..54cb128806f 100644
--- a/plugins/woocommerce/templates/order/customer-review-order-empty.php
+++ b/plugins/woocommerce/templates/order/customer-review-order-empty.php
@@ -30,6 +30,9 @@ $meta_parts = \Automattic\WooCommerce\Internal\OrderReviews\Meta::parts_for_orde
 		if ( $reviewed_count > 0 ) {
 			esc_html_e( 'Thank you for your reviews', 'woocommerce' );
 		} else {
+			// Defensive fallback for direct-URL visits (bookmark, admin-shared link).
+			// The email pipeline never schedules or sends when an order has no
+			// reviewable items, so customers don't reach this branch via the email.
 			esc_html_e( 'Nothing to review here', 'woocommerce' );
 		}
 		?>
diff --git a/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-review-request-test.php b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-review-request-test.php
index 6bb0ed461d9..44b91f9896a 100644
--- a/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-review-request-test.php
+++ b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-review-request-test.php
@@ -256,6 +256,33 @@ class WC_Email_Customer_Review_Request_Test extends \WC_Unit_Test_Case {
 		$this->assertSame( $before, $after, 'Review-request email must not dispatch for non-eligible status.' );
 	}

+	/**
+	 * @testdox trigger() skips orders whose items all have reviews disabled.
+	 *
+	 * Eligibility can change between scheduling and sending — e.g. the admin
+	 * disables product reviews site-wide during the delay window — so the
+	 * email gates on `ItemEligibility::has_actionable_items()` at send time.
+	 */
+	public function test_trigger_skips_when_no_actionable_items(): void {
+		$this->sut->update_option( 'enabled', 'yes' );
+		$this->sut->enabled = 'yes';
+
+		$product = \WC_Helper_Product::create_simple_product();
+		$product->set_reviews_allowed( false );
+		$product->save();
+
+		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order( 1, $product );
+		$order->set_status( 'completed' );
+		$order->save();
+
+		$mailer = tests_retrieve_phpmailer_instance();
+		$before = count( $mailer->mock_sent );
+		$this->sut->trigger( $order->get_id() );
+		$after = count( $mailer->mock_sent );
+
+		$this->assertSame( $before, $after, 'Review-request email must not dispatch when nothing on the order is reviewable.' );
+	}
+
 	/**
 	 * @testdox The woocommerce_review_order_eligible_statuses filter widens the eligible set for trigger().
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php
index 7b4cf23c4f8..d1c1895fc16 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php
@@ -278,4 +278,54 @@ class ItemEligibilityTest extends WC_Unit_Test_Case {
 		$this->assertNotNull( $decision['comment'] );
 		$this->assertSame( 0, $call_count, 'decide() should not query when preload_for_items() has cached the result.' );
 	}
+
+	/**
+	 * @testdox has_actionable_items() returns true when at least one item is reviewable.
+	 */
+	public function test_has_actionable_items_true_for_default_order(): void {
+		$built = $this->make_order();
+
+		$this->assertTrue( ItemEligibility::has_actionable_items( $built['order'] ) );
+	}
+
+	/**
+	 * @testdox has_actionable_items() returns false when every product has reviews disabled.
+	 */
+	public function test_has_actionable_items_false_when_all_items_disabled(): void {
+		$built   = $this->make_order();
+		$product = wc_get_product( $built['product_id'] );
+		$product->set_reviews_allowed( false );
+		$product->save();
+
+		$this->assertFalse( ItemEligibility::has_actionable_items( $built['order'] ) );
+	}
+
+	/**
+	 * @testdox has_actionable_items() returns false when reviews are disabled site-wide.
+	 */
+	public function test_has_actionable_items_false_when_site_wide_reviews_disabled(): void {
+		$built    = $this->make_order();
+		$previous = get_option( 'woocommerce_enable_reviews', 'yes' );
+		update_option( 'woocommerce_enable_reviews', 'no' );
+		remove_post_type_support( 'product', 'comments' );
+
+		try {
+			$this->assertFalse( ItemEligibility::has_actionable_items( $built['order'] ) );
+		} finally {
+			update_option( 'woocommerce_enable_reviews', $previous );
+			if ( 'yes' === $previous ) {
+				add_post_type_support( 'product', 'comments' );
+			}
+		}
+	}
+
+	/**
+	 * @testdox has_actionable_items() returns false once every reviewable item is reviewed.
+	 */
+	public function test_has_actionable_items_false_when_all_items_reviewed(): void {
+		$built = $this->make_order( 'all-done@example.test' );
+		$this->insert_review( $built['product_id'], 'all-done@example.test', 'Done.', 5, (int) $built['order']->get_id() );
+
+		$this->assertFalse( ItemEligibility::has_actionable_items( $built['order'] ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SchedulerTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SchedulerTest.php
index dbd9f09448b..d88bab23924 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SchedulerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SchedulerTest.php
@@ -6,7 +6,9 @@ namespace Automattic\WooCommerce\Tests\Internal\OrderReviews;
 use Automattic\WooCommerce\Internal\OrderReviews\Scheduler;
 use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
 use WC_Email_Customer_Review_Request;
+use WC_Helper_Product;
 use WC_Order;
+use WC_Order_Item_Product;
 use WC_Unit_Test_Case;

 /**
@@ -105,6 +107,79 @@ class SchedulerTest extends WC_Unit_Test_Case {
 		$this->assertSame( $first, $second, 'Scheduled-at meta should not change on re-completion.' );
 	}

+	/**
+	 * @testdox Scheduling is skipped when every product on the order has reviews disabled per-product.
+	 */
+	public function test_skips_when_all_items_have_reviews_disabled(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_reviews_allowed( false );
+		$product->save();
+
+		$order = $this->create_pending_order_with_product( $product );
+		$order->update_status( 'completed' );
+
+		$this->assertFalse( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+		$this->assertEmpty( wc_get_order( $order->get_id() )->get_meta( Scheduler::SCHEDULED_META_KEY ) );
+	}
+
+	/**
+	 * @testdox Scheduling is skipped when site-wide reviews are disabled.
+	 *
+	 * The `woocommerce_enable_reviews=no` setting removes `comments` support
+	 * from the product post type so `comments_open()` returns false for every
+	 * product, which `ItemEligibility::has_actionable_items()` reads.
+	 */
+	public function test_skips_when_site_wide_reviews_disabled(): void {
+		$previous = get_option( 'woocommerce_enable_reviews', 'yes' );
+		update_option( 'woocommerce_enable_reviews', 'no' );
+		// `comments` post-type support is registered at init based on the
+		// option, so reflect the option change for the rest of this test.
+		remove_post_type_support( 'product', 'comments' );
+
+		try {
+			$order = $this->create_pending_order();
+			$order->update_status( 'completed' );
+
+			$this->assertFalse( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+			$this->assertEmpty( wc_get_order( $order->get_id() )->get_meta( Scheduler::SCHEDULED_META_KEY ) );
+		} finally {
+			update_option( 'woocommerce_enable_reviews', $previous );
+			if ( 'yes' === $previous ) {
+				add_post_type_support( 'product', 'comments' );
+			}
+		}
+	}
+
+	/**
+	 * @testdox A mixed order with at least one reviewable item still schedules.
+	 */
+	public function test_schedules_when_at_least_one_item_is_reviewable(): void {
+		$reviewable = WC_Helper_Product::create_simple_product();
+		$disabled   = WC_Helper_Product::create_simple_product();
+		$disabled->set_reviews_allowed( false );
+		$disabled->save();
+
+		$order = OrderHelper::create_order( 1, $reviewable );
+		$item  = new WC_Order_Item_Product();
+		$item->set_props(
+			array(
+				'product'  => $disabled,
+				'quantity' => 1,
+				'subtotal' => wc_get_price_excluding_tax( $disabled ),
+				'total'    => wc_get_price_excluding_tax( $disabled ),
+			)
+		);
+		$item->save();
+		$order->add_item( $item );
+		$order->set_status( 'pending' );
+		$order->calculate_totals();
+		$order->save();
+
+		$order->update_status( 'completed' );
+
+		$this->assertTrue( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+	}
+
 	/**
 	 * @testdox woocommerce_should_send_review_request=false skips scheduling.
 	 */
@@ -236,6 +311,18 @@ class SchedulerTest extends WC_Unit_Test_Case {
 		return $order;
 	}

+	/**
+	 * Create a pending order whose single line item is the provided product.
+	 *
+	 * @param \WC_Product $product Product to add to the order.
+	 */
+	private function create_pending_order_with_product( \WC_Product $product ): WC_Order {
+		$order = OrderHelper::create_order( 1, $product );
+		$order->set_status( 'pending' );
+		$order->save();
+		return $order;
+	}
+
 	/**
 	 * Get the review-request email instance from the mailer.
 	 */