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();