Commit 6a0189c3ba2 for woocommerce
commit 6a0189c3ba229ac034abd05cbc3598d4a19f8296
Author: Mayisha <33387139+Mayisha@users.noreply.github.com>
Date: Wed Jul 8 13:53:46 2026 +0600
[RSM] Auto-send abandoned cart recovery email (#65238)
* Schedule the abandoned cart recovery email automatically
* add changelog
* Address PR review on checkout-recovery unsubscribe
* Address nitpick comments
* Update order note
* fix test
* Dispatch the auto-send AS action from the Scheduler
* Add an order note when the auto-send dispatches
* Address feedback
* Address feedback
* fix lint
* Address feedback
* fix changelog
* Address feedback
* Narrow abandoned cart recovery auto-send to pending-only orders
* Retarget abandoned cart recovery @since tags from 10.9.0 to 11.0.0
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/abandoned-cart-recovery-auto-send b/plugins/woocommerce/changelog/abandoned-cart-recovery-auto-send
new file mode 100644
index 00000000000..f4827614bdd
--- /dev/null
+++ b/plugins/woocommerce/changelog/abandoned-cart-recovery-auto-send
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Schedule the abandoned cart recovery email to send 2 hours after a pending order is created, and cancel the pending send when the order leaves the pending status.
diff --git a/plugins/woocommerce/changelog/abandoned-cart-recovery-email b/plugins/woocommerce/changelog/abandoned-cart-recovery-email
index cad033f0e9a..e42b071dff1 100644
--- a/plugins/woocommerce/changelog/abandoned-cart-recovery-email
+++ b/plugins/woocommerce/changelog/abandoned-cart-recovery-email
@@ -1,4 +1,4 @@
Significance: minor
Type: add
-Add a new transactional abandoned cart recovery email that prompts customers to complete a pending checkout, behind the new `checkout_recovery` feature flag.
+Add a new transactional abandoned cart recovery email that prompts customers to complete a pending checkout, behind the new `abandoned_cart_recovery` feature flag.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index c53fd3b6376..1ac3e2a4e83 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -1001,7 +1001,7 @@ final class WooCommerce {
* so the order-edit action listener is registered before
* `WC_Meta_Box_Order_Actions::save()` dispatches its hook on POST.
*
- * @since 10.9.0
+ * @since 11.0.0
* @internal
*/
public function maybe_init_abandoned_cart_recovery(): void {
@@ -1009,6 +1009,7 @@ final class WooCommerce {
return;
}
wc_get_container()->get( \Automattic\WooCommerce\Internal\AbandonedCartRecovery\ManualSendHandler::class );
+ wc_get_container()->get( \Automattic\WooCommerce\Internal\AbandonedCartRecovery\Scheduler::class );
}
/**
@@ -1023,7 +1024,7 @@ final class WooCommerce {
* stop working. Both consequences are wrong, so this method runs even
* when no specific email kind that uses it is currently active.
*
- * @since 10.9.0
+ * @since 11.0.0
* @internal
*/
public function init_email_unsubscribes(): void {
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 9780c73616a..d7b67585388 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
@@ -24,7 +24,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
* Merchants can also trigger the email manually from the order edit page.
*
* @class WC_Email_Customer_Abandoned_Cart_Recovery
- * @version 10.9.0
+ * @version 11.0.0
* @package WooCommerce\Classes\Emails
*/
class WC_Email_Customer_Abandoned_Cart_Recovery extends WC_Email {
@@ -87,6 +87,18 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
*/
public const ABANDONMENT_THRESHOLD_SECONDS = HOUR_IN_SECONDS;
+ /**
+ * Delay between order creation and the automated recovery send.
+ *
+ * Deliberately longer than `ABANDONMENT_THRESHOLD_SECONDS` so the auto-send
+ * never races the customer's own checkout retry. The shorter eligibility
+ * threshold still gates manual sends from the order edit page so merchants
+ * have an earlier window to intervene.
+ *
+ * @since 11.0.0
+ */
+ public const AUTO_SEND_DELAY_SECONDS = 2 * HOUR_IN_SECONDS;
+
/**
* Constructor.
*/
@@ -105,13 +117,6 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
'{order_number}' => '',
);
- // Trigger fires after Action Scheduler dispatches `woocommerce_send_abandoned_cart_recovery_notification`,
- // or when the merchant invokes the manual-send action from the order edit page.
- // The order-edit action hooks live in `Internal\AbandonedCartRecovery\ManualSendHandler`
- // so the listener is in place before the admin POST runs the order-meta save flow
- // (which happens before the mailer would otherwise be instantiated).
- add_action( 'woocommerce_send_abandoned_cart_recovery_notification', array( $this, 'trigger' ), 10, 1 );
-
parent::__construct();
// Must be after parent's constructor which sets `email_improvements_enabled` property.
@@ -121,17 +126,20 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
/**
* Trigger the sending of this email.
*
- * Wired to `woocommerce_send_abandoned_cart_recovery_notification`, which Action
- * Scheduler fires with the order id as its single argument. Also called
- * directly by the manual-send action on the order edit page.
+ * Called by `Scheduler::handle_scheduled_send()` after Action Scheduler dispatches
+ * `woocommerce_send_abandoned_cart_recovery_notification`, and directly by the
+ * manual-send action on the order edit page.
*
- * @since 10.9.0
+ * @since 11.0.0
*
* @param int $order_id The order ID.
+ * @return bool True when an email was dispatched in this call, false when any
+ * gate (suppression, disabled, ineligible, unsubscribed, dedup) or
+ * the underlying `send()` rejected the request.
*/
- public function trigger( $order_id ): void {
+ public function trigger( $order_id ): bool {
if ( self::is_suppressed() ) {
- return;
+ return false;
}
$this->setup_locale();
@@ -153,23 +161,30 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
$this->placeholders['{order_number}'] = $order->get_order_number();
}
+ $dispatched = false;
+
if (
$this->is_enabled()
&& $this->get_recipient()
&& $this->object instanceof WC_Order
&& $this->is_order_eligible_for_recovery( $this->object )
&& ! self::is_recipient_unsubscribed( $this->get_recipient() )
+ && '' === (string) $this->object->get_meta( self::META_KEY_SENT_AT )
) {
- $sent = $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
+ $dispatched = (bool) $this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
// Only record the send timestamp when the dispatch actually succeeded.
- if ( $sent ) {
+ // Subsequent invocations (duplicate AS firings, post-manual auto fires)
+ // short-circuit on this meta before reaching `send()` again.
+ if ( $dispatched ) {
$this->object->update_meta_data( self::META_KEY_SENT_AT, (string) time() );
$this->object->save_meta_data();
}
}
$this->restore_locale();
+
+ return $dispatched;
}
/**
@@ -181,7 +196,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
* abandonment. A capability check guards against role configurations that
* grant the order edit page to users without `edit_shop_orders`.
*
- * @since 10.9.0
+ * @since 11.0.0
*
* @param array $actions Existing order actions keyed by action id.
* @param WC_Order|null $order Order being rendered, or null in contexts without one.
@@ -212,6 +227,12 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
return $actions;
}
+ // trigger() refuses to dispatch a second time once META_KEY_SENT_AT is
+ // set, so don't surface a control that would silently no-op.
+ if ( '' !== (string) $order->get_meta( self::META_KEY_SENT_AT ) ) {
+ return $actions;
+ }
+
$actions[ self::MANUAL_RECOVERY_EMAIL_SEND_ACTION ] = __( 'Send abandoned cart recovery email', 'woocommerce' );
return $actions;
@@ -231,7 +252,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
* eligible-status set via `woocommerce_abandoned_cart_recovery_eligible_statuses`
* and both paths will agree.
*
- * @since 10.9.0
+ * @since 11.0.0
*
* @param WC_Order $order Order to evaluate.
* @return bool
@@ -244,7 +265,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
* integrations or merchants who want recovery to fire for other states (e.g. `failed`)
* can widen the list here.
*
- * @since 10.9.0
+ * @since 11.0.0
*
* @param string[] $eligible_statuses Default: ABANDONED_STATUSES.
* @param WC_Order $order Order being inspected.
@@ -278,7 +299,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
* capability and order status as defense in depth in case the hook is
* invoked from a non-metabox path.
*
- * @since 10.9.0
+ * @since 11.0.0
*
* @param WC_Order $order The order whose customer should receive the email.
* @return void
@@ -308,17 +329,28 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
return;
}
+ // Mirror trigger()'s dedup gate before firing the before/after
+ // resend hooks so extensions don't see a resend event for a send
+ // that trigger() would short-circuit.
+ if ( '' !== (string) $order->get_meta( self::META_KEY_SENT_AT ) ) {
+ return;
+ }
+
/**
* Fires before the abandoned cart recovery email is manually resent.
*
- * @since 10.9.0
+ * @since 11.0.0
*
* @param WC_Order $order Order being recovered.
* @param string $email_type Email identifier ('customer_abandoned_cart_recovery').
*/
do_action( 'woocommerce_before_resend_order_emails', $order, $this->id );
- $this->trigger( $order->get_id() );
+ // Gate the note + admin notice on trigger()'s return so the audit
+ // trail only records sends that actually went out.
+ if ( ! $this->trigger( $order->get_id() ) ) {
+ return;
+ }
$order->add_order_note(
__( 'Abandoned cart recovery email sent from the order actions menu.', 'woocommerce' ),
@@ -330,7 +362,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
/**
* Fires after the abandoned cart recovery email has been manually resent.
*
- * @since 10.9.0
+ * @since 11.0.0
*
* @param WC_Order $order Order being recovered.
* @param string $email_type Email identifier ('customer_abandoned_cart_recovery').
@@ -351,7 +383,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
* order edit page. The Action Scheduler integration consults this before
* scheduling a send.
*
- * @since 10.9.0
+ * @since 11.0.0
* @return bool
*/
public function is_automated(): bool {
@@ -361,7 +393,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
/**
* Currently-active known recovery handlers, keyed by plugin file path with the display name as value.
*
- * @since 10.9.0
+ * @since 11.0.0
* @return array<string, string> Map of plugin file path → display name for plugins that are active.
*/
public static function get_active_recovery_handlers(): array {
@@ -386,7 +418,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
* manual-send handler and the scheduler can call it without instantiating
* the email class.
*
- * @since 10.9.0
+ * @since 11.0.0
*
* @return bool
*/
@@ -397,7 +429,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
* Partner plugins that handle abandoned cart recovery themselves can
* return true here to prevent core from sending a duplicate email.
*
- * @since 10.9.0
+ * @since 11.0.0
*
* @param bool $suppress Default false.
*/
@@ -411,7 +443,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
* pending order. A future iteration may swap this for a single-use signed
* URL with explicit expiry (see `woocommerce_abandoned_cart_recovery_url` filter).
*
- * @since 10.9.0
+ * @since 11.0.0
* @return string
*/
public function get_recovery_url() {
@@ -422,7 +454,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
/**
* Filter the URL included in the abandoned cart recovery email.
*
- * @since 10.9.0
+ * @since 11.0.0
*
* @param string $url Default: the pending order's pay endpoint.
* @param WC_Order $order Order being recovered.
@@ -439,7 +471,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
* both states mean there's no recipient to unsubscribe and the
* template should suppress the footer link.
*
- * @since 10.9.0
+ * @since 11.0.0
* @return string
*/
public function get_unsubscribe_url() {
@@ -460,7 +492,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
* dropdown gate, and any future auto-send scheduler — without each
* caller needing to thread the repository through.
*
- * @since 10.9.0
+ * @since 11.0.0
*
* @param string $email Raw recipient email.
* @return bool
@@ -475,7 +507,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
/**
* Get default email subject.
*
- * @since 10.9.0
+ * @since 11.0.0
* @return string
*/
public function get_default_subject() {
@@ -485,7 +517,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
/**
* Get default email heading.
*
- * @since 10.9.0
+ * @since 11.0.0
* @return string
*/
public function get_default_heading() {
@@ -495,7 +527,7 @@ if ( ! class_exists( 'WC_Email_Customer_Abandoned_Cart_Recovery', false ) ) :
/**
* Default content to show below main email content.
*
- * @since 10.9.0
+ * @since 11.0.0
* @return string
*/
public function get_default_additional_content() {
diff --git a/plugins/woocommerce/src/Internal/AbandonedCartRecovery/ManualSendHandler.php b/plugins/woocommerce/src/Internal/AbandonedCartRecovery/ManualSendHandler.php
index 1883aebb330..3e11df33ab1 100644
--- a/plugins/woocommerce/src/Internal/AbandonedCartRecovery/ManualSendHandler.php
+++ b/plugins/woocommerce/src/Internal/AbandonedCartRecovery/ManualSendHandler.php
@@ -27,7 +27,7 @@ use WC_Order;
*
* @internal Just for internal use.
*
- * @since 10.9.0
+ * @since 11.0.0
*/
class ManualSendHandler {
diff --git a/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php b/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php
new file mode 100644
index 00000000000..82c104e250f
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AbandonedCartRecovery/Scheduler.php
@@ -0,0 +1,221 @@
+<?php
+/**
+ * Scheduler class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AbandonedCartRecovery;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
+use WC_Email_Customer_Abandoned_Cart_Recovery;
+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
+ * `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.
+ *
+ * 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
+ * dispatch when `META_KEY_SENT_AT` is already populated. Together these handle
+ * duplicate `woocommerce_new_order` fires, duplicate AS action firings, and
+ * the race between a manual send and the still-pending automated send.
+ *
+ * The container auto-calls `init()` after instantiation; resolution is driven
+ * by `WooCommerce::maybe_init_abandoned_cart_recovery()`, hooked on `init`
+ * priority 1.
+ *
+ * @internal Just for internal use.
+ *
+ * @since 11.0.0
+ */
+class Scheduler {
+
+ /**
+ * Action Scheduler hook fired when the configured delay elapses.
+ *
+ * Registered in `Scheduler::init()` against `handle_scheduled_send()`, which
+ * resolves the email class through the mailer and performs the actual send
+ * when the hook fires.
+ */
+ public const ACTION_HOOK = 'woocommerce_send_abandoned_cart_recovery_notification';
+
+ /**
+ * Order meta key storing the unix timestamp the email is scheduled for.
+ *
+ * Used for idempotency (block duplicate schedules) and so merchants can
+ * tell from order meta when an automated send is due. Distinct from
+ * `META_KEY_SENT_AT`, which records that a send already happened.
+ */
+ public const SCHEDULED_META_KEY = '_abandoned_cart_recovery_scheduled_at';
+
+ /**
+ * Register hooks and filters.
+ *
+ * Auto-called by the WC dependency container after instantiation.
+ *
+ * @internal
+ */
+ 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.
+ 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 );
+ add_action( self::ACTION_HOOK, array( $this, 'handle_scheduled_send' ), 10, 1 );
+ }
+
+ /**
+ * Schedule the automated send when a `pending` order is created.
+ *
+ * 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.
+ *
+ * @internal
+ *
+ * @param int $order_id The new order ID.
+ * @param WC_Order|null $order The order object passed by `woocommerce_new_order`;
+ * falls back to a lookup only when absent.
+ */
+ public function handle_new_order( int $order_id, $order = null ): void {
+ if ( ! $order instanceof WC_Order ) {
+ $order = wc_get_order( $order_id );
+ }
+ if ( ! $order instanceof WC_Order ) {
+ return;
+ }
+
+ if ( OrderStatus::PENDING !== $order->get_status() ) {
+ return;
+ }
+
+ $email = $this->get_email();
+ if ( null === $email || ! $email->is_enabled() || ! $email->is_automated() ) {
+ return;
+ }
+
+ if ( WC_Email_Customer_Abandoned_Cart_Recovery::is_suppressed() ) {
+ return;
+ }
+
+ if ( '' !== (string) $order->get_meta( self::SCHEDULED_META_KEY ) ) {
+ return;
+ }
+
+ if ( '' !== (string) $order->get_meta( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT ) ) {
+ return;
+ }
+
+ $when = time() + WC_Email_Customer_Abandoned_Cart_Recovery::AUTO_SEND_DELAY_SECONDS;
+ as_schedule_single_action( $when, self::ACTION_HOOK, array( $order_id ) );
+
+ $order->update_meta_data( self::SCHEDULED_META_KEY, (string) $when );
+ $order->save_meta_data();
+ }
+
+ /**
+ * 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.
+ *
+ * @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 {
+ if ( OrderStatus::PENDING !== $old_status || OrderStatus::PENDING === $new_status ) {
+ return;
+ }
+
+ $this->handle_cancellation( $order_id );
+ }
+
+ /**
+ * Cancel any pending recovery-send action and clear the scheduled-at meta.
+ *
+ * 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 transition out of
+ * `pending`.
+ *
+ * @internal
+ *
+ * @param int $order_id The affected order ID.
+ */
+ public function handle_cancellation( int $order_id ): 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 && '' !== (string) $order->get_meta( self::SCHEDULED_META_KEY ) ) {
+ $order->delete_meta_data( self::SCHEDULED_META_KEY );
+ $order->save_meta_data();
+ }
+ }
+
+ /**
+ * 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.
+ *
+ * @internal
+ *
+ * @param int $order_id Order id the AS action was scheduled with.
+ */
+ public function handle_scheduled_send( int $order_id ): void {
+ $email = $this->get_email();
+ if ( null === $email ) {
+ return;
+ }
+
+ $dispatched = $email->trigger( $order_id );
+ if ( ! $dispatched ) {
+ return;
+ }
+
+ $order = wc_get_order( $order_id );
+ if ( ! $order instanceof WC_Order ) {
+ return;
+ }
+
+ $order->add_order_note(
+ __( 'Abandoned cart recovery email sent automatically.', 'woocommerce' ),
+ 0,
+ false,
+ array( 'note_group' => OrderNoteGroup::EMAIL_NOTIFICATION )
+ );
+ }
+
+ /**
+ * Retrieve the recovery email class instance from the mailer.
+ */
+ private function get_email(): ?WC_Email_Customer_Abandoned_Cart_Recovery {
+ $mailer = WC()->mailer();
+ if ( ! $mailer ) {
+ return null;
+ }
+
+ $emails = $mailer->get_emails();
+ $email = $emails['WC_Email_Customer_Abandoned_Cart_Recovery'] ?? null;
+
+ return $email instanceof WC_Email_Customer_Abandoned_Cart_Recovery ? $email : null;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Email/Unsubscribes/Endpoint.php b/plugins/woocommerce/src/Internal/Email/Unsubscribes/Endpoint.php
index faaf2f96d7b..6a1a4ca72a9 100644
--- a/plugins/woocommerce/src/Internal/Email/Unsubscribes/Endpoint.php
+++ b/plugins/woocommerce/src/Internal/Email/Unsubscribes/Endpoint.php
@@ -21,7 +21,7 @@ namespace Automattic\WooCommerce\Internal\Email\Unsubscribes;
*
* @internal Just for internal use.
*
- * @since 10.9.0
+ * @since 11.0.0
*/
class Endpoint {
diff --git a/plugins/woocommerce/src/Internal/Email/Unsubscribes/Storage.php b/plugins/woocommerce/src/Internal/Email/Unsubscribes/Storage.php
index 6b7ae670f8b..34892622fa2 100644
--- a/plugins/woocommerce/src/Internal/Email/Unsubscribes/Storage.php
+++ b/plugins/woocommerce/src/Internal/Email/Unsubscribes/Storage.php
@@ -26,7 +26,7 @@ namespace Automattic\WooCommerce\Internal\Email\Unsubscribes;
*
* @internal Just for internal use.
*
- * @since 10.9.0
+ * @since 11.0.0
*/
class Storage {
diff --git a/plugins/woocommerce/templates/emails/block/customer-abandoned-cart-recovery.php b/plugins/woocommerce/templates/emails/block/customer-abandoned-cart-recovery.php
index b730479f9ac..dbc7964cf5c 100644
--- a/plugins/woocommerce/templates/emails/block/customer-abandoned-cart-recovery.php
+++ b/plugins/woocommerce/templates/emails/block/customer-abandoned-cart-recovery.php
@@ -12,7 +12,7 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates\Emails\Block
- * @version 10.9.0
+ * @version 11.0.0
*/
defined( 'ABSPATH' ) || exit;
diff --git a/plugins/woocommerce/templates/emails/customer-abandoned-cart-recovery.php b/plugins/woocommerce/templates/emails/customer-abandoned-cart-recovery.php
index dfffe09b065..d7831312af2 100644
--- a/plugins/woocommerce/templates/emails/customer-abandoned-cart-recovery.php
+++ b/plugins/woocommerce/templates/emails/customer-abandoned-cart-recovery.php
@@ -12,7 +12,7 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates\Emails
- * @version 10.9.0
+ * @version 11.0.0
*/
use Automattic\WooCommerce\Utilities\FeaturesUtil;
diff --git a/plugins/woocommerce/templates/emails/plain/customer-abandoned-cart-recovery.php b/plugins/woocommerce/templates/emails/plain/customer-abandoned-cart-recovery.php
index dcf4ea84d9d..9059466f001 100644
--- a/plugins/woocommerce/templates/emails/plain/customer-abandoned-cart-recovery.php
+++ b/plugins/woocommerce/templates/emails/plain/customer-abandoned-cart-recovery.php
@@ -12,7 +12,7 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates\Emails\Plain
- * @version 10.9.0
+ * @version 11.0.0
*/
// phpcs:disable Universal.WhiteSpace.PrecisionAlignment.Found, Generic.WhiteSpace.DisallowSpaceIndent.SpacesUsed -- Plain text output needs specific spacing without tabs
diff --git a/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-abandoned-cart-recovery-test.php b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-abandoned-cart-recovery-test.php
index 7e380d146ef..9f4829bcaf2 100644
--- a/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-abandoned-cart-recovery-test.php
+++ b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-abandoned-cart-recovery-test.php
@@ -290,6 +290,26 @@ class WC_Email_Customer_Abandoned_Cart_Recovery_Test extends \WC_Unit_Test_Case
$this->assertLessThanOrEqual( time() + 1, (int) $saved );
}
+ /**
+ * @testdox trigger() does not dispatch when META_KEY_SENT_AT is already populated, so a duplicate AS firing or a re-trigger after a successful send cannot double-email the customer.
+ */
+ public function test_trigger_skips_when_already_sent(): void {
+ $this->sut->update_option( 'enabled', 'yes' );
+ $this->sut->enabled = 'yes';
+
+ $order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
+ $order = $this->age_order_past_threshold( $order );
+ $order->update_meta_data( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT, (string) time() );
+ $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, 'Order already marked as sent must not receive a second dispatch.' );
+ }
+
/**
* @testdox trigger() does not write the sent_at meta when the email is disabled (no send happened).
*/
@@ -684,6 +704,55 @@ class WC_Email_Customer_Abandoned_Cart_Recovery_Test extends \WC_Unit_Test_Case
$this->assertArrayNotHasKey( WC_Email_Customer_Abandoned_Cart_Recovery::MANUAL_RECOVERY_EMAIL_SEND_ACTION, $actions );
}
+ /**
+ * @testdox register_order_action() hides the action once the order is marked as sent so merchants don't click a dropdown item that would silently no-op on the trigger-side dedup gate.
+ */
+ public function test_register_order_action_skips_when_already_sent(): void {
+ $this->become_admin();
+ $this->sut->update_option( 'enabled', 'yes' );
+ $this->sut->enabled = 'yes';
+
+ $order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
+ $order = $this->age_order_past_threshold( $order );
+ $order->update_meta_data( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT, (string) time() );
+ $order->save();
+
+ $actions = $this->sut->register_order_action( array(), $order );
+
+ $this->assertArrayNotHasKey( WC_Email_Customer_Abandoned_Cart_Recovery::MANUAL_RECOVERY_EMAIL_SEND_ACTION, $actions );
+ }
+
+ /**
+ * @testdox handle_recovery_email_send() does not record a "sent from the order actions menu" order note when trigger() bails on the dedup gate — keeps the audit trail honest about whether an email actually went out.
+ */
+ public function test_handle_recovery_email_send_skips_note_when_already_sent(): void {
+ $this->become_admin();
+ $this->sut->update_option( 'enabled', 'yes' );
+ $this->sut->enabled = 'yes';
+
+ $order = \Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper::create_order();
+ $order = $this->age_order_past_threshold( $order );
+ $order->update_meta_data( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT, (string) ( time() - HOUR_IN_SECONDS ) );
+ $order->save();
+
+ $mailer = tests_retrieve_phpmailer_instance();
+ $before = count( $mailer->mock_sent );
+
+ $this->sut->handle_recovery_email_send( $order );
+
+ $this->assertSame( $before, count( $mailer->mock_sent ), 'Already-sent order must not dispatch a second message from the manual handler.' );
+
+ $notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+ $note_strings = wp_list_pluck( $notes, 'content' );
+ $this->assertEmpty(
+ array_filter(
+ $note_strings,
+ static fn ( $note ) => false !== strpos( $note, 'sent from the order actions menu' )
+ ),
+ 'Dedup-gated trigger() must not leave a "sent from the order actions menu" order note behind.'
+ );
+ }
+
/**
* @testdox handle_recovery_email_send() is a no-op when the email is disabled — avoids writing an order note for a send that never happened.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php b/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php
new file mode 100644
index 00000000000..5c9e01846fb
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AbandonedCartRecovery/SchedulerTest.php
@@ -0,0 +1,392 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AbandonedCartRecovery;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\AbandonedCartRecovery\Scheduler;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use WC_Email_Customer_Abandoned_Cart_Recovery;
+use WC_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Scheduler test.
+ *
+ * @covers \Automattic\WooCommerce\Internal\AbandonedCartRecovery\Scheduler
+ */
+class SchedulerTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var Scheduler
+ */
+ private $sut;
+
+ /**
+ * The email class instance — needed so the scheduler's `get_email()` lookup
+ * finds something in the mailer registry.
+ *
+ * @var WC_Email_Customer_Abandoned_Cart_Recovery
+ */
+ private $email;
+
+ /**
+ * Snapshot of `active_plugins` taken in setUp so tests that mock a known
+ * recovery handler can restore the original list in tearDown.
+ *
+ * @var array
+ */
+ private $original_active_plugins = array();
+
+ /**
+ * Enable the feature flag, force-include the email class, re-init the
+ * mailer so it picks up the registration, then resolve the SUT.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ update_option( 'woocommerce_feature_abandoned_cart_recovery_enabled', 'yes' );
+ $this->original_active_plugins = (array) get_option( 'active_plugins', array() );
+
+ $bootstrap = \WC_Unit_Tests_Bootstrap::instance();
+ require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email.php';
+ require_once $bootstrap->plugin_dir . '/includes/emails/class-wc-email-customer-abandoned-cart-recovery.php';
+
+ WC()->mailer()->init();
+
+ // Grab the mailer's registered instance — the Scheduler's `get_email()`
+ // returns this same instance, so option updates from the test propagate
+ // to the SUT instead of being applied to a parallel object.
+ $emails = WC()->mailer()->get_emails();
+ $this->email = $emails['WC_Email_Customer_Abandoned_Cart_Recovery'];
+ $this->email->update_option( 'enabled', 'yes' );
+ $this->email->enabled = 'yes';
+ $this->email->update_option( 'automated', 'yes' );
+
+ $this->sut = wc_get_container()->get( Scheduler::class );
+
+ add_action( Scheduler::ACTION_HOOK, array( $this->sut, 'handle_scheduled_send' ), 10, 1 );
+ }
+
+ /**
+ * Reset settings + cancel any leftover scheduled actions between tests.
+ */
+ public function tearDown(): void {
+ remove_action( Scheduler::ACTION_HOOK, array( $this->sut, 'handle_scheduled_send' ), 10 );
+
+ delete_option( 'woocommerce_feature_abandoned_cart_recovery_enabled' );
+ delete_option( 'woocommerce_customer_abandoned_cart_recovery_settings' );
+ update_option( 'active_plugins', $this->original_active_plugins );
+
+ as_unschedule_all_actions( Scheduler::ACTION_HOOK );
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox init() registers the new-order, status-changed, trash, delete, and AS-callback hooks so a fresh container resolve wires the schedule + cancel + dispatch listeners in one place.
+ */
+ public function test_init_registers_hooks(): void {
+ // setUp() pre-registers ACTION_HOOK so the dispatch test works without
+ // init() (which would also wire woocommerce_new_order and auto-fire on
+ // every OrderHelper::create_order). Tear that fixture shortcut down
+ // here so this test asserts the production wiring rather than passing
+ // on the setUp registration.
+ remove_action( Scheduler::ACTION_HOOK, array( $this->sut, 'handle_scheduled_send' ), 10 );
+ $this->assertFalse(
+ has_action( Scheduler::ACTION_HOOK, array( $this->sut, 'handle_scheduled_send' ) ),
+ 'Fixture cleanup precondition: ACTION_HOOK must be unregistered before init() is asserted.'
+ );
+
+ // init() ran when the container first resolved Scheduler, but WP's
+ // test framework has since restored `$wp_filter` past that point.
+ // Re-invoke here so the assertions exercise the production wiring.
+ $this->sut->init();
+
+ $this->assertNotFalse( has_action( 'woocommerce_new_order', array( $this->sut, 'handle_new_order' ) ) );
+ $this->assertNotFalse( has_action( 'woocommerce_order_status_changed', array( $this->sut, 'handle_status_changed' ) ) );
+ $this->assertNotFalse( has_action( 'woocommerce_trash_order', array( $this->sut, 'handle_cancellation' ) ) );
+ $this->assertNotFalse( has_action( 'woocommerce_before_delete_order', array( $this->sut, 'handle_cancellation' ) ) );
+ $this->assertNotFalse( has_action( Scheduler::ACTION_HOOK, array( $this->sut, 'handle_scheduled_send' ) ) );
+ }
+
+ /**
+ * @testdox handle_scheduled_send() resolves the email lazily and dispatches the send — the path AS uses on its WP-Cron firing context where the email class isn't already loaded.
+ */
+ public function test_handle_scheduled_send_dispatches_to_email(): void {
+ $order = OrderHelper::create_order();
+ $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();
+ $order = wc_get_order( $order->get_id() );
+
+ $mailer = tests_retrieve_phpmailer_instance();
+ $before = count( $mailer->mock_sent );
+
+ $this->sut->handle_scheduled_send( $order->get_id() );
+
+ $this->assertSame( $before + 1, count( $mailer->mock_sent ), 'AS-fired callback must dispatch one email.' );
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertNotEmpty(
+ $fresh->get_meta( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT ),
+ 'Successful send must record the sent_at meta so the dedup gate works on subsequent fires.'
+ );
+ }
+
+ /**
+ * @testdox handle_scheduled_send() records a "sent automatically" order note when the send actually goes out, so the audit trail mirrors the manual-send path.
+ */
+ public function test_handle_scheduled_send_records_order_note_on_success(): void {
+ $order = OrderHelper::create_order();
+ $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->sut->handle_scheduled_send( $order->get_id() );
+
+ $notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+ $note_strings = wp_list_pluck( $notes, 'content' );
+ $this->assertNotEmpty(
+ array_filter(
+ $note_strings,
+ static fn ( $note ) => false !== strpos( $note, 'sent automatically' )
+ ),
+ 'Successful auto-send must add a "sent automatically" order note.'
+ );
+ }
+
+ /**
+ * @testdox handle_scheduled_send() does NOT add a note when trigger() bails (already sent, disabled, suppressed, unsubscribed) — no audit row for a non-event.
+ */
+ public function test_handle_scheduled_send_skips_note_when_trigger_bails(): void {
+ $order = OrderHelper::create_order();
+ $order->set_status( OrderStatus::PENDING );
+ // Mark the order as already sent so trigger() bails on the dedup gate.
+ $order->update_meta_data( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT, (string) ( time() - HOUR_IN_SECONDS ) );
+ $order->save();
+ $order->set_date_created( time() - WC_Email_Customer_Abandoned_Cart_Recovery::ABANDONMENT_THRESHOLD_SECONDS - MINUTE_IN_SECONDS );
+ $order->save();
+
+ $this->sut->handle_scheduled_send( $order->get_id() );
+
+ $notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+ $note_strings = wp_list_pluck( $notes, 'content' );
+ $this->assertEmpty(
+ array_filter(
+ $note_strings,
+ static fn ( $note ) => false !== strpos( $note, 'sent automatically' )
+ ),
+ 'Dedup-gated trigger() must not record a "sent automatically" order note.'
+ );
+ }
+
+ /**
+ * @testdox do_action( Scheduler::ACTION_HOOK, $order_id ) reaches handle_scheduled_send so the production WP-Cron dispatch path is wired without the email class being instantiated up-front.
+ */
+ public function test_action_dispatch_reaches_handle_scheduled_send(): void {
+ $order = OrderHelper::create_order();
+ $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();
+
+ $mailer = tests_retrieve_phpmailer_instance();
+ $before = count( $mailer->mock_sent );
+
+ /**
+ * Fires the Action Scheduler callback that dispatches the abandoned
+ * cart recovery email — simulated here so the test exercises the
+ * registered handler end-to-end.
+ *
+ * @since 11.0.0
+ *
+ * @param int $order_id The order to dispatch the recovery email for.
+ */
+ do_action( Scheduler::ACTION_HOOK, $order->get_id() );
+
+ $this->assertSame( $before + 1, count( $mailer->mock_sent ) );
+ }
+
+ /**
+ * @testdox handle_new_order() schedules the AS action and records the scheduled-at meta for a pending order when automated + enabled.
+ */
+ public function test_handle_new_order_schedules_for_pending_order(): void {
+ $order = OrderHelper::create_order();
+ $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 ),
+ 'Scheduled-at meta must be populated after handle_new_order() schedules the send.'
+ );
+ $this->assertNotFalse(
+ as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ),
+ 'An AS action must be queued for the new pending order.'
+ );
+ }
+
+ /**
+ * @testdox handle_new_order() is a no-op when the order is created in a non-abandoned status (e.g. processing).
+ */
+ public function test_handle_new_order_skips_non_abandoned_status(): void {
+ $order = OrderHelper::create_order();
+ $order->set_status( OrderStatus::PROCESSING );
+ $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() ) ) );
+ }
+
+ /**
+ * @testdox handle_new_order() is a no-op when the merchant has turned off automated scheduling — the email stays manual-send-only.
+ */
+ public function test_handle_new_order_skips_when_not_automated(): void {
+ $this->email->update_option( 'automated', 'no' );
+
+ $order = OrderHelper::create_order();
+ $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 ) );
+ }
+
+ /**
+ * @testdox handle_new_order() is a no-op when the email itself is disabled, so the dropdown gate and the scheduler agree on what "off" means.
+ */
+ public function test_handle_new_order_skips_when_email_disabled(): void {
+ $this->email->update_option( 'enabled', 'no' );
+ $this->email->enabled = 'no';
+
+ $order = OrderHelper::create_order();
+ $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 ) );
+ }
+
+ /**
+ * @testdox handle_new_order() is a no-op when the suppress filter returns true, so partner plugins that handle recovery themselves don't see a duplicate send queued.
+ */
+ public function test_handle_new_order_skips_when_suppressed(): void {
+ $order = OrderHelper::create_order();
+ $order->set_status( OrderStatus::PENDING );
+ $order->save();
+
+ add_filter( 'woocommerce_abandoned_cart_recovery_suppress', '__return_true' );
+ try {
+ $this->sut->handle_new_order( $order->get_id() );
+ } finally {
+ remove_filter( 'woocommerce_abandoned_cart_recovery_suppress', '__return_true' );
+ }
+
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ) );
+ }
+
+ /**
+ * @testdox handle_new_order() does not stack schedules: a second call for the same order id is a no-op once SCHEDULED_META_KEY is set.
+ */
+ public function test_handle_new_order_is_idempotent(): void {
+ $order = OrderHelper::create_order();
+ $order->set_status( OrderStatus::PENDING );
+ $order->save();
+
+ $this->sut->handle_new_order( $order->get_id() );
+ $fresh = wc_get_order( $order->get_id() );
+ $first_when = (string) $fresh->get_meta( Scheduler::SCHEDULED_META_KEY );
+
+ $this->sut->handle_new_order( $order->get_id() );
+ $fresh = wc_get_order( $order->get_id() );
+ $second_when = (string) $fresh->get_meta( Scheduler::SCHEDULED_META_KEY );
+
+ $this->assertSame( $first_when, $second_when, 'Repeat new-order events must not reschedule the send.' );
+ }
+
+ /**
+ * @testdox handle_new_order() refuses to schedule when the order is already marked as sent — defense against re-creating a schedule for an order that already received the email.
+ */
+ public function test_handle_new_order_skips_when_already_sent(): void {
+ $order = OrderHelper::create_order();
+ $order->set_status( OrderStatus::PENDING );
+ $order->update_meta_data( WC_Email_Customer_Abandoned_Cart_Recovery::META_KEY_SENT_AT, (string) time() );
+ $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 ) );
+ }
+
+ /**
+ * @testdox handle_status_changed() cancels the pending send when the order transitions out of the abandoned set (e.g. pending → processing).
+ */
+ public function test_handle_status_changed_cancels_on_exit_from_abandoned_set(): void {
+ $order = $this->schedule_for_pending_order();
+
+ $this->sut->handle_status_changed( $order->get_id(), OrderStatus::PENDING, OrderStatus::PROCESSING );
+
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ), 'Scheduled-at meta must be cleared once the order leaves the abandoned set.' );
+ $this->assertFalse( as_next_scheduled_action( Scheduler::ACTION_HOOK, array( $order->get_id() ) ) );
+ }
+
+ /**
+ * @testdox handle_status_changed() does nothing when the previous status was already outside `pending` — nothing to cancel.
+ */
+ public function test_handle_status_changed_noop_when_old_status_already_outside_set(): void {
+ $order = OrderHelper::create_order();
+ $order->set_status( OrderStatus::PROCESSING );
+ $order->save();
+
+ // No prior schedule → just assert this path doesn't blow up and the
+ // meta stays empty.
+ $this->sut->handle_status_changed( $order->get_id(), OrderStatus::PROCESSING, OrderStatus::COMPLETED );
+
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertSame( '', $fresh->get_meta( Scheduler::SCHEDULED_META_KEY ) );
+ }
+
+ /**
+ * @testdox handle_cancellation() unschedules and clears the meta for a trashed order so a deleted-then-restored order doesn't fire a stale send.
+ */
+ public function test_handle_cancellation_clears_state(): void {
+ $order = $this->schedule_for_pending_order();
+
+ $this->sut->handle_cancellation( $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() ) ) );
+ }
+
+ /**
+ * 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
+ * state.
+ */
+ private function schedule_for_pending_order(): WC_Order {
+ $order = OrderHelper::create_order();
+ $order->set_status( OrderStatus::PENDING );
+ $order->save();
+
+ $this->sut->handle_new_order( $order->get_id() );
+
+ return wc_get_order( $order->get_id() );
+ }
+}