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.
*