Commit ea491b0d1e8 for woocommerce

commit ea491b0d1e82e2eab4affa92b6a618372af2a83a
Author: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
Date:   Wed Sep 2 11:07:18 2026 +0300

    Add filter for failed order confirmation text (#68221)

    * fix: Add failed order confirmation text filter

    Refs #52517

    * Give failed order text filter final control in block renderer

    The block renderer applied woocommerce_thankyou_order_failed_text first and fed
    its result into the legacy woocommerce_thankyou_order_received_text filter, whose
    return value won. Gateway callbacks that declare a single $text parameter and
    replace the message unconditionally therefore discarded merchant customizations
    made through the new filter, so the new hook silently did nothing on those stores.

    Run the legacy filter first on the untouched default and the new filter last, so
    the failure-specific hook has final say. This also restores the legacy filter's
    input to the core default for callbacks that append rather than replace, and it
    removes the divergent @param null docblock in favour of the phpcs:ignore the
    sibling call sites already use, with the PayPal hazard named inline so the null
    second argument is not "corrected" later.

    Refs #52517

    * Update changelog entry for failed order text filter precedence

    Record that __return_false on the legacy block filter now falls back to the
    default message, that __return_empty_string remains a valid suppression value,
    and that filtered output is restricted by wp_kses_post().

    Refs #52517

    * Update plugins/woocommerce/changelog/52517-failed-order-text-filter

    Co-authored-by: Daniel Mallory <daniel.mallory@automattic.com>

    * Update plugins/woocommerce/tests/php/src/Blocks/BlockTypes/OrderConfirmation/StatusTest.php

    Co-authored-by: Daniel Mallory <daniel.mallory@automattic.com>

    * Update plugins/woocommerce/tests/php/src/Blocks/BlockTypes/OrderConfirmation/StatusTest.php

    Co-authored-by: Daniel Mallory <daniel.mallory@automattic.com>

    ---------

    Co-authored-by: Daniel Mallory <daniel.mallory@automattic.com>

diff --git a/plugins/woocommerce/changelog/52517-failed-order-text-filter b/plugins/woocommerce/changelog/52517-failed-order-text-filter
new file mode 100644
index 00000000000..29030ec0dac
--- /dev/null
+++ b/plugins/woocommerce/changelog/52517-failed-order-text-filter
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Add an order-aware filter for failed order confirmation messages in classic and block renderers.
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/Status.php b/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/Status.php
index ff7524e7f8c..fbc90e02eb6 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/Status.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/Status.php
@@ -139,9 +139,28 @@ class Status extends AbstractOrderConfirmationBlock {
 				) . '</p>';
 				break;
 			case 'failed':
+				$default_order_failed_text = esc_html__( 'Your order cannot be processed as the originating bank/merchant has declined your transaction. Please attempt your purchase again.', 'woocommerce' );
+
+				// Null, not $order: passing the real order would let WC_Gateway_Paypal::order_received_text()
+				// overwrite this failure message with PayPal success copy, since that callback checks the
+				// payment method but not the order status.
 				// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
-				$order_received_text = apply_filters( 'woocommerce_thankyou_order_received_text', esc_html__( 'Your order cannot be processed as the originating bank/merchant has declined your transaction. Please attempt your purchase again.', 'woocommerce' ), null );
-				$actions             = '<a href="' . esc_url( $order->get_checkout_payment_url() ) . '" class="button">' . esc_html__( 'Try again', 'woocommerce' ) . '</a> ';
+				$legacy_order_failed_text = apply_filters( 'woocommerce_thankyou_order_received_text', $default_order_failed_text, null );
+				$order_failed_text        = is_string( $legacy_order_failed_text ) ? $legacy_order_failed_text : $default_order_failed_text;
+
+				/**
+				 * Filters the message shown when an order has failed.
+				 *
+				 * Runs after the legacy order-received filter so callbacks can customize the final failed-order message.
+				 *
+				 * @param string    $message The failed order message.
+				 * @param \WC_Order $order   The failed order.
+				 *
+				 * @since 11.2.0
+				 */
+				$filtered_order_failed_text = apply_filters( 'woocommerce_thankyou_order_failed_text', $order_failed_text, $order );
+				$order_failed_text          = is_string( $filtered_order_failed_text ) ? $filtered_order_failed_text : $order_failed_text;
+				$actions                    = '<a href="' . esc_url( $order->get_checkout_payment_url() ) . '" class="button">' . esc_html__( 'Try again', 'woocommerce' ) . '</a> ';

 				if ( wc_get_page_permalink( 'myaccount' ) ) {
 					$actions .= '<a href="' . esc_url( wc_get_page_permalink( 'myaccount' ) ) . '" class="button">' . esc_html__( 'My account', 'woocommerce' ) . '</a> ';
@@ -155,7 +174,7 @@ class Status extends AbstractOrderConfirmationBlock {
 					)
 				) . '</h1>';
 				$content .= '
-				<p>' . $order_received_text . '</p>
+				<p>' . wp_kses_post( $order_failed_text ) . '</p>
 				<p class="wc-block-order-confirmation-status__actions">' . $actions . '</p>
 			';
 				break;
diff --git a/plugins/woocommerce/templates/checkout/thankyou.php b/plugins/woocommerce/templates/checkout/thankyou.php
index 53d5191e227..b9af64aed00 100644
--- a/plugins/woocommerce/templates/checkout/thankyou.php
+++ b/plugins/woocommerce/templates/checkout/thankyou.php
@@ -12,7 +12,7 @@
  *
  * @see https://woocommerce.com/document/template-structure/
  * @package WooCommerce\Templates
- * @version 8.1.0
+ * @version 11.2.0
  *
  * @var WC_Order $order
  */
@@ -30,7 +30,22 @@ defined( 'ABSPATH' ) || exit;

 		<?php if ( $order->has_status( 'failed' ) ) : ?>

-			<p class="woocommerce-notice woocommerce-notice--error woocommerce-thankyou-order-failed"><?php esc_html_e( 'Unfortunately your order cannot be processed as the originating bank/merchant has declined your transaction. Please attempt your purchase again.', 'woocommerce' ); ?></p>
+			<?php
+			$default_order_failed_text = esc_html__( 'Unfortunately your order cannot be processed as the originating bank/merchant has declined your transaction. Please attempt your purchase again.', 'woocommerce' );
+
+			/**
+			 * Filters the message shown when an order has failed.
+			 *
+			 * @param string   $message The failed order message.
+			 * @param WC_Order $order   The failed order.
+			 *
+			 * @since 11.2.0
+			 */
+			$order_failed_text = apply_filters( 'woocommerce_thankyou_order_failed_text', $default_order_failed_text, $order );
+			$order_failed_text = is_string( $order_failed_text ) ? $order_failed_text : $default_order_failed_text;
+			?>
+
+			<p class="woocommerce-notice woocommerce-notice--error woocommerce-thankyou-order-failed"><?php echo wp_kses_post( $order_failed_text ); ?></p>

 			<p class="woocommerce-notice woocommerce-notice--error woocommerce-thankyou-order-failed-actions">
 				<a href="<?php echo esc_url( $order->get_checkout_payment_url() ); ?>" class="button pay"><?php esc_html_e( 'Pay', 'woocommerce' ); ?></a>
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/OrderConfirmation/StatusTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/OrderConfirmation/StatusTest.php
new file mode 100644
index 00000000000..4deea90017a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/OrderConfirmation/StatusTest.php
@@ -0,0 +1,275 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes\OrderConfirmation;
+
+use Automattic\WooCommerce\Blocks\BlockTypes\OrderConfirmation\Status as StatusBlock;
+use Automattic\WooCommerce\Enums\OrderStatus;
+use WC_Gateway_Paypal;
+use WC_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Order Confirmation Status block.
+ */
+class StatusTest extends WC_Unit_Test_Case {
+
+	private const BLOCK_FAILED_MESSAGE = 'Your order cannot be processed as the originating bank/merchant has declined your transaction. Please attempt your purchase again.';
+
+	/**
+	 * Reset message filters before each test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		remove_all_filters( 'woocommerce_thankyou_order_failed_text' );
+		remove_all_filters( 'woocommerce_thankyou_order_received_text' );
+	}
+
+	/**
+	 * @testdox The block filter pipeline preserves order context, legacy arguments, and new-filter precedence.
+	 */
+	public function test_filter_pipeline_preserves_context_arguments_and_precedence(): void {
+		$order                   = $this->create_failed_order();
+		$new_filter_message      = null;
+		$new_filter_order        = null;
+		$legacy_filter_arguments = array();
+		$new_filter_result       = '<strong>New failure context</strong>';
+		$legacy_filter_result    = '<a href="https://example.com/legacy">Legacy replacement</a>';
+
+		add_filter(
+			'woocommerce_thankyou_order_failed_text',
+			static function ( $message, $filtered_order ) use ( &$new_filter_message, &$new_filter_order, $new_filter_result ) {
+				$new_filter_message = $message;
+				$new_filter_order   = $filtered_order;
+				return $new_filter_result;
+			},
+			10,
+			2
+		);
+		add_filter(
+			'woocommerce_thankyou_order_received_text',
+			static function ( ...$arguments ) use ( &$legacy_filter_arguments, $legacy_filter_result ) {
+				$legacy_filter_arguments = $arguments;
+				return $legacy_filter_result;
+			},
+			10,
+			2
+		);
+
+		$html = $this->render_failed_order( $order );
+
+		$this->assertCount( 2, $legacy_filter_arguments, 'The legacy filter should continue receiving two arguments.' );
+		$this->assertSame( self::BLOCK_FAILED_MESSAGE, $legacy_filter_arguments[0], 'The legacy filter should receive the untouched block default.' );
+		$this->assertNull( $legacy_filter_arguments[1], 'The legacy filter second argument should remain null.' );
+		$this->assertSame( $legacy_filter_result, $new_filter_message, 'The new filter should receive the validated legacy-filter result.' );
+		$this->assertSame( $order, $new_filter_order, 'The new filter should receive the exact failed order.' );
+		$this->assertStringContainsString( $new_filter_result, $html, 'The new filter should retain final output control.' );
+		$this->assertStringNotContainsString( $legacy_filter_result, $html, 'The new-filter replacement should win final precedence.' );
+	}
+
+	/**
+	 * @testdox An empty new block-filter result is the final message.
+	 */
+	public function test_empty_new_filter_result_is_final(): void {
+		add_filter(
+			'woocommerce_thankyou_order_failed_text',
+			static function () {
+				return '';
+			}
+		);
+		add_filter(
+			'woocommerce_thankyou_order_received_text',
+			static function () {
+				return '<strong>Legacy replacement</strong>';
+			}
+		);
+
+		$html = $this->render_failed_order( $this->create_failed_order() );
+
+		$this->assertStringNotContainsString( '<strong>Legacy replacement</strong>', $html, 'An empty new-filter result should override the legacy replacement.' );
+		$this->assertMatchesRegularExpression(
+			'/<p><\/p>\s*<p class="wc-block-order-confirmation-status__actions">/',
+			$html,
+			'The failed-message paragraph should be empty.'
+		);
+	}
+
+	/**
+	 * @testdox An invalid new block-filter result preserves the valid legacy-filter result.
+	 */
+	public function test_invalid_new_filter_return_preserves_legacy_filter_result(): void {
+		$new_filter_message   = null;
+		$legacy_filter_result = '<a href="https://example.com/legacy">Legacy replacement</a>';
+
+		add_filter(
+			'woocommerce_thankyou_order_failed_text',
+			static function ( $message ) use ( &$new_filter_message ) {
+				$new_filter_message = $message;
+				return array( 'invalid' );
+			}
+		);
+		add_filter(
+			'woocommerce_thankyou_order_received_text',
+			static function () use ( $legacy_filter_result ) {
+				return $legacy_filter_result;
+			}
+		);
+
+		$html = $this->render_failed_order( $this->create_failed_order() );
+
+		$this->assertSame( $legacy_filter_result, $new_filter_message, 'The new filter should receive the validated legacy-filter result.' );
+		$this->assertStringContainsString( $legacy_filter_result, $html, 'An invalid new-filter return should preserve the legacy-filter result, not the block default.' );
+		$this->assertStringNotContainsString( self::BLOCK_FAILED_MESSAGE, $html, 'An invalid new-filter return should not discard valid legacy text for the block default.' );
+	}
+
+	/**
+	 * @testdox An invalid legacy block-filter result falls back to the valid new-filter result.
+	 */
+	public function test_invalid_legacy_filter_return_falls_back_to_new_filter_result(): void {
+		$new_filter_result = '<strong>New failure context</strong>';
+
+		add_filter(
+			'woocommerce_thankyou_order_failed_text',
+			static function () use ( $new_filter_result ) {
+				return $new_filter_result;
+			}
+		);
+		add_filter(
+			'woocommerce_thankyou_order_received_text',
+			static function () {
+				return new \stdClass();
+			}
+		);
+
+		$html = $this->render_failed_order( $this->create_failed_order() );
+
+		$this->assertStringContainsString( $new_filter_result, $html, 'An invalid legacy return should restore the valid new-filter result.' );
+	}
+
+	/**
+	 * @testdox An empty legacy block-filter result remains the final message when no new-filter callback runs.
+	 */
+	public function test_empty_legacy_filter_result_remains_empty(): void {
+		add_filter(
+			'woocommerce_thankyou_order_received_text',
+			static function () {
+				return '';
+			}
+		);
+
+		$html = $this->render_failed_order( $this->create_failed_order() );
+
+		$this->assertMatchesRegularExpression(
+			'/<p><\/p>\s*<p class="wc-block-order-confirmation-status__actions">/',
+			$html,
+			'The failed-message paragraph should remain empty.'
+		);
+	}
+
+	/**
+	 * @testdox The new filter overrides a single-argument legacy callback that replaces the message unconditionally.
+	 */
+	public function test_single_argument_legacy_replacer_is_overridden_by_new_filter(): void {
+		$new_filter_result    = '<strong>Merchant failure context</strong>';
+		$legacy_filter_result = '<strong>Unconditional legacy replacement</strong>';
+
+		add_filter(
+			'woocommerce_thankyou_order_failed_text',
+			static function () use ( $new_filter_result ) {
+				return $new_filter_result;
+			}
+		);
+		add_filter(
+			'woocommerce_thankyou_order_received_text',
+			static function ( $text ) use ( $legacy_filter_result ) {
+				unset( $text );
+				return $legacy_filter_result;
+			},
+			11
+		);
+
+		$html = $this->render_failed_order( $this->create_failed_order() );
+
+		$this->assertStringContainsString( $new_filter_result, $html, 'The new filter should override an unconditional single-argument legacy replacer.' );
+		$this->assertStringNotContainsString( $legacy_filter_result, $html, 'The unconditional legacy replacement should not survive the new filter.' );
+	}
+
+	/**
+	 * @testdox The final block message keeps safe post HTML and removes unsafe markup.
+	 */
+	public function test_final_message_preserves_safe_post_html_and_removes_unsafe_markup(): void {
+		add_filter(
+			'woocommerce_thankyou_order_received_text',
+			static function () {
+				return '<strong>Retry</strong> or <a href="https://example.com/help" onclick="alert(1)">contact support</a><script>alert(1)</script>';
+			}
+		);
+
+		$html = $this->render_failed_order( $this->create_failed_order() );
+
+		$this->assertStringContainsString( '<strong>Retry</strong>', $html, 'Safe emphasis should remain.' );
+		$this->assertStringContainsString( '<a href="https://example.com/help">contact support</a>', $html, 'A safe inline link should remain.' );
+		$this->assertStringNotContainsString( 'onclick=', $html, 'Event handler attributes should be removed.' );
+		$this->assertStringNotContainsString( '<script', $html, 'Script elements should be removed.' );
+	}
+
+	/**
+	 * @testdox The PayPal success callback preserves the new failed-order result in the block pipeline.
+	 */
+	public function test_paypal_success_callback_does_not_replace_block_failed_message(): void {
+		$new_filter_result = '<strong>Payment failed</strong>';
+		$paypal_gateway    = new WC_Gateway_Paypal();
+
+		add_filter(
+			'woocommerce_thankyou_order_failed_text',
+			static function () use ( $new_filter_result ) {
+				return $new_filter_result;
+			}
+		);
+		add_filter( 'woocommerce_thankyou_order_received_text', array( $paypal_gateway, 'order_received_text' ), 10, 2 );
+
+		$html = $this->render_failed_order( $this->create_failed_order( WC_Gateway_Paypal::ID ) );
+
+		$this->assertStringContainsString( $new_filter_result, $html, 'The PayPal callback should pass through the failure-specific result when its order argument is null.' );
+	}
+
+	/**
+	 * Create a failed order for a block test.
+	 *
+	 * @param string $payment_method Payment method ID.
+	 * @return WC_Order
+	 */
+	private function create_failed_order( string $payment_method = '' ): WC_Order {
+		$order = new WC_Order();
+		$order->set_status( OrderStatus::FAILED );
+
+		if ( '' !== $payment_method ) {
+			$order->set_payment_method( $payment_method );
+		}
+
+		$order->save();
+		return $order;
+	}
+
+	/**
+	 * Render a failed order with full permissions.
+	 *
+	 * @param WC_Order $order Order to render.
+	 * @return string
+	 */
+	private function render_failed_order( WC_Order $order ): string {
+		$sut = new class() extends StatusBlock {
+			// phpcs:ignore Squiz.Commenting.FunctionComment.Missing
+			public function __construct() {
+			}
+
+			// phpcs:ignore Squiz.Commenting.FunctionComment.Missing
+			public function render_content_proxy( $order, $permission ) {
+				return $this->render_content( $order, $permission );
+			}
+		};
+
+		return $sut->render_content_proxy( $order, 'full' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/templates/checkout/ThankyouTemplateTest.php b/plugins/woocommerce/tests/php/templates/checkout/ThankyouTemplateTest.php
new file mode 100644
index 00000000000..b9fa60c82ea
--- /dev/null
+++ b/plugins/woocommerce/tests/php/templates/checkout/ThankyouTemplateTest.php
@@ -0,0 +1,141 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Templates\Checkout;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use WC_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the checkout thankyou template.
+ */
+class ThankyouTemplateTest extends WC_Unit_Test_Case {
+
+	private const CLASSIC_FAILED_MESSAGE = 'Unfortunately your order cannot be processed as the originating bank/merchant has declined your transaction. Please attempt your purchase again.';
+
+	/**
+	 * @testdox The classic failure filter receives the exact default and order, then replaces the message.
+	 */
+	public function test_failed_message_filter_replaces_text_and_receives_order(): void {
+		$order            = $this->create_failed_order();
+		$received_message = null;
+		$received_order   = null;
+
+		add_filter(
+			'woocommerce_thankyou_order_failed_text',
+			static function ( $message, $filtered_order ) use ( &$received_message, &$received_order ) {
+				$received_message = $message;
+				$received_order   = $filtered_order;
+				return '<strong>Use another payment method.</strong>';
+			},
+			10,
+			2
+		);
+
+		$html = $this->render_thankyou_template( $order );
+
+		$this->assertSame( self::CLASSIC_FAILED_MESSAGE, $received_message, 'The failure filter should receive the exact classic default.' );
+		$this->assertSame( $order, $received_order, 'The failure filter should receive the exact order instance.' );
+		$this->assertStringContainsString( '<strong>Use another payment method.</strong>', $html, 'The filtered message should render.' );
+		$this->assertStringNotContainsString( self::CLASSIC_FAILED_MESSAGE, $html, 'The filtered message should replace the default.' );
+	}
+
+	/**
+	 * @testdox An empty classic failure-filter result remains empty.
+	 */
+	public function test_empty_failed_message_filter_return_remains_empty(): void {
+		add_filter(
+			'woocommerce_thankyou_order_failed_text',
+			static function () {
+				return '';
+			}
+		);
+
+		$html = $this->render_thankyou_template( $this->create_failed_order() );
+
+		$this->assertStringContainsString(
+			'<p class="woocommerce-notice woocommerce-notice--error woocommerce-thankyou-order-failed"></p>',
+			$html,
+			'The failed-message element should remain empty.'
+		);
+	}
+
+	/**
+	 * @testdox A non-string classic failure-filter result falls back to the default message.
+	 */
+	public function test_invalid_failed_message_filter_return_falls_back_to_default(): void {
+		add_filter(
+			'woocommerce_thankyou_order_failed_text',
+			static function () {
+				return array( 'invalid' );
+			}
+		);
+
+		$html = $this->render_thankyou_template( $this->create_failed_order() );
+
+		$this->assertStringContainsString( self::CLASSIC_FAILED_MESSAGE, $html, 'An invalid filter return should restore the classic default.' );
+	}
+
+	/**
+	 * @testdox The classic failed message keeps safe post HTML and removes unsafe markup.
+	 */
+	public function test_failed_message_preserves_safe_post_html_and_removes_unsafe_markup(): void {
+		add_filter(
+			'woocommerce_thankyou_order_failed_text',
+			static function () {
+				return '<strong>Retry</strong> or <a href="https://example.com/help" onclick="alert(1)">contact support</a><script>alert(1)</script>';
+			}
+		);
+
+		$html = $this->render_thankyou_template( $this->create_failed_order() );
+
+		$this->assertStringContainsString( '<strong>Retry</strong>', $html, 'Safe emphasis should remain.' );
+		$this->assertStringContainsString( '<a href="https://example.com/help">contact support</a>', $html, 'A safe inline link should remain.' );
+		$this->assertStringNotContainsString( 'onclick=', $html, 'Event handler attributes should be removed.' );
+		$this->assertStringNotContainsString( '<script', $html, 'Script elements should be removed.' );
+	}
+
+	/**
+	 * @testdox The classic failed template never invokes the legacy success-oriented message filter.
+	 */
+	public function test_failed_template_does_not_invoke_legacy_received_text_filter(): void {
+		$legacy_filter_calls = 0;
+
+		add_filter(
+			'woocommerce_thankyou_order_received_text',
+			static function () use ( &$legacy_filter_calls ) {
+				++$legacy_filter_calls;
+				return 'Unexpected success copy';
+			},
+			10,
+			2
+		);
+
+		$this->render_thankyou_template( $this->create_failed_order() );
+
+		$this->assertSame( 0, $legacy_filter_calls, 'The legacy filter should not run in the classic failed branch.' );
+	}
+
+	/**
+	 * Create a failed order for a template test.
+	 *
+	 * @return WC_Order
+	 */
+	private function create_failed_order(): WC_Order {
+		$order = new WC_Order();
+		$order->set_status( OrderStatus::FAILED );
+		$order->save();
+		return $order;
+	}
+
+	/**
+	 * Render the classic thankyou template.
+	 *
+	 * @param WC_Order $order Order to render.
+	 * @return string
+	 */
+	private function render_thankyou_template( WC_Order $order ): string {
+		return wc_get_template_html( 'checkout/thankyou.php', array( 'order' => $order ) );
+	}
+}