Commit fcbb5dfb97 for woocommerce
commit fcbb5dfb97a6120253084e8cc95f233c761926a3
Author: malinajirka <malinajirka@gmail.com>
Date: Fri Jan 9 13:15:17 2026 +0100
Add support for hiding products in the Point of Sale (#62534)
* Add taxonomy indicating whether product should be hidden in POS
* Add pos visibility to product detail
* Move pos visibility from product overview to advanced
* Filter out products hidden in pos from generated catalog
* Remove todo
* Hide POS Taxonomy settings when POS flag disabled
* Remove support for variations in POS product visibility taxonomy
* Make pos product visibility taxonomy private
* Add tests for changes in /products endpoint
* Rename pos_visible to visible_in_pos for clarity
* Fix linting errors
* Add changelog entry - add ability to hide products in POS
* Suppress lint warning about slow query
* Use product_object->get_id() to get product id
* Update phpstan baseline
* Escape visible_in_pos query param
* Update text for visible in POS toggle
* Hide label description behind the tip icon
* Do not change pos visibility when field not present
* Fix version in @since comment
* Update query_var for pos product visibility taxonomy
* Filter out variations with hidden parent from catalog feed
* Rename visible_in_pos to pos_products_only and simplify behavior
The visible_in_pos parameter was confusing because setting it to false
returned only hidden products rather than all products. The new
pos_products_only parameter has clearer semantics:
- pos_products_only=true: return only POS-visible products
- pos_products_only=false or omitted: return all products (no filtering)
This makes the API more intuitive since false now means "don't apply
this filter" rather than "show hidden products only".
* Update plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
Co-authored-by: Radoslav Georgiev <rageorgiev@gmail.com>
* Remove misleading outdated comment
* Fix lint/formatting in class-wc-meta-box-product-data.php
* Sync pos-hidden taxonomy to variations for accurate catalog totals
When a variable product is hidden from POS, its variations should also be
excluded from the catalog feed and counted correctly in the total.
Previously, the pos_product_visibility taxonomy was only applied to parent
products, and variations were filtered out during feed generation via
FeedValidator. This caused the catalog endpoint to return incorrect totals
since variations were counted in the query but filtered out later.
This change:
- Registers pos_product_visibility taxonomy for product_variation post type
- Introduces POSProductVisibilitySync class to centralize visibility logic
- Syncs pos-hidden term to all variations when parent visibility changes
- Inherits pos-hidden term when new variations are created
- Removes FeedValidator variation check (no longer needed)
Now the tax_query correctly filters both products and variations at query time,
ensuring accurate totals in the catalog API response.
* Add pos_products_only filter to variations REST API endpoint
Add the pos_products_only parameter to the /variations and /products/<id>/variations endpoints to filter out variations with the pos-hidden term, matching the existing implementation in the products endpoint.
Changes:
- Add pos_products_only boolean parameter to get_collection_params()
- Add tax_query filter in prepare_objects_query() to exclude pos-hidden variations
- Add tests for the new filter behavior
This enables POS clients to fetch only variations visible in Point of Sale when querying the variations endpoint with pos_products_only=true.
* Guard POS visibility save logic with feature flag check
When the POS feature is disabled, the _visible_in_pos checkbox is not rendered in the admin product edit UI. However, the save logic was executing unconditionally. This caused $_POST['_visible_in_pos'] to be unset, resulting in $visible_in_pos becoming false and unintentionally hiding products from POS.
This fix wraps the POS visibility save logic with a feature flag check, matching the UI logic that also checks feature_is_enabled('point_of_sale') before rendering the checkbox. This prevents data loss when editing products while the POS feature is disabled.
* Fix lint issues in class-wc-rest-product-variations-controller-tests.php
* Add tests for POSProductVisibilitySync.php
---------
Co-authored-by: Radoslav Georgiev <rageorgiev@gmail.com>
diff --git a/plugins/woocommerce/changelog/issue-woomob-1894-add-pos-visibility-taxonomy-to-core b/plugins/woocommerce/changelog/issue-woomob-1894-add-pos-visibility-taxonomy-to-core
new file mode 100644
index 0000000000..05a79ecf07
--- /dev/null
+++ b/plugins/woocommerce/changelog/issue-woomob-1894-add-pos-visibility-taxonomy-to-core
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add ability to hide products from Point of Sale.
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
index 247417d998..ac92a1bf23 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
@@ -11,6 +11,8 @@
use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\POSProductVisibilitySync;
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
if ( ! defined( 'ABSPATH' ) ) {
exit;
@@ -427,6 +429,11 @@ class WC_Meta_Box_Product_Data {
// Remove _product_template_id for products that were created with the new product editor.
$product->delete_meta_data( '_product_template_id' );
+ if ( FeaturesUtil::feature_is_enabled( 'point_of_sale' ) ) {
+ $visible_in_pos = isset( $_POST['_visible_in_pos'] ) && 'yes' === wc_clean( wp_unslash( $_POST['_visible_in_pos'] ) );
+ wc_get_container()->get( POSProductVisibilitySync::class )->set_product_pos_visibility( $post_id, $visible_in_pos );
+ }
+
/**
* Set props before save.
*
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-advanced.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-advanced.php
index e17130aa96..08e98bbc05 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-advanced.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-advanced.php
@@ -1,4 +1,6 @@
<?php
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
@@ -52,6 +54,22 @@ if ( ! defined( 'ABSPATH' ) ) {
?>
</div>
<?php endif; ?>
+ <?php if ( FeaturesUtil::feature_is_enabled( 'point_of_sale' ) ) : ?>
+ <div class="options_group">
+ <?php
+ $visible_in_pos = ! has_term( 'pos-hidden', 'pos_product_visibility', $product_object->get_id() );
+ woocommerce_wp_checkbox(
+ array(
+ 'id' => '_visible_in_pos',
+ 'value' => $visible_in_pos ? 'yes' : 'no',
+ 'label' => __( 'Available for POS', 'woocommerce' ),
+ 'desc_tip' => true,
+ 'description' => __( 'Controls whether this product appears in the Point of Sale system.', 'woocommerce' ),
+ )
+ );
+ ?>
+ </div>
+ <?php endif; ?>
<?php do_action( 'woocommerce_product_options_advanced' ); ?>
</div>
diff --git a/plugins/woocommerce/includes/class-wc-post-types.php b/plugins/woocommerce/includes/class-wc-post-types.php
index bb8ff160e6..3f4986a226 100644
--- a/plugins/woocommerce/includes/class-wc-post-types.php
+++ b/plugins/woocommerce/includes/class-wc-post-types.php
@@ -212,6 +212,35 @@ class WC_Post_Types {
)
);
+ register_taxonomy(
+ 'pos_product_visibility',
+ /**
+ * Filter the post types that the POS product visibility taxonomy is attached to.
+ *
+ * @since 10.5.0
+ * @param array $post_types Array of post types.
+ */
+ apply_filters( 'woocommerce_taxonomy_objects_pos_product_visibility', array( 'product', 'product_variation' ) ),
+ /**
+ * Filter the arguments for the POS product visibility taxonomy.
+ *
+ * @since 10.5.0
+ * @param array $args Array of taxonomy arguments.
+ */
+ apply_filters(
+ 'woocommerce_taxonomy_args_pos_product_visibility',
+ array(
+ 'hierarchical' => false,
+ 'show_ui' => false,
+ 'show_in_nav_menus' => false,
+ 'query_var' => false,
+ 'rewrite' => false,
+ 'public' => false,
+ 'label' => _x( 'POS Product visibility', 'Taxonomy name', 'woocommerce' ),
+ )
+ )
+ );
+
global $wc_product_attributes;
$wc_product_attributes = array();
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
index 3533f85cbf..89ea057c8b 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
@@ -1102,6 +1102,16 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
);
}
+ // Filter by visibility in POS.
+ if ( true === $request['pos_products_only'] ) {
+ $args['tax_query'][] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+ 'taxonomy' => 'pos_product_visibility',
+ 'field' => 'slug',
+ 'terms' => 'pos-hidden',
+ 'operator' => 'NOT IN',
+ );
+ }
+
$args['post_parent'] = $request['product_id'];
return $args;
@@ -1223,6 +1233,13 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
'validate_callback' => 'rest_validate_request_arg',
);
+ $params['pos_products_only'] = array(
+ 'description' => __( 'Limit result set to variations visible in Point of Sale.', 'woocommerce' ),
+ 'type' => 'boolean',
+ 'sanitize_callback' => 'wc_string_to_bool',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+
return $params;
}
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
index b497d4b582..dd625e3c69 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
@@ -327,6 +327,16 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
);
}
+ // Filter by visibility in POS.
+ if ( true === $request['pos_products_only'] ) {
+ $args['tax_query'][] = array(
+ 'taxonomy' => 'pos_product_visibility',
+ 'field' => 'slug',
+ 'terms' => 'pos-hidden',
+ 'operator' => 'NOT IN',
+ );
+ }
+
// Search parameter precedence: search_fields > search_name_or_sku > search_sku > sku.
$search_fields = $request['search_fields'] ?? array();
$search_arg = trim( $request['search'] ?? '' );
@@ -1947,6 +1957,13 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
'validate_callback' => 'rest_validate_request_arg',
);
+ $params['pos_products_only'] = array(
+ 'description' => __( 'Limit result set to products visible in Point of Sale.', 'woocommerce' ),
+ 'type' => 'boolean',
+ 'sanitize_callback' => 'wc_string_to_bool',
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+
return $params;
}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index d810d08cae..92c61ac030 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -7551,7 +7551,7 @@ parameters:
-
message: '#^Variable \$product_object might not be defined\.$#'
identifier: variable.undefined
- count: 3
+ count: 4
path: includes/admin/meta-boxes/views/html-product-data-advanced.php
-
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/POSIntegration.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/POSIntegration.php
index 00fdda9f97..18f9ec9677 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/POSIntegration.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/POSIntegration.php
@@ -54,7 +54,16 @@ class POSIntegration implements IntegrationInterface {
*/
public function get_product_feed_query_args(): array {
return array(
- 'type' => array( 'simple', 'variable', 'variation' ),
+ 'type' => array( 'simple', 'variable', 'variation' ),
+ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+ 'tax_query' => array(
+ array(
+ 'taxonomy' => 'pos_product_visibility',
+ 'field' => 'slug',
+ 'terms' => 'pos-hidden',
+ 'operator' => 'NOT IN',
+ ),
+ ),
);
}
@@ -64,6 +73,7 @@ class POSIntegration implements IntegrationInterface {
public function register_hooks(): void {
add_action( 'rest_api_init', array( $this, 'rest_api_init' ) );
$this->container->get( AsyncGenerator::class )->register_hooks();
+ $this->container->get( POSProductVisibilitySync::class )->register_hooks();
}
/**
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/POSProductVisibilitySync.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/POSProductVisibilitySync.php
new file mode 100644
index 0000000000..4781f06c9c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/POSProductVisibilitySync.php
@@ -0,0 +1,106 @@
+<?php
+/**
+ * POS Product Visibility Sync class.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * Handles syncing pos_product_visibility taxonomy to products and variations.
+ *
+ * When a variable product is marked as hidden from POS, all its variations
+ * should also be marked as hidden. This class ensures that:
+ * - Products and their variations have the correct pos-hidden term
+ * - New variations inherit the pos-hidden term from their parent
+ *
+ * @since 10.5.0
+ */
+class POSProductVisibilitySync {
+
+ /**
+ * Register hooks for syncing POS visibility.
+ *
+ * @since 10.5.0
+ *
+ * @return void
+ */
+ public function register_hooks(): void {
+ add_action( 'woocommerce_new_product_variation', array( $this, 'inherit_parent_pos_visibility' ), 10, 2 );
+ }
+
+ /**
+ * Set POS visibility for a product and its variations.
+ *
+ * This method sets or removes the pos-hidden term on the product,
+ * and if it's a variable product, syncs the visibility to all variations.
+ *
+ * @since 10.5.0
+ *
+ * @param int $product_id The product ID.
+ * @param bool $visible_in_pos Whether the product should be visible in POS.
+ * @return void
+ */
+ public function set_product_pos_visibility( int $product_id, bool $visible_in_pos ): void {
+ if ( $visible_in_pos ) {
+ wp_remove_object_terms( $product_id, 'pos-hidden', 'pos_product_visibility' );
+ } else {
+ wp_set_object_terms( $product_id, 'pos-hidden', 'pos_product_visibility' );
+ }
+
+ $product = wc_get_product( $product_id );
+ if ( $product && $product->is_type( 'variable' ) ) {
+ $this->sync_pos_visibility_to_variations( $product, $visible_in_pos );
+ }
+ }
+
+ /**
+ * Sync POS visibility to all variations of a variable product.
+ *
+ * @since 10.5.0
+ *
+ * @param \WC_Product $product The variable product.
+ * @param bool $visible_in_pos Whether the product should be visible in POS.
+ * @return void
+ */
+ private function sync_pos_visibility_to_variations( \WC_Product $product, bool $visible_in_pos ): void {
+ $variation_ids = $product->get_children();
+ foreach ( $variation_ids as $variation_id ) {
+ if ( $visible_in_pos ) {
+ wp_remove_object_terms( $variation_id, 'pos-hidden', 'pos_product_visibility' );
+ } else {
+ wp_set_object_terms( $variation_id, 'pos-hidden', 'pos_product_visibility' );
+ }
+ }
+ }
+
+ /**
+ * Inherit POS visibility from parent when a new variation is created.
+ *
+ * When a new variation is created, check if the parent product has the
+ * pos-hidden term and apply it to the variation if so.
+ *
+ * @since 10.5.0
+ *
+ * @param int $variation_id The variation ID.
+ * @param \WC_Product_Variation|null $variation The variation object.
+ * @return void
+ */
+ public function inherit_parent_pos_visibility( $variation_id, $variation ): void {
+ if ( ! $variation instanceof \WC_Product_Variation ) {
+ return;
+ }
+
+ $parent_id = $variation->get_parent_id();
+ if ( has_term( 'pos-hidden', 'pos_product_visibility', $parent_id ) ) {
+ wp_set_object_terms( $variation_id, 'pos-hidden', 'pos_product_visibility' );
+ }
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
index e5937b4ffd..b9a964662b 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
@@ -774,4 +774,70 @@ class WC_REST_Product_Variations_Controller_Tests extends WC_REST_Unit_Test_Case
$this->assertEmpty( $response->get_data() );
}
+
+ /**
+ * Test `pos_products_only` filter returns only POS-visible variations when true.
+ */
+ public function test_pos_products_only_true_returns_only_pos_visible_variations() {
+ $parent_product = WC_Helper_Product::create_variation_product();
+ $variations = $parent_product->get_available_variations( 'objects' );
+
+ // Mark one variation as hidden from POS.
+ wp_set_object_terms( $variations[0]->get_id(), 'pos-hidden', 'pos_product_visibility' );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v3/products/' . $parent_product->get_id() . '/variations' );
+ $request->set_param( 'pos_products_only', true );
+
+ $response = $this->server->dispatch( $request );
+ $response_data = $response->get_data();
+ $variation_ids = wp_list_pluck( $response_data, 'id' );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertNotContains( $variations[0]->get_id(), $variation_ids );
+ $this->assertContains( $variations[1]->get_id(), $variation_ids );
+ }
+
+ /**
+ * Test `pos_products_only` filter returns all variations when false.
+ */
+ public function test_pos_products_only_false_returns_all_variations() {
+ $parent_product = WC_Helper_Product::create_variation_product();
+ $variations = $parent_product->get_available_variations( 'objects' );
+
+ // Mark one variation as hidden from POS.
+ wp_set_object_terms( $variations[0]->get_id(), 'pos-hidden', 'pos_product_visibility' );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v3/products/' . $parent_product->get_id() . '/variations' );
+ $request->set_param( 'pos_products_only', false );
+
+ $response = $this->server->dispatch( $request );
+ $response_data = $response->get_data();
+ $variation_ids = wp_list_pluck( $response_data, 'id' );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertContains( $variations[0]->get_id(), $variation_ids );
+ $this->assertContains( $variations[1]->get_id(), $variation_ids );
+ }
+
+ /**
+ * Test that omitting `pos_products_only` filter returns all variations regardless of POS visibility.
+ */
+ public function test_pos_products_only_omitted_returns_all_variations() {
+ $parent_product = WC_Helper_Product::create_variation_product();
+ $variations = $parent_product->get_available_variations( 'objects' );
+
+ // Mark one variation as hidden from POS.
+ wp_set_object_terms( $variations[0]->get_id(), 'pos-hidden', 'pos_product_visibility' );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v3/products/' . $parent_product->get_id() . '/variations' );
+ // Do not set pos_products_only parameter.
+
+ $response = $this->server->dispatch( $request );
+ $response_data = $response->get_data();
+ $variation_ids = wp_list_pluck( $response_data, 'id' );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertContains( $variations[0]->get_id(), $variation_ids );
+ $this->assertContains( $variations[1]->get_id(), $variation_ids );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
index a9b3c44346..3decf623f2 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
@@ -2045,4 +2045,73 @@ class WC_REST_Products_Controller_Tests extends WC_REST_Unit_Test_Case {
$count_after = (int) get_term_meta( $term_id, 'product_count_product_cat', true );
$this->assertEquals( $count_before - 1, $count_after, 'Batch delete should decrement term count immediately.' );
}
+
+ /**
+ * Test `pos_products_only` filter returns only POS-visible products when true.
+ */
+ public function test_pos_products_only_true_returns_only_pos_visible_products() {
+ $visible_product = WC_Helper_Product::create_simple_product();
+ $hidden_product = WC_Helper_Product::create_simple_product();
+
+ // Mark the hidden product as hidden from POS.
+ wp_set_object_terms( $hidden_product->get_id(), 'pos-hidden', 'pos_product_visibility' );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v3/products' );
+ $request->set_param( 'pos_products_only', true );
+
+ $response = $this->server->dispatch( $request );
+ $products = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+
+ $product_ids = wp_list_pluck( $products, 'id' );
+ $this->assertContains( $visible_product->get_id(), $product_ids );
+ $this->assertNotContains( $hidden_product->get_id(), $product_ids );
+ }
+
+ /**
+ * Test `pos_products_only` filter returns all products when false.
+ */
+ public function test_pos_products_only_false_returns_all_products() {
+ $visible_product = WC_Helper_Product::create_simple_product();
+ $hidden_product = WC_Helper_Product::create_simple_product();
+
+ // Mark the hidden product as hidden from POS.
+ wp_set_object_terms( $hidden_product->get_id(), 'pos-hidden', 'pos_product_visibility' );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v3/products' );
+ $request->set_param( 'pos_products_only', false );
+
+ $response = $this->server->dispatch( $request );
+ $products = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+
+ $product_ids = wp_list_pluck( $products, 'id' );
+ $this->assertContains( $visible_product->get_id(), $product_ids );
+ $this->assertContains( $hidden_product->get_id(), $product_ids );
+ }
+
+ /**
+ * Test that omitting `pos_products_only` filter returns all products regardless of visibility in POS.
+ */
+ public function test_pos_products_only_omitted_returns_all_products() {
+ $visible_product = WC_Helper_Product::create_simple_product();
+ $hidden_product = WC_Helper_Product::create_simple_product();
+
+ // Mark the hidden product as hidden from POS.
+ wp_set_object_terms( $hidden_product->get_id(), 'pos-hidden', 'pos_product_visibility' );
+
+ $request = new WP_REST_Request( 'GET', '/wc/v3/products' );
+ // Do not set pos_products_only parameter.
+
+ $response = $this->server->dispatch( $request );
+ $products = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+
+ $product_ids = wp_list_pluck( $products, 'id' );
+ $this->assertContains( $visible_product->get_id(), $product_ids );
+ $this->assertContains( $hidden_product->get_id(), $product_ids );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/POSProductVisibilitySyncTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/POSProductVisibilitySyncTest.php
new file mode 100644
index 0000000000..312010775c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Integrations/POSCatalog/POSProductVisibilitySyncTest.php
@@ -0,0 +1,158 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Integrations\POSCatalog;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\POSProductVisibilitySync;
+use WC_Helper_Product;
+
+/**
+ * POSProductVisibilitySync test class.
+ */
+class POSProductVisibilitySyncTest extends \WC_Unit_Test_Case {
+
+ /**
+ * System under test.
+ *
+ * @var POSProductVisibilitySync
+ */
+ private POSProductVisibilitySync $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->sut = new POSProductVisibilitySync();
+
+ // Ensure the taxonomy is registered for tests.
+ if ( ! taxonomy_exists( 'pos_product_visibility' ) ) {
+ register_taxonomy( 'pos_product_visibility', 'product' );
+ }
+
+ // Ensure the pos-hidden term exists.
+ if ( ! term_exists( 'pos-hidden', 'pos_product_visibility' ) ) {
+ wp_insert_term( 'pos-hidden', 'pos_product_visibility' );
+ }
+ }
+
+ /**
+ * Test that a simple product gets pos-hidden term when set to hidden.
+ */
+ public function test_set_product_pos_visibility_simple_product_hidden(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $this->sut->set_product_pos_visibility( $product->get_id(), false );
+
+ $this->assertTrue( has_term( 'pos-hidden', 'pos_product_visibility', $product->get_id() ) );
+ }
+
+ /**
+ * Test that a simple product has pos-hidden term removed when set to visible.
+ */
+ public function test_set_product_pos_visibility_simple_product_visible(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ // First set as hidden.
+ wp_set_object_terms( $product->get_id(), 'pos-hidden', 'pos_product_visibility' );
+ $this->assertTrue( has_term( 'pos-hidden', 'pos_product_visibility', $product->get_id() ) );
+
+ // Then set as visible.
+ $this->sut->set_product_pos_visibility( $product->get_id(), true );
+
+ $this->assertFalse( has_term( 'pos-hidden', 'pos_product_visibility', $product->get_id() ) );
+ }
+
+ /**
+ * Test that a variable product and all its variations get pos-hidden term when set to hidden.
+ */
+ public function test_set_product_pos_visibility_variable_product_hidden(): void {
+ $product = WC_Helper_Product::create_variation_product();
+ $variation_ids = $product->get_children();
+
+ $this->sut->set_product_pos_visibility( $product->get_id(), false );
+
+ $this->assertTrue( has_term( 'pos-hidden', 'pos_product_visibility', $product->get_id() ) );
+ foreach ( $variation_ids as $variation_id ) {
+ $this->assertTrue(
+ has_term( 'pos-hidden', 'pos_product_visibility', $variation_id ),
+ "Variation $variation_id should have pos-hidden term"
+ );
+ }
+ }
+
+ /**
+ * Test that a variable product and all its variations have pos-hidden term removed when set to visible.
+ */
+ public function test_set_product_pos_visibility_variable_product_visible(): void {
+ $product = WC_Helper_Product::create_variation_product();
+ $variation_ids = $product->get_children();
+
+ // First set as hidden.
+ wp_set_object_terms( $product->get_id(), 'pos-hidden', 'pos_product_visibility' );
+ foreach ( $variation_ids as $variation_id ) {
+ wp_set_object_terms( $variation_id, 'pos-hidden', 'pos_product_visibility' );
+ }
+
+ // Then set as visible.
+ $this->sut->set_product_pos_visibility( $product->get_id(), true );
+
+ $this->assertFalse( has_term( 'pos-hidden', 'pos_product_visibility', $product->get_id() ) );
+ foreach ( $variation_ids as $variation_id ) {
+ $this->assertFalse(
+ has_term( 'pos-hidden', 'pos_product_visibility', $variation_id ),
+ "Variation $variation_id should not have pos-hidden term"
+ );
+ }
+ }
+
+ /**
+ * Test that a new variation inherits pos-hidden term from parent.
+ */
+ public function test_inherit_parent_pos_visibility_parent_hidden(): void {
+ $product = WC_Helper_Product::create_variation_product();
+
+ // Set parent as hidden.
+ wp_set_object_terms( $product->get_id(), 'pos-hidden', 'pos_product_visibility' );
+
+ // Create a new variation.
+ $variation = new \WC_Product_Variation();
+ $variation->set_parent_id( $product->get_id() );
+ $variation->save();
+
+ $this->sut->inherit_parent_pos_visibility( $variation->get_id(), $variation );
+
+ $this->assertTrue( has_term( 'pos-hidden', 'pos_product_visibility', $variation->get_id() ) );
+ }
+
+ /**
+ * Test that a new variation does not inherit pos-hidden term when parent is visible.
+ */
+ public function test_inherit_parent_pos_visibility_parent_visible(): void {
+ $product = WC_Helper_Product::create_variation_product();
+
+ // Ensure parent does not have pos-hidden term.
+ wp_remove_object_terms( $product->get_id(), 'pos-hidden', 'pos_product_visibility' );
+
+ // Create a new variation.
+ $variation = new \WC_Product_Variation();
+ $variation->set_parent_id( $product->get_id() );
+ $variation->save();
+
+ $this->sut->inherit_parent_pos_visibility( $variation->get_id(), $variation );
+
+ $this->assertFalse( has_term( 'pos-hidden', 'pos_product_visibility', $variation->get_id() ) );
+ }
+
+ /**
+ * Test that inherit_parent_pos_visibility handles invalid variation gracefully.
+ */
+ public function test_inherit_parent_pos_visibility_invalid_variation(): void {
+ // Pass a non-WC_Product_Variation object.
+ $this->sut->inherit_parent_pos_visibility( 123, null );
+
+ // No exception should be thrown - test passes if we reach this point.
+ $this->assertTrue( true );
+ }
+}