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.
*/