Commit c4093fc8a35 for woocommerce

commit c4093fc8a35b2b937f9d2e0e268aac02bf125d03
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date:   Tue Jul 21 14:34:35 2026 +0100

    Add order withdrawal form (UI only) (#66783)

    * Form POC

    * OrderWithdrawalFormHandler logic

    * Fix error messages

    * Separate logic

    * Remove title and description

    * Text area note

    * Changelog

    * Update form messaging per designs

    * Render sidebar for logged in users

    * OrderWithdrawal tests

    * Remove sidebar

    * Required fields test

    * fix lint

diff --git a/plugins/woocommerce/changelog/add-order-withdrawal-form b/plugins/woocommerce/changelog/add-order-withdrawal-form
new file mode 100644
index 00000000000..0dd219211fe
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-order-withdrawal-form
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Order withdrawal form
diff --git a/plugins/woocommerce/client/legacy/css/woocommerce.scss b/plugins/woocommerce/client/legacy/css/woocommerce.scss
index b14ae038e93..6026622fe9b 100644
--- a/plugins/woocommerce/client/legacy/css/woocommerce.scss
+++ b/plugins/woocommerce/client/legacy/css/woocommerce.scss
@@ -2319,6 +2319,138 @@ p.demo_store,
 	}
 }

+/**
+ * Order withdrawal form
+ */
+.woocommerce-order-withdrawal-content {
+	&__intro {
+		margin-bottom: 0.5em;
+	}
+
+	&__note,
+	&__field-description,
+	.description {
+		color: $subtext;
+		font-size: 0.875em;
+		line-height: 1.4;
+	}
+
+	&__field-description,
+	.description {
+		display: block;
+		margin-top: 0.25em;
+	}
+
+	&__field-error {
+		color: var(--wc-red);
+		display: block;
+		font-size: 0.875em;
+		margin: -0.25em 0 0.75em;
+	}
+
+	&__steps {
+		display: flex;
+		gap: 1em;
+		list-style: none;
+		margin: 2em 0;
+		padding: 0;
+		color: $subtext;
+		font-size: 0.875em;
+	}
+
+	&__step {
+		&:not(:last-child)::after {
+			content: "";
+			display: inline-block;
+			width: 0.25em;
+			height: 0.25em;
+			margin-left: 1em;
+			border-radius: 50%;
+			background: currentColor;
+			vertical-align: middle;
+		}
+
+		&--current {
+			color: inherit;
+			font-weight: 700;
+		}
+	}
+
+	&__radio-field {
+		clear: both;
+		border: 0;
+		margin: 0 0 6px;
+		padding: 3px;
+
+		legend {
+			line-height: 2;
+			padding: 0;
+		}
+
+		&.woocommerce-invalid legend {
+			color: var(--wc-red);
+		}
+	}
+
+	&__radio-label {
+		display: block;
+		line-height: 1.7;
+		margin: 0;
+	}
+
+	&__review {
+		margin: 2em 0;
+	}
+
+	&__review-row {
+		padding: 0.75em 0 0.8em;
+
+		&:not(:last-child) {
+			border-bottom: 1px solid $secondary;
+		}
+
+		dt,
+		dd {
+			margin: 0;
+			line-height: 1.4;
+		}
+
+		dt {
+			color: $subtext;
+			font-size: 0.875em;
+			margin-bottom: 0.25em;
+		}
+
+		dd {
+			font-size: 1em;
+		}
+	}
+
+	&__actions {
+		display: flex;
+		gap: 1em;
+		align-items: center;
+		flex-wrap: wrap;
+		margin-top: 2em;
+	}
+
+	&__button--secondary.button {
+		background: transparent;
+		border: 1px solid currentColor;
+		color: inherit;
+
+		&:hover,
+		&:focus {
+			background: transparent;
+			color: inherit;
+		}
+	}
+
+	.required {
+		visibility: visible;
+	}
+}
+
 /**
  * Password strength meter
  */
diff --git a/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-my-account.php b/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-my-account.php
index 21ca1025616..5c9cfa4923a 100644
--- a/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-my-account.php
+++ b/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-my-account.php
@@ -49,14 +49,15 @@ class WC_Shortcode_My_Account {
 			return;
 		}

+		if ( wc_get_container()->get( OrderWithdrawalController::class )->is_endpoint_request() ) {
+			// Order withdrawal is an EU regulation requirement which needs standalone access.
+			wc_get_container()->get( OrderWithdrawalController::class )->render_view();
+			return;
+		}
+
 		// Show login form if not logged in.
 		if ( ! is_user_logged_in() ) {
-			if ( wc_get_container()->get( OrderWithdrawalController::class )->is_endpoint_request() ) {
-				// Order withdrawal is an EU regulation requirement which needs to be accessible without logging in.
-				wc_get_container()->get( OrderWithdrawalController::class )->render_view();
-			} else {
-				wc_get_template( 'myaccount/form-login.php' );
-			}
+			wc_get_template( 'myaccount/form-login.php' );
 			return;
 		}

diff --git a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalController.php b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalController.php
index c447c2534e2..1de5b630cab 100644
--- a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalController.php
+++ b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalController.php
@@ -16,10 +16,41 @@ final class OrderWithdrawalController implements RegisterHooksInterface {

 	private const FEATURE_ID      = 'order_withdrawal';
 	private const ENDPOINT_KEY    = 'order-withdrawal';
+	private const ENDPOINT_SLUG   = 'withdraw-order';
 	private const ENDPOINT_OPTION = 'woocommerce_myaccount_order_withdrawal_endpoint';

+	/**
+	 * Form processor.
+	 *
+	 * @var OrderWithdrawalFormProcessor
+	 */
+	private OrderWithdrawalFormProcessor $form_processor;
+
+	/**
+	 * Form view.
+	 *
+	 * @var OrderWithdrawalFormView
+	 */
+	private OrderWithdrawalFormView $form_view;
+
+	/**
+	 * Initialize dependencies.
+	 *
+	 * @param OrderWithdrawalFormProcessor $form_processor Form processor.
+	 * @param OrderWithdrawalFormView      $form_view Form view.
+	 * @internal
+	 *
+	 * @since 11.1.0
+	 */
+	final public function init( OrderWithdrawalFormProcessor $form_processor, OrderWithdrawalFormView $form_view ): void { // phpcs:ignore Generic.CodeAnalysis.UnnecessaryFinalModifier.Found -- Required by WooCommerce injection method rules.
+		$this->form_processor = $form_processor;
+		$this->form_view      = $form_view;
+	}
+
 	/**
 	 * Register hooks.
+	 *
+	 * @since 11.1.0
 	 */
 	public function register(): void {
 		add_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'maybe_flush_rewrite_rules' ), 10, 1 );
@@ -31,6 +62,8 @@ final class OrderWithdrawalController implements RegisterHooksInterface {

 	/**
 	 * Whether order withdrawal is enabled.
+	 *
+	 * @since 11.1.0
 	 */
 	public function is_enabled(): bool {
 		return FeaturesUtil::feature_is_enabled( self::FEATURE_ID );
@@ -38,6 +71,8 @@ final class OrderWithdrawalController implements RegisterHooksInterface {

 	/**
 	 * Whether the current My Account request is for the public order withdrawal endpoint.
+	 *
+	 * @since 11.1.0
 	 */
 	public function is_endpoint_request(): bool {
 		global $wp;
@@ -51,6 +86,8 @@ final class OrderWithdrawalController implements RegisterHooksInterface {
 	 * Queue a rewrite rules flush when the feature is toggled.
 	 *
 	 * @param string $feature_id Feature being toggled.
+	 *
+	 * @since 11.1.0
 	 */
 	public function maybe_flush_rewrite_rules( string $feature_id ): void {
 		if ( self::FEATURE_ID === $feature_id ) {
@@ -62,6 +99,9 @@ final class OrderWithdrawalController implements RegisterHooksInterface {
 	 * Register the order withdrawal query var.
 	 *
 	 * @param array $query_vars Existing query vars keyed by endpoint key.
+	 * @return array
+	 *
+	 * @since 11.1.0
 	 */
 	public function add_query_var( $query_vars ): array {
 		if ( ! is_array( $query_vars ) ) {
@@ -69,7 +109,7 @@ final class OrderWithdrawalController implements RegisterHooksInterface {
 		}

 		if ( $this->is_enabled() ) {
-			$query_vars[ self::ENDPOINT_KEY ] = (string) get_option( self::ENDPOINT_OPTION, self::ENDPOINT_KEY );
+			$query_vars[ self::ENDPOINT_KEY ] = (string) get_option( self::ENDPOINT_OPTION, self::ENDPOINT_SLUG );
 		}

 		return $query_vars;
@@ -79,15 +119,21 @@ final class OrderWithdrawalController implements RegisterHooksInterface {
 	 * Order withdrawal endpoint page title.
 	 *
 	 * @param string $title Default title.
+	 * @return string
+	 *
+	 * @since 11.1.0
 	 */
 	public function get_endpoint_title( $title ): string {
-		return __( 'Order withdrawal', 'woocommerce' );
+		return __( 'Withdraw from contract', 'woocommerce' );
 	}

 	/**
 	 * Add the endpoint setting when the feature is enabled.
 	 *
 	 * @param array $settings Page settings.
+	 * @return array
+	 *
+	 * @since 11.1.0
 	 */
 	public function add_endpoint_setting( $settings ): array {
 		if ( ! is_array( $settings ) ) {
@@ -103,7 +149,7 @@ final class OrderWithdrawalController implements RegisterHooksInterface {
 			'desc'     => __( 'Endpoint for the order withdrawal page.', 'woocommerce' ),
 			'id'       => self::ENDPOINT_OPTION,
 			'type'     => 'text',
-			'default'  => self::ENDPOINT_KEY,
+			'default'  => self::ENDPOINT_SLUG,
 			'desc_tip' => true,
 		);

@@ -137,15 +183,45 @@ final class OrderWithdrawalController implements RegisterHooksInterface {

 	/**
 	 * Render the order withdrawal view.
+	 *
+	 * @since 11.1.0
 	 */
 	public function render_view(): void {
 		if ( ! $this->is_enabled() ) {
 			return;
 		}

-		?>
-		<h2><?php esc_html_e( 'Order withdrawal', 'woocommerce' ); ?></h2>
-		<p><?php esc_html_e( 'This is placeholder content for the order withdrawal page.', 'woocommerce' ); ?></p>
-		<?php
+		wc_get_template( 'myaccount/form-order-withdrawal.php', $this->get_template_args() );
+	}
+
+	/**
+	 * Get template arguments for the order withdrawal form.
+	 *
+	 * @return array<string,mixed>
+	 */
+	private function get_template_args(): array {
+		return $this->form_view->get_template_args(
+			$this->form_processor->process_current_request(),
+			$this->get_form_action_url(),
+			$this->get_shop_url()
+		);
+	}
+
+	/**
+	 * Get a safe shop URL for the confirmation link.
+	 */
+	private function get_shop_url(): string {
+		$shop_url = wc_get_page_permalink( 'shop' );
+
+		return $shop_url ? $shop_url : home_url( '/' );
+	}
+
+	/**
+	 * Get the form action URL.
+	 */
+	private function get_form_action_url(): string {
+		$account_url = wc_get_page_permalink( 'myaccount' );
+
+		return wc_get_endpoint_url( self::ENDPOINT_KEY, '', $account_url ? $account_url : home_url( '/' ) );
 	}
 }
diff --git a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php
new file mode 100644
index 00000000000..8f40d02dbb3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php
@@ -0,0 +1,227 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\OrderWithdrawal;
+
+/**
+ * Processes order withdrawal form requests.
+ *
+ * @internal Just for internal use.
+ */
+final class OrderWithdrawalFormProcessor {
+
+	public const NONCE_ACTION   = 'woocommerce_order_withdrawal';
+	public const NONCE_FIELD    = 'woocommerce-order-withdrawal-nonce';
+	public const ACTION_FIELD   = 'order_withdrawal_action';
+	public const ACTION_REVIEW  = 'review';
+	public const ACTION_CONFIRM = 'confirm';
+	public const ACTION_EDIT    = 'edit';
+
+	public const FIELD_PREFIX             = 'order_withdrawal_';
+	public const FIELD_FIRST_NAME         = 'first_name';
+	public const FIELD_LAST_NAME          = 'last_name';
+	public const FIELD_EMAIL              = 'email';
+	public const FIELD_EMAIL_CONFIRMATION = 'email_confirmation';
+	public const FIELD_ORDER_NUMBER       = 'order_number';
+	public const FIELD_WITHDRAWAL_TYPE    = 'withdrawal_type';
+	public const FIELD_ADDITIONAL_DETAILS = 'additional_details';
+	public const WITHDRAWAL_TYPE_FULL     = 'full_order';
+	public const WITHDRAWAL_TYPE_SPECIFIC = 'specific_items_only';
+
+	/**
+	 * Process the current order withdrawal request.
+	 *
+	 * @since 11.1.0
+	 */
+	public function process_current_request(): OrderWithdrawalFormState {
+		$data   = $this->get_default_form_data();
+		$errors = array();
+		$screen = 'form';
+
+		if ( ! $this->is_post_request() ) {
+			return new OrderWithdrawalFormState( $screen, $data, $errors );
+		}
+
+		if ( ! $this->has_valid_nonce() ) {
+			wc_add_notice( __( 'We could not verify your request. Please try again.', 'woocommerce' ), 'error' );
+			return new OrderWithdrawalFormState( $screen, $data, $errors );
+		}
+
+		$data   = $this->get_posted_form_data();
+		$action = $this->get_posted_action();
+
+		if ( self::ACTION_EDIT === $action ) {
+			return new OrderWithdrawalFormState( $screen, $data, $errors );
+		}
+
+		$errors = $this->validate_form_data( $data );
+
+		if ( ! empty( $errors ) ) {
+			$this->add_validation_notices( $errors );
+			return new OrderWithdrawalFormState( $screen, $data, $errors );
+		}
+
+		$screen = self::ACTION_CONFIRM === $action ? 'confirmation' : 'review';
+
+		return new OrderWithdrawalFormState( $screen, $data, $errors );
+	}
+
+	/**
+	 * Get the posted name for a form field key.
+	 *
+	 * @param string $field_key Field key.
+	 *
+	 * @since 11.1.0
+	 */
+	public static function get_field_name( string $field_key ): string {
+		return self::FIELD_PREFIX . $field_key;
+	}
+
+	/**
+	 * Get the default form data.
+	 *
+	 * @return array<string,string>
+	 */
+	private function get_default_form_data(): array {
+		return array(
+			self::FIELD_FIRST_NAME         => '',
+			self::FIELD_LAST_NAME          => '',
+			self::FIELD_EMAIL              => '',
+			self::FIELD_EMAIL_CONFIRMATION => '',
+			self::FIELD_ORDER_NUMBER       => '',
+			self::FIELD_WITHDRAWAL_TYPE    => self::WITHDRAWAL_TYPE_FULL,
+			self::FIELD_ADDITIONAL_DETAILS => '',
+		);
+	}
+
+	/**
+	 * Whether the current request is a form post.
+	 */
+	private function is_post_request(): bool {
+		$request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
+
+		return 'POST' === strtoupper( $request_method );
+	}
+
+	/**
+	 * Get the submitted form action.
+	 */
+	private function get_posted_action(): string {
+		$action = $this->get_posted_text_value( self::ACTION_FIELD );
+
+		if ( in_array( $action, array( self::ACTION_REVIEW, self::ACTION_CONFIRM, self::ACTION_EDIT ), true ) ) {
+			return $action;
+		}
+
+		return self::ACTION_REVIEW;
+	}
+
+	/**
+	 * Verify the order withdrawal form nonce.
+	 */
+	private function has_valid_nonce(): bool {
+		$nonce_value = $this->get_posted_text_value( self::NONCE_FIELD );
+
+		return '' !== $nonce_value && (bool) wp_verify_nonce( $nonce_value, self::NONCE_ACTION );
+	}
+
+	/**
+	 * Get sanitized submitted form data.
+	 *
+	 * @return array<string,string>
+	 */
+	private function get_posted_form_data(): array {
+		return array(
+			self::FIELD_FIRST_NAME         => $this->get_posted_text_value( self::get_field_name( self::FIELD_FIRST_NAME ) ),
+			self::FIELD_LAST_NAME          => $this->get_posted_text_value( self::get_field_name( self::FIELD_LAST_NAME ) ),
+			self::FIELD_EMAIL              => sanitize_email( $this->get_posted_text_value( self::get_field_name( self::FIELD_EMAIL ) ) ),
+			self::FIELD_EMAIL_CONFIRMATION => sanitize_email( $this->get_posted_text_value( self::get_field_name( self::FIELD_EMAIL_CONFIRMATION ) ) ),
+			self::FIELD_ORDER_NUMBER       => $this->get_posted_text_value( self::get_field_name( self::FIELD_ORDER_NUMBER ) ),
+			self::FIELD_WITHDRAWAL_TYPE    => $this->get_posted_text_value( self::get_field_name( self::FIELD_WITHDRAWAL_TYPE ) ),
+			self::FIELD_ADDITIONAL_DETAILS => $this->get_posted_textarea_value( self::get_field_name( self::FIELD_ADDITIONAL_DETAILS ) ),
+		);
+	}
+
+	/**
+	 * Get a sanitized text value from the current POST request.
+	 *
+	 * @param string $field_name Field name.
+	 */
+	private function get_posted_text_value( string $field_name ): string {
+		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verification happens before submitted data is used.
+		if ( ! isset( $_POST[ $field_name ] ) || ! is_scalar( $_POST[ $field_name ] ) ) {
+			return '';
+		}
+
+		return sanitize_text_field( wp_unslash( (string) $_POST[ $field_name ] ) );
+		// phpcs:enable WordPress.Security.NonceVerification.Missing
+	}
+
+	/**
+	 * Get a sanitized textarea value from the current POST request.
+	 *
+	 * @param string $field_name Field name.
+	 */
+	private function get_posted_textarea_value( string $field_name ): string {
+		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verification happens before submitted data is used.
+		if ( ! isset( $_POST[ $field_name ] ) || ! is_scalar( $_POST[ $field_name ] ) ) {
+			return '';
+		}
+
+		return sanitize_textarea_field( wp_unslash( (string) $_POST[ $field_name ] ) );
+		// phpcs:enable WordPress.Security.NonceVerification.Missing
+	}
+
+	/**
+	 * Validate the form data.
+	 *
+	 * @param array<string,string> $data Form data.
+	 * @return array<string,string>
+	 */
+	private function validate_form_data( array $data ): array {
+		$errors = array();
+
+		if ( '' === $data[ self::FIELD_FIRST_NAME ] ) {
+			$errors[ self::FIELD_FIRST_NAME ] = __( 'First name is a required field.', 'woocommerce' );
+		}
+
+		if ( '' === $data[ self::FIELD_LAST_NAME ] ) {
+			$errors[ self::FIELD_LAST_NAME ] = __( 'Last name is a required field.', 'woocommerce' );
+		}
+
+		if ( '' === $data[ self::FIELD_EMAIL ] || ! is_email( $data[ self::FIELD_EMAIL ] ) ) {
+			$errors[ self::FIELD_EMAIL ] = __( 'Enter a valid email address.', 'woocommerce' );
+		}
+
+		if ( '' === $data[ self::FIELD_EMAIL_CONFIRMATION ] ) {
+			$errors[ self::FIELD_EMAIL_CONFIRMATION ] = __( 'Confirm email address is a required field.', 'woocommerce' );
+		} elseif ( 0 !== strcasecmp( $data[ self::FIELD_EMAIL ], $data[ self::FIELD_EMAIL_CONFIRMATION ] ) ) {
+			$errors[ self::FIELD_EMAIL_CONFIRMATION ] = __( 'Email addresses do not match.', 'woocommerce' );
+		}
+
+		if ( '' === $data[ self::FIELD_ORDER_NUMBER ] ) {
+			$errors[ self::FIELD_ORDER_NUMBER ] = __( 'Order number is a required field.', 'woocommerce' );
+		}
+
+		if ( ! in_array( $data[ self::FIELD_WITHDRAWAL_TYPE ], array( self::WITHDRAWAL_TYPE_FULL, self::WITHDRAWAL_TYPE_SPECIFIC ), true ) ) {
+			$errors[ self::FIELD_WITHDRAWAL_TYPE ] = __( 'Choose what you want to withdraw.', 'woocommerce' );
+		}
+
+		if ( self::WITHDRAWAL_TYPE_SPECIFIC === $data[ self::FIELD_WITHDRAWAL_TYPE ] && '' === $data[ self::FIELD_ADDITIONAL_DETAILS ] ) {
+			$errors[ self::FIELD_ADDITIONAL_DETAILS ] = __( 'List the specific items you want to withdraw.', 'woocommerce' );
+		}
+
+		return $errors;
+	}
+
+	/**
+	 * Add form validation notices.
+	 *
+	 * @param array<string,string> $errors Validation errors keyed by field.
+	 */
+	private function add_validation_notices( array $errors ): void {
+		foreach ( $errors as $field_key => $message ) {
+			wc_add_notice( $message, 'error', array( 'id' => self::get_field_name( $field_key ) ) );
+		}
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormState.php b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormState.php
new file mode 100644
index 00000000000..3903c0ba3ad
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormState.php
@@ -0,0 +1,48 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\OrderWithdrawal;
+
+/**
+ * Holds the processed order withdrawal form state.
+ *
+ * @internal Just for internal use.
+ */
+final class OrderWithdrawalFormState {
+
+	/**
+	 * Current screen.
+	 *
+	 * @var string
+	 */
+	public string $screen;
+
+	/**
+	 * Sanitized form data.
+	 *
+	 * @var array<string,string>
+	 */
+	public array $data;
+
+	/**
+	 * Validation errors keyed by field.
+	 *
+	 * @var array<string,string>
+	 */
+	public array $errors;
+
+	/**
+	 * Initialize form state.
+	 *
+	 * @param string               $screen Current screen.
+	 * @param array<string,string> $data Sanitized form data.
+	 * @param array<string,string> $errors Validation errors keyed by field.
+	 *
+	 * @since 11.1.0
+	 */
+	public function __construct( string $screen, array $data, array $errors ) {
+		$this->screen = $screen;
+		$this->data   = $data;
+		$this->errors = $errors;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormView.php b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormView.php
new file mode 100644
index 00000000000..f8036146942
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormView.php
@@ -0,0 +1,209 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\OrderWithdrawal;
+
+/**
+ * Prepares order withdrawal template data.
+ *
+ * @internal Just for internal use.
+ */
+final class OrderWithdrawalFormView {
+
+	/**
+	 * Get template arguments for the order withdrawal form.
+	 *
+	 * @param OrderWithdrawalFormState $state Processed form state.
+	 * @param string                   $form_action_url Form action URL.
+	 * @param string                   $shop_url Shop URL.
+	 * @return array<string,mixed>
+	 *
+	 * @since 11.1.0
+	 */
+	public function get_template_args( OrderWithdrawalFormState $state, string $form_action_url, string $shop_url ): array {
+		return array(
+			'screen'                  => $state->screen,
+			'data'                    => $state->data,
+			'errors'                  => $state->errors,
+			'fields'                  => $this->get_prepared_form_fields( $state->errors ),
+			'withdrawal_type_options' => $this->get_withdrawal_type_options(),
+			'hidden_fields'           => $this->get_hidden_fields( $state->data ),
+			'review_rows'             => $this->get_review_rows( $state->data ),
+			'nonce_action'            => OrderWithdrawalFormProcessor::NONCE_ACTION,
+			'nonce_field'             => OrderWithdrawalFormProcessor::NONCE_FIELD,
+			'action_field'            => OrderWithdrawalFormProcessor::ACTION_FIELD,
+			'action_review'           => OrderWithdrawalFormProcessor::ACTION_REVIEW,
+			'action_confirm'          => OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+			'action_edit'             => OrderWithdrawalFormProcessor::ACTION_EDIT,
+			'form_action_url'         => $form_action_url,
+			'shop_url'                => $shop_url,
+		);
+	}
+
+	/**
+	 * Get form field definitions prepared for the template.
+	 *
+	 * @param array<string,string> $errors Validation errors keyed by field.
+	 * @return array<string,array<string,mixed>>
+	 */
+	private function get_prepared_form_fields( array $errors ): array {
+		$fields = array();
+
+		foreach ( $this->get_form_field_schema() as $field_key => $field ) {
+			$field['name']        = OrderWithdrawalFormProcessor::get_field_name( $field_key );
+			$field['id']          = OrderWithdrawalFormProcessor::get_field_name( $field_key );
+			$field['input_class'] = array( 'woocommerce-Input', 'woocommerce-Input--' . (string) $field['type'] );
+
+			if ( isset( $errors[ $field_key ] ) ) {
+				$field['class'][]                                = 'woocommerce-invalid';
+				$field['custom_attributes']['aria-invalid']      = 'true';
+				$field['custom_attributes']['aria-errormessage'] = OrderWithdrawalFormProcessor::get_field_name( $field_key ) . '_error';
+			}
+
+			if ( OrderWithdrawalFormProcessor::FIELD_ADDITIONAL_DETAILS === $field_key ) {
+				$field['custom_attributes']['rows'] = '5';
+			}
+
+			$fields[ $field_key ] = $field;
+		}
+
+		return $fields;
+	}
+
+	/**
+	 * Get hidden fields for review actions.
+	 *
+	 * @param array<string,string> $data Form data.
+	 * @return array<int,array{name:string,value:string}>
+	 */
+	private function get_hidden_fields( array $data ): array {
+		$hidden_fields = array();
+
+		foreach ( $data as $field_key => $value ) {
+			$hidden_fields[] = array(
+				'name'  => OrderWithdrawalFormProcessor::get_field_name( $field_key ),
+				'value' => $value,
+			);
+		}
+
+		return $hidden_fields;
+	}
+
+	/**
+	 * Get rows for the review screen.
+	 *
+	 * @param array<string,string> $data Form data.
+	 * @return array<int,array{label:string,value:string}>
+	 */
+	private function get_review_rows( array $data ): array {
+		return array(
+			array(
+				'label' => __( 'Name', 'woocommerce' ),
+				'value' => $this->get_customer_name( $data ),
+			),
+			array(
+				'label' => __( 'Email address', 'woocommerce' ),
+				'value' => $data[ OrderWithdrawalFormProcessor::FIELD_EMAIL ],
+			),
+			array(
+				'label' => __( 'Order number', 'woocommerce' ),
+				'value' => $data[ OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER ],
+			),
+			array(
+				'label' => __( 'Withdrawing', 'woocommerce' ),
+				'value' => $this->get_withdrawal_type_label( $data[ OrderWithdrawalFormProcessor::FIELD_WITHDRAWAL_TYPE ] ),
+			),
+			array(
+				'label' => __( 'Additional details', 'woocommerce' ),
+				'value' => '' === $data[ OrderWithdrawalFormProcessor::FIELD_ADDITIONAL_DETAILS ] ? __( 'None provided', 'woocommerce' ) : $data[ OrderWithdrawalFormProcessor::FIELD_ADDITIONAL_DETAILS ],
+			),
+		);
+	}
+
+	/**
+	 * Get the form field definitions for the template.
+	 *
+	 * @return array<string,array<string,mixed>>
+	 */
+	private function get_form_field_schema(): array {
+		return array(
+			OrderWithdrawalFormProcessor::FIELD_FIRST_NAME => array(
+				'label'        => __( 'First name', 'woocommerce' ),
+				'type'         => 'text',
+				'class'        => array( 'woocommerce-form-row', 'woocommerce-form-row--first', 'form-row-first' ),
+				'autocomplete' => 'given-name',
+				'required'     => true,
+			),
+			OrderWithdrawalFormProcessor::FIELD_LAST_NAME  => array(
+				'label'        => __( 'Last name', 'woocommerce' ),
+				'type'         => 'text',
+				'class'        => array( 'woocommerce-form-row', 'woocommerce-form-row--last', 'form-row-last' ),
+				'autocomplete' => 'family-name',
+				'required'     => true,
+			),
+			OrderWithdrawalFormProcessor::FIELD_EMAIL      => array(
+				'label'        => __( 'Email address', 'woocommerce' ),
+				'type'         => 'email',
+				'class'        => array( 'woocommerce-form-row', 'woocommerce-form-row--wide', 'form-row-wide' ),
+				'autocomplete' => 'email',
+				'required'     => true,
+			),
+			OrderWithdrawalFormProcessor::FIELD_EMAIL_CONFIRMATION => array(
+				'label'        => __( 'Confirm email address', 'woocommerce' ),
+				'type'         => 'email',
+				'class'        => array( 'woocommerce-form-row', 'woocommerce-form-row--wide', 'form-row-wide' ),
+				'autocomplete' => 'email',
+				'required'     => true,
+			),
+			OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => array(
+				'label'    => __( 'Order number', 'woocommerce' ),
+				'type'     => 'text',
+				'class'    => array( 'woocommerce-form-row', 'woocommerce-form-row--wide', 'form-row-wide' ),
+				'required' => true,
+			),
+			OrderWithdrawalFormProcessor::FIELD_WITHDRAWAL_TYPE => array(
+				'label'    => __( 'What do you want to withdraw?', 'woocommerce' ),
+				'type'     => 'radio',
+				'required' => true,
+			),
+			OrderWithdrawalFormProcessor::FIELD_ADDITIONAL_DETAILS => array(
+				'label'    => __( 'Additional details', 'woocommerce' ),
+				'type'     => 'textarea',
+				'class'    => array( 'woocommerce-form-row', 'woocommerce-form-row--wide', 'form-row-wide' ),
+				'required' => false,
+			),
+		);
+	}
+
+	/**
+	 * Get the available withdrawal type options.
+	 *
+	 * @return array<string,string>
+	 */
+	private function get_withdrawal_type_options(): array {
+		return array(
+			OrderWithdrawalFormProcessor::WITHDRAWAL_TYPE_FULL     => __( 'The full order', 'woocommerce' ),
+			OrderWithdrawalFormProcessor::WITHDRAWAL_TYPE_SPECIFIC => __( 'Specific items only', 'woocommerce' ),
+		);
+	}
+
+	/**
+	 * 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 = $this->get_withdrawal_type_options();
+
+		return $options[ $withdrawal_type ] ?? '';
+	}
+
+	/**
+	 * 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[ OrderWithdrawalFormProcessor::FIELD_FIRST_NAME ] . ' ' . $data[ OrderWithdrawalFormProcessor::FIELD_LAST_NAME ] );
+	}
+}
diff --git a/plugins/woocommerce/templates/myaccount/form-order-withdrawal.php b/plugins/woocommerce/templates/myaccount/form-order-withdrawal.php
new file mode 100644
index 00000000000..b0b53a082e6
--- /dev/null
+++ b/plugins/woocommerce/templates/myaccount/form-order-withdrawal.php
@@ -0,0 +1,200 @@
+<?php
+/**
+ * Order withdrawal form
+ *
+ * This template can be overridden by copying it to yourtheme/woocommerce/myaccount/form-order-withdrawal.php.
+ *
+ * HOWEVER, on occasion WooCommerce will need to update template files and you
+ * (the theme developer) will need to copy the new files to your theme to
+ * maintain compatibility. We try to do this as little as possible, but it does
+ * happen. When this occurs the version of the template file will be bumped and
+ * the readme will list any important changes.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates
+ * @version 11.1.0
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+$screen                  = isset( $screen ) ? (string) $screen : 'form';
+$data                    = isset( $data ) && is_array( $data ) ? $data : array();
+$form_errors             = isset( $errors ) && is_array( $errors ) ? $errors : array();
+$fields                  = isset( $fields ) && is_array( $fields ) ? $fields : array();
+$withdrawal_type_options = isset( $withdrawal_type_options ) && is_array( $withdrawal_type_options ) ? $withdrawal_type_options : array();
+$hidden_fields           = isset( $hidden_fields ) && is_array( $hidden_fields ) ? $hidden_fields : array();
+$review_rows             = isset( $review_rows ) && is_array( $review_rows ) ? $review_rows : array();
+$nonce_action            = isset( $nonce_action ) ? (string) $nonce_action : '';
+$nonce_field             = isset( $nonce_field ) ? (string) $nonce_field : '';
+$action_field            = isset( $action_field ) ? (string) $action_field : '';
+$action_review           = isset( $action_review ) ? (string) $action_review : '';
+$action_confirm          = isset( $action_confirm ) ? (string) $action_confirm : '';
+$action_edit             = isset( $action_edit ) ? (string) $action_edit : '';
+$form_action_url         = isset( $form_action_url ) ? (string) $form_action_url : '';
+$shop_url                = isset( $shop_url ) ? (string) $shop_url : wc_get_page_permalink( 'shop' );
+$form_field_keys         = array( 'first_name', 'last_name', 'email', 'email_confirmation', 'order_number' );
+$withdrawal_type_field   = $fields['withdrawal_type'] ?? array();
+$additional_details      = $fields['additional_details'] ?? array();
+$button_classes          = array( 'woocommerce-Button', 'button' );
+$theme_button_class      = wc_wp_theme_get_element_class_name( 'button' );
+
+if ( $theme_button_class ) {
+	$button_classes[] = $theme_button_class;
+}
+
+$button_class           = implode( ' ', $button_classes );
+$secondary_button_class = implode( ' ', array_merge( $button_classes, array( 'woocommerce-order-withdrawal-content__button--secondary' ) ) );
+?>
+
+<div class="woocommerce-order-withdrawal-content">
+	<?php wc_print_notices(); ?>
+
+	<?php if ( 'confirmation' === $screen ) : ?>
+		<p><strong><?php esc_html_e( 'Your withdrawal has been submitted.', 'woocommerce' ); ?></strong></p>
+		<p class="woocommerce-order-withdrawal-content__note">
+			<?php
+			printf(
+				/* translators: %s: Email address. */
+				esc_html__( 'We\'ve emailed an acknowledgment to %s with your details and the date and time of submission. Keep it as proof of your withdrawal.', 'woocommerce' ),
+				esc_html( $data['email'] ?? '' )
+			);
+			?>
+		</p>
+		<p class="woocommerce-order-withdrawal-content__note"><?php esc_html_e( 'We\'ll review your request and contact you about next steps, including any refund due.', 'woocommerce' ); ?></p>
+		<p><a href="<?php echo esc_url( $shop_url ); ?>"><?php esc_html_e( 'Back to the shop', 'woocommerce' ); ?></a></p>
+	<?php elseif ( 'review' === $screen ) : ?>
+		<p class="woocommerce-order-withdrawal-content__intro"><?php esc_html_e( 'Check your details before confirming.', 'woocommerce' ); ?></p>
+		<p class="woocommerce-order-withdrawal-content__note"><?php esc_html_e( 'Nothing has been sent yet. Your withdrawal is submitted when you select "Confirm withdrawal".', 'woocommerce' ); ?></p>
+
+		<ol class="woocommerce-order-withdrawal-content__steps" aria-label="<?php esc_attr_e( 'Order withdrawal progress', 'woocommerce' ); ?>">
+			<li class="woocommerce-order-withdrawal-content__step"><?php esc_html_e( 'Your details', 'woocommerce' ); ?></li>
+			<li class="woocommerce-order-withdrawal-content__step woocommerce-order-withdrawal-content__step--current" aria-current="step"><?php esc_html_e( 'Review and confirm', 'woocommerce' ); ?></li>
+		</ol>
+
+		<dl class="woocommerce-order-withdrawal-content__review">
+			<?php foreach ( $review_rows as $review_row ) : ?>
+				<div class="woocommerce-order-withdrawal-content__review-row">
+					<dt><?php echo esc_html( $review_row['label'] ?? '' ); ?></dt>
+					<dd><?php echo nl2br( esc_html( $review_row['value'] ?? '' ) ); ?></dd>
+				</div>
+			<?php endforeach; ?>
+		</dl>
+
+		<p class="woocommerce-order-withdrawal-content__note">
+			<?php
+			printf(
+				/* translators: %s: Email address. */
+				esc_html__( 'After you confirm, we\'ll email an acknowledgment to %s with these details and the date and time of submission. Keep it as proof of your withdrawal.', 'woocommerce' ),
+				esc_html( $data['email'] ?? '' )
+			);
+			?>
+		</p>
+
+		<form class="woocommerce-OrderWithdrawalForm" action="<?php echo esc_url( $form_action_url ); ?>" method="post" novalidate>
+			<?php foreach ( $hidden_fields as $hidden_field ) : ?>
+				<input type="hidden" name="<?php echo esc_attr( $hidden_field['name'] ?? '' ); ?>" value="<?php echo esc_attr( $hidden_field['value'] ?? '' ); ?>" />
+			<?php endforeach; ?>
+
+			<p class="woocommerce-order-withdrawal-content__actions">
+				<?php wp_nonce_field( $nonce_action, $nonce_field ); ?>
+				<button type="submit" class="<?php echo esc_attr( $button_class ); ?>" name="<?php echo esc_attr( $action_field ); ?>" value="<?php echo esc_attr( $action_confirm ); ?>"><?php esc_html_e( 'Confirm withdrawal', 'woocommerce' ); ?></button>
+				<button type="submit" class="<?php echo esc_attr( $secondary_button_class ); ?>" name="<?php echo esc_attr( $action_field ); ?>" value="<?php echo esc_attr( $action_edit ); ?>"><?php esc_html_e( 'Edit details', 'woocommerce' ); ?></button>
+			</p>
+		</form>
+	<?php else : ?>
+		<p class="woocommerce-order-withdrawal-content__intro"><?php esc_html_e( 'Tell us you want to withdraw from an order placed on this store. You do not need to give a reason.', 'woocommerce' ); ?></p>
+		<p class="woocommerce-order-withdrawal-content__note"><?php esc_html_e( 'Some items, like personalized products, may not be eligible. We review every request and reply by email.', 'woocommerce' ); ?></p>
+
+		<ol class="woocommerce-order-withdrawal-content__steps" aria-label="<?php esc_attr_e( 'Order withdrawal progress', 'woocommerce' ); ?>">
+			<li class="woocommerce-order-withdrawal-content__step woocommerce-order-withdrawal-content__step--current" aria-current="step"><?php esc_html_e( 'Your details', 'woocommerce' ); ?></li>
+			<li class="woocommerce-order-withdrawal-content__step"><?php esc_html_e( 'Review and confirm', 'woocommerce' ); ?></li>
+		</ol>
+
+		<form class="woocommerce-OrderWithdrawalForm" action="<?php echo esc_url( $form_action_url ); ?>" method="post" novalidate>
+			<?php foreach ( $form_field_keys as $field_key ) : ?>
+				<?php
+				$field      = $fields[ $field_key ] ?? array();
+				$field_name = isset( $field['name'] ) ? (string) $field['name'] : '';
+
+				if ( '' === $field_name ) {
+					continue;
+				}
+
+				$field['return'] = true;
+				$field_html      = woocommerce_form_field( $field_name, $field, $data[ $field_key ] ?? '' );
+
+				if ( isset( $form_errors[ $field_key ] ) ) {
+					$field_error_html = sprintf(
+						'<span id="%1$s" class="woocommerce-order-withdrawal-content__field-error">%2$s</span>',
+						esc_attr( $field_name . '_error' ),
+						esc_html( $form_errors[ $field_key ] )
+					);
+					$field_html       = str_replace( '</p>', $field_error_html . '</p>', $field_html );
+				}
+				?>
+				<?php echo $field_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+
+				<?php if ( 'last_name' === $field_key ) : ?>
+					<div class="clear"></div>
+				<?php endif; ?>
+			<?php endforeach; ?>
+
+			<?php
+			$withdrawal_type_name    = isset( $withdrawal_type_field['name'] ) ? (string) $withdrawal_type_field['name'] : '';
+			$withdrawal_type_id      = isset( $withdrawal_type_field['id'] ) ? (string) $withdrawal_type_field['id'] : $withdrawal_type_name;
+			$withdrawal_type_classes = array( 'woocommerce-form-row', 'form-row', 'form-row-wide', 'woocommerce-order-withdrawal-content__radio-field' );
+
+			if ( isset( $form_errors['withdrawal_type'] ) ) {
+				$withdrawal_type_classes[] = 'woocommerce-invalid';
+			}
+
+			$withdrawal_type_error_attributes = array(
+				'aria-invalid' => 'false',
+			);
+
+			if ( isset( $form_errors['withdrawal_type'] ) ) {
+				$withdrawal_type_error_attributes = array(
+					'aria-invalid'      => 'true',
+					'aria-errormessage' => $withdrawal_type_name . '_error',
+				);
+			}
+			?>
+			<fieldset class="<?php echo esc_attr( implode( ' ', $withdrawal_type_classes ) ); ?>">
+				<legend><?php echo esc_html( $withdrawal_type_field['label'] ?? '' ); ?>&nbsp;<span class="required" aria-hidden="true">*</span></legend>
+				<?php foreach ( $withdrawal_type_options as $option_value => $option_label ) : ?>
+					<label for="<?php echo esc_attr( $withdrawal_type_id . '_' . $option_value ); ?>" class="woocommerce-order-withdrawal-content__radio-label">
+						<input type="radio" class="input-radio" name="<?php echo esc_attr( $withdrawal_type_name ); ?>" id="<?php echo esc_attr( $withdrawal_type_id . '_' . $option_value ); ?>" value="<?php echo esc_attr( $option_value ); ?>" <?php checked( $data['withdrawal_type'] ?? '', $option_value ); ?> aria-required="true" <?php echo wc_implode_html_attributes( $withdrawal_type_error_attributes ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> />
+						<?php echo esc_html( $option_label ); ?>
+					</label>
+				<?php endforeach; ?>
+			</fieldset>
+			<?php if ( isset( $form_errors['withdrawal_type'] ) ) : ?>
+				<span id="<?php echo esc_attr( $withdrawal_type_name . '_error' ); ?>" class="woocommerce-order-withdrawal-content__field-error"><?php echo esc_html( $form_errors['withdrawal_type'] ); ?></span>
+			<?php endif; ?>
+
+			<?php if ( ! empty( $additional_details['name'] ) ) : ?>
+				<?php
+				$additional_details_name      = (string) $additional_details['name'];
+				$additional_details['return'] = true;
+				$additional_details_html      = woocommerce_form_field( $additional_details_name, $additional_details, $data['additional_details'] ?? '' );
+
+				if ( isset( $form_errors['additional_details'] ) ) {
+					$additional_details_error_html = sprintf(
+						'<span id="%1$s" class="woocommerce-order-withdrawal-content__field-error">%2$s</span>',
+						esc_attr( $additional_details_name . '_error' ),
+						esc_html( $form_errors['additional_details'] )
+					);
+					$additional_details_html       = str_replace( '</p>', $additional_details_error_html . '</p>', $additional_details_html );
+				}
+				?>
+				<?php echo $additional_details_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+			<?php endif; ?>
+			<span class="woocommerce-order-withdrawal-content__note"><?php esc_html_e( 'No reason needed. If you selected specific items, list them here.', 'woocommerce' ); ?></span>
+
+			<p class="woocommerce-order-withdrawal-content__actions">
+				<?php wp_nonce_field( $nonce_action, $nonce_field ); ?>
+				<button type="submit" class="<?php echo esc_attr( $button_class ); ?>" name="<?php echo esc_attr( $action_field ); ?>" value="<?php echo esc_attr( $action_review ); ?>"><?php esc_html_e( 'Continue to review', 'woocommerce' ); ?></button>
+			</p>
+		</form>
+	<?php endif; ?>
+</div>
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
new file mode 100644
index 00000000000..263263eacce
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
@@ -0,0 +1,307 @@
+<?php
+declare( strict_types = 1 );
+
+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_Unit_Test_Case;
+
+/**
+ * Critical path tests for order withdrawal form handling.
+ */
+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__';
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var OrderWithdrawalFormProcessor
+	 */
+	private $sut;
+
+	/**
+	 * Original POST data.
+	 *
+	 * @var array<string,mixed>
+	 */
+	private array $original_post = array();
+
+	/**
+	 * Original REQUEST_METHOD value.
+	 *
+	 * @var string|null
+	 */
+	private ?string $original_request_method = null;
+
+	/**
+	 * Whether REQUEST_METHOD existed before the test.
+	 *
+	 * @var bool
+	 */
+	private bool $had_request_method = false;
+
+	/**
+	 * Original WooCommerce session.
+	 *
+	 * @var \WC_Session|null
+	 */
+	private $original_session;
+
+	/**
+	 * Original feature option value.
+	 *
+	 * @var mixed
+	 */
+	private $original_feature_option;
+
+	/**
+	 * Original endpoint option value.
+	 *
+	 * @var mixed
+	 */
+	private $original_endpoint_option;
+
+	/**
+	 * Original flush queue option value.
+	 *
+	 * @var mixed
+	 */
+	private $original_flush_queue_option;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->sut                         = new OrderWithdrawalFormProcessor();
+		$this->original_post               = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing
+		$this->had_request_method          = filter_has_var( INPUT_SERVER, 'REQUEST_METHOD' );
+		$this->original_request_method     = $this->had_request_method ? filter_input( INPUT_SERVER, 'REQUEST_METHOD', FILTER_SANITIZE_FULL_SPECIAL_CHARS ) : null;
+		$this->original_session            = WC()->session;
+		$this->original_feature_option     = get_option( self::FEATURE_OPTION, self::MISSING_OPTION_MARK );
+		$this->original_endpoint_option    = get_option( self::ENDPOINT_OPTION, self::MISSING_OPTION_MARK );
+		$this->original_flush_queue_option = get_option( self::FLUSH_QUEUE_OPTION, self::MISSING_OPTION_MARK );
+
+		if ( ! WC()->session ) {
+			WC()->initialize_session();
+		}
+
+		$_POST                     = array();
+		$_SERVER['REQUEST_METHOD'] = 'GET';
+		$this->disable_feature();
+		delete_option( self::ENDPOINT_OPTION );
+		delete_option( self::FLUSH_QUEUE_OPTION );
+		wc_clear_notices();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		$_POST = $this->original_post;
+
+		if ( $this->had_request_method ) {
+			$_SERVER['REQUEST_METHOD'] = (string) $this->original_request_method;
+		} else {
+			unset( $_SERVER['REQUEST_METHOD'] );
+		}
+
+		$this->restore_option( self::FEATURE_OPTION, $this->original_feature_option );
+		$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();
+		WC()->session = $this->original_session;
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should return the empty form state for non-POST requests.
+	 */
+	public function test_process_current_request_returns_default_form_state_for_get_requests(): void {
+		$state = $this->sut->process_current_request();
+
+		$this->assertSame( 'form', $state->screen, 'GET requests should render the form screen.' );
+		$this->assertEmpty( $state->errors, 'GET requests should not have validation errors.' );
+		$this->assertSame( '', $state->data[ OrderWithdrawalFormProcessor::FIELD_FIRST_NAME ], 'Default first name should be empty.' );
+		$this->assertSame( OrderWithdrawalFormProcessor::WITHDRAWAL_TYPE_FULL, $state->data[ OrderWithdrawalFormProcessor::FIELD_WITHDRAWAL_TYPE ], 'The default withdrawal type should be the full order.' );
+	}
+
+	/**
+	 * @testdox Should move valid submissions to the requested next screen.
+	 * @dataProvider provide_valid_submission_actions
+	 *
+	 * @param string $action          Submitted form action.
+	 * @param string $expected_screen Expected screen.
+	 */
+	public function test_process_current_request_moves_valid_submissions_to_next_screen( string $action, string $expected_screen ): void {
+		$this->prepare_post_request( $action );
+
+		$state = $this->sut->process_current_request();
+
+		$this->assertSame( $expected_screen, $state->screen, 'Valid submissions should advance to the requested screen.' );
+		$this->assertEmpty( $state->errors, 'Valid submissions should not have validation errors.' );
+		$this->assertSame( 'Jane', $state->data[ OrderWithdrawalFormProcessor::FIELD_FIRST_NAME ], 'Submitted first name should be retained.' );
+		$this->assertSame( 'jane@example.test', $state->data[ OrderWithdrawalFormProcessor::FIELD_EMAIL ], 'Submitted email should be retained.' );
+	}
+
+	/**
+	 * Data provider for {@see test_process_current_request_moves_valid_submissions_to_next_screen()}.
+	 *
+	 * @return array<string,array{0:string,1:string}>
+	 */
+	public function provide_valid_submission_actions(): array {
+		return array(
+			'review action'  => array( OrderWithdrawalFormProcessor::ACTION_REVIEW, 'review' ),
+			'confirm action' => array( OrderWithdrawalFormProcessor::ACTION_CONFIRM, 'confirmation' ),
+		);
+	}
+
+	/**
+	 * @testdox Should reject specific-item withdrawals that do not list the items.
+	 */
+	public function test_process_current_request_requires_details_for_specific_item_withdrawals(): void {
+		$this->prepare_post_request(
+			OrderWithdrawalFormProcessor::ACTION_REVIEW,
+			array(
+				OrderWithdrawalFormProcessor::FIELD_WITHDRAWAL_TYPE    => OrderWithdrawalFormProcessor::WITHDRAWAL_TYPE_SPECIFIC,
+				OrderWithdrawalFormProcessor::FIELD_ADDITIONAL_DETAILS => '',
+			)
+		);
+
+		$state         = $this->sut->process_current_request();
+		$error_notices = wc_get_notices( 'error' );
+
+		$this->assertSame( 'form', $state->screen, 'Invalid submissions should stay on the form screen.' );
+		$this->assertArrayHasKey( OrderWithdrawalFormProcessor::FIELD_ADDITIONAL_DETAILS, $state->errors, 'Specific-item withdrawals should require item details.' );
+		$this->assertCount( 1, $error_notices, 'The validation failure should add one error notice.' );
+		$this->assertSame( OrderWithdrawalFormProcessor::get_field_name( OrderWithdrawalFormProcessor::FIELD_ADDITIONAL_DETAILS ), $error_notices[0]['data']['id'] ?? null, 'The notice should identify the details field.' );
+	}
+
+	/**
+	 * @testdox Should require the fields needed to identify the customer and order.
+	 */
+	public function test_process_current_request_requires_customer_and_order_fields(): void {
+		$required_fields = array(
+			OrderWithdrawalFormProcessor::FIELD_FIRST_NAME,
+			OrderWithdrawalFormProcessor::FIELD_LAST_NAME,
+			OrderWithdrawalFormProcessor::FIELD_EMAIL,
+			OrderWithdrawalFormProcessor::FIELD_EMAIL_CONFIRMATION,
+			OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER,
+			OrderWithdrawalFormProcessor::FIELD_WITHDRAWAL_TYPE,
+		);
+		$field_overrides = array_fill_keys( $required_fields, '' );
+
+		$this->prepare_post_request( OrderWithdrawalFormProcessor::ACTION_REVIEW, $field_overrides );
+
+		$state      = $this->sut->process_current_request();
+		$notice_ids = wp_list_pluck( wp_list_pluck( wc_get_notices( 'error' ), 'data' ), 'id' );
+
+		$this->assertSame( 'form', $state->screen, 'Submissions missing required fields should stay on the form screen.' );
+		$this->assertSame( $required_fields, array_keys( $state->errors ), 'The required customer and order fields should all fail validation.' );
+		$this->assertSame( array_map( array( OrderWithdrawalFormProcessor::class, 'get_field_name' ), $required_fields ), $notice_ids, 'Each required-field error should add a notice tied to that field.' );
+	}
+
+	/**
+	 * @testdox Should reject POST requests that fail nonce verification.
+	 */
+	public function test_process_current_request_rejects_invalid_nonce(): void {
+		$this->prepare_post_request( OrderWithdrawalFormProcessor::ACTION_REVIEW, array(), 'not-a-valid-nonce' );
+
+		$state         = $this->sut->process_current_request();
+		$error_notices = wc_get_notices( 'error' );
+
+		$this->assertSame( 'form', $state->screen, 'Invalid nonce submissions should stay on the form screen.' );
+		$this->assertEmpty( $state->errors, 'Nonce failures should not run field validation.' );
+		$this->assertSame( '', $state->data[ OrderWithdrawalFormProcessor::FIELD_FIRST_NAME ], 'Nonce failures should not retain posted data.' );
+		$this->assertCount( 1, $error_notices, 'Nonce failures should add an error notice.' );
+	}
+
+	/**
+	 * @testdox Should prepare the template data needed for the review screen.
+	 */
+	public function test_form_view_prepares_review_template_args(): void {
+		$view  = new OrderWithdrawalFormView();
+		$state = new OrderWithdrawalFormState( 'review', $this->get_valid_form_data(), array() );
+
+		$args = $view->get_template_args( $state, 'https://example.test/account/withdraw-order/', 'https://example.test/shop/' );
+
+		$hidden_fields = wp_list_pluck( $args['hidden_fields'], 'value', 'name' );
+		$review_rows   = wp_list_pluck( $args['review_rows'], 'value', 'label' );
+
+		$this->assertSame( 'review', $args['screen'], 'The view should expose the current screen.' );
+		$this->assertArrayHasKey( OrderWithdrawalFormProcessor::FIELD_EMAIL, $args['fields'], 'The view should expose prepared form fields.' );
+		$this->assertSame( 'jane@example.test', $hidden_fields[ OrderWithdrawalFormProcessor::get_field_name( OrderWithdrawalFormProcessor::FIELD_EMAIL ) ], 'Hidden fields should use posted field names.' );
+		$this->assertSame( 'Jane Doe', $review_rows['Name'], 'The review rows should include the customer name.' );
+		$this->assertSame( 'Specific items only', $review_rows['Withdrawing'], 'The review rows should include the withdrawal type label.' );
+		$this->assertSame( 'Line item 1', $review_rows['Additional details'], 'The review rows should include additional details.' );
+		$this->assertSame( 'https://example.test/account/withdraw-order/', $args['form_action_url'], 'The view should expose the form action URL.' );
+	}
+
+	/**
+	 * Prepare a form POST request.
+	 *
+	 * @param string               $action          Submitted action.
+	 * @param array<string,string> $field_overrides Field value overrides keyed by unprefixed field key.
+	 * @param string|null          $nonce           Nonce value.
+	 */
+	private function prepare_post_request( string $action, array $field_overrides = array(), ?string $nonce = null ): void {
+		$fields = array_merge( $this->get_valid_form_data(), $field_overrides );
+
+		$_SERVER['REQUEST_METHOD'] = 'POST';
+		$_POST                     = array(
+			OrderWithdrawalFormProcessor::ACTION_FIELD => $action,
+			OrderWithdrawalFormProcessor::NONCE_FIELD  => $nonce ?? wp_create_nonce( OrderWithdrawalFormProcessor::NONCE_ACTION ),
+		);
+
+		foreach ( $fields as $field_key => $value ) {
+			$_POST[ OrderWithdrawalFormProcessor::get_field_name( $field_key ) ] = $value;
+		}
+	}
+
+	/**
+	 * Get valid order withdrawal form data.
+	 *
+	 * @return array<string,string>
+	 */
+	private function get_valid_form_data(): array {
+		return array(
+			OrderWithdrawalFormProcessor::FIELD_FIRST_NAME => 'Jane',
+			OrderWithdrawalFormProcessor::FIELD_LAST_NAME  => 'Doe',
+			OrderWithdrawalFormProcessor::FIELD_EMAIL      => 'jane@example.test',
+			OrderWithdrawalFormProcessor::FIELD_EMAIL_CONFIRMATION => 'jane@example.test',
+			OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => '1001',
+			OrderWithdrawalFormProcessor::FIELD_WITHDRAWAL_TYPE => OrderWithdrawalFormProcessor::WITHDRAWAL_TYPE_SPECIFIC,
+			OrderWithdrawalFormProcessor::FIELD_ADDITIONAL_DETAILS => 'Line item 1',
+		);
+	}
+
+	/**
+	 * Disable the order withdrawal feature.
+	 */
+	private function disable_feature(): void {
+		update_option( self::FEATURE_OPTION, 'no' );
+	}
+
+	/**
+	 * Restore an option to its original state.
+	 *
+	 * @param string $option Option name.
+	 * @param mixed  $value  Original value.
+	 */
+	private function restore_option( string $option, $value ): void {
+		if ( self::MISSING_OPTION_MARK === $value ) {
+			delete_option( $option );
+			return;
+		}
+
+		update_option( $option, $value );
+	}
+}