Commit f7615572e33 for woocommerce

commit f7615572e331d50ea8d658bcb2f46552b59eb330
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date:   Tue Aug 4 14:48:58 2026 +0100

    Add merchant inbox notification for order withdrawal requests (#67155)

    * Add note for withdraw order requests

    * Changelog

    * Update order withdrawal inbox note privacy

    * Update test

    * Support HPOS and legacy order for cleanup

    * Remove PII from order note

    * Add order notes after marking the request

    * Add order number to inbox note title

    * OrderID guards

    * Add order ID into error log

    * Fix test case for failing emails

    * Use appropriate note method for test helpers

    * Final review fixes

diff --git a/plugins/woocommerce/changelog/add-withdraw-order-note b/plugins/woocommerce/changelog/add-withdraw-order-note
new file mode 100644
index 00000000000..47901e470d1
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-withdraw-order-note
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Adds an inbox note when a customer submits a withdraw order request
diff --git a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalController.php b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalController.php
index 1de5b630cab..0ffa4568780 100644
--- a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalController.php
+++ b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalController.php
@@ -58,6 +58,9 @@ final class OrderWithdrawalController implements RegisterHooksInterface {
 		add_filter( 'woocommerce_endpoint_' . self::ENDPOINT_KEY . '_title', array( $this, 'get_endpoint_title' ), 10, 1 );
 		add_filter( 'woocommerce_settings_pages', array( $this, 'add_endpoint_setting' ), 10, 1 );
 		add_action( 'woocommerce_account_' . self::ENDPOINT_KEY . '_endpoint', array( $this, 'render_view' ) );
+		add_action( 'woocommerce_before_delete_order', array( $this->form_processor, 'delete_order_withdrawal_inbox_note_for_order' ), 10, 1 );
+		add_action( 'before_delete_post', array( $this->form_processor, 'delete_order_withdrawal_inbox_note_for_order' ), 10, 1 );
+		add_action( 'woocommerce_privacy_remove_order_personal_data', array( $this->form_processor, 'delete_order_withdrawal_inbox_note_for_order' ), 10, 1 );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php
index da0d2064a6e..67956193205 100644
--- a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php
+++ b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php
@@ -3,7 +3,10 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Internal\OrderWithdrawal;

+use Automattic\WooCommerce\Admin\Notes\Note;
+use Automattic\WooCommerce\Admin\Notes\Notes;
 use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
+use Automattic\WooCommerce\Utilities\OrderUtil;
 use Throwable;
 use WC_Geolocation;
 use WC_Order;
@@ -37,6 +40,7 @@ final class OrderWithdrawalFormProcessor {
 	private const LOGGER_SOURCE                       = 'order-withdrawal';
 	private const ORDER_WITHDRAWAL_REQUESTED_META_KEY = '_order_withdrawal_requested';
 	private const ORDER_WITHDRAWAL_REQUESTED_VALUE    = 'yes';
+	private const INBOX_NOTE_NAME_PREFIX              = 'wc-order-withdrawal-requested-order-';
 	private const RATE_LIMIT_IP_PREFIX                = 'order_withdrawal_ip_';
 	private const RATE_LIMIT_EMAIL_PREFIX             = 'order_withdrawal_email_';
 	private const RATE_LIMIT_DELAY                    = MINUTE_IN_SECONDS / 2;
@@ -266,19 +270,15 @@ final class OrderWithdrawalFormProcessor {

 		$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'
-				);
-
-				$this->apply_rate_limits( $rate_limit_ids, -1 );
+		if ( $matched_order && $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->apply_rate_limits( $rate_limit_ids, -1 );

-			$this->add_order_withdrawal_note( $matched_order, $data );
+			return false;
 		}

 		if ( ! $this->send_order_withdrawal_emails( $data, $matched_order ) ) {
@@ -290,6 +290,8 @@ final class OrderWithdrawalFormProcessor {

 		if ( $matched_order ) {
 			$this->mark_order_withdrawal_requested( $matched_order );
+			$this->add_order_withdrawal_note( $matched_order, $data );
+			$this->add_order_withdrawal_inbox_note( $matched_order );
 		}

 		return true;
@@ -458,20 +460,11 @@ final class OrderWithdrawalFormProcessor {
 	 */
 	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 ]
+			/* translators: %s: withdrawal type label. */
+			__( 'Order withdrawal requested. Withdrawal type: %s.', 'woocommerce' ),
+			$this->get_withdrawal_type_label( $data[ self::FIELD_WITHDRAWAL_TYPE ] )
 		);

-		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 );
@@ -481,6 +474,73 @@ final class OrderWithdrawalFormProcessor {
 		}
 	}

+	/**
+	 * Add a withdrawal request notification to the merchant's WooCommerce inbox.
+	 *
+	 * @param WC_Order $matched_order Matched order.
+	 */
+	private function add_order_withdrawal_inbox_note( WC_Order $matched_order ): void {
+		try {
+			$note = new Note();
+			$note->set_title(
+				sprintf(
+					/* translators: %s: order number. */
+					__( 'Order withdrawal request for #%s', 'woocommerce' ),
+					$matched_order->get_order_number()
+				)
+			);
+			$note->set_content(
+				sprintf(
+				/* translators: %s: order number. */
+					__( 'A customer submitted an order withdrawal request for order #%s. Review the matched order to confirm the request details.', 'woocommerce' ),
+					$matched_order->get_order_number()
+				)
+			);
+			$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
+			$note->set_name( self::INBOX_NOTE_NAME_PREFIX . $matched_order->get_id() );
+			$note->set_source( 'woocommerce-admin' );
+
+			$order_url = $matched_order->get_edit_order_url();
+
+			if ( '' !== $order_url ) {
+				$note->add_action( 'view-order', __( 'View order', 'woocommerce' ), $order_url );
+			}
+
+			$note->save();
+		} catch ( Throwable $e ) {
+			$this->log_inbox_note_error( $e, $matched_order->get_id() );
+		}
+	}
+
+	/**
+	 * Delete the withdrawal request inbox notification associated with an order.
+	 *
+	 * @param int|WC_Order $order Order ID or order object.
+	 */
+	public function delete_order_withdrawal_inbox_note_for_order( $order ): void {
+		if ( $order instanceof WC_Order ) {
+			$order_id = $order->get_id();
+		} elseif ( is_int( $order ) ) {
+			if ( ! OrderUtil::is_order( $order ) ) {
+				return;
+			}
+
+			$order_id = $order;
+		} else {
+			return;
+		}
+
+		if ( 0 >= $order_id ) {
+			return;
+		}
+
+		try {
+			Notes::delete_notes_with_name( self::INBOX_NOTE_NAME_PREFIX . $order_id );
+		} catch ( Throwable $e ) {
+			$this->log_inbox_note_error( $e, $order_id );
+		}
+	}
+
 	/**
 	 * Whether the matched order already has a submitted withdrawal request.
 	 *
@@ -720,6 +780,19 @@ final class OrderWithdrawalFormProcessor {
 		);
 	}

+	/**
+	 * Log an inbox note failure without failing the submission.
+	 *
+	 * @param Throwable $e        Inbox note error.
+	 * @param int       $order_id Order ID.
+	 */
+	private function log_inbox_note_error( Throwable $e, int $order_id ): void {
+		wc_get_logger()->warning(
+			sprintf( 'Order withdrawal inbox note could not be processed for order %1$d. Error: %2$s', $order_id, $e->getMessage() ),
+			array( 'source' => self::LOGGER_SOURCE )
+		);
+	}
+
 	/**
 	 * Log an order note failure without failing the submission.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
index fd1560da7dc..9d997df6389 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
@@ -3,6 +3,10 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Tests\Internal\OrderWithdrawal;

+use Automattic\WooCommerce\Admin\Notes\Note;
+use Automattic\WooCommerce\Admin\Notes\Notes;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalController;
 use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormProcessor;
 use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormState;
 use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormView;
@@ -20,7 +24,9 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
 	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';
+	private const INBOX_NOTE_NAME_PREFIX              = 'wc-order-withdrawal-requested-order-';
 	private const RATE_LIMIT_PREFIX                   = 'order_withdrawal_';
+	private const ORDER_NOTE_WITHDRAWAL_REQUESTED     = 'Order withdrawal requested. Withdrawal type: Specific items only.';

 	/**
 	 * The System Under Test.
@@ -153,6 +159,7 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
 		$this->restore_option( self::FLUSH_QUEUE_OPTION, $this->original_flush_queue_option );
 		wc_clear_notices();
 		$this->clear_order_withdrawal_rate_limits();
+		$this->delete_created_inbox_notes();
 		$this->delete_created_orders();
 		WC()->session = $this->original_session;

@@ -222,8 +229,10 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
 			$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->assertTrue( $this->order_has_note_containing( $order, self::ORDER_NOTE_WITHDRAWAL_REQUESTED ), 'The matched order should receive a withdrawal note.' );
+			$this->assertFalse( $this->order_has_note_containing( $order, 'Jane Doe' ), 'The order note should not include the customer name.' );
+			$this->assertFalse( $this->order_has_note_containing( $order, 'jane@example.test' ), 'The order note should not include the customer email address.' );
+			$this->assertFalse( $this->order_has_note_containing( $order, 'Line item 1' ), 'The order note should not include free-form withdrawal details.' );
 			$this->assert_order_withdrawal_requested( $order );
 		} finally {
 			$capture['remove']();
@@ -257,7 +266,7 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {

 			$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->assertTrue( $this->order_has_note_containing( $order, self::ORDER_NOTE_WITHDRAWAL_REQUESTED ), 'The custom-number matched order should receive a withdrawal note.' );
 			$this->assert_order_withdrawal_requested( $order );
 		} finally {
 			remove_filter( 'woocommerce_order_number', $filter, 10 );
@@ -286,7 +295,7 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
 			$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->assertTrue( $this->order_has_note_containing( $target_order, self::ORDER_NOTE_WITHDRAWAL_REQUESTED ), '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 {
@@ -333,7 +342,7 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
 			$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->assertTrue( $this->order_has_note_containing( $target_order, self::ORDER_NOTE_WITHDRAWAL_REQUESTED ), '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 {
@@ -365,6 +374,7 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
 			$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.' );
+			$this->assertCount( 0, $this->get_created_inbox_note_ids(), 'Duplicate matched submissions should not create merchant inbox notifications.' );

 			wc_clear_notices();
 			$this->prepare_post_request(
@@ -448,16 +458,171 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox Should add a merchant inbox notification with a view order action when a confirmed submission matches an order.
+	 */
+	public function test_process_current_request_adds_inbox_note_with_order_action_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();
+			$note_ids = $this->get_created_inbox_note_ids();
+
+			$this->assertSame( 'confirmation', $state->screen, 'Matched confirm submissions should reach the confirmation screen.' );
+			$this->assertCount( 1, $note_ids, 'A matched submission should create one merchant inbox notification.' );
+
+			$note = Notes::get_note( $note_ids[0] );
+
+			$this->assertInstanceOf( Note::class, $note, 'The merchant inbox notification should be readable.' );
+			$this->assertSame( Note::E_WC_ADMIN_NOTE_INFORMATIONAL, $note->get_type(), 'The inbox notification should be informational.' );
+			$this->assertSame( Note::E_WC_ADMIN_NOTE_UNACTIONED, $note->get_status(), 'The inbox notification should start unactioned.' );
+			$this->assertSame( sprintf( 'Order withdrawal request for #%s', $order->get_order_number() ), $note->get_title(), 'The inbox notification should have the expected title.' );
+			$this->assertStringContainsString( sprintf( 'order #%s', $order->get_order_number() ), $note->get_content(), 'The inbox notification should consistently prefix the order number.' );
+			$this->assertStringContainsString( (string) $order->get_order_number(), $note->get_content(), 'The inbox notification should include the order number.' );
+			$this->assertStringContainsString( 'Review the matched order to confirm the request details.', $note->get_content(), 'The inbox notification should direct merchants to the matched order.' );
+			$this->assertStringNotContainsString( 'Jane Doe', $note->get_content(), 'The inbox notification should not include the customer name.' );
+			$this->assertStringNotContainsString( 'jane@example.test', $note->get_content(), 'The inbox notification should not include the customer email address.' );
+			$this->assertStringNotContainsString( 'Line item 1', $note->get_content(), 'The inbox notification should not include free-form withdrawal details.' );
+
+			$actions = $note->get_actions();
+
+			$this->assertCount( 1, $actions, 'The inbox notification should have one action.' );
+			$this->assertSame( 'view-order', $actions[0]->name, 'The inbox notification action should be the view order action.' );
+			$this->assertSame( $order->get_edit_order_url(), $actions[0]->query, 'The inbox notification action should link to the matched order.' );
+		} finally {
+			$capture['remove']();
+		}
+	}
+
+	/**
+	 * @testdox Should skip the merchant inbox notification when no order matches.
+	 */
+	public function test_process_current_request_skips_inbox_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();
+			$note_ids = $this->get_created_inbox_note_ids();
+
+			$this->assertSame( 'confirmation', $state->screen, 'Unmatched confirm submissions should still reach the confirmation screen.' );
+			$this->assertCount( 0, $note_ids, 'An unmatched submission should not create a merchant inbox notification.' );
+		} finally {
+			$capture['remove']();
+		}
+	}
+
+	/**
+	 * @testdox Should delete a matched merchant inbox notification when its order is deleted.
+	 */
+	public function test_delete_order_withdrawal_inbox_note_for_order_deletes_matched_inbox_note(): 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() )
+			);
+
+			$this->sut->process_current_request();
+
+			$note_ids = $this->get_created_inbox_note_ids();
+
+			$this->assertCount( 1, $note_ids, 'A matched submission should create one merchant inbox notification.' );
+
+			$this->sut->delete_order_withdrawal_inbox_note_for_order( $order );
+
+			$this->assertCount( 0, $this->get_created_inbox_note_ids(), 'Cleaning up the order should remove the associated merchant inbox notification.' );
+		} finally {
+			$capture['remove']();
+		}
+	}
+
+	/**
+	 * @testdox Should not delete a merchant inbox notification for a non-order post ID.
+	 */
+	public function test_delete_order_withdrawal_inbox_note_for_order_ignores_non_order_post_ids(): void {
+		$post_id = wp_insert_post(
+			array(
+				'post_title'  => 'Not an order',
+				'post_status' => 'publish',
+				'post_type'   => 'post',
+			)
+		);
+
+		$this->assertIsInt( $post_id, 'The test post should be created.' );
+
+		$note_name = self::INBOX_NOTE_NAME_PREFIX . $post_id;
+		$note      = new Note();
+		$note->set_title( 'Order withdrawal request for non-order post' );
+		$note->set_content( 'This note should not be deleted.' );
+		$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
+		$note->set_name( $note_name );
+		$note->set_source( 'woocommerce-admin' );
+		$note->save();
+
+		try {
+			$this->sut->delete_order_withdrawal_inbox_note_for_order( $post_id );
+
+			$this->assertInstanceOf( Note::class, Notes::get_note_by_name( $note_name ), 'Non-order post deletion should not delete matching inbox notes.' );
+		} finally {
+			Notes::delete_notes_with_name( $note_name );
+			wp_delete_post( $post_id, true );
+		}
+	}
+
+	/**
+	 * @testdox Should register cleanup hooks for HPOS and legacy order deletion.
+	 */
+	public function test_controller_registers_order_deletion_cleanup_hooks(): void {
+		$controller = new OrderWithdrawalController();
+		$controller->init( $this->sut, new OrderWithdrawalFormView() );
+
+		try {
+			$controller->register();
+
+			$this->assertNotFalse( has_action( 'woocommerce_before_delete_order', array( $this->sut, 'delete_order_withdrawal_inbox_note_for_order' ) ) );
+			$this->assertNotFalse( has_action( 'before_delete_post', array( $this->sut, 'delete_order_withdrawal_inbox_note_for_order' ) ) );
+		} finally {
+			remove_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $controller, 'maybe_flush_rewrite_rules' ), 10 );
+			remove_filter( 'woocommerce_get_query_vars', array( $controller, 'add_query_var' ), 10 );
+			remove_filter( 'woocommerce_endpoint_order-withdrawal_title', array( $controller, 'get_endpoint_title' ), 10 );
+			remove_filter( 'woocommerce_settings_pages', array( $controller, 'add_endpoint_setting' ), 10 );
+			remove_action( 'woocommerce_account_order-withdrawal_endpoint', array( $controller, 'render_view' ), 10 );
+			remove_action( 'woocommerce_before_delete_order', array( $this->sut, 'delete_order_withdrawal_inbox_note_for_order' ), 10 );
+			remove_action( 'before_delete_post', array( $this->sut, 'delete_order_withdrawal_inbox_note_for_order' ), 10 );
+			remove_action( 'woocommerce_privacy_remove_order_personal_data', array( $this->sut, 'delete_order_withdrawal_inbox_note_for_order' ), 10 );
+		}
+	}
+
 	/**
 	 * @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 {
+		$order   = $this->create_order_for_form_data();
 		$capture = $this->capture_wp_mail( false );

 		try {
 			$this->prepare_post_request(
 				OrderWithdrawalFormProcessor::ACTION_CONFIRM,
-				array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => '999999999' )
+				array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => (string) $order->get_id() )
 			);

 			$state         = $this->sut->process_current_request();
@@ -467,11 +632,22 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
 			$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.' );
+			$this->assertFalse( $this->order_has_note_containing( $order, 'Order withdrawal requested' ), 'Email failures should not add a retryable request to the order notes.' );
+
+			$updated_order = wc_get_order( $order->get_id() );
+
+			$this->assertInstanceOf( WC_Order::class, $updated_order, 'The matched order should still exist.' );
+			$this->assertNotSame(
+				'yes',
+				$updated_order->get_meta( self::ORDER_WITHDRAWAL_REQUESTED_META_KEY, true, 'edit' ),
+				'Email failures should not mark the matched order as having a withdrawal request.'
+			);
+			$this->assertCount( 0, $this->get_created_inbox_note_ids(), 'Email failures should not create merchant inbox notifications.' );

 			wc_clear_notices();
 			$this->prepare_post_request(
 				OrderWithdrawalFormProcessor::ACTION_CONFIRM,
-				array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => '999999999' )
+				array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => (string) $order->get_id() )
 			);

 			$second_state         = $this->sut->process_current_request();
@@ -797,6 +973,40 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
 		$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.' );
 	}

+	/**
+	 * Get the IDs of order withdrawal inbox notes created during a test.
+	 *
+	 * @return int[]
+	 */
+	private function get_created_inbox_note_ids(): array {
+		$note_ids = array();
+
+		foreach ( $this->created_order_ids as $order_id ) {
+			$note = Notes::get_note_by_name( self::INBOX_NOTE_NAME_PREFIX . $order_id );
+
+			if ( $note instanceof Note ) {
+				$note_ids[] = $note->get_id();
+			}
+		}
+
+		return array_map( 'intval', $note_ids );
+	}
+
+	/**
+	 * Delete inbox notes created during a test.
+	 */
+	private function delete_created_inbox_notes(): void {
+		$note_names = array();
+
+		foreach ( $this->created_order_ids as $order_id ) {
+			$note_names[] = self::INBOX_NOTE_NAME_PREFIX . $order_id;
+		}
+
+		if ( ! empty( $note_names ) ) {
+			Notes::delete_notes_with_name( $note_names );
+		}
+	}
+
 	/**
 	 * Delete orders created during a test.
 	 */