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