Commit 97e40ba6c86 for woocommerce

commit 97e40ba6c868aebaa65283acc9e40551fd3a0832
Author: Jorge A. Torres <jorge.torres@automattic.com>
Date:   Tue Jul 7 16:18:35 2026 +0100

    Add `WC_Product::is_*viewable()` methods to consolidate product visibility checks (#66173)

diff --git a/plugins/woocommerce/changelog/65498-centralize-product-is-viewable b/plugins/woocommerce/changelog/65498-centralize-product-is-viewable
new file mode 100644
index 00000000000..f53a2d3b2e4
--- /dev/null
+++ b/plugins/woocommerce/changelog/65498-centralize-product-is-viewable
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add WC_Product::is_viewable() and is_publicly_viewable() to centralize product visibility checks.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
index 86dbf930778..1dccdd369ae 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
@@ -1713,6 +1713,33 @@ class WC_Product extends WC_Abstract_Legacy_Product {
 		return apply_filters( 'woocommerce_is_sold_individually', true === $this->get_sold_individually(), $this );
 	}

+	/**
+	 * Whether the current user can view this product: it is published, or they can edit it.
+	 * A variation additionally requires its parent to be viewable.
+	 *
+	 * @since 11.1.0
+	 * @return bool
+	 */
+	public function is_viewable() {
+		$parent_id = $this->get_parent_id();
+
+		return ( ProductStatus::PUBLISH === $this->get_status() || current_user_can( 'edit_post', $this->get_id() ) )
+			&& ( ! $parent_id || ProductStatus::PUBLISH === get_post_status( $parent_id ) || current_user_can( 'edit_post', $parent_id ) );
+	}
+
+	/**
+	 * Whether this product is publicly viewable: the product and its parent (if it has one) are published.
+	 *
+	 * @since 11.1.0
+	 * @return bool
+	 */
+	public function is_publicly_viewable() {
+		$parent_id = $this->get_parent_id();
+
+		return ProductStatus::PUBLISH === $this->get_status()
+			&& ( ! $parent_id || ProductStatus::PUBLISH === get_post_status( $parent_id ) );
+	}
+
 	/**
 	 * Returns whether or not the product is visible in the catalog.
 	 *
@@ -1731,20 +1758,10 @@ class WC_Product extends WC_Abstract_Legacy_Product {
 	protected function is_visible_core() {
 		$visible = CatalogVisibility::VISIBLE === $this->get_catalog_visibility() || ( is_search() && CatalogVisibility::SEARCH === $this->get_catalog_visibility() ) || ( ! is_search() && CatalogVisibility::CATALOG === $this->get_catalog_visibility() );

-		if ( ProductStatus::TRASH === $this->get_status() ) {
-			$visible = false;
-		} elseif ( ProductStatus::PUBLISH !== $this->get_status() && ! current_user_can( 'edit_post', $this->get_id() ) ) {
+		if ( ProductStatus::TRASH === $this->get_status() || ! $this->is_viewable() ) {
 			$visible = false;
 		}

-		if ( $this->get_parent_id() ) {
-			$parent_product = wc_get_product( $this->get_parent_id() );
-
-			if ( $parent_product && ProductStatus::PUBLISH !== $parent_product->get_status() && ! current_user_can( 'edit_post', $parent_product->get_id() ) ) {
-				$visible = false;
-			}
-		}
-
 		if ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) && ! $this->is_in_stock() ) {
 			$visible = false;
 		}
@@ -1765,7 +1782,7 @@ class WC_Product extends WC_Abstract_Legacy_Product {
 		 * @param bool          $purchasable Whether the product is purchasable.
 		 * @param WC_Product    $product     Product object.
 		 */
-		return apply_filters( 'woocommerce_is_purchasable', $this->exists() && ( ProductStatus::PUBLISH === $this->get_status() || current_user_can( 'edit_post', $this->get_id() ) ) && '' !== $this->get_price(), $this );
+		return apply_filters( 'woocommerce_is_purchasable', $this->exists() && $this->is_viewable() && '' !== $this->get_price(), $this );
 	}

 	/**
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index 138cb54a75d..b1bf152c1bb 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -618,7 +618,7 @@ class WC_AJAX {
 			wp_die();
 		}

-		if ( ProductStatus::PUBLISH !== $variable_product->get_status() && ! current_user_can( 'edit_post', $variable_product->get_id() ) ) {
+		if ( ! $variable_product->is_viewable() ) {
 			wp_die();
 		}

diff --git a/plugins/woocommerce/includes/class-wc-product-variation.php b/plugins/woocommerce/includes/class-wc-product-variation.php
index 8bdd58f9aaa..dff98f8ff1d 100644
--- a/plugins/woocommerce/includes/class-wc-product-variation.php
+++ b/plugins/woocommerce/includes/class-wc-product-variation.php
@@ -559,7 +559,7 @@ class WC_Product_Variation extends WC_Product_Simple {
 		 * @param bool $purchasable If the variation is purchasable.
 		 * @param object $variation The variation object.
 		 */
-		return apply_filters( 'woocommerce_variation_is_purchasable', $this->variation_is_visible() && parent::is_purchasable() && ( ProductStatus::PUBLISH === $this->parent_data['status'] || current_user_can( 'edit_post', $this->get_parent_id() ) ), $this );
+		return apply_filters( 'woocommerce_variation_is_purchasable', $this->variation_is_visible() && parent::is_purchasable(), $this );
 	}

 	/**
diff --git a/plugins/woocommerce/includes/class-wc-shortcodes.php b/plugins/woocommerce/includes/class-wc-shortcodes.php
index 39e3f9e2039..5203aedff58 100644
--- a/plugins/woocommerce/includes/class-wc-shortcodes.php
+++ b/plugins/woocommerce/includes/class-wc-shortcodes.php
@@ -575,13 +575,12 @@ class WC_Shortcodes {

 		$single_product = new WP_Query( $args );

-		if (
-			! isset( $force_rendering ) &&
-			$single_product->have_posts() &&
-			ProductStatus::PUBLISH !== $single_product->post->post_status &&
-			! current_user_can( 'read_product', $single_product->post->ID )
-		) {
-			return '';
+		if ( ! isset( $force_rendering ) && $single_product->have_posts() ) {
+			$queried_product = wc_get_product( $single_product->post->ID );
+
+			if ( $queried_product && ! $queried_product->is_viewable() ) {
+				return '';
+			}
 		}

 		$preselected_id = '0';
diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index a63fe826bc4..aec062ee748 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -9,7 +9,6 @@
  */

 use Automattic\Jetpack\Constants;
-use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Enums\CatalogVisibility;
@@ -1740,7 +1739,7 @@ function wc_products_array_filter_visible( $product ) {
  * @return bool
  */
 function wc_products_array_filter_visible_grouped( $product ) {
-	return $product && is_a( $product, 'WC_Product' ) && ( ProductStatus::PUBLISH === $product->get_status() || current_user_can( 'edit_product', $product->get_id() ) );
+	return $product && is_a( $product, 'WC_Product' ) && $product->is_viewable();
 }

 /**
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index af373fb9e2f..3b414bb09bf 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -15168,12 +15168,6 @@ parameters:
 			count: 1
 			path: includes/class-wc-shortcodes.php

-		-
-			message: '#^Cannot access property \$post_status on WP_Post\|null\.$#'
-			identifier: property.nonObject
-			count: 1
-			path: includes/class-wc-shortcodes.php
-
 		-
 			message: '#^Cannot access property \$post_type on WP_Post\|null\.$#'
 			identifier: property.nonObject
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/FeaturedProduct.php b/plugins/woocommerce/src/Blocks/BlockTypes/FeaturedProduct.php
index 0579c194ab8..dd2a4075183 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/FeaturedProduct.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/FeaturedProduct.php
@@ -2,7 +2,6 @@
 declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Blocks\BlockTypes;

-use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Enums\ProductType;

 /**
@@ -26,7 +25,7 @@ class FeaturedProduct extends FeaturedItem {
 		$id = absint( $attributes['productId'] ?? 0 );

 		$product = wc_get_product( $id );
-		if ( ! $product || ( ProductStatus::PUBLISH !== $product->get_status() && ! current_user_can( 'read_product', $id ) ) ) {
+		if ( ! $product || ! $product->is_viewable() ) {
 			return null;
 		}

diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php
index c1793638d8a..20383b34539 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php
@@ -1,7 +1,6 @@
 <?php // phpcs:ignore Generic.PHP.RequireStrictTypes.MissingDeclaration
 namespace Automattic\WooCommerce\StoreApi\Routes\V1;

-use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
 use Automattic\WooCommerce\StoreApi\Utilities\ProductLinksTrait;

@@ -83,19 +82,10 @@ class ProductsById extends AbstractRoute {
 	protected function get_route_response( \WP_REST_Request $request ) {
 		$object = wc_get_product( (int) $request['id'] );

-		if ( ! $object || 0 === $object->get_id() || ProductStatus::PUBLISH !== $object->get_status() ) {
+		if ( ! $object || 0 === $object->get_id() || ! $object->is_publicly_viewable() ) {
 			throw new RouteException( 'woocommerce_rest_product_invalid_id', __( 'Invalid product ID.', 'woocommerce' ), 404 );
 		}

-		// A variation's visibility follows its parent product.
-		if ( $object->get_parent_id() ) {
-			$parent = wc_get_product( $object->get_parent_id() );
-
-			if ( ! $parent || ProductStatus::PUBLISH !== $parent->get_status() ) {
-				throw new RouteException( 'woocommerce_rest_product_invalid_id', __( 'Invalid product ID.', 'woocommerce' ), 404 ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- REST API JSON response, not HTML.
-			}
-		}
-
 		return $this->prepare_item_for_response( $object, $request );
 	}
 }
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php
index 0296263c315..39af34dbdf1 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php
@@ -1,7 +1,6 @@
 <?php // phpcs:ignore Generic.PHP.RequireStrictTypes.MissingDeclaration
 namespace Automattic\WooCommerce\StoreApi\Routes\V1;

-use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
 use Automattic\WooCommerce\StoreApi\Utilities\ProductLinksTrait;

@@ -88,19 +87,10 @@ class ProductsBySlug extends AbstractRoute {
 			$object = $this->get_product_variation_by_slug( $slug );
 		}

-		if ( ! $object || 0 === $object->get_id() || ProductStatus::PUBLISH !== $object->get_status() ) {
+		if ( ! $object || 0 === $object->get_id() || ! $object->is_publicly_viewable() ) {
 			throw new RouteException( 'woocommerce_rest_product_invalid_slug', __( 'Invalid product slug.', 'woocommerce' ), 404 );
 		}

-		// A variation's visibility follows its parent product.
-		if ( $object->get_parent_id() ) {
-			$parent = wc_get_product( $object->get_parent_id() );
-
-			if ( ! $parent || ProductStatus::PUBLISH !== $parent->get_status() ) {
-				throw new RouteException( 'woocommerce_rest_product_invalid_slug', __( 'Invalid product slug.', 'woocommerce' ), 404 ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- REST API JSON response, not HTML.
-			}
-		}
-
 		return $this->prepare_item_for_response( $object, $request );
 	}

diff --git a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php
index 4344030499f..d95279e32b1 100644
--- a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php
+++ b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php
@@ -454,4 +454,45 @@ class WC_Abstract_Product_Test extends WC_Unit_Test_Case {

 		$this->assertSame( 'outofstock', $product->get_stock_status() );
 	}
+
+	/**
+	 * @testdox A published product is viewable by anyone, with or without capability checks.
+	 */
+	public function test_is_viewable_published_product() {
+		$product = WC_Helper_Product::create_simple_product();
+
+		wp_set_current_user( 0 );
+		$this->assertTrue( $product->is_viewable(), 'Published products are viewable when logged out.' );
+		$this->assertTrue( $product->is_publicly_viewable(), 'Published products are publicly viewable.' );
+
+		wp_set_current_user( $this->admin_user );
+		$this->assertTrue( $product->is_viewable(), 'Published products are viewable by admins.' );
+	}
+
+	/**
+	 * @testdox A non-published product is hidden from the public but viewable by users who can edit it, unless capability checks are disabled.
+	 * @testWith ["draft"]
+	 *           ["pending"]
+	 *           ["private"]
+	 * @param string $status A non-published product status.
+	 */
+	public function test_is_viewable_non_published_product( $status ) {
+		$customer = self::factory()->user->create( array( 'role' => 'customer' ) );
+		$product  = WC_Helper_Product::create_simple_product();
+		$product->set_status( $status );
+		$product->save();
+
+		wp_set_current_user( 0 );
+		$this->assertFalse( $product->is_viewable(), "A $status product is not viewable when logged out." );
+
+		wp_set_current_user( $customer );
+		$this->assertFalse( $product->is_viewable(), "A $status product is not viewable by customers." );
+
+		wp_set_current_user( $this->shop_manager_user );
+		$this->assertTrue( $product->is_viewable(), "A $status product is viewable by shop managers." );
+
+		wp_set_current_user( $this->admin_user );
+		$this->assertTrue( $product->is_viewable(), "A $status product is viewable by admins." );
+		$this->assertFalse( $product->is_publicly_viewable(), "A $status product is never publicly viewable, even for admins." );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-product-variation-test.php b/plugins/woocommerce/tests/php/includes/class-wc-product-variation-test.php
index c782c177f8e..e4c5e864e2c 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-product-variation-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-product-variation-test.php
@@ -121,4 +121,24 @@ class WC_Product_Variation_Test extends WC_Unit_Test_Case {
 		$this->assertIsString( $url );
 		$this->assertNotEmpty( $url );
 	}
+
+	/**
+	 * @testdox A variation's viewability follows its parent's status.
+	 */
+	public function test_is_viewable_variation_follows_parent_status() {
+		wp_set_current_user( 0 );
+		$this->assertTrue( $this->variation->is_viewable(), 'A variation of a published parent is viewable when logged out.' );
+		$this->assertTrue( $this->variation->is_publicly_viewable(), 'A variation of a published parent is publicly viewable.' );
+
+		$this->parent_product->set_status( 'draft' );
+		$this->parent_product->save();
+
+		wp_set_current_user( 0 );
+		$this->assertFalse( $this->variation->is_viewable(), 'A variation whose parent is a draft is not viewable when logged out.' );
+
+		$admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin );
+		$this->assertTrue( $this->variation->is_viewable(), 'A variation whose parent is a draft is viewable by admins.' );
+		$this->assertFalse( $this->variation->is_publicly_viewable(), 'A variation whose parent is a draft is never publicly viewable.' );
+	}
 }