diff --git a/assets/js/admin/products-admin.js b/assets/js/admin/products-admin.js index 0eb249620..e1cc4ccbe 100644 --- a/assets/js/admin/products-admin.js +++ b/assets/js/admin/products-admin.js @@ -122,7 +122,7 @@ jQuery( document ).ready( function( $ ) { } } - + /** * Disables and changes the checked status of the Sell on Instagram setting field. * diff --git a/facebook-commerce.php b/facebook-commerce.php index b83c9d278..bf004b055 100644 --- a/facebook-commerce.php +++ b/facebook-commerce.php @@ -16,6 +16,7 @@ use WooCommerce\Facebook\Products; use WooCommerce\Facebook\Products\Feed; use WooCommerce\Facebook\Framework\Logger; +use WooCommerce\Facebook\RolloutSwitches; defined( 'ABSPATH' ) || exit; @@ -2343,6 +2344,10 @@ public function get_facebook_pixel_id() { * @since 1.10.0 */ public function get_excluded_product_category_ids() { + + if ( $this->is_woo_all_products_enabled() ) { + return (array) []; + } /** * Filters the configured excluded product category IDs. * @@ -2351,11 +2356,7 @@ public function get_excluded_product_category_ids() { * * @since 1.10.0 */ - - // TODO: to Remove all existence of these function `get_excluded_product_category_ids` as we are no longer supporting these. - // Matter of fact it is used in multiple places including important places like Product sets tab. - // Hence providing empty array as excluded categories :) - return (array) []; + return (array) apply_filters( 'wc_facebook_excluded_product_category_ids', get_option( self::SETTING_EXCLUDED_PRODUCT_CATEGORY_IDS, [] ), $this ); } /** @@ -2365,6 +2366,9 @@ public function get_excluded_product_category_ids() { * @since 1.10.0 */ public function get_excluded_product_tag_ids() { + if ( $this->is_woo_all_products_enabled() ) { + return (array) []; + } /** * Filters the configured excluded product tag IDs. * @@ -2373,10 +2377,7 @@ public function get_excluded_product_tag_ids() { * * @since 1.10.0 */ - // TODO: to Remove all existence of these function `get_excluded_product_tag_ids` as we are no longer supporting these. - // Matter of fact it is used in multiple places including important places like Product sets tab. - // Hence providing empty array as excluded tags :) - return (array) []; + return (array) apply_filters( 'wc_facebook_excluded_product_tag_ids', get_option( self::SETTING_EXCLUDED_PRODUCT_TAG_IDS, [] ), $this ); } /** Setter methods ************************************************************************************************/ @@ -2485,6 +2486,19 @@ public function is_configured() { return $this->get_facebook_page_id() && $this->facebook_for_woocommerce->get_connection_handler()->is_connected(); } + /** + * Determines if viewing the plugin settings in the admin. + * + * @since 3.5.3 + * + * @return bool + */ + public function is_woo_all_products_enabled() { + return $this->facebook_for_woocommerce->get_rollout_switches()->is_switch_enabled( + RolloutSwitches::SWITCH_WOO_ALL_PRODUCTS_SYNC_ENABLED + ); + } + /** * Determines whether advanced matching is enabled. * diff --git a/includes/Admin.php b/includes/Admin.php index a92d3b791..8a5547cb8 100644 --- a/includes/Admin.php +++ b/includes/Admin.php @@ -749,6 +749,23 @@ private function maybe_add_tax_query_for_excluded_taxonomies( $query_vars, $in = return $query_vars; } + /** + * Adds bulk actions in the products edit screen. + * + * @internal + * + * @since 1.10.0 + * + * @param array $bulk_actions array of bulk action keys and labels + * @return array + */ + public function add_products_sync_bulk_actions( $bulk_actions ) { + $bulk_actions['facebook_include'] = __( 'Include in Facebook sync', 'facebook-for-woocommerce' ); + $bulk_actions['facebook_exclude'] = __( 'Exclude from Facebook sync', 'facebook-for-woocommerce' ); + return $bulk_actions; + } + + /** * Handles a Facebook product sync bulk action. * Called every time for a product @@ -842,7 +859,13 @@ private function resync_products( array $products ) { $integration->on_product_publish( $product->get_id() ); } elseif ( $integration->product_should_be_synced( $product ) ) { - facebook_for_woocommerce()->get_products_sync_handler()->create_or_update_products( array( $product->get_id() ) ); + + // schedule simple products to be updated or deleted from the catalog in the background + if ( Products::product_should_be_deleted( $product ) ) { + facebook_for_woocommerce()->get_products_sync_handler()->delete_products( array( $product->get_id() ) ); + } else { + facebook_for_woocommerce()->get_products_sync_handler()->create_or_update_products( array( $product->get_id() ) ); + } } } } diff --git a/includes/ProductSync/ProductValidator.php b/includes/ProductSync/ProductValidator.php index 413dba5c1..e07089f02 100644 --- a/includes/ProductSync/ProductValidator.php +++ b/includes/ProductSync/ProductValidator.php @@ -112,6 +112,7 @@ public function validate() { $this->validate_product_sync_field(); $this->validate_product_status(); $this->validate_product_visibility(); + $this->validate_product_terms(); } /** @@ -124,6 +125,7 @@ public function validate() { public function validate_but_skip_status_check() { $this->validate_product_sync_field(); $this->validate_product_visibility(); + $this->validate_product_terms(); } /** @@ -134,6 +136,7 @@ public function validate_but_skip_status_check() { */ public function validate_but_skip_sync_field() { $this->validate_product_visibility(); + $this->validate_product_terms(); } /** @@ -153,6 +156,23 @@ public function passes_all_checks(): bool { return true; } + /** + * Check if the product's terms (categories and tags) allow it to sync. + * + * @return bool + */ + public function passes_product_terms_check(): bool { + try { + $this->validate_product_terms(); + } catch ( ProductExcludedException $e ) { + return false; + } catch ( ProductInvalidException $e ) { + return false; + } + + return true; + } + /** * Check if the product's product sync meta field allows it to sync. * @@ -238,6 +258,34 @@ protected function validate_product_visibility() { } } + /** + * Check whether the product's categories or tags (terms) exclude it from sync. + * + * @throws ProductExcludedException If product should not be synced. + */ + protected function validate_product_terms() { + + if ( $this->integration->is_woo_all_products_enabled() ) { + return; + } + + $product = $this->product_parent ? $this->product_parent : $this->product; + + $excluded_categories = $this->integration->get_excluded_product_category_ids(); + if ( $excluded_categories ) { + if ( ! empty( array_intersect( $product->get_category_ids(), $excluded_categories ) ) ) { + throw new ProductExcludedException( __( 'Product excluded because of categories.', 'facebook-for-woocommerce' ) ); + } + } + + $excluded_tags = $this->integration->get_excluded_product_tag_ids(); + if ( $excluded_tags ) { + if ( ! empty( array_intersect( $product->get_tag_ids(), $excluded_tags ) ) ) { + throw new ProductExcludedException( __( 'Product excluded because of tags.', 'facebook-for-woocommerce' ) ); + } + } + } + /** * Validate if the product is excluded from at the "product level" (product meta value). * diff --git a/includes/Products.php b/includes/Products.php index eb863c099..9a81d1865 100644 --- a/includes/Products.php +++ b/includes/Products.php @@ -226,6 +226,23 @@ public static function published_product_should_be_synced( \WC_Product $product } } + + /** + * Determines whether the given product should be removed from the catalog. + * + * A product should be removed if it is no longer in stock and the user has opted-in to hide products that are out of stock, + * or belongs to an excluded category. + * + * @since 2.0.0 + * + * @param \WC_Product $product + * @return bool + */ + public static function product_should_be_deleted( \WC_Product $product ) { + return ! facebook_for_woocommerce()->get_product_sync_validator( $product )->passes_product_terms_check(); + } + + /** * Determines whether a product is enabled to be synced in Facebook. * @@ -243,6 +260,22 @@ public static function is_sync_enabled_for_product( \WC_Product $product ) { return facebook_for_woocommerce()->get_product_sync_validator( $product )->passes_product_sync_field_check(); } + + /** + * Determines whether the product's terms would make it excluded to be synced from Facebook. + * + * @since 1.10.0 + * + * @deprecated use \WooCommerce\Facebook\ProductSync\ProductValidator::passes_product_terms_check() instead + * + * @param \WC_Product $product product object + * @return bool if true, product should be excluded from sync, if false, product can be included in sync (unless manually excluded by individual product meta) + */ + public static function is_sync_excluded_for_product_terms( \WC_Product $product ) { + return ! facebook_for_woocommerce()->get_product_sync_validator( $product )->passes_product_terms_check(); + } + + /** * Sets a product's visibility in the Facebook shop. * diff --git a/includes/Products/Stock.php b/includes/Products/Stock.php index 9ed952685..917bc4946 100644 --- a/includes/Products/Stock.php +++ b/includes/Products/Stock.php @@ -86,13 +86,19 @@ function ( $item ) { /** - * Schedules a product sync to update the product's stock status + * Schedules a product sync to update the product's stock status. + * + * The product is removed from Facebook if it is out of stock and the plugin is configured to remove out of stock products from the catalog. * * @since 2.0.5 * * @param \WC_Product $product a product object */ private function maybe_sync_product_stock_status( \WC_Product $product ) { + if ( Products::product_should_be_deleted( $product ) ) { + facebook_for_woocommerce()->get_integration()->delete_fb_product( $product ); + return; + } facebook_for_woocommerce()->get_products_sync_handler()->create_or_update_products( array( $product->get_id() ) ); } } diff --git a/includes/Products/Sync/Background.php b/includes/Products/Sync/Background.php index 115c7b816..5b22317e4 100644 --- a/includes/Products/Sync/Background.php +++ b/includes/Products/Sync/Background.php @@ -182,7 +182,7 @@ private function process_item_update( $prefixed_product_id ) { } $request = null; - if ( Products::product_should_be_synced( $product ) ) { + if ( ! Products::product_should_be_deleted( $product ) && Products::product_should_be_synced( $product ) ) { if ( $product->is_type( 'variation' ) ) { $product_data = \WC_Facebookcommerce_Utils::prepare_product_variation_data_items_batch( $product ); diff --git a/includes/RolloutSwitches.php b/includes/RolloutSwitches.php index 5d7caa396..c3456bffa 100644 --- a/includes/RolloutSwitches.php +++ b/includes/RolloutSwitches.php @@ -24,15 +24,17 @@ class RolloutSwitches { /** @var \WC_Facebookcommerce commerce handler */ private \WC_Facebookcommerce $plugin; - public const SWITCH_ROLLOUT_FEATURES = 'rollout_enabled'; - public const WHATSAPP_UTILITY_MESSAGING = 'whatsapp_utility_messages_enabled'; - public const SWITCH_PRODUCT_SETS_SYNC_ENABLED = 'product_sets_sync_enabled'; - private const SETTINGS_KEY = 'wc_facebook_for_woocommerce_rollout_switches'; + public const SWITCH_ROLLOUT_FEATURES = 'rollout_enabled'; + public const WHATSAPP_UTILITY_MESSAGING = 'whatsapp_utility_messages_enabled'; + public const SWITCH_PRODUCT_SETS_SYNC_ENABLED = 'product_sets_sync_enabled'; + public const SWITCH_WOO_ALL_PRODUCTS_SYNC_ENABLED = 'woo_all_products_sync_enabled'; + private const SETTINGS_KEY = 'wc_facebook_for_woocommerce_rollout_switches'; private const ACTIVE_SWITCHES = array( self::SWITCH_ROLLOUT_FEATURES, self::WHATSAPP_UTILITY_MESSAGING, self::SWITCH_PRODUCT_SETS_SYNC_ENABLED, + self::SWITCH_WOO_ALL_PRODUCTS_SYNC_ENABLED, ); public function __construct( \WC_Facebookcommerce $plugin ) { @@ -82,13 +84,13 @@ public function init() { Logger::log( $e->getMessage(), array( - 'flow_name' => 'rollout_switches', - 'flow_step' => 'init', + 'flow_name' => 'rollout_switches', + 'flow_step' => 'init', ), array( - 'should_send_log_to_meta' => true, + 'should_send_log_to_meta' => true, 'should_save_log_in_woocommerce' => true, - 'woocommerce_log_level' => \WC_Log_Levels::ERROR, + 'woocommerce_log_level' => \WC_Log_Levels::ERROR, ) ); } diff --git a/includes/fbproductfeed.php b/includes/fbproductfeed.php index 6371840ee..7589564b7 100644 --- a/includes/fbproductfeed.php +++ b/includes/fbproductfeed.php @@ -522,7 +522,12 @@ private function prepare_product_for_feed( $woo_product, &$attribute_variants ) $product_data['default_product'] = ''; } - + + // when dealing with the feed file, only set out-of-stock products as hidden + if ( Products::product_should_be_deleted( $woo_product->woo_product ) ) { + $product_data['visibility'] = \WC_Facebookcommerce_Integration::FB_SHOP_PRODUCT_HIDDEN; + } + // Sale price, only format if we have a sale price set for the product, else leave as empty (''). $sale_price = static::get_value_from_product_data( $product_data, 'sale_price', '' ); $sale_price_effective_date = ''; diff --git a/tests/Unit/WCFacebookCommerceIntegrationTest.php b/tests/Unit/WCFacebookCommerceIntegrationTest.php index 83f4bd4e2..1f055f60c 100644 --- a/tests/Unit/WCFacebookCommerceIntegrationTest.php +++ b/tests/Unit/WCFacebookCommerceIntegrationTest.php @@ -13,6 +13,7 @@ use WooCommerce\Facebook\ProductSync\ProductValidator; use WooCommerce\Facebook\Framework\AdminMessageHandler; use WooCommerce\Facebook\Handlers\PluginRender; +use WooCommerce\Facebook\RolloutSwitches; /** * Unit tests for Facebook Graph API calls. @@ -39,6 +40,12 @@ class WCFacebookCommerceIntegrationTest extends \WooCommerce\Facebook\Tests\Abst */ private $integration; + + /** + * @var RolloutSwitches + */ + private $rollout_switches; + /** * Default plugin options. * @@ -59,11 +66,20 @@ public function setUp(): void { $this->facebook_for_woocommerce = $this->createMock( WC_Facebookcommerce::class ); $this->connection_handler = $this->createMock( Connection::class ); + $this->rollout_switches = $this->createMock(RolloutSwitches::class); + $this->rollout_switches->method('is_switch_enabled') + ->willReturn(false); + + $this->facebook_for_woocommerce->method( 'get_connection_handler' ) ->willReturn( $this->connection_handler ); $this->api = $this->createMock( Api::class ); $this->facebook_for_woocommerce->method( 'get_api' ) ->willReturn( $this->api ); + $this->facebook_for_woocommerce->method('get_rollout_switches') + ->willReturn($this->rollout_switches); + + $this->integration = new WC_Facebookcommerce_Integration( $this->facebook_for_woocommerce ); @@ -2082,7 +2098,7 @@ public function test_get_excluded_product_category_ids_no_filter() { $categories = $this->integration->get_excluded_product_category_ids(); - $this->assertEquals( [ ], $categories ); + $this->assertEquals( [ 121, 221, 321, 421, 521, 621 ], $categories ); } /** @@ -2133,10 +2149,10 @@ public function test_get_excluded_product_tag_ids_no_filter() { WC_Facebookcommerce_Integration::SETTING_EXCLUDED_PRODUCT_TAG_IDS, [ ] ); - + $tags = $this->integration->get_excluded_product_tag_ids(); - $this->assertEquals( [ ], $tags ); + $this->assertEquals( [ ], $tags ); } /** @@ -2156,10 +2172,14 @@ function ( $ids ) { WC_Facebookcommerce_Integration::SETTING_EXCLUDED_PRODUCT_TAG_IDS, [ 121, 221, 321, 421, 521, 621 ] ); - + // $integration_mock = $this->createMock(WC_Facebookcommerce_Integration::class); + // $integration_mock->method('is_woo_all_products_enabled') + // ->willReturn(true); + // $this->integration = $integration_mock; $tags = $this->integration->get_excluded_product_tag_ids(); + var_dump($tags); - $this->assertEquals( [ ], $tags ); + $this->assertEquals( [], $tags ); }