Commit acf75078a4a for woocommerce
commit acf75078a4a4fd2acd9227f4b2c9c9fd0e58486d
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date: Thu Oct 22 14:26:14 2026 +0200
Add an option to skip Cancelled order emails for timed-out unpaid orders (#70054)
* Mark orders cancelled by the unpaid order timeout
The held-stock cron cancels unpaid orders with a plain status change,
so nothing later can tell a timeout from a manual cancellation. The
Cancelled order email needs that to let merchants skip these orders.
The cron now records on the order that the timer cancelled it. The
marker lives in order meta because deferred emails reload the order in
a later request. It is removed whenever the order is cancelled for any
other reason, so a restored order cancelled by hand is not affected.
* Let merchants skip Cancelled order emails for timed-out orders
Since the admin Cancelled order email started covering pending orders,
every unpaid order the hold stock timer cancels sends the admin an
email and adds an "Email sent" note. Stores with many abandoned
checkouts get a stream of emails they did not ask for.
The email gets a checkbox, on by default, to leave those orders out.
When it is off, the email is skipped for orders the timer cancelled and
fires woocommerce_email_skipped with a new reason, so no "Email sent"
note is added. Manual, customer and REST cancellations still send.
* Expose the unpaid-timeout email setting to the block email editor
With the block email editor on, merchants edit the Cancelled order
email there and never reach the classic settings page, so they could
not see the new setting for automatic cancellations.
The editor's woocommerce_data field now returns the setting as a
boolean for emails that have it, and null for the rest. Saving accepts
only a boolean, so an empty value cannot switch the email off.
* Show the automatic cancellations toggle in the block email editor
Merchants using the block email editor had no way to change whether
the Cancelled order email goes out for unpaid orders cancelled by the
hold stock timer.
The sidebar now shows the setting as a toggle for emails that have it,
with the same wording as the classic settings page, next to the
recipients and the other email toggles.
* Use Text from @wordpress/ui in the email editor sidebar
The branch lint checks whole changed files, and this file still
imported the experimental Text component, which the recommended
components rule flags. Switch to the supported component and drop the
suppression that is no longer needed.
* Test that the block editor shows automatic cancellations on by default
Stores that never saved the setting keep receiving these emails, so the
editor must show the toggle as on. No test pinned that default.
diff --git a/plugins/woocommerce/changelog/add-wooplug-8041-cancelled-email-unpaid-timeout b/plugins/woocommerce/changelog/add-wooplug-8041-cancelled-email-unpaid-timeout
new file mode 100644
index 00000000000..8584648f9bb
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-wooplug-8041-cancelled-email-unpaid-timeout
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add an option to skip the Cancelled order email for orders cancelled automatically when the hold stock time runs out.
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/sidebar-settings.test.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/sidebar-settings.test.tsx
index ad845ab57a9..37dae56ff3b 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/sidebar-settings.test.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/sidebar-settings.test.tsx
@@ -108,6 +108,7 @@ const defaultWooCommerceData: WooCommerceData = {
default_subject: 'Default subject',
is_manual: false,
enabled: true,
+ send_on_unpaid_timeout: null,
};
const RichTextWithButton = ( {
@@ -424,4 +425,83 @@ describe( 'Email editor sidebar settings', () => {
screen.getByDisplayValue( 'team@example.com' )
).toBeInTheDocument();
} );
+
+ describe( 'automatic cancellations toggle', () => {
+ const toggleName =
+ 'Send this email when an unpaid order is cancelled automatically';
+
+ const setSendOnUnpaidTimeout = ( value: boolean ) => {
+ mockEntityState.woocommerceData = {
+ ...defaultWooCommerceData,
+ send_on_unpaid_timeout: value,
+ };
+ mockEntityState.editedWooCommerceData = {
+ ...defaultWooCommerceData,
+ send_on_unpaid_timeout: value,
+ unrelated_setting: 'preserved',
+ };
+ };
+
+ it( 'shows the automatic cancellations toggle checked when the email supports it', () => {
+ setSendOnUnpaidTimeout( true );
+ renderSettings();
+
+ expect(
+ screen.getByRole( 'checkbox', { name: toggleName } )
+ ).toBeChecked();
+ expect(
+ screen.getByText(
+ 'Unpaid orders are cancelled automatically when the “Hold stock (minutes)” limit in Settings › Products › Inventory is reached.'
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'saves the toggle as a boolean and keeps other settings', async () => {
+ setSendOnUnpaidTimeout( true );
+ renderSettings();
+ const initialEditedWooCommerceData = {
+ ...mockEntityState.editedWooCommerceData,
+ };
+
+ await userEvent.click(
+ screen.getByRole( 'checkbox', { name: toggleName } )
+ );
+
+ expect( mockEditEntityRecord ).toHaveBeenLastCalledWith(
+ 'postType',
+ 'woo_email',
+ '42',
+ {
+ woocommerce_data: {
+ ...initialEditedWooCommerceData,
+ send_on_unpaid_timeout: false,
+ },
+ }
+ );
+ expect(
+ screen.getByRole( 'checkbox', { name: toggleName } )
+ ).not.toBeChecked();
+ } );
+
+ it.each( [
+ [ 'null', null ],
+ [ 'missing', undefined ],
+ ] )(
+ 'hides the toggle when the email has no such setting (%s)',
+ ( _label, value ) => {
+ const data: WooCommerceData = { ...defaultWooCommerceData };
+ if ( value === undefined ) {
+ delete data.send_on_unpaid_timeout;
+ } else {
+ data.send_on_unpaid_timeout = value;
+ }
+ mockEntityState.woocommerceData = data;
+ renderSettings();
+
+ expect(
+ screen.queryByRole( 'checkbox', { name: /unpaid order/ } )
+ ).toBeNull();
+ }
+ );
+ } );
} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/global.d.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/global.d.ts
index 46ef1b815ed..dbfb4d6279a 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/global.d.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/global.d.ts
@@ -39,6 +39,8 @@ interface EmailWooCommerceData {
default_subject: string | null;
is_manual: boolean;
enabled: boolean;
+ /** `null` means this email has no such setting. */
+ send_on_unpaid_timeout: boolean | null;
}
/**
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/sidebar_settings.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/sidebar_settings.tsx
index 0fb96c9ce03..c0d3b6f46dc 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/sidebar_settings.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/sidebar_settings.tsx
@@ -8,9 +8,9 @@ import {
PanelRow,
TextControl,
ToggleControl,
- __experimentalText as Text,
} from '@wordpress/components';
import { addFilter } from '@wordpress/hooks';
+import { Text } from '@wordpress/ui';
import { __ } from '@wordpress/i18n';
import clsx from 'clsx';
import { useState } from '@wordpress/element';
@@ -207,6 +207,34 @@ const SidebarSettings = ( {
) }
</BaseControl>
</PanelRow>
+ { typeof woocommerce_email_data.send_on_unpaid_timeout ===
+ 'boolean' && (
+ <PanelRow>
+ <BaseControl __nextHasNoMarginBottom>
+ <ToggleControl
+ __nextHasNoMarginBottom
+ name="send_on_unpaid_timeout"
+ checked={
+ woocommerce_email_data.send_on_unpaid_timeout
+ }
+ label={ __(
+ 'Send this email when an unpaid order is cancelled automatically',
+ 'woocommerce'
+ ) }
+ help={ __(
+ 'Unpaid orders are cancelled automatically when the “Hold stock (minutes)” limit in Settings › Products › Inventory is reached.',
+ 'woocommerce'
+ ) }
+ onChange={ ( value ) => {
+ updateWooMailProperty(
+ 'send_on_unpaid_timeout',
+ value
+ );
+ } }
+ />
+ </BaseControl>
+ </PanelRow>
+ ) }
{ supportsCcBcc && (
<>
<PanelRow>
diff --git a/plugins/woocommerce/client/admin/eslint-suppressions.json b/plugins/woocommerce/client/admin/eslint-suppressions.json
index d0d57ff41d4..7c4b7e65807 100644
--- a/plugins/woocommerce/client/admin/eslint-suppressions.json
+++ b/plugins/woocommerce/client/admin/eslint-suppressions.json
@@ -349,11 +349,6 @@
"count": 3
}
},
- "client/wp-admin-scripts/email-editor-integration/sidebar_settings.tsx": {
- "@wordpress/use-recommended-components": {
- "count": 1
- }
- },
"client/wp-admin-scripts/email-editor-integration/update-banner.tsx": {
"@wordpress/use-recommended-components": {
"count": 1
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 8966580160f..e7c1d2ad15c 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -437,6 +437,7 @@ final class WooCommerce {
$container->get( Automattic\WooCommerce\Internal\ProductFeed\ProductFeed::class )->register();
$container->get( Automattic\WooCommerce\Internal\PushNotifications\PushNotifications::class )->register();
$container->get( Automattic\WooCommerce\Internal\Orders\PointOfSaleEmailHandler::class )->register();
+ $container->get( Automattic\WooCommerce\Internal\Orders\UnpaidOrderTimeoutMarker::class )->register();
$container->get( Automattic\WooCommerce\Internal\POS\POSController::class )->register();
$container->get( Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController::class )->register();
$container->get( Automattic\WooCommerce\Internal\StockNotifications\StockNotifications::class )->register();
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-cancelled-order.php b/plugins/woocommerce/includes/emails/class-wc-email-cancelled-order.php
index c5755e7a8f1..c4e77697aa5 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-cancelled-order.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-cancelled-order.php
@@ -5,6 +5,7 @@
* @package WooCommerce\Emails
*/
+use Automattic\WooCommerce\Internal\Orders\UnpaidOrderTimeoutMarker;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
if ( ! defined( 'ABSPATH' ) ) {
@@ -104,11 +105,34 @@ if ( ! class_exists( 'WC_Email_Cancelled_Order', false ) ) :
$this->placeholders['{order_billing_full_name}'] = $this->object->get_formatted_billing_full_name();
}
+ if ( $this->is_enabled() && $this->object instanceof WC_Order && ! $this->should_send_for_unpaid_timeout( $this->object ) ) {
+ /**
+ * Fires when a transactional email is not sent for a reason other than being disabled.
+ *
+ * This action is documented in includes/emails/class-wc-email.php
+ *
+ * @since 10.9.0
+ */
+ do_action( 'woocommerce_email_skipped', self::SKIP_REASON_UNPAID_TIMEOUT, $this->id, $this );
+ $this->restore_locale();
+ return;
+ }
+
$this->send_notification();
$this->restore_locale();
}
+ /**
+ * Whether the email should go out, given the setting for orders the unpaid order timer cancelled.
+ *
+ * @param WC_Order $order The order.
+ * @return bool
+ */
+ private function should_send_for_unpaid_timeout( WC_Order $order ): bool {
+ return 'yes' === $this->get_option( 'send_on_unpaid_timeout', 'yes' ) || ! UnpaidOrderTimeoutMarker::is_marked( $order );
+ }
+
/**
* Get content html.
*
@@ -181,13 +205,20 @@ if ( ! class_exists( 'WC_Email_Cancelled_Order', false ) ) :
/* translators: %s: list of placeholders */
$placeholder_text = sprintf( __( 'Available placeholders: %s', 'woocommerce' ), '<code>' . esc_html( implode( '</code>, <code>', array_keys( $this->placeholders ) ) ) . '</code>' );
$this->form_fields = array(
- 'enabled' => array(
+ 'enabled' => array(
'title' => __( 'Enable/Disable', 'woocommerce' ),
'type' => 'checkbox',
'label' => __( 'Enable this email notification', 'woocommerce' ),
'default' => 'yes',
),
- 'recipient' => array(
+ 'send_on_unpaid_timeout' => array(
+ 'title' => __( 'Automatic cancellations', 'woocommerce' ),
+ 'type' => 'checkbox',
+ 'label' => __( 'Send this email when an unpaid order is cancelled automatically', 'woocommerce' ),
+ 'description' => __( 'Unpaid orders are cancelled automatically when the “Hold stock (minutes)” limit in Settings › Products › Inventory is reached.', 'woocommerce' ),
+ 'default' => 'yes',
+ ),
+ 'recipient' => array(
'title' => __( 'Recipient(s)', 'woocommerce' ),
'type' => 'text',
/* translators: %s: admin email */
@@ -196,7 +227,7 @@ if ( ! class_exists( 'WC_Email_Cancelled_Order', false ) ) :
'default' => '',
'desc_tip' => true,
),
- 'subject' => array(
+ 'subject' => array(
'title' => __( 'Subject', 'woocommerce' ),
'type' => 'text',
'desc_tip' => true,
@@ -204,7 +235,7 @@ if ( ! class_exists( 'WC_Email_Cancelled_Order', false ) ) :
'placeholder' => $this->get_default_subject(),
'default' => '',
),
- 'heading' => array(
+ 'heading' => array(
'title' => __( 'Email heading', 'woocommerce' ),
'type' => 'text',
'desc_tip' => true,
@@ -212,7 +243,7 @@ if ( ! class_exists( 'WC_Email_Cancelled_Order', false ) ) :
'placeholder' => $this->get_default_heading(),
'default' => '',
),
- 'additional_content' => array(
+ 'additional_content' => array(
'title' => __( 'Additional content', 'woocommerce' ),
'description' => __( 'Text to appear below the main email content.', 'woocommerce' ) . ' ' . $placeholder_text,
'css' => 'width:400px; height: 75px;',
@@ -221,7 +252,7 @@ if ( ! class_exists( 'WC_Email_Cancelled_Order', false ) ) :
'default' => $this->get_default_additional_content(),
'desc_tip' => true,
),
- 'email_type' => array(
+ 'email_type' => array(
'title' => __( 'Email type', 'woocommerce' ),
'type' => 'select',
'description' => __( 'Choose which format of email to send.', 'woocommerce' ),
diff --git a/plugins/woocommerce/includes/emails/class-wc-email.php b/plugins/woocommerce/includes/emails/class-wc-email.php
index 0a6a380d602..a3a7e349aa1 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email.php
@@ -41,6 +41,13 @@ class WC_Email extends WC_Settings_API {
*/
public const SKIP_REASON_NO_RECIPIENT = 'no_recipient';
+ /**
+ * Skip-reason identifier used when the unpaid order timer cancelled the order and the email is set not to send for those.
+ *
+ * @since 11.4.0
+ */
+ public const SKIP_REASON_UNPAID_TIMEOUT = 'unpaid_timeout_notifications_disabled';
+
/**
* Email method ID.
*
@@ -1201,6 +1208,7 @@ class WC_Email extends WC_Settings_API {
*
* The $reason parameter identifies why the email was not sent:
* - WC_Email::SKIP_REASON_NO_RECIPIENT: No recipient address was available at send time.
+ * - WC_Email::SKIP_REASON_UNPAID_TIMEOUT: The unpaid order timer cancelled the order and the email is set not to send for those.
*
* @since 10.9.0
*
diff --git a/plugins/woocommerce/includes/wc-order-functions.php b/plugins/woocommerce/includes/wc-order-functions.php
index d290cfbe596..8b0cb81aea0 100644
--- a/plugins/woocommerce/includes/wc-order-functions.php
+++ b/plugins/woocommerce/includes/wc-order-functions.php
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Enums\PaymentGatewayFeature;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
+use Automattic\WooCommerce\Internal\Orders\UnpaidOrderTimeoutMarker;
use Automattic\WooCommerce\Internal\Utilities\Users;
use Automattic\WooCommerce\Utilities\OrderUtil;
use Automattic\WooCommerce\Utilities\StringUtil;
@@ -1163,7 +1164,7 @@ function wc_cancel_unpaid_orders() {
* @param WC_Order $order The unpaid order object.
*/
if ( apply_filters( 'woocommerce_cancel_unpaid_order', in_array( $order->get_created_via(), array( 'checkout', 'store-api' ), true ), $order ) ) {
- $order->update_status( OrderStatus::CANCELLED, __( 'Unpaid order cancelled - time limit reached.', 'woocommerce' ) );
+ wc_get_container()->get( UnpaidOrderTimeoutMarker::class )->cancel_unpaid_order( $order, __( 'Unpaid order cancelled - time limit reached.', 'woocommerce' ) );
}
}
}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php b/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
index 642089beb01..18367d7f68d 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
@@ -61,33 +61,35 @@ class EmailApiController {
// When the email type is not found, it means that the email type is not supported.
if ( ! $email ) {
return array(
- 'subject' => null,
- 'subject_full' => null,
- 'subject_partial' => null,
- 'preheader' => null,
- 'default_subject' => null,
- 'email_type' => null,
- 'recipient' => null,
- 'cc' => null,
- 'bcc' => null,
+ 'subject' => null,
+ 'subject_full' => null,
+ 'subject_partial' => null,
+ 'preheader' => null,
+ 'default_subject' => null,
+ 'email_type' => null,
+ 'recipient' => null,
+ 'cc' => null,
+ 'bcc' => null,
+ 'send_on_unpaid_timeout' => null,
);
}
$form_fields = $email->get_form_fields();
$enabled = $email->get_option( 'enabled' );
return array(
- 'enabled' => is_null( $enabled ) ? $email->is_enabled() : 'yes' === $enabled,
- 'is_manual' => $email->is_manual(),
- 'subject' => $email->get_option( 'subject' ),
- 'subject_full' => $email->get_option( 'subject_full' ), // For customer_refunded_order email type because it has two different subjects.
- 'subject_partial' => $email->get_option( 'subject_partial' ),
- 'preheader' => $email->get_option( 'preheader' ),
- 'default_subject' => $email->get_default_subject(),
- 'email_type' => $email_type,
+ 'enabled' => is_null( $enabled ) ? $email->is_enabled() : 'yes' === $enabled,
+ 'is_manual' => $email->is_manual(),
+ 'subject' => $email->get_option( 'subject' ),
+ 'subject_full' => $email->get_option( 'subject_full' ), // For customer_refunded_order email type because it has two different subjects.
+ 'subject_partial' => $email->get_option( 'subject_partial' ),
+ 'preheader' => $email->get_option( 'preheader' ),
+ 'default_subject' => $email->get_default_subject(),
+ 'email_type' => $email_type,
// Recipient is possible to set only for the specific type of emails. When the field `recipient` is set in the form fields, it means that the email type has a recipient field.
- 'recipient' => array_key_exists( 'recipient', $form_fields ) ? $email->get_option( 'recipient', get_option( 'admin_email' ) ) : null,
- 'cc' => $email->supports_cc_bcc() ? $email->get_option( 'cc' ) : null,
- 'bcc' => $email->supports_cc_bcc() ? $email->get_option( 'bcc' ) : null,
+ 'recipient' => array_key_exists( 'recipient', $form_fields ) ? $email->get_option( 'recipient', get_option( 'admin_email' ) ) : null,
+ 'cc' => $email->supports_cc_bcc() ? $email->get_option( 'cc' ) : null,
+ 'bcc' => $email->supports_cc_bcc() ? $email->get_option( 'bcc' ) : null,
+ 'send_on_unpaid_timeout' => array_key_exists( 'send_on_unpaid_timeout', $form_fields ) ? 'yes' === $email->get_option( 'send_on_unpaid_timeout', 'yes' ) : null,
);
}
@@ -136,6 +138,9 @@ class EmailApiController {
if ( array_key_exists( 'recipient', $data ) ) {
$email->update_option( 'recipient', $data['recipient'] );
}
+ if ( isset( $data['send_on_unpaid_timeout'] ) && is_bool( $data['send_on_unpaid_timeout'] ) && array_key_exists( 'send_on_unpaid_timeout', $email->get_form_fields() ) ) {
+ $email->update_option( 'send_on_unpaid_timeout', $data['send_on_unpaid_timeout'] ? 'yes' : 'no' );
+ }
if ( $email->supports_cc_bcc() ) {
if ( array_key_exists( 'cc', $data ) ) {
$email->update_option( 'cc', $data['cc'] );
@@ -227,15 +232,16 @@ class EmailApiController {
public function get_email_data_schema(): array {
return Builder::object(
array(
- 'subject' => Builder::string()->nullable(),
- 'subject_full' => Builder::string()->nullable(), // For customer_refunded_order email type because it has two different subjects.
- 'subject_partial' => Builder::string()->nullable(),
- 'preheader' => Builder::string()->nullable(),
- 'default_subject' => Builder::string()->nullable(),
- 'email_type' => Builder::string()->nullable(),
- 'recipient' => Builder::string()->nullable(),
- 'cc' => Builder::string()->nullable(),
- 'bcc' => Builder::string()->nullable(),
+ 'subject' => Builder::string()->nullable(),
+ 'subject_full' => Builder::string()->nullable(), // For customer_refunded_order email type because it has two different subjects.
+ 'subject_partial' => Builder::string()->nullable(),
+ 'preheader' => Builder::string()->nullable(),
+ 'default_subject' => Builder::string()->nullable(),
+ 'email_type' => Builder::string()->nullable(),
+ 'recipient' => Builder::string()->nullable(),
+ 'cc' => Builder::string()->nullable(),
+ 'bcc' => Builder::string()->nullable(),
+ 'send_on_unpaid_timeout' => Builder::boolean()->nullable(),
)
)->to_array();
}
diff --git a/plugins/woocommerce/src/Internal/Orders/UnpaidOrderTimeoutMarker.php b/plugins/woocommerce/src/Internal/Orders/UnpaidOrderTimeoutMarker.php
new file mode 100644
index 00000000000..b52637e3e0b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Orders/UnpaidOrderTimeoutMarker.php
@@ -0,0 +1,96 @@
+<?php
+/**
+ * UnpaidOrderTimeoutMarker class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Orders;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use WC_Order;
+
+/**
+ * Marks orders that the unpaid order timer cancelled.
+ *
+ * The marker lives in order meta so it survives the deferred email queue, which reloads
+ * the order later. It is only managed through status transitions: set by the timer,
+ * and cleared when the order enters the cancelled status for any other reason.
+ *
+ * @internal Just for internal use.
+ *
+ * @since 11.4.0
+ */
+class UnpaidOrderTimeoutMarker implements RegisterHooksInterface {
+
+ /**
+ * Order meta key holding the marker.
+ */
+ public const META_KEY = '_cancelled_by_unpaid_timeout';
+
+ /**
+ * ID of the order the timer is cancelling right now.
+ *
+ * @var int|null
+ */
+ private static ?int $timer_order_id = null;
+
+ /**
+ * Whether the unpaid order timer cancelled the order.
+ *
+ * @param WC_Order $order The order.
+ * @return bool
+ */
+ public static function is_marked( WC_Order $order ): bool {
+ return 'yes' === $order->get_meta( self::META_KEY );
+ }
+
+ /**
+ * Cancel an order on behalf of the unpaid order timer.
+ *
+ * The marker is saved together with the status, before any status hook runs.
+ *
+ * @param WC_Order $order The order to cancel.
+ * @param string $note Order note to add.
+ */
+ public function cancel_unpaid_order( WC_Order $order, string $note ): void {
+ $previous_id = self::$timer_order_id;
+ self::$timer_order_id = $order->get_id();
+
+ try {
+ $order->update_meta_data( self::META_KEY, 'yes' );
+ $order->update_status( OrderStatus::CANCELLED, $note );
+ } finally {
+ self::$timer_order_id = $previous_id;
+ }
+ }
+
+ /**
+ * Register hooks and filters.
+ */
+ public function register(): void {
+ add_action( 'woocommerce_order_status_cancelled', array( $this, 'clear_marker_on_other_cancellations' ), 10, 2 );
+ }
+
+ /**
+ * Remove the marker when an order is cancelled by anything other than the timer.
+ *
+ * @param mixed $order_id Order ID.
+ * @param mixed $order Order object, if the hook passes it.
+ *
+ * @internal For exclusive usage within this class, backwards compatibility not guaranteed.
+ */
+ public function clear_marker_on_other_cancellations( $order_id, $order = null ): void {
+ if ( ! $order instanceof WC_Order ) {
+ $order = wc_get_order( $order_id );
+ }
+
+ if ( ! $order instanceof WC_Order || (int) $order_id === self::$timer_order_id || ! self::is_marked( $order ) ) {
+ return;
+ }
+
+ $order->delete_meta_data( self::META_KEY );
+ $order->save_meta_data();
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/emails/class-wc-email-cancelled-order-test.php b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-cancelled-order-test.php
new file mode 100644
index 00000000000..46ef013832d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-cancelled-order-test.php
@@ -0,0 +1,343 @@
+<?php
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Internal\Email\DeferredEmailQueue;
+use Automattic\WooCommerce\Internal\Orders\UnpaidOrderTimeoutMarker;
+
+/**
+ * WC_Email_Cancelled_Order test.
+ *
+ * @covers WC_Email_Cancelled_Order
+ */
+class WC_Email_Cancelled_Order_Test extends \WC_Unit_Test_Case {
+
+ private const OPTION_KEY = 'woocommerce_cancelled_order_settings';
+
+ private const NOTIFICATION_HOOKS = array(
+ 'woocommerce_order_status_pending_to_cancelled_notification',
+ 'woocommerce_order_status_processing_to_cancelled_notification',
+ 'woocommerce_order_status_on-hold_to_cancelled_notification',
+ );
+
+ /**
+ * Hook calls captured from woocommerce_email_skipped.
+ *
+ * @var array
+ */
+ private $skipped = array();
+
+ /**
+ * Hook calls captured from woocommerce_email_disabled.
+ *
+ * @var array
+ */
+ private $disabled = array();
+
+ /**
+ * Load the email classes, which are not loaded by default.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $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-cancelled-order.php';
+
+ WC()->mailer()->init();
+
+ $this->skipped = array();
+ $this->disabled = array();
+ add_action( 'woocommerce_email_skipped', array( $this, 'capture_skipped' ), 10, 2 );
+ add_action( 'woocommerce_email_disabled', array( $this, 'capture_disabled' ) );
+ }
+
+ /**
+ * Clean up the option, hooks and deferred queue.
+ */
+ public function tearDown(): void {
+ delete_option( self::OPTION_KEY );
+ remove_action( 'woocommerce_email_skipped', array( $this, 'capture_skipped' ), 10 );
+ remove_action( 'woocommerce_email_disabled', array( $this, 'capture_disabled' ) );
+ remove_all_filters( 'woocommerce_queue_class' );
+ remove_action( 'woocommerce_order_status_pending_to_cancelled', array( \WC_Emails::class, 'queue_transactional_email' ), 10 );
+ $this->set_wc_emails_deferred_queue( null );
+ $this->reset_queue_singleton();
+ parent::tearDown();
+ }
+
+ /**
+ * Capture woocommerce_email_skipped.
+ *
+ * @param string $reason Skip reason.
+ * @param string $email_id Email ID.
+ */
+ public function capture_skipped( $reason, $email_id ): void {
+ $this->skipped[] = array( $reason, $email_id );
+ }
+
+ /**
+ * Capture woocommerce_email_disabled.
+ *
+ * @param string $email_id Email ID.
+ */
+ public function capture_disabled( $email_id ): void {
+ $this->disabled[] = $email_id;
+ }
+
+ /**
+ * @testdox The automatic cancellations setting is a checkbox right after Enable/Disable and defaults to yes.
+ */
+ public function test_send_on_unpaid_timeout_defaults_to_yes(): void {
+ update_option( self::OPTION_KEY, array( 'enabled' => 'yes' ) );
+ $sut = new WC_Email_Cancelled_Order();
+
+ $keys = array_keys( $sut->form_fields );
+
+ $this->assertSame( 'yes', $sut->get_option( 'send_on_unpaid_timeout' ) );
+ $this->assertSame( 'checkbox', $sut->form_fields['send_on_unpaid_timeout']['type'] );
+ $this->assertSame( 'send_on_unpaid_timeout', $keys[ array_search( 'enabled', $keys, true ) + 1 ] );
+ }
+
+ /**
+ * @testdox Trigger with marked $marked and setting $setting sends: $expect_sent.
+ * @dataProvider provide_trigger_cases
+ *
+ * @param bool $marked Whether the unpaid order timer cancelled the order.
+ * @param string $setting Value of the send_on_unpaid_timeout setting.
+ * @param bool $expect_sent Whether the email is expected to be sent.
+ */
+ public function test_trigger_respects_unpaid_timeout_setting( bool $marked, string $setting, bool $expect_sent ): void {
+ $order = $this->create_cancelled_order( $marked );
+ $sut = $this->make_email( $setting );
+
+ $mailer = tests_retrieve_phpmailer_instance();
+ $sent_before = count( $mailer->mock_sent );
+ $notes_before = $this->count_sent_notes( $order, $sut );
+
+ $sut->trigger( $order->get_id(), $order );
+
+ $this->assertSame( $expect_sent ? 1 : 0, count( $mailer->mock_sent ) - $sent_before );
+ $this->assertSame( $expect_sent ? 1 : 0, $this->count_sent_notes( $order, $sut ) - $notes_before );
+ $this->assertSame(
+ $expect_sent ? array() : array( array( WC_Email::SKIP_REASON_UNPAID_TIMEOUT, 'cancelled_order' ) ),
+ $this->skipped
+ );
+ }
+
+ /**
+ * Cases for the trigger test.
+ *
+ * @return array
+ */
+ public function provide_trigger_cases(): array {
+ return array(
+ 'timer cancel, setting off' => array( true, 'no', false ),
+ 'timer cancel, setting on' => array( true, 'yes', true ),
+ 'other cancel, setting off' => array( false, 'no', true ),
+ 'other cancel, setting on' => array( false, 'yes', true ),
+ );
+ }
+
+ /**
+ * @testdox A disabled email fires the disabled hook, not the skipped hook.
+ */
+ public function test_disabled_email_fires_disabled_not_skipped(): void {
+ $order = $this->create_cancelled_order( true );
+ $sut = $this->make_email( 'no', 'no' );
+
+ $mailer = tests_retrieve_phpmailer_instance();
+ $sent_before = count( $mailer->mock_sent );
+
+ $sut->trigger( $order->get_id(), $order );
+
+ $this->assertSame( array( 'cancelled_order' ), $this->disabled );
+ $this->assertSame( array(), $this->skipped );
+ $this->assertSame( $sent_before, count( $mailer->mock_sent ) );
+ }
+
+ /**
+ * @testdox An order the timer cancelled, then reopened ($intermediate) and cancelled by hand, sends the email.
+ * @dataProvider provide_intermediate_statuses
+ *
+ * @param string $intermediate Status the order passes through before the manual cancellation.
+ */
+ public function test_order_cancelled_again_by_hand_sends_with_setting_off( string $intermediate ): void {
+ $order = $this->create_cancelled_order( true );
+ $sut = $this->make_email( 'no' );
+
+ $order->update_status( $intermediate );
+ $order->update_status( 'cancelled' );
+ wc_get_container()->get( \Automattic\WooCommerce\Caches\OrderCache::class )->remove( $order->get_id() );
+
+ $mailer = tests_retrieve_phpmailer_instance();
+ $sent_before = count( $mailer->mock_sent );
+
+ $sut->trigger( $order->get_id() );
+
+ $this->assertSame( 1, count( $mailer->mock_sent ) - $sent_before );
+ $this->assertSame( array(), $this->skipped );
+ }
+
+ /**
+ * Intermediate statuses.
+ *
+ * @return array
+ */
+ public function provide_intermediate_statuses(): array {
+ return array(
+ 'pending' => array( 'pending' ),
+ 'processing' => array( 'processing' ),
+ 'on-hold' => array( 'on-hold' ),
+ );
+ }
+
+ /**
+ * @testdox A deferred email reads the marker from the reloaded order (setting $setting, reopened: $reopened).
+ * @dataProvider provide_deferred_cases
+ *
+ * @param string $setting Value of the send_on_unpaid_timeout setting.
+ * @param bool $reopened Whether the order goes back to pending before the queued email runs.
+ * @param bool $expect_sent Whether the email is expected to be sent.
+ */
+ public function test_deferred_email_reads_marker_after_reload( string $setting, bool $reopened, bool $expect_sent ): void {
+ $this->reset_queue_singleton();
+ add_filter(
+ 'woocommerce_queue_class',
+ function () {
+ return \WC_Admin_Test_Action_Queue::class;
+ }
+ );
+ $queue = new DeferredEmailQueue();
+ $this->set_wc_emails_deferred_queue( $queue );
+ remove_action( 'woocommerce_order_status_pending_to_cancelled', array( \WC_Emails::class, 'send_transactional_email' ), 10 );
+ add_action( 'woocommerce_order_status_pending_to_cancelled', array( \WC_Emails::class, 'queue_transactional_email' ), 10, 10 );
+
+ // The real status change pushes the email onto the deferred queue instead of sending it.
+ $order = $this->create_cancelled_order( true );
+ $sut = $this->make_email( $setting );
+ $queue->dispatch();
+
+ if ( $reopened ) {
+ $order->set_status( 'pending' );
+ $order->save();
+ }
+ wc_get_container()->get( \Automattic\WooCommerce\Caches\OrderCache::class )->remove( $order->get_id() );
+
+ $action = null;
+ foreach ( \WC_Queue::instance()->actions as $candidate ) {
+ if ( 'woocommerce_send_queued_transactional_email' === $candidate['hook'] && 'woocommerce_order_status_pending_to_cancelled' === $candidate['args'][0] ) {
+ $action = $candidate;
+ }
+ }
+ $this->assertNotNull( $action, 'The cancelled email should be queued.' );
+
+ $notes_before = $this->count_sent_notes( $order, $sut );
+ $round_trip = json_decode( wp_json_encode( $action['args'] ), true );
+ $queue->send_queued_transactional_email( ...$round_trip );
+
+ $this->assertSame( $expect_sent ? 1 : 0, $this->count_sent_notes( $order, $sut ) - $notes_before );
+ }
+
+ /**
+ * Deferred cases.
+ *
+ * @return array
+ */
+ public function provide_deferred_cases(): array {
+ return array(
+ 'setting off' => array( 'no', false, false ),
+ 'setting on' => array( 'yes', false, true ),
+ 'setting off, order reopened' => array( 'no', true, false ),
+ );
+ }
+
+ /**
+ * Detach the cancelled email listeners so real status changes do not send emails.
+ */
+ private function detach_notification_hooks(): void {
+ foreach ( self::NOTIFICATION_HOOKS as $hook ) {
+ remove_all_actions( $hook );
+ }
+ }
+
+ /**
+ * Create the email with the given settings.
+ *
+ * @param string $send_on_timeout Value of send_on_unpaid_timeout.
+ * @param string $enabled Value of enabled.
+ * @return WC_Email_Cancelled_Order
+ */
+ private function make_email( string $send_on_timeout, string $enabled = 'yes' ): WC_Email_Cancelled_Order {
+ update_option(
+ self::OPTION_KEY,
+ array(
+ 'enabled' => $enabled,
+ 'recipient' => 'admin@example.com',
+ 'send_on_unpaid_timeout' => $send_on_timeout,
+ )
+ );
+ return new WC_Email_Cancelled_Order();
+ }
+
+ /**
+ * Cancel a pending order, through the timer when $by_timer is true, with the email listeners detached.
+ *
+ * @param bool $by_timer Whether the unpaid order timer cancels it.
+ * @return WC_Order
+ */
+ private function create_cancelled_order( bool $by_timer ): WC_Order {
+ $order = WC_Helper_Order::create_order();
+ $order->set_status( 'pending' );
+ $order->save();
+
+ $this->detach_notification_hooks();
+
+ if ( $by_timer ) {
+ wc_get_container()->get( UnpaidOrderTimeoutMarker::class )->cancel_unpaid_order( $order, 'Unpaid order cancelled - time limit reached.' );
+ } else {
+ $order->update_status( 'cancelled' );
+ }
+
+ return $order;
+ }
+
+ /**
+ * Count the "email sent" notes of an email on an order.
+ *
+ * @param WC_Order $order Order.
+ * @param WC_Email_Cancelled_Order $email Email.
+ * @return int
+ */
+ private function count_sent_notes( WC_Order $order, WC_Email_Cancelled_Order $email ): int {
+ $expected = sprintf( 'Email "%s" sent.', $email->get_title() );
+ $count = 0;
+ foreach ( wc_get_order_notes( array( 'order_id' => $order->get_id() ) ) as $note ) {
+ if ( $expected === $note->content ) {
+ ++$count;
+ }
+ }
+ return $count;
+ }
+
+ /**
+ * Reset the WC_Queue singleton so the test queue filter takes effect.
+ */
+ private function reset_queue_singleton(): void {
+ $reflection = new \ReflectionClass( \WC_Queue::class );
+ $instance = $reflection->getProperty( 'instance' );
+ $instance->setAccessible( true );
+ $instance->setValue( null, null );
+ }
+
+ /**
+ * Set the deferred queue used by WC_Emails.
+ *
+ * @param DeferredEmailQueue|null $queue Deferred email queue instance.
+ */
+ private function set_wc_emails_deferred_queue( ?DeferredEmailQueue $queue ): void {
+ $reflection = new \ReflectionClass( \WC_Emails::class );
+ $deferred_queue = $reflection->getProperty( 'deferred_queue' );
+ $deferred_queue->setAccessible( true );
+ $deferred_queue->setValue( null, $queue );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
index 72ffb2df23a..2ef95e266c7 100644
--- a/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
@@ -8,7 +8,10 @@
use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Enums\OrderInternalStatus;
use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
+use Automattic\WooCommerce\Internal\Orders\UnpaidOrderTimeoutMarker;
use Automattic\WooCommerce\Internal\Utilities\Users;
+use Automattic\WooCommerce\Utilities\OrderUtil;
/**
* Class WC_Order_Functions_Test
@@ -814,4 +817,68 @@ class WC_Order_Functions_Test extends \WC_Unit_Test_Case {
$this->assertSame( strtotime( '2024-06-28 15:34:05 UTC' ), $note->date_created->getTimestamp(), 'The note should keep the moment it was created.' );
$this->assertSame( '2024-06-29 00:34:05', $note->date_created->date( 'Y-m-d H:i:s' ), 'The note should be shown in the new site timezone.' );
}
+
+ /**
+ * @testdox wc_cancel_unpaid_orders() marks the orders it cancels before the status hooks run.
+ */
+ public function test_wc_cancel_unpaid_orders_marks_orders_cancelled_by_timeout(): void {
+ global $wpdb;
+
+ $original_manage_stock = get_option( 'woocommerce_manage_stock' );
+ $original_hold_minutes = get_option( 'woocommerce_hold_stock_minutes' );
+ update_option( 'woocommerce_manage_stock', 'yes' );
+ update_option( 'woocommerce_hold_stock_minutes', '60' );
+
+ $order_cache = wc_get_container()->get( OrderCache::class );
+ $expired = time() - 3 * HOUR_IN_SECONDS;
+ $orders = array();
+ foreach ( array( 'checkout', 'admin' ) as $created_via ) {
+ $order = WC_Helper_Order::create_order();
+ $order->set_created_via( $created_via );
+ $order->save();
+ $orders[ $created_via ] = $order->get_id();
+
+ if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ $wpdb->update( OrdersTableDataStore::get_orders_table_name(), array( 'date_updated_gmt' => gmdate( 'Y-m-d H:i:s', $expired ) ), array( 'id' => $order->get_id() ) );
+ } else {
+ $wpdb->update(
+ $wpdb->posts,
+ array(
+ 'post_modified' => get_date_from_gmt( gmdate( 'Y-m-d H:i:s', $expired ) ),
+ 'post_modified_gmt' => gmdate( 'Y-m-d H:i:s', $expired ),
+ ),
+ array( 'ID' => $order->get_id() )
+ );
+ clean_post_cache( $order->get_id() );
+ }
+ $order_cache->remove( $order->get_id() );
+ }
+
+ $marked_in_hook = null;
+ $callback = function ( $order_id ) use ( $order_cache, &$marked_in_hook ) {
+ $order_cache->remove( $order_id );
+ $marked_in_hook = UnpaidOrderTimeoutMarker::is_marked( wc_get_order( $order_id ) );
+ };
+ add_action( 'woocommerce_order_status_pending_to_cancelled', $callback );
+
+ wc_cancel_unpaid_orders();
+
+ remove_action( 'woocommerce_order_status_pending_to_cancelled', $callback );
+ as_unschedule_all_actions( 'woocommerce_cancel_unpaid_orders' );
+ update_option( 'woocommerce_manage_stock', $original_manage_stock );
+ update_option( 'woocommerce_hold_stock_minutes', $original_hold_minutes );
+
+ $order_cache->remove( $orders['checkout'] );
+ $order_cache->remove( $orders['admin'] );
+ $cancelled = wc_get_order( $orders['checkout'] );
+ $untouched = wc_get_order( $orders['admin'] );
+ $notes = implode( ' ', wp_list_pluck( wc_get_order_notes( array( 'order_id' => $orders['checkout'] ) ), 'content' ) );
+
+ $this->assertSame( OrderStatus::CANCELLED, $cancelled->get_status() );
+ $this->assertTrue( $marked_in_hook, 'The marker should be saved before the status hooks run.' );
+ $this->assertTrue( UnpaidOrderTimeoutMarker::is_marked( $cancelled ) );
+ $this->assertStringContainsString( 'Unpaid order cancelled', $notes );
+ $this->assertSame( OrderStatus::PENDING, $untouched->get_status() );
+ $this->assertFalse( UnpaidOrderTimeoutMarker::is_marked( $untouched ) );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
index b41e62d3a20..94a6c758867 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
@@ -89,6 +89,8 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
$result = $this->email_api_controller->get_email_data( $post_data );
$this->assertNull( $result['subject'] );
$this->assertNull( $result['email_type'] );
+ $this->assertArrayHasKey( 'send_on_unpaid_timeout', $result );
+ $this->assertNull( $result['send_on_unpaid_timeout'] );
}
/**
@@ -107,13 +109,15 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
array( 'recipient', get_option( 'admin_email' ), 'admin@example.com' ),
array( 'cc', null, 'cc@example.com' ),
array( 'bcc', null, 'bcc@example.com' ),
+ array( 'send_on_unpaid_timeout', 'yes', 'no' ),
)
);
$mock_email->method( 'supports_cc_bcc' )->willReturn( true );
$mock_email->method( 'get_default_subject' )->willReturn( 'Default Subject' );
$mock_email->method( 'get_form_fields' )->willReturn(
array(
- 'recipient' => array(),
+ 'recipient' => array(),
+ 'send_on_unpaid_timeout' => array(),
)
);
@@ -134,6 +138,7 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
$this->assertEquals( 'admin@example.com', $result['recipient'] );
$this->assertEquals( 'cc@example.com', $result['cc'] );
$this->assertEquals( 'bcc@example.com', $result['bcc'] );
+ $this->assertFalse( $result['send_on_unpaid_timeout'] );
}
/**
@@ -249,6 +254,7 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
$this->assertArrayHasKey( 'subject', $schema['properties'] );
$this->assertArrayHasKey( 'preheader', $schema['properties'] );
$this->assertArrayHasKey( 'recipient', $schema['properties'] );
+ $this->assertArrayHasKey( 'send_on_unpaid_timeout', $schema['properties'] );
}
/**
@@ -324,6 +330,101 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
$post_data = array( 'id' => $this->email_post->ID );
$result = $controller->get_email_data( $post_data );
$this->assertNull( $result['recipient'] );
+ $this->assertNull( $result['send_on_unpaid_timeout'] );
+ }
+
+ /**
+ * @testdox get_email_data() reports the unpaid timeout toggle as enabled when no option is saved.
+ */
+ public function test_get_email_data_send_on_unpaid_timeout_defaults_to_true(): void {
+ $controller = $this->create_controller_with_email( $this->create_email_with_unpaid_timeout_field() );
+
+ $result = $controller->get_email_data( array( 'id' => $this->email_post->ID ) );
+
+ $this->assertTrue( $result['send_on_unpaid_timeout'] );
+ }
+
+ /**
+ * @testdox save_email_data() stores the unpaid timeout toggle as yes/no.
+ */
+ public function test_save_email_data_stores_send_on_unpaid_timeout(): void {
+ $controller = $this->create_controller_with_email( $this->create_email_with_unpaid_timeout_field() );
+
+ $controller->save_email_data(
+ array(
+ 'subject' => 'Subject',
+ 'send_on_unpaid_timeout' => false,
+ ),
+ $this->email_post
+ );
+ $option = get_option( 'woocommerce_' . $this->email_type . '_settings' );
+ $this->assertSame( 'no', $option['send_on_unpaid_timeout'] );
+
+ $controller->save_email_data(
+ array(
+ 'subject' => 'Subject',
+ 'send_on_unpaid_timeout' => true,
+ ),
+ $this->email_post
+ );
+ $option = get_option( 'woocommerce_' . $this->email_type . '_settings' );
+ $this->assertSame( 'yes', $option['send_on_unpaid_timeout'] );
+ }
+
+ /**
+ * @testdox save_email_data() leaves the unpaid timeout toggle untouched for non-boolean values.
+ */
+ public function test_save_email_data_ignores_non_boolean_send_on_unpaid_timeout(): void {
+ update_option( 'woocommerce_' . $this->email_type . '_settings', array( 'send_on_unpaid_timeout' => 'no' ) );
+ $controller = $this->create_controller_with_email( $this->create_email_with_unpaid_timeout_field() );
+
+ foreach ( array( null, 'yes', 1 ) as $value ) {
+ $controller->save_email_data(
+ array(
+ 'subject' => 'Subject',
+ 'send_on_unpaid_timeout' => $value,
+ ),
+ $this->email_post
+ );
+ $option = get_option( 'woocommerce_' . $this->email_type . '_settings' );
+ $this->assertSame( 'no', $option['send_on_unpaid_timeout'] );
+ }
+ }
+
+ /**
+ * @testdox save_email_data() ignores the unpaid timeout toggle when the email has no such field.
+ */
+ public function test_save_email_data_ignores_send_on_unpaid_timeout_without_field(): void {
+ $controller = $this->create_controller_with_email( new EmailStub() );
+
+ $controller->save_email_data(
+ array(
+ 'subject' => 'Subject',
+ 'send_on_unpaid_timeout' => false,
+ ),
+ $this->email_post
+ );
+
+ $option = get_option( 'woocommerce_' . $this->email_type . '_settings' );
+ $this->assertArrayNotHasKey( 'send_on_unpaid_timeout', $option );
+ }
+
+ /**
+ * Create a test email that has the unpaid timeout form field.
+ *
+ * @return EmailStub
+ */
+ private function create_email_with_unpaid_timeout_field(): EmailStub {
+ return new class() extends EmailStub {
+ /**
+ * Get form fields.
+ *
+ * @return array
+ */
+ public function get_form_fields(): array {
+ return array_merge( parent::get_form_fields(), array( 'send_on_unpaid_timeout' => array( 'default' => '' ) ) );
+ }
+ };
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Orders/UnpaidOrderTimeoutMarkerTest.php b/plugins/woocommerce/tests/php/src/Internal/Orders/UnpaidOrderTimeoutMarkerTest.php
new file mode 100644
index 00000000000..12d8a0c8828
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Orders/UnpaidOrderTimeoutMarkerTest.php
@@ -0,0 +1,151 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Orders;
+
+use Automattic\WooCommerce\Caches\OrderCache;
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\Orders\UnpaidOrderTimeoutMarker;
+use WC_Helper_Order;
+use WC_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the UnpaidOrderTimeoutMarker class.
+ *
+ * @covers \Automattic\WooCommerce\Internal\Orders\UnpaidOrderTimeoutMarker
+ */
+class UnpaidOrderTimeoutMarkerTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var UnpaidOrderTimeoutMarker
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new UnpaidOrderTimeoutMarker();
+ $this->sut->register();
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ remove_all_actions( 'woocommerce_order_status_cancelled' );
+ parent::tearDown();
+ }
+
+ /**
+ * Load an order from the database, bypassing the order cache.
+ *
+ * @param int $order_id Order ID.
+ * @return WC_Order
+ */
+ private function reload( int $order_id ): WC_Order {
+ wc_get_container()->get( OrderCache::class )->remove( $order_id );
+ return wc_get_order( $order_id );
+ }
+
+ /**
+ * @testdox cancel_unpaid_order cancels the order, marks it and adds the note.
+ */
+ public function test_cancel_unpaid_order_cancels_and_marks_order(): void {
+ $order = WC_Helper_Order::create_order();
+
+ $this->sut->cancel_unpaid_order( $order, 'Timer note.' );
+
+ $fresh = $this->reload( $order->get_id() );
+ $this->assertSame( OrderStatus::CANCELLED, $fresh->get_status() );
+ $this->assertTrue( UnpaidOrderTimeoutMarker::is_marked( $fresh ) );
+ $notes = implode( ' ', wp_list_pluck( wc_get_order_notes( array( 'order_id' => $order->get_id() ) ), 'content' ) );
+ $this->assertStringContainsString( 'Timer note.', $notes );
+ }
+
+ /**
+ * @testdox The marker survives a restore: $to
+ * @dataProvider provide_restore_statuses
+ *
+ * @param string $to Status the order is restored to.
+ */
+ public function test_marker_survives_restore( string $to ): void {
+ $order = WC_Helper_Order::create_order();
+ $this->sut->cancel_unpaid_order( $order, 'Timer note.' );
+
+ $order->update_status( $to );
+
+ $this->assertTrue( UnpaidOrderTimeoutMarker::is_marked( $this->reload( $order->get_id() ) ) );
+ }
+
+ /**
+ * @testdox A later cancellation clears the marker after a restore to $to
+ * @dataProvider provide_restore_statuses
+ *
+ * @param string $to Status the order is restored to.
+ */
+ public function test_later_cancellation_clears_marker( string $to ): void {
+ $order = WC_Helper_Order::create_order();
+ $this->sut->cancel_unpaid_order( $order, 'Timer note.' );
+ $order->update_status( $to );
+
+ $order->update_status( OrderStatus::CANCELLED );
+
+ $this->assertFalse( UnpaidOrderTimeoutMarker::is_marked( $order ), 'The caller\'s object should be cleared.' );
+ $this->assertFalse( UnpaidOrderTimeoutMarker::is_marked( $this->reload( $order->get_id() ) ) );
+ }
+
+ /**
+ * Data provider for statuses an order can be restored to.
+ *
+ * @return array
+ */
+ public function provide_restore_statuses(): array {
+ return array(
+ 'pending' => array( OrderStatus::PENDING ),
+ 'processing' => array( OrderStatus::PROCESSING ),
+ 'on-hold' => array( OrderStatus::ON_HOLD ),
+ );
+ }
+
+ /**
+ * @testdox A leftover marker on a pending order is cleared by a manual cancellation.
+ */
+ public function test_manual_cancellation_clears_leftover_marker(): void {
+ $order = WC_Helper_Order::create_order();
+ $order->update_meta_data( UnpaidOrderTimeoutMarker::META_KEY, 'yes' );
+ $order->save();
+
+ $order->update_status( OrderStatus::CANCELLED );
+
+ $this->assertFalse( UnpaidOrderTimeoutMarker::is_marked( $this->reload( $order->get_id() ) ) );
+ }
+
+ /**
+ * @testdox A nested timer cancellation does not clear the outer order's marker.
+ */
+ public function test_nested_timer_cancellation_keeps_outer_marker(): void {
+ $order_a = WC_Helper_Order::create_order();
+ $order_b = WC_Helper_Order::create_order();
+ $sut = $this->sut;
+
+ add_action(
+ 'woocommerce_order_status_cancelled',
+ function ( $order_id ) use ( $order_a, $order_b, $sut ) {
+ if ( $order_id === $order_a->get_id() ) {
+ $sut->cancel_unpaid_order( $order_b, 'Nested.' );
+ }
+ },
+ 5
+ );
+
+ $sut->cancel_unpaid_order( $order_a, 'Outer.' );
+
+ $this->assertTrue( UnpaidOrderTimeoutMarker::is_marked( $this->reload( $order_a->get_id() ) ) );
+ $this->assertTrue( UnpaidOrderTimeoutMarker::is_marked( $this->reload( $order_b->get_id() ) ) );
+ }
+}