JezK
Edit File: Scheduler.php
<?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 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 the * eligible-status set or is trashed/deleted, so a customer who completes * 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 * 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'; /** * 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' ); /** * 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 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, 4 ); add_action( 'woocommerce_trash_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 ); } /** * Schedule the automated send when an order is created in an eligible status. * * 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 * * @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; } // 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; } $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 * 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 * * @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, $order = null ): void { if ( ! $order instanceof WC_Order ) { $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; } $this->handle_cancellation( $order_id, $order ); } /** * 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. * @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, $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 ) ); 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(); } } /** * Dispatch the recovery email when the scheduled AS action fires. * * 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 * * @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; } if ( ! $email->is_automated() ) { 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 ) ); } /** * 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 { /** * This filter is documented in includes/emails/class-wc-email-customer-abandoned-cart-recovery.php * * @since 11.0.0 */ return (array) apply_filters( 'woocommerce_abandoned_cart_recovery_eligible_statuses', array( OrderStatus::PENDING ), $order ); } /** * 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; } }