diff --git a/includes/Feed/AbstractFeed.php b/includes/Feed/AbstractFeed.php index 37951026c..18ed9a22b 100644 --- a/includes/Feed/AbstractFeed.php +++ b/includes/Feed/AbstractFeed.php @@ -13,6 +13,7 @@ use WooCommerce\Facebook\Framework\Api\Exception; use WooCommerce\Facebook\Framework\Helper; use WooCommerce\Facebook\Framework\Plugin\Exception as PluginException; +use WooCommerce\Facebook\Utilities\Heartbeat; defined( 'ABSPATH' ) || exit; @@ -32,20 +33,20 @@ abstract class AbstractFeed { /** The action slug for triggering file upload */ const FEED_GEN_COMPLETE_ACTION = 'wc_facebook_feed_generation_completed_'; - /** Schedule feed generation on some interval hook name for children classes. */ - const SCHEDULE_CALL_BACK = 'schedule_feed_generation'; - /** Schedule an immediate file generator on the scheduler hook name. For testing mostly. */ - const REGENERATE_CALL_BACK = 'regenerate_feed'; - /** Make upload call to Meta hook name for children classes. */ - const UPLOAD_CALL_BACK = 'send_request_to_upload_feed'; - /** Stream file to upload endpoint hook name for children classes. */ - const STREAM_CALL_BACK = 'handle_feed_data_request'; /** Hook prefix for Legacy REST API hook name */ const LEGACY_API_PREFIX = 'woocommerce_api_'; /** @var string the WordPress option name where the secret included in the feed URL is stored */ const OPTION_FEED_URL_SECRET = 'wc_facebook_feed_url_secret_'; + /** + * The feed writer instance for the given feed. + * + * @var FeedFileWriter + * @since 3.5.0 + */ + protected FeedFileWriter $feed_writer; + /** * The feed generator instance for the given feed. * @@ -63,32 +64,38 @@ abstract class AbstractFeed { protected AbstractFeedHandler $feed_handler; /** - * The name of the data feed. + * Initialize feed properties. * - * @var string + * @param FeedFileWriter $feed_writer The feed file writer instance. + * @param AbstractFeedHandler $feed_handler The feed handler instance. + * @param FeedGenerator $feed_generator The feed generator instance. */ - protected string $data_stream_name; + protected function init( FeedFileWriter $feed_writer, AbstractFeedHandler $feed_handler, FeedGenerator $feed_generator ): void { + $this->feed_writer = $feed_writer; + $this->feed_handler = $feed_handler; + $this->feed_generator = $feed_generator; - /** - * The option name for the feed URL secret. - * - * @var string - */ - protected string $feed_url_secret_option_name; - - /** - * The type of feed as per the endpoint requirements. - * - * @var string - */ - protected string $feed_type; + $this->feed_generator->init(); + $this->add_hooks(); + } /** - * The interval in seconds for the feed generation. + * Adds the necessary hooks for feed generation and data request handling. * - * @var int + * @since 3.5.0 */ - protected int $gen_feed_interval; + protected function add_hooks(): void { + add_action( static::get_feed_gen_scheduling_interval(), array( $this, 'schedule_feed_generation' ) ); + add_action( self::GENERATE_FEED_ACTION . static::get_data_stream_name(), array( $this, 'regenerate_feed' ) ); + add_action( self::FEED_GEN_COMPLETE_ACTION . static::get_data_stream_name(), array( $this, 'send_request_to_upload_feed' ) ); + add_action( + self::LEGACY_API_PREFIX . self::REQUEST_FEED_ACTION . static::get_data_stream_name(), + array( + $this, + 'handle_feed_data_request', + ) + ); + } /** * Schedules the recurring feed generation. @@ -96,11 +103,11 @@ abstract class AbstractFeed { * @since 3.5.0 */ public function schedule_feed_generation(): void { - $schedule_action_hook_name = self::GENERATE_FEED_ACTION . $this->data_stream_name; + $schedule_action_hook_name = self::GENERATE_FEED_ACTION . static::get_data_stream_name(); if ( ! as_next_scheduled_action( $schedule_action_hook_name ) ) { as_schedule_recurring_action( time(), - $this->gen_feed_interval, + static::get_feed_gen_interval(), $schedule_action_hook_name, array(), facebook_for_woocommerce()->get_id_dasherized() @@ -132,10 +139,10 @@ public function regenerate_feed(): void { * @since 3.5.0 */ public function send_request_to_upload_feed(): void { - $name = $this->data_stream_name; + $name = static::get_data_stream_name(); $data = array( 'url' => self::get_feed_data_url(), - 'feed_type' => $this->feed_type, + 'feed_type' => static::get_feed_type(), 'update_type' => 'CREATE', ); @@ -160,7 +167,7 @@ public function send_request_to_upload_feed(): void { */ public function get_feed_data_url(): string { $query_args = array( - 'wc-api' => self::REQUEST_FEED_ACTION . $this->data_stream_name, + 'wc-api' => self::REQUEST_FEED_ACTION . static::get_data_stream_name(), 'secret' => self::get_feed_secret(), ); @@ -177,10 +184,12 @@ public function get_feed_data_url(): string { * @since 3.5.0 */ public function get_feed_secret(): string { - $secret = get_option( $this->feed_url_secret_option_name, '' ); + $secret_option_name = self::OPTION_FEED_URL_SECRET . static::get_data_stream_name(); + + $secret = get_option( $secret_option_name, '' ); if ( ! $secret ) { $secret = wp_hash( 'example-feed-' . time() ); - update_option( $this->feed_url_secret_option_name, $secret ); + update_option( $secret_option_name, $secret ); } return $secret; @@ -196,12 +205,12 @@ public function get_feed_secret(): string { * @since 3.5.0 */ public function handle_feed_data_request(): void { - $name = $this->data_stream_name; + $name = static::get_data_stream_name(); \WC_Facebookcommerce_Utils::log( "{$name} feed: Meta is requesting feed file." ); - $file_path = $this->feed_handler->get_feed_writer()->get_file_path(); + $file_path = $this->feed_writer->get_file_path(); - // regenerate if the file doesn't exist. + // regenerate if the file doesn't exist using the legacy flow. if ( ! file_exists( $file_path ) ) { $this->feed_handler->generate_feed_file(); } @@ -249,4 +258,36 @@ public function handle_feed_data_request(): void { } exit; } + + /** + * Get the data stream name for the given feed. + * + * @return string + */ + abstract protected static function get_data_stream_name(): string; + + /** + * Get the data feed type. + * + * @return string + */ + abstract protected static function get_feed_type(): string; + + /** + * Get the feed generation interval. Must be longer than the heartbeat. + * + * @return int + */ + protected static function get_feed_gen_interval(): int { + return DAY_IN_SECONDS; + } + + /** + * Get the Heartbeat interval to ensure that feed gen is scheduled. Must be shorter than the feed gen interval. + * + * @return string Heartbeat constant value + */ + protected static function get_feed_gen_scheduling_interval(): string { + return Heartbeat::HOURLY; + } } diff --git a/includes/Feed/CsvFeedFileWriter.php b/includes/Feed/CsvFeedFileWriter.php index 0c3bb3aa9..e97771ddc 100644 --- a/includes/Feed/CsvFeedFileWriter.php +++ b/includes/Feed/CsvFeedFileWriter.php @@ -163,7 +163,10 @@ public function write_temp_feed_file( array $data ): void { foreach ( $data as $obj ) { $row = []; foreach ( $accessors as $accessor ) { - $row[] = $obj[ $accessor ] ?? ''; + // Map each field in the row to ensure proper string conversion + $value = $obj[ $accessor ] ?? ''; + $row[] = $this->format_field( $value ); + } if ( fputcsv( $temp_feed_file, $row, $this->delimiter, $this->enclosure, $this->escape_char ) === false ) { throw new PluginException( 'Failed to write a CSV data row.', 500 ); @@ -174,6 +177,14 @@ public function write_temp_feed_file( array $data ): void { fclose( $temp_feed_file ); } + protected function format_field( $value ) { + if ( is_array( $value ) || is_object( $value ) ) { + return wp_json_encode( $value ); + } + return $value; + } + + /** * Creates files in the feed directory to prevent directory listing and hotlinking. * diff --git a/includes/Feed/FeedManager.php b/includes/Feed/FeedManager.php index 71cfbbc68..490329398 100644 --- a/includes/Feed/FeedManager.php +++ b/includes/Feed/FeedManager.php @@ -18,6 +18,7 @@ * @since 3.5.0 */ class FeedManager { + const PROMOTIONS = 'promotions'; const RATINGS_AND_REVIEWS = 'ratings_and_reviews'; /** @@ -45,14 +46,14 @@ public function __construct() { * * @param string $data_stream_name The name of the data stream. * - * phpcs:ignore -- Method to be implemented when new feed types are added. - * * @return AbstractFeed The created feed instance derived from AbstractFeed. * @throws \InvalidArgumentException If the data stream doesn't correspond to a FeedType. * @since 3.5.0 */ private function create_feed( string $data_stream_name ): AbstractFeed { switch ( $data_stream_name ) { + case self::PROMOTIONS: + return new PromotionsFeed(); case self::RATINGS_AND_REVIEWS: return new RatingsAndReviewsFeed(); default: @@ -67,7 +68,7 @@ private function create_feed( string $data_stream_name ): AbstractFeed { * @since 3.5.0 */ public static function get_active_feed_types(): array { - return array( self::RATINGS_AND_REVIEWS ); + return array( self::PROMOTIONS, self::RATINGS_AND_REVIEWS ); } /** diff --git a/includes/Feed/FeedUploadUtils.php b/includes/Feed/FeedUploadUtils.php index a3a46f8ab..2a83612a3 100644 --- a/includes/Feed/FeedUploadUtils.php +++ b/includes/Feed/FeedUploadUtils.php @@ -10,12 +10,25 @@ namespace WooCommerce\Facebook\Feed; +use WC_Coupon; + /** * Class containing util functions related to various feed uploads. * * @since 3.5.0 */ class FeedUploadUtils { + const VALUE_TYPE_PERCENTAGE = 'PERCENTAGE'; + const VALUE_TYPE_FIXED_AMOUNT = 'FIXED_AMOUNT'; + const TARGET_TYPE_SHIPPING = 'SHIPPING'; + const TARGET_TYPE_LINE_ITEM = 'LINE_ITEM'; + const TARGET_GRANULARITY_ORDER_LEVEL = 'ORDER_LEVEL'; + const TARGET_GRANULARITY_ITEM_LEVEL = 'ITEM_LEVEL'; + const TARGET_SELECTION_ENTIRE_CATALOG = 'ALL_CATALOG_PRODUCTS'; + const TARGET_SELECTION_SPECIFIC_PRODUCTS = 'SPECIFIC_PRODUCTS'; + const APPLICATION_TYPE_BUYER_APPLIED = 'BUYER_APPLIED'; + const PROMO_SYNC_LOGGING_FLOW_NAME = 'promotion_feed_sync'; + public static function get_ratings_and_reviews_data( array $query_args ): array { $comments = get_comments( $query_args ); $reviews_data = array(); @@ -72,4 +85,283 @@ public static function get_ratings_and_reviews_data( array $query_args ): array return $reviews_data; } + + /** + * Query for coupons and map them to Meta format. + * + * @param array $query_args arguments for the get_posts() call + * + * @throws \Exception If an error occurs during fetching coupons. + */ + public static function get_coupons_data( array $query_args ): array { + try { + $coupon_posts = get_posts( $query_args ); + $coupons_data = array(); + + // Loop through each coupon post and map the necessary fields. + foreach ( $coupon_posts as $coupon_post ) { + // Create a coupon object using the coupon code. + $coupon = new WC_Coupon( $coupon_post->post_title ); + + if ( ! self::is_valid_coupon( $coupon ) ) { + continue; + } + + try { + // Map discount type and amount + $woo_discount_type = $coupon->get_discount_type(); + $percent_off = ''; + $fixed_amount_off = ''; + + if ( 'percent' === $woo_discount_type ) { + $value_type = self::VALUE_TYPE_PERCENTAGE; + $percent_off = $coupon->get_amount(); + } elseif ( in_array( $woo_discount_type, array( 'fixed_cart', 'fixed_product' ), true ) ) { + $value_type = self::VALUE_TYPE_FIXED_AMOUNT; + $fixed_amount_off = $coupon->get_amount(); // TODO we may want to pass in optional currency code for multinational support + } else { + \WC_Facebookcommerce_Utils::logTelemetryToMeta( + 'Unknown discount type encountered during feed processing', + array( + 'promotion_id' => $coupon_post->ID, + 'extra_data' => array( 'discount_type' => $woo_discount_type ), + 'flow_name' => self::PROMO_SYNC_LOGGING_FLOW_NAME, + 'flow_step' => 'map_discount_type', + ) + ); + continue; + } + + // Map start and end dates (if available) + $start_date_time = $coupon->get_date_created() ? (string) $coupon->get_date_created()->getTimestamp() : $coupon_post->post_date; + $end_date_time = $coupon->get_date_expires() ? (string) $coupon->get_date_expires()->getTimestamp() : ''; + + // Map target type. Coupons that apply both a discount and free shipping are already + // filtered out in is_valid_coupon + $is_free_shipping = $coupon->get_free_shipping(); + if ( $is_free_shipping ) { + $target_type = self::TARGET_TYPE_SHIPPING; + } else { + $target_type = self::TARGET_TYPE_LINE_ITEM; + } + + // Map target granularity + if ( $is_free_shipping || 'fixed_cart' === $woo_discount_type ) { + $target_granularity = self::TARGET_GRANULARITY_ORDER_LEVEL; + } else { + $target_granularity = self::TARGET_GRANULARITY_ITEM_LEVEL; + } + + // Map target selection + if ( empty( $coupon->get_product_ids() ) + && empty( $coupon->get_product_categories() ) + && empty( $coupon->get_excluded_product_ids() ) + && empty( $coupon->get_excluded_product_categories() ) + ) { + // Coupon applies to all products. + $target_selection = self::TARGET_SELECTION_ENTIRE_CATALOG; + } else { + $target_selection = self::TARGET_SELECTION_SPECIFIC_PRODUCTS; + } + + // Determine target product mapping + $target_product_set_retailer_ids = ''; + $target_product_retailer_ids = ''; + $target_filter = ''; + + if ( self::TARGET_SELECTION_SPECIFIC_PRODUCTS === $target_selection ) { + $target_filter = self::get_target_filter( + $coupon->get_product_ids(), + $coupon->get_excluded_product_ids(), + $coupon->get_product_categories(), + $coupon->get_excluded_product_categories() + ); + } + + // Build the mapped coupon data array. + $data = array( + 'offer_id' => $coupon->get_id(), + 'title' => $coupon->get_code(), + 'value_type' => $value_type, + 'percent_off' => $percent_off, + 'fixed_amount_off' => $fixed_amount_off, + 'application_type' => self::APPLICATION_TYPE_BUYER_APPLIED, + 'target_type' => $target_type, + 'target_shipping_option_types' => '', // Not needed for offsite checkout + 'target_granularity' => $target_granularity, + 'target_selection' => $target_selection, + 'start_date_time' => $start_date_time, + 'end_date_time' => $end_date_time, + 'coupon_codes' => array( $coupon->get_code() ), + 'public_coupon_code' => '', // TODO allow public coupons + 'target_filter' => $target_filter, + 'target_product_retailer_ids' => $target_product_retailer_ids, + 'target_product_group_retailer_ids' => '', // Concept does not exist in Woo + 'target_product_set_retailer_ids' => $target_product_set_retailer_ids, + 'redeem_limit_per_user' => $coupon->get_usage_limit_per_user(), + 'min_subtotal' => $coupon->get_minimum_amount(), // TODO we may want to pass in optional currency code for multinational support + 'min_quantity' => '', // Concept does not exist in Woo + 'offer_terms' => '', // TODO link to T&C page? + 'redemption_limit_per_seller' => $coupon->get_usage_limit(), + 'target_quantity' => '', // Concept does not exist in Woo + 'prerequisite_filter' => '', // Concept does not exist in Woo + 'prerequisite_product_retailer_ids' => '', // Concept does not exist in Woo + 'prerequisite_product_group_retailer_ids' => '', // Concept does not exist in Woo + 'prerequisite_product_set_retailer_ids' => '', // Concept does not exist in Woo + 'exclude_sale_priced_products' => $coupon->get_exclude_sale_items(), + ); + + $coupons_data[] = $data; + } catch ( \Exception $e ) { + \WC_Facebookcommerce_Utils::logTelemetryToMeta( + 'Exception while trying to get coupon data for feed', + array( + 'promotion_id' => $coupon_post->ID, + 'extra_data' => [ + 'exception_message' => $e->getMessage(), + 'query_args' => wp_json_encode( $query_args ), + ], + 'flow_name' => self::PROMO_SYNC_LOGGING_FLOW_NAME, + 'flow_step' => 'map_coupon_data', + ) + ); + continue; + } + } + + return $coupons_data; + } catch ( \Exception $e ) { + \WC_Facebookcommerce_Utils::logExceptionImmediatelyToMeta( + $e, + array( + 'event' => self::PROMO_SYNC_LOGGING_FLOW_NAME, + 'event_type' => 'get_coupon_data', + 'extra_data' => [ 'query_args' => wp_json_encode( $query_args ) ], + ) + ); + throw $e; + } + } + + private static function is_valid_coupon( WC_Coupon $coupon ): bool { + /** + * Fields not supported by Meta: + * - coupon gives both a discount and free shipping + * - Maximum Spend is set + * - Allowed Emails are set + * - limit_usage_to_x_items is set + * - missing coupon code + * - coupon uses brand targeting + */ + if ( empty( $coupon->get_code() ) ) { + return false; + } + if ( $coupon->get_free_shipping() && $coupon->get_amount() > 0 ) { + return false; + } + if ( $coupon->get_maximum_amount() > 0 ) { + return false; + } + if ( count( $coupon->get_email_restrictions() ) > 0 ) { + return false; + } + if ( ( $coupon->get_limit_usage_to_x_items() ?? 0 ) > 0 ) { + return false; + } + + $brands = $coupon->get_meta( 'product_brands' ); + $brands_count = is_countable( $brands ) ? count( $brands ) : ( ! empty( $brands ) ? 1 : 0 ); + if ( $brands_count > 0 ) { + return false; + } + + $exclude_brands = $coupon->get_meta( 'exclude_product_brands' ); + $exclude_brands_count = is_countable( $exclude_brands ) ? count( $exclude_brands ) : ( ! empty( $exclude_brands ) ? 1 : 0 ); + if ( $exclude_brands_count > 0 ) { + return false; + } + + return true; + } + + private static function get_target_filter( + array $included_product_ids, + array $excluded_product_ids, + array $included_product_category_ids, + array $excluded_product_category_ids + ): string { + $filter_parts = []; + + $included_products = self::get_products( $included_product_ids, $included_product_category_ids ); + $excluded_products = self::get_products( $excluded_product_ids, $excluded_product_category_ids ); + + if ( ! empty( $included_products ) ) { + // "is product x or is product y" + $included = self::build_retailer_id_filter( $included_products, 'eq' ); + $filter_parts[] = [ 'or' => $included ]; + } + if ( ! empty( $excluded_products ) ) { + // "is not product x and is not product y" + $excluded = self::build_retailer_id_filter( $excluded_products, 'neq' ); + $filter_parts[] = [ 'and' => $excluded ]; + } + + // Combine the filter parts: + // - If both parts are present, wrap them in an "and" clause. + // - If only one part exists, use it directly. + if ( count( $filter_parts ) > 1 ) { + $final_filter = [ 'and' => $filter_parts ]; + } elseif ( count( $filter_parts ) === 1 ) { + $final_filter = $filter_parts[0]; + } else { + return ''; + } + + // Return the JSON representation. It should look something like: + // {"and":[ + // {"or":[{"retailer_id":{"eq":"retailer_id_1"}},{"retailer_id":{"eq":"retailer_id_2"}}]}, + // {"and":[{"retailer_id":{"neq":"retailer_id_3"}},{"retailer_id":{"neq":"retailer_id_4"}}]} + // ]} + return wp_json_encode( $final_filter ); + } + + private static function build_retailer_id_filter( array $products, string $operator ): array { + return array_map( + function ( $product ) use ( $operator ) { + $fb_retailer_id = \WC_Facebookcommerce_Utils::get_fb_retailer_id( $product ); + return [ 'retailer_id' => [ $operator => $fb_retailer_id ] ]; + }, + $products + ); + } + + private static function get_products( array $product_ids, array $product_category_ids ): array { + $products = []; + + if ( ! empty( $product_ids ) ) { + $products = wc_get_products( + array( + 'include' => $product_ids, + 'orderby' => 'ID', + 'order' => 'ASC', + ) + ); + } + + // TODO when confident in category syncing, we can use target_product_set_retailer_ids instead of + // extracting products from the categories to use in the target filter. This current logic + // may result in the target filter field being too large for Meta to ingest. + if ( ! empty( $product_category_ids ) ) { + $products_from_categories = wc_get_products( + array( + 'product_category_id' => $product_category_ids, + 'orderby' => 'ID', + 'order' => 'ASC', + ) + ); + $products = array_unique( array_merge( $products, $products_from_categories ) ); + } + + return $products; + } } diff --git a/includes/Feed/PromotionsFeed.php b/includes/Feed/PromotionsFeed.php new file mode 100644 index 000000000..6bc01cd10 --- /dev/null +++ b/includes/Feed/PromotionsFeed.php @@ -0,0 +1,60 @@ +init( + $file_writer, + $feed_handler, + $feed_generator, + ); + } + + protected static function get_feed_type(): string { + return 'PROMOTIONS'; + } + + protected static function get_data_stream_name(): string { + return FeedManager::PROMOTIONS; + } + + protected static function get_feed_gen_interval(): int { + return HOUR_IN_SECONDS; + } +} diff --git a/includes/Feed/PromotionsFeedGenerator.php b/includes/Feed/PromotionsFeedGenerator.php new file mode 100644 index 000000000..912f14402 --- /dev/null +++ b/includes/Feed/PromotionsFeedGenerator.php @@ -0,0 +1,59 @@ +get_batch_size(); + $offset = ( $batch_number - 1 ) * $batch_size; + + $query_args = array( + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'posts_per_page' => $batch_size, + 'offset' => $offset, + 'order' => 'ASC', + 'orderby' => 'ID', + ); + + return FeedUploadUtils::get_coupons_data( $query_args ); + } + + /** + * Get the job's batch size. + * + * @return int + * @since 3.5.0 + */ + protected function get_batch_size(): int { + return 25; + } +} diff --git a/includes/Feed/PromotionsFeedHandler.php b/includes/Feed/PromotionsFeedHandler.php new file mode 100644 index 000000000..2d33e18c2 --- /dev/null +++ b/includes/Feed/PromotionsFeedHandler.php @@ -0,0 +1,49 @@ +feed_writer = $feed_writer; + $this->feed_type = FeedManager::PROMOTIONS; + } + + /** + * Get the feed data and return as array of objects. + * Array contents should match headers in PromotionsFeed::PROMOTIONS_FEED_HEADER + * + * @return array + * @since 3.5.0 + */ + public function get_feed_data(): array { + $query_args = array( + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'posts_per_page' => - 1, // retrieve all items + ); + + return FeedUploadUtils::get_coupons_data( $query_args ); + } +} diff --git a/includes/Feed/RatingsAndReviewsFeed.php b/includes/Feed/RatingsAndReviewsFeed.php index 3627e5e7c..2be50eaae 100644 --- a/includes/Feed/RatingsAndReviewsFeed.php +++ b/includes/Feed/RatingsAndReviewsFeed.php @@ -12,12 +12,6 @@ defined( 'ABSPATH' ) || exit; use Automattic\WooCommerce\ActionSchedulerJobFramework\Proxies\ActionScheduler; -use WooCommerce\Facebook\Feed\AbstractFeed; -use WooCommerce\Facebook\Feed\CsvFeedFileWriter; -use WooCommerce\Facebook\Feed\RatingsAndReviewsFeedHandler; -use WooCommerce\Facebook\Feed\FeedManager; -use WooCommerce\Facebook\Framework\Api\Exception; -use WooCommerce\Facebook\Utilities\Heartbeat; /** * Ratings and Reviews Feed class @@ -37,29 +31,28 @@ class RatingsAndReviewsFeed extends AbstractFeed { * @since 3.5.0 */ public function __construct() { - $this->data_stream_name = FeedManager::RATINGS_AND_REVIEWS; - $this->gen_feed_interval = WEEK_IN_SECONDS; - $this->feed_type = 'PRODUCT_RATINGS_AND_REVIEWS'; - $this->feed_url_secret_option_name = self::OPTION_FEED_URL_SECRET . $this->data_stream_name; + $file_writer = new CsvFeedFileWriter( self::get_data_stream_name(), self::RATINGS_AND_REVIEWS_FEED_HEADER ); + $feed_handler = new RatingsAndReviewsFeedHandler( $file_writer ); - $this->feed_handler = new RatingsAndReviewsFeedHandler( new CsvFeedFileWriter( $this->data_stream_name, self::RATINGS_AND_REVIEWS_FEED_HEADER ) ); - $scheduler = new ActionScheduler(); - $this->feed_generator = new RatingsAndReviewsFeedGenerator( $scheduler, $this->feed_handler->get_feed_writer(), $this->data_stream_name ); - $this->feed_generator->init(); - $this->add_hooks( Heartbeat::HOURLY ); + $scheduler = new ActionScheduler(); + $feed_generator = new RatingsAndReviewsFeedGenerator( $scheduler, $file_writer, self::get_data_stream_name() ); + + $this->init( + $file_writer, + $feed_handler, + $feed_generator, + ); } - /** - * Adds the necessary hooks for feed generation and data request handling. - * - * @param string $heartbeat The heartbeat interval for the feed generation. - * - * @since 3.5.0 - */ - protected function add_hooks( string $heartbeat ): void { - add_action( $heartbeat, array( $this, self::SCHEDULE_CALL_BACK ) ); - add_action( self::GENERATE_FEED_ACTION . $this->data_stream_name, array( $this, self::REGENERATE_CALL_BACK ) ); - add_action( self::FEED_GEN_COMPLETE_ACTION . $this->data_stream_name, array( $this, self::UPLOAD_CALL_BACK ) ); - add_action( self::LEGACY_API_PREFIX . self::REQUEST_FEED_ACTION . $this->data_stream_name, array( $this, self::STREAM_CALL_BACK ) ); + protected static function get_feed_type(): string { + return 'PRODUCT_RATINGS_AND_REVIEWS'; + } + + protected static function get_data_stream_name(): string { + return FeedManager::RATINGS_AND_REVIEWS; + } + + protected static function get_feed_gen_interval(): int { + return WEEK_IN_SECONDS; } } diff --git a/tests/Unit/Feed/FeedUploadUtilsTest.php b/tests/Unit/Feed/FeedUploadUtilsTest.php index 4d7219485..a45abe1d4 100644 --- a/tests/Unit/Feed/FeedUploadUtilsTest.php +++ b/tests/Unit/Feed/FeedUploadUtilsTest.php @@ -258,4 +258,425 @@ public function test_get_ratings_and_reviews_data_invalid_product() { $result = \WooCommerce\Facebook\Feed\FeedUploadUtils::get_ratings_and_reviews_data( [] ); $this->assertEmpty( $result, 'Expected no review for comment with invalid product.' ); } + + public function test_get_coupons_data_valid_coupon_with_target_product() { + // Create a target product. + $product1 = new WC_Product_Simple(); + $product1->set_name('Included Product 1'); + $product1->set_slug('included-product-1'); + $product1->set_status('publish'); + $product1->set_sku('product-sku-1'); + $product1->save(); + $included_product1 = $product1->get_id(); + + // Create a coupon with a valid coupon code. + $coupon_id = self::factory()->post->create([ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'post_title' => 'COUPON-CODE-1', + ]); + // Set coupon meta so that it is valid and a percentage discount. + update_post_meta( $coupon_id, 'discount_type', 'percent' ); + update_post_meta( $coupon_id, 'coupon_amount', '15' ); // 15% discount + update_post_meta( $coupon_id, 'free_shipping', 'no' ); + update_post_meta( $coupon_id, 'usage_limit', '' ); + update_post_meta( $coupon_id, 'limit_usage_to_x_items', '' ); + update_post_meta( $coupon_id, 'maximum_amount', '' ); + update_post_meta( $coupon_id, 'email_restrictions', array() ); + update_post_meta( $coupon_id, 'product_ids', array( $product1->get_id() ) ); + + $query_args = [ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'posts_per_page' => -1, // retrieve all items + ]; + + $result = \WooCommerce\Facebook\Feed\FeedUploadUtils::get_coupons_data( $query_args ); + + // Verify that one coupon is returned. + $this->assertCount( 1, $result, 'Should have returned one coupon in the feed data.' ); + $coupon_data = $result[0]; + + // Build the expected coupon shape according to how FeedUploadUtils outputs the data. + $expected_coupon = [ + 'offer_id' => $coupon_id, // coupon ID as an integer + 'title' => 'coupon-code-1', // lowercased coupon post title + 'value_type' => 'PERCENTAGE', + 'percent_off' => '15', // as a string + 'fixed_amount_off' => '', // empty string output + 'application_type' => 'BUYER_APPLIED', + 'target_type' => 'LINE_ITEM', + 'target_granularity' => 'ITEM_LEVEL', + 'target_selection' => 'SPECIFIC_PRODUCTS', + 'start_date_time' => $coupon_data['start_date_time'], // use the output from the coupon post date/time + 'end_date_time' => '', + 'coupon_codes' => ['coupon-code-1'], + 'public_coupon_code' => '', + 'target_filter' => '{"or":[{"retailer_id":{"eq":"product-sku-1_'.$product1->get_id().'"}}]}', + 'target_product_retailer_ids' => '', + 'target_product_group_retailer_ids' => '', + 'target_product_set_retailer_ids' => '', + 'redeem_limit_per_user' => 0, + 'min_subtotal' => '', + 'min_quantity' => '', + 'offer_terms' => '', + 'redemption_limit_per_seller' => 0, + 'target_quantity' => '', + 'prerequisite_filter' => '', + 'prerequisite_product_retailer_ids' => '', + 'prerequisite_product_group_retailer_ids' => '', + 'prerequisite_product_set_retailer_ids' => '', + 'exclude_sale_priced_products' => false, + 'target_shipping_option_types' => '', + ]; + + // Assert that the coupon data exactly matches the expected shape. + $this->assertEquals( $expected_coupon, $coupon_data, 'Coupon feed data does not match expected data structure.' ); + } + + public function test_get_coupons_data_coupon_with_included_excluded_products() { + // Create products for inclusion and exclusion. + $product1 = new WC_Product_Simple(); + $product1->set_name('Included Product 1'); + $product1->set_slug('included-product-1'); + $product1->set_status('publish'); + $product1->set_sku('product-sku-1'); + $product1->save(); + $included_product1 = $product1->get_id(); + + $product2 = new WC_Product_Simple(); + $product2->set_name('Included Product 2'); + $product2->set_slug('included-product-2'); + $product2->set_status('publish'); + $product2->set_sku('product-sku-2'); + $product2->save(); + $included_product2 = $product2->get_id(); + + $product3 = new WC_Product_Simple(); + $product3->set_name('Excluded Product'); + $product3->set_slug('excluded-product'); + $product3->set_status('publish'); + $product3->set_sku('product-sku-3'); + $product3->save(); + $excluded_product = $product3->get_id(); + + // Create a coupon with both included and excluded product and category restrictions. + $coupon_id = self::factory()->post->create([ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'post_title' => 'COUPON-INCL-EXCL', + ]); + // Set coupon meta so that it is valid with a percentage discount. + update_post_meta( $coupon_id, 'discount_type', 'percent' ); + update_post_meta( $coupon_id, 'coupon_amount', '20' ); // 20% discount + update_post_meta( $coupon_id, 'free_shipping', 'no' ); + update_post_meta( $coupon_id, 'usage_limit', '' ); + update_post_meta( $coupon_id, 'limit_usage_to_x_items', '' ); + update_post_meta( $coupon_id, 'maximum_amount', '' ); + update_post_meta( $coupon_id, 'email_restrictions', array() ); + // Set product restrictions. + update_post_meta( $coupon_id, 'product_ids', array( $included_product1, $included_product2 ) ); + update_post_meta( $coupon_id, 'exclude_product_ids', array( $excluded_product ) ); + + $query_args = [ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'posts_per_page' => -1, + ]; + + $result = \WooCommerce\Facebook\Feed\FeedUploadUtils::get_coupons_data( $query_args ); + $this->assertCount( 1, $result, 'Should have returned one coupon in the feed data.' ); + $coupon_data = $result[0]; + + // Build the expected coupon shape. + $expected_coupon = [ + 'offer_id' => $coupon_id, // coupon ID as an integer + 'title' => 'coupon-incl-excl', // lowercased coupon post title + 'value_type' => 'PERCENTAGE', + 'percent_off' => '20', // as a string + 'fixed_amount_off' => '', // empty string output + 'application_type' => 'BUYER_APPLIED', + 'target_type' => 'LINE_ITEM', + 'target_granularity' => 'ITEM_LEVEL', + 'target_selection' => 'SPECIFIC_PRODUCTS', + 'start_date_time' => $coupon_data['start_date_time'], // use the generated start date/time + 'end_date_time' => '', + 'coupon_codes' => ['coupon-incl-excl'], // coupon_codes as an array containing the title + 'public_coupon_code' => '', + 'target_filter' => '{"and":[{"or":[{"retailer_id":{"eq":"product-sku-1_'.$product1->get_id().'"}},{"retailer_id":{"eq":"product-sku-2_'.$product2->get_id().'"}}]},{"and":[{"retailer_id":{"neq":"product-sku-3_'.$product3->get_id().'"}}]}]}', + 'target_product_retailer_ids' => '', + 'target_product_group_retailer_ids' => '', + 'target_product_set_retailer_ids' => '', + 'redeem_limit_per_user' => 0, + 'min_subtotal' => '', + 'min_quantity' => '', + 'offer_terms' => '', + 'redemption_limit_per_seller' => 0, + 'target_quantity' => '', + 'prerequisite_filter' => '', + 'prerequisite_product_retailer_ids' => '', + 'prerequisite_product_group_retailer_ids' => '', + 'prerequisite_product_set_retailer_ids' => '', + 'exclude_sale_priced_products' => false, + 'target_shipping_option_types' => '', + ]; + + $this->assertEquals( $expected_coupon, $coupon_data, 'Coupon feed data with included/excluded restrictions does not match expected data structure.' ); + } + + public function test_get_coupons_data_coupon_with_included_excluded_categories() { + // Create product categories. + $included_cat = self::factory()->term->create([ + 'taxonomy' => 'product_cat', + 'name' => 'Included Category', + ]); + $excluded_cat = self::factory()->term->create([ + 'taxonomy' => 'product_cat', + 'name' => 'Excluded Category', + ]); + + // Create products and assign them to categories. + $product1 = new WC_Product_Simple(); + $product1->set_name('Product In Included Category 1'); + $product1->set_slug('product-in-included-cat-1'); + $product1->set_status('publish'); + $product1->set_sku('product-sku-1'); + $product1->set_category_ids([ $included_cat ]); + $product1->save(); + $prod_id1 = $product1->get_id(); + + $product2 = new WC_Product_Simple(); + $product2->set_name('Product In Included Category 2'); + $product2->set_slug('product-in-included-cat-2'); + $product2->set_status('publish'); + $product2->set_sku('product-sku-2'); + $product2->set_category_ids([ $included_cat ]); + $product2->save(); + $prod_id2 = $product2->get_id(); + + $product3 = new WC_Product_Simple(); + $product3->set_name('Product In Excluded Category'); + $product3->set_slug('product-in-excluded-cat'); + $product3->set_status('publish'); + $product3->set_sku('product-sku-3'); + $product3->set_category_ids([ $excluded_cat ]); + $product3->save(); + $prod_id3 = $product3->get_id(); + + // Create a coupon that restricts by category. + $coupon_id = self::factory()->post->create([ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'post_title' => 'COUPON-CAT-ONLY', + ]); + // Set coupon meta for a percentage discount. + update_post_meta( $coupon_id, 'discount_type', 'percent' ); + update_post_meta( $coupon_id, 'coupon_amount', '15' ); // 15% discount + update_post_meta( $coupon_id, 'free_shipping', 'no' ); + update_post_meta( $coupon_id, 'usage_limit', '' ); + update_post_meta( $coupon_id, 'limit_usage_to_x_items', '' ); + update_post_meta( $coupon_id, 'maximum_amount', '' ); + update_post_meta( $coupon_id, 'email_restrictions', array() ); + // Instead of setting product_ids, set category restrictions. + update_post_meta( $coupon_id, 'product_categories', [ $included_cat ] ); + update_post_meta( $coupon_id, 'exclude_product_categories', [ $excluded_cat ] ); + + $query_args = [ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'posts_per_page' => -1, + ]; + + $result = \WooCommerce\Facebook\Feed\FeedUploadUtils::get_coupons_data( $query_args ); + $this->assertCount( 1, $result, 'Should have returned one coupon in the feed data.' ); + $coupon_data = $result[0]; + + // Expected target_filter: + // The get_target_filter() function pulls products via wc_get_products() based on the category IDs. + // It should return products from the included category (product1 and product2) and products from the excluded category (product3). + // The expected JSON string (assuming WC_Facebookcommerce_Utils::get_fb_retailer_id returns "_") is: + $expected_target_filter = '{"and":[{"or":[{"retailer_id":{"eq":"' . $product1->get_sku() . '_' . $prod_id1 . '"}},{"retailer_id":{"eq":"' . $product2->get_sku() . '_' . $prod_id2 . '"}}]},{"and":[{"retailer_id":{"neq":"' . $product3->get_sku() . '_' . $prod_id3 . '"}}]}]}'; + + // Build the expected coupon shape. + $expected_coupon = [ + 'offer_id' => $coupon_id, // coupon ID as an integer + 'title' => 'coupon-cat-only', // lowercased coupon post title + 'value_type' => 'PERCENTAGE', + 'percent_off' => '15', // as a string + 'fixed_amount_off' => '', // empty string output + 'application_type' => 'BUYER_APPLIED', + 'target_type' => 'LINE_ITEM', + 'target_granularity' => 'ITEM_LEVEL', + 'target_selection' => 'SPECIFIC_PRODUCTS', + 'start_date_time' => $coupon_data['start_date_time'], // generated start date/time + 'end_date_time' => '', + 'coupon_codes' => ['coupon-cat-only'], // coupon_codes as an array containing the code + 'public_coupon_code' => '', + 'target_filter' => $expected_target_filter, + 'target_product_retailer_ids' => '', + 'target_product_group_retailer_ids' => '', + 'target_product_set_retailer_ids' => '', + 'redeem_limit_per_user' => 0, + 'min_subtotal' => '', + 'min_quantity' => '', + 'offer_terms' => '', + 'redemption_limit_per_seller' => 0, + 'target_quantity' => '', + 'prerequisite_filter' => '', + 'prerequisite_product_retailer_ids' => '', + 'prerequisite_product_group_retailer_ids' => '', + 'prerequisite_product_set_retailer_ids' => '', + 'exclude_sale_priced_products' => false, + 'target_shipping_option_types' => '', + ]; + + $this->assertEquals( $expected_coupon, $coupon_data, 'Coupon feed data with included/excluded category restrictions does not match expected data structure.' ); + } + + public function test_get_coupons_data_invalid_coupon_both_amount_and_free_ship() { + $coupon_id = self::factory()->post->create([ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'post_title' => 'VALIDCODE', + ]); + update_post_meta( $coupon_id, 'discount_type', 'percent' ); + update_post_meta( $coupon_id, 'coupon_amount', '10' ); + update_post_meta( $coupon_id, 'free_shipping', 'yes' ); // Conflicting: free shipping + amount + update_post_meta( $coupon_id, 'email_restrictions', array( 'test@example.com' ) ); + update_post_meta( $coupon_id, 'product_brands', array( 'brand1' ) ); + update_post_meta( $coupon_id, 'exclude_product_brands', array( 'brand2' ) ); + + $query_args = [ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'posts_per_page' => -1, + ]; + + $result = \WooCommerce\Facebook\Feed\FeedUploadUtils::get_coupons_data( $query_args ); + + // Expect that the coupon is filtered out as invalid and thus not included in the feed. + $this->assertEmpty( $result, 'Expected no coupon to be returned for an invalid coupon configuration.' ); + } + + public function test_get_coupons_data_invalid_coupon_missing_code() { + // Create a coupon with an empty code. + $coupon_id = self::factory()->post->create([ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'post_title' => '', // Missing code + ]); + update_post_meta($coupon_id, 'discount_type', 'percent'); + update_post_meta($coupon_id, 'coupon_amount', '10'); + $query_args = [ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'posts_per_page' => -1, + ]; + $result = \WooCommerce\Facebook\Feed\FeedUploadUtils::get_coupons_data($query_args); + $this->assertEmpty($result, 'Expected coupon to be invalid if coupon code is missing.'); + } + + public function test_get_coupons_data_invalid_coupon_maximum_spend_set() { + // Create a coupon with a valid code but a maximum spend set. + $coupon_id = self::factory()->post->create([ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'post_title' => 'VALIDCODE', + ]); + update_post_meta($coupon_id, 'discount_type', 'percent'); + update_post_meta($coupon_id, 'coupon_amount', '10'); + // Set maximum spend (should be zero to be valid). + update_post_meta($coupon_id, 'maximum_amount', '50'); + $query_args = [ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'posts_per_page' => -1, + ]; + $result = \WooCommerce\Facebook\Feed\FeedUploadUtils::get_coupons_data($query_args); + $this->assertEmpty($result, 'Expected coupon to be invalid if maximum spend is set.'); + } + + public function test_get_coupons_data_invalid_coupon_allowed_emails_set() { + // Create a coupon with allowed emails specified. + $coupon_id = self::factory()->post->create([ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'post_title' => 'VALIDCODE', + ]); + update_post_meta($coupon_id, 'discount_type', 'percent'); + update_post_meta($coupon_id, 'coupon_amount', '10'); + // Set allowed emails (should be empty to be valid). + $coupon = new WC_Coupon( $coupon_id ); + $coupon->set_email_restrictions(['test@example.com']); + $coupon->save(); + + $query_args = [ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'posts_per_page' => -1, + ]; + + $result = \WooCommerce\Facebook\Feed\FeedUploadUtils::get_coupons_data($query_args); + $this->assertEmpty($result, 'Expected coupon to be invalid if allowed emails are set.'); + } + + public function test_get_coupons_data_invalid_coupon_limit_usage_set() { + // Create a coupon with a limit_usage_to_x_items value set. + $coupon_id = self::factory()->post->create([ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'post_title' => 'VALIDCODE', + ]); + update_post_meta($coupon_id, 'discount_type', 'percent'); + update_post_meta($coupon_id, 'coupon_amount', '10'); + // Set limit_usage_to_x_items to a positive value. + update_post_meta($coupon_id, 'limit_usage_to_x_items', '5'); + $query_args = [ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'posts_per_page' => -1, + ]; + $result = \WooCommerce\Facebook\Feed\FeedUploadUtils::get_coupons_data($query_args); + $this->assertEmpty($result, 'Expected coupon to be invalid if limit_usage_to_x_items is set.'); + } + + public function test_get_coupons_data_invalid_coupon_brand_targeting() { + // Create a coupon that uses product brand targeting. + $coupon_id = self::factory()->post->create([ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'post_title' => 'VALIDCODE', + ]); + update_post_meta($coupon_id, 'discount_type', 'percent'); + update_post_meta($coupon_id, 'coupon_amount', '10'); + // Set product_brands meta so that it is non-empty. + update_post_meta($coupon_id, 'product_brands', ['brand1']); + $query_args = [ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'posts_per_page' => -1, + ]; + $result = \WooCommerce\Facebook\Feed\FeedUploadUtils::get_coupons_data($query_args); + $this->assertEmpty($result, 'Expected coupon to be invalid if product_brands targeting is used.'); + } + + public function test_get_coupons_data_invalid_coupon_excluded_brand_targeting() { + // Create a coupon that uses excluded product brand targeting. + $coupon_id = self::factory()->post->create([ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'post_title' => 'VALIDCODE', + ]); + update_post_meta($coupon_id, 'discount_type', 'percent'); + update_post_meta($coupon_id, 'coupon_amount', '10'); + // Set exclude_product_brands meta so that it is non-empty. + update_post_meta($coupon_id, 'exclude_product_brands', ['brand2']); + $query_args = [ + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'posts_per_page' => -1, + ]; + $result = \WooCommerce\Facebook\Feed\FeedUploadUtils::get_coupons_data($query_args); + $this->assertEmpty($result, 'Expected coupon to be invalid if excluded product_brands targeting is used.'); + } }