Commit 74946acf2a0 for woocommerce

commit 74946acf2a0b3897d6c67447fa3f4bcdfbb32953
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date:   Tue Jul 28 14:01:02 2026 +0100

    Add order withdrawal submission handling (#66963)

    * Add order withdrawal submission handling

    * Add changelog entry for order withdrawal handling

    * Treat withdrawal order notes as best effort

    * Link matched withdrawal orders in merchant email

    * Remove validation since wc_get_order validates data anyway

    * Prevent duplicate requests

    * Scope order lookup by email and normalize order number

    * Add order withdrawal matching regression tests

    * Attempt wc_get_order first

    * Simplify order withdrawal submission error handling

    * Simplify if statement

diff --git a/plugins/woocommerce/changelog/add-order-withdrawal-submission-handling b/plugins/woocommerce/changelog/add-order-withdrawal-submission-handling
new file mode 100644
index 00000000000..9c317c01e34
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-order-withdrawal-submission-handling
@@ -0,0 +1,5 @@
+Significance: patch
+Type: fix
+Comment: Record and notify order withdrawal submissions when submitted.
+
+
diff --git a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php
index 8f40d02dbb3..1e726cc17b5 100644
--- a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php
+++ b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php
@@ -3,6 +3,10 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Internal\OrderWithdrawal;

+use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
+use Throwable;
+use WC_Order;
+
 /**
  * Processes order withdrawal form requests.
  *
@@ -28,6 +32,10 @@ final class OrderWithdrawalFormProcessor {
 	public const WITHDRAWAL_TYPE_FULL     = 'full_order';
 	public const WITHDRAWAL_TYPE_SPECIFIC = 'specific_items_only';

+	private const LOGGER_SOURCE                       = 'order-withdrawal';
+	private const ORDER_WITHDRAWAL_REQUESTED_META_KEY = '_order_withdrawal_requested';
+	private const ORDER_WITHDRAWAL_REQUESTED_VALUE    = 'yes';
+
 	/**
 	 * Process the current order withdrawal request.
 	 *
@@ -61,7 +69,15 @@ final class OrderWithdrawalFormProcessor {
 			return new OrderWithdrawalFormState( $screen, $data, $errors );
 		}

-		$screen = self::ACTION_CONFIRM === $action ? 'confirmation' : 'review';
+		if ( self::ACTION_CONFIRM === $action ) {
+			if ( ! $this->submit_order_withdrawal( $data ) ) {
+				return new OrderWithdrawalFormState( 'review', $data, $errors );
+			}
+
+			$screen = 'confirmation';
+		} else {
+			$screen = 'review';
+		}

 		return new OrderWithdrawalFormState( $screen, $data, $errors );
 	}
@@ -224,4 +240,435 @@ final class OrderWithdrawalFormProcessor {
 			wc_add_notice( $message, 'error', array( 'id' => self::get_field_name( $field_key ) ) );
 		}
 	}
+
+	/**
+	 * Submit a validated order withdrawal request.
+	 *
+	 * @param array<string,string> $data Form data.
+	 */
+	private function submit_order_withdrawal( array $data ): bool {
+		$matched_order = $this->get_matching_order( $data );
+
+		if ( $matched_order ) {
+			if ( $this->has_order_withdrawal_request( $matched_order ) ) {
+				wc_add_notice(
+					__( 'A withdrawal request has already been submitted for this order. Please contact us if you need help or want to make changes.', 'woocommerce' ),
+					'error'
+				);
+
+				return false;
+			}
+
+			$this->add_order_withdrawal_note( $matched_order, $data );
+		}
+
+		if ( ! $this->send_order_withdrawal_emails( $data, $matched_order ) ) {
+			wc_add_notice( __( 'We could not submit your withdrawal request. Please try again or contact us if the problem continues.', 'woocommerce' ), 'error' );
+
+			return false;
+		}
+
+		if ( $matched_order ) {
+			$this->mark_order_withdrawal_requested( $matched_order );
+		}
+
+		return true;
+	}
+
+	/**
+	 * Get an order only when every submitted order-identifying field matches.
+	 *
+	 * @param array<string,string> $data Form data.
+	 */
+	private function get_matching_order( array $data ): ?WC_Order {
+		$order_number = $this->normalize_order_number( $data[ self::FIELD_ORDER_NUMBER ] );
+		$email        = $data[ self::FIELD_EMAIL ];
+
+		if ( '' === $order_number || '' === $email ) {
+			return null;
+		}
+
+		if ( ctype_digit( $order_number ) ) {
+			$order = wc_get_order( (int) $order_number );
+
+			if ( $order instanceof WC_Order && $this->order_matches_form_data( $order, $data ) ) {
+				return $order;
+			}
+		}
+
+		// Search by email first because the submitted order number may not be the internal order ID.
+		$candidate_orders = wc_get_orders(
+			array(
+				'billing_email' => $email,
+				'limit'         => -1,
+				'orderby'       => 'date',
+				'order'         => 'DESC',
+				'return'        => 'objects',
+			)
+		);
+
+		if ( ! is_array( $candidate_orders ) ) {
+			return null;
+		}
+
+		foreach ( $candidate_orders as $order ) {
+			if ( ! $order instanceof WC_Order ) {
+				continue;
+			}
+
+			if ( $this->normalize_order_number( (string) $order->get_order_number() ) !== $order_number ) {
+				continue;
+			}
+
+			if ( $this->order_matches_form_data( $order, $data ) ) {
+				return $order;
+			}
+		}
+
+		return null;
+	}
+
+	/**
+	 * Whether a candidate order exactly matches the submitted identifying data.
+	 *
+	 * @param WC_Order             $order Candidate order.
+	 * @param array<string,string> $data  Form data.
+	 */
+	private function order_matches_form_data( WC_Order $order, array $data ): bool {
+		return $this->normalize_order_number( (string) $order->get_order_number() ) === $this->normalize_order_number( $data[ self::FIELD_ORDER_NUMBER ] )
+			&& $this->text_values_match( $order->get_billing_first_name( 'edit' ), $data[ self::FIELD_FIRST_NAME ] )
+			&& $this->text_values_match( $order->get_billing_last_name( 'edit' ), $data[ self::FIELD_LAST_NAME ] )
+			&& $this->text_values_match( $order->get_billing_email( 'edit' ), $data[ self::FIELD_EMAIL ] );
+	}
+
+	/**
+	 * Normalize a submitted order number for lookup and comparison.
+	 *
+	 * @param string $order_number Order number.
+	 */
+	private function normalize_order_number( string $order_number ): string {
+		$order_number = trim( $order_number );
+
+		if ( 0 === strpos( $order_number, '#' ) ) {
+			$order_number = trim( substr( $order_number, 1 ) );
+		}
+
+		return $order_number;
+	}
+
+	/**
+	 * Compare submitted text values for identity while ignoring casing and surrounding spaces.
+	 *
+	 * @param string $stored_value    Stored order value.
+	 * @param string $submitted_value Submitted form value.
+	 */
+	private function text_values_match( string $stored_value, string $submitted_value ): bool {
+		return 0 === strcasecmp( trim( $stored_value ), trim( $submitted_value ) );
+	}
+
+	/**
+	 * Add the withdrawal request note to a matched order.
+	 *
+	 * @param WC_Order             $order Matched order.
+	 * @param array<string,string> $data  Form data.
+	 */
+	private function add_order_withdrawal_note( WC_Order $order, array $data ): void {
+		$note = sprintf(
+			/* translators: 1: customer name, 2: customer email address. */
+			__( 'Order withdrawal requested by %1$s (%2$s).', 'woocommerce' ),
+			$this->get_customer_name( $data ),
+			$data[ self::FIELD_EMAIL ]
+		);
+
+		if ( self::WITHDRAWAL_TYPE_SPECIFIC === $data[ self::FIELD_WITHDRAWAL_TYPE ] ) {
+			$note .= "\n\n" . sprintf(
+				/* translators: %s: items the customer listed for partial withdrawal. */
+				__( 'Items requested for withdrawal: %s', 'woocommerce' ),
+				$data[ self::FIELD_ADDITIONAL_DETAILS ]
+			);
+		}
+
+		try {
+			if ( ! $order->add_order_note( $note, 0, false, array( 'note_group' => OrderNoteGroup::ORDER_UPDATE ) ) ) {
+				$this->log_order_note_error( $order );
+			}
+		} catch ( Throwable $e ) {
+			$this->log_order_note_error( $order, $e );
+		}
+	}
+
+	/**
+	 * Whether the matched order already has a submitted withdrawal request.
+	 *
+	 * @param WC_Order $order Matched order.
+	 */
+	private function has_order_withdrawal_request( WC_Order $order ): bool {
+		return self::ORDER_WITHDRAWAL_REQUESTED_VALUE === $order->get_meta( self::ORDER_WITHDRAWAL_REQUESTED_META_KEY, true, 'edit' );
+	}
+
+	/**
+	 * Mark a matched order as having a submitted withdrawal request.
+	 *
+	 * @param WC_Order $order Matched order.
+	 */
+	private function mark_order_withdrawal_requested( WC_Order $order ): void {
+		try {
+			$order->update_meta_data( self::ORDER_WITHDRAWAL_REQUESTED_META_KEY, self::ORDER_WITHDRAWAL_REQUESTED_VALUE );
+			$order->save_meta_data();
+		} catch ( Throwable $e ) {
+			$this->log_order_meta_error( $order, $e );
+		}
+	}
+
+	/**
+	 * Send customer and merchant order withdrawal emails.
+	 *
+	 * @param array<string,string> $data          Form data.
+	 * @param WC_Order|null        $matched_order Matched order, if found.
+	 */
+	private function send_order_withdrawal_emails( array $data, ?WC_Order $matched_order ): bool {
+		try {
+			$submitted_at  = time();
+			$customer_sent = $this->send_customer_order_withdrawal_email( $data, $submitted_at );
+			$merchant_sent = $this->send_merchant_order_withdrawal_email( $data, $matched_order, $submitted_at );
+		} catch ( Throwable $e ) {
+			$this->log_email_error( $e );
+
+			return false;
+		}
+
+		if ( ! $customer_sent || ! $merchant_sent ) {
+			$this->log_email_error(
+				sprintf(
+					'Order withdrawal notification email failed. Customer email sent: %s. Merchant email sent: %s.',
+					$customer_sent ? 'yes' : 'no',
+					$merchant_sent ? 'yes' : 'no'
+				)
+			);
+
+			return false;
+		}
+
+		return true;
+	}
+
+	/**
+	 * Send the customer order withdrawal acknowledgement email.
+	 *
+	 * @param array<string,string> $data         Form data.
+	 * @param int                  $submitted_at Unix timestamp for the submission.
+	 */
+	private function send_customer_order_withdrawal_email( array $data, int $submitted_at ): bool {
+		$subject = __( 'We received your withdrawal request', 'woocommerce' );
+		$heading = __( 'We received your withdrawal request', 'woocommerce' );
+		$body    = '<p>' . esc_html__( 'We have received your request to withdraw from the order below.', 'woocommerce' ) . '</p>';
+		$body   .= $this->get_email_details_html( $data, $submitted_at );
+		$body   .= '<p>' . esc_html__( 'We will review your request and contact you about next steps, including any refund due.', 'woocommerce' ) . '</p>';
+
+		return wc_mail(
+			$data[ self::FIELD_EMAIL ],
+			$subject,
+			$this->wrap_email_message( $heading, $body )
+		);
+	}
+
+	/**
+	 * Send the merchant order withdrawal notification email.
+	 *
+	 * @param array<string,string> $data          Form data.
+	 * @param WC_Order|null        $matched_order Matched order, if found.
+	 * @param int                  $submitted_at  Unix timestamp for the submission.
+	 */
+	private function send_merchant_order_withdrawal_email( array $data, ?WC_Order $matched_order, int $submitted_at ): bool {
+		$recipient = sanitize_email( (string) get_option( 'admin_email' ) );
+
+		if ( '' === $recipient || ! is_email( $recipient ) ) {
+			return false;
+		}
+
+		$subject = sprintf(
+			/* translators: %s: order number. */
+			__( 'Order withdrawal request for order %s', 'woocommerce' ),
+			$data[ self::FIELD_ORDER_NUMBER ]
+		);
+		$heading = __( 'Order withdrawal request received', 'woocommerce' );
+		$body    = '<p>' . esc_html__( 'A customer submitted an order withdrawal request.', 'woocommerce' ) . '</p>';
+
+		if ( $matched_order instanceof WC_Order ) {
+			$body .= '<p>' . esc_html__( 'WooCommerce matched this request to an order and added an order note.', 'woocommerce' ) . '</p>';
+		} else {
+			$body .= '<p>' . esc_html__( 'WooCommerce could not match this request to an order automatically, so no order note was added.', 'woocommerce' ) . '</p>';
+		}
+
+		$body .= $this->get_email_details_html( $data, $submitted_at );
+
+		if ( $matched_order instanceof WC_Order ) {
+			$order_url = $matched_order->get_edit_order_url();
+
+			$body .= sprintf(
+				'<p>%s</p>',
+				sprintf(
+					/* translators: %d: order ID. */
+					esc_html__( 'Matched order ID: %d', 'woocommerce' ),
+					$matched_order->get_id()
+				)
+			);
+
+			if ( '' !== $order_url ) {
+				$body .= sprintf(
+					'<p><a href="%1$s">%2$s</a></p>',
+					esc_url( $order_url ),
+					esc_html__( 'View matched order', 'woocommerce' )
+				);
+			}
+		}
+
+		return wc_mail(
+			$recipient,
+			$subject,
+			$this->wrap_email_message( $heading, $body ),
+			$this->get_merchant_email_headers( $data )
+		);
+	}
+
+	/**
+	 * Get merchant email headers.
+	 *
+	 * @param array<string,string> $data Form data.
+	 * @return string
+	 */
+	private function get_merchant_email_headers( array $data ): string {
+		$headers = array( 'Content-Type: text/html; charset=UTF-8' );
+		$name    = $this->get_customer_name( $data );
+		$email   = $data[ self::FIELD_EMAIL ];
+
+		if ( '' !== $name && is_email( $email ) ) {
+			$headers[] = sprintf( 'Reply-To: %1$s <%2$s>', $name, $email );
+		}
+
+		return implode( "\r\n", $headers );
+	}
+
+	/**
+	 * Wrap an email body in the WooCommerce email template.
+	 *
+	 * @param string $heading Email heading.
+	 * @param string $body    Email body.
+	 */
+	private function wrap_email_message( string $heading, string $body ): string {
+		return WC()->mailer()->wrap_message( $heading, $body );
+	}
+
+	/**
+	 * Get the email details list.
+	 *
+	 * @param array<string,string> $data         Form data.
+	 * @param int                  $submitted_at Unix timestamp for the submission.
+	 */
+	private function get_email_details_html( array $data, int $submitted_at ): string {
+		$date_format        = (string) get_option( 'date_format' );
+		$time_format        = (string) get_option( 'time_format' );
+		$additional_details = '' === $data[ self::FIELD_ADDITIONAL_DETAILS ] ? __( 'None provided', 'woocommerce' ) : $data[ self::FIELD_ADDITIONAL_DETAILS ];
+		$submitted_at_text  = wp_date( trim( $date_format . ' ' . $time_format ), $submitted_at );
+
+		if ( false === $submitted_at_text ) {
+			$submitted_at_text = '';
+		}
+
+		$rows = array(
+			__( 'Submitted', 'woocommerce' )          => $submitted_at_text,
+			__( 'Name', 'woocommerce' )               => $this->get_customer_name( $data ),
+			__( 'Email address', 'woocommerce' )      => $data[ self::FIELD_EMAIL ],
+			__( 'Order number', 'woocommerce' )       => $data[ self::FIELD_ORDER_NUMBER ],
+			__( 'Withdrawing', 'woocommerce' )        => $this->get_withdrawal_type_label( $data[ self::FIELD_WITHDRAWAL_TYPE ] ),
+			__( 'Additional details', 'woocommerce' ) => $additional_details,
+		);
+
+		$html = '<ul>';
+
+		foreach ( $rows as $label => $value ) {
+			$html .= sprintf(
+				'<li><strong>%1$s:</strong> %2$s</li>',
+				esc_html( $label ),
+				nl2br( esc_html( $value ) )
+			);
+		}
+
+		$html .= '</ul>';
+
+		return $html;
+	}
+
+	/**
+	 * Get the customer's full name for display.
+	 *
+	 * @param array<string,string> $data Form data.
+	 */
+	private function get_customer_name( array $data ): string {
+		return trim( $data[ self::FIELD_FIRST_NAME ] . ' ' . $data[ self::FIELD_LAST_NAME ] );
+	}
+
+	/**
+	 * Get the label for a withdrawal type value.
+	 *
+	 * @param string $withdrawal_type Withdrawal type value.
+	 */
+	private function get_withdrawal_type_label( string $withdrawal_type ): string {
+		$options = array(
+			self::WITHDRAWAL_TYPE_FULL     => __( 'The full order', 'woocommerce' ),
+			self::WITHDRAWAL_TYPE_SPECIFIC => __( 'Specific items only', 'woocommerce' ),
+		);
+
+		return $options[ $withdrawal_type ] ?? '';
+	}
+
+	/**
+	 * Log an email failure.
+	 *
+	 * @param Throwable|string $error Email error.
+	 */
+	private function log_email_error( $error ): void {
+		$message = $error instanceof Throwable ? $error->getMessage() : $error;
+
+		wc_get_logger()->warning(
+			sprintf( 'Order withdrawal email failed: %s', $message ),
+			array( 'source' => self::LOGGER_SOURCE )
+		);
+	}
+
+	/**
+	 * Log an order note failure without failing the submission.
+	 *
+	 * @param WC_Order       $order Matched order.
+	 * @param Throwable|null $e     Order note error.
+	 */
+	private function log_order_note_error( WC_Order $order, ?Throwable $e = null ): void {
+		$message = sprintf( 'Order withdrawal note could not be added to order %d.', $order->get_id() );
+
+		if ( $e instanceof Throwable ) {
+			$message .= sprintf( ' Error: %s', $e->getMessage() );
+		}
+
+		wc_get_logger()->warning(
+			$message,
+			array( 'source' => self::LOGGER_SOURCE )
+		);
+	}
+
+	/**
+	 * Log an order meta failure without failing the submission.
+	 *
+	 * @param WC_Order  $order Matched order.
+	 * @param Throwable $e     Order meta error.
+	 */
+	private function log_order_meta_error( WC_Order $order, Throwable $e ): void {
+		wc_get_logger()->warning(
+			sprintf(
+				'Order withdrawal meta flag could not be added to order %1$d. Error: %2$s',
+				$order->get_id(),
+				$e->getMessage()
+			),
+			array( 'source' => self::LOGGER_SOURCE )
+		);
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
index 263263eacce..c5db4cd17fa 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Tests\Internal\OrderWithdrawal;
 use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormProcessor;
 use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormState;
 use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormView;
+use WC_Order;
 use WC_Unit_Test_Case;

 /**
@@ -13,10 +14,11 @@ use WC_Unit_Test_Case;
  */
 class OrderWithdrawalTest extends WC_Unit_Test_Case {

-	private const FEATURE_OPTION      = 'woocommerce_feature_order_withdrawal_enabled';
-	private const ENDPOINT_OPTION     = 'woocommerce_myaccount_order_withdrawal_endpoint';
-	private const FLUSH_QUEUE_OPTION  = 'woocommerce_queue_flush_rewrite_rules';
-	private const MISSING_OPTION_MARK = '__woocommerce_order_withdrawal_missing_option__';
+	private const FEATURE_OPTION                      = 'woocommerce_feature_order_withdrawal_enabled';
+	private const ENDPOINT_OPTION                     = 'woocommerce_myaccount_order_withdrawal_endpoint';
+	private const FLUSH_QUEUE_OPTION                  = 'woocommerce_queue_flush_rewrite_rules';
+	private const MISSING_OPTION_MARK                 = '__woocommerce_order_withdrawal_missing_option__';
+	private const ORDER_WITHDRAWAL_REQUESTED_META_KEY = '_order_withdrawal_requested';

 	/**
 	 * The System Under Test.
@@ -74,6 +76,13 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
 	 */
 	private $original_flush_queue_option;

+	/**
+	 * Created order IDs.
+	 *
+	 * @var int[]
+	 */
+	private array $created_order_ids = array();
+
 	/**
 	 * Set up test fixtures.
 	 */
@@ -117,6 +126,7 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
 		$this->restore_option( self::ENDPOINT_OPTION, $this->original_endpoint_option );
 		$this->restore_option( self::FLUSH_QUEUE_OPTION, $this->original_flush_queue_option );
 		wc_clear_notices();
+		$this->delete_created_orders();
 		WC()->session = $this->original_session;

 		parent::tearDown();
@@ -159,9 +169,267 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
 	 */
 	public function provide_valid_submission_actions(): array {
 		return array(
-			'review action'  => array( OrderWithdrawalFormProcessor::ACTION_REVIEW, 'review' ),
-			'confirm action' => array( OrderWithdrawalFormProcessor::ACTION_CONFIRM, 'confirmation' ),
+			'review action' => array( OrderWithdrawalFormProcessor::ACTION_REVIEW, 'review' ),
+		);
+	}
+
+	/**
+	 * @testdox Should add an order note and send emails when a confirmed submission matches an order exactly.
+	 */
+	public function test_process_current_request_adds_note_and_sends_emails_for_exact_order_match(): void {
+		$order   = $this->create_order_for_form_data();
+		$capture = $this->capture_wp_mail();
+
+		try {
+			$this->prepare_post_request(
+				OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+				array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => (string) $order->get_id() )
+			);
+
+			$state = $this->sut->process_current_request();
+
+			$this->assertSame( 'confirmation', $state->screen, 'Matched confirm submissions should reach the confirmation screen.' );
+			$this->assertCount( 2, $capture['captures'], 'The customer and merchant emails should both be sent.' );
+			$this->assert_mail_sent_to( 'jane@example.test', $capture['captures'] );
+			$this->assert_mail_sent_to( (string) get_option( 'admin_email' ), $capture['captures'] );
+			$merchant_email = $this->get_captured_mail_to( (string) get_option( 'admin_email' ), $capture['captures'] );
+			$this->assertStringContainsString( str_replace( '&', '&amp;', $order->get_edit_order_url() ), (string) $merchant_email['message'], 'The merchant email should link to the matched order.' );
+			$this->assertStringContainsString( 'View matched order', (string) $merchant_email['message'], 'The merchant email should include clear link text for the matched order.' );
+			$this->assertTrue( $this->order_has_note_containing( $order, 'Order withdrawal requested by Jane Doe (jane@example.test).' ), 'The matched order should receive a withdrawal note.' );
+			$this->assertTrue( $this->order_has_note_containing( $order, 'Items requested for withdrawal: Line item 1' ), 'Specific-item details should be included in the order note.' );
+			$this->assert_order_withdrawal_requested( $order );
+		} finally {
+			$capture['remove']();
+		}
+	}
+
+	/**
+	 * @testdox Should match orders by custom order number instead of assuming the order number is the ID.
+	 */
+	public function test_process_current_request_matches_custom_order_number(): void {
+		$order               = $this->create_order_for_form_data();
+		$custom_order_number = 'CUSTOM-1001';
+		$capture             = $this->capture_wp_mail();
+		$filter              = static function ( $order_number, $filtered_order ) use ( $order, $custom_order_number ) {
+			if ( $filtered_order instanceof WC_Order && $order->get_id() === $filtered_order->get_id() ) {
+				return $custom_order_number;
+			}
+
+			return $order_number;
+		};
+
+		add_filter( 'woocommerce_order_number', $filter, 10, 2 );
+
+		try {
+			$this->prepare_post_request(
+				OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+				array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => $custom_order_number )
+			);
+
+			$state = $this->sut->process_current_request();
+
+			$this->assertSame( 'confirmation', $state->screen, 'Custom order number submissions should reach the confirmation screen.' );
+			$this->assertCount( 2, $capture['captures'], 'The customer and merchant emails should both be sent.' );
+			$this->assertTrue( $this->order_has_note_containing( $order, 'Order withdrawal requested by Jane Doe (jane@example.test).' ), 'The custom-number matched order should receive a withdrawal note.' );
+			$this->assert_order_withdrawal_requested( $order );
+		} finally {
+			remove_filter( 'woocommerce_order_number', $filter, 10 );
+			$capture['remove']();
+		}
+	}
+
+	/**
+	 * @testdox Should match normalized submitted order numbers only to the intended order.
+	 */
+	public function test_process_current_request_matches_normalized_order_number_to_intended_order(): void {
+		$target_order = $this->create_order_for_form_data();
+		$wrong_order  = $this->create_order_for_form_data();
+		$capture      = $this->capture_wp_mail();
+
+		try {
+			$this->prepare_post_request(
+				OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+				array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => '#' . $target_order->get_id() )
+			);
+
+			$state          = $this->sut->process_current_request();
+			$merchant_email = $this->get_captured_mail_to( (string) get_option( 'admin_email' ), $capture['captures'] );
+
+			$this->assertSame( 'confirmation', $state->screen, 'Normalized order number submissions should reach the confirmation screen.' );
+			$this->assertCount( 2, $capture['captures'], 'The customer and merchant emails should both be sent.' );
+			$this->assertStringContainsString( str_replace( '&', '&amp;', $target_order->get_edit_order_url() ), (string) $merchant_email['message'], 'The merchant email should link to the intended order.' );
+			$this->assertStringNotContainsString( str_replace( '&', '&amp;', $wrong_order->get_edit_order_url() ), (string) $merchant_email['message'], 'The merchant email should not link to the wrong order.' );
+			$this->assertTrue( $this->order_has_note_containing( $target_order, 'Order withdrawal requested by Jane Doe (jane@example.test).' ), 'The intended order should receive a withdrawal note.' );
+			$this->assertFalse( $this->order_has_note_containing( $wrong_order, 'Order withdrawal requested' ), 'The wrong order should not receive a withdrawal note.' );
+			$this->assert_order_withdrawal_requested( $target_order );
+		} finally {
+			$capture['remove']();
+		}
+	}
+
+	/**
+	 * @testdox Should not match an order that shares the email but has a different billing name.
+	 */
+	public function test_process_current_request_matches_only_order_with_same_email_and_billing_name(): void {
+		$target_order         = $this->create_order_for_form_data();
+		$different_name_order = $this->create_order_for_form_data(
+			array(
+				OrderWithdrawalFormProcessor::FIELD_FIRST_NAME => 'Janet',
+				OrderWithdrawalFormProcessor::FIELD_LAST_NAME  => 'Smith',
+			)
 		);
+		$custom_order_number  = 'CUSTOM-1001';
+		$capture              = $this->capture_wp_mail();
+		$filter               = static function ( $order_number, $filtered_order ) use ( $target_order, $different_name_order, $custom_order_number ) {
+			if (
+				$filtered_order instanceof WC_Order
+				&& in_array( $filtered_order->get_id(), array( $target_order->get_id(), $different_name_order->get_id() ), true )
+			) {
+				return $custom_order_number;
+			}
+
+			return $order_number;
+		};
+
+		add_filter( 'woocommerce_order_number', $filter, 10, 2 );
+
+		try {
+			$this->prepare_post_request(
+				OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+				array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => $custom_order_number )
+			);
+
+			$state          = $this->sut->process_current_request();
+			$merchant_email = $this->get_captured_mail_to( (string) get_option( 'admin_email' ), $capture['captures'] );
+
+			$this->assertSame( 'confirmation', $state->screen, 'Matching submissions should reach the confirmation screen.' );
+			$this->assertCount( 2, $capture['captures'], 'The customer and merchant emails should both be sent.' );
+			$this->assertStringContainsString( str_replace( '&', '&amp;', $target_order->get_edit_order_url() ), (string) $merchant_email['message'], 'The merchant email should link to the intended order.' );
+			$this->assertStringNotContainsString( str_replace( '&', '&amp;', $different_name_order->get_edit_order_url() ), (string) $merchant_email['message'], 'The merchant email should not link to the order with a different billing name.' );
+			$this->assertTrue( $this->order_has_note_containing( $target_order, 'Order withdrawal requested by Jane Doe (jane@example.test).' ), 'The intended order should receive a withdrawal note.' );
+			$this->assertFalse( $this->order_has_note_containing( $different_name_order, 'Order withdrawal requested' ), 'The order with a different billing name should not receive a withdrawal note.' );
+			$this->assert_order_withdrawal_requested( $target_order );
+		} finally {
+			remove_filter( 'woocommerce_order_number', $filter, 10 );
+			$capture['remove']();
+		}
+	}
+
+	/**
+	 * @testdox Should reject duplicate withdrawal requests for an already flagged matched order.
+	 */
+	public function test_process_current_request_rejects_duplicate_withdrawal_for_matched_order(): void {
+		$order = $this->create_order_for_form_data();
+		$order->update_meta_data( self::ORDER_WITHDRAWAL_REQUESTED_META_KEY, 'yes' );
+		$order->save_meta_data();
+		$capture = $this->capture_wp_mail();
+
+		try {
+			$this->prepare_post_request(
+				OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+				array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => (string) $order->get_id() )
+			);
+
+			$state         = $this->sut->process_current_request();
+			$error_notices = wc_get_notices( 'error' );
+
+			$this->assertSame( 'review', $state->screen, 'Duplicate matched submissions should return to the review screen.' );
+			$this->assertCount( 1, $error_notices, 'Duplicate matched submissions should add one error notice.' );
+			$this->assertStringContainsString( 'already been submitted for this order', $error_notices[0]['notice'], 'The notice should explain that the order already has a withdrawal request.' );
+			$this->assertCount( 0, $capture['captures'], 'Duplicate matched submissions should not send notification emails.' );
+			$this->assertFalse( $this->order_has_note_containing( $order, 'Order withdrawal requested' ), 'Duplicate matched submissions should not add another order note.' );
+		} finally {
+			$capture['remove']();
+		}
+	}
+
+	/**
+	 * @testdox Should submit and send emails when adding the matched order note fails.
+	 */
+	public function test_process_current_request_treats_order_note_failure_as_best_effort(): void {
+		$order           = $this->create_order_for_form_data();
+		$capture         = $this->capture_wp_mail();
+		$fail_order_note = static function ( $commentdata ) {
+			if ( is_array( $commentdata ) && 'order_note' === ( $commentdata['comment_type'] ?? '' ) ) {
+				throw new \RuntimeException( 'Order note insert failed.' );
+			}
+
+			return $commentdata;
+		};
+
+		add_filter( 'preprocess_comment', $fail_order_note, 10, 1 );
+
+		try {
+			$this->prepare_post_request(
+				OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+				array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => (string) $order->get_id() )
+			);
+
+			$state = $this->sut->process_current_request();
+
+			$this->assertSame( 'confirmation', $state->screen, 'Order note failures should not block submission.' );
+			$this->assertCount( 2, $capture['captures'], 'The customer and merchant emails should still be sent.' );
+			$this->assert_mail_sent_to( 'jane@example.test', $capture['captures'] );
+			$this->assert_mail_sent_to( (string) get_option( 'admin_email' ), $capture['captures'] );
+			$this->assert_order_withdrawal_requested( $order );
+		} finally {
+			remove_filter( 'preprocess_comment', $fail_order_note, 10 );
+			$capture['remove']();
+		}
+	}
+
+	/**
+	 * @testdox Should send emails without adding a note when no exact order match is found.
+	 */
+	public function test_process_current_request_sends_emails_without_note_when_order_does_not_match(): void {
+		$order   = $this->create_order_for_form_data(
+			array(
+				OrderWithdrawalFormProcessor::FIELD_EMAIL => 'different@example.test',
+				OrderWithdrawalFormProcessor::FIELD_EMAIL_CONFIRMATION => 'different@example.test',
+			)
+		);
+		$capture = $this->capture_wp_mail();
+
+		try {
+			$this->prepare_post_request(
+				OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+				array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => (string) $order->get_id() )
+			);
+
+			$state = $this->sut->process_current_request();
+
+			$this->assertSame( 'confirmation', $state->screen, 'Unmatched confirm submissions should still reach the confirmation screen.' );
+			$this->assertCount( 2, $capture['captures'], 'The customer and merchant emails should both be sent even without a match.' );
+			$this->assert_mail_sent_to( 'jane@example.test', $capture['captures'] );
+			$this->assert_mail_sent_to( (string) get_option( 'admin_email' ), $capture['captures'] );
+			$this->assertFalse( $this->order_has_note_containing( $order, 'Order withdrawal requested' ), 'An order note should not be added when the identifying data does not all match.' );
+		} finally {
+			$capture['remove']();
+		}
+	}
+
+	/**
+	 * @testdox Should keep the user on review with an error notice when notification emails fail.
+	 */
+	public function test_process_current_request_surfaces_error_when_emails_fail(): void {
+		$capture = $this->capture_wp_mail( false );
+
+		try {
+			$this->prepare_post_request(
+				OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+				array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => '999999999' )
+			);
+
+			$state         = $this->sut->process_current_request();
+			$error_notices = wc_get_notices( 'error' );
+
+			$this->assertSame( 'review', $state->screen, 'Email failures should keep the submitted details on the review screen.' );
+			$this->assertCount( 2, $capture['captures'], 'The processor should attempt both notification emails before surfacing the failure.' );
+			$this->assertNotEmpty( $error_notices, 'Email failures should add an error notice.' );
+			$this->assertStringContainsString( 'We could not submit your withdrawal request.', $error_notices[0]['notice'], 'The error notice should tell the user the submission did not complete.' );
+		} finally {
+			$capture['remove']();
+		}
 	}

 	/**
@@ -283,6 +551,144 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * Create an order from the default valid form data.
+	 *
+	 * @param array<string,string> $field_overrides Field value overrides keyed by unprefixed field key.
+	 */
+	private function create_order_for_form_data( array $field_overrides = array() ): WC_Order {
+		$data  = array_merge( $this->get_valid_form_data(), $field_overrides );
+		$order = wc_create_order();
+
+		if ( ! $order instanceof WC_Order ) {
+			$this->fail( 'Expected wc_create_order() to create a WC_Order instance.' );
+		}
+
+		$order->set_billing_first_name( $data[ OrderWithdrawalFormProcessor::FIELD_FIRST_NAME ] );
+		$order->set_billing_last_name( $data[ OrderWithdrawalFormProcessor::FIELD_LAST_NAME ] );
+		$order->set_billing_email( $data[ OrderWithdrawalFormProcessor::FIELD_EMAIL ] );
+		$order->save();
+
+		$this->created_order_ids[] = $order->get_id();
+
+		return $order;
+	}
+
+	/**
+	 * Capture wp_mail() calls without sending real email.
+	 *
+	 * @param bool $send_result Value returned to wp_mail().
+	 * @return array{captures: array<int,array<string,mixed>>, remove: callable}
+	 */
+	private function capture_wp_mail( bool $send_result = true ): array {
+		$captures = array();
+
+		$capture = static function ( $short_circuit, $atts ) use ( &$captures, $send_result ) {
+			unset( $short_circuit );
+			$captures[] = is_array( $atts ) ? $atts : array();
+
+			return $send_result;
+		};
+
+		add_filter( 'pre_wp_mail', $capture, 10, 2 );
+
+		$remove = static function () use ( $capture ) {
+			remove_filter( 'pre_wp_mail', $capture, 10 );
+		};
+
+		return array(
+			'captures' => &$captures,
+			'remove'   => $remove,
+		);
+	}
+
+	/**
+	 * Assert an email was sent to a recipient.
+	 *
+	 * @param string                         $recipient Recipient email.
+	 * @param array<int,array<string,mixed>> $captures   Captured email arguments.
+	 */
+	private function assert_mail_sent_to( string $recipient, array $captures ): void {
+		$recipients = array();
+
+		foreach ( $captures as $mail ) {
+			$to = $mail['to'] ?? '';
+
+			if ( is_array( $to ) ) {
+				$recipients = array_merge( $recipients, $to );
+			} else {
+				$recipients[] = (string) $to;
+			}
+		}
+
+		$this->assertContains( $recipient, $recipients, sprintf( 'Expected an email to be sent to %s.', $recipient ) );
+	}
+
+	/**
+	 * Get a captured email sent to a recipient.
+	 *
+	 * @param string                         $recipient Recipient email.
+	 * @param array<int,array<string,mixed>> $captures   Captured email arguments.
+	 * @return array<string,mixed>
+	 */
+	private function get_captured_mail_to( string $recipient, array $captures ): array {
+		foreach ( $captures as $mail ) {
+			$to         = $mail['to'] ?? '';
+			$recipients = is_array( $to ) ? $to : array( (string) $to );
+
+			if ( in_array( $recipient, $recipients, true ) ) {
+				return $mail;
+			}
+		}
+
+		$this->fail( sprintf( 'Expected an email to be sent to %s.', $recipient ) );
+	}
+
+	/**
+	 * Whether an order has a note containing specific text.
+	 *
+	 * @param WC_Order $order  Order.
+	 * @param string   $needle Note content to search for.
+	 */
+	private function order_has_note_containing( WC_Order $order, string $needle ): bool {
+		$notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+		foreach ( $notes as $note ) {
+			if ( false !== strpos( (string) $note->content, $needle ) ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Assert that an order has been flagged as having a withdrawal request.
+	 *
+	 * @param WC_Order $order Order.
+	 */
+	private function assert_order_withdrawal_requested( WC_Order $order ): void {
+		$updated_order = wc_get_order( $order->get_id() );
+
+		$this->assertInstanceOf( WC_Order::class, $updated_order, 'The order should still exist.' );
+		$this->assertSame( 'yes', $updated_order->get_meta( self::ORDER_WITHDRAWAL_REQUESTED_META_KEY, true, 'edit' ), 'The matched order should be flagged as having a withdrawal request.' );
+	}
+
+	/**
+	 * Delete orders created during a test.
+	 */
+	private function delete_created_orders(): void {
+		foreach ( $this->created_order_ids as $order_id ) {
+			$order = wc_get_order( $order_id );
+
+			if ( $order instanceof WC_Order ) {
+				$order->delete( true );
+			}
+		}
+
+		$this->created_order_ids = array();
+	}
+
 	/**
 	 * Disable the order withdrawal feature.
 	 */