HEX
Server: LiteSpeed
System: Linux eko108.isimtescil.net 4.18.0-477.21.1.lve.1.el8.x86_64 #1 SMP Tue Sep 5 23:08:35 UTC 2023 x86_64
User: uyarreklamcomtr (11202)
PHP: 7.4.33
Disabled: opcache_get_status
Upload Files
File: /var/www/vhosts/uyarreklam.com.tr/httpdocs/Coupon.tar
CouponHelper.php000064400000027474151542752640007711 0ustar00<?php
declare(strict_types = 1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Coupon;

use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\NotificationStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\SyncStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications\HelperNotificationInterface;
use WC_Coupon;
defined( 'ABSPATH' ) || exit();

/**
 * Class CouponHelper
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Coupon
 */
class CouponHelper implements Service, HelperNotificationInterface {

	use PluginHelper;

	/**
	 *
	 * @var CouponMetaHandler
	 */
	protected $meta_handler;

	/**
	 *
	 * @var WC
	 */
	protected $wc;

	/**
	 *
	 * @var MerchantCenterService
	 */
	protected $merchant_center;

	/**
	 * CouponHelper constructor.
	 *
	 * @param CouponMetaHandler     $meta_handler
	 * @param WC                    $wc
	 * @param MerchantCenterService $merchant_center
	 */
	public function __construct(
		CouponMetaHandler $meta_handler,
		WC $wc,
		MerchantCenterService $merchant_center
	) {
		$this->meta_handler    = $meta_handler;
		$this->wc              = $wc;
		$this->merchant_center = $merchant_center;
	}

	/**
	 * Mark the item as notified.
	 *
	 * @param WC_Coupon $coupon
	 *
	 * @return void
	 */
	public function mark_as_notified( $coupon ): void {
		$this->meta_handler->update_synced_at( $coupon, time() );
		$this->meta_handler->update_sync_status( $coupon, SyncStatus::SYNCED );
		$this->update_empty_visibility( $coupon );
	}

	/**
	 * Mark a coupon as synced. This function accepts nullable $google_id,
	 * which guarantees version compatibility for Alpha, Beta and stable verison promtoion APIs.
	 *
	 * @param WC_Coupon   $coupon
	 * @param string|null $google_id
	 * @param string      $target_country
	 */
	public function mark_as_synced(
		WC_Coupon $coupon,
		?string $google_id,
		string $target_country
	) {
		$this->meta_handler->update_synced_at( $coupon, time() );
		$this->meta_handler->update_sync_status( $coupon, SyncStatus::SYNCED );
		$this->update_empty_visibility( $coupon );

		// merge and update all google ids
		$current_google_ids = $this->meta_handler->get_google_ids( $coupon );
		$current_google_ids = ! empty( $current_google_ids ) ? $current_google_ids : [];
		$google_ids         = array_unique(
			array_merge(
				$current_google_ids,
				[
					$target_country => $google_id,
				]
			)
		);
		$this->meta_handler->update_google_ids( $coupon, $google_ids );
	}

	/**
	 *
	 * @param WC_Coupon $coupon
	 */
	public function mark_as_unsynced( $coupon ): void {
		$this->meta_handler->delete_synced_at( $coupon );
		$this->meta_handler->update_sync_status( $coupon, SyncStatus::NOT_SYNCED );
		$this->meta_handler->delete_google_ids( $coupon );
		$this->meta_handler->delete_errors( $coupon );
		$this->meta_handler->delete_failed_sync_attempts( $coupon );
		$this->meta_handler->delete_sync_failed_at( $coupon );
	}

	/**
	 *
	 * @param WC_Coupon $coupon
	 * @param string    $target_country
	 */
	public function remove_google_id_by_country( WC_Coupon $coupon, string $target_country ) {
		$google_ids = $this->meta_handler->get_google_ids( $coupon );
		if ( empty( $google_ids ) ) {
			return;
		}

		unset( $google_ids[ $target_country ] );

		if ( ! empty( $google_ids ) ) {
			$this->meta_handler->update_google_ids( $coupon, $google_ids );
		} else {
			// if there are no Google IDs left then this coupon is no longer considered "synced"
			$this->mark_as_unsynced( $coupon );
		}
	}

	/**
	 * Marks a WooCommerce coupon as invalid and stores the errors in a meta data key.
	 *
	 * @param WC_Coupon            $coupon
	 * @param InvalidCouponEntry[] $errors
	 */
	public function mark_as_invalid( WC_Coupon $coupon, array $errors ) {
		// bail if no errors exist
		if ( empty( $errors ) ) {
			return;
		}

		$this->meta_handler->update_errors( $coupon, $errors );
		$this->meta_handler->update_sync_status( $coupon, SyncStatus::HAS_ERRORS );
		$this->update_empty_visibility( $coupon );

		// TODO: Update failed sync attempts count in case of internal errors
	}

	/**
	 * Marks a WooCommerce coupon as pending synchronization.
	 *
	 * @param WC_Coupon $coupon
	 */
	public function mark_as_pending( WC_Coupon $coupon ) {
		$this->meta_handler->update_sync_status( $coupon, SyncStatus::PENDING );
		$this->meta_handler->delete_errors( $coupon );
	}

	/**
	 * Update empty (NOT EXIST) visibility meta values to SYNC_AND_SHOW.
	 *
	 * @param WC_Coupon $coupon
	 */
	protected function update_empty_visibility( WC_Coupon $coupon ): void {
		$visibility = $this->meta_handler->get_visibility( $coupon );

		if ( empty( $visibility ) ) {
			$this->meta_handler->update_visibility(
				$coupon,
				ChannelVisibility::SYNC_AND_SHOW
			);
		}
	}

	/**
	 *
	 * @param WC_Coupon $coupon
	 *
	 * @return string[]|null An array of Google IDs stored for each WooCommerce coupon
	 */
	public function get_synced_google_ids( WC_Coupon $coupon ): ?array {
		return $this->meta_handler->get_google_ids( $coupon );
	}

	/**
	 * Get WooCommerce coupon
	 *
	 * @param int $coupon_id
	 *
	 * @return WC_Coupon
	 *
	 * @throws InvalidValue If the given ID doesn't reference a valid coupon.
	 */
	public function get_wc_coupon( int $coupon_id ): WC_Coupon {
		$coupon = $this->wc->maybe_get_coupon( $coupon_id );

		if ( ! $coupon instanceof WC_Coupon ) {
			throw InvalidValue::not_valid_coupon_id( $coupon_id );
		}

		return $coupon;
	}

	/**
	 *
	 * @param WC_Coupon $coupon
	 *
	 * @return bool
	 */
	public function is_coupon_synced( WC_Coupon $coupon ): bool {
		$synced_at  = $this->meta_handler->get_synced_at( $coupon );
		$google_ids = $this->meta_handler->get_google_ids( $coupon );

		return ! empty( $synced_at ) && ! empty( $google_ids );
	}

	/**
	 *
	 * @param WC_Coupon $coupon
	 *
	 * @return bool
	 */
	public function is_sync_ready( WC_Coupon $coupon ): bool {
		return ( ChannelVisibility::SYNC_AND_SHOW ===
			$this->get_channel_visibility( $coupon ) ) &&
			( CouponSyncer::is_coupon_supported( $coupon ) ) &&
			( ! $coupon->get_virtual() );
	}

	/**
	 * Whether the sync has failed repeatedly for the coupon within the given timeframe.
	 *
	 * @param WC_Coupon $coupon
	 *
	 * @return bool
	 *
	 * @see CouponSyncer::FAILURE_THRESHOLD The number of failed attempts allowed per timeframe
	 * @see CouponSyncer::FAILURE_THRESHOLD_WINDOW The specified timeframe
	 */
	public function is_sync_failed_recently( WC_Coupon $coupon ): bool {
		$failed_attempts = $this->meta_handler->get_failed_sync_attempts(
			$coupon
		);
		$failed_at       = $this->meta_handler->get_sync_failed_at( $coupon );

		// if it has failed more times than the specified threshold AND if syncing it has failed within the specified window
		return $failed_attempts > CouponSyncer::FAILURE_THRESHOLD &&
			$failed_at >
			strtotime( sprintf( '-%s', CouponSyncer::FAILURE_THRESHOLD_WINDOW ) );
	}

	/**
	 *
	 * @param WC_Coupon $coupon
	 *
	 * @return string
	 */
	public function get_channel_visibility( WC_Coupon $coupon ): string {
		$visibility = $this->meta_handler->get_visibility( $coupon );

		if ( empty( $visibility ) ) {
			do_action(
				'woocommerce_gla_debug_message',
				sprintf(
					'Channel visibility forced to "%s" for visibility unknown (Post ID: %s).',
					ChannelVisibility::DONT_SYNC_AND_SHOW,
					$coupon->get_id()
				),
				__METHOD__
			);
			return ChannelVisibility::DONT_SYNC_AND_SHOW;
		}

		return $visibility;
	}

	/**
	 * Return a string indicating sync status based on several factors.
	 *
	 * @param WC_Coupon $coupon
	 *
	 * @return string|null
	 */
	public function get_sync_status( WC_Coupon $coupon ): ?string {
		return $this->meta_handler->get_sync_status( $coupon );
	}

	/**
	 * Return the string indicating the coupon status as reported by the Merchant Center.
	 *
	 * @param WC_Coupon $coupon
	 *
	 * @return string|null
	 */
	public function get_mc_status( WC_Coupon $coupon ): ?string {
		try {
			return $this->meta_handler->get_mc_status( $coupon );
		} catch ( InvalidValue $exception ) {
			do_action(
				'woocommerce_gla_debug_message',
				sprintf(
					'Coupon status returned null for invalid coupon (ID: %s).',
					$coupon->get_id()
				),
				__METHOD__
			);

			return null;
		}
	}

	/**
	 * Get validation errors for a specific coupon.
	 * Combines errors for variable coupons, which have a variation-indexed array of errors.
	 *
	 * @param WC_Coupon $coupon
	 *
	 * @return array
	 */
	public function get_validation_errors( WC_Coupon $coupon ): array {
		$errors = $this->meta_handler->get_errors( $coupon ) ?: [];

		$first_key = array_key_first( $errors );
		if ( ! empty( $errors ) && is_array( $errors[ $first_key ] ) ) {
			$errors = array_unique( array_merge( ...$errors ) );
		}

		return $errors;
	}

	/**
	 * Indicates if a coupon is ready for sending Notifications.
	 * A coupon is ready to send notifications if its sync ready and the post status is publish.
	 *
	 * @param WC_Coupon $coupon
	 *
	 * @return bool
	 */
	public function is_ready_to_notify( WC_Coupon $coupon ): bool {
		$is_ready = $this->is_sync_ready( $coupon ) && $coupon->get_status() === 'publish';

		/**
		 * Allow users to filter if a coupon is ready to notify.
		 *
		 * @since 2.8.0
		 *
		 * @param bool $value The current filter value.
		 * @param WC_Coupon $coupon The coupon for the notification.
		 */
		return apply_filters( 'woocommerce_gla_coupon_is_ready_to_notify', $is_ready, $coupon );
	}


	/**
	 * Indicates if a coupon was already notified about its creation.
	 * Notice we consider synced coupons in MC as notified for creation.
	 *
	 * @param WC_Coupon $coupon
	 *
	 * @return bool
	 */
	public function has_notified_creation( WC_Coupon $coupon ): bool {
		$valid_has_notified_creation_statuses = [
			NotificationStatus::NOTIFICATION_CREATED,
			NotificationStatus::NOTIFICATION_UPDATED,
			NotificationStatus::NOTIFICATION_PENDING_UPDATE,
			NotificationStatus::NOTIFICATION_PENDING_DELETE,
		];

		return in_array(
			$this->meta_handler->get_notification_status( $coupon ),
			$valid_has_notified_creation_statuses,
			true
		) || $this->is_coupon_synced( $coupon );
	}

	/**
	 * Set the notification status for a WooCommerce coupon.
	 *
	 * @param WC_Coupon $coupon
	 * @param string    $status
	 */
	public function set_notification_status( $coupon, $status ): void {
		$this->meta_handler->update_notification_status( $coupon, $status );
	}

	/**
	 * Indicates if a coupon is ready for sending a create Notification.
	 * A coupon is ready to send create notifications if is ready to notify and has not sent create notification yet.
	 *
	 * @param WC_Coupon $coupon
	 *
	 * @return bool
	 */
	public function should_trigger_create_notification( $coupon ): bool {
		return $this->is_ready_to_notify( $coupon ) && ! $this->has_notified_creation( $coupon );
	}

	/**
	 * Indicates if a coupon is ready for sending an update Notification.
	 * A coupon is ready to send update notifications if is ready to notify and has sent create notification already.
	 *
	 * @param WC_Coupon $coupon
	 *
	 * @return bool
	 */
	public function should_trigger_update_notification( $coupon ): bool {
		return $this->is_ready_to_notify( $coupon ) && $this->has_notified_creation( $coupon );
	}

	/**
	 * Indicates if a coupon is ready for sending a delete Notification.
	 * A coupon is ready to send delete notifications if it is not ready to notify and has sent create notification already.
	 *
	 * @param WC_Coupon $coupon
	 *
	 * @return bool
	 */
	public function should_trigger_delete_notification( $coupon ): bool {
		return ! $this->is_ready_to_notify( $coupon ) && $this->has_notified_creation( $coupon );
	}
}
CouponMetaHandler.php000064400000014542151542752640010646 0ustar00<?php
declare(strict_types = 1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Coupon;

use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidMeta;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use BadMethodCallException;
use WC_Coupon;
defined( 'ABSPATH' ) || exit();

/**
 * Class CouponMetaHandler
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Coupon
 *
 * @method update_synced_at( WC_Coupon $coupon, $value )
 * @method delete_synced_at( WC_Coupon $coupon )
 * @method get_synced_at( WC_Coupon $coupon ): int|null
 * @method update_google_ids( WC_Coupon $coupon, array $value )
 * @method delete_google_ids( WC_Coupon $coupon )
 * @method get_google_ids( WC_Coupon $coupon ): array|null
 * @method update_visibility( WC_Coupon $coupon, $value )
 * @method delete_visibility( WC_Coupon $coupon )
 * @method get_visibility( WC_Coupon $coupon ): string|null
 * @method update_errors( WC_Coupon $coupon, array $value )
 * @method delete_errors( WC_Coupon $coupon )
 * @method get_errors( WC_Coupon $coupon ): array|null
 * @method update_failed_sync_attempts( WC_Coupon $coupon, int $value )
 * @method delete_failed_sync_attempts( WC_Coupon $coupon )
 * @method get_failed_sync_attempts( WC_Coupon $coupon ): int|null
 * @method update_sync_failed_at( WC_Coupon $coupon, int $value )
 * @method delete_sync_failed_at( WC_Coupon $coupon )
 * @method get_sync_failed_at( WC_Coupon $coupon ): int|null
 * @method update_sync_status( WC_Coupon $coupon, string $value )
 * @method delete_sync_status( WC_Coupon $coupon )
 * @method get_sync_status( WC_Coupon $coupon ): string|null
 * @method update_mc_status( WC_Coupon $coupon, string $value )
 * @method delete_mc_status( WC_Coupon $coupon )
 * @method get_mc_status( WC_Coupon $coupon ): string|null
 * @method update_notification_status( WC_Coupon $coupon, string $value )
 * @method delete_notification_status( WC_Coupon $coupon )
 * @method get_notification_status( WC_Coupon $coupon ): string|null
 */
class CouponMetaHandler implements Service {

	use PluginHelper;

	public const KEY_SYNCED_AT = 'synced_at';

	public const KEY_GOOGLE_IDS = 'google_ids';

	public const KEY_VISIBILITY = 'visibility';

	public const KEY_ERRORS = 'errors';

	public const KEY_FAILED_SYNC_ATTEMPTS = 'failed_sync_attempts';

	public const KEY_SYNC_FAILED_AT = 'sync_failed_at';

	public const KEY_SYNC_STATUS = 'sync_status';

	public const KEY_MC_STATUS = 'mc_status';

	public const KEY_NOTIFICATION_STATUS = 'notification_status';


	protected const TYPES = [
		self::KEY_SYNCED_AT            => 'int',
		self::KEY_GOOGLE_IDS           => 'array',
		self::KEY_VISIBILITY           => 'string',
		self::KEY_ERRORS               => 'array',
		self::KEY_FAILED_SYNC_ATTEMPTS => 'int',
		self::KEY_SYNC_FAILED_AT       => 'int',
		self::KEY_SYNC_STATUS          => 'string',
		self::KEY_MC_STATUS            => 'string',
		self::KEY_NOTIFICATION_STATUS  => 'string',
	];

	/**
	 *
	 * @param string $name
	 * @param mixed  $arguments
	 *
	 * @return mixed
	 *
	 * @throws BadMethodCallException If the method that's called doesn't exist.
	 * @throws InvalidMeta If the meta key is invalid.
	 */
	public function __call( string $name, $arguments ) {
		$found_matches = preg_match( '/^([a-z]+)_([\w\d]+)$/i', $name, $matches );

		if ( ! $found_matches ) {
			throw new BadMethodCallException(
				sprintf(
					'The method %s does not exist in class CouponMetaHandler',
					$name
				)
			);
		}

		[
			$function_name,
			$method,
			$key
		] = $matches;

		// validate the method
		if ( ! in_array(
			$method,
			[
				'update',
				'delete',
				'get',
			],
			true
		) ) {
			throw new BadMethodCallException(
				sprintf(
					'The method %s does not exist in class CouponMetaHandler',
					$function_name
				)
			);
		}

		// set the value as the third argument if method is `update`
		if ( 'update' === $method ) {
			$arguments[2] = $arguments[1];
		}
		// set the key as the second argument
		$arguments[1] = $key;

		return call_user_func_array(
			[
				$this,
				$method,
			],
			$arguments
		);
	}

	/**
	 *
	 * @param WC_Coupon $coupon
	 * @param string    $key
	 * @param mixed     $value
	 *
	 * @throws InvalidMeta If the meta key is invalid.
	 */
	public function update( WC_Coupon $coupon, string $key, $value ) {
		self::validate_meta_key( $key );

		if ( isset( self::TYPES[ $key ] ) ) {
			if ( in_array(
				self::TYPES[ $key ],
				[
					'bool',
					'boolean',
				],
				true
			) ) {
				$value = wc_bool_to_string( $value );
			} else {
				settype( $value, self::TYPES[ $key ] );
			}
		}

		$coupon->update_meta_data( $this->prefix_meta_key( $key ), $value );
		$coupon->save_meta_data();
	}

	/**
	 *
	 * @param WC_Coupon $coupon
	 * @param string    $key
	 *
	 * @throws InvalidMeta If the meta key is invalid.
	 */
	public function delete( WC_Coupon $coupon, string $key ) {
		self::validate_meta_key( $key );

		$coupon->delete_meta_data( $this->prefix_meta_key( $key ) );
		$coupon->save_meta_data();
	}

	/**
	 *
	 * @param WC_Coupon $coupon
	 * @param string    $key
	 *
	 * @return mixed The value, or null if the meta key doesn't exist.
	 *
	 * @throws InvalidMeta If the meta key is invalid.
	 */
	public function get( WC_Coupon $coupon, string $key ) {
		self::validate_meta_key( $key );

		$value = null;
		if ( $coupon->meta_exists( $this->prefix_meta_key( $key ) ) ) {
			$value = $coupon->get_meta( $this->prefix_meta_key( $key ), true );

			if ( isset( self::TYPES[ $key ] ) &&
				in_array(
					self::TYPES[ $key ],
					[
						'bool',
						'boolean',
					],
					true
				) ) {
				$value = wc_string_to_bool( $value );
			}
		}

		return $value;
	}

	/**
	 *
	 * @param string $key
	 *
	 * @throws InvalidMeta If the meta key is invalid.
	 */
	protected static function validate_meta_key( string $key ) {
		if ( ! self::is_meta_key_valid( $key ) ) {
			do_action(
				'woocommerce_gla_error',
				sprintf( 'Coupon meta key is invalid: %s', $key ),
				__METHOD__
			);

			throw InvalidMeta::invalid_key( $key );
		}
	}

	/**
	 *
	 * @param string $key
	 *
	 * @return bool Whether the meta key is valid.
	 */
	public static function is_meta_key_valid( string $key ): bool {
		return isset( self::TYPES[ $key ] );
	}

	/**
	 * Returns all available meta keys.
	 *
	 * @return array
	 */
	public static function get_all_meta_keys(): array {
		return array_keys( self::TYPES );
	}
}
CouponSyncer.php000064400000031061151542752640007720 0ustar00<?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 Automattic\WooCommerce\GoogleListingsAndAds\Vendor\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(),
					wp_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(),
					wp_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",
					wp_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",
					wp_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(),
						wp_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 ),
					wp_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 ),
				wp_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 connected and ready for pushing data.
	 *
	 * @throws CouponSyncerException If Google Merchant Center is not set up and connected or is not ready for pushing data.
	 */
	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'
				)
			);
		}

		if ( ! $this->merchant_center->should_push() ) {
			do_action(
				'woocommerce_gla_error',
				'Cannot push any coupons because they are being fetched automatically.',
				__METHOD__
			);

			throw new CouponSyncerException(
				__(
					'Pushing Coupons will not run if the automatic data fetching is enabled. Please review your configuration in Google Listing and Ads settings.',
					'google-listings-and-ads'
				)
			);
		}
	}
}
CouponSyncerException.php000064400000000655151542752640011604 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Coupon;

use Automattic\WooCommerce\GoogleListingsAndAds\Exception\GoogleListingsAndAdsException;
use Exception;

defined( 'ABSPATH' ) || exit;

/**
 * Class CouponSyncerException
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Coupon
 */
class CouponSyncerException extends Exception implements GoogleListingsAndAdsException {
}
SyncerHooks.php000064400000032770151542752640007550 0ustar00<?php
declare(strict_types = 1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Coupon;

use Automattic\WooCommerce\GoogleListingsAndAds\Google\DeleteCouponEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\NotificationsService;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\DeleteCoupon;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Notifications\CouponNotificationJob;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateCoupon;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\NotificationStatus;
use WC_Coupon;
defined( 'ABSPATH' ) || exit();

/**
 * Class SyncerHooks
 *
 * Hooks to various WooCommerce and WordPress actions to provide automatic coupon sync functionality.
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Coupon
 */
class SyncerHooks implements Service, Registerable {

	use PluginHelper;

	protected const SCHEDULE_TYPE_UPDATE = 'update';

	protected const SCHEDULE_TYPE_DELETE = 'delete';

	/**
	 * Array of strings mapped to coupon IDs indicating that they have been already
	 * scheduled for update or delete during current request.
	 * Used to avoid scheduling
	 * duplicate jobs.
	 *
	 * @var string[]
	 */
	protected $already_scheduled = [];

	/**
	 *
	 * @var DeleteCouponEntry[][]
	 */
	protected $delete_requests_map;

	/**
	 *
	 * @var CouponHelper
	 */
	protected $coupon_helper;

	/**
	 * @var JobRepository
	 */
	protected $job_repository;

	/**
	 *
	 * @var MerchantCenterService
	 */
	protected $merchant_center;

	/**
	 * @var NotificationsService
	 */
	protected $notifications_service;

	/**
	 *
	 * @var WC
	 */
	protected $wc;

	/**
	 * WP Proxy
	 *
	 * @var WP
	 */
	protected WP $wp;

	/**
	 * SyncerHooks constructor.
	 *
	 * @param CouponHelper          $coupon_helper
	 * @param JobRepository         $job_repository
	 * @param MerchantCenterService $merchant_center
	 * @param NotificationsService  $notifications_service
	 * @param WC                    $wc
	 * @param WP                    $wp
	 */
	public function __construct(
		CouponHelper $coupon_helper,
		JobRepository $job_repository,
		MerchantCenterService $merchant_center,
		NotificationsService $notifications_service,
		WC $wc,
		WP $wp
	) {
		$this->coupon_helper         = $coupon_helper;
		$this->job_repository        = $job_repository;
		$this->merchant_center       = $merchant_center;
		$this->notifications_service = $notifications_service;
		$this->wc                    = $wc;
		$this->wp                    = $wp;
	}

	/**
	 * Register a service.
	 */
	public function register(): void {
		// only register the hooks if Merchant Center is set up correctly.
		if ( ! $this->merchant_center->is_ready_for_syncing() ) {
			return;
		}

		// when a coupon is added / updated, schedule a update job.
		add_action( 'woocommerce_new_coupon', [ $this, 'update_by_id' ], 90, 2 );
		add_action( 'woocommerce_update_coupon', [ $this, 'update_by_id' ], 90, 2 );
		add_action( 'woocommerce_gla_bulk_update_coupon', [ $this, 'update_by_id' ], 90 );

		// when a coupon is trashed or removed, schedule a delete job.
		add_action( 'wp_trash_post', [ $this, 'pre_delete' ], 90 );
		add_action( 'before_delete_post', [ $this, 'pre_delete' ], 90 );
		add_action( 'trashed_post', [ $this, 'delete_by_id' ], 90 );
		add_action( 'deleted_post', [ $this, 'delete_by_id' ], 90 );
		add_action( 'woocommerce_delete_coupon', [ $this, 'delete_by_id' ], 90, 2 );
		add_action( 'woocommerce_trash_coupon', [ $this, 'delete_by_id' ], 90, 2 );

		// when a coupon is restored from trash, schedule a update job.
		add_action( 'untrashed_post', [ $this, 'update_by_id' ], 90 );

		// Update coupons when object terms get updated.
		add_action( 'set_object_terms', [ $this, 'maybe_update_by_id_when_terms_updated' ], 90, 6 );
	}

	/**
	 * Update a coupon by the ID
	 *
	 * @param int $coupon_id
	 */
	public function update_by_id( int $coupon_id ) {
		$coupon = $this->wc->maybe_get_coupon( $coupon_id );
		if ( $coupon instanceof WC_Coupon ) {
			$this->handle_update_coupon( $coupon );
		}
	}

	/**
	 * Update a coupon by the ID when the terms get updated.
	 *
	 * @param int    $object_id  The object ID.
	 * @param array  $terms      An array of object term IDs or slugs.
	 * @param array  $tt_ids     An array of term taxonomy IDs.
	 * @param string $taxonomy   The taxonomy slug.
	 * @param bool   $append     Whether to append new terms to the old terms.
	 * @param array  $old_tt_ids Old array of term taxonomy IDs.
	 */
	public function maybe_update_by_id_when_terms_updated( int $object_id, array $terms, array $tt_ids, string $taxonomy, bool $append, array $old_tt_ids ) {
		$this->handle_update_coupon_when_product_brands_updated( $taxonomy, $tt_ids, $old_tt_ids );
	}

	/**
	 * Delete a coupon by the ID
	 *
	 * @param int $coupon_id
	 */
	public function delete_by_id( int $coupon_id ) {
		$this->handle_delete_coupon( $coupon_id );
	}

	/**
	 * Pre Delete a coupon by the ID
	 *
	 * @param int $coupon_id
	 */
	public function pre_delete( int $coupon_id ) {
		$this->handle_pre_delete_coupon( $coupon_id );
	}

	/**
	 * Handle updating of a coupon.
	 *
	 * @param WC_Coupon $coupon
	 *            The coupon being saved.
	 *
	 * @return void
	 */
	protected function handle_update_coupon( WC_Coupon $coupon ) {
		$coupon_id = $coupon->get_id();

		if ( $this->notifications_service->is_ready() ) {
			$this->handle_update_coupon_notification( $coupon );
		}

		// Schedule an update job if product sync is enabled.
		if ( $this->coupon_helper->is_sync_ready( $coupon ) ) {
			$this->coupon_helper->mark_as_pending( $coupon );
			$this->job_repository->get( UpdateCoupon::class )->schedule(
				[
					[ $coupon_id ],
				]
			);
		} elseif ( $this->coupon_helper->is_coupon_synced( $coupon ) ) {
			// Delete the coupon from Google Merchant Center if it's already synced BUT it is not sync ready after the edit.
			$coupon_to_delete = new DeleteCouponEntry(
				$coupon_id,
				$this->get_coupon_to_delete( $coupon ),
				$this->coupon_helper->get_synced_google_ids( $coupon )
			);
			$this->job_repository->get( DeleteCoupon::class )->schedule(
				[
					$coupon_to_delete,
				]
			);

			do_action(
				'woocommerce_gla_debug_message',
				sprintf(
					'Deleting coupon (ID: %s) from Google Merchant Center because it is not ready to be synced.',
					$coupon->get_id()
				),
				__METHOD__
			);
		} else {
			$this->coupon_helper->mark_as_unsynced( $coupon );
		}
	}

	/**
	 * Create request entries for the coupon (containing its Google ID),
	 * so we can schedule a delete job when it is actually trashed / deleted.
	 *
	 * @param int $coupon_id
	 */
	protected function handle_pre_delete_coupon( int $coupon_id ) {
		$coupon = $this->wc->maybe_get_coupon( $coupon_id );

		if ( $coupon instanceof WC_Coupon &&
			$this->coupon_helper->is_coupon_synced( $coupon ) ) {
			$this->delete_requests_map[ $coupon_id ] = new DeleteCouponEntry(
				$coupon_id,
				$this->get_coupon_to_delete( $coupon ),
				$this->coupon_helper->get_synced_google_ids( $coupon )
			);
		}
	}

	/**
	 * @param WC_Coupon $coupon
	 *
	 * @return WCCouponAdapter
	 */
	protected function get_coupon_to_delete( WC_Coupon $coupon ): WCCouponAdapter {
		$adapted_coupon_to_delete = new WCCouponAdapter(
			[
				'wc_coupon' => $coupon,
			]
		);

		// Promotion stored in Google can only be soft-deleted to keep historical records.
		// Instead of 'delete', we update the promotion with effective dates expired.
		// Here we reset an expiring date based on WooCommerce coupon source.
		$adapted_coupon_to_delete->disable_promotion( $coupon );

		return $adapted_coupon_to_delete;
	}

	/**
	 * Handle deleting of a coupon.
	 *
	 * @param int $coupon_id
	 */
	protected function handle_delete_coupon( int $coupon_id ) {
		if ( $this->notifications_service->is_ready() ) {
			$this->maybe_send_delete_notification( $coupon_id );
		}

		if ( ! isset( $this->delete_requests_map[ $coupon_id ] ) ) {
			return;
		}

		$coupon_to_delete = $this->delete_requests_map[ $coupon_id ];
		if ( ! empty( $coupon_to_delete->get_synced_google_ids() ) &&
				! $this->is_already_scheduled_to_delete( $coupon_id ) ) {
			$this->job_repository->get( DeleteCoupon::class )->schedule(
				[
					$coupon_to_delete,
				]
			);
			$this->set_already_scheduled_to_delete( $coupon_id );
		}
	}

	/**
	 * Send the notification for coupon deletion
	 *
	 * @since 2.8.0
	 * @param int $coupon_id
	 */
	protected function maybe_send_delete_notification( int $coupon_id ): void {
		$coupon = $this->wc->maybe_get_coupon( $coupon_id );

		if ( $coupon instanceof WC_Coupon && $this->coupon_helper->should_trigger_delete_notification( $coupon ) ) {
			$this->coupon_helper->set_notification_status( $coupon, NotificationStatus::NOTIFICATION_PENDING_DELETE );
			$this->job_repository->get( CouponNotificationJob::class )->schedule(
				[
					'item_id' => $coupon->get_id(),
					'topic'   => NotificationsService::TOPIC_COUPON_DELETED,
				]
			);
		}
	}

	/**
	 *
	 * @param int    $coupon_id
	 * @param string $schedule_type
	 *
	 * @return bool
	 */
	protected function is_already_scheduled(
		int $coupon_id,
		string $schedule_type
	): bool {
		return isset( $this->already_scheduled[ $coupon_id ] ) &&
			$this->already_scheduled[ $coupon_id ] === $schedule_type;
	}

	/**
	 *
	 * @param int $coupon_id
	 *
	 * @return bool
	 */
	protected function is_already_scheduled_to_update( int $coupon_id ): bool {
		return $this->is_already_scheduled(
			$coupon_id,
			self::SCHEDULE_TYPE_UPDATE
		);
	}

	/**
	 *
	 * @param int $coupon_id
	 *
	 * @return bool
	 */
	protected function is_already_scheduled_to_delete( int $coupon_id ): bool {
		return $this->is_already_scheduled(
			$coupon_id,
			self::SCHEDULE_TYPE_DELETE
		);
	}

	/**
	 *
	 * @param int    $coupon_id
	 * @param string $schedule_type
	 *
	 * @return void
	 */
	protected function set_already_scheduled(
		int $coupon_id,
		string $schedule_type
	): void {
		$this->already_scheduled[ $coupon_id ] = $schedule_type;
	}

	/**
	 *
	 * @param int $coupon_id
	 *
	 * @return void
	 */
	protected function set_already_scheduled_to_update( int $coupon_id ): void {
		$this->set_already_scheduled( $coupon_id, self::SCHEDULE_TYPE_UPDATE );
	}

	/**
	 *
	 * @param int $coupon_id
	 *
	 * @return void
	 */
	protected function set_already_scheduled_to_delete( int $coupon_id ): void {
		$this->set_already_scheduled( $coupon_id, self::SCHEDULE_TYPE_DELETE );
	}

	/**
	 * Schedules notifications for an updated coupon
	 *
	 * @param WC_Coupon $coupon
	 */
	protected function handle_update_coupon_notification( WC_Coupon $coupon ) {
		if ( $this->coupon_helper->should_trigger_create_notification( $coupon ) ) {
			$this->coupon_helper->set_notification_status( $coupon, NotificationStatus::NOTIFICATION_PENDING_CREATE );
			$this->job_repository->get( CouponNotificationJob::class )->schedule(
				[
					'item_id' => $coupon->get_id(),
					'topic'   => NotificationsService::TOPIC_COUPON_CREATED,
				]
			);
		} elseif ( $this->coupon_helper->should_trigger_update_notification( $coupon ) ) {
			$this->coupon_helper->set_notification_status( $coupon, NotificationStatus::NOTIFICATION_PENDING_UPDATE );
			$this->job_repository->get( CouponNotificationJob::class )->schedule(
				[
					'item_id' => $coupon->get_id(),
					'topic'   => NotificationsService::TOPIC_COUPON_UPDATED,
				]
			);
		} elseif ( $this->coupon_helper->should_trigger_delete_notification( $coupon ) ) {
			$this->coupon_helper->set_notification_status( $coupon, NotificationStatus::NOTIFICATION_PENDING_DELETE );
			$this->job_repository->get( CouponNotificationJob::class )->schedule(
				[
					'item_id' => $coupon->get_id(),
					'topic'   => NotificationsService::TOPIC_COUPON_DELETED,
				]
			);
		}
	}

	/**
	 * If product to brands relationship is updated, update the coupons that are related to the brands.
	 *
	 * @param string $taxonomy   The taxonomy slug.
	 * @param array  $tt_ids     An array of term taxonomy IDs.
	 * @param array  $old_tt_ids Old array of term taxonomy IDs.
	 */
	protected function handle_update_coupon_when_product_brands_updated( string $taxonomy, array $tt_ids, array $old_tt_ids ) {
		if ( 'product_brand' !== $taxonomy ) {
			return;
		}

		// Convert term taxonomy IDs to integers.
		$tt_ids     = array_map( 'intval', $tt_ids );
		$old_tt_ids = array_map( 'intval', $old_tt_ids );

		// Find the difference between the new and old term taxonomy IDs.
		$diff1 = array_diff( $tt_ids, $old_tt_ids );
		$diff2 = array_diff( $old_tt_ids, $tt_ids );
		$diff  = array_merge( $diff1, $diff2 );

		if ( empty( $diff ) ) {
			return;
		}

		// Serialize the diff to use in the meta query.
		// This is needed because the meta value is serialized.
		$serialized_diff = maybe_serialize( $diff );

		$args = [
			'post_type'  => 'shop_coupon',
			'meta_query' => [
				'relation' => 'OR',
				[
					'key'     => 'product_brands',
					'value'   => $serialized_diff,
					'compare' => 'LIKE',
				],
				[
					'key'     => 'exclude_product_brands',
					'value'   => $serialized_diff,
					'compare' => 'LIKE',
				],
			],
		];

		// Get coupon posts based on the above query args.
		$posts = $this->wp->get_posts( $args );

		if ( empty( $posts ) ) {
			return;
		}

		foreach ( $posts as $post ) {
			$this->update_by_id( $post->ID );
		}
	}
}
WCCouponAdapter.php000064400000032241151542752640010270 0ustar00<?php
declare(strict_types = 1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Coupon;

use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\WCProductAdapter;
use Automattic\WooCommerce\GoogleListingsAndAds\Validator\Validatable;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\PriceAmount as GooglePriceAmount;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Promotion as GooglePromotion;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\TimePeriod as GoogleTimePeriod;
use DateInterval;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Mapping\ClassMetadata;
use WC_DateTime;
use WC_Coupon;
defined( 'ABSPATH' ) || exit();

/**
 * Class WCCouponAdapter
 *
 * This class adapts the WooCommerce coupon class to the Google's Promotion class by mapping their attributes.
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Coupon
 */
class WCCouponAdapter extends GooglePromotion implements Validatable {
	use PluginHelper;

	public const CHANNEL_ONLINE = 'ONLINE';

	public const PRODUCT_APPLICABILITY_ALL_PRODUCTS = 'ALL_PRODUCTS';

	public const PRODUCT_APPLICABILITY_SPECIFIC_PRODUCTS = 'SPECIFIC_PRODUCTS';

	public const OFFER_TYPE_GENERIC_CODE = 'GENERIC_CODE';

	public const PROMOTION_DESTINATION_ADS = 'Shopping_ads';

	public const PROMOTION_DESTINATION_FREE_LISTING = 'Free_listings';

	public const WC_DISCOUNT_TYPE_PERCENT = 'percent';

	public const WC_DISCOUNT_TYPE_FIXED_CART = 'fixed_cart';

	public const WC_DISCOUNT_TYPE_FIXED_PRODUCT = 'fixed_product';

	public const COUPON_VALUE_TYPE_MONEY_OFF = 'MONEY_OFF';

	public const COUPON_VALUE_TYPE_PERCENT_OFF = 'PERCENT_OFF';

	protected const DATE_TIME_FORMAT = 'Y-m-d h:i:sa';

	public const COUNTRIES_WITH_FREE_SHIPPING_DESTINATION = [ 'BR', 'IT', 'ES', 'JP', 'NL', 'KR', 'US' ];

	/**
	 *
	 * @var int wc_coupon_id
	 */
	protected $wc_coupon_id;

	/**
	 * Initialize this object's properties from an array.
	 *
	 * @param array $properties Used to seed this object's properties.
	 *
	 * @return void
	 *
	 * @throws InvalidValue When a WooCommerce coupon is not provided or it is invalid.
	 */
	public function mapTypes( $properties ) {
		if ( empty( $properties['wc_coupon'] ) ||
			! $properties['wc_coupon'] instanceof WC_Coupon ) {
				throw InvalidValue::not_instance_of( WC_Coupon::class, 'wc_coupon' );
		}

		$wc_coupon          = $properties['wc_coupon'];
		$this->wc_coupon_id = $wc_coupon->get_id();
		$this->map_woocommerce_coupon( $wc_coupon, $this->get_coupon_destinations( $properties ) );

		// Google doesn't expect extra fields, so it's best to remove them
		unset( $properties['wc_coupon'] );

		parent::mapTypes( $properties );
	}

	/**
	 * Map the WooCommerce coupon attributes to the current class.
	 *
	 * @param WC_Coupon $wc_coupon
	 * @param string[]  $destinations The destination ID's for the coupon
	 *
	 * @return void
	 */
	protected function map_woocommerce_coupon( WC_Coupon $wc_coupon, array $destinations ) {
		$this->setRedemptionChannel( self::CHANNEL_ONLINE );
		$this->setPromotionDestinationIds( $destinations );

		$content_language = empty( get_locale() ) ? 'en' : strtolower(
			substr( get_locale(), 0, 2 )
		); // ISO 639-1.
		$this->setContentLanguage( $content_language );

		$this->map_wc_coupon_id( $wc_coupon )
			->map_wc_general_attributes( $wc_coupon )
			->map_wc_usage_restriction( $wc_coupon );
	}

	/**
	 * Map the WooCommerce coupon ID.
	 *
	 * @param WC_Coupon $wc_coupon
	 *
	 * @return $this
	 */
	protected function map_wc_coupon_id( WC_Coupon $wc_coupon ): WCCouponAdapter {
		$coupon_id = "{$this->get_slug()}_{$wc_coupon->get_id()}";
		$this->setPromotionId( $coupon_id );

		return $this;
	}

	/**
	 * Map the general WooCommerce coupon attributes.
	 *
	 * @param WC_Coupon $wc_coupon
	 *
	 * @return $this
	 */
	protected function map_wc_general_attributes( WC_Coupon $wc_coupon ): WCCouponAdapter {
		$this->setOfferType( self::OFFER_TYPE_GENERIC_CODE );
		$this->setGenericRedemptionCode( $wc_coupon->get_code() );

		$coupon_amount = $wc_coupon->get_amount();
		if ( $wc_coupon->is_type( self::WC_DISCOUNT_TYPE_PERCENT ) ) {
			$this->setCouponValueType( self::COUPON_VALUE_TYPE_PERCENT_OFF );
			$percent_off = round( floatval( $coupon_amount ) );
			$this->setPercentOff( $percent_off );
			$this->setLongtitle( sprintf( '%d%% off', $percent_off ) );
		} elseif ( $wc_coupon->is_type(
			[
				self::WC_DISCOUNT_TYPE_FIXED_CART,
				self::WC_DISCOUNT_TYPE_FIXED_PRODUCT,
			]
		) ) {
			$this->setCouponValueType( self::COUPON_VALUE_TYPE_MONEY_OFF );
			$this->setMoneyOffAmount(
				$this->map_google_price_amount( $coupon_amount )
			);
			$this->setLongtitle(
				sprintf(
					'%d %s off',
					$coupon_amount,
					get_woocommerce_currency()
				)
			);
		}

		$this->setPromotionEffectiveTimePeriod(
			$this->get_wc_coupon_effective_dates( $wc_coupon )
		);

		return $this;
	}

	/**
	 * Return the effective time period for the WooCommerce coupon.
	 *
	 * @param WC_Coupon $wc_coupon
	 *
	 * @return GoogleTimePeriod
	 */
	protected function get_wc_coupon_effective_dates( WC_Coupon $wc_coupon ): GoogleTimePeriod {
		$start_date = $this->get_wc_coupon_start_date( $wc_coupon );

		$end_date = $wc_coupon->get_date_expires();
		// If there is no expiring date, set to promotion maximumal effective days allowed by Google.\
		// Refer to https://support.google.com/merchants/answer/2906014?hl=en
		if ( empty( $end_date ) ) {
			$end_date = clone $start_date;
			$end_date->add( new DateInterval( 'P183D' ) );
		}

		// If the coupon is already expired. set the coupon expires immediately after start date.
		if ( $end_date < $start_date ) {
			$end_date = clone $start_date;
			$end_date->add( new DateInterval( 'PT1S' ) );
		}
		return new GoogleTimePeriod(
			[
				'startTime' => (string) $start_date,
				'endTime'   => (string) $end_date,
			]
		);
	}

	/**
	 * Return the start date for the WooCommerce coupon.
	 *
	 * @param WC_Coupon $wc_coupon
	 *
	 * @return WC_DateTime
	 */
	protected function get_wc_coupon_start_date( $wc_coupon ): WC_DateTime {
		new WC_DateTime();
		$post_time = get_post_time( self::DATE_TIME_FORMAT, true, $wc_coupon->get_id(), false );
		if ( ! empty( $post_time ) ) {
			return new WC_DateTime( $post_time );
		} else {
			return new WC_DateTime();
		}
	}

	/**
	 * Map the WooCommerce coupon usage restriction.
	 *
	 * @param WC_Coupon $wc_coupon
	 *
	 * @return $this
	 */
	protected function map_wc_usage_restriction( WC_Coupon $wc_coupon ): WCCouponAdapter {
		$minimal_spend = $wc_coupon->get_minimum_amount();
		if ( ! empty( $minimal_spend ) ) {
			$this->setMinimumPurchaseAmount(
				$this->map_google_price_amount( $minimal_spend )
			);
		}

		$maximal_spend = $wc_coupon->get_maximum_amount();
		if ( ! empty( $maximal_spend ) ) {
			$this->setLimitValue(
				$this->map_google_price_amount( $maximal_spend )
			);
		}

		$has_product_restriction = false;
		$get_offer_id            = function ( int $product_id ) {
			return WCProductAdapter::get_google_product_offer_id( $this->get_slug(), $product_id );
		};

		$wc_product_ids = $wc_coupon->get_product_ids();
		if ( ! empty( $wc_product_ids ) ) {
			$google_product_ids      = array_map( $get_offer_id, $wc_product_ids );
			$has_product_restriction = true;
			$this->setItemId( $google_product_ids );
		}

		// Currently the brand inclusion restriction will override the product inclustion restriction.
		// It's align with the current coupon discounts behaviour in WooCommerce.
		$wc_product_ids_in_brand = $this->get_product_ids_in_brand( $wc_coupon );
		if ( ! empty( $wc_product_ids_in_brand ) ) {
			$google_product_ids      = array_map( $get_offer_id, $wc_product_ids_in_brand );
			$has_product_restriction = true;
			$this->setItemId( $google_product_ids );
		}

		// Get excluded product IDs and excluded product IDs in brand.
		$wc_excluded_product_ids          = $wc_coupon->get_excluded_product_ids();
		$wc_excluded_product_ids_in_brand = $this->get_product_ids_in_brand( $wc_coupon, true );
		if ( ! empty( $wc_excluded_product_ids ) || ! empty( $wc_excluded_product_ids_in_brand ) ) {
			$google_product_ids = array_merge(
				array_map( $get_offer_id, $wc_excluded_product_ids ),
				array_map( $get_offer_id, $wc_excluded_product_ids_in_brand )
			);
			$google_product_ids = array_values( array_unique( $google_product_ids ) );

			$has_product_restriction = true;
			$this->setItemIdExclusion( $google_product_ids );
		}

		$wc_product_catetories = $wc_coupon->get_product_categories();
		if ( ! empty( $wc_product_catetories ) ) {
			$str_product_categories  =
			WCProductAdapter::convert_product_types( $wc_product_catetories );
			$has_product_restriction = true;
			$this->setProductType( $str_product_categories );
		}

		$wc_excluded_product_catetories = $wc_coupon->get_excluded_product_categories();
		if ( ! empty( $wc_excluded_product_catetories ) ) {
			$str_product_categories  =
			WCProductAdapter::convert_product_types( $wc_excluded_product_catetories );
			$has_product_restriction = true;
			$this->setProductTypeExclusion( $str_product_categories );
		}

		if ( $has_product_restriction ) {
			$this->setProductApplicability(
				self::PRODUCT_APPLICABILITY_SPECIFIC_PRODUCTS
			);
		} else {
			$this->setProductApplicability(
				self::PRODUCT_APPLICABILITY_ALL_PRODUCTS
			);
		}

		return $this;
	}

	/**
	 * Map WooCommerce price number to Google price structure.
	 *
	 * @param float $wc_amount
	 *
	 * @return GooglePriceAmount
	 */
	protected function map_google_price_amount( $wc_amount ): GooglePriceAmount {
		return new GooglePriceAmount(
			[
				'currency' => get_woocommerce_currency(),
				'value'    => $wc_amount,
			]
		);
	}

	/**
	 * Disable promotion shared in Google by only updating promotion effective end_date
	 * to make the promotion expired.
	 *
	 * @param WC_Coupon $wc_coupon
	 */
	public function disable_promotion( WC_Coupon $wc_coupon ) {
		$start_date = $this->get_wc_coupon_start_date( $wc_coupon );
		// Set promotion to be disabled immediately.
		$end_date = new WC_DateTime();

		// If this coupon is scheduled in the future, disable it right after start date.
		if ( $start_date >= $end_date ) {
			$end_date = clone $start_date;
			$end_date->add( new DateInterval( 'PT1S' ) );
		}

		$this->setPromotionEffectiveTimePeriod(
			new GoogleTimePeriod(
				[
					'startTime' => (string) $start_date,
					'endTime'   => (string) $end_date,
				]
			)
		);
	}

	/**
	 *
	 * @param ClassMetadata $metadata
	 */
	public static function load_validator_metadata( ClassMetadata $metadata ) {
		$metadata->addPropertyConstraint(
			'targetCountry',
			new Assert\NotBlank()
		);
		$metadata->addPropertyConstraint(
			'promotionId',
			new Assert\NotBlank()
		);
		$metadata->addPropertyConstraint(
			'genericRedemptionCode',
			new Assert\NotBlank()
		);
		$metadata->addPropertyConstraint(
			'productApplicability',
			new Assert\NotBlank()
		);
		$metadata->addPropertyConstraint(
			'offerType',
			new Assert\NotBlank()
		);
		$metadata->addPropertyConstraint(
			'redemptionChannel',
			new Assert\NotBlank()
		);
		$metadata->addPropertyConstraint(
			'couponValueType',
			new Assert\NotBlank()
		);
	}

	/**
	 *
	 * @return int $wc_coupon_id
	 */
	public function get_wc_coupon_id(): int {
		return $this->wc_coupon_id;
	}

	/**
	 *
	 * @param string $targetCountry
     *            phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
	 */
	public function setTargetCountry( $targetCountry ) {
		// set the new target country
		parent::setTargetCountry( $targetCountry );
	}

	/**
	 * Get the destinations allowed per specific country.
	 *
	 * @param array $coupon_data The coupon data to get the allowed destinations.
	 * @return string[] The destinations country based.
	 */
	private function get_coupon_destinations( array $coupon_data ): array {
		$destinations = [ self::PROMOTION_DESTINATION_ADS ];
		if ( isset( $coupon_data['targetCountry'] ) && in_array( $coupon_data['targetCountry'], self::COUNTRIES_WITH_FREE_SHIPPING_DESTINATION, true ) ) {
			$destinations[] = self::PROMOTION_DESTINATION_FREE_LISTING;
		}

		return apply_filters( 'woocommerce_gla_coupon_destinations', $destinations, $coupon_data );
	}

	/**
	 * Get the product IDs that belongs to a brand.
	 *
	 * @param WC_Coupon $wc_coupon The WC coupon object.
	 * @param bool      $is_exclude If the product IDs are for exclusion.
	 * @return string[] The product IDs that belongs to a brand.
	 */
	private function get_product_ids_in_brand( WC_Coupon $wc_coupon, bool $is_exclude = false ) {
		$coupon_id = $wc_coupon->get_id();
		$meta_key  = $is_exclude ? 'exclude_product_brands' : 'product_brands';

		// Get the brand term IDs if brand restriction is set.
		$brand_term_ids = get_post_meta( $coupon_id, $meta_key );

		if ( ! is_array( $brand_term_ids ) ) {
			return [];
		}

		$product_ids = [];
		foreach ( $brand_term_ids as $brand_term_id ) {
			// Get the product IDs that belongs to the brand.
			$object_ids = get_objects_in_term( $brand_term_id, 'product_brand' );
			if ( is_wp_error( $object_ids ) ) {
				continue;
			}
			$product_ids = array_merge( $product_ids, $object_ids );
		}

		return $product_ids;
	}
}