Commit 44f7810de64 for woocommerce
commit 44f7810de64ca1366d193adeec6cead55e17ad17
Author: Povilas Staskus <povilas.staskus@automattic.com>
Date: Tue Feb 24 14:53:51 2026 +0200
Suppress standard emails for POS-paid orders and auto-trigger POS emails (#63322)
* Add is_order_paid_at_pos() to PointOfSaleOrderUtil
Detect POS payments regardless of where the order was created.
An order is considered paid at POS if created via POS REST API,
paid via card terminal (_wcpay_ipp_channel = mobile_pos), or
paid via cash (_cash_change_amount meta present).
* Add PointOfSaleEmailHandler to suppress standard emails for POS-paid orders
Centralized handler that suppresses standard transactional emails
(completed, processing, on-hold, refunded, new order) for orders
paid at POS. POS uses its own email templates sent automatically
or via REST API.
* Add auto-trigger for POS completed email when POS-paid orders are completed
Hook auto_trigger to woocommerce_order_status_completed_notification
to automatically send the POS completed email when any order paid at
POS transitions to completed status. Also update template availability
to use is_order_paid_at_pos() and require completed status.
* Refactor POS refunded email for POS-paid orders
- Replace per-email refund suppression with centralized handler
- Add auto_trigger for POS-paid order refunds (uses is_order_paid_at_pos)
- Add REST API manual send and template availability support
- Set manual=true for consistency with POS completed email
- Remove enabled toggle from settings (POS emails are always active)
* Update PHPStan baseline and add changelog entry
* Remove extra blank line in POS refunded order email class
* Use instanceof instead of is_a() in POS refunded email
Removes unnecessary PHPStan baseline entry by using the type-safe
instanceof operator which PHPStan understands natively.
* Clean up docblocks: remove unnecessary @since on private methods, add @internal to trigger()
* Add return types and fix property type instead of baseline suppressions
- Add : void return type to auto_trigger(), trigger(), enable_order_email_actions()
- Fix $refund property type from WC_Order|bool to WC_Order_Refund|false
- Remove corresponding PHPStan baseline entries
* Remove status guard from POS completed email template eligibility
The add_to_valid_template_classes method no longer requires the order
to have 'completed' status. A POS receipt should be sendable for any
POS-paid order regardless of current status (e.g., processing,
on-hold). The POS payment check alone is sufficient.
* Pass specific refund_id to POS refunded email trigger
Instead of picking the most recent refund via get_refunds(), the
trigger method now accepts an optional refund_id parameter and
resolves the specific refund. When no refund_id is provided (REST
API resend path), refund is set to false. This ensures the correct
refund is used when multiple refunds exist on an order.
* Add tests for POS refunded email refund_id parameter
Verify that trigger uses the specific refund_id when provided and
sets refund to false when no refund_id is given (REST API resend).
* Update docblock to reflect removed status guard
The comment still referenced "POS-paid completed orders" after the
status check was removed. Update to "POS-paid orders" to avoid
misleading future maintainers.
* Remove unused $second_refund assignment in test
* Add @internal annotations to public hook callback methods
diff --git a/plugins/woocommerce/changelog/woomob-2161-pos-bookings-receipts b/plugins/woocommerce/changelog/woomob-2161-pos-bookings-receipts
new file mode 100644
index 00000000000..8e4ab4dd4d9
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-2161-pos-bookings-receipts
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Suppress standard transactional emails and auto-trigger POS email templates for orders paid at POS, regardless of where the order was created.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 02d4eb20d92..ebd6390d958 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -396,6 +396,7 @@ final class WooCommerce {
$container->get( Automattic\WooCommerce\Internal\Admin\Agentic\AgenticController::class )->register();
$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();
// Classes inheriting from RestApiControllerBase.
$container->get( Automattic\WooCommerce\Internal\ReceiptRendering\ReceiptRenderingRestController::class )->register();
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-pos-completed-order.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-pos-completed-order.php
index 7a913805db9..ea11b1df635 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-pos-completed-order.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-pos-completed-order.php
@@ -66,6 +66,8 @@ if ( ! class_exists( 'WC_Email_Customer_POS_Completed_Order', false ) ) :
*
* @param int $order_id The order ID.
* @param string $template_id The email template ID.
+ *
+ * @internal For exclusive usage within this class, backwards compatibility not guaranteed.
*/
public function trigger( $order_id, $template_id ) {
if ( $this->id !== $template_id ) {
@@ -190,12 +192,31 @@ if ( ! class_exists( 'WC_Email_Customer_POS_Completed_Order', false ) ) :
);
}
+ /**
+ * Auto-trigger this email when a POS-paid order is completed.
+ *
+ * @param int $order_id The order ID.
+ * @param WC_Order|false $order The order object.
+ *
+ * @internal
+ * @since 10.6.0
+ */
+ public function auto_trigger( $order_id, $order = false ): void {
+ if ( ! $order instanceof WC_Order ) {
+ $order = wc_get_order( $order_id );
+ }
+ if ( ! $order instanceof WC_Order || ! PointOfSaleOrderUtil::is_order_paid_at_pos( $order ) ) {
+ return;
+ }
+ $this->trigger( $order_id, $this->id );
+ }
+
/**
* Enable order email actions for POS orders.
*/
private function enable_order_email_actions_for_pos_orders() {
+ add_action( 'woocommerce_order_status_completed_notification', array( $this, 'auto_trigger' ), 10, 2 );
$this->enable_email_template_for_pos_orders();
- // Enable send email when requested.
add_action( 'woocommerce_rest_order_actions_email_send', array( $this, 'trigger' ), 10, 2 );
}
@@ -308,6 +329,8 @@ if ( ! class_exists( 'WC_Email_Customer_POS_Completed_Order', false ) ) :
* @param int $item_id Order item ID.
* @param array $item Order item data.
* @param WC_Order $order Order object.
+ *
+ * @internal For exclusive usage within this class, backwards compatibility not guaranteed.
*/
public function add_unit_price( $item_id, $item, $order ) {
$unit_price = OrderPriceFormatter::get_formatted_item_subtotal( $order, $item, get_option( 'woocommerce_tax_display_cart' ) );
@@ -321,6 +344,8 @@ if ( ! class_exists( 'WC_Email_Customer_POS_Completed_Order', false ) ) :
* @param WC_Order $order Order object.
* @param string $tax_display Tax display.
* @return array Modified array of total rows.
+ *
+ * @internal For exclusive usage within this class, backwards compatibility not guaranteed.
*/
public function order_item_totals( $total_rows, $order, $tax_display ) {
$cash_payment_change_due_amount = $order->get_meta( '_cash_change_amount', true );
@@ -361,14 +386,17 @@ if ( ! class_exists( 'WC_Email_Customer_POS_Completed_Order', false ) ) :
}
/**
- * Add this email template to the list of valid templates for POS orders.
+ * Add this email template to the list of valid templates for POS-paid orders.
*
* @param array $valid_template_classes Array of valid template class names.
* @param WC_Order $order The order.
* @return array Modified array of valid template class names.
+ *
+ * @internal For exclusive usage within this class, backwards compatibility not guaranteed.
+ * @since 10.6.0
*/
public function add_to_valid_template_classes( $valid_template_classes, $order ) {
- if ( ! PointOfSaleOrderUtil::is_pos_order( $order ) ) {
+ if ( ! PointOfSaleOrderUtil::is_order_paid_at_pos( $order ) ) {
return $valid_template_classes;
}
$valid_template_classes[] = get_class( $this );
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-pos-refunded-order.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-pos-refunded-order.php
index 1efd4d4f1c5..34edc84ee46 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-pos-refunded-order.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-pos-refunded-order.php
@@ -31,7 +31,7 @@ if ( ! class_exists( 'WC_Email_Customer_POS_Refunded_Order', false ) ) :
/**
* Refund order.
*
- * @var WC_Order|bool
+ * @var WC_Order_Refund|false
*/
public $refund;
@@ -57,6 +57,8 @@ if ( ! class_exists( 'WC_Email_Customer_POS_Refunded_Order', false ) ) :
'{order_number}' => '',
);
+ $this->enable_order_email_actions();
+
// Call parent constructor.
parent::__construct();
@@ -65,8 +67,7 @@ if ( ! class_exists( 'WC_Email_Customer_POS_Refunded_Order', false ) ) :
? __( 'Let customers know when a full or partial refund is on its way to them for their POS order.', 'woocommerce' )
: __( 'Order refunded emails are sent to customers when their POS orders are refunded.', 'woocommerce' );
- $this->disable_default_refund_emails_for_pos_orders();
- $this->register_refund_email_triggers();
+ $this->manual = true;
if ( $this->block_email_editor_enabled ) {
$this->title = __( 'POS order refunded', 'woocommerce' );
@@ -164,60 +165,63 @@ if ( ! class_exists( 'WC_Email_Customer_POS_Refunded_Order', false ) ) :
public function set_email_strings( $partial_refund = false ) {}
/**
- * Full refund notification.
+ * Auto-trigger this email when a POS-paid order is refunded.
*
- * @param int $order_id Order ID.
- * @param int $refund_id Refund ID.
+ * @param int $order_id The order ID.
+ * @param int $refund_id The refund ID.
*
- * @internal For exclusive usage within this class, backwards compatibility not guaranteed.
+ * @internal
+ * @since 10.6.0
*/
- public function trigger_full( $order_id, $refund_id = null ) {
- $this->trigger( $order_id, false, $refund_id );
+ public function auto_trigger( $order_id, $refund_id = null ): void {
+ $order = wc_get_order( $order_id );
+ if ( ! $order instanceof WC_Order || ! PointOfSaleOrderUtil::is_order_paid_at_pos( $order ) ) {
+ return;
+ }
+ $this->trigger( $order_id, $this->id, $refund_id );
}
/**
- * Partial refund notification.
+ * Trigger the sending of this email.
*
- * @param int $order_id Order ID.
- * @param int $refund_id Refund ID.
+ * @param int $order_id The order ID.
+ * @param string $template_id The email template ID.
+ * @param int $refund_id The refund ID.
*
- * @internal For exclusive usage within this class, backwards compatibility not guaranteed.
+ * @internal
+ * @since 10.6.0
*/
- public function trigger_partial( $order_id, $refund_id = null ) {
- $this->trigger( $order_id, true, $refund_id );
- }
+ public function trigger( $order_id, $template_id, $refund_id = null ): void {
+ if ( $this->id !== $template_id ) {
+ return;
+ }
- /**
- * Trigger.
- *
- * @param int $order_id Order ID.
- * @param bool $partial_refund Whether it is a partial refund or a full refund.
- * @param int $refund_id Refund ID.
- */
- private function trigger( $order_id, $partial_refund = false, $refund_id = null ) {
if ( ! $order_id ) {
return;
}
- // Only trigger for POS orders.
+
$order = wc_get_order( $order_id );
- if ( ! $order || ! PointOfSaleOrderUtil::is_pos_order( $order ) ) {
+ if ( ! $order instanceof WC_Order ) {
return;
}
+
$this->setup_locale();
- $this->partial_refund = $partial_refund;
- $this->object = $order;
- $this->recipient = $this->object->get_billing_email();
- $this->placeholders['{order_date}'] = wc_format_datetime( $this->object->get_date_created() );
- $this->placeholders['{order_number}'] = $this->object->get_order_number();
+ $this->partial_refund = $order->get_remaining_refund_amount() > 0;
+ $this->object = $order;
+ $this->recipient = $order->get_billing_email();
+
+ $this->placeholders['{order_date}'] = wc_format_datetime( $order->get_date_created() );
+ $this->placeholders['{order_number}'] = $order->get_order_number();
if ( ! empty( $refund_id ) ) {
- $this->refund = wc_get_order( $refund_id );
+ $refund = wc_get_order( $refund_id );
+ $this->refund = $refund instanceof WC_Order_Refund ? $refund : false;
} else {
$this->refund = false;
}
- if ( $this->is_enabled() && $this->get_recipient() ) {
+ if ( $this->get_recipient() ) {
$this->send( $this->get_recipient(), $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments() );
}
@@ -315,12 +319,6 @@ if ( ! class_exists( 'WC_Email_Customer_POS_Refunded_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(
- 'title' => __( 'Enable/Disable', 'woocommerce' ),
- 'type' => 'checkbox',
- 'label' => __( 'Enable this email notification', 'woocommerce' ),
- 'default' => 'yes',
- ),
'subject_full' => array(
'title' => __( 'Full refund subject', 'woocommerce' ),
'type' => 'text',
@@ -442,6 +440,8 @@ if ( ! class_exists( 'WC_Email_Customer_POS_Refunded_Order', false ) ) :
* @param int $item_id Order item ID.
* @param array $item Order item data.
* @param WC_Order $order Order object.
+ *
+ * @internal For exclusive usage within this class, backwards compatibility not guaranteed.
*/
public function add_unit_price( $item_id, $item, $order ) {
$unit_price = OrderPriceFormatter::get_formatted_item_subtotal( $order, $item, get_option( 'woocommerce_tax_display_cart' ) );
@@ -449,44 +449,34 @@ if ( ! class_exists( 'WC_Email_Customer_POS_Refunded_Order', false ) ) :
}
/**
- * Disable default WooCommerce refund emails for POS orders.
- * The core refund email IDs are in WC_Email_Customer_Refunded_Order's trigger method.
- *
- * This method adds filters to prevent the default WooCommerce refund emails
- * from being sent for orders created through the Point of Sale system.
- * Instead, the POS-specific refund emails will be used.
+ * Enable order email actions for POS refunded orders.
*/
- private function disable_default_refund_emails_for_pos_orders() {
- add_filter( 'woocommerce_email_enabled_customer_partially_refunded_order', array( $this, 'disable_default_refund_email_for_pos_orders' ), 10, 3 );
- add_filter( 'woocommerce_email_enabled_customer_refunded_order', array( $this, 'disable_default_refund_email_for_pos_orders' ), 10, 3 );
+ private function enable_order_email_actions(): void {
+ add_action( 'woocommerce_order_fully_refunded_notification', array( $this, 'auto_trigger' ), 10, 2 );
+ add_action( 'woocommerce_order_partially_refunded_notification', array( $this, 'auto_trigger' ), 10, 2 );
+ add_filter( 'woocommerce_rest_order_actions_email_valid_template_classes', array( $this, 'add_to_valid_template_classes' ), 10, 2 );
+ add_action( 'woocommerce_rest_order_actions_email_send', array( $this, 'trigger' ), 10, 2 );
}
/**
- * Disable the default WooCommerce refund email for POS orders.
+ * Add this email template to the list of valid templates for POS-paid orders with refunds.
*
- * @param bool $enabled Whether the email is enabled.
- * @param WC_Order|null $order The order object.
- * @param WC_Email|null $email The email object.
- * @return bool
+ * @param array $valid_template_classes Array of valid template class names.
+ * @param WC_Order $order The order.
+ * @return array Modified array of valid template class names.
*
* @internal For exclusive usage within this class, backwards compatibility not guaranteed.
+ * @since 10.6.0
*/
- public function disable_default_refund_email_for_pos_orders( $enabled, $order, $email ) {
- if ( $order && PointOfSaleOrderUtil::is_pos_order( $order ) ) {
- return false;
+ public function add_to_valid_template_classes( $valid_template_classes, $order ) {
+ if ( 0 === count( $order->get_refunds() ) ) {
+ return $valid_template_classes;
}
- return $enabled;
- }
-
- /**
- * Register triggers for POS refund emails.
- *
- * This method adds actions to trigger the refund emails for POS orders.
- * It ensures that the emails are sent correctly when a full or partial refund is made.
- */
- private function register_refund_email_triggers() {
- add_action( 'woocommerce_order_fully_refunded_notification', array( $this, 'trigger_full' ), 10, 2 );
- add_action( 'woocommerce_order_partially_refunded_notification', array( $this, 'trigger_partial' ), 10, 2 );
+ if ( ! PointOfSaleOrderUtil::is_order_paid_at_pos( $order ) ) {
+ return $valid_template_classes;
+ }
+ $valid_template_classes[] = get_class( $this );
+ return $valid_template_classes;
}
/**
@@ -496,6 +486,8 @@ if ( ! class_exists( 'WC_Email_Customer_POS_Refunded_Order', false ) ) :
* @param WC_Order $order Order object.
* @param string $tax_display Tax display.
* @return array Modified array of total rows.
+ *
+ * @internal For exclusive usage within this class, backwards compatibility not guaranteed.
*/
public function order_item_totals( $total_rows, $order, $tax_display ) {
$auth_code = $order->get_meta( '_charge_id', true );
@@ -573,7 +565,6 @@ if ( ! class_exists( 'WC_Email_Customer_POS_Refunded_Order', false ) ) :
);
}
-
/**
* Replace footer text placeholders with POS-specific values.
*
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 16c8c7774a2..fe9bec56cfc 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -19092,18 +19092,6 @@ parameters:
count: 2
path: includes/emails/class-wc-email-customer-pos-refunded-order.php
- -
- message: '#^Call to an undefined method WC_Order\|WC_Order_Refund\:\:get_billing_email\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: includes/emails/class-wc-email-customer-pos-refunded-order.php
-
- -
- message: '#^Call to an undefined method WC_Order\|WC_Order_Refund\:\:get_order_number\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: includes/emails/class-wc-email-customer-pos-refunded-order.php
-
-
message: '#^Callback expects 3 parameters, \$accepted_args is set to 4\.$#'
identifier: arguments.count
@@ -19122,12 +19110,6 @@ parameters:
count: 1
path: includes/emails/class-wc-email-customer-pos-refunded-order.php
- -
- message: '#^Method WC_Email_Customer_POS_Refunded_Order\:\:disable_default_refund_emails_for_pos_orders\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: includes/emails/class-wc-email-customer-pos-refunded-order.php
-
-
message: '#^Method WC_Email_Customer_POS_Refunded_Order\:\:email_footer\(\) has no return type specified\.$#'
identifier: missingType.return
@@ -19146,12 +19128,6 @@ parameters:
count: 1
path: includes/emails/class-wc-email-customer-pos-refunded-order.php
- -
- message: '#^Method WC_Email_Customer_POS_Refunded_Order\:\:register_refund_email_triggers\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: includes/emails/class-wc-email-customer-pos-refunded-order.php
-
-
message: '#^Method WC_Email_Customer_POS_Refunded_Order\:\:remove_pos_customizations\(\) has no return type specified\.$#'
identifier: missingType.return
@@ -19164,24 +19140,6 @@ parameters:
count: 1
path: includes/emails/class-wc-email-customer-pos-refunded-order.php
- -
- message: '#^Method WC_Email_Customer_POS_Refunded_Order\:\:trigger\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: includes/emails/class-wc-email-customer-pos-refunded-order.php
-
- -
- message: '#^Method WC_Email_Customer_POS_Refunded_Order\:\:trigger_full\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: includes/emails/class-wc-email-customer-pos-refunded-order.php
-
- -
- message: '#^Method WC_Email_Customer_POS_Refunded_Order\:\:trigger_partial\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: includes/emails/class-wc-email-customer-pos-refunded-order.php
-
-
message: '#^PHPDoc tag @extends has invalid value \(WC_Email\)\: Unexpected token "\\n\\t ", expected ''\<'' at offset 267 on line 9$#'
identifier: phpDoc.parseError
@@ -19200,11 +19158,6 @@ parameters:
count: 1
path: includes/emails/class-wc-email-customer-pos-refunded-order.php
- -
- message: '#^Property WC_Email_Customer_POS_Refunded_Order\:\:\$refund \(bool\|WC_Order\) does not accept WC_Order\|WC_Order_Refund\|false\.$#'
- identifier: assign.propertyType
- count: 1
- path: includes/emails/class-wc-email-customer-pos-refunded-order.php
-
message: '#^Method WC_Email_Customer_Processing_Order\:\:trigger\(\) has no return type specified\.$#'
diff --git a/plugins/woocommerce/src/Internal/Orders/OrderActionsRestController.php b/plugins/woocommerce/src/Internal/Orders/OrderActionsRestController.php
index 98718d14c7c..93d2639b315 100644
--- a/plugins/woocommerce/src/Internal/Orders/OrderActionsRestController.php
+++ b/plugins/woocommerce/src/Internal/Orders/OrderActionsRestController.php
@@ -470,6 +470,7 @@ class OrderActionsRestController extends RestApiControllerBase {
do_action( 'woocommerce_order_status_pending_to_processing_notification', $order->get_id(), $order );
break;
case 'customer_refunded_order':
+ case 'customer_pos_refunded_order':
if ( $this->order_is_partially_refunded( $order ) ) {
/** This action is documented in includes/class-wc-emails.php */
do_action( 'woocommerce_order_partially_refunded_notification', $order->get_id() );
diff --git a/plugins/woocommerce/src/Internal/Orders/PointOfSaleEmailHandler.php b/plugins/woocommerce/src/Internal/Orders/PointOfSaleEmailHandler.php
new file mode 100644
index 00000000000..07d1f59de2c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Orders/PointOfSaleEmailHandler.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * PointOfSaleEmailHandler class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Orders;
+
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+use WC_Abstract_Order;
+
+/**
+ * Suppresses standard automated emails for orders paid at POS.
+ *
+ * POS has its own email templates (customer_pos_completed_order,
+ * customer_pos_refunded_order) that are sent automatically or via REST API.
+ * This handler prevents the standard transactional emails from firing
+ * when an order is paid at POS, regardless of where it was created.
+ *
+ * @internal Just for internal use.
+ *
+ * @since 10.6.0
+ */
+class PointOfSaleEmailHandler implements RegisterHooksInterface {
+
+ /**
+ * Standard email IDs to suppress for POS-paid orders.
+ */
+ private const SUPPRESSED_EMAIL_IDS = array(
+ 'customer_processing_order',
+ 'customer_completed_order',
+ 'customer_on_hold_order',
+ 'customer_refunded_order',
+ 'customer_partially_refunded_order',
+ 'new_order',
+ );
+
+ /**
+ * Register hooks and filters.
+ */
+ public function register(): void {
+ if ( ! FeaturesUtil::feature_is_enabled( 'point_of_sale' ) ) {
+ return;
+ }
+
+ foreach ( self::SUPPRESSED_EMAIL_IDS as $email_id ) {
+ add_filter( 'woocommerce_email_enabled_' . $email_id, array( $this, 'maybe_suppress_email' ), 10, 2 );
+ }
+ }
+
+ /**
+ * Suppress email if the order was paid at POS.
+ *
+ * @param bool $enabled Whether the email is enabled.
+ * @param mixed $order The order object (or null).
+ * @return bool False if the order was paid at POS, original value otherwise.
+ *
+ * @internal For exclusive usage within this class, backwards compatibility not guaranteed.
+ */
+ public function maybe_suppress_email( bool $enabled, $order ): bool {
+ if ( ! $order instanceof WC_Abstract_Order ) {
+ return $enabled;
+ }
+
+ if ( PointOfSaleOrderUtil::is_order_paid_at_pos( $order ) ) {
+ return false;
+ }
+
+ return $enabled;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Orders/PointOfSaleOrderUtil.php b/plugins/woocommerce/src/Internal/Orders/PointOfSaleOrderUtil.php
index 408bda4d7c8..b675a4793f0 100644
--- a/plugins/woocommerce/src/Internal/Orders/PointOfSaleOrderUtil.php
+++ b/plugins/woocommerce/src/Internal/Orders/PointOfSaleOrderUtil.php
@@ -27,4 +27,33 @@ class PointOfSaleOrderUtil {
public static function is_pos_order( WC_Abstract_Order $order ): bool {
return 'pos-rest-api' === $order->get_created_via();
}
+
+ /**
+ * Check if the order was paid at POS, regardless of where it was created.
+ *
+ * An order is considered paid at POS if:
+ * - It was created via the POS REST API, OR
+ * - It was paid via card terminal (_wcpay_ipp_channel = mobile_pos), OR
+ * - It was paid via cash at POS (_cash_change_amount meta present).
+ *
+ * @param WC_Abstract_Order $order Order instance.
+ * @return bool
+ *
+ * @since 10.6.0
+ */
+ public static function is_order_paid_at_pos( WC_Abstract_Order $order ): bool {
+ if ( self::is_pos_order( $order ) ) {
+ return true;
+ }
+
+ if ( 'mobile_pos' === $order->get_meta( '_wcpay_ipp_channel' ) ) {
+ return true;
+ }
+
+ if ( '' !== $order->get_meta( '_cash_change_amount' ) ) {
+ return true;
+ }
+
+ return false;
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-pos-refunded-order-test.php b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-pos-refunded-order-test.php
index 6a81853cae8..710875a63df 100644
--- a/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-pos-refunded-order-test.php
+++ b/plugins/woocommerce/tests/php/includes/emails/class-wc-email-customer-pos-refunded-order-test.php
@@ -422,4 +422,59 @@ class WC_Email_Customer_POS_Refunded_Order_Test extends \WC_Unit_Test_Case {
// And plain text email should not include refund & returns policy.
$this->assertStringNotContainsString( esc_html__( 'Refund & Returns Policy', 'woocommerce' ), $plain_text_content );
}
+
+ /**
+ * @testdox trigger uses the specific refund_id when provided instead of picking from get_refunds.
+ */
+ public function test_trigger_uses_specific_refund_id_when_provided() {
+ $order = OrderHelper::create_order();
+ $order->set_status( 'completed' );
+ $order->save();
+
+ // Create two refunds so the order has multiple.
+ $first_refund = wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => '5.00',
+ 'reason' => 'First refund',
+ )
+ );
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => '3.00',
+ 'reason' => 'Second refund',
+ )
+ );
+
+ $email = new WC_Email_Customer_POS_Refunded_Order();
+
+ // Trigger with the first refund's ID.
+ $email->trigger( $order->get_id(), $email->id, $first_refund->get_id() );
+
+ $this->assertSame( $first_refund->get_id(), $email->refund->get_id(), 'Should use the specific refund that triggered the email' );
+ }
+
+ /**
+ * @testdox trigger sets refund to false when refund_id is not provided (REST API resend path).
+ */
+ public function test_trigger_sets_refund_to_false_when_no_refund_id() {
+ $order = OrderHelper::create_order();
+ $order->set_status( 'completed' );
+ $order->save();
+
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => '5.00',
+ 'reason' => 'First refund',
+ )
+ );
+
+ $email = new WC_Email_Customer_POS_Refunded_Order();
+
+ $email->trigger( $order->get_id(), $email->id );
+
+ $this->assertFalse( $email->refund, 'Refund should be false when no refund_id is provided' );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Orders/PointOfSaleEmailHandlerTest.php b/plugins/woocommerce/tests/php/src/Internal/Orders/PointOfSaleEmailHandlerTest.php
new file mode 100644
index 00000000000..2900e73cd62
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Orders/PointOfSaleEmailHandlerTest.php
@@ -0,0 +1,155 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Orders;
+
+use Automattic\WooCommerce\Internal\Orders\PointOfSaleEmailHandler;
+use WC_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the PointOfSaleEmailHandler class.
+ *
+ * @covers \Automattic\WooCommerce\Internal\Orders\PointOfSaleEmailHandler
+ */
+class PointOfSaleEmailHandlerTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var PointOfSaleEmailHandler
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new PointOfSaleEmailHandler();
+ }
+
+ /**
+ * @testdox maybe_suppress_email returns false for POS-created orders.
+ */
+ public function test_suppresses_email_for_pos_created_order(): void {
+ $order = new WC_Order();
+ $order->set_created_via( 'pos-rest-api' );
+ $order->save();
+
+ $this->assertFalse( $this->sut->maybe_suppress_email( true, $order ) );
+ }
+
+ /**
+ * @testdox maybe_suppress_email returns false for POS card reader payment.
+ */
+ public function test_suppresses_email_for_pos_card_reader_payment(): void {
+ $order = new WC_Order();
+ $order->set_created_via( 'bookings' );
+ $order->update_meta_data( '_wcpay_ipp_channel', 'mobile_pos' );
+ $order->save();
+
+ $this->assertFalse( $this->sut->maybe_suppress_email( true, $order ) );
+ }
+
+ /**
+ * @testdox maybe_suppress_email returns false for cash payment at POS.
+ */
+ public function test_suppresses_email_for_cash_payment(): void {
+ $order = new WC_Order();
+ $order->set_created_via( 'admin' );
+ $order->update_meta_data( '_cash_change_amount', '5.00' );
+ $order->save();
+
+ $this->assertFalse( $this->sut->maybe_suppress_email( true, $order ) );
+ }
+
+ /**
+ * @testdox maybe_suppress_email returns original value for regular web orders.
+ */
+ public function test_returns_original_value_for_regular_order(): void {
+ $order = new WC_Order();
+ $order->set_created_via( 'checkout' );
+ $order->save();
+
+ $this->assertTrue( $this->sut->maybe_suppress_email( true, $order ) );
+ }
+
+ /**
+ * @testdox maybe_suppress_email keeps already-disabled emails disabled for regular orders.
+ */
+ public function test_already_disabled_email_stays_disabled(): void {
+ $order = new WC_Order();
+ $order->set_created_via( 'checkout' );
+ $order->save();
+
+ $this->assertFalse( $this->sut->maybe_suppress_email( false, $order ) );
+ }
+
+ /**
+ * @testdox maybe_suppress_email returns original value when order is null.
+ */
+ public function test_returns_original_value_for_null_order(): void {
+ $this->assertTrue( $this->sut->maybe_suppress_email( true, null ) );
+ }
+
+ /**
+ * @testdox maybe_suppress_email returns original value when object is not an order.
+ */
+ public function test_returns_original_value_for_non_order_object(): void {
+ $this->assertTrue( $this->sut->maybe_suppress_email( true, new \stdClass() ) );
+ }
+
+ /**
+ * @testdox register adds filters only when POS feature is enabled.
+ */
+ public function test_register_adds_filters_when_pos_enabled(): void {
+ update_option( 'woocommerce_feature_point_of_sale_enabled', 'yes' );
+
+ $handler = new PointOfSaleEmailHandler();
+ $handler->register();
+
+ $this->assertNotFalse(
+ has_filter( 'woocommerce_email_enabled_customer_completed_order', array( $handler, 'maybe_suppress_email' ) )
+ );
+ $this->assertNotFalse(
+ has_filter( 'woocommerce_email_enabled_customer_processing_order', array( $handler, 'maybe_suppress_email' ) )
+ );
+ $this->assertNotFalse(
+ has_filter( 'woocommerce_email_enabled_customer_on_hold_order', array( $handler, 'maybe_suppress_email' ) )
+ );
+ $this->assertNotFalse(
+ has_filter( 'woocommerce_email_enabled_new_order', array( $handler, 'maybe_suppress_email' ) )
+ );
+ $this->assertNotFalse(
+ has_filter( 'woocommerce_email_enabled_customer_refunded_order', array( $handler, 'maybe_suppress_email' ) )
+ );
+ $this->assertNotFalse(
+ has_filter( 'woocommerce_email_enabled_customer_partially_refunded_order', array( $handler, 'maybe_suppress_email' ) )
+ );
+
+ remove_all_filters( 'woocommerce_email_enabled_customer_completed_order' );
+ remove_all_filters( 'woocommerce_email_enabled_customer_processing_order' );
+ remove_all_filters( 'woocommerce_email_enabled_customer_on_hold_order' );
+ remove_all_filters( 'woocommerce_email_enabled_customer_refunded_order' );
+ remove_all_filters( 'woocommerce_email_enabled_customer_partially_refunded_order' );
+ remove_all_filters( 'woocommerce_email_enabled_new_order' );
+ }
+
+ /**
+ * @testdox register does not add filters when POS feature is disabled.
+ */
+ public function test_register_does_not_add_filters_when_pos_disabled(): void {
+ remove_all_filters( 'woocommerce_email_enabled_customer_completed_order' );
+ update_option( 'woocommerce_feature_point_of_sale_enabled', 'no' );
+
+ $handler = new PointOfSaleEmailHandler();
+ $handler->register();
+
+ $this->assertFalse(
+ has_filter( 'woocommerce_email_enabled_customer_completed_order', array( $handler, 'maybe_suppress_email' ) )
+ );
+
+ delete_option( 'woocommerce_feature_point_of_sale_enabled' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Orders/PointOfSaleOrderUtilTest.php b/plugins/woocommerce/tests/php/src/Internal/Orders/PointOfSaleOrderUtilTest.php
index 168271b491e..9368d2a3d29 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Orders/PointOfSaleOrderUtilTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Orders/PointOfSaleOrderUtilTest.php
@@ -34,4 +34,73 @@ class PointOfSaleOrderUtilTest extends WC_Unit_Test_Case {
$order->save();
$this->assertFalse( PointOfSaleOrderUtil::is_pos_order( $order ), 'Order with empty created_via should not be identified as POS order' );
}
+
+ /**
+ * @testdox is_order_paid_at_pos returns true for POS-created orders.
+ */
+ public function test_is_order_paid_at_pos_returns_true_for_pos_created_order(): void {
+ $order = new WC_Order();
+ $order->set_created_via( 'pos-rest-api' );
+ $order->save();
+
+ $this->assertTrue( PointOfSaleOrderUtil::is_order_paid_at_pos( $order ) );
+ }
+
+ /**
+ * @testdox is_order_paid_at_pos returns true for POS card reader payment.
+ */
+ public function test_is_order_paid_at_pos_returns_true_for_card_reader_payment(): void {
+ $order = new WC_Order();
+ $order->set_created_via( 'bookings' );
+ $order->update_meta_data( '_wcpay_ipp_channel', 'mobile_pos' );
+ $order->save();
+
+ $this->assertTrue( PointOfSaleOrderUtil::is_order_paid_at_pos( $order ) );
+ }
+
+ /**
+ * @testdox is_order_paid_at_pos returns true for cash payment at POS.
+ */
+ public function test_is_order_paid_at_pos_returns_true_for_cash_payment(): void {
+ $order = new WC_Order();
+ $order->set_created_via( 'admin' );
+ $order->update_meta_data( '_cash_change_amount', '5.00' );
+ $order->save();
+
+ $this->assertTrue( PointOfSaleOrderUtil::is_order_paid_at_pos( $order ) );
+ }
+
+ /**
+ * @testdox is_order_paid_at_pos returns true for cash payment with zero change.
+ */
+ public function test_is_order_paid_at_pos_returns_true_for_zero_change(): void {
+ $order = new WC_Order();
+ $order->set_created_via( 'bookings' );
+ $order->update_meta_data( '_cash_change_amount', '0' );
+ $order->save();
+
+ $this->assertTrue( PointOfSaleOrderUtil::is_order_paid_at_pos( $order ) );
+ }
+
+ /**
+ * @testdox is_order_paid_at_pos returns false for regular web orders.
+ */
+ public function test_is_order_paid_at_pos_returns_false_for_regular_order(): void {
+ $order = new WC_Order();
+ $order->set_created_via( 'checkout' );
+ $order->save();
+
+ $this->assertFalse( PointOfSaleOrderUtil::is_order_paid_at_pos( $order ) );
+ }
+
+ /**
+ * @testdox is_order_paid_at_pos returns false for bookings order without POS payment.
+ */
+ public function test_is_order_paid_at_pos_returns_false_for_bookings_without_pos(): void {
+ $order = new WC_Order();
+ $order->set_created_via( 'bookings' );
+ $order->save();
+
+ $this->assertFalse( PointOfSaleOrderUtil::is_order_paid_at_pos( $order ) );
+ }
}