Commit 7ed2c5485ec for woocommerce

commit 7ed2c5485ec9f1e465aef7026c0e1f047f069339
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Mon May 11 19:17:44 2026 +0300

    Cancel review-request email on any transition out of completed (#64756)

    * Cancel and gate the review-request email when the order leaves completed

    WOOPLUG-6672: a delayed review-request email could still send for an
    order that was no longer in `completed`. The scheduler only listened
    for `cancelled`, `refunded`, `trash`, and `before_delete`, so any other
    transition (completed -> processing, completed -> on-hold, custom
    statuses, …) left the action queued. The email's `trigger()` had no
    status guard, so once the action fired the email went out regardless
    of the order's current state.

    Two-layer fix:

    - Scheduler::handle_status_changed() listens on
      `woocommerce_order_status_changed` and unschedules whenever the order
      leaves `completed`, no matter which status it moves to. The
      per-status hooks for `cancelled` / `refunded` are dropped because the
      generic listener already covers them.
    - WC_Email_Customer_Review_Request::trigger() now consults the same
      `woocommerce_review_order_eligible_statuses` filter the page-load
      endpoint and submission handler use. If the filter rejects the
      current order status, the email is not dispatched.

    Coverage:

    - SchedulerTest::test_status_transition_cancels_pending_action gains
      data-set rows for `processing`, `on-hold`, `pending`, and `failed`.
    - WC_Email_Customer_Review_Request_Test gains
      test_trigger_skips_when_order_not_in_eligible_status and
      test_trigger_eligible_statuses_filter_can_widen.

    57 tests in Scheduler + 14 in the email test pass locally; PHPCS and
    PHPStan clean on changed files.

    * Use 10.8.0 as the @since version for the new eligibility check

    The whole Customer Review Request feature ships in 10.8.0, so the new
    trigger guard and its filter docblock should match.

    * Add changelog entry for the review-request cancellation fix

    * Drop manual changelog entry; the PR-description bot creates one automatically

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

    * Use targeted remove_filter() in the eligible-statuses test

    remove_all_filters() can wipe unrelated callbacks and leak across
    tests. Capture the closure and remove only that specific callback in
    a finally block, per CodeRabbit feedback.

    * Respect woocommerce_review_order_eligible_statuses in handle_status_changed

    Address ayushpahwa review feedback:

    - handle_status_changed() now consults the same
      'woocommerce_review_order_eligible_statuses' filter that
      WC_Email_Customer_Review_Request::trigger() uses. A site that widens
      the filter (e.g. to include 'processing') keeps the email queued
      through transitions inside its expanded eligible set; the unschedule
      only fires when the new status is outside the eligible set.
    - Refresh the handle_cancellation docblock to match current wiring
      (trash + delete direct, status changes via handle_status_changed).

    * Add docblock to apply_filters() call so phpcs MissingHookComment passes

    ---------

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

diff --git a/plugins/woocommerce/changelog/64756-wooplug-6672-cancel-review-email-on-status-change b/plugins/woocommerce/changelog/64756-wooplug-6672-cancel-review-email-on-status-change
new file mode 100644
index 00000000000..4d84b78c6ef
--- /dev/null
+++ b/plugins/woocommerce/changelog/64756-wooplug-6672-cancel-review-email-on-status-change
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Cancel the delayed Customer Review Request email when the order leaves `completed` for any status, and refuse to send if the order is no longer in an eligible status at trigger time.
\ 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 f286e5bf367..a7e3192e637 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
@@ -5,6 +5,7 @@
  * @package WooCommerce\Emails
  */

+use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Utilities\FeaturesUtil;

 defined( 'ABSPATH' ) || exit;
@@ -98,13 +99,51 @@ if ( ! class_exists( 'WC_Email_Customer_Review_Request', false ) ) :
 				$this->placeholders['{order_number}'] = $order->get_order_number();
 			}

-			if ( $this->is_enabled() && $this->get_recipient() ) {
+			if ( $this->is_enabled() && $this->get_recipient() && $this->is_order_eligible_for_send() ) {
 				$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
 			}

 			$this->restore_locale();
 		}

+		/**
+		 * Defence-in-depth status check at send time.
+		 *
+		 * The scheduler unschedules the pending action when the order leaves
+		 * `completed`, but a race window or a direct invocation of the action
+		 * hook can still reach `trigger()` for an order that is no longer in
+		 * an eligible state. Checking the same `woocommerce_review_order_eligible_statuses`
+		 * filter the page-load endpoint and submission handler use keeps the
+		 * three entry points consistent.
+		 *
+		 * @since 10.8.0
+		 * @return bool
+		 */
+		protected function is_order_eligible_for_send(): bool {
+			if ( ! $this->object instanceof WC_Order ) {
+				return false;
+			}
+
+			/**
+			 * Filter the order statuses that are eligible to receive the review-request email.
+			 *
+			 * Defaults to `completed` only. Same hook the page-load endpoint and the
+			 * submission handler use, so the three entry points stay aligned.
+			 *
+			 * @since 10.8.0
+			 *
+			 * @param string[] $eligible_statuses Default: `[ 'completed' ]`.
+			 * @param WC_Order $order             Order being inspected.
+			 */
+			$eligible_statuses = (array) apply_filters(
+				'woocommerce_review_order_eligible_statuses',
+				array( OrderStatus::COMPLETED ),
+				$this->object
+			);
+
+			return in_array( $this->object->get_status(), $eligible_statuses, true );
+		}
+
 		/**
 		 * Get default email subject.
 		 *
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php b/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php
index f2a5302d942..bcd262b59b2 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/Scheduler.php
@@ -7,6 +7,7 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Internal\OrderReviews;

+use Automattic\WooCommerce\Enums\OrderStatus;
 use WC_Email_Customer_Review_Request;
 use WC_Order;

@@ -55,12 +56,62 @@ class Scheduler {
 	 */
 	final public function init(): void {
 		add_action( 'woocommerce_order_status_completed', array( $this, 'handle_woocommerce_order_status_completed' ), 10, 1 );
-		add_action( 'woocommerce_order_status_cancelled', array( $this, 'handle_cancellation' ), 10, 1 );
-		add_action( 'woocommerce_order_status_refunded', array( $this, 'handle_cancellation' ), 10, 1 );
+		// Catch every transition out of `completed` (cancelled, refunded,
+		// processing, on-hold, pending, failed, custom statuses…) so the
+		// pending email is unscheduled regardless of which status the order
+		// moves to.
+		add_action( 'woocommerce_order_status_changed', array( $this, 'handle_status_changed' ), 10, 3 );
 		add_action( 'woocommerce_trash_order', array( $this, 'handle_cancellation' ), 10, 1 );
 		add_action( 'woocommerce_before_delete_order', array( $this, 'handle_cancellation' ), 10, 1 );
 	}

+	/**
+	 * Unschedule the pending review-request email whenever the order leaves
+	 * the eligible state. `woocommerce_order_status_changed` fires for every
+	 * transition, so a single listener covers cancelled / refunded /
+	 * processing / on-hold / pending / failed / custom statuses in one place.
+	 *
+	 * Eligibility is read from the same `woocommerce_review_order_eligible_statuses`
+	 * filter the trigger uses, so a site that widens the filter (e.g. to also
+	 * accept `processing`) keeps the email queued through transitions inside
+	 * its expanded eligible set.
+	 *
+	 * @internal
+	 *
+	 * @param int    $order_id   Order ID.
+	 * @param string $old_status Previous status (sans `wc-` prefix).
+	 * @param string $new_status New status (sans `wc-` prefix).
+	 */
+	public function handle_status_changed( int $order_id, string $old_status, string $new_status ): void {
+		$order = wc_get_order( $order_id );
+
+		/**
+		 * Filter the order statuses that are eligible to receive the review-request email.
+		 *
+		 * Same hook the email's `trigger()` consults at send time; documented on
+		 * `WC_Email_Customer_Review_Request::is_order_eligible_for_send()`.
+		 *
+		 * @since 10.8.0
+		 *
+		 * @param string[]      $eligible_statuses Default: `[ 'completed' ]`.
+		 * @param WC_Order|null $order             Order being inspected, or null if it could not be loaded.
+		 */
+		$eligible_statuses = (array) apply_filters(
+			'woocommerce_review_order_eligible_statuses',
+			array( OrderStatus::COMPLETED ),
+			$order instanceof WC_Order ? $order : null
+		);
+
+		$was_eligible = in_array( $old_status, $eligible_statuses, true );
+		$is_eligible  = in_array( $new_status, $eligible_statuses, true );
+
+		if ( ! $was_eligible || $is_eligible ) {
+			return;
+		}
+
+		$this->handle_cancellation( $order_id );
+	}
+
 	/**
 	 * Schedule the review-request email when an order becomes complete.
 	 *
@@ -111,13 +162,13 @@ class Scheduler {
 	}

 	/**
-	 * Cancel any pending review-request action when the order leaves the
-	 * eligible state.
+	 * Cancel any pending review-request action and clear the scheduled-at meta.
 	 *
-	 * Hooked into `woocommerce_order_status_cancelled`,
-	 * `woocommerce_order_status_refunded`, `woocommerce_trash_order` and
-	 * `woocommerce_before_delete_order` so full refunds, cancellations, trashes
-	 * and deletions all clean up the pending job.
+	 * Hooked directly into `woocommerce_trash_order` and
+	 * `woocommerce_before_delete_order` for the trash/delete lifecycle events,
+	 * and called from `handle_status_changed()` for every status transition
+	 * out of an eligible status (cancelled, refunded, processing, on-hold,
+	 * pending, failed, custom statuses…).
 	 *
 	 * @internal
 	 *
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 a2fb7805a25..abfab4e7ab9 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
@@ -209,4 +209,57 @@ class WC_Email_Customer_Review_Request_Test extends \WC_Unit_Test_Case {

 		$this->assertSame( $before, $after, 'Disabled review-request email must not dispatch any mail.' );
 	}
+
+	/**
+	 * @testdox trigger() refuses to send when the order is no longer in an eligible status.
+	 *
+	 * Defence-in-depth against the scheduler missing a transition out of
+	 * `completed` (WOOPLUG-6672): even if the action fires, the email must
+	 * not go out for an order that is no longer eligible.
+	 */
+	public function test_trigger_skips_when_order_not_in_eligible_status(): void {
+		$this->sut->update_option( 'enabled', 'yes' );
+		$this->sut->enabled = 'yes';
+
+		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
+		$order->set_status( 'completed' );
+		$order->save();
+		$order->set_status( 'processing' );
+		$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 for non-eligible status.' );
+	}
+
+	/**
+	 * @testdox The woocommerce_review_order_eligible_statuses filter widens the eligible set for trigger().
+	 */
+	public function test_trigger_eligible_statuses_filter_can_widen(): void {
+		$this->sut->update_option( 'enabled', 'yes' );
+		$this->sut->enabled = 'yes';
+
+		$order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
+		$order->set_status( 'processing' );
+		$order->save();
+
+		$widen_statuses = static function () {
+			return array( 'completed', 'processing' );
+		};
+		add_filter( 'woocommerce_review_order_eligible_statuses', $widen_statuses );
+
+		$mailer = tests_retrieve_phpmailer_instance();
+		$before = count( $mailer->mock_sent );
+		try {
+			$this->sut->trigger( $order->get_id() );
+			$after = count( $mailer->mock_sent );
+		} finally {
+			remove_filter( 'woocommerce_review_order_eligible_statuses', $widen_statuses );
+		}
+
+		$this->assertSame( $before + 1, $after, 'Filter must allow non-default statuses to receive the email.' );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SchedulerTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SchedulerTest.php
index 2f06f63adb5..0e225a6b965 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SchedulerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SchedulerTest.php
@@ -136,8 +136,13 @@ class SchedulerTest extends WC_Unit_Test_Case {
 	 */
 	public function cancellation_status_provider(): array {
 		return array(
-			'cancelled' => array( 'cancelled' ),
-			'refunded'  => array( 'refunded' ),
+			'cancelled'  => array( 'cancelled' ),
+			'refunded'   => array( 'refunded' ),
+			// Any other transition out of `completed` must also unschedule.
+			'processing' => array( 'processing' ),
+			'on-hold'    => array( 'on-hold' ),
+			'pending'    => array( 'pending' ),
+			'failed'     => array( 'failed' ),
 		);
 	}

@@ -169,6 +174,32 @@ class SchedulerTest extends WC_Unit_Test_Case {
 		$this->assertFalse( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order_id ) ) );
 	}

+	/**
+	 * @testdox The woocommerce_review_order_eligible_statuses filter keeps the action queued through transitions inside the widened set.
+	 */
+	public function test_status_changed_respects_eligible_statuses_filter(): void {
+		$widen = static function () {
+			return array( 'completed', 'processing' );
+		};
+		add_filter( 'woocommerce_review_order_eligible_statuses', $widen );
+
+		try {
+			$order = $this->create_pending_order();
+			$order->update_status( 'completed' );
+			$this->assertTrue( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+
+			// `processing` is eligible per the filter, so the pending action stays.
+			$order->update_status( 'processing' );
+			$this->assertTrue( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+
+			// `on-hold` is NOT in the filter's eligible set, so the action is now unscheduled.
+			$order->update_status( 'on-hold' );
+			$this->assertFalse( (bool) as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+		} finally {
+			remove_filter( 'woocommerce_review_order_eligible_statuses', $widen );
+		}
+	}
+
 	/**
 	 * @testdox Cancellation unschedules the action even when the tracking meta is missing.
 	 *