<?php declare(strict_types = 1); namespace Automattic\WooCommerce\GoogleListingsAndAds\Coupon; use Automattic\WooCommerce\GoogleListingsAndAds\Google\DeleteCouponEntry; use Automattic\WooCommerce\GoogleListingsAndAds\Google\GooglePromotionService; use Automattic\WooCommerce\GoogleListingsAndAds\Google\InvalidCouponEntry; use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service; use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService; use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\TargetAudience; use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC; use Google\Exception as GoogleException; use Symfony\Component\Validator\Validator\ValidatorInterface; use Exception; use WC_Coupon; defined( 'ABSPATH' ) || exit(); /** * Class CouponSyncer * * @package Automattic\WooCommerce\GoogleListingsAndAds\Coupon */ class CouponSyncer implements Service { public const FAILURE_THRESHOLD = 5; // Number of failed attempts allowed per FAILURE_THRESHOLD_WINDOW public const FAILURE_THRESHOLD_WINDOW = '3 hours'; /** * * @var GooglePromotionService */ protected $google_service; /** * * @var CouponHelper */ protected $coupon_helper; /** * * @var ValidatorInterface */ protected $validator; /** * * @var MerchantCenterService */ protected $merchant_center; /** * * @var WC */ protected $wc; /** * @var TargetAudience */ protected $target_audience; /** * CouponSyncer constructor. * * @param GooglePromotionService $google_service * @param CouponHelper $coupon_helper * @param ValidatorInterface $validator * @param MerchantCenterService $merchant_center * @param TargetAudience $target_audience * @param WC $wc */ public function __construct( GooglePromotionService $google_service, CouponHelper $coupon_helper, ValidatorInterface $validator, MerchantCenterService $merchant_center, TargetAudience $target_audience, WC $wc ) { $this->google_service = $google_service; $this->coupon_helper = $coupon_helper; $this->validator = $validator; $this->merchant_center = $merchant_center; $this->target_audience = $target_audience; $this->wc = $wc; } /** * Submit a WooCommerce coupon to Google Merchant Center. * * @param WC_Coupon $coupon * * @throws CouponSyncerException If there are any errors while syncing coupon with Google Merchant Center. */ public function update( WC_Coupon $coupon ) { $this->validate_merchant_center_setup(); if ( ! $this->coupon_helper->is_sync_ready( $coupon ) ) { do_action( 'woocommerce_gla_debug_message', sprintf( 'Skipping coupon (ID: %s) because it is not ready to be synced.', $coupon->get_id() ), __METHOD__ ); return; } $target_country = $this->target_audience->get_main_target_country(); if ( ! $this->merchant_center->is_promotion_supported_country( $target_country ) ) { do_action( 'woocommerce_gla_debug_message', sprintf( 'Skipping coupon (ID: %s) because it is not supported in main target country %s.', $coupon->get_id(), $target_country ), __METHOD__ ); return; } $adapted_coupon = new WCCouponAdapter( [ 'wc_coupon' => $coupon, 'targetCountry' => $target_country, ] ); $validation_result = $this->validate_coupon( $adapted_coupon ); if ( $validation_result instanceof InvalidCouponEntry ) { $this->coupon_helper->mark_as_invalid( $coupon, $validation_result->get_errors() ); do_action( 'woocommerce_gla_debug_message', sprintf( 'Skipping coupon (ID: %s) because it does not pass validation: %s', $coupon->get_id(), json_encode( $validation_result ) ), __METHOD__ ); return; } try { do_action( 'woocommerce_gla_debug_message', sprintf( 'Start to upload coupon (ID: %s) as promotion structure: %s', $coupon->get_id(), json_encode( $adapted_coupon ) ), __METHOD__ ); $response = $this->google_service->create( $adapted_coupon ); $this->coupon_helper->mark_as_synced( $coupon, $response->getId(), $target_country ); do_action( 'woocommerce_gla_updated_coupon', $adapted_coupon ); do_action( 'woocommerce_gla_debug_message', sprintf( "Submitted promotion:\n%s", json_encode( $adapted_coupon ) ), __METHOD__ ); } catch ( GoogleException $google_exception ) { $invalid_promotion = new InvalidCouponEntry( $coupon->get_id(), [ $google_exception->getCode() => $google_exception->getMessage(), ], $target_country ); $this->coupon_helper->mark_as_invalid( $coupon, $invalid_promotion->get_errors() ); $this->handle_update_errors( [ $invalid_promotion ] ); do_action( 'woocommerce_gla_debug_message', sprintf( "Promotion failed to sync with Merchant Center:\n%s", json_encode( $invalid_promotion ) ), __METHOD__ ); } catch ( Exception $exception ) { do_action( 'woocommerce_gla_exception', $exception, __METHOD__ ); throw new CouponSyncerException( sprintf( 'Error updating Google promotion: %s', $exception->getMessage() ), 0, $exception ); } } /** * * @param WCCouponAdapter $coupon * * @return InvalidCouponEntry|true */ protected function validate_coupon( WCCouponAdapter $coupon ) { $violations = $this->validator->validate( $coupon ); if ( 0 !== count( $violations ) ) { $invalid_promotion = new InvalidCouponEntry( $coupon->get_wc_coupon_id() ); $invalid_promotion->map_validation_violations( $violations ); return $invalid_promotion; } return true; } /** * Delete a WooCommerce coupon from Google Merchant Center. * * @param DeleteCouponEntry $coupon * * @throws CouponSyncerException If there are any errors while deleting coupon from Google Merchant Center. */ public function delete( DeleteCouponEntry $coupon ) { $this->validate_merchant_center_setup(); $deleted_promotions = []; $invalid_promotions = []; $synced_google_ids = $coupon->get_synced_google_ids(); $wc_coupon = $this->wc->maybe_get_coupon( $coupon->get_wc_coupon_id() ); $wc_coupon_exist = $wc_coupon instanceof WC_Coupon; foreach ( $synced_google_ids as $target_country => $google_id ) { try { $adapted_coupon = $coupon->get_google_promotion(); $adapted_coupon->setTargetCountry( $target_country ); do_action( 'woocommerce_gla_debug_message', sprintf( 'Start to delete coupon (ID: %s) as promotion structure: %s', $coupon->get_wc_coupon_id(), json_encode( $adapted_coupon ) ), __METHOD__ ); // DeleteCouponEntry is generated with promotion effective date expired // when WC coupon is able to be deleted. // To soft-delete the promotion from Google side, // we will update Google promotion with expired effective date. $response = $this->google_service->create( $adapted_coupon ); array_push( $deleted_promotions, $response ); if ( $wc_coupon_exist ) { $this->coupon_helper->remove_google_id_by_country( $wc_coupon, $target_country ); } } catch ( GoogleException $google_exception ) { array_push( $invalid_promotions, new InvalidCouponEntry( $coupon->get_wc_coupon_id(), [ $google_exception->getCode() => $google_exception->getMessage(), ], $target_country, $google_id ) ); } catch ( Exception $exception ) { do_action( 'woocommerce_gla_exception', $exception, __METHOD__ ); throw new CouponSyncerException( sprintf( 'Error deleting Google promotion: %s', $exception->getMessage() ), 0, $exception ); } } if ( ! empty( $invalid_promotions ) ) { $this->handle_delete_errors( $invalid_promotions ); do_action( 'woocommerce_gla_debug_message', sprintf( "Failed to delete %s promotions from Merchant Center:\n%s", count( $invalid_promotions ), json_encode( $invalid_promotions ) ), __METHOD__ ); } elseif ( $wc_coupon_exist ) { $this->coupon_helper->mark_as_unsynced( $wc_coupon ); } do_action( 'woocommerce_gla_deleted_promotions', $deleted_promotions, $invalid_promotions ); do_action( 'woocommerce_gla_debug_message', sprintf( "Deleted %s promoitons:\n%s", count( $deleted_promotions ), json_encode( $deleted_promotions ) ), __METHOD__ ); } /** * Return whether coupon is supported as visible on Google. * * @param WC_Coupon $coupon * * @return bool */ public static function is_coupon_supported( WC_Coupon $coupon ): bool { if ( $coupon->get_virtual() ) { return false; } if ( ! empty( $coupon->get_email_restrictions() ) ) { return false; } if ( ! empty( $coupon->get_exclude_sale_items() ) && $coupon->get_exclude_sale_items() ) { return false; } return true; } /** * Return the list of supported coupon types. * * @return array */ public static function get_supported_coupon_types(): array { return (array) apply_filters( 'woocommerce_gla_supported_coupon_types', [ 'percent', 'fixed_cart', 'fixed_product' ] ); } /** * Return the list of coupon types we will hide functionality for (default none). * * @since 1.2.0 * * @return array */ public static function get_hidden_coupon_types(): array { return (array) apply_filters( 'woocommerce_gla_hidden_coupon_types', [] ); } /** * * @param InvalidCouponEntry[] $invalid_coupons */ protected function handle_update_errors( array $invalid_coupons ) { // Get a coupon id to country mappings. $internal_error_coupon_ids = []; foreach ( $invalid_coupons as $invalid_coupon ) { if ( $invalid_coupon->has_error( GooglePromotionService::INTERNAL_ERROR_CODE ) ) { $coupon_id = $invalid_coupon->get_wc_coupon_id(); $internal_error_coupon_ids[ $coupon_id ] = $invalid_coupon->get_target_country(); } } if ( ! empty( $internal_error_coupon_ids ) && apply_filters( 'woocommerce_gla_coupons_update_retry_on_failure', true, $internal_error_coupon_ids ) ) { do_action( 'woocommerce_gla_retry_update_coupons', $internal_error_coupon_ids ); do_action( 'woocommerce_gla_error', sprintf( 'Internal API errors while submitting the following coupons: %s', join( ', ', $internal_error_coupon_ids ) ), __METHOD__ ); } } /** * * @param BatchInvalidCouponEntry[] $invalid_coupons */ protected function handle_delete_errors( array $invalid_coupons ) { // Get all wc coupon id to google id mappings that have internal errors. $internal_error_coupon_ids = []; foreach ( $invalid_coupons as $invalid_coupon ) { if ( $invalid_coupon->has_error( GooglePromotionService::INTERNAL_ERROR_CODE ) ) { $coupon_id = $invalid_coupon->get_wc_coupon_id(); $internal_error_coupon_ids[ $coupon_id ] = $invalid_coupon->get_google_promotion_id(); } } if ( ! empty( $internal_error_coupon_ids ) && apply_filters( 'woocommerce_gla_coupons_delete_retry_on_failure', true, $internal_error_coupon_ids ) ) { do_action( 'woocommerce_gla_retry_delete_coupons', $internal_error_coupon_ids ); do_action( 'woocommerce_gla_error', sprintf( 'Internal API errors while deleting the following coupons: %s', join( ', ', $internal_error_coupon_ids ) ), __METHOD__ ); } } /** * Validates whether Merchant Center is set up and connected. * * @throws CouponSyncerException If Google Merchant Center is not set up and connected. */ protected function validate_merchant_center_setup(): void { if ( ! $this->merchant_center->is_ready_for_syncing() ) { do_action( 'woocommerce_gla_error', 'Cannot sync any coupons before setting up Google Merchant Center.', __METHOD__ ); throw new CouponSyncerException( __( 'Google Merchant Center has not been set up correctly. Please review your configuration.', 'google-listings-and-ads' ) ); } } }