Commit 1aaa8fc8de3 for woocommerce
commit 1aaa8fc8de3727f4e33ee09f353fbcc0ce2672bf
Author: Mayisha <33387139+Mayisha@users.noreply.github.com>
Date: Thu Jul 23 11:20:50 2026 +0600
[AI Regression Review] Fix abandoned cart recovery auto-send scheduling gates (#66808)
* Fix abandoned cart scheduler ignoring eligible-statuses filter
The auto-send scheduler hardcoded pending for both scheduling and
cancellation, ignoring the
woocommerce_abandoned_cart_recovery_eligible_statuses filter that the
send/manual paths honor. Derive eligibility from the same filter, and
re-check is_automated() at send time so disabling automation also stops
queued sends.
* Limit abandoned cart auto-scheduling to customer checkout orders
The scheduler queued the recovery email for every pending order,
including admin invoices, REST-created orders, and subscription
renewals. Gate handle_new_order() on created_via so only classic
(checkout) and block (store-api) checkout orders are scheduled.
* Add changelog entry for abandoned cart recovery scheduler fixes
* Add test for store-api order scheduling via draft transition wiring
Drive the real production path: the data store re-fires
woocommerce_new_order on the checkout-draft -> pending transition, which
is how store-api orders reach the abandoned cart recovery scheduler.
* Address feedback
* Remove redundant instanceof check
---------
Co-authored-by: Malith Senaweera <6216000+malithsen@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/fix-abandoned-cart-recovery-scheduler b/plugins/woocommerce/changelog/fix-abandoned-cart-recovery-scheduler
new file mode 100644
index 00000000000..2d45d5dca0a
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-abandoned-cart-recovery-scheduler
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+Comment: Schedule the abandoned cart recovery email only for orders created via checkout, honor the eligible-statuses filter when scheduling and cancelling, and skip queued sends when automated sending is turned off.
+
diff --git a/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php b/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php
index 82c104e250f..da03b0eb0f1 100644
--- a/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php
+++ b/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php
@@ -15,12 +15,14 @@ use WC_Order;
/**
* Schedules and cancels the automated abandoned-cart recovery email via Action Scheduler.
*
- * Listens for new orders in the `pending` status to enqueue a single
+ * Listens for new orders in an abandoned-checkout status to enqueue a single
* `woocommerce_send_abandoned_cart_recovery_notification` action that fires
* after `WC_Email_Customer_Abandoned_Cart_Recovery::AUTO_SEND_DELAY_SECONDS`.
- * The pending action is cancelled when the order transitions out of `pending`
- * or is trashed/deleted, so a customer who completes checkout before the delay
- * elapses never receives the nudge.
+ * The pending action is cancelled when the order transitions out of the
+ * eligible-status set or is trashed/deleted, so a customer who completes
+ * checkout before the delay elapses never receives the nudge. Eligibility
+ * comes from the same `woocommerce_abandoned_cart_recovery_eligible_statuses`
+ * filter the send/manual paths use.
*
* Per-order idempotency is enforced two ways: a scheduled-at meta key blocks
* re-scheduling for the same order, and the trigger-time send gate refuses to
@@ -56,6 +58,14 @@ class Scheduler {
*/
public const SCHEDULED_META_KEY = '_abandoned_cart_recovery_scheduled_at';
+ /**
+ * Order-creation origins (`created_via`) eligible for an automated send:
+ * the classic checkout and the block (Store API) checkout.
+ *
+ * @var string[]
+ */
+ private const ELIGIBLE_CREATED_VIA = array( 'checkout', 'store-api' );
+
/**
* Register hooks and filters.
*
@@ -65,9 +75,8 @@ class Scheduler {
*/
final public function init(): void {
add_action( 'woocommerce_new_order', array( $this, 'handle_new_order' ), 10, 2 );
- // Catch every transition out of `pending` (processing, completed,
- // cancelled, failed, refunded, custom statuses…) so the pending send is
- // unscheduled regardless of which status the order moves to.
+ // Catch every transition out of the eligible set so the pending send
+ // 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 );
@@ -75,11 +84,12 @@ class Scheduler {
}
/**
- * Schedule the automated send when a `pending` order is created.
+ * Schedule the automated send when an order is created in an eligible status.
*
- * No-op when the order is not `pending`, when the email is disabled or
- * suppressed, when the merchant has opted out of automated sends, or when
- * this order already has a pending or completed send.
+ * No-op when the order is not eligible, when it was not created by a
+ * customer checkout flow, when the email is disabled or suppressed, when
+ * the merchant has opted out of automated sends, or when this order
+ * already has a pending or completed send.
*
* @internal
*
@@ -95,7 +105,13 @@ class Scheduler {
return;
}
- if ( OrderStatus::PENDING !== $order->get_status() ) {
+ // Only nudge customers who abandoned a checkout — pending orders can
+ // also originate from admin invoices, the REST API, or renewals.
+ if ( ! in_array( $order->get_created_via(), self::ELIGIBLE_CREATED_VIA, true ) ) {
+ return;
+ }
+
+ if ( ! in_array( $order->get_status(), $this->get_eligible_statuses( $order ), true ) ) {
return;
}
@@ -125,9 +141,9 @@ class Scheduler {
/**
* Unschedule the pending recovery send whenever the order leaves the
- * `pending` status. `woocommerce_order_status_changed` fires for every
- * transition, so a single listener covers processing / completed /
- * cancelled / failed / refunded / custom statuses in one place.
+ * eligible-status set. `woocommerce_order_status_changed` fires for every
+ * transition, so a single listener covers all statuses in one place, and
+ * transitions inside a filter-widened eligible set keep the send queued.
*
* @internal
*
@@ -136,7 +152,17 @@ class Scheduler {
* @param string $new_status New status (sans `wc-` prefix).
*/
public function handle_status_changed( int $order_id, string $old_status, string $new_status ): void {
- if ( OrderStatus::PENDING !== $old_status || OrderStatus::PENDING === $new_status ) {
+ $order = wc_get_order( $order_id );
+ if ( ! $order instanceof WC_Order ) {
+ return;
+ }
+
+ $eligible_statuses = $this->get_eligible_statuses( $order );
+
+ $was_eligible = in_array( $old_status, $eligible_statuses, true );
+ $is_eligible = in_array( $new_status, $eligible_statuses, true );
+
+ if ( ! $was_eligible || $is_eligible ) {
return;
}
@@ -171,10 +197,9 @@ class Scheduler {
/**
* Dispatch the recovery email when the scheduled AS action fires.
*
- * Resolve the email lazily through the mailer (which loads the class) and delegate the
- * actual send to `trigger()`, which keeps every send-time gate
- * (enabled, recipient, eligibility, unsubscribed, dedup-meta) in one
- * place.
+ * Resolve the email lazily through the mailer, re-check the automation
+ * opt-in, and delegate the actual send to `trigger()`, which keeps every
+ * send-time gate in one place.
*
* @internal
*
@@ -186,6 +211,10 @@ class Scheduler {
return;
}
+ if ( ! $email->is_automated() ) {
+ return;
+ }
+
$dispatched = $email->trigger( $order_id );
if ( ! $dispatched ) {
return;
@@ -204,6 +233,29 @@ class Scheduler {
);
}
+ /**
+ * Order statuses eligible for a recovery send, shared with the send-time
+ * gate in `WC_Email_Customer_Abandoned_Cart_Recovery::is_order_eligible_for_recovery()`.
+ *
+ * @param WC_Order|null $order Order being inspected, or null if it could not be loaded.
+ * @return string[]
+ */
+ private function get_eligible_statuses( ?WC_Order $order ): array {
+ /**
+ * Filter the order statuses that are eligible to receive the abandoned cart recovery email.
+ *
+ * @since 11.0.0
+ *
+ * @param string[] $eligible_statuses Default: `pending`.
+ * @param WC_Order|null $order Order being inspected, or null if it could not be loaded.
+ */
+ return (array) apply_filters(
+ 'woocommerce_abandoned_cart_recovery_eligible_statuses',
+ array( OrderStatus::PENDING ),
+ $order
+ );
+ }
+
/**
* Retrieve the recovery email class instance from the mailer.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php b/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php
index 5c9e01846fb..00c490ef307 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php
@@ -117,6 +117,7 @@ class SchedulerTest extends WC_Unit_Test_Case {
*/
public function test_handle_scheduled_send_dispatches_to_email(): void {
$order = OrderHelper::create_order();
+ $order->set_created_via( 'checkout' );
$order->set_status( OrderStatus::PENDING );
$order->save();
$order->set_date_created( time() - WC_Email_Customer_Abandoned_Cart_Recovery::ABANDONMENT_THRESHOLD_SECONDS - MINUTE_IN_SECONDS );
@@ -141,6 +142,7 @@ class SchedulerTest extends WC_Unit_Test_Case {
*/
public function test_handle_scheduled_send_records_order_note_on_success(): void {
$order = OrderHelper::create_order();
+ $order->set_created_via( 'checkout' );
$order->set_status( OrderStatus::PENDING );
$order->save();
$order->set_date_created( time() - WC_Email_Customer_Abandoned_Cart_Recovery::ABANDONMENT_THRESHOLD_SECONDS - MINUTE_IN_SECONDS );
@@ -189,6 +191,7 @@ class SchedulerTest extends WC_Unit_Test_Case {
*/
public function test_action_dispatch_reaches_handle_scheduled_send(): void {
$order = OrderHelper::create_order();
+ $order->set_created_via( 'checkout' );
$order->set_status( OrderStatus::PENDING );
$order->save();
$order->set_date_created( time() - WC_Email_Customer_Abandoned_Cart_Recovery::ABANDONMENT_THRESHOLD_SECONDS - MINUTE_IN_SECONDS );
@@ -216,6 +219,7 @@ class SchedulerTest extends WC_Unit_Test_Case {
*/
public function test_handle_new_order_schedules_for_pending_order(): void {
$order = OrderHelper::create_order();
+ $order->set_created_via( 'checkout' );
$order->set_status( OrderStatus::PENDING );
$order->save();
@@ -237,6 +241,7 @@ class SchedulerTest extends WC_Unit_Test_Case {
*/
public function test_handle_new_order_skips_non_abandoned_status(): void {
$order = OrderHelper::create_order();
+ $order->set_created_via( 'checkout' );
$order->set_status( OrderStatus::PROCESSING );
$order->save();
@@ -254,6 +259,7 @@ class SchedulerTest extends WC_Unit_Test_Case {
$this->email->update_option( 'automated', 'no' );
$order = OrderHelper::create_order();
+ $order->set_created_via( 'checkout' );
$order->set_status( OrderStatus::PENDING );
$order->save();
@@ -271,6 +277,7 @@ class SchedulerTest extends WC_Unit_Test_Case {
$this->email->enabled = 'no';
$order = OrderHelper::create_order();
+ $order->set_created_via( 'checkout' );
$order->set_status( OrderStatus::PENDING );
$order->save();
@@ -285,6 +292,7 @@ class SchedulerTest extends WC_Unit_Test_Case {
*/
public function test_handle_new_order_skips_when_suppressed(): void {
$order = OrderHelper::create_order();
+ $order->set_created_via( 'checkout' );
$order->set_status( OrderStatus::PENDING );
$order->save();
@@ -304,6 +312,7 @@ class SchedulerTest extends WC_Unit_Test_Case {
*/
public function test_handle_new_order_is_idempotent(): void {
$order = OrderHelper::create_order();
+ $order->set_created_via( 'checkout' );
$order->set_status( OrderStatus::PENDING );
$order->save();
@@ -323,6 +332,7 @@ class SchedulerTest extends WC_Unit_Test_Case {
*/
public function test_handle_new_order_skips_when_already_sent(): void {
$order = OrderHelper::create_order();
+ $order->set_created_via( 'checkout' );
$order->set_status( OrderStatus::PENDING );
$order->update_meta_data( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT, (string) time() );
$order->save();
@@ -333,6 +343,182 @@ class SchedulerTest extends WC_Unit_Test_Case {
$this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ) );
}
+ /**
+ * @testdox handle_new_order() is a no-op for pending orders not created by a customer checkout (admin invoices, REST API, renewals) — only abandoned checkouts get the automated nudge.
+ * @dataProvider provider_non_checkout_origins
+ *
+ * @param string $created_via Order-creation origin to test.
+ */
+ public function test_handle_new_order_skips_non_checkout_origin( string $created_via ): void {
+ $order = OrderHelper::create_order();
+ $order->set_created_via( $created_via );
+ $order->set_status( OrderStatus::PENDING );
+ $order->save();
+
+ $this->sut->handle_new_order( $order->get_id() );
+
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ) );
+ $this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+ }
+
+ /**
+ * Non-checkout order-creation origins.
+ *
+ * @return array<string, array<string>>
+ */
+ public function provider_non_checkout_origins(): array {
+ return array(
+ 'admin invoice' => array( 'admin' ),
+ 'REST API' => array( 'rest-api' ),
+ 'subscription renewal' => array( 'subscription' ),
+ );
+ }
+
+ /**
+ * @testdox handle_new_order() schedules for a store-api (block checkout) order, so both checkout flows are covered by default.
+ */
+ public function test_handle_new_order_schedules_for_store_api_origin(): void {
+ $order = OrderHelper::create_order();
+ $order->set_created_via( 'store-api' );
+ $order->set_status( OrderStatus::PENDING );
+ $order->save();
+
+ $this->sut->handle_new_order( $order->get_id() );
+
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertNotEmpty( $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ) );
+ $this->assertNotFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+ }
+
+ /**
+ * @testdox handle_new_order() honors the woocommerce_abandoned_cart_recovery_eligible_statuses filter: an order created in a widened status (e.g. failed) is scheduled, matching the send/manual paths.
+ */
+ public function test_handle_new_order_schedules_for_filter_widened_status(): void {
+ $widen = static function ( $statuses ) {
+ $statuses[] = OrderStatus::FAILED;
+ return $statuses;
+ };
+
+ $order = OrderHelper::create_order();
+ $order->set_created_via( 'checkout' );
+ $order->set_status( OrderStatus::FAILED );
+ $order->save();
+
+ add_filter( 'woocommerce_abandoned_cart_recovery_eligible_statuses', $widen );
+ try {
+ $this->sut->handle_new_order( $order->get_id() );
+ } finally {
+ remove_filter( 'woocommerce_abandoned_cart_recovery_eligible_statuses', $widen );
+ }
+
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertNotEmpty(
+ $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ),
+ 'A status added via the eligible-statuses filter must be scheduled like the default abandoned statuses.'
+ );
+ $this->assertNotFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+ }
+
+ /**
+ * @testdox handle_status_changed() keeps the send queued when the order moves between statuses inside the filter-widened eligible set (e.g. pending → failed with `failed` added).
+ */
+ public function test_handle_status_changed_keeps_schedule_within_widened_set(): void {
+ $order = $this->schedule_for_pending_order();
+
+ $widen = static function ( $statuses ) {
+ $statuses[] = OrderStatus::FAILED;
+ return $statuses;
+ };
+
+ add_filter( 'woocommerce_abandoned_cart_recovery_eligible_statuses', $widen );
+ try {
+ $this->sut->handle_status_changed( $order->get_id(), OrderStatus::PENDING, OrderStatus::FAILED );
+ } finally {
+ remove_filter( 'woocommerce_abandoned_cart_recovery_eligible_statuses', $widen );
+ }
+
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertNotEmpty(
+ $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ),
+ 'A transition inside the widened eligible set must not cancel the queued send.'
+ );
+ $this->assertNotFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+ }
+
+ /**
+ * @testdox handle_status_changed() still cancels when the order exits the filter-widened eligible set (e.g. failed → processing with `failed` added).
+ */
+ public function test_handle_status_changed_cancels_on_exit_from_widened_set(): void {
+ $order = $this->schedule_for_pending_order();
+
+ $widen = static function ( $statuses ) {
+ $statuses[] = OrderStatus::FAILED;
+ return $statuses;
+ };
+
+ add_filter( 'woocommerce_abandoned_cart_recovery_eligible_statuses', $widen );
+ try {
+ $this->sut->handle_status_changed( $order->get_id(), OrderStatus::FAILED, OrderStatus::PROCESSING );
+ } finally {
+ remove_filter( 'woocommerce_abandoned_cart_recovery_eligible_statuses', $widen );
+ }
+
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ) );
+ $this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+ }
+
+ /**
+ * @testdox handle_scheduled_send() is a no-op when the merchant disabled automation after the send was queued — the in-flight action must honor the current setting.
+ */
+ public function test_handle_scheduled_send_skips_when_automation_disabled(): void {
+ $order = OrderHelper::create_order();
+ $order->set_created_via( 'checkout' );
+ $order->set_status( OrderStatus::PENDING );
+ $order->save();
+ $order->set_date_created( time() - WC_Email_Customer_Abandoned_Cart_Recovery::ABANDONMENT_THRESHOLD_SECONDS - MINUTE_IN_SECONDS );
+ $order->save();
+
+ $this->email->update_option( 'automated', 'no' );
+
+ $mailer = tests_retrieve_phpmailer_instance();
+ $before = count( $mailer->mock_sent );
+
+ $this->sut->handle_scheduled_send( $order->get_id() );
+
+ $this->assertSame( $before, count( $mailer->mock_sent ), 'A queued send must not dispatch once automation is toggled off.' );
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertSame(
+ '',
+ $fresh->get_meta( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT ),
+ 'A skipped send must not record the sent_at meta.'
+ );
+ }
+
+ /**
+ * @testdox A store-api order is scheduled through the real production wiring when it exits checkout-draft: the data store re-fires woocommerce_new_order on the draft → pending transition.
+ */
+ public function test_store_api_draft_to_pending_transition_schedules_via_hooks(): void {
+ $order = OrderHelper::create_order();
+ $order->set_created_via( 'store-api' );
+ $order->set_status( OrderStatus::CHECKOUT_DRAFT );
+ $order->save();
+
+ // Register the production hooks; setUp() only wires ACTION_HOOK.
+ $this->sut->init();
+
+ $order->set_status( OrderStatus::PENDING );
+ $order->save();
+
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertNotEmpty(
+ $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ),
+ 'The checkout-draft → pending transition must schedule the send via the re-fired woocommerce_new_order hook.'
+ );
+ $this->assertNotFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+ }
+
/**
* @testdox handle_status_changed() cancels the pending send when the order transitions out of the abandoned set (e.g. pending → processing).
*/
@@ -382,6 +568,7 @@ class SchedulerTest extends WC_Unit_Test_Case {
*/
private function schedule_for_pending_order(): WC_Order {
$order = OrderHelper::create_order();
+ $order->set_created_via( 'checkout' );
$order->set_status( OrderStatus::PENDING );
$order->save();