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 ) );
+	}
 }