Commit fe17b183075 for woocommerce

commit fe17b183075b8592af4f1ed181ad8a1df4bf2566
Author: Tung Du <dinhtungdu@gmail.com>
Date:   Wed Sep 30 15:58:13 2026 +0700

    Add return policy page selection and OnlineStore markup (#69002)

    * feat: expose refund policy page and link-only store markup

    * chore: add refund policy changelog entry

    * feat: label selected refund policy page in Pages list

    * test: verify refund policy page reassignment

    * test: restore settings section after reassignment check

    * perf: skip return policy lookup on other pages

    * test: verify clearing refund policy selection

    * test: verify site-scoped return policy markup on multisite

    * test: assert return policy footer hook ordering

    * chore: rerun PHPStan after trunk baseline revert

    * fix: describe all advanced page settings

    * fix: exclude commerce pages from policy search

    * test: remove synthetic translated policy scenario

    * test: remove redundant multisite policy scenario

diff --git a/plugins/woocommerce/changelog/task-issue-48401-return-policy-settings-design b/plugins/woocommerce/changelog/task-issue-48401-return-policy-settings-design
new file mode 100644
index 00000000000..4069a30a041
--- /dev/null
+++ b/plugins/woocommerce/changelog/task-issue-48401-return-policy-settings-design
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Allow stores to select a refund and returns policy page and share its link with search engines.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php b/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
index da241ff9462..ee3cf0bc856 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
@@ -897,6 +897,10 @@ class WC_Admin_Post_Types {
 			$post_states['wc_page_for_terms'] = __( 'Terms and Conditions Page', 'woocommerce' );
 		}

+		if ( wc_get_page_id( 'refund_returns' ) === $post->ID ) {
+			$post_states['wc_page_for_refund_returns'] = __( 'Refund and Returns Policy Page', 'woocommerce' );
+		}
+
 		return $post_states;
 	}

diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-advanced.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-advanced.php
index 631f3459890..fd83730a98e 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-advanced.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-advanced.php
@@ -81,7 +81,7 @@ class WC_Settings_Advanced extends WC_Settings_Page {
 			array(
 				array(
 					'title' => __( 'Page setup', 'woocommerce' ),
-					'desc'  => __( 'These pages need to be set so that WooCommerce knows where to send users to checkout.', 'woocommerce' ),
+					'desc'  => __( 'Select the pages WooCommerce uses for the cart, checkout, accounts, and store policies.', 'woocommerce' ),
 					'type'  => 'title',
 					'id'    => 'advanced_page_options',
 				),
@@ -159,6 +159,31 @@ class WC_Settings_Advanced extends WC_Settings_Page {
 					'autoload' => false,
 				),

+				array(
+					'title'    => __( 'Refund and returns policy', 'woocommerce' ),
+					'desc'     => __( 'Select the page describing your store\'s refund and returns policy. WooCommerce shares it with search engines once it is publicly available.', 'woocommerce' ),
+					'id'       => 'woocommerce_refund_returns_page_id',
+					'default'  => '',
+					'class'    => 'wc-page-search',
+					'css'      => 'min-width:300px;',
+					'type'     => 'single_select_page_with_search',
+					'args'     => array(
+						'exclude' => array_values(
+							array_filter(
+								array(
+									wc_get_page_id( 'cart' ),
+									wc_get_page_id( 'checkout' ),
+									wc_get_page_id( 'shop' ),
+									wc_get_page_id( 'myaccount' ),
+								),
+								static fn ( $page_id ) => $page_id > 0
+							)
+						),
+					),
+					'desc_tip' => true,
+					'autoload' => false,
+				),
+
 				array(
 					'type' => 'sectionend',
 					'id'   => 'advanced_page_options',
diff --git a/plugins/woocommerce/includes/class-wc-structured-data.php b/plugins/woocommerce/includes/class-wc-structured-data.php
index a064ddaf198..555a85e5c25 100644
--- a/plugins/woocommerce/includes/class-wc-structured-data.php
+++ b/plugins/woocommerce/includes/class-wc-structured-data.php
@@ -41,6 +41,7 @@ class WC_Structured_Data {
 		add_action( 'woocommerce_breadcrumb', array( $this, 'generate_breadcrumblist_data' ), 10 );
 		add_action( 'woocommerce_single_product_summary', array( $this, 'generate_product_data' ), 60 );
 		add_action( 'woocommerce_email_order_details', array( $this, 'generate_order_data' ), 20, 3 );
+		add_action( 'wp_footer', array( $this, 'generate_online_store_data' ), 9 );

 		// Output structured data.
 		add_action( 'woocommerce_email_order_details', array( $this, 'output_email_structured_data' ), 30, 3 );
@@ -102,6 +103,7 @@ class WC_Structured_Data {
 	 * 'review',
 	 * 'breadcrumblist',
 	 * 'website',
+	 * 'onlinestore',
 	 * 'order',
 	 *
 	 * @param  array $types Structured data types.
@@ -149,9 +151,12 @@ class WC_Structured_Data {
 	 * @return array
 	 */
 	protected function get_data_type_for_page() {
+		$policy_page_id = wc_get_page_id( 'refund_returns' );
+
 		$types   = array();
 		$types[] = is_shop() || is_product_category() || is_product() ? 'product' : '';
 		$types[] = is_shop() && is_front_page() ? 'website' : '';
+		$types[] = $policy_page_id > 0 && is_page( $policy_page_id ) && ! doing_action( 'woocommerce_email_order_details' ) ? 'onlinestore' : '';
 		$types[] = is_product() ? 'review' : '';
 		$types[] = 'breadcrumblist';
 		$types[] = 'order';
@@ -201,6 +206,7 @@ class WC_Structured_Data {
 	| - Review
 	| - BreadcrumbList
 	| - WebSite
+	| - OnlineStore
 	| - Order
 	|
 	| The generated data is stored into `$this->_data`.
@@ -624,6 +630,52 @@ class WC_Structured_Data {
 		$this->set_data( apply_filters( 'woocommerce_structured_data_breadcrumblist', $markup, $breadcrumbs ) );
 	}

+	/**
+	 * Generates link-only return-policy data on the selected public policy page.
+	 *
+	 * Hooked into `wp_footer` before structured data is output, so it cannot leak into order emails.
+	 *
+	 * @since 11.3.0
+	 */
+	public function generate_online_store_data(): void {
+		$page_id = wc_get_page_id( 'refund_returns' );
+		if ( $page_id <= 0 || ! is_page( $page_id ) ) {
+			return;
+		}
+
+		$page = get_post( $page_id );
+		if ( ! $page instanceof WP_Post || 'page' !== $page->post_type || 'publish' !== $page->post_status || ! is_post_publicly_viewable( $page ) || $page->post_password ) {
+			return;
+		}
+
+		$url = wc_get_page_permalink( 'refund_returns', '' );
+		if ( ! is_string( $url ) || ! in_array( wp_parse_url( $url, PHP_URL_SCHEME ), array( 'http', 'https' ), true ) || ! wp_parse_url( $url, PHP_URL_HOST ) ) {
+			return;
+		}
+
+		$markup = array(
+			'@type'                   => 'OnlineStore',
+			'name'                    => get_bloginfo( 'name' ),
+			'url'                     => home_url(),
+			'hasMerchantReturnPolicy' => array(
+				'@type'              => 'MerchantReturnPolicy',
+				'merchantReturnLink' => esc_url_raw( $url ),
+			),
+		);
+
+		/**
+		 * Filters the store and return-policy structured data on the policy page.
+		 *
+		 * @since 11.3.0
+		 * @param array   $markup Structured data for the online store.
+		 * @param WP_Post $page   Selected refund and returns policy page.
+		 */
+		$markup = apply_filters( 'woocommerce_structured_data_online_store', $markup, $page );
+		if ( is_array( $markup ) ) {
+			$this->set_data( $markup );
+		}
+	}
+
 	/**
 	 * Generates WebSite structured data.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
index 6eddb72d69d..5f7e885bc57 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
@@ -63,6 +63,30 @@ class WC_Admin_Post_Types_Test extends WC_Unit_Test_Case {
 		parent::tearDown();
 	}

+	/**
+	 * @testdox The selected return-policy page has a Pages-list label, even while it is a draft.
+	 */
+	public function test_return_policy_page_display_state(): void {
+		$page_id  = $this->factory->post->create(
+			array(
+				'post_type'   => 'page',
+				'post_status' => 'draft',
+			)
+		);
+		$other_id = $this->factory->post->create( array( 'post_type' => 'page' ) );
+		update_option( 'woocommerce_refund_returns_page_id', $page_id );
+
+		$this->assertSame(
+			array(
+				'draft'                      => 'Draft',
+				'wc_page_for_refund_returns' => 'Refund and Returns Policy Page',
+			),
+			$this->sut->add_display_post_states( array( 'draft' => 'Draft' ), get_post( $page_id ) ),
+			'The selected draft should retain its status and show the return-policy page label.'
+		);
+		$this->assertSame( array(), $this->sut->add_display_post_states( array(), get_post( $other_id ) ), 'Unrelated pages should not receive the return-policy label.' );
+	}
+
 	/**
 	 * @testdox The CPT Add Order screen leaves insertion to WordPress without redirecting or eagerly saving order metadata.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php b/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
index f228990762e..1f378fc45cc 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
@@ -714,4 +714,115 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
 			unset( $_GET['attribute_pa_size'], $_GET['attribute_pa_colour'], $_GET['attribute_pa_number'] );
 		}
 	}
+
+	/**
+	 * @testdox A selected public return-policy page emits only a link, not inferred policy facts.
+	 */
+	public function test_online_store_links_to_selected_public_return_policy_page(): void {
+		$page_id = self::factory()->post->create(
+			array(
+				'post_type'   => 'page',
+				'post_status' => 'publish',
+			)
+		);
+		update_option( 'woocommerce_refund_returns_page_id', $page_id );
+		$this->go_to( get_permalink( $page_id ) );
+
+		$this->assertSame( 9, has_action( 'wp_footer', array( $this->structured_data, 'generate_online_store_data' ) ), 'Policy data must be generated before the footer output at priority 10.' );
+		$this->assertSame( array(), $this->structured_data->get_structured_data( array( 'onlinestore' ) ), 'Policy data should not be generated before the footer (including in emails).' );
+		$this->structured_data->generate_online_store_data();
+		$this->assertSame(
+			array(
+				'@context'                => 'https://schema.org/',
+				'@type'                   => 'OnlineStore',
+				'name'                    => get_bloginfo( 'name' ),
+				'url'                     => home_url(),
+				'hasMerchantReturnPolicy' => array(
+					'@type'              => 'MerchantReturnPolicy',
+					'merchantReturnLink' => get_permalink( $page_id ),
+				),
+			),
+			$this->structured_data->get_structured_data( array( 'onlinestore' ) ),
+			'The policy page should expose a link-only OnlineStore node.'
+		);
+
+		ob_start();
+		$this->structured_data->output_structured_data();
+		$output = ob_get_clean();
+		$this->assertStringContainsString( '"merchantReturnLink":"' . get_permalink( $page_id ) . '"', $output, 'The footer should output the policy link.' );
+
+		remove_all_actions( 'woocommerce_email_order_details' );
+		add_action( 'woocommerce_email_order_details', array( $this->structured_data, 'output_email_structured_data' ), 30, 3 );
+		ob_start();
+		do_action( 'woocommerce_email_order_details', null, false, false );
+		$email = ob_get_clean();
+		$this->assertStringNotContainsString( 'OnlineStore', $email, 'The policy node must not appear in an order email, even after the footer.' );
+	}
+
+	/**
+	 * @testdox No return-policy data is generated on another page or for an unpublished, private, or password-protected page.
+	 */
+	public function test_online_store_requires_a_public_selected_page(): void {
+		$page_id  = self::factory()->post->create(
+			array(
+				'post_type'   => 'page',
+				'post_status' => 'publish',
+			)
+		);
+		$other_id = self::factory()->post->create(
+			array(
+				'post_type'   => 'page',
+				'post_status' => 'publish',
+			)
+		);
+		update_option( 'woocommerce_refund_returns_page_id', $page_id );
+		$this->go_to( get_permalink( $other_id ) );
+		$this->structured_data->generate_online_store_data();
+		$this->assertSame( array(), $this->structured_data->get_data(), 'Other pages must not advertise the policy.' );
+
+		$this->go_to( get_permalink( $page_id ) );
+		foreach ( array( 'draft', 'private' ) as $status ) {
+			wp_update_post(
+				array(
+					'ID'          => $page_id,
+					'post_status' => $status,
+				)
+			);
+			$this->structured_data->generate_online_store_data();
+			$this->assertSame( array(), $this->structured_data->get_data(), "{$status} pages must not be advertised." );
+		}
+
+		wp_update_post(
+			array(
+				'ID'            => $page_id,
+				'post_status'   => 'publish',
+				'post_password' => 'secret',
+			)
+		);
+		$this->structured_data->generate_online_store_data();
+		$this->assertSame( array(), $this->structured_data->get_data(), 'Password-protected pages must not be advertised.' );
+	}
+
+	/**
+	 * @testdox Invalid permalink filters cannot advertise a missing or unsafe URL.
+	 */
+	public function test_online_store_rejects_invalid_permalink(): void {
+		$page_id = self::factory()->post->create(
+			array(
+				'post_type'   => 'page',
+				'post_status' => 'publish',
+			)
+		);
+		update_option( 'woocommerce_refund_returns_page_id', $page_id );
+		add_filter(
+			'woocommerce_get_refund_returns_page_permalink',
+			static function () {
+				return 'javascript:alert(1)';
+			}
+		);
+		$this->go_to( get_permalink( $page_id ) );
+
+		$this->structured_data->generate_online_store_data();
+		$this->assertSame( array(), $this->structured_data->get_data(), 'Invalid URL must not produce policy markup.' );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-advanced-test.php b/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-advanced-test.php
index e480e9fee31..07fef06d21f 100644
--- a/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-advanced-test.php
+++ b/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-advanced-test.php
@@ -94,6 +94,7 @@ class WC_Settings_Advanced_Test extends WC_Settings_Unit_Test_Case {
 			'woocommerce_checkout_page_id'                 => 'single_select_page_with_search',
 			'woocommerce_myaccount_page_id'                => 'single_select_page_with_search',
 			'woocommerce_terms_page_id'                    => 'single_select_page_with_search',
+			'woocommerce_refund_returns_page_id'           => 'single_select_page_with_search',
 			'checkout_process_options'                     => array( 'title', 'sectionend' ),
 			'woocommerce_force_ssl_checkout'               => 'checkbox',
 			'woocommerce_unforce_ssl_checkout'             => 'checkbox',
@@ -121,6 +122,68 @@ class WC_Settings_Advanced_Test extends WC_Settings_Unit_Test_Case {
 		$this->assertEquals( $expected, $setting_ids_and_types );
 	}

+	/**
+	 * @testdox The return-policy page search excludes assigned commerce pages.
+	 */
+	public function test_refund_policy_page_excludes_commerce_pages(): void {
+		$page_ids = $this->factory->post->create_many( 4, array( 'post_type' => 'page' ) );
+		foreach ( array( 'cart', 'checkout', 'shop', 'myaccount' ) as $index => $role ) {
+			update_option( 'woocommerce_' . $role . '_page_id', $page_ids[ $index ] );
+		}
+
+		$settings = ( new WC_Settings_Advanced() )->get_settings_for_section( '' );
+		$by_id    = array_column( $settings, null, 'id' );
+		$this->assertSame( $page_ids, $by_id['woocommerce_refund_returns_page_id']['args']['exclude'] );
+
+		update_option( 'woocommerce_cart_page_id', '' );
+		$settings = ( new WC_Settings_Advanced() )->get_settings_for_section( '' );
+		$by_id    = array_column( $settings, null, 'id' );
+		$this->assertSame( array_slice( $page_ids, 1 ), $by_id['woocommerce_refund_returns_page_id']['args']['exclude'], 'Unset pages must not exclude unrelated page IDs.' );
+	}
+
+	/**
+	 * @testdox Saving Page setup reassigns or clears the selected refund and returns policy page.
+	 */
+	public function test_save_reassigns_refund_returns_page(): void {
+		$old_page_id = $this->factory->post->create(
+			array(
+				'post_type'   => 'page',
+				'post_status' => 'publish',
+			)
+		);
+		$new_page_id = $this->factory->post->create(
+			array(
+				'post_type'   => 'page',
+				'post_status' => 'publish',
+			)
+		);
+		update_option( 'woocommerce_refund_returns_page_id', $old_page_id );
+
+		$original_post    = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Restore the test request after saving.
+		$had_section      = array_key_exists( 'current_section', $GLOBALS );
+		$original_section = $GLOBALS['current_section'] ?? null;
+		try {
+			$GLOBALS['current_section'] = '';
+			$_POST                      = array( 'woocommerce_refund_returns_page_id' => (string) $new_page_id );
+			( new WC_Settings_Advanced() )->save();
+
+			$this->assertSame( (string) $new_page_id, get_option( 'woocommerce_refund_returns_page_id' ), 'The new selection should be saved.' );
+			$this->assertSame( $new_page_id, wc_get_page_id( 'refund_returns' ), 'Consumers should resolve the reassigned page.' );
+
+			$_POST['woocommerce_refund_returns_page_id'] = '';
+			( new WC_Settings_Advanced() )->save();
+			$this->assertSame( '', get_option( 'woocommerce_refund_returns_page_id' ), 'Clearing the selection should be saved.' );
+			$this->assertSame( -1, wc_get_page_id( 'refund_returns' ), 'Consumers should not resolve a page once selection is cleared.' );
+		} finally {
+			$_POST = $original_post;
+			if ( $had_section ) {
+				$GLOBALS['current_section'] = $original_section;
+			} else {
+				unset( $GLOBALS['current_section'] );
+			}
+		}
+	}
+
 	/**
 	 * @testdox get_settings('woocommerce_com') should return all the settings for the woocommerce_com section.
 	 */