Commit 454f200bc9b for woocommerce

commit 454f200bc9bc985ec85bcb747ef845a74001e2b3
Author: Mayisha <33387139+Mayisha@users.noreply.github.com>
Date:   Mon Jul 27 16:07:44 2026 +0600

    Avoid a redundant order lookup and document eligible-status scoping in the abandoned cart recovery scheduler (#66921)

    * tweak: avoid redundant order lookup in abandoned cart recovery scheduler

    `handle_status_changed()` re-fetched the order with `wc_get_order()` even
    though `woocommerce_order_status_changed` passes it as the 4th argument
    (class-wc-order.php:478). The callback runs on every order status
    transition store-wide, so this removes a lookup from a hot path and
    guarantees we act on the instance that actually transitioned.

    The new `$order` parameter is optional and falls back to a lookup when
    absent, matching `handle_new_order()` in the same class.

    Also documents why `store-api` is in `ELIGIBLE_CREATED_VIA` when it
    matches no order today, so it isn't removed as dead code before
    checkout-draft scheduling lands.

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * docs: clarify abandoned cart recovery eligible-status scoping

    The class docblock and `get_eligible_statuses()` both implied the
    scheduler and the send-time gate resolve to the same status set. They
    share the `woocommerce_abandoned_cart_recovery_eligible_statuses` filter
    but not its default: the scheduler is scoped to `pending`, the send-time
    gate also accepts `checkout-draft`.

    Document what each path covers and why, and note that append/replace
    callbacks behave consistently across both call sites. Mirrored on
    `ABANDONED_STATUSES` so the scoping is visible from either file.

    Comments only, no behavior change.

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * tweak: reuse the hook-supplied order in the abandoned cart recovery scheduler

    Addresses review feedback on #66921.

    `handle_cancellation()` reloaded the order immediately after
    `handle_status_changed()` had already resolved it. It now takes an
    optional `$order` and falls back to `wc_get_order()` only when absent,
    matching `handle_new_order()` in the same class.

    `woocommerce_before_delete_order` passes the order as its 2nd argument
    (OrdersTableDataStore.php:2632), so that registration moves to arity 2.
    `woocommerce_trash_order` passes only the ID (:2693) and stays at arity
    1, taking the fallback path.

    Also rewrites the `ELIGIBLE_CREATED_VIA` comment: Store API orders are
    generally created in `checkout-draft`, which is not scheduled yet, so
    the entry is listed for future block checkout support.

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * use @see with full classname

    Co-authored-by: daledupreez <dale@automattic.com>

    * use @see for get_eligible_statuses()

    Co-authored-by: daledupreez <dale@automattic.com>

    * remove unnecessary comment

    Co-authored-by: daledupreez <dale@automattic.com>

    * make comment generic

    Co-authored-by: daledupreez <dale@automattic.com>

    * simplify doc

    Co-authored-by: daledupreez <dale@automattic.com>

    * add tests

    * consolidate eligible-statuses filter docs at a single call site

    * fix docblock spacing before @see tag

    * Use explicit order status values instead of reference to class-local constant

    ---------

    Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Co-authored-by: daledupreez <dale@automattic.com>

diff --git a/plugins/woocommerce/changelog/fix-abandoned-cart-recovery-order-lookup b/plugins/woocommerce/changelog/fix-abandoned-cart-recovery-order-lookup
new file mode 100644
index 00000000000..3b64250aacd
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-abandoned-cart-recovery-order-lookup
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Abandoned cart recovery: remove a redundant order lookup on every order status transition.
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-abandoned-cart-recovery.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-abandoned-cart-recovery.php
index d7b67585388..9114faaea7d 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-abandoned-cart-recovery.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-abandoned-cart-recovery.php
@@ -73,6 +73,11 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 		 *                     the customer is mid-flow. May have no billing email yet,
 		 *                     in which case `trigger()` no-ops.
 		 *
+		 * Covers block checkout as well as classic. The automated scheduler is
+		 * scoped to `pending`.
+		 *
+		 * @see \Automattic\WooCommerce\Internal\AbandonedCartRecovery\Scheduler::get_eligible_statuses()
+		 *
 		 * @var string[]
 		 */
 		private const ABANDONED_STATUSES = array(
@@ -261,14 +266,21 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
 			/**
 			 * Filter the order statuses that are eligible to receive the abandoned cart recovery email.
 			 *
-			 * Defaults to the abandoned-checkout statuses (`pending`, `checkout-draft`). Partner
-			 * integrations or merchants who want recovery to fire for other states (e.g. `failed`)
-			 * can widen the list here.
+			 * The filter is applied at two points in the order lifecycle, which pass different
+			 * defaults because they cover different statuses:
+			 *
+			 * - At send time (here), the default is `pending` and `checkout-draft`, covering
+			 *   abandonment from both the classic and the block checkout, used in manual email trigger path
+			 *   from the order edit page.
+			 * - When the automated send is scheduled, the default is `pending` only.
+			 *   Store API orders are generally created in `checkout-draft`, which is not supported
+			 *   yet for automatic scheduled email.
+			 *   {@see \Automattic\WooCommerce\Internal\AbandonedCartRecovery\Scheduler::get_eligible_statuses()}
 			 *
 			 * @since 11.0.0
 			 *
-			 * @param string[] $eligible_statuses Default: ABANDONED_STATUSES.
-			 * @param WC_Order $order             Order being inspected.
+			 * @param string[]      $eligible_statuses Default: `pending` and `checkout-draft` at manual send time, `pending` when scheduling.
+			 * @param WC_Order|null $order             Order being inspected, or null if it could not be loaded.
 			 */
 			$eligible_statuses = (array) apply_filters(
 				'woocommerce_abandoned_cart_recovery_eligible_statuses',
diff --git a/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php b/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php
index da03b0eb0f1..fb50c71f62e 100644
--- a/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php
+++ b/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php
@@ -20,9 +20,10 @@ use WC_Order;
  * after `WC_Email_Customer_Abandoned_Cart_Recovery::AUTO_SEND_DELAY_SECONDS`.
  * 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.
+ * checkout before the delay elapses never receives the nudge. Eligibility runs
+ * through the same `woocommerce_abandoned_cart_recovery_eligible_statuses`
+ * filter the send/manual paths use, with a default scoped to `pending` —
+ * {@see get_eligible_statuses()}.
  *
  * 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
@@ -62,6 +63,9 @@ class Scheduler {
 	 * Order-creation origins (`created_via`) eligible for an automated send:
 	 * the classic checkout and the block (Store API) checkout.
 	 *
+	 * Store API orders are generally created in `checkout-draft`, which is not scheduled
+	 * yet. `store-api` is listed here for future block checkout support.
+	 *
 	 * @var string[]
 	 */
 	private const ELIGIBLE_CREATED_VIA = array( 'checkout', 'store-api' );
@@ -77,9 +81,9 @@ class Scheduler {
 		add_action( 'woocommerce_new_order', array( $this, 'handle_new_order' ), 10, 2 );
 		// 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_order_status_changed', array( $this, 'handle_status_changed' ), 10, 4 );
 		add_action( 'woocommerce_trash_order', array( $this, 'handle_cancellation' ), 10, 1 );
-		add_action( 'woocommerce_before_delete_order', array( $this, 'handle_cancellation' ), 10, 1 );
+		add_action( 'woocommerce_before_delete_order', array( $this, 'handle_cancellation' ), 10, 2 );
 		add_action( self::ACTION_HOOK, array( $this, 'handle_scheduled_send' ), 10, 1 );
 	}

@@ -147,12 +151,15 @@ class Scheduler {
 	 *
 	 * @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).
+	 * @param int           $order_id   Order ID.
+	 * @param string        $old_status Previous status (sans `wc-` prefix).
+	 * @param string        $new_status New status (sans `wc-` prefix).
+	 * @param WC_Order|null $order      Order passed by the hook; looked up when absent.
 	 */
-	public function handle_status_changed( int $order_id, string $old_status, string $new_status ): void {
-		$order = wc_get_order( $order_id );
+	public function handle_status_changed( int $order_id, string $old_status, string $new_status, $order = null ): void {
+		if ( ! $order instanceof WC_Order ) {
+			$order = wc_get_order( $order_id );
+		}
 		if ( ! $order instanceof WC_Order ) {
 			return;
 		}
@@ -166,7 +173,7 @@ class Scheduler {
 			return;
 		}

-		$this->handle_cancellation( $order_id );
+		$this->handle_cancellation( $order_id, $order );
 	}

 	/**
@@ -179,15 +186,19 @@ class Scheduler {
 	 *
 	 * @internal
 	 *
-	 * @param int $order_id The affected order ID.
+	 * @param int           $order_id The affected order ID.
+	 * @param WC_Order|null $order    Order passed by the caller or hook; looked up when absent, as
+	 *                                some hooks don't supply the order.
 	 */
-	public function handle_cancellation( int $order_id ): void {
+	public function handle_cancellation( int $order_id, $order = null ): void {
 		// Always attempt to unschedule, even when the order or meta is missing,
 		// so an out-of-sync meta value cannot leave a stray scheduled send.
 		// `as_unschedule_action()` is a no-op when no matching action exists.
 		as_unschedule_action( self::ACTION_HOOK, array( $order_id ) );

-		$order = wc_get_order( $order_id );
+		if ( ! $order instanceof WC_Order ) {
+			$order = wc_get_order( $order_id );
+		}
 		if ( $order instanceof WC_Order && '' !== (string) $order->get_meta( self::SCHEDULED_META_KEY ) ) {
 			$order->delete_meta_data( self::SCHEDULED_META_KEY );
 			$order->save_meta_data();
@@ -234,20 +245,17 @@ 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()`.
+	 * Order statuses that can be scheduled for an automated send. Defaults to `pending`, which is
+	 * where classic checkout leaves an abandoned order at creation time.
 	 *
 	 * @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.
+		 * This filter is documented in includes/emails/class-wc-email-customer-abandoned-cart-recovery.php
 		 *
 		 * @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',
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php b/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php
index 00c490ef307..42052a0ff6c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php
@@ -532,6 +532,32 @@ class SchedulerTest extends WC_Unit_Test_Case {
 		$this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
 	}

+	/**
+	 * @testdox handle_status_changed() cancels the pending send when the hook supplies the order object, matching the id-only path that falls back to a lookup.
+	 */
+	public function test_handle_status_changed_cancels_scheduled_send_when_order_object_passed(): void {
+		$order = $this->schedule_for_pending_order();
+
+		$this->sut->handle_status_changed( $order->get_id(), OrderStatus::PENDING, OrderStatus::PROCESSING, $order );
+
+		$fresh = wc_get_order( $order->get_id() );
+		$this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ), 'Scheduled-at meta must be cleared when the hook supplies the order object.' );
+		$this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ), 'Queued send must be unscheduled when the hook supplies the order object.' );
+	}
+
+	/**
+	 * @testdox handle_status_changed() falls back to a lookup when the 4th argument is not an order, so a caller passing null behaves like the id-only path.
+	 */
+	public function test_handle_status_changed_cancels_scheduled_send_when_order_object_is_null(): void {
+		$order = $this->schedule_for_pending_order();
+
+		$this->sut->handle_status_changed( $order->get_id(), OrderStatus::PENDING, OrderStatus::PROCESSING, null );
+
+		$fresh = wc_get_order( $order->get_id() );
+		$this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ), 'Scheduled-at meta must be cleared when the order object is absent.' );
+		$this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ), 'Queued send must be unscheduled when the order object is absent.' );
+	}
+
 	/**
 	 * @testdox handle_status_changed() does nothing when the previous status was already outside `pending` — nothing to cancel.
 	 */
@@ -549,9 +575,9 @@ class SchedulerTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox handle_cancellation() unschedules and clears the meta for a trashed order so a deleted-then-restored order doesn't fire a stale send.
+	 * @testdox handle_cancellation() unschedules and clears the meta when no order object is supplied and it falls back to a lookup — the `woocommerce_trash_order` path — so a deleted-then-restored order doesn't fire a stale send.
 	 */
-	public function test_handle_cancellation_clears_state(): void {
+	public function test_handle_cancellation_clears_state_when_order_object_is_null(): void {
 		$order = $this->schedule_for_pending_order();

 		$this->sut->handle_cancellation( $order->get_id() );
@@ -561,6 +587,19 @@ class SchedulerTest extends WC_Unit_Test_Case {
 		$this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
 	}

+	/**
+	 * @testdox handle_cancellation() clears the same state when the caller supplies the order object, so the status-change and delete entry points match the id-only trash path.
+	 */
+	public function test_handle_cancellation_clears_state_when_order_object_passed(): void {
+		$order = $this->schedule_for_pending_order();
+
+		$this->sut->handle_cancellation( $order->get_id(), $order );
+
+		$fresh = wc_get_order( $order->get_id() );
+		$this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ), 'Scheduled-at meta must be cleared when the caller supplies the order object.' );
+		$this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ), 'Queued send must be unscheduled when the caller supplies the order object.' );
+	}
+
 	/**
 	 * Create a pending order and run it through handle_new_order() so the
 	 * tests for the cancel/status-change paths start from a known scheduled