Commit 74946acf2a0 for woocommerce
commit 74946acf2a0b3897d6c67447fa3f4bcdfbb32953
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date: Tue Jul 28 14:01:02 2026 +0100
Add order withdrawal submission handling (#66963)
* Add order withdrawal submission handling
* Add changelog entry for order withdrawal handling
* Treat withdrawal order notes as best effort
* Link matched withdrawal orders in merchant email
* Remove validation since wc_get_order validates data anyway
* Prevent duplicate requests
* Scope order lookup by email and normalize order number
* Add order withdrawal matching regression tests
* Attempt wc_get_order first
* Simplify order withdrawal submission error handling
* Simplify if statement
diff --git a/plugins/woocommerce/changelog/add-order-withdrawal-submission-handling b/plugins/woocommerce/changelog/add-order-withdrawal-submission-handling
new file mode 100644
index 00000000000..9c317c01e34
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-order-withdrawal-submission-handling
@@ -0,0 +1,5 @@
+Significance: patch
+Type: fix
+Comment: Record and notify order withdrawal submissions when submitted.
+
+
diff --git a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php
index 8f40d02dbb3..1e726cc17b5 100644
--- a/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php
+++ b/plugins/woocommerce/src/Internal/OrderWithdrawal/OrderWithdrawalFormProcessor.php
@@ -3,6 +3,10 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\OrderWithdrawal;
+use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
+use Throwable;
+use WC_Order;
+
/**
* Processes order withdrawal form requests.
*
@@ -28,6 +32,10 @@ final class OrderWithdrawalFormProcessor {
public const WITHDRAWAL_TYPE_FULL = 'full_order';
public const WITHDRAWAL_TYPE_SPECIFIC = 'specific_items_only';
+ private const LOGGER_SOURCE = 'order-withdrawal';
+ private const ORDER_WITHDRAWAL_REQUESTED_META_KEY = '_order_withdrawal_requested';
+ private const ORDER_WITHDRAWAL_REQUESTED_VALUE = 'yes';
+
/**
* Process the current order withdrawal request.
*
@@ -61,7 +69,15 @@ final class OrderWithdrawalFormProcessor {
return new OrderWithdrawalFormState( $screen, $data, $errors );
}
- $screen = self::ACTION_CONFIRM === $action ? 'confirmation' : 'review';
+ if ( self::ACTION_CONFIRM === $action ) {
+ if ( ! $this->submit_order_withdrawal( $data ) ) {
+ return new OrderWithdrawalFormState( 'review', $data, $errors );
+ }
+
+ $screen = 'confirmation';
+ } else {
+ $screen = 'review';
+ }
return new OrderWithdrawalFormState( $screen, $data, $errors );
}
@@ -224,4 +240,435 @@ final class OrderWithdrawalFormProcessor {
wc_add_notice( $message, 'error', array( 'id' => self::get_field_name( $field_key ) ) );
}
}
+
+ /**
+ * Submit a validated order withdrawal request.
+ *
+ * @param array<string,string> $data Form data.
+ */
+ private function submit_order_withdrawal( array $data ): bool {
+ $matched_order = $this->get_matching_order( $data );
+
+ if ( $matched_order ) {
+ if ( $this->has_order_withdrawal_request( $matched_order ) ) {
+ wc_add_notice(
+ __( 'A withdrawal request has already been submitted for this order. Please contact us if you need help or want to make changes.', 'woocommerce' ),
+ 'error'
+ );
+
+ return false;
+ }
+
+ $this->add_order_withdrawal_note( $matched_order, $data );
+ }
+
+ if ( ! $this->send_order_withdrawal_emails( $data, $matched_order ) ) {
+ wc_add_notice( __( 'We could not submit your withdrawal request. Please try again or contact us if the problem continues.', 'woocommerce' ), 'error' );
+
+ return false;
+ }
+
+ if ( $matched_order ) {
+ $this->mark_order_withdrawal_requested( $matched_order );
+ }
+
+ return true;
+ }
+
+ /**
+ * Get an order only when every submitted order-identifying field matches.
+ *
+ * @param array<string,string> $data Form data.
+ */
+ private function get_matching_order( array $data ): ?WC_Order {
+ $order_number = $this->normalize_order_number( $data[ self::FIELD_ORDER_NUMBER ] );
+ $email = $data[ self::FIELD_EMAIL ];
+
+ if ( '' === $order_number || '' === $email ) {
+ return null;
+ }
+
+ if ( ctype_digit( $order_number ) ) {
+ $order = wc_get_order( (int) $order_number );
+
+ if ( $order instanceof WC_Order && $this->order_matches_form_data( $order, $data ) ) {
+ return $order;
+ }
+ }
+
+ // Search by email first because the submitted order number may not be the internal order ID.
+ $candidate_orders = wc_get_orders(
+ array(
+ 'billing_email' => $email,
+ 'limit' => -1,
+ 'orderby' => 'date',
+ 'order' => 'DESC',
+ 'return' => 'objects',
+ )
+ );
+
+ if ( ! is_array( $candidate_orders ) ) {
+ return null;
+ }
+
+ foreach ( $candidate_orders as $order ) {
+ if ( ! $order instanceof WC_Order ) {
+ continue;
+ }
+
+ if ( $this->normalize_order_number( (string) $order->get_order_number() ) !== $order_number ) {
+ continue;
+ }
+
+ if ( $this->order_matches_form_data( $order, $data ) ) {
+ return $order;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Whether a candidate order exactly matches the submitted identifying data.
+ *
+ * @param WC_Order $order Candidate order.
+ * @param array<string,string> $data Form data.
+ */
+ private function order_matches_form_data( WC_Order $order, array $data ): bool {
+ return $this->normalize_order_number( (string) $order->get_order_number() ) === $this->normalize_order_number( $data[ self::FIELD_ORDER_NUMBER ] )
+ && $this->text_values_match( $order->get_billing_first_name( 'edit' ), $data[ self::FIELD_FIRST_NAME ] )
+ && $this->text_values_match( $order->get_billing_last_name( 'edit' ), $data[ self::FIELD_LAST_NAME ] )
+ && $this->text_values_match( $order->get_billing_email( 'edit' ), $data[ self::FIELD_EMAIL ] );
+ }
+
+ /**
+ * Normalize a submitted order number for lookup and comparison.
+ *
+ * @param string $order_number Order number.
+ */
+ private function normalize_order_number( string $order_number ): string {
+ $order_number = trim( $order_number );
+
+ if ( 0 === strpos( $order_number, '#' ) ) {
+ $order_number = trim( substr( $order_number, 1 ) );
+ }
+
+ return $order_number;
+ }
+
+ /**
+ * Compare submitted text values for identity while ignoring casing and surrounding spaces.
+ *
+ * @param string $stored_value Stored order value.
+ * @param string $submitted_value Submitted form value.
+ */
+ private function text_values_match( string $stored_value, string $submitted_value ): bool {
+ return 0 === strcasecmp( trim( $stored_value ), trim( $submitted_value ) );
+ }
+
+ /**
+ * Add the withdrawal request note to a matched order.
+ *
+ * @param WC_Order $order Matched order.
+ * @param array<string,string> $data Form data.
+ */
+ private function add_order_withdrawal_note( WC_Order $order, array $data ): void {
+ $note = sprintf(
+ /* translators: 1: customer name, 2: customer email address. */
+ __( 'Order withdrawal requested by %1$s (%2$s).', 'woocommerce' ),
+ $this->get_customer_name( $data ),
+ $data[ self::FIELD_EMAIL ]
+ );
+
+ if ( self::WITHDRAWAL_TYPE_SPECIFIC === $data[ self::FIELD_WITHDRAWAL_TYPE ] ) {
+ $note .= "\n\n" . sprintf(
+ /* translators: %s: items the customer listed for partial withdrawal. */
+ __( 'Items requested for withdrawal: %s', 'woocommerce' ),
+ $data[ self::FIELD_ADDITIONAL_DETAILS ]
+ );
+ }
+
+ try {
+ if ( ! $order->add_order_note( $note, 0, false, array( 'note_group' => OrderNoteGroup::ORDER_UPDATE ) ) ) {
+ $this->log_order_note_error( $order );
+ }
+ } catch ( Throwable $e ) {
+ $this->log_order_note_error( $order, $e );
+ }
+ }
+
+ /**
+ * Whether the matched order already has a submitted withdrawal request.
+ *
+ * @param WC_Order $order Matched order.
+ */
+ private function has_order_withdrawal_request( WC_Order $order ): bool {
+ return self::ORDER_WITHDRAWAL_REQUESTED_VALUE === $order->get_meta( self::ORDER_WITHDRAWAL_REQUESTED_META_KEY, true, 'edit' );
+ }
+
+ /**
+ * Mark a matched order as having a submitted withdrawal request.
+ *
+ * @param WC_Order $order Matched order.
+ */
+ private function mark_order_withdrawal_requested( WC_Order $order ): void {
+ try {
+ $order->update_meta_data( self::ORDER_WITHDRAWAL_REQUESTED_META_KEY, self::ORDER_WITHDRAWAL_REQUESTED_VALUE );
+ $order->save_meta_data();
+ } catch ( Throwable $e ) {
+ $this->log_order_meta_error( $order, $e );
+ }
+ }
+
+ /**
+ * Send customer and merchant order withdrawal emails.
+ *
+ * @param array<string,string> $data Form data.
+ * @param WC_Order|null $matched_order Matched order, if found.
+ */
+ private function send_order_withdrawal_emails( array $data, ?WC_Order $matched_order ): bool {
+ try {
+ $submitted_at = time();
+ $customer_sent = $this->send_customer_order_withdrawal_email( $data, $submitted_at );
+ $merchant_sent = $this->send_merchant_order_withdrawal_email( $data, $matched_order, $submitted_at );
+ } catch ( Throwable $e ) {
+ $this->log_email_error( $e );
+
+ return false;
+ }
+
+ if ( ! $customer_sent || ! $merchant_sent ) {
+ $this->log_email_error(
+ sprintf(
+ 'Order withdrawal notification email failed. Customer email sent: %s. Merchant email sent: %s.',
+ $customer_sent ? 'yes' : 'no',
+ $merchant_sent ? 'yes' : 'no'
+ )
+ );
+
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Send the customer order withdrawal acknowledgement email.
+ *
+ * @param array<string,string> $data Form data.
+ * @param int $submitted_at Unix timestamp for the submission.
+ */
+ private function send_customer_order_withdrawal_email( array $data, int $submitted_at ): bool {
+ $subject = __( 'We received your withdrawal request', 'woocommerce' );
+ $heading = __( 'We received your withdrawal request', 'woocommerce' );
+ $body = '<p>' . esc_html__( 'We have received your request to withdraw from the order below.', 'woocommerce' ) . '</p>';
+ $body .= $this->get_email_details_html( $data, $submitted_at );
+ $body .= '<p>' . esc_html__( 'We will review your request and contact you about next steps, including any refund due.', 'woocommerce' ) . '</p>';
+
+ return wc_mail(
+ $data[ self::FIELD_EMAIL ],
+ $subject,
+ $this->wrap_email_message( $heading, $body )
+ );
+ }
+
+ /**
+ * Send the merchant order withdrawal notification email.
+ *
+ * @param array<string,string> $data Form data.
+ * @param WC_Order|null $matched_order Matched order, if found.
+ * @param int $submitted_at Unix timestamp for the submission.
+ */
+ private function send_merchant_order_withdrawal_email( array $data, ?WC_Order $matched_order, int $submitted_at ): bool {
+ $recipient = sanitize_email( (string) get_option( 'admin_email' ) );
+
+ if ( '' === $recipient || ! is_email( $recipient ) ) {
+ return false;
+ }
+
+ $subject = sprintf(
+ /* translators: %s: order number. */
+ __( 'Order withdrawal request for order %s', 'woocommerce' ),
+ $data[ self::FIELD_ORDER_NUMBER ]
+ );
+ $heading = __( 'Order withdrawal request received', 'woocommerce' );
+ $body = '<p>' . esc_html__( 'A customer submitted an order withdrawal request.', 'woocommerce' ) . '</p>';
+
+ if ( $matched_order instanceof WC_Order ) {
+ $body .= '<p>' . esc_html__( 'WooCommerce matched this request to an order and added an order note.', 'woocommerce' ) . '</p>';
+ } else {
+ $body .= '<p>' . esc_html__( 'WooCommerce could not match this request to an order automatically, so no order note was added.', 'woocommerce' ) . '</p>';
+ }
+
+ $body .= $this->get_email_details_html( $data, $submitted_at );
+
+ if ( $matched_order instanceof WC_Order ) {
+ $order_url = $matched_order->get_edit_order_url();
+
+ $body .= sprintf(
+ '<p>%s</p>',
+ sprintf(
+ /* translators: %d: order ID. */
+ esc_html__( 'Matched order ID: %d', 'woocommerce' ),
+ $matched_order->get_id()
+ )
+ );
+
+ if ( '' !== $order_url ) {
+ $body .= sprintf(
+ '<p><a href="%1$s">%2$s</a></p>',
+ esc_url( $order_url ),
+ esc_html__( 'View matched order', 'woocommerce' )
+ );
+ }
+ }
+
+ return wc_mail(
+ $recipient,
+ $subject,
+ $this->wrap_email_message( $heading, $body ),
+ $this->get_merchant_email_headers( $data )
+ );
+ }
+
+ /**
+ * Get merchant email headers.
+ *
+ * @param array<string,string> $data Form data.
+ * @return string
+ */
+ private function get_merchant_email_headers( array $data ): string {
+ $headers = array( 'Content-Type: text/html; charset=UTF-8' );
+ $name = $this->get_customer_name( $data );
+ $email = $data[ self::FIELD_EMAIL ];
+
+ if ( '' !== $name && is_email( $email ) ) {
+ $headers[] = sprintf( 'Reply-To: %1$s <%2$s>', $name, $email );
+ }
+
+ return implode( "\r\n", $headers );
+ }
+
+ /**
+ * Wrap an email body in the WooCommerce email template.
+ *
+ * @param string $heading Email heading.
+ * @param string $body Email body.
+ */
+ private function wrap_email_message( string $heading, string $body ): string {
+ return WC()->mailer()->wrap_message( $heading, $body );
+ }
+
+ /**
+ * Get the email details list.
+ *
+ * @param array<string,string> $data Form data.
+ * @param int $submitted_at Unix timestamp for the submission.
+ */
+ private function get_email_details_html( array $data, int $submitted_at ): string {
+ $date_format = (string) get_option( 'date_format' );
+ $time_format = (string) get_option( 'time_format' );
+ $additional_details = '' === $data[ self::FIELD_ADDITIONAL_DETAILS ] ? __( 'None provided', 'woocommerce' ) : $data[ self::FIELD_ADDITIONAL_DETAILS ];
+ $submitted_at_text = wp_date( trim( $date_format . ' ' . $time_format ), $submitted_at );
+
+ if ( false === $submitted_at_text ) {
+ $submitted_at_text = '';
+ }
+
+ $rows = array(
+ __( 'Submitted', 'woocommerce' ) => $submitted_at_text,
+ __( 'Name', 'woocommerce' ) => $this->get_customer_name( $data ),
+ __( 'Email address', 'woocommerce' ) => $data[ self::FIELD_EMAIL ],
+ __( 'Order number', 'woocommerce' ) => $data[ self::FIELD_ORDER_NUMBER ],
+ __( 'Withdrawing', 'woocommerce' ) => $this->get_withdrawal_type_label( $data[ self::FIELD_WITHDRAWAL_TYPE ] ),
+ __( 'Additional details', 'woocommerce' ) => $additional_details,
+ );
+
+ $html = '<ul>';
+
+ foreach ( $rows as $label => $value ) {
+ $html .= sprintf(
+ '<li><strong>%1$s:</strong> %2$s</li>',
+ esc_html( $label ),
+ nl2br( esc_html( $value ) )
+ );
+ }
+
+ $html .= '</ul>';
+
+ return $html;
+ }
+
+ /**
+ * Get the customer's full name for display.
+ *
+ * @param array<string,string> $data Form data.
+ */
+ private function get_customer_name( array $data ): string {
+ return trim( $data[ self::FIELD_FIRST_NAME ] . ' ' . $data[ self::FIELD_LAST_NAME ] );
+ }
+
+ /**
+ * Get the label for a withdrawal type value.
+ *
+ * @param string $withdrawal_type Withdrawal type value.
+ */
+ private function get_withdrawal_type_label( string $withdrawal_type ): string {
+ $options = array(
+ self::WITHDRAWAL_TYPE_FULL => __( 'The full order', 'woocommerce' ),
+ self::WITHDRAWAL_TYPE_SPECIFIC => __( 'Specific items only', 'woocommerce' ),
+ );
+
+ return $options[ $withdrawal_type ] ?? '';
+ }
+
+ /**
+ * Log an email failure.
+ *
+ * @param Throwable|string $error Email error.
+ */
+ private function log_email_error( $error ): void {
+ $message = $error instanceof Throwable ? $error->getMessage() : $error;
+
+ wc_get_logger()->warning(
+ sprintf( 'Order withdrawal email failed: %s', $message ),
+ array( 'source' => self::LOGGER_SOURCE )
+ );
+ }
+
+ /**
+ * Log an order note failure without failing the submission.
+ *
+ * @param WC_Order $order Matched order.
+ * @param Throwable|null $e Order note error.
+ */
+ private function log_order_note_error( WC_Order $order, ?Throwable $e = null ): void {
+ $message = sprintf( 'Order withdrawal note could not be added to order %d.', $order->get_id() );
+
+ if ( $e instanceof Throwable ) {
+ $message .= sprintf( ' Error: %s', $e->getMessage() );
+ }
+
+ wc_get_logger()->warning(
+ $message,
+ array( 'source' => self::LOGGER_SOURCE )
+ );
+ }
+
+ /**
+ * Log an order meta failure without failing the submission.
+ *
+ * @param WC_Order $order Matched order.
+ * @param Throwable $e Order meta error.
+ */
+ private function log_order_meta_error( WC_Order $order, Throwable $e ): void {
+ wc_get_logger()->warning(
+ sprintf(
+ 'Order withdrawal meta flag could not be added to order %1$d. Error: %2$s',
+ $order->get_id(),
+ $e->getMessage()
+ ),
+ array( 'source' => self::LOGGER_SOURCE )
+ );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
index 263263eacce..c5db4cd17fa 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderWithdrawal/OrderWithdrawalTest.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Tests\Internal\OrderWithdrawal;
use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormProcessor;
use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormState;
use Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalFormView;
+use WC_Order;
use WC_Unit_Test_Case;
/**
@@ -13,10 +14,11 @@ use WC_Unit_Test_Case;
*/
class OrderWithdrawalTest extends WC_Unit_Test_Case {
- private const FEATURE_OPTION = 'woocommerce_feature_order_withdrawal_enabled';
- private const ENDPOINT_OPTION = 'woocommerce_myaccount_order_withdrawal_endpoint';
- private const FLUSH_QUEUE_OPTION = 'woocommerce_queue_flush_rewrite_rules';
- private const MISSING_OPTION_MARK = '__woocommerce_order_withdrawal_missing_option__';
+ private const FEATURE_OPTION = 'woocommerce_feature_order_withdrawal_enabled';
+ private const ENDPOINT_OPTION = 'woocommerce_myaccount_order_withdrawal_endpoint';
+ private const FLUSH_QUEUE_OPTION = 'woocommerce_queue_flush_rewrite_rules';
+ private const MISSING_OPTION_MARK = '__woocommerce_order_withdrawal_missing_option__';
+ private const ORDER_WITHDRAWAL_REQUESTED_META_KEY = '_order_withdrawal_requested';
/**
* The System Under Test.
@@ -74,6 +76,13 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
*/
private $original_flush_queue_option;
+ /**
+ * Created order IDs.
+ *
+ * @var int[]
+ */
+ private array $created_order_ids = array();
+
/**
* Set up test fixtures.
*/
@@ -117,6 +126,7 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
$this->restore_option( self::ENDPOINT_OPTION, $this->original_endpoint_option );
$this->restore_option( self::FLUSH_QUEUE_OPTION, $this->original_flush_queue_option );
wc_clear_notices();
+ $this->delete_created_orders();
WC()->session = $this->original_session;
parent::tearDown();
@@ -159,9 +169,267 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
*/
public function provide_valid_submission_actions(): array {
return array(
- 'review action' => array( OrderWithdrawalFormProcessor::ACTION_REVIEW, 'review' ),
- 'confirm action' => array( OrderWithdrawalFormProcessor::ACTION_CONFIRM, 'confirmation' ),
+ 'review action' => array( OrderWithdrawalFormProcessor::ACTION_REVIEW, 'review' ),
+ );
+ }
+
+ /**
+ * @testdox Should add an order note and send emails when a confirmed submission matches an order exactly.
+ */
+ public function test_process_current_request_adds_note_and_sends_emails_for_exact_order_match(): void {
+ $order = $this->create_order_for_form_data();
+ $capture = $this->capture_wp_mail();
+
+ try {
+ $this->prepare_post_request(
+ OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+ array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => (string) $order->get_id() )
+ );
+
+ $state = $this->sut->process_current_request();
+
+ $this->assertSame( 'confirmation', $state->screen, 'Matched confirm submissions should reach the confirmation screen.' );
+ $this->assertCount( 2, $capture['captures'], 'The customer and merchant emails should both be sent.' );
+ $this->assert_mail_sent_to( 'jane@example.test', $capture['captures'] );
+ $this->assert_mail_sent_to( (string) get_option( 'admin_email' ), $capture['captures'] );
+ $merchant_email = $this->get_captured_mail_to( (string) get_option( 'admin_email' ), $capture['captures'] );
+ $this->assertStringContainsString( str_replace( '&', '&', $order->get_edit_order_url() ), (string) $merchant_email['message'], 'The merchant email should link to the matched order.' );
+ $this->assertStringContainsString( 'View matched order', (string) $merchant_email['message'], 'The merchant email should include clear link text for the matched order.' );
+ $this->assertTrue( $this->order_has_note_containing( $order, 'Order withdrawal requested by Jane Doe (jane@example.test).' ), 'The matched order should receive a withdrawal note.' );
+ $this->assertTrue( $this->order_has_note_containing( $order, 'Items requested for withdrawal: Line item 1' ), 'Specific-item details should be included in the order note.' );
+ $this->assert_order_withdrawal_requested( $order );
+ } finally {
+ $capture['remove']();
+ }
+ }
+
+ /**
+ * @testdox Should match orders by custom order number instead of assuming the order number is the ID.
+ */
+ public function test_process_current_request_matches_custom_order_number(): void {
+ $order = $this->create_order_for_form_data();
+ $custom_order_number = 'CUSTOM-1001';
+ $capture = $this->capture_wp_mail();
+ $filter = static function ( $order_number, $filtered_order ) use ( $order, $custom_order_number ) {
+ if ( $filtered_order instanceof WC_Order && $order->get_id() === $filtered_order->get_id() ) {
+ return $custom_order_number;
+ }
+
+ return $order_number;
+ };
+
+ add_filter( 'woocommerce_order_number', $filter, 10, 2 );
+
+ try {
+ $this->prepare_post_request(
+ OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+ array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => $custom_order_number )
+ );
+
+ $state = $this->sut->process_current_request();
+
+ $this->assertSame( 'confirmation', $state->screen, 'Custom order number submissions should reach the confirmation screen.' );
+ $this->assertCount( 2, $capture['captures'], 'The customer and merchant emails should both be sent.' );
+ $this->assertTrue( $this->order_has_note_containing( $order, 'Order withdrawal requested by Jane Doe (jane@example.test).' ), 'The custom-number matched order should receive a withdrawal note.' );
+ $this->assert_order_withdrawal_requested( $order );
+ } finally {
+ remove_filter( 'woocommerce_order_number', $filter, 10 );
+ $capture['remove']();
+ }
+ }
+
+ /**
+ * @testdox Should match normalized submitted order numbers only to the intended order.
+ */
+ public function test_process_current_request_matches_normalized_order_number_to_intended_order(): void {
+ $target_order = $this->create_order_for_form_data();
+ $wrong_order = $this->create_order_for_form_data();
+ $capture = $this->capture_wp_mail();
+
+ try {
+ $this->prepare_post_request(
+ OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+ array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => '#' . $target_order->get_id() )
+ );
+
+ $state = $this->sut->process_current_request();
+ $merchant_email = $this->get_captured_mail_to( (string) get_option( 'admin_email' ), $capture['captures'] );
+
+ $this->assertSame( 'confirmation', $state->screen, 'Normalized order number submissions should reach the confirmation screen.' );
+ $this->assertCount( 2, $capture['captures'], 'The customer and merchant emails should both be sent.' );
+ $this->assertStringContainsString( str_replace( '&', '&', $target_order->get_edit_order_url() ), (string) $merchant_email['message'], 'The merchant email should link to the intended order.' );
+ $this->assertStringNotContainsString( str_replace( '&', '&', $wrong_order->get_edit_order_url() ), (string) $merchant_email['message'], 'The merchant email should not link to the wrong order.' );
+ $this->assertTrue( $this->order_has_note_containing( $target_order, 'Order withdrawal requested by Jane Doe (jane@example.test).' ), 'The intended order should receive a withdrawal note.' );
+ $this->assertFalse( $this->order_has_note_containing( $wrong_order, 'Order withdrawal requested' ), 'The wrong order should not receive a withdrawal note.' );
+ $this->assert_order_withdrawal_requested( $target_order );
+ } finally {
+ $capture['remove']();
+ }
+ }
+
+ /**
+ * @testdox Should not match an order that shares the email but has a different billing name.
+ */
+ public function test_process_current_request_matches_only_order_with_same_email_and_billing_name(): void {
+ $target_order = $this->create_order_for_form_data();
+ $different_name_order = $this->create_order_for_form_data(
+ array(
+ OrderWithdrawalFormProcessor::FIELD_FIRST_NAME => 'Janet',
+ OrderWithdrawalFormProcessor::FIELD_LAST_NAME => 'Smith',
+ )
);
+ $custom_order_number = 'CUSTOM-1001';
+ $capture = $this->capture_wp_mail();
+ $filter = static function ( $order_number, $filtered_order ) use ( $target_order, $different_name_order, $custom_order_number ) {
+ if (
+ $filtered_order instanceof WC_Order
+ && in_array( $filtered_order->get_id(), array( $target_order->get_id(), $different_name_order->get_id() ), true )
+ ) {
+ return $custom_order_number;
+ }
+
+ return $order_number;
+ };
+
+ add_filter( 'woocommerce_order_number', $filter, 10, 2 );
+
+ try {
+ $this->prepare_post_request(
+ OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+ array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => $custom_order_number )
+ );
+
+ $state = $this->sut->process_current_request();
+ $merchant_email = $this->get_captured_mail_to( (string) get_option( 'admin_email' ), $capture['captures'] );
+
+ $this->assertSame( 'confirmation', $state->screen, 'Matching submissions should reach the confirmation screen.' );
+ $this->assertCount( 2, $capture['captures'], 'The customer and merchant emails should both be sent.' );
+ $this->assertStringContainsString( str_replace( '&', '&', $target_order->get_edit_order_url() ), (string) $merchant_email['message'], 'The merchant email should link to the intended order.' );
+ $this->assertStringNotContainsString( str_replace( '&', '&', $different_name_order->get_edit_order_url() ), (string) $merchant_email['message'], 'The merchant email should not link to the order with a different billing name.' );
+ $this->assertTrue( $this->order_has_note_containing( $target_order, 'Order withdrawal requested by Jane Doe (jane@example.test).' ), 'The intended order should receive a withdrawal note.' );
+ $this->assertFalse( $this->order_has_note_containing( $different_name_order, 'Order withdrawal requested' ), 'The order with a different billing name should not receive a withdrawal note.' );
+ $this->assert_order_withdrawal_requested( $target_order );
+ } finally {
+ remove_filter( 'woocommerce_order_number', $filter, 10 );
+ $capture['remove']();
+ }
+ }
+
+ /**
+ * @testdox Should reject duplicate withdrawal requests for an already flagged matched order.
+ */
+ public function test_process_current_request_rejects_duplicate_withdrawal_for_matched_order(): void {
+ $order = $this->create_order_for_form_data();
+ $order->update_meta_data( self::ORDER_WITHDRAWAL_REQUESTED_META_KEY, 'yes' );
+ $order->save_meta_data();
+ $capture = $this->capture_wp_mail();
+
+ try {
+ $this->prepare_post_request(
+ OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+ array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => (string) $order->get_id() )
+ );
+
+ $state = $this->sut->process_current_request();
+ $error_notices = wc_get_notices( 'error' );
+
+ $this->assertSame( 'review', $state->screen, 'Duplicate matched submissions should return to the review screen.' );
+ $this->assertCount( 1, $error_notices, 'Duplicate matched submissions should add one error notice.' );
+ $this->assertStringContainsString( 'already been submitted for this order', $error_notices[0]['notice'], 'The notice should explain that the order already has a withdrawal request.' );
+ $this->assertCount( 0, $capture['captures'], 'Duplicate matched submissions should not send notification emails.' );
+ $this->assertFalse( $this->order_has_note_containing( $order, 'Order withdrawal requested' ), 'Duplicate matched submissions should not add another order note.' );
+ } finally {
+ $capture['remove']();
+ }
+ }
+
+ /**
+ * @testdox Should submit and send emails when adding the matched order note fails.
+ */
+ public function test_process_current_request_treats_order_note_failure_as_best_effort(): void {
+ $order = $this->create_order_for_form_data();
+ $capture = $this->capture_wp_mail();
+ $fail_order_note = static function ( $commentdata ) {
+ if ( is_array( $commentdata ) && 'order_note' === ( $commentdata['comment_type'] ?? '' ) ) {
+ throw new \RuntimeException( 'Order note insert failed.' );
+ }
+
+ return $commentdata;
+ };
+
+ add_filter( 'preprocess_comment', $fail_order_note, 10, 1 );
+
+ try {
+ $this->prepare_post_request(
+ OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+ array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => (string) $order->get_id() )
+ );
+
+ $state = $this->sut->process_current_request();
+
+ $this->assertSame( 'confirmation', $state->screen, 'Order note failures should not block submission.' );
+ $this->assertCount( 2, $capture['captures'], 'The customer and merchant emails should still be sent.' );
+ $this->assert_mail_sent_to( 'jane@example.test', $capture['captures'] );
+ $this->assert_mail_sent_to( (string) get_option( 'admin_email' ), $capture['captures'] );
+ $this->assert_order_withdrawal_requested( $order );
+ } finally {
+ remove_filter( 'preprocess_comment', $fail_order_note, 10 );
+ $capture['remove']();
+ }
+ }
+
+ /**
+ * @testdox Should send emails without adding a note when no exact order match is found.
+ */
+ public function test_process_current_request_sends_emails_without_note_when_order_does_not_match(): void {
+ $order = $this->create_order_for_form_data(
+ array(
+ OrderWithdrawalFormProcessor::FIELD_EMAIL => 'different@example.test',
+ OrderWithdrawalFormProcessor::FIELD_EMAIL_CONFIRMATION => 'different@example.test',
+ )
+ );
+ $capture = $this->capture_wp_mail();
+
+ try {
+ $this->prepare_post_request(
+ OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+ array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => (string) $order->get_id() )
+ );
+
+ $state = $this->sut->process_current_request();
+
+ $this->assertSame( 'confirmation', $state->screen, 'Unmatched confirm submissions should still reach the confirmation screen.' );
+ $this->assertCount( 2, $capture['captures'], 'The customer and merchant emails should both be sent even without a match.' );
+ $this->assert_mail_sent_to( 'jane@example.test', $capture['captures'] );
+ $this->assert_mail_sent_to( (string) get_option( 'admin_email' ), $capture['captures'] );
+ $this->assertFalse( $this->order_has_note_containing( $order, 'Order withdrawal requested' ), 'An order note should not be added when the identifying data does not all match.' );
+ } finally {
+ $capture['remove']();
+ }
+ }
+
+ /**
+ * @testdox Should keep the user on review with an error notice when notification emails fail.
+ */
+ public function test_process_current_request_surfaces_error_when_emails_fail(): void {
+ $capture = $this->capture_wp_mail( false );
+
+ try {
+ $this->prepare_post_request(
+ OrderWithdrawalFormProcessor::ACTION_CONFIRM,
+ array( OrderWithdrawalFormProcessor::FIELD_ORDER_NUMBER => '999999999' )
+ );
+
+ $state = $this->sut->process_current_request();
+ $error_notices = wc_get_notices( 'error' );
+
+ $this->assertSame( 'review', $state->screen, 'Email failures should keep the submitted details on the review screen.' );
+ $this->assertCount( 2, $capture['captures'], 'The processor should attempt both notification emails before surfacing the failure.' );
+ $this->assertNotEmpty( $error_notices, 'Email failures should add an error notice.' );
+ $this->assertStringContainsString( 'We could not submit your withdrawal request.', $error_notices[0]['notice'], 'The error notice should tell the user the submission did not complete.' );
+ } finally {
+ $capture['remove']();
+ }
}
/**
@@ -283,6 +551,144 @@ class OrderWithdrawalTest extends WC_Unit_Test_Case {
);
}
+ /**
+ * Create an order from the default valid form data.
+ *
+ * @param array<string,string> $field_overrides Field value overrides keyed by unprefixed field key.
+ */
+ private function create_order_for_form_data( array $field_overrides = array() ): WC_Order {
+ $data = array_merge( $this->get_valid_form_data(), $field_overrides );
+ $order = wc_create_order();
+
+ if ( ! $order instanceof WC_Order ) {
+ $this->fail( 'Expected wc_create_order() to create a WC_Order instance.' );
+ }
+
+ $order->set_billing_first_name( $data[ OrderWithdrawalFormProcessor::FIELD_FIRST_NAME ] );
+ $order->set_billing_last_name( $data[ OrderWithdrawalFormProcessor::FIELD_LAST_NAME ] );
+ $order->set_billing_email( $data[ OrderWithdrawalFormProcessor::FIELD_EMAIL ] );
+ $order->save();
+
+ $this->created_order_ids[] = $order->get_id();
+
+ return $order;
+ }
+
+ /**
+ * Capture wp_mail() calls without sending real email.
+ *
+ * @param bool $send_result Value returned to wp_mail().
+ * @return array{captures: array<int,array<string,mixed>>, remove: callable}
+ */
+ private function capture_wp_mail( bool $send_result = true ): array {
+ $captures = array();
+
+ $capture = static function ( $short_circuit, $atts ) use ( &$captures, $send_result ) {
+ unset( $short_circuit );
+ $captures[] = is_array( $atts ) ? $atts : array();
+
+ return $send_result;
+ };
+
+ add_filter( 'pre_wp_mail', $capture, 10, 2 );
+
+ $remove = static function () use ( $capture ) {
+ remove_filter( 'pre_wp_mail', $capture, 10 );
+ };
+
+ return array(
+ 'captures' => &$captures,
+ 'remove' => $remove,
+ );
+ }
+
+ /**
+ * Assert an email was sent to a recipient.
+ *
+ * @param string $recipient Recipient email.
+ * @param array<int,array<string,mixed>> $captures Captured email arguments.
+ */
+ private function assert_mail_sent_to( string $recipient, array $captures ): void {
+ $recipients = array();
+
+ foreach ( $captures as $mail ) {
+ $to = $mail['to'] ?? '';
+
+ if ( is_array( $to ) ) {
+ $recipients = array_merge( $recipients, $to );
+ } else {
+ $recipients[] = (string) $to;
+ }
+ }
+
+ $this->assertContains( $recipient, $recipients, sprintf( 'Expected an email to be sent to %s.', $recipient ) );
+ }
+
+ /**
+ * Get a captured email sent to a recipient.
+ *
+ * @param string $recipient Recipient email.
+ * @param array<int,array<string,mixed>> $captures Captured email arguments.
+ * @return array<string,mixed>
+ */
+ private function get_captured_mail_to( string $recipient, array $captures ): array {
+ foreach ( $captures as $mail ) {
+ $to = $mail['to'] ?? '';
+ $recipients = is_array( $to ) ? $to : array( (string) $to );
+
+ if ( in_array( $recipient, $recipients, true ) ) {
+ return $mail;
+ }
+ }
+
+ $this->fail( sprintf( 'Expected an email to be sent to %s.', $recipient ) );
+ }
+
+ /**
+ * Whether an order has a note containing specific text.
+ *
+ * @param WC_Order $order Order.
+ * @param string $needle Note content to search for.
+ */
+ private function order_has_note_containing( WC_Order $order, string $needle ): bool {
+ $notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+
+ foreach ( $notes as $note ) {
+ if ( false !== strpos( (string) $note->content, $needle ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Assert that an order has been flagged as having a withdrawal request.
+ *
+ * @param WC_Order $order Order.
+ */
+ private function assert_order_withdrawal_requested( WC_Order $order ): void {
+ $updated_order = wc_get_order( $order->get_id() );
+
+ $this->assertInstanceOf( WC_Order::class, $updated_order, 'The order should still exist.' );
+ $this->assertSame( 'yes', $updated_order->get_meta( self::ORDER_WITHDRAWAL_REQUESTED_META_KEY, true, 'edit' ), 'The matched order should be flagged as having a withdrawal request.' );
+ }
+
+ /**
+ * Delete orders created during a test.
+ */
+ private function delete_created_orders(): void {
+ foreach ( $this->created_order_ids as $order_id ) {
+ $order = wc_get_order( $order_id );
+
+ if ( $order instanceof WC_Order ) {
+ $order->delete( true );
+ }
+ }
+
+ $this->created_order_ids = array();
+ }
+
/**
* Disable the order withdrawal feature.
*/