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/Google.tar
Ads/GoogleAdsClient.php000064400000002660151543047350011002 0ustar00<?php
declare( strict_types=1 );

/**
 * Overrides vendor/googleads/google-ads-php/src/Google/Ads/GoogleAds/Lib/V18/GoogleAdsClient.php
 *
 * phpcs:disable WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
 * phpcs:disable WordPress.NamingConventions.ValidVariableName
 */

namespace Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads;

use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Auth\Credentials\InsecureCredentials;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Auth\HttpHandler\HttpHandlerFactory;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\GuzzleHttp\Client;

/**
 * A Google Ads API client for handling common configuration and OAuth2 settings.
 */
class GoogleAdsClient {
	use ServiceClientFactoryTrait;

	/** @var Client $httpClient */
	private $httpClient = null;

	/**
	 * GoogleAdsClient constructor
	 *
	 * @param string $endpoint Endpoint URL to send requests to.
	 */
	public function __construct( string $endpoint ) {
		$this->oAuth2Credential = new InsecureCredentials();
		$this->endpoint         = $endpoint;
	}

	/**
	 * Set a guzzle client to use for requests.
	 *
	 * @param Client $client Guzzle client.
	 */
	public function setHttpClient( Client $client ) {
		$this->httpClient = $client;
	}

	/**
	 * Build a HTTP Handler to handle the requests.
	 */
	protected function buildHttpHandler() {
		return [ HttpHandlerFactory::build( $this->httpClient ), 'async' ];
	}
}
Ads/ServiceClientFactoryTrait.php000064400000015464151543047350013100 0ustar00<?php
declare( strict_types=1 );

/**
 * Overrides vendor/googleads/google-ads-php/src/Google/Ads/GoogleAds/Lib/V18/ServiceClientFactoryTrait.php
 *
 * phpcs:disable WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
 * phpcs:disable WordPress.NamingConventions.ValidVariableName
 * phpcs:disable Squiz.Commenting.VariableComment
 */

namespace Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads;

use Google\Ads\GoogleAds\Constants;
use Google\Ads\GoogleAds\Lib\ConfigurationTrait;
use Google\Ads\GoogleAds\V18\Services\Client\AccountLinkServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\AdGroupAdLabelServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\AdGroupAdServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\AdGroupCriterionServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\AdGroupServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\AdServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\AssetGroupListingGroupFilterServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\AssetGroupServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\BillingSetupServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\CampaignBudgetServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\CampaignCriterionServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\CampaignServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\ConversionActionServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\CustomerServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\CustomerUserAccessServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\GeoTargetConstantServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\GoogleAdsServiceClient;
use Google\Ads\GoogleAds\V18\Services\Client\ProductLinkInvitationServiceClient;

/**
 * Contains service client factory methods.
 */
trait ServiceClientFactoryTrait {
	use ConfigurationTrait;

	private static $CREDENTIALS_LOADER_KEY  = 'credentials';
	private static $DEVELOPER_TOKEN_KEY     = 'developer-token';
	private static $LOGIN_CUSTOMER_ID_KEY   = 'login-customer-id';
	private static $LINKED_CUSTOMER_ID_KEY  = 'linked-customer-id';
	private static $SERVICE_ADDRESS_KEY     = 'serviceAddress';
	private static $DEFAULT_SERVICE_ADDRESS = 'googleads.googleapis.com';
	private static $TRANSPORT_KEY           = 'transport';

	/**
	 * Gets the Google Ads client options for making API calls.
	 *
	 * @return array the client options
	 */
	public function getGoogleAdsClientOptions(): array {
		$clientOptions = [
			self::$CREDENTIALS_LOADER_KEY => $this->getOAuth2Credential(),
			self::$DEVELOPER_TOKEN_KEY    => '',
			self::$TRANSPORT_KEY          => 'rest',
			'libName'                     => Constants::LIBRARY_NAME,
			'libVersion'                  => Constants::LIBRARY_VERSION,
		];

		if ( ! empty( $this->getEndpoint() ) ) {
			$clientOptions += [ self::$SERVICE_ADDRESS_KEY => $this->getEndpoint() ];
		}

		if ( isset( $this->httpClient ) ) {
			$clientOptions['transportConfig'] = [
				'rest' => [
					'httpHandler' => $this->buildHttpHandler(),
				],
			];
		}

		return $clientOptions;
	}


	/**
	 * @return AccountLinkServiceClient
	 */
	public function getAccountLinkServiceClient(): AccountLinkServiceClient {
		return new AccountLinkServiceClient( $this->getGoogleAdsClientOptions() );
	}

	/**
	 * @return AdGroupAdLabelServiceClient
	 */
	public function getAdGroupAdLabelServiceClient(): AdGroupAdLabelServiceClient {
		return new AdGroupAdLabelServiceClient( $this->getGoogleAdsClientOptions() );
	}

	/**
	 * @return AdGroupAdServiceClient
	 */
	public function getAdGroupAdServiceClient(): AdGroupAdServiceClient {
		return new AdGroupAdServiceClient( $this->getGoogleAdsClientOptions() );
	}

	/**
	 * @return AdGroupCriterionServiceClient
	 */
	public function getAdGroupCriterionServiceClient(): AdGroupCriterionServiceClient {
		return new AdGroupCriterionServiceClient( $this->getGoogleAdsClientOptions() );
	}

	/**
	 * @return AdGroupServiceClient
	 */
	public function getAdGroupServiceClient(): AdGroupServiceClient {
		return new AdGroupServiceClient( $this->getGoogleAdsClientOptions() );
	}

	/**
	 * @return AdServiceClient
	 */
	public function getAdServiceClient(): AdServiceClient {
		return new AdServiceClient( $this->getGoogleAdsClientOptions() );
	}

	/**
	 * @return AssetGroupListingGroupFilterServiceClient
	 */
	public function getAssetGroupListingGroupFilterServiceClient(): AssetGroupListingGroupFilterServiceClient {
		return new AssetGroupListingGroupFilterServiceClient( $this->getGoogleAdsClientOptions() );
	}

	/**
	 * @return AssetGroupServiceClient
	 */
	public function getAssetGroupServiceClient(): AssetGroupServiceClient {
		return new AssetGroupServiceClient( $this->getGoogleAdsClientOptions() );
	}

	/**
	 * @return BillingSetupServiceClient
	 */
	public function getBillingSetupServiceClient(): BillingSetupServiceClient {
		return new BillingSetupServiceClient( $this->getGoogleAdsClientOptions() );
	}

	/**
	 * @return CampaignBudgetServiceClient
	 */
	public function getCampaignBudgetServiceClient(): CampaignBudgetServiceClient {
		return new CampaignBudgetServiceClient( $this->getGoogleAdsClientOptions() );
	}

	/**
	 * @return CampaignCriterionServiceClient
	 */
	public function getCampaignCriterionServiceClient(): CampaignCriterionServiceClient {
		return new CampaignCriterionServiceClient( $this->getGoogleAdsClientOptions() );
	}

	/**
	 * @return CampaignServiceClient
	 */
	public function getCampaignServiceClient(): CampaignServiceClient {
		return new CampaignServiceClient( $this->getGoogleAdsClientOptions() );
	}

	/**
	 * @return ConversionActionServiceClient
	 */
	public function getConversionActionServiceClient(): ConversionActionServiceClient {
		return new ConversionActionServiceClient( $this->getGoogleAdsClientOptions() );
	}

	/**
	 * @return CustomerServiceClient
	 */
	public function getCustomerServiceClient(): CustomerServiceClient {
		return new CustomerServiceClient( $this->getGoogleAdsClientOptions() );
	}

	/**
	 * @return CustomerUserAccessServiceClient
	 */
	public function getCustomerUserAccessServiceClient(): CustomerUserAccessServiceClient {
		return new CustomerUserAccessServiceClient( $this->getGoogleAdsClientOptions() );
	}

	/**
	 * @return GeoTargetConstantServiceClient
	 */
	public function getGeoTargetConstantServiceClient(): GeoTargetConstantServiceClient {
		return new GeoTargetConstantServiceClient( $this->getGoogleAdsClientOptions() );
	}

	/**
	 * @return GoogleAdsServiceClient
	 */
	public function getGoogleAdsServiceClient(): GoogleAdsServiceClient {
		return new GoogleAdsServiceClient( $this->getGoogleAdsClientOptions() );
	}

	/**
	 * @return ProductLinkInvitationServiceClient
	 */
	public function getProductLinkInvitationServiceClient(): ProductLinkInvitationServiceClient {
		return new ProductLinkInvitationServiceClient( $this->getGoogleAdsClientOptions() );
	}
}
BatchInvalidProductEntry.php000064400000004432151543047360012203 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;

use JsonSerializable;
use Symfony\Component\Validator\ConstraintViolationListInterface;

defined( 'ABSPATH' ) || exit;

/**
 * Class BatchInvalidProductEntry
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Google
 */
class BatchInvalidProductEntry implements JsonSerializable {

	/**
	 * @var int WooCommerce product ID.
	 */
	protected $wc_product_id;

	/**
	 * @var string|null Google product ID. Always defined if the method is delete.
	 */
	protected $google_product_id;

	/**
	 * @var string[]
	 */
	protected $errors;

	/**
	 * BatchInvalidProductEntry constructor.
	 *
	 * @param int         $wc_product_id
	 * @param string|null $google_product_id
	 * @param string[]    $errors
	 */
	public function __construct( int $wc_product_id, ?string $google_product_id = null, array $errors = [] ) {
		$this->wc_product_id     = $wc_product_id;
		$this->google_product_id = $google_product_id;
		$this->errors            = $errors;
	}

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

	/**
	 * @return string|null
	 */
	public function get_google_product_id(): ?string {
		return $this->google_product_id;
	}

	/**
	 * @return string[]
	 */
	public function get_errors(): array {
		return $this->errors;
	}

	/**
	 * @param string $error_reason
	 *
	 * @return bool
	 */
	public function has_error( string $error_reason ): bool {
		return ! empty( $this->errors[ $error_reason ] );
	}

	/**
	 * @param ConstraintViolationListInterface $violations
	 *
	 * @return BatchInvalidProductEntry
	 */
	public function map_validation_violations( ConstraintViolationListInterface $violations ): BatchInvalidProductEntry {
		$validation_errors = [];
		foreach ( $violations as $violation ) {
			$validation_errors[] = sprintf( '[%s] %s', $violation->getPropertyPath(), $violation->getMessage() );
		}

		$this->errors = $validation_errors;

		return $this;
	}

	/**
	 * @return array
	 */
	public function jsonSerialize(): array {
		$data = [
			'woocommerce_id' => $this->get_wc_product_id(),
			'errors'         => $this->get_errors(),
		];

		if ( null !== $this->get_google_product_id() ) {
			$data['google_id'] = $this->get_google_product_id();
		}

		return $data;
	}
}
BatchProductEntry.php000064400000002651151543047360010675 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Product as GoogleProduct;
use JsonSerializable;

defined( 'ABSPATH' ) || exit;

/**
 * Class BatchProductEntry
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Google
 */
class BatchProductEntry implements JsonSerializable {

	/**
	 * @var int WooCommerce product ID.
	 */
	protected $wc_product_id;

	/**
	 * @var GoogleProduct|null The inserted product. Only defined if the method is insert.
	 */
	protected $google_product;

	/**
	 * BatchProductEntry constructor.
	 *
	 * @param int                $wc_product_id
	 * @param GoogleProduct|null $google_product
	 */
	public function __construct( int $wc_product_id, ?GoogleProduct $google_product = null ) {
		$this->wc_product_id  = $wc_product_id;
		$this->google_product = $google_product;
	}

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

	/**
	 * @return GoogleProduct|null
	 */
	public function get_google_product(): ?GoogleProduct {
		return $this->google_product;
	}

	/**
	 * @return array
	 */
	public function jsonSerialize(): array {
		$data = [ 'woocommerce_id' => $this->get_wc_product_id() ];

		if ( null !== $this->get_google_product() ) {
			$data['google_id'] = $this->get_google_product()->getId();
		}

		return $data;
	}
}
BatchProductIDRequestEntry.php000064400000003342151543047360012461 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Value\ProductIDMap;

defined( 'ABSPATH' ) || exit;

/**
 * Class BatchProductIDRequestEntry
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Google
 */
class BatchProductIDRequestEntry {
	/**
	 * @var int
	 */
	protected $wc_product_id;

	/**
	 * @var string The Google product REST ID.
	 */
	protected $product_id;

	/**
	 * BatchProductIDRequestEntry constructor.
	 *
	 * @param int    $wc_product_id
	 * @param string $product_id
	 */
	public function __construct( int $wc_product_id, string $product_id ) {
		$this->wc_product_id = $wc_product_id;
		$this->product_id    = $product_id;
	}

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

	/**
	 * @return string
	 */
	public function get_product_id(): string {
		return $this->product_id;
	}

	/**
	 * @param ProductIDMap $product_id_map
	 *
	 * @return BatchProductIDRequestEntry[]
	 */
	public static function create_from_id_map( ProductIDMap $product_id_map ): array {
		$product_entries = [];
		foreach ( $product_id_map as $google_product_id => $wc_product_id ) {
			$product_entries[] = new BatchProductIDRequestEntry( $wc_product_id, $google_product_id );
		}

		return $product_entries;
	}

	/**
	 * @param BatchProductIDRequestEntry[] $request_entries
	 *
	 * @return ProductIDMap $product_id_map
	 */
	public static function convert_to_id_map( array $request_entries ): ProductIDMap {
		$id_map = [];
		foreach ( $request_entries as $request_entry ) {
			$id_map[ $request_entry->get_product_id() ] = $request_entry->get_wc_product_id();
		}

		return new ProductIDMap( $id_map );
	}
}
BatchProductRequestEntry.php000064400000001750151543047360012245 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Product\WCProductAdapter;

defined( 'ABSPATH' ) || exit;

/**
 * Class BatchProductRequestEntry
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Google
 */
class BatchProductRequestEntry {
	/**
	 * @var int
	 */
	protected $wc_product_id;

	/**
	 * @var WCProductAdapter The Google product object
	 */
	protected $product;

	/**
	 * BatchProductRequestEntry constructor.
	 *
	 * @param int              $wc_product_id
	 * @param WCProductAdapter $product
	 */
	public function __construct( int $wc_product_id, WCProductAdapter $product ) {
		$this->wc_product_id = $wc_product_id;
		$this->product       = $product;
	}

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

	/**
	 * @return WCProductAdapter
	 */
	public function get_product(): WCProductAdapter {
		return $this->product;
	}
}
BatchProductResponse.php000064400000001675151543047360011377 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;

defined( 'ABSPATH' ) || exit;

/**
 * Class BatchProductResponse
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Google
 */
class BatchProductResponse {
	/**
	 * @var BatchProductEntry[] Products that were successfully updated, deleted or retrieved.
	 */
	protected $products;

	/**
	 * @var BatchInvalidProductEntry[]
	 */
	protected $errors;

	/**
	 * BatchProductResponse constructor.
	 *
	 * @param BatchProductEntry[]        $products
	 * @param BatchInvalidProductEntry[] $errors
	 */
	public function __construct( array $products, array $errors ) {
		$this->products = $products;
		$this->errors   = $errors;
	}
	/**
	 * @return BatchProductEntry[]
	 */
	public function get_products(): array {
		return $this->products;
	}

	/**
	 * @return BatchInvalidProductEntry[]
	 */
	public function get_errors(): array {
		return $this->errors;
	}
}
DeleteCouponEntry.php000064400000002614151543047360010700 0ustar00<?php
declare(strict_types = 1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Promotion as GooglePromotion;
defined( 'ABSPATH' ) || exit();

/**
 * Class DeleteCouponEntry
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Google
 */
class DeleteCouponEntry {

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

	/**
	 *
	 * @var GooglePromotion
	 */
	protected $google_promotion;

	/**
	 *
	 * @var array List of country to google promotion id mappings
	 */
	protected $synced_google_ids;

	/**
	 * DeleteCouponEntry constructor.
	 *
	 * @param int             $wc_coupon_id
	 * @param GooglePromotion $google_promotion
	 * @param array           $synced_google_ids
	 */
	public function __construct(
		int $wc_coupon_id,
		GooglePromotion $google_promotion,
		array $synced_google_ids
	) {
		$this->wc_coupon_id      = $wc_coupon_id;
		$this->google_promotion  = $google_promotion;
		$this->synced_google_ids = $synced_google_ids;
	}

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

	/**
	 *
	 * @return GooglePromotion
	 */
	public function get_google_promotion(): GooglePromotion {
		return $this->google_promotion;
	}

	/**
	 *
	 * @return array
	 */
	public function get_synced_google_ids(): array {
		return $this->synced_google_ids;
	}
}
GlobalSiteTag.php000064400000041206151543047360007751 0ustar00<?php
declare( strict_types=1 );

/**
 * Global Site Tag functionality - add main script and track conversions.
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds
 */

namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Assets\AssetsHandlerInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Assets\ScriptWithBuiltDependenciesAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Conditional;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\GoogleGtagJs;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\BuiltScriptDependencyArray;
use WC_Product;

defined( 'ABSPATH' ) || exit;

/**
 * Main class for Global Site Tag.
 */
class GlobalSiteTag implements Service, Registerable, Conditional, OptionsAwareInterface {

	use OptionsAwareTrait;
	use PluginHelper;

	/** @var string Developer ID */
	protected const DEVELOPER_ID = 'dOGY3NW';

	/** @var string Meta key used to mark orders as converted */
	protected const ORDER_CONVERSION_META_KEY = '_gla_tracked';

	/**
	 * @var AssetsHandlerInterface
	 */
	protected $assets_handler;

	/**
	 * @var GoogleGtagJs
	 */
	protected $gtag_js;

	/**
	 * @var ProductHelper
	 */
	protected $product_helper;

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

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

	/**
	 * Additional product data used for tracking add_to_cart events.
	 *
	 * @var array
	 */
	protected $products = [];

	/**
	 * Global Site Tag constructor.
	 *
	 * @param AssetsHandlerInterface $assets_handler
	 * @param GoogleGtagJs           $gtag_js
	 * @param ProductHelper          $product_helper
	 * @param WC                     $wc
	 * @param WP                     $wp
	 */
	public function __construct(
		AssetsHandlerInterface $assets_handler,
		GoogleGtagJs $gtag_js,
		ProductHelper $product_helper,
		WC $wc,
		WP $wp
	) {
		$this->assets_handler = $assets_handler;
		$this->gtag_js        = $gtag_js;
		$this->product_helper = $product_helper;
		$this->wc             = $wc;
		$this->wp             = $wp;
	}

	/**
	 * Register the service.
	 */
	public function register(): void {
		$conversion_action = $this->options->get( OptionsInterface::ADS_CONVERSION_ACTION );

		// No snippets without conversion action info.
		if ( ! $conversion_action ) {
			return;
		}

		$ads_conversion_id    = $conversion_action['conversion_id'];
		$ads_conversion_label = $conversion_action['conversion_label'];

		add_action(
			'wp_head',
			function () use ( $ads_conversion_id ) {
				$this->activate_global_site_tag( $ads_conversion_id );
			},
			999999
		);

		add_action(
			'woocommerce_before_thankyou',
			function ( $order_id ) use ( $ads_conversion_id, $ads_conversion_label ) {
				$this->maybe_display_conversion_and_purchase_event_snippets( $ads_conversion_id, $ads_conversion_label, $order_id );
			},
		);

		add_action(
			'woocommerce_after_single_product',
			function () {
				$this->display_view_item_event_snippet();
			}
		);

		add_action(
			'wp_body_open',
			function () {
				$this->display_page_view_event_snippet();
			}
		);

		$this->product_data_hooks();
		$this->register_assets();
	}

	/**
	 * Attach filters to add product data required for tracking events.
	 */
	protected function product_data_hooks() {
		// Add product data for any add_to_cart link.
		add_filter(
			'woocommerce_loop_add_to_cart_link',
			function ( $link, $product ) {
				$this->add_product_data( $product );
				return $link;
			},
			10,
			2
		);

		// Add display name for an available variation.
		add_filter(
			'woocommerce_available_variation',
			function ( $data, $instance, $variation ) {
				$data['display_name'] = $variation->get_name();
				return $data;
			},
			10,
			3
		);
	}

	/**
	 * Register and enqueue assets for gtag events in blocks.
	 */
	protected function register_assets() {
		$gtag_events = new ScriptWithBuiltDependenciesAsset(
			'gla-gtag-events',
			'js/build/gtag-events',
			"{$this->get_root_dir()}/js/build/gtag-events.asset.php",
			new BuiltScriptDependencyArray(
				[
					'dependencies' => [],
					'version'      => $this->get_version(),
				]
			),
			function () {
				return is_page() || is_woocommerce() || is_cart();
			}
		);

		$this->assets_handler->register( $gtag_events );

		$wp_consent_api = new ScriptWithBuiltDependenciesAsset(
			'gla-wp-consent-api',
			'js/build/wp-consent-api',
			"{$this->get_root_dir()}/js/build/wp-consent-api.asset.php",
			new BuiltScriptDependencyArray(
				[
					'dependencies' => [ 'wp-consent-api' ],
					'version'      => $this->get_version(),
				]
			)
		);

		$this->assets_handler->register( $wp_consent_api );

		add_action(
			'wp_footer',
			function () use ( $gtag_events, $wp_consent_api ) {
				$gtag_events->add_localization(
					'glaGtagData',
					[
						'currency_minor_unit' => wc_get_price_decimals(),
						'products'            => $this->products,
					]
				);

				$this->register_js_for_fast_refresh_dev();
				$this->assets_handler->enqueue( $gtag_events );

				if ( ! class_exists( '\WC_Google_Gtag_JS' ) && function_exists( 'wp_has_consent' ) ) {
					$this->assets_handler->enqueue( $wp_consent_api );
				}
			}
		);
	}

	/**
	 * Activate the Global Site Tag framework:
	 * - Insert GST code, or
	 * - Include the Google Ads conversion ID in WooCommerce Google Analytics for WooCommerce output, if available
	 *
	 * @param string $ads_conversion_id Google Ads account conversion ID.
	 */
	public function activate_global_site_tag( string $ads_conversion_id ) {
		if ( $this->gtag_js->is_adding_framework() ) {
			if ( $this->gtag_js->ga4w_v2 ) {
				$this->wp->wp_add_inline_script(
					'woocommerce-google-analytics-integration',
					$this->get_gtag_config( $ads_conversion_id )
				);
			} else {
				// Legacy code to support Google Analytics for WooCommerce version < 2.0.0.
				add_filter(
					'woocommerce_gtag_snippet',
					function ( $gtag_snippet ) use ( $ads_conversion_id ) {
						return preg_replace(
							'~(\s)</script>~',
							"\tgtag('config', '" . $ads_conversion_id . "', { 'groups': 'GLA', 'send_page_view': false });\n$1</script>",
							$gtag_snippet
						);
					}
				);
			}
		} else {
			$this->display_global_site_tag( $ads_conversion_id );
		}
	}

	/**
	 * Display the JavaScript code to load the Global Site Tag framework.
	 *
	 * @param string $ads_conversion_id Google Ads account conversion ID.
	 */
	protected function display_global_site_tag( string $ads_conversion_id ) {
		// phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript
		?>

		<!-- Global site tag (gtag.js) - Google Ads: <?php echo esc_js( $ads_conversion_id ); ?> - Google for WooCommerce -->
		<script async src="https://www.googletagmanager.com/gtag/js?id=<?php echo esc_js( $ads_conversion_id ); ?>"></script>
		<script>
			window.dataLayer = window.dataLayer || [];
			function gtag() { dataLayer.push(arguments); }
			<?php
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
				echo $this->get_consent_mode_config();
			?>

			gtag('js', new Date());
			gtag('set', 'developer_id.<?php echo esc_js( self::DEVELOPER_ID ); ?>', true);
			<?php
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
				echo $this->get_gtag_config( $ads_conversion_id );
			?>
		</script>

		<?php
		// phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript
	}

	/**
	 * Get the ads conversion configuration for the Global Site Tag
	 *
	 * @param string $ads_conversion_id Google Ads account conversion ID.
	 */
	protected function get_gtag_config( string $ads_conversion_id ) {
		return sprintf(
			'gtag("config", "%1$s", { "groups": "GLA", "send_page_view": false });',
			esc_js( $ads_conversion_id )
		);
	}

	/**
	 * Get the default consent mode configuration.
	 */
	protected function get_consent_mode_config() {
		$consent_mode_snippet = "gtag( 'consent', 'default', {
				analytics_storage: 'denied',
				ad_storage: 'denied',
				ad_user_data: 'denied',
				ad_personalization: 'denied',
				region: ['AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IS', 'IE', 'IT', 'LV', 'LI', 'LT', 'LU', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'GB', 'CH'],
				wait_for_update: 500,
			} );";
		/**
		 * Filters the default gtag consent mode configuration.
		 *
		 * @param string $consent_mode_snippet Default configuration with all the parameters `denied` for the EEA region.
		 */
		return apply_filters( 'woocommerce_gla_gtag_consent', $consent_mode_snippet );
	}

	/**
	 * Add inline JavaScript to the page either as a standalone script or
	 * attach it to Google Analytics for WooCommerce if it's installed
	 *
	 * @param string $inline_script The JavaScript code to display
	 *
	 * @return void
	 */
	public function add_inline_event_script( string $inline_script ) {
		if ( class_exists( '\WC_Google_Gtag_JS' ) ) {
			$this->wp->wp_add_inline_script(
				'woocommerce-google-analytics-integration',
				$inline_script
			);
		} else {
			$this->wp->wp_print_inline_script_tag( $inline_script );
		}
	}

	/**
	 * Display the JavaScript code to track conversions on the order confirmation page.
	 *
	 * @param string $ads_conversion_id Google Ads account conversion ID.
	 * @param string $ads_conversion_label Google Ads conversion label.
	 * @param int    $order_id The order id.
	 */
	public function maybe_display_conversion_and_purchase_event_snippets( string $ads_conversion_id, string $ads_conversion_label, int $order_id ): void {
		// Only display on the order confirmation page.
		if ( ! is_order_received_page() ) {
			return;
		}

		$order = wc_get_order( $order_id );
		// Make sure there is a valid order object and it is not already marked as tracked
		if ( ! $order || 1 === (int) $order->get_meta( self::ORDER_CONVERSION_META_KEY, true ) ) {
			return;
		}

		// Mark the order as tracked, to avoid double-reporting if the confirmation page is reloaded.
		$order->update_meta_data( self::ORDER_CONVERSION_META_KEY, 1 );
		$order->save_meta_data();

		$conversion_gtag_info =
		sprintf(
			'gtag("event", "conversion", {
			send_to: "%s",
			value: %f,
			currency: "%s",
			transaction_id: "%s"});',
			esc_js( "{$ads_conversion_id}/{$ads_conversion_label}" ),
			$order->get_total(),
			esc_js( $order->get_currency() ),
			esc_js( $order->get_id() ),
		);
		$this->add_inline_event_script( $conversion_gtag_info );

		// Get the item info in the order
		$item_info = [];
		foreach ( $order->get_items() as $item_id => $item ) {
			$product_id   = $item->get_product_id();
			$product_name = $item->get_name();
			$quantity     = $item->get_quantity();
			$price        = $order->get_item_total( $item );
			$item_info [] = sprintf(
				'{
				id: "gla_%s",
				price: %f,
				google_business_vertical: "retail",
				name: "%s",
				quantity: %d,
				}',
				esc_js( $product_id ),
				$price,
				esc_js( $product_name ),
				$quantity,
			);
		}

		// Check if this is the first time customer
		$is_new_customer = $this->is_first_time_customer( $order->get_billing_email() );

		// Track the purchase page
		$language = $this->wp->get_locale();
		if ( 'en_US' === $language ) {
			$language = 'English';
		}
		$purchase_page_gtag =
		sprintf(
			'gtag("event", "purchase", {
			ecomm_pagetype: "purchase",
			send_to: "%s",
			transaction_id: "%s",
			currency: "%s",
			country: "%s",
			value: %f,
			new_customer: %s,
			tax: %f,
			shipping: %f,
			delivery_postal_code: "%s",
			aw_feed_country: "%s",
			aw_feed_language: "%s",
			items: [%s]});',
			esc_js( "{$ads_conversion_id}/{$ads_conversion_label}" ),
			esc_js( $order->get_id() ),
			esc_js( $order->get_currency() ),
			esc_js( $this->wc->get_base_country() ),
			$order->get_total(),
			$is_new_customer ? 'true' : 'false',
			esc_js( $order->get_cart_tax() ),
			$order->get_total_shipping(),
			esc_js( $order->get_billing_postcode() ),
			esc_js( $this->wc->get_base_country() ),
			esc_js( $language ),
			join( ',', $item_info ),
		);
		$this->add_inline_event_script( $purchase_page_gtag );
	}

	/**
	 * Display the JavaScript code to track the product view page.
	 */
	private function display_view_item_event_snippet(): void {
		$product = wc_get_product( get_the_ID() );
		if ( ! $product instanceof WC_Product ) {
			return;
		}

		$this->add_product_data( $product );

		$view_item_gtag = sprintf(
			'gtag("event", "view_item", {
			send_to: "GLA",
			ecomm_pagetype: "product",
			value: %f,
			items:[{
				id: "gla_%s",
				price: %f,
				google_business_vertical: "retail",
				name: "%s",
				category: "%s",
			}]});',
			wc_get_price_to_display( $product ),
			esc_js( $product->get_id() ),
			wc_get_price_to_display( $product ),
			esc_js( $product->get_name() ),
			esc_js( join( ' & ', $this->product_helper->get_categories( $product ) ) ),
		);
		$this->add_inline_event_script( $view_item_gtag );
	}

	/**
	 * Display the JavaScript code to track all pages.
	 */
	private function display_page_view_event_snippet(): void {
		if ( ! is_cart() ) {
			$this->add_inline_event_script(
				'gtag("event", "page_view", {send_to: "GLA"});'
			);
			return;
		}
		// display the JavaScript code to track the cart page
		$item_info = [];

		foreach ( WC()->cart->get_cart() as $cart_item ) {
			// gets the product id
			$id = $cart_item['product_id'];

			// gets the product object
			$product = $cart_item['data'];
			$name    = $product->get_name();
			$price   = WC()->cart->display_prices_including_tax() ? wc_get_price_including_tax( $product ) : wc_get_price_excluding_tax( $product );
			// gets the cart item quantity
			$quantity = $cart_item['quantity'];

			$item_info[] = sprintf(
				'{
				id: "gla_%s",
				price: %f,
				google_business_vertical: "retail",
				name:"%s",
				quantity: %d,
				}',
				esc_js( $id ),
				$price,
				esc_js( $name ),
				$quantity,
			);
		}
		$value          = WC()->cart->total;
		$page_view_gtag = sprintf(
			'gtag("event", "page_view", {
			send_to: "GLA",
			ecomm_pagetype: "cart",
			value: %f,
			items: [%s]});',
			$value,
			join( ',', $item_info ),
		);
		$this->add_inline_event_script( $page_view_gtag );
	}

	/**
	 * Add product data to include in JS data.
	 *
	 * @since 2.0.3
	 *
	 * @param WC_Product $product
	 */
	protected function add_product_data( $product ) {
		$this->products[ $product->get_id() ] = [
			'name'  => $product->get_name(),
			'price' => wc_get_price_to_display( $product ),
		];
	}

	/**
	 * TODO: Should the Global Site Tag framework be used if there are no paid Ads campaigns?
	 *
	 * @return bool True if the Global Site Tag framework should be included.
	 */
	public static function is_needed(): bool {
		if ( apply_filters( 'woocommerce_gla_disable_gtag_tracking', false ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Check if the customer has previous orders.
	 * Called after order creation (check for older orders including the order which was just created).
	 *
	 * @param string $customer_email Customer email address.
	 * @return bool True if this customer has previous orders.
	 */
	private static function is_first_time_customer( $customer_email ): bool {
		$query = new \WC_Order_Query(
			[
				'limit'  => 2,
				'return' => 'ids',
			]
		);
		$query->set( 'customer', $customer_email );
		$orders = $query->get_orders();
		return count( $orders ) === 1 ? true : false;
	}

	/**
	 * This method ONLY works during development in the Fast Refresh mode.
	 *
	 * The runtime.js and react-refresh-runtime.js files are created when the front-end development is
	 * running `npm run start:hot`, and they need to be loaded to make the gtag-events scrips work.
	 */
	private function register_js_for_fast_refresh_dev() {
		// This file exists only when running `npm run start:hot`
		$runtime_path = "{$this->get_root_dir()}/js/build/runtime.js";

		if ( ! file_exists( $runtime_path ) ) {
			return;
		}

		$plugin_url = $this->get_plugin_url();

		wp_enqueue_script(
			'gla-webpack-runtime',
			"{$plugin_url}/js/build/runtime.js",
			[],
			(string) filemtime( $runtime_path ),
			false
		);

		// This script is one of the gtag-events dependencies, and its handle is wp-react-refresh-runtime.
		// Ref: js/build/gtag-events.asset.php
		wp_register_script(
			'wp-react-refresh-runtime',
			"{$plugin_url}/js/build-dev/react-refresh-runtime.js",
			[ 'gla-webpack-runtime' ],
			$this->get_version(),
			false
		);
	}
}
GoogleHelper.php000064400000066245151543047370007657 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;

/**
 * Class GoogleHelper
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds
 *
 * @since 1.12.0
 */
class GoogleHelper implements Service {

	protected const SUPPORTED_COUNTRIES = [
		// Algeria
		'DZ' => [
			'code'     => 'DZ',
			'currency' => 'DZD',
			'id'       => 2012,
		],
		// Angola
		'AO' => [
			'code'     => 'AO',
			'currency' => 'AOA',
			'id'       => 2024,
		],
		// Argentina
		'AR' => [
			'code'     => 'AR',
			'currency' => 'ARS',
			'id'       => 2032,
		],
		// Australia
		'AU' => [
			'code'     => 'AU',
			'currency' => 'AUD',
			'id'       => 2036,
		],
		// Austria
		'AT' => [
			'code'     => 'AT',
			'currency' => 'EUR',
			'id'       => 2040,
		],
		// Bahrain
		'BH' => [
			'code'     => 'BH',
			'currency' => 'BHD',
			'id'       => 2048,
		],
		// Bangladesh
		'BD' => [
			'code'     => 'BD',
			'currency' => 'BDT',
			'id'       => 2050,
		],
		// Belarus
		'BY' => [
			'code'     => 'BY',
			'currency' => 'BYN',
			'id'       => 2112,
		],
		// Belgium
		'BE' => [
			'code'     => 'BE',
			'currency' => 'EUR',
			'id'       => 2056,
		],
		// Brazil
		'BR' => [
			'code'     => 'BR',
			'currency' => 'BRL',
			'id'       => 2076,
		],
		// Cambodia
		'KH' => [
			'code'     => 'KH',
			'currency' => 'KHR',
			'id'       => 2116,
		],
		// Cameroon
		'CM' => [
			'code'     => 'CM',
			'currency' => 'XAF',
			'id'       => 2120,
		],
		// Canada
		'CA' => [
			'code'     => 'CA',
			'currency' => 'CAD',
			'id'       => 2124,
		],
		// Chile
		'CL' => [
			'code'     => 'CL',
			'currency' => 'CLP',
			'id'       => 2152,
		],
		// Colombia
		'CO' => [
			'code'     => 'CO',
			'currency' => 'COP',
			'id'       => 2170,
		],
		// Costa Rica
		'CR' => [
			'code'     => 'CR',
			'currency' => 'CRC',
			'id'       => 2188,
		],
		// Cote d'Ivoire
		'CI' => [
			'code'     => 'CI',
			'currency' => 'XOF',
			'id'       => 2384,
		],
		// Czechia
		'CZ' => [
			'code'     => 'CZ',
			'currency' => 'CZK',
			'id'       => 2203,
		],
		// Denmark
		'DK' => [
			'code'     => 'DK',
			'currency' => 'DKK',
			'id'       => 2208,
		],
		// Dominican Republic
		'DO' => [
			'code'     => 'DO',
			'currency' => 'DOP',
			'id'       => 2214,
		],
		// Ecuador
		'EC' => [
			'code'     => 'EC',
			'currency' => 'USD',
			'id'       => 2218,
		],
		// Egypt
		'EG' => [
			'code'     => 'EG',
			'currency' => 'EGP',
			'id'       => 2818,
		],
		// El Salvador
		'SV' => [
			'code'     => 'SV',
			'currency' => 'USD',
			'id'       => 2222,
		],
		// Ethiopia
		'ET' => [
			'code'     => 'ET',
			'currency' => 'ETB',
			'id'       => 2231,
		],
		// Finland
		'FI' => [
			'code'     => 'FI',
			'currency' => 'EUR',
			'id'       => 2246,
		],
		// France
		'FR' => [
			'code'     => 'FR',
			'currency' => 'EUR',
			'id'       => 2250,
		],
		// Georgia
		'GE' => [
			'code'     => 'GE',
			'currency' => 'GEL',
			'id'       => 2268,
		],
		// Germany
		'DE' => [
			'code'     => 'DE',
			'currency' => 'EUR',
			'id'       => 2276,
		],
		// Ghana
		'GH' => [
			'code'     => 'GH',
			'currency' => 'GHS',
			'id'       => 2288,
		],
		// Greece
		'GR' => [
			'code'     => 'GR',
			'currency' => 'EUR',
			'id'       => 2300,
		],
		// Guatemala
		'GT' => [
			'code'     => 'GT',
			'currency' => 'GTQ',
			'id'       => 2320,
		],
		// Hong Kong
		'HK' => [
			'code'     => 'HK',
			'currency' => 'HKD',
			'id'       => 2344,
		],
		// Hungary
		'HU' => [
			'code'     => 'HU',
			'currency' => 'HUF',
			'id'       => 2348,
		],
		// India
		'IN' => [
			'code'     => 'IN',
			'currency' => 'INR',
			'id'       => 2356,
		],
		// Indonesia
		'ID' => [
			'code'     => 'ID',
			'currency' => 'IDR',
			'id'       => 2360,
		],
		// Ireland
		'IE' => [
			'code'     => 'IE',
			'currency' => 'EUR',
			'id'       => 2372,
		],
		// Israel
		'IL' => [
			'code'     => 'IL',
			'currency' => 'ILS',
			'id'       => 2376,
		],
		// Italy
		'IT' => [
			'code'     => 'IT',
			'currency' => 'EUR',
			'id'       => 2380,
		],
		// Japan
		'JP' => [
			'code'     => 'JP',
			'currency' => 'JPY',
			'id'       => 2392,
		],
		// Jordan
		'JO' => [
			'code'     => 'JO',
			'currency' => 'JOD',
			'id'       => 2400,
		],
		// Kazakhstan
		'KZ' => [
			'code'     => 'KZ',
			'currency' => 'KZT',
			'id'       => 2398,
		],
		// Kenya
		'KE' => [
			'code'     => 'KE',
			'currency' => 'KES',
			'id'       => 2404,
		],
		// Kuwait
		'KW' => [
			'code'     => 'KW',
			'currency' => 'KWD',
			'id'       => 2414,
		],
		// Lebanon
		'LB' => [
			'code'     => 'LB',
			'currency' => 'LBP',
			'id'       => 2422,
		],
		// Madagascar
		'MG' => [
			'code'     => 'MG',
			'currency' => 'MGA',
			'id'       => 2450,
		],
		// Malaysia
		'MY' => [
			'code'     => 'MY',
			'currency' => 'MYR',
			'id'       => 2458,
		],
		// Mauritius
		'MU' => [
			'code'     => 'MU',
			'currency' => 'MUR',
			'id'       => 2480,
		],
		// Mexico
		'MX' => [
			'code'     => 'MX',
			'currency' => 'MXN',
			'id'       => 2484,
		],
		// Morocco
		'MA' => [
			'code'     => 'MA',
			'currency' => 'MAD',
			'id'       => 2504,
		],
		// Mozambique
		'MZ' => [
			'code'     => 'MZ',
			'currency' => 'MZN',
			'id'       => 2508,
		],
		// Myanmar 'Burma'
		'MM' => [
			'code'     => 'MM',
			'currency' => 'MMK',
			'id'       => 2104,
		],
		// Nepal
		'NP' => [
			'code'     => 'NP',
			'currency' => 'NPR',
			'id'       => 2524,
		],
		// Netherlands
		'NL' => [
			'code'     => 'NL',
			'currency' => 'EUR',
			'id'       => 2528,
		],
		// New Zealand
		'NZ' => [
			'code'     => 'NZ',
			'currency' => 'NZD',
			'id'       => 2554,
		],
		// Nicaragua
		'NI' => [
			'code'     => 'NI',
			'currency' => 'NIO',
			'id'       => 2558,
		],
		// Nigeria
		'NG' => [
			'code'     => 'NG',
			'currency' => 'NGN',
			'id'       => 2566,
		],
		// Norway
		'NO' => [
			'code'     => 'NO',
			'currency' => 'NOK',
			'id'       => 2578,
		],
		// Oman
		'OM' => [
			'code'     => 'OM',
			'currency' => 'OMR',
			'id'       => 2512,
		],
		// Pakistan
		'PK' => [
			'code'     => 'PK',
			'currency' => 'PKR',
			'id'       => 2586,
		],
		// Panama
		'PA' => [
			'code'     => 'PA',
			'currency' => 'PAB',
			'id'       => 2591,
		],
		// Paraguay
		'PY' => [
			'code'     => 'PY',
			'currency' => 'PYG',
			'id'       => 2600,
		],
		// Peru
		'PE' => [
			'code'     => 'PE',
			'currency' => 'PEN',
			'id'       => 2604,
		],
		// Philippines
		'PH' => [
			'code'     => 'PH',
			'currency' => 'PHP',
			'id'       => 2608,
		],
		// Poland
		'PL' => [
			'code'     => 'PL',
			'currency' => 'PLN',
			'id'       => 2616,
		],
		// Portugal
		'PT' => [
			'code'     => 'PT',
			'currency' => 'EUR',
			'id'       => 2620,
		],
		// Puerto Rico
		'PR' => [
			'code'     => 'PR',
			'currency' => 'USD',
			'id'       => 2630,
		],
		// Romania
		'RO' => [
			'code'     => 'RO',
			'currency' => 'RON',
			'id'       => 2642,
		],
		// Russia
		'RU' => [
			'code'     => 'RU',
			'currency' => 'RUB',
			'id'       => 2643,
		],
		// Saudi Arabia
		'SA' => [
			'code'     => 'SA',
			'currency' => 'SAR',
			'id'       => 2682,
		],
		// Senegal
		'SN' => [
			'code'     => 'SN',
			'currency' => 'XOF',
			'id'       => 2686,
		],
		// Singapore
		'SG' => [
			'code'     => 'SG',
			'currency' => 'SGD',
			'id'       => 2702,
		],
		// Slovakia
		'SK' => [
			'code'     => 'SK',
			'currency' => 'EUR',
			'id'       => 2703,
		],
		// South Africa
		'ZA' => [
			'code'     => 'ZA',
			'currency' => 'ZAR',
			'id'       => 2710,
		],
		// Spain
		'ES' => [
			'code'     => 'ES',
			'currency' => 'EUR',
			'id'       => 2724,
		],
		// Sri Lanka
		'LK' => [
			'code'     => 'LK',
			'currency' => 'LKR',
			'id'       => 2144,
		],
		// Sweden
		'SE' => [
			'code'     => 'SE',
			'currency' => 'SEK',
			'id'       => 2752,
		],
		// Switzerland
		'CH' => [
			'code'     => 'CH',
			'currency' => 'CHF',
			'id'       => 2756,
		],
		// Taiwan
		'TW' => [
			'code'     => 'TW',
			'currency' => 'TWD',
			'id'       => 2158,
		],
		// Tanzania
		'TZ' => [
			'code'     => 'TZ',
			'currency' => 'TZS',
			'id'       => 2834,
		],
		// Thailand
		'TH' => [
			'code'     => 'TH',
			'currency' => 'THB',
			'id'       => 2764,
		],
		// Tunisia
		'TN' => [
			'code'     => 'TN',
			'currency' => 'TND',
			'id'       => 2788,
		],
		// Turkey
		'TR' => [
			'code'     => 'TR',
			'currency' => 'TRY',
			'id'       => 2792,
		],
		// United Arab Emirates
		'AE' => [
			'code'     => 'AE',
			'currency' => 'AED',
			'id'       => 2784,
		],
		// Uganda
		'UG' => [
			'code'     => 'UG',
			'currency' => 'UGX',
			'id'       => 2800,
		],
		// Ukraine
		'UA' => [
			'code'     => 'UA',
			'currency' => 'UAH',
			'id'       => 2804,
		],
		// United Kingdom
		'GB' => [
			'code'     => 'GB',
			'currency' => 'GBP',
			'id'       => 2826,
		],
		// United States
		'US' => [
			'code'     => 'US',
			'currency' => 'USD',
			'id'       => 2840,
		],
		// Uruguay
		'UY' => [
			'code'     => 'UY',
			'currency' => 'UYU',
			'id'       => 2858,
		],
		// Uzbekistan
		'UZ' => [
			'code'     => 'UZ',
			'currency' => 'UZS',
			'id'       => 2860,
		],
		// Venezuela
		'VE' => [
			'code'     => 'VE',
			'currency' => 'VEF',
			'id'       => 2862,
		],
		// Vietnam
		'VN' => [
			'code'     => 'VN',
			'currency' => 'VND',
			'id'       => 2704,
		],
		// Zambia
		'ZM' => [
			'code'     => 'ZM',
			'currency' => 'ZMW',
			'id'       => 2894,
		],
		// Zimbabwe
		'ZW' => [
			'code'     => 'ZW',
			'currency' => 'USD',
			'id'       => 2716,
		],
	];

	protected const COUNTRY_SUBDIVISIONS = [
		// Australia
		'AU' => [
			'ACT' => [
				'id'   => 20034,
				'code' => 'ACT',
				'name' => 'Australian Capital Territory',
			],
			'NSW' => [
				'id'   => 20035,
				'code' => 'NSW',
				'name' => 'New South Wales',
			],
			'NT'  => [
				'id'   => 20036,
				'code' => 'NT',
				'name' => 'Northern Territory',
			],
			'QLD' => [
				'id'   => 20037,
				'code' => 'QLD',
				'name' => 'Queensland',
			],
			'SA'  => [
				'id'   => 20038,
				'code' => 'SA',
				'name' => 'South Australia',
			],
			'TAS' => [
				'id'   => 20039,
				'code' => 'TAS',
				'name' => 'Tasmania',
			],
			'VIC' => [
				'id'   => 20040,
				'code' => 'VIC',
				'name' => 'Victoria',
			],
			'WA'  => [
				'id'   => 20041,
				'code' => 'WA',
				'name' => 'Western Australia',
			],
		],
		// Japan
		'JP' => [
			'JP01' => [
				'id'   => 20624,
				'code' => 'JP01',
				'name' => 'Hokkaido',
			],
			'JP02' => [
				'id'   => 20625,
				'code' => 'JP02',
				'name' => 'Aomori',
			],
			'JP03' => [
				'id'   => 20626,
				'code' => 'JP03',
				'name' => 'Iwate',
			],
			'JP04' => [
				'id'   => 20627,
				'code' => 'JP04',
				'name' => 'Miyagi',
			],
			'JP05' => [
				'id'   => 20628,
				'code' => 'JP05',
				'name' => 'Akita',
			],
			'JP06' => [
				'id'   => 20629,
				'code' => 'JP06',
				'name' => 'Yamagata',
			],
			'JP07' => [
				'id'   => 20630,
				'code' => 'JP07',
				'name' => 'Fukushima',
			],
			'JP08' => [
				'id'   => 20631,
				'code' => 'JP08',
				'name' => 'Ibaraki',
			],
			'JP09' => [
				'id'   => 20632,
				'code' => 'JP09',
				'name' => 'Tochigi',
			],
			'JP10' => [
				'id'   => 20633,
				'code' => 'JP10',
				'name' => 'Gunma',
			],
			'JP11' => [
				'id'   => 20634,
				'code' => 'JP11',
				'name' => 'Saitama',
			],
			'JP12' => [
				'id'   => 20635,
				'code' => 'JP12',
				'name' => 'Chiba',
			],
			'JP13' => [
				'id'   => 20636,
				'code' => 'JP13',
				'name' => 'Tokyo',
			],
			'JP14' => [
				'id'   => 20637,
				'code' => 'JP14',
				'name' => 'Kanagawa',
			],
			'JP15' => [
				'id'   => 20638,
				'code' => 'JP15',
				'name' => 'Niigata',
			],
			'JP16' => [
				'id'   => 20639,
				'code' => 'JP16',
				'name' => 'Toyama',
			],
			'JP17' => [
				'id'   => 20640,
				'code' => 'JP17',
				'name' => 'Ishikawa',
			],
			'JP18' => [
				'id'   => 20641,
				'code' => 'JP18',
				'name' => 'Fukui',
			],
			'JP19' => [
				'id'   => 20642,
				'code' => 'JP19',
				'name' => 'Yamanashi',
			],
			'JP20' => [
				'id'   => 20643,
				'code' => 'JP20',
				'name' => 'Nagano',
			],
			'JP21' => [
				'id'   => 20644,
				'code' => 'JP21',
				'name' => 'Gifu',
			],
			'JP22' => [
				'id'   => 20645,
				'code' => 'JP22',
				'name' => 'Shizuoka',
			],
			'JP23' => [
				'id'   => 20646,
				'code' => 'JP23',
				'name' => 'Aichi',
			],
			'JP24' => [
				'id'   => 20647,
				'code' => 'JP24',
				'name' => 'Mie',
			],
			'JP25' => [
				'id'   => 20648,
				'code' => 'JP25',
				'name' => 'Shiga',
			],
			'JP26' => [
				'id'   => 20649,
				'code' => 'JP26',
				'name' => 'Kyoto',
			],
			'JP27' => [
				'id'   => 20650,
				'code' => 'JP27',
				'name' => 'Osaka',
			],
			'JP28' => [
				'id'   => 20651,
				'code' => 'JP28',
				'name' => 'Hyogo',
			],
			'JP29' => [
				'id'   => 20652,
				'code' => 'JP29',
				'name' => 'Nara',
			],
			'JP30' => [
				'id'   => 20653,
				'code' => 'JP30',
				'name' => 'Wakayama',
			],
			'JP31' => [
				'id'   => 20654,
				'code' => 'JP31',
				'name' => 'Tottori',
			],
			'JP32' => [
				'id'   => 20655,
				'code' => 'JP32',
				'name' => 'Shimane',
			],
			'JP33' => [
				'id'   => 20656,
				'code' => 'JP33',
				'name' => 'Okayama',
			],
			'JP34' => [
				'id'   => 20657,
				'code' => 'JP34',
				'name' => 'Hiroshima',
			],
			'JP35' => [
				'id'   => 20658,
				'code' => 'JP35',
				'name' => 'Yamaguchi',
			],
			'JP36' => [
				'id'   => 20659,
				'code' => 'JP36',
				'name' => 'Tokushima',
			],
			'JP37' => [
				'id'   => 20660,
				'code' => 'JP37',
				'name' => 'Kagawa',
			],
			'JP38' => [
				'id'   => 20661,
				'code' => 'JP38',
				'name' => 'Ehime',
			],
			'JP39' => [
				'id'   => 20662,
				'code' => 'JP39',
				'name' => 'Kochi',
			],
			'JP40' => [
				'id'   => 20663,
				'code' => 'JP40',
				'name' => 'Fukuoka',
			],
			'JP41' => [
				'id'   => 20664,
				'code' => 'JP41',
				'name' => 'Saga',
			],
			'JP42' => [
				'id'   => 20665,
				'code' => 'JP42',
				'name' => 'Nagasaki',
			],
			'JP43' => [
				'id'   => 20666,
				'code' => 'JP43',
				'name' => 'Kumamoto',
			],
			'JP44' => [
				'id'   => 20667,
				'code' => 'JP44',
				'name' => 'Oita',
			],
			'JP45' => [
				'id'   => 20668,
				'code' => 'JP45',
				'name' => 'Miyazaki',
			],
			'JP46' => [
				'id'   => 20669,
				'code' => 'JP46',
				'name' => 'Kagoshima',
			],
			'JP47' => [
				'id'   => 20670,
				'code' => 'JP47',
				'name' => 'Okinawa',
			],
		],
		// United States
		'US' => [
			'AK' => [
				'id'   => 21132,
				'code' => 'AK',
				'name' => 'Alaska',
			],
			'AL' => [
				'id'   => 21133,
				'code' => 'AL',
				'name' => 'Alabama',
			],
			'AR' => [
				'id'   => 21135,
				'code' => 'AR',
				'name' => 'Arkansas',
			],
			'AZ' => [
				'id'   => 21136,
				'code' => 'AZ',
				'name' => 'Arizona',
			],
			'CA' => [
				'id'   => 21137,
				'code' => 'CA',
				'name' => 'California',
			],
			'CO' => [
				'id'   => 21138,
				'code' => 'CO',
				'name' => 'Colorado',
			],
			'CT' => [
				'id'   => 21139,
				'code' => 'CT',
				'name' => 'Connecticut',
			],
			'DC' => [
				'id'   => 21140,
				'code' => 'DC',
				'name' => 'District of Columbia',
			],
			'DE' => [
				'id'   => 21141,
				'code' => 'DE',
				'name' => 'Delaware',
			],
			'FL' => [
				'id'   => 21142,
				'code' => 'FL',
				'name' => 'Florida',
			],
			'GA' => [
				'id'   => 21143,
				'code' => 'GA',
				'name' => 'Georgia',
			],
			'HI' => [
				'id'   => 21144,
				'code' => 'HI',
				'name' => 'Hawaii',
			],
			'IA' => [
				'id'   => 21145,
				'code' => 'IA',
				'name' => 'Iowa',
			],
			'ID' => [
				'id'   => 21146,
				'code' => 'ID',
				'name' => 'Idaho',
			],
			'IL' => [
				'id'   => 21147,
				'code' => 'IL',
				'name' => 'Illinois',
			],
			'IN' => [
				'id'   => 21148,
				'code' => 'IN',
				'name' => 'Indiana',
			],
			'KS' => [
				'id'   => 21149,
				'code' => 'KS',
				'name' => 'Kansas',
			],
			'KY' => [
				'id'   => 21150,
				'code' => 'KY',
				'name' => 'Kentucky',
			],
			'LA' => [
				'id'   => 21151,
				'code' => 'LA',
				'name' => 'Louisiana',
			],
			'MA' => [
				'id'   => 21152,
				'code' => 'MA',
				'name' => 'Massachusetts',
			],
			'MD' => [
				'id'   => 21153,
				'code' => 'MD',
				'name' => 'Maryland',
			],
			'ME' => [
				'id'   => 21154,
				'code' => 'ME',
				'name' => 'Maine',
			],
			'MI' => [
				'id'   => 21155,
				'code' => 'MI',
				'name' => 'Michigan',
			],
			'MN' => [
				'id'   => 21156,
				'code' => 'MN',
				'name' => 'Minnesota',
			],
			'MO' => [
				'id'   => 21157,
				'code' => 'MO',
				'name' => 'Missouri',
			],
			'MS' => [
				'id'   => 21158,
				'code' => 'MS',
				'name' => 'Mississippi',
			],
			'MT' => [
				'id'   => 21159,
				'code' => 'MT',
				'name' => 'Montana',
			],
			'NC' => [
				'id'   => 21160,
				'code' => 'NC',
				'name' => 'North Carolina',
			],
			'ND' => [
				'id'   => 21161,
				'code' => 'ND',
				'name' => 'North Dakota',
			],
			'NE' => [
				'id'   => 21162,
				'code' => 'NE',
				'name' => 'Nebraska',
			],
			'NH' => [
				'id'   => 21163,
				'code' => 'NH',
				'name' => 'New Hampshire',
			],
			'NJ' => [
				'id'   => 21164,
				'code' => 'NJ',
				'name' => 'New Jersey',
			],
			'NM' => [
				'id'   => 21165,
				'code' => 'NM',
				'name' => 'New Mexico',
			],
			'NV' => [
				'id'   => 21166,
				'code' => 'NV',
				'name' => 'Nevada',
			],
			'NY' => [
				'id'   => 21167,
				'code' => 'NY',
				'name' => 'New York',
			],
			'OH' => [
				'id'   => 21168,
				'code' => 'OH',
				'name' => 'Ohio',
			],
			'OK' => [
				'id'   => 21169,
				'code' => 'OK',
				'name' => 'Oklahoma',
			],
			'OR' => [
				'id'   => 21170,
				'code' => 'OR',
				'name' => 'Oregon',
			],
			'PA' => [
				'id'   => 21171,
				'code' => 'PA',
				'name' => 'Pennsylvania',
			],
			'RI' => [
				'id'   => 21172,
				'code' => 'RI',
				'name' => 'Rhode Island',
			],
			'SC' => [
				'id'   => 21173,
				'code' => 'SC',
				'name' => 'South Carolina',
			],
			'SD' => [
				'id'   => 21174,
				'code' => 'SD',
				'name' => 'South Dakota',
			],
			'TN' => [
				'id'   => 21175,
				'code' => 'TN',
				'name' => 'Tennessee',
			],
			'TX' => [
				'id'   => 21176,
				'code' => 'TX',
				'name' => 'Texas',
			],
			'UT' => [
				'id'   => 21177,
				'code' => 'UT',
				'name' => 'Utah',
			],
			'VA' => [
				'id'   => 21178,
				'code' => 'VA',
				'name' => 'Virginia',
			],
			'VT' => [
				'id'   => 21179,
				'code' => 'VT',
				'name' => 'Vermont',
			],
			'WA' => [
				'id'   => 21180,
				'code' => 'WA',
				'name' => 'Washington',
			],
			'WI' => [
				'id'   => 21182,
				'code' => 'WI',
				'name' => 'Wisconsin',
			],
			'WV' => [
				'id'   => 21183,
				'code' => 'WV',
				'name' => 'West Virginia',
			],
			'WY' => [
				'id'   => 21184,
				'code' => 'WY',
				'name' => 'Wyoming',
			],
		],
	];

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

	/**
	 * @var array Map of location ids to country codes.
	 */
	private $country_id_code_map;

	/**
	 * @var array Map of location ids to subdivision codes.
	 */
	private $subdivision_id_code_map;

	/**
	 * GoogleHelper constructor.
	 *
	 * @param WC $wc
	 */
	public function __construct( WC $wc ) {
		$this->wc = $wc;
	}


	/**
	 * Get the data for countries supported by Google.
	 *
	 * @return array[]
	 */
	protected function get_mc_supported_countries_data(): array {
		$supported = self::SUPPORTED_COUNTRIES;

		// Currency conversion is unavailable in South Korea: https://support.google.com/merchants/answer/7055540
		if ( 'KRW' === $this->wc->get_woocommerce_currency() ) {
			// South Korea
			$supported['KR'] = [
				'code'     => 'KR',
				'currency' => 'KRW',
				'id'       => 2410,
			];
		}

		return $supported;
	}

	/**
	 * Get an array of Google Merchant Center supported countries and currencies.
	 *
	 * Note - Other currencies may be supported using currency conversion.
	 *
	 * WooCommerce Countries -> https://github.com/woocommerce/woocommerce/blob/master/i18n/countries.php
	 * Google Supported Countries -> https://support.google.com/merchants/answer/160637?hl=en
	 *
	 * @return array
	 */
	public function get_mc_supported_countries_currencies(): array {
		return array_column(
			$this->get_mc_supported_countries_data(),
			'currency',
			'code'
		);
	}

	/**
	 * Get an array of Google Merchant Center supported countries.
	 *
	 * WooCommerce Countries -> https://github.com/woocommerce/woocommerce/blob/master/i18n/countries.php
	 * Google Supported Countries -> https://support.google.com/merchants/answer/160637?hl=en
	 *
	 * @return string[] Array of country codes.
	 */
	public function get_mc_supported_countries(): array {
		return array_keys( $this->get_mc_supported_countries_data() );
	}

	/**
	 * Get an array of Google Merchant Center supported countries and currencies for promotions.
	 *
	 * Google Promotion Supported Countries -> https://developers.google.com/shopping-content/reference/rest/v2.1/promotions
	 *
	 * @return array
	 */
	protected function get_mc_promotion_supported_countries_currencies(): array {
		return [
			'AU' => 'AUD', // Australia
			'BR' => 'BRL', // Brazil
			'CA' => 'CAD', // Canada
			'DE' => 'EUR', // Germany
			'ES' => 'EUR', // Spain
			'FR' => 'EUR', // France
			'GB' => 'GBP', // United Kingdom
			'IN' => 'INR', // India
			'IT' => 'EUR', // Italy
			'JP' => 'JPY', // Japan
			'NL' => 'EUR', // The Netherlands
			'KR' => 'KRW', // South Korea
			'US' => 'USD', // United States
		];
	}

	/**
	 * Get an array of Google Merchant Center supported countries for promotions.
	 *
	 * @return string[]
	 */
	public function get_mc_promotion_supported_countries(): array {
		return array_keys( $this->get_mc_promotion_supported_countries_currencies() );
	}

	/**
	 * Get an array of Google Merchant Center supported languages (ISO 639-1).
	 *
	 * WooCommerce Languages -> https://translate.wordpress.org/projects/wp-plugins/woocommerce/
	 * Google Supported Languages -> https://support.google.com/merchants/answer/160637?hl=en
	 * ISO 639-1 -> https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
	 *
	 * @return array
	 */
	public function get_mc_supported_languages(): array {
		// Repeated values removed:
		// 'pt', // Brazilian Portuguese
		// 'zh', // Simplified Chinese*

		return [
			'ar' => 'ar', // Arabic
			'cs' => 'cs', // Czech
			'da' => 'da', // Danish
			'nl' => 'nl', // Dutch
			'en' => 'en', // English
			'fi' => 'fi', // Finnish
			'fr' => 'fr', // French
			'de' => 'de', // German
			'he' => 'he', // Hebrew
			'hu' => 'hu', // Hungarian
			'id' => 'id', // Indonesian
			'it' => 'it', // Italian
			'ja' => 'ja', // Japanese
			'ko' => 'ko', // Korean
			'el' => 'el', // Modern Greek
			'nb' => 'nb', // Norwegian (Norsk Bokmål)
			'nn' => 'nn', // Norwegian (Norsk Nynorsk)
			'no' => 'no', // Norwegian
			'pl' => 'pl', // Polish
			'pt' => 'pt', // Portuguese
			'ro' => 'ro', // Romanian
			'ru' => 'ru', // Russian
			'sk' => 'sk', // Slovak
			'es' => 'es', // Spanish
			'sv' => 'sv', // Swedish
			'th' => 'th', // Thai
			'zh' => 'zh', // Traditional Chinese
			'tr' => 'tr', // Turkish
			'uk' => 'uk', // Ukrainian
			'vi' => 'vi', // Vietnamese
		];
	}

	/**
	 * Get whether the country is supported by the Merchant Center.
	 *
	 * @param string $country Country code.
	 *
	 * @return bool True if the country is in the list of MC-supported countries.
	 */
	public function is_country_supported( string $country ): bool {
		return array_key_exists(
			strtoupper( $country ),
			$this->get_mc_supported_countries_data()
		);
	}

	/**
	 * Find the ISO 3166-1 code of the Merchant Center supported country by its location ID.
	 *
	 * @param int $id
	 *
	 * @return string|null ISO 3166-1 representation of the country code.
	 */
	public function find_country_code_by_id( int $id ): ?string {
		return $this->get_country_id_code_map()[ $id ] ?? null;
	}

	/**
	 * Find the code of the Merchant Center supported subdivision by its location ID.
	 *
	 * @param int $id
	 *
	 * @return string|null
	 */
	public function find_subdivision_code_by_id( int $id ): ?string {
		return $this->get_subdivision_id_code_map()[ $id ] ?? null;
	}

	/**
	 * Find and return the location id for the given country code.
	 *
	 * @param string $code
	 *
	 * @return int|null
	 */
	public function find_country_id_by_code( string $code ): ?int {
		$countries = $this->get_mc_supported_countries_data();

		if ( isset( $countries[ $code ] ) ) {
			return $countries[ $code ]['id'];
		}

		return null;
	}

	/**
	 * Find and return the location id for the given subdivision (state, province, etc.) code.
	 *
	 * @param string $code
	 * @param string $country_code
	 *
	 * @return int|null
	 */
	public function find_subdivision_id_by_code( string $code, string $country_code ): ?int {
		return self::COUNTRY_SUBDIVISIONS[ $country_code ][ $code ]['id'] ?? null;
	}

	/**
	 * Gets the list of supported Merchant Center countries from a continent.
	 *
	 * @param string $continent_code
	 *
	 * @return string[] Returns an array of country codes with each country code used both as the key and value.
	 *                  For example: [ 'US' => 'US', 'DE' => 'DE' ].
	 *
	 * @since 1.13.0
	 */
	public function get_supported_countries_from_continent( string $continent_code ): array {
		$countries  = [];
		$continents = $this->wc->get_continents();
		if ( isset( $continents[ $continent_code ] ) ) {
			$countries = $continents[ $continent_code ]['countries'];

			// Match the list of countries with the list of Merchant Center supported countries.
			$countries = array_intersect( $countries, $this->get_mc_supported_countries() );

			// Use the country code as array keys.
			$countries = array_combine( $countries, $countries );
		}

		return $countries;
	}

	/**
	 * Check whether the given country code supports regional shipping (i.e. setting up rates for states/provinces and postal codes).
	 *
	 * @param string $country_code
	 *
	 * @return bool
	 *
	 * @since 2.1.0
	 */
	public function does_country_support_regional_shipping( string $country_code ): bool {
		return in_array( $country_code, [ 'AU', 'JP', 'US' ], true );
	}

	/**
	 * Returns an array mapping the ID of the Merchant Center supported countries to their respective codes.
	 *
	 * @return string[] Array of country codes with location IDs as keys. e.g. [ 2840 => 'US' ]
	 */
	protected function get_country_id_code_map(): array {
		if ( isset( $this->country_id_code_map ) ) {
			return $this->country_id_code_map;
		}
		$this->country_id_code_map = [];

		$countries = $this->get_mc_supported_countries_data();
		foreach ( $countries as $country ) {
			$this->country_id_code_map[ $country['id'] ] = $country['code'];
		}

		return $this->country_id_code_map;
	}

	/**
	 * Returns an array mapping the ID of the Merchant Center supported subdivisions to their respective codes.
	 *
	 * @return string[] Array of subdivision codes with location IDs as keys. e.g. [ 20035 => 'NSW' ]
	 */
	protected function get_subdivision_id_code_map(): array {
		if ( isset( $this->subdivision_id_code_map ) ) {
			return $this->subdivision_id_code_map;
		}
		$this->subdivision_id_code_map = [];

		foreach ( self::COUNTRY_SUBDIVISIONS as $subdivisions ) {
			foreach ( $subdivisions as $item ) {
				$this->subdivision_id_code_map[ $item['id'] ] = $item['code'];
			}
		}

		return $this->subdivision_id_code_map;
	}
}
GoogleHelperAwareInterface.php000064400000000663151543047370012450 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;

defined( 'ABSPATH' ) || exit;

/**
 * Interface GoogleHelperAwareInterface
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Google;
 */
interface GoogleHelperAwareInterface {

	/**
	 * @param GoogleHelper $google_helper
	 *
	 * @return void
	 */
	public function set_google_helper_object( GoogleHelper $google_helper ): void;
}
GoogleHelperAwareTrait.php000064400000001056151543047370011630 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;

defined( 'ABSPATH' ) || exit;

/**
 * Trait GoogleHelperAwareTrait
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Google;
 */
trait GoogleHelperAwareTrait {

	/**
	 * The GoogleHelper object.
	 *
	 * @var google_helper
	 */
	protected $google_helper;

	/**
	 * @param GoogleHelper $google_helper
	 *
	 * @return void
	 */
	public function set_google_helper_object( GoogleHelper $google_helper ): void {
		$this->google_helper = $google_helper;
	}
}
GoogleProductService.php000064400000020237151543047370011370 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Exception as GoogleException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Product as GoogleProduct;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductsCustomBatchRequest as GoogleBatchRequest;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductsCustomBatchRequestEntry as GoogleBatchRequestEntry;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductsCustomBatchResponse as GoogleBatchResponse;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductsCustomBatchResponseEntry as GoogleBatchResponseEntry;

defined( 'ABSPATH' ) || exit;

/**
 * Class GoogleProductService
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Google
 */
class GoogleProductService implements OptionsAwareInterface, Service {

	use OptionsAwareTrait;
	use ValidateInterface;

	public const INTERNAL_ERROR_REASON  = 'internalError';
	public const NOT_FOUND_ERROR_REASON = 'notFound';

	/**
	 * This is the maximum batch size recommended by Google
	 *
	 * @link https://developers.google.com/shopping-content/guides/how-tos/batch
	 */
	public const BATCH_SIZE = 1000;

	protected const METHOD_DELETE = 'delete';
	protected const METHOD_GET    = 'get';
	protected const METHOD_INSERT = 'insert';

	/**
	 * @var ShoppingContent
	 */
	protected $shopping_service;

	/**
	 * GoogleProductService constructor.
	 *
	 * @param ShoppingContent $shopping_service
	 */
	public function __construct( ShoppingContent $shopping_service ) {
		$this->shopping_service = $shopping_service;
	}

	/**
	 * @param string $product_id Google product ID.
	 *
	 * @return GoogleProduct
	 *
	 * @throws GoogleException If there are any Google API errors.
	 */
	public function get( string $product_id ): GoogleProduct {
		$merchant_id = $this->options->get_merchant_id();

		return $this->shopping_service->products->get( $merchant_id, $product_id );
	}

	/**
	 * @param GoogleProduct $product
	 *
	 * @return GoogleProduct
	 *
	 * @throws GoogleException If there are any Google API errors.
	 */
	public function insert( GoogleProduct $product ): GoogleProduct {
		$merchant_id = $this->options->get_merchant_id();

		return $this->shopping_service->products->insert( $merchant_id, $product );
	}

	/**
	 * @param string $product_id Google product ID.
	 *
	 * @throws GoogleException If there are any Google API errors.
	 */
	public function delete( string $product_id ) {
		$merchant_id = $this->options->get_merchant_id();

		$this->shopping_service->products->delete( $merchant_id, $product_id );
	}

	/**
	 * @param BatchProductIDRequestEntry[] $products
	 *
	 * @return BatchProductResponse
	 *
	 * @throws InvalidValue If any of the provided products are invalid.
	 * @throws GoogleException If there are any Google API errors.
	 */
	public function get_batch( array $products ): BatchProductResponse {
		return $this->custom_batch( $products, self::METHOD_GET );
	}

	/**
	 * @param BatchProductRequestEntry[] $products
	 *
	 * @return BatchProductResponse
	 *
	 * @throws InvalidValue If any of the provided products are invalid.
	 * @throws GoogleException If there are any Google API errors.
	 */
	public function insert_batch( array $products ): BatchProductResponse {
		return $this->custom_batch( $products, self::METHOD_INSERT );
	}

	/**
	 * @param BatchProductIDRequestEntry[] $products
	 *
	 * @return BatchProductResponse
	 *
	 * @throws InvalidValue If any of the provided products are invalid.
	 * @throws GoogleException If there are any Google API errors.
	 */
	public function delete_batch( array $products ): BatchProductResponse {
		return $this->custom_batch( $products, self::METHOD_DELETE );
	}

	/**
	 * @param BatchProductRequestEntry[]|BatchProductIDRequestEntry[] $products
	 * @param string                                                  $method
	 *
	 * @return BatchProductResponse
	 *
	 * @throws InvalidValue    If any of the products' type is invalid for the batch method.
	 * @throws GoogleException If there are any Google API errors.
	 */
	protected function custom_batch( array $products, string $method ): BatchProductResponse {
		if ( empty( $products ) ) {
			return new BatchProductResponse( [], [] );
		}

		$merchant_id     = $this->options->get_merchant_id();
		$request_entries = [];

		// An array of product entries mapped to each batch ID. Used to parse Google's batch response.
		$batch_id_product_map = [];

		$batch_id = 0;
		foreach ( $products as $product_entry ) {
			$this->validate_batch_request_entry( $product_entry, $method );

			$request_entry = new GoogleBatchRequestEntry(
				[
					'batchId'    => $batch_id,
					'merchantId' => $merchant_id,
					'method'     => $method,
				]
			);

			if ( $product_entry instanceof BatchProductRequestEntry ) {
				$request_entry['product'] = $product_entry->get_product();
			} else {
				$request_entry['product_id'] = $product_entry->get_product_id();
			}
			$request_entries[] = $request_entry;

			$batch_id_product_map[ $batch_id ] = $product_entry;

			++$batch_id;
		}

		$responses = $this->shopping_service->products->custombatch( new GoogleBatchRequest( [ 'entries' => $request_entries ] ) );

		return $this->parse_batch_responses( $responses, $batch_id_product_map );
	}

	/**
	 * @param GoogleBatchResponse                                     $responses
	 * @param BatchProductRequestEntry[]|BatchProductIDRequestEntry[] $batch_id_product_map An array of product entries mapped to each batch ID. Used to parse Google's batch response.
	 *
	 * @return BatchProductResponse
	 */
	protected function parse_batch_responses( GoogleBatchResponse $responses, array $batch_id_product_map ): BatchProductResponse {
		$result_products = [];
		$errors          = [];

		/**
		 * @var GoogleBatchResponseEntry $response
		 */
		foreach ( $responses as $response ) {
			// Product entry is mapped to batchId when sending the request
			$product_entry = $batch_id_product_map[ $response->batchId ]; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
			$wc_product_id = $product_entry->get_wc_product_id();
			if ( $product_entry instanceof BatchProductRequestEntry ) {
				$google_product_id = $product_entry->get_product()->getId();
			} else {
				$google_product_id = $product_entry->get_product_id();
			}

			if ( empty( $response->getErrors() ) ) {
				$result_products[] = new BatchProductEntry( $wc_product_id, $response->getProduct() );
			} else {
				$errors[] = new BatchInvalidProductEntry( $wc_product_id, $google_product_id, self::get_batch_response_error_messages( $response ) );
			}
		}

		return new BatchProductResponse( $result_products, $errors );
	}

	/**
	 * @param BatchProductRequestEntry|BatchProductIDRequestEntry $request_entry
	 * @param string                                              $method
	 *
	 * @throws InvalidValue If the product type is invalid for the batch method.
	 */
	protected function validate_batch_request_entry( $request_entry, string $method ) {
		if ( self::METHOD_INSERT === $method ) {
			$this->validate_instanceof( $request_entry, BatchProductRequestEntry::class );
		} else {
			$this->validate_instanceof( $request_entry, BatchProductIDRequestEntry::class );
		}
	}

	/**
	 * @param GoogleBatchResponseEntry $batch_response_entry
	 *
	 * @return string[]
	 */
	protected static function get_batch_response_error_messages( GoogleBatchResponseEntry $batch_response_entry ): array {
		$errors = [];
		foreach ( $batch_response_entry->getErrors()->getErrors() as $error ) {
			$errors[ $error->getReason() ] = $error->getMessage();
		}

		return $errors;
	}
}
GooglePromotionService.php000064400000003114151543047370011731 0ustar00<?php
declare(strict_types = 1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Exception as GoogleException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Promotion as GooglePromotion;
defined( 'ABSPATH' ) || exit();

/**
 * Class GooglePromotionService
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Google
 */
class GooglePromotionService implements OptionsAwareInterface, Service {

	use OptionsAwareTrait;

	public const INTERNAL_ERROR_CODE = 500;

	public const INTERNAL_ERROR_MSG = 'Internal error';

	/**
	 *
	 * @var ShoppingContent
	 */
	protected $shopping_service;

	/**
	 * GooglePromotionService constructor.
	 *
	 * @param ShoppingContent $shopping_service
	 */
	public function __construct( ShoppingContent $shopping_service ) {
		$this->shopping_service = $shopping_service;
	}

	/**
	 *
	 * @param GooglePromotion $promotion
	 *
	 * @return GooglePromotion
	 *
	 * @throws GoogleException If there are any Google API errors.
	 */
	public function create( GooglePromotion $promotion ): GooglePromotion {
		$merchant_id = $this->options->get_merchant_id();

		return $this->shopping_service->promotions->create(
			$merchant_id,
			$promotion
		);
	}
}
InvalidCouponEntry.php000064400000005365151543047370011073 0ustar00<?php
declare(strict_types = 1);
namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;

use JsonSerializable;
use Symfony\Component\Validator\ConstraintViolationListInterface;
defined( 'ABSPATH' ) || exit();

/**
 * Class InvalidCouponEntry
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Google
 */
class InvalidCouponEntry implements JsonSerializable {

	/**
	 *
	 * @var int WooCommerce coupon ID.
	 */
	protected $wc_coupon_id;

	/**
	 *
	 * @var string target country of the promotion.
	 */
	protected $target_country;

	/**
	 *
	 * @var string|null Google promotion ID.
	 */
	protected $google_promotion_id;

	/**
	 *
	 * @var string[]
	 */
	protected $errors;

	/**
	 * InvalidCouponEntry constructor.
	 *
	 * @param int         $wc_coupon_id
	 * @param string[]    $errors
	 * @param string|null $target_country
	 * @param string|null $google_promotion_id
	 */
	public function __construct(
		int $wc_coupon_id,
		array $errors = [],
		?string $target_country = null,
		?string $google_promotion_id = null
	) {
		$this->wc_coupon_id        = $wc_coupon_id;
		$this->target_country      = $target_country;
		$this->google_promotion_id = $google_promotion_id;
		$this->errors              = $errors;
	}

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

	/**
	 *
	 * @return string|null
	 */
	public function get_google_promotion_id(): ?string {
		return $this->google_promotion_id;
	}

	/**
	 *
	 * @return string|null
	 */
	public function get_target_country(): ?string {
		return $this->target_country;
	}

	/**
	 *
	 * @return string[]
	 */
	public function get_errors(): array {
		return $this->errors;
	}

	/**
	 *
	 * @param int $error_code
	 *
	 * @return bool
	 */
	public function has_error( int $error_code ): bool {
		return ! empty( $this->errors[ $error_code ] );
	}

	/**
	 *
	 * @param ConstraintViolationListInterface $violations
	 *
	 * @return InvalidCouponEntry
	 */
	public function map_validation_violations(
		ConstraintViolationListInterface $violations
	): InvalidCouponEntry {
		$validation_errors = [];
		foreach ( $violations as $violation ) {
			array_push(
				$validation_errors,
				sprintf(
					'[%s] %s',
					$violation->getPropertyPath(),
					$violation->getMessage()
				)
			);
		}

		$this->errors = $validation_errors;

		return $this;
	}

	/**
	 *
	 * @return array
	 */
	public function jsonSerialize(): array {
		$data = [
			'woocommerce_id' => $this->get_wc_coupon_id(),
			'errors'         => $this->get_errors(),
		];

		if ( null !== $this->get_google_promotion_id() ) {
			$data['google_id'] = $this->get_google_promotion_id();
		}

		if ( null !== $this->get_target_country() ) {
			$data['google_target_country'] = $this->get_target_country();
		}

		return $data;
	}
}
RequestReviewStatuses.php000064400000013731151543047370011641 0ustar00<?php

namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;

/**
 * Helper class for Account request Review feature
 */
class RequestReviewStatuses implements Service {

	public const ENABLED        = 'ENABLED';
	public const DISAPPROVED    = 'DISAPPROVED';
	public const WARNING        = 'WARNING';
	public const UNDER_REVIEW   = 'UNDER_REVIEW';
	public const PENDING_REVIEW = 'PENDING_REVIEW';
	public const ONBOARDING     = 'ONBOARDING';
	public const APPROVED       = 'APPROVED';
	public const NO_OFFERS      = 'NO_OFFERS_UPLOADED';
	public const ELIGIBLE       = 'ELIGIBLE';


	public const MC_ACCOUNT_REVIEW_LIFETIME = MINUTE_IN_SECONDS * 20; // 20 minutes

	/**
	 * Merges the different program statuses, issues and cooldown period date.
	 *
	 * @param array $response Associative array containing the response data from Google API
	 * @return array The computed status, with the issues and cooldown period.
	 */
	public function get_statuses_from_response( array $response ) {
		$issues   = [];
		$cooldown = 0;
		$status   = null;

		$valid_program_states    = [ self::ENABLED, self::NO_OFFERS ];
		$review_eligible_regions = [];

		foreach ( $response as $program_type_name => $program_type ) {

			// In case any Program is with no offers we consider it Onboarding
			if ( $program_type['globalState'] === self::NO_OFFERS ) {
				$status = self::ONBOARDING;
				break;
			}

			// In case any Program is not enabled or there are no regionStatuses we return null status
			if ( ! isset( $program_type['regionStatuses'] ) || ! in_array( $program_type['globalState'], $valid_program_states, true ) ) {
				continue;
			}

			// Otherwise, we compute the new status, issues and cooldown period
			foreach ( $program_type['regionStatuses'] as $region_status ) {
				$issues                  = array_merge( $issues, $region_status['reviewIssues'] ?? [] );
				$cooldown                = $this->maybe_update_cooldown_period( $region_status, $cooldown );
				$status                  = $this->maybe_update_status( $region_status['eligibilityStatus'], $status );
				$review_eligible_regions = $this->maybe_load_eligible_region( $region_status, $review_eligible_regions, $program_type_name );
			}
		}

		return [
			'issues'                => array_map( 'strtolower', array_values( array_unique( $issues ) ) ),
			'cooldown'              => $this->get_cooldown( $cooldown ), // add lifetime cache to cooldown time
			'status'                => $status,
			'reviewEligibleRegions' => array_unique( $review_eligible_regions ),
		];
	}
	/**
	 * Updates the cooldown period in case the new cooldown period date is available and later than the current cooldown period.
	 *
	 * @param array $region_status Associative array containing (maybe) a cooldown date property.
	 * @param int   $cooldown Referenced current cooldown to compare with
	 *
	 * @return int The cooldown
	 */
	private function maybe_update_cooldown_period( $region_status, $cooldown ) {
		if (
			isset( $region_status['reviewIneligibilityReasonDetails'] ) &&
			isset( $region_status['reviewIneligibilityReasonDetails']['cooldownTime'] )
		) {
			$region_cooldown = intval( strtotime( $region_status['reviewIneligibilityReasonDetails']['cooldownTime'] ) );

			if ( ! $cooldown || $region_cooldown > $cooldown ) {
				$cooldown = $region_cooldown;
			}
		}

		return $cooldown;
	}

	/**
	 * Updates the status reference in case the new status has more priority.
	 *
	 * @param String $new_status New status to check has more priority than the current one
	 * @param String $status Referenced current status
	 *
	 * @return String The status
	 */
	private function maybe_update_status( $new_status, $status ) {
		$status_priority_list = [
			self::ONBOARDING, // highest priority
			self::DISAPPROVED,
			self::WARNING,
			self::UNDER_REVIEW,
			self::PENDING_REVIEW,
			self::APPROVED,
		];

		$current_status_priority = array_search( $status, $status_priority_list, true );
		$new_status_priority     = array_search( $new_status, $status_priority_list, true );

		if ( $new_status_priority !== false && ( is_null( $status ) || $current_status_priority > $new_status_priority ) ) {
			return $new_status;
		}

		return $status;
	}


	/**
	 * Updates the regions where a request review is allowed.
	 *
	 * @param array                                      $region_status Associative array containing the region eligibility.
	 * @param array                                      $review_eligible_regions Indexed array with the current eligible regions.
	 * @param "freeListingsProgram"|"shoppingAdsProgram" $type The program type.
	 *
	 * @return array The (maybe) modified $review_eligible_regions array
	 */
	private function maybe_load_eligible_region( $region_status, $review_eligible_regions, $type = 'freeListingsProgram' ) {
		if (
			! empty( $region_status['regionCodes'] ) &&
			isset( $region_status['reviewEligibilityStatus'] ) &&
			$region_status['reviewEligibilityStatus'] === self::ELIGIBLE
		) {

			$region_codes = $region_status['regionCodes'];
			sort( $region_codes ); // sometimes the regions come unsorted between the different programs
			$region_id = $region_codes[0];

			if ( ! isset( $review_eligible_regions[ $region_id ] ) ) {
				$review_eligible_regions[ $region_id ] = [];
			}

			$review_eligible_regions[ $region_id ][] = strtolower( $type ); // lowercase as is how we expect it in WCS

		}

		return $review_eligible_regions;
	}

	/**
	 * Allows a hook to modify the lifetime of the Account review data.
	 *
	 * @return int
	 */
	public function get_account_review_lifetime(): int {
		return apply_filters( 'woocommerce_gla_mc_account_review_lifetime', self::MC_ACCOUNT_REVIEW_LIFETIME );
	}

	/**
	 * @param int $cooldown The cooldown in PHP format (seconds)
	 *
	 * @return int The cooldown in milliseconds and adding the lifetime cache
	 */
	private function get_cooldown( int $cooldown ) {
		if ( $cooldown ) {
			$cooldown = ( $cooldown + $this->get_account_review_lifetime() ) * 1000;
		}

		return $cooldown;
	}
}
SiteVerificationMeta.php000064400000002520151543047370011343 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Registerable;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;

defined( 'ABSPATH' ) || exit;

/**
 * Class SiteVerificationMeta
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Google
 */
class SiteVerificationMeta implements OptionsAwareInterface, Registerable, Service {

	use OptionsAwareTrait;

	/**
	 * Add the meta header hook.
	 */
	public function register(): void {
		add_action(
			'wp_head',
			function () {
				$this->display_meta_token();
			}
		);
	}

	/**
	 * Display the meta tag with the site verification token.
	 */
	protected function display_meta_token() {
		$settings = $this->options->get( OptionsInterface::SITE_VERIFICATION, [] );

		if ( empty( $settings['meta_tag'] ) ) {
			return;
		}

		echo '<!-- Google site verification - Google for WooCommerce -->' . PHP_EOL;
		echo wp_kses(
			$settings['meta_tag'],
			[
				'meta' => [
					'name'    => true,
					'content' => true,
				],
			]
		) . PHP_EOL;
	}
}
Ads.php000064400000025013151545120240005764 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsAccountAccessQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsAccountQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsBillingStatusQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsProductLinkInvitationQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Exception;
use Google\Ads\GoogleAds\Util\V18\ResourceNames;
use Google\Ads\GoogleAds\V18\Enums\AccessRoleEnum\AccessRole;
use Google\Ads\GoogleAds\V18\Enums\ProductLinkInvitationStatusEnum\ProductLinkInvitationStatus;
use Google\Ads\GoogleAds\V18\Resources\ProductLinkInvitation;
use Google\Ads\GoogleAds\V18\Services\ListAccessibleCustomersRequest;
use Google\Ads\GoogleAds\V18\Services\UpdateProductLinkInvitationRequest;
use Google\ApiCore\ApiException;
use Google\ApiCore\ValidationException;

defined( 'ABSPATH' ) || exit;

/**
 * Class Ads
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class Ads implements OptionsAwareInterface {

	use ExceptionTrait;
	use OptionsAwareTrait;

	/**
	 * The Google Ads Client.
	 *
	 * @var GoogleAdsClient
	 */
	protected $client;

	/**
	 * Ads constructor.
	 *
	 * @param GoogleAdsClient $client
	 */
	public function __construct( GoogleAdsClient $client ) {
		$this->client = $client;
	}

	/**
	 * Get Ads accounts associated with the connected Google account.
	 *
	 * @return array
	 * @throws ExceptionWithResponseData When an ApiException is caught.
	 */
	public function get_ads_accounts(): array {
		try {
			$customers = $this->client->getCustomerServiceClient()->listAccessibleCustomers( new ListAccessibleCustomersRequest() );
			$accounts  = [];

			foreach ( $customers->getResourceNames() as $name ) {
				$account = $this->get_account_details( $name );

				if ( $account ) {
					$accounts[] = $account;
				}
			}

			return $accounts;
		} catch ( ApiException $e ) {
			do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );

			$errors = $this->get_exception_errors( $e );

			// Return an empty list if the user has not signed up to ads yet.
			if ( isset( $errors['NOT_ADS_USER'] ) ) {
				return [];
			}

			throw new ExceptionWithResponseData(
				/* translators: %s Error message */
				sprintf( __( 'Error retrieving accounts: %s', 'google-listings-and-ads' ), reset( $errors ) ),
				$this->map_grpc_code_to_http_status_code( $e ),
				null,
				[ 'errors' => $errors ]
			);
		}
	}

	/**
	 * Get billing status.
	 *
	 * @return string
	 */
	public function get_billing_status(): string {
		$ads_id = $this->options->get_ads_id();

		if ( ! $ads_id ) {
			return BillingSetupStatus::UNKNOWN;
		}

		try {
			$results = ( new AdsBillingStatusQuery() )
				->set_client( $this->client, $this->options->get_ads_id() )
				->get_results();

			foreach ( $results->iterateAllElements() as $row ) {
				$billing_setup = $row->getBillingSetup();
				$status        = BillingSetupStatus::label( $billing_setup->getStatus() );
				return apply_filters( 'woocommerce_gla_ads_billing_setup_status', $status, $ads_id );
			}
		} catch ( ApiException | ValidationException $e ) {
			// Do not act upon error as we might not have permission to access this account yet.
			if ( 'PERMISSION_DENIED' !== $e->getStatus() ) {
				do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
			}
		}

		return apply_filters( 'woocommerce_gla_ads_billing_setup_status', BillingSetupStatus::UNKNOWN, $ads_id );
	}

	/**
	 * Accept the pending approval link sent from a merchant account.
	 *
	 * @param int $merchant_id Merchant Center account id.
	 * @throws Exception When the pending approval link can not be found.
	 */
	public function accept_merchant_link( int $merchant_id ) {
		$link    = $this->get_merchant_link( $merchant_id, 10 );
		$request = new UpdateProductLinkInvitationRequest();
		$request->setCustomerId( $this->options->get_ads_id() );
		$request->setResourceName( $link->getResourceName() );
		$request->setProductLinkInvitationStatus( ProductLinkInvitationStatus::ACCEPTED );
		$this->client->getProductLinkInvitationServiceClient()->updateProductLinkInvitation( $request );
	}

	/**
	 * Check if we have access to the ads account.
	 *
	 * @param string $email Email address of the connected account.
	 *
	 * @return bool
	 */
	public function has_access( string $email ): bool {
		$ads_id = $this->options->get_ads_id();

		try {
			$results = ( new AdsAccountAccessQuery() )
				->set_client( $this->client, $ads_id )
				->where( 'customer_user_access.email_address', $email )
				->get_results();

			foreach ( $results->iterateAllElements() as $row ) {
				$access = $row->getCustomerUserAccess();
				if ( AccessRole::ADMIN === $access->getAccessRole() ) {
					return true;
				}
			}
		} catch ( ApiException $e ) {
			do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
		}

		return false;
	}

	/**
	 * Get the ads account currency.
	 *
	 * @since 1.4.1
	 *
	 * @return string
	 */
	public function get_ads_currency(): string {
		// Retrieve account currency from the API if we haven't done so previously.
		if ( $this->options->get_ads_id() && ! $this->options->get( OptionsInterface::ADS_ACCOUNT_CURRENCY ) ) {
			$this->request_ads_currency();
		}

		return strtoupper( $this->options->get( OptionsInterface::ADS_ACCOUNT_CURRENCY ) ?? get_woocommerce_currency() );
	}

	/**
	 * Request the Ads Account currency, and cache it as an option.
	 *
	 * @since 1.1.0
	 *
	 * @return boolean
	 */
	public function request_ads_currency(): bool {
		try {
			$ads_id   = $this->options->get_ads_id();
			$account  = ResourceNames::forCustomer( $ads_id );
			$customer = ( new AdsAccountQuery() )
				->set_client( $this->client, $ads_id )
				->columns( [ 'customer.currency_code' ] )
				->where( 'customer.resource_name', $account, '=' )
				->get_result()
				->getCustomer();

			$currency = $customer->getCurrencyCode();
		} catch ( ApiException $e ) {
			do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
			$currency = null;
		}

		return $this->options->update( OptionsInterface::ADS_ACCOUNT_CURRENCY, $currency );
	}

	/**
	 * Save the Ads account currency to the same value as the Store currency.
	 *
	 * @since 1.1.0
	 *
	 * @return boolean
	 */
	public function use_store_currency(): bool {
		return $this->options->update( OptionsInterface::ADS_ACCOUNT_CURRENCY, get_woocommerce_currency() );
	}

	/**
	 * Convert ads ID from a resource name to an int.
	 *
	 * @param string $name Resource name containing ID number.
	 *
	 * @return int
	 */
	public function parse_ads_id( string $name ): int {
		return absint( str_replace( 'customers/', '', $name ) );
	}

	/**
	 * Update the Ads ID to use for requests.
	 *
	 * @param int $id Ads ID number.
	 *
	 * @return bool
	 */
	public function update_ads_id( int $id ): bool {
		return $this->options->update( OptionsInterface::ADS_ID, $id );
	}

	/**
	 * Returns true if the Ads id exists in the options.
	 *
	 * @return bool
	 */
	public function ads_id_exists(): bool {
		return ! empty( $this->options->get( OptionsInterface::ADS_ID ) );
	}

	/**
	 * Update the billing flow URL so we can retrieve it again later.
	 *
	 * @param string $url Billing flow URL.
	 *
	 * @return bool
	 */
	public function update_billing_url( string $url ): bool {
		return $this->options->update( OptionsInterface::ADS_BILLING_URL, $url );
	}

	/**
	 * Update the OCID for the account so that we can reference it later in order
	 * to link to accept invite link or to send customer to conversion settings page
	 * in their account.
	 *
	 * @param string $url Billing flow URL.
	 *
	 * @return bool
	 */
	public function update_ocid_from_billing_url( string $url ): bool {
		$query_string = wp_parse_url( $url, PHP_URL_QUERY );

		// Return if no params.
		if ( empty( $query_string ) ) {
			return false;
		}

		parse_str( $query_string, $params );

		if ( empty( $params['ocid'] ) ) {
			return false;
		}

		return $this->options->update( OptionsInterface::ADS_ACCOUNT_OCID, $params['ocid'] );
	}

	/**
	 * Fetch the account details.
	 * Returns null for any account that fails or is not the right type.
	 *
	 * @param string $account Customer resource name.
	 * @return null|array
	 */
	private function get_account_details( string $account ): ?array {
		try {
			$customer = ( new AdsAccountQuery() )
				->set_client( $this->client, $this->parse_ads_id( $account ) )
				->where( 'customer.resource_name', $account, '=' )
				->get_result()
				->getCustomer();

			if ( ! $customer || $customer->getManager() || $customer->getTestAccount() ) {
				return null;
			}

			return [
				'id'   => $customer->getId(),
				'name' => $customer->getDescriptiveName(),
			];
		} catch ( ApiException $e ) {
			do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
		}

		return null;
	}

	/**
	 * Get the pending approval link sent from a Google Merchant account.
	 *
	 * The invitation link may not be available in Google Ads immediately after
	 * the invitation is sent from Google Merchant Center, so this method offers
	 * a parameter to specify the number of retries.
	 *
	 * @param int $merchant_id Merchant Center account id.
	 * @param int $attempts_left The number of attempts left to get the link.
	 *
	 * @return ProductLinkInvitation
	 * @throws Exception When the pending approval link can not be found.
	 */
	private function get_merchant_link( int $merchant_id, int $attempts_left = 0 ): ProductLinkInvitation {
		$res = ( new AdsProductLinkInvitationQuery() )
			->set_client( $this->client, $this->options->get_ads_id() )
			->where( 'product_link_invitation.status', ProductLinkInvitationStatus::name( ProductLinkInvitationStatus::PENDING_APPROVAL ) )
			->get_results();

		foreach ( $res->iterateAllElements() as $row ) {
			$link  = $row->getProductLinkInvitation();
			$mc    = $link->getMerchantCenter();
			$mc_id = $mc->getMerchantCenterId();
			if ( absint( $mc_id ) === $merchant_id ) {
				return $link;
			}
		}

		if ( $attempts_left > 0 ) {
			sleep( 1 );
			return $this->get_merchant_link( $merchant_id, $attempts_left - 1 );
		}

		throw new Exception( __( 'Unable to find the pending approval link sent from the Merchant Center account', 'google-listings-and-ads' ) );
	}
}
AdsAsset.php000064400000021774151545120240006776 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Google\Ads\GoogleAds\V18\Services\GoogleAdsRow;
use Google\Ads\GoogleAds\V18\Enums\AssetTypeEnum\AssetType;
use Google\Ads\GoogleAds\V18\Resources\Asset;
use Google\Ads\GoogleAds\V18\Services\AssetOperation;
use Google\Ads\GoogleAds\V18\Services\MutateGoogleAdsRequest;
use Google\Ads\GoogleAds\V18\Services\MutateOperation;
use Google\Ads\GoogleAds\Util\V18\ResourceNames;
use Google\Ads\GoogleAds\V18\Common\TextAsset;
use Google\Ads\GoogleAds\V18\Common\ImageAsset;
use Google\Ads\GoogleAds\V18\Common\CallToActionAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Google\ApiCore\ApiException;
use Exception;

/**
 * Class AdsAsset
 *
 * Used for the Performance Max Campaigns
 * https://developers.google.com/google-ads/api/docs/performance-max/assets
 *
 * @since 2.4.0
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class AdsAsset implements OptionsAwareInterface {

	use OptionsAwareTrait;

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

	/**
	 * The Google Ads Client.
	 *
	 * @var GoogleAdsClient
	 */
	protected GoogleAdsClient $client;

	/**
	 * Maximum payload size in bytes.
	 *
	 * @var int
	 */
	protected const MAX_PAYLOAD_BYTES = 30 * 1024 * 1024;

	/**
	 * Maximum image size in bytes.
	 *
	 * @var int
	 */
	protected const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024;

	/**
	 * AdsAsset constructor.
	 *
	 * @param GoogleAdsClient $client The Google Ads client.
	 * @param WP              $wp The WordPress proxy.
	 */
	public function __construct( GoogleAdsClient $client, WP $wp ) {
		$this->client = $client;
		$this->wp     = $wp;
	}

	/**
	 * Temporary ID to use within a batch job.
	 * A negative number which is unique for all the created resources.
	 *
	 * @var int
	 */
	protected static $temporary_id = -5;

	/**
	 * Return a temporary resource name for the asset.
	 *
	 * @param int $temporary_id The temporary ID to use for the asset.
	 *
	 * @return string The Asset resource name.
	 */
	protected function temporary_resource_name( int $temporary_id ): string {
		return ResourceNames::forAsset( $this->options->get_ads_id(), $temporary_id );
	}

	/**
	 * Returns the asset type for the given field type.
	 *
	 * @param string $field_type The field type.
	 *
	 * @return int The asset type.
	 * @throws Exception If the field type is not supported.
	 */
	protected function get_asset_type_by_field_type( string $field_type ): int {
		switch ( $field_type ) {
			case AssetFieldType::LOGO:
			case AssetFieldType::MARKETING_IMAGE:
			case AssetFieldType::SQUARE_MARKETING_IMAGE:
			case AssetFieldType::PORTRAIT_MARKETING_IMAGE:
				return AssetType::IMAGE;
			case AssetFieldType::CALL_TO_ACTION_SELECTION:
				return AssetType::CALL_TO_ACTION;
			case AssetFieldType::HEADLINE:
			case AssetFieldType::LONG_HEADLINE:
			case AssetFieldType::DESCRIPTION:
			case AssetFieldType::BUSINESS_NAME:
				return AssetType::TEXT;
			default:
				throw new Exception( 'Asset Field type not supported' );
		}
	}

	/**
	 * Returns the image data.
	 *
	 * @param string $url The image url.
	 *
	 * @return array The image data.
	 * @throws Exception If the image url is not a valid url or the image size is too large.
	 */
	protected function get_image_data( string $url ): array {
		$image_data = $this->wp->wp_remote_get( $url );

		if ( is_wp_error( $image_data ) || empty( $image_data['body'] ) ) {
			throw new Exception( sprintf( 'There was a problem loading the url: %s', $url ) );
		}

		$size = $image_data['headers']->offsetGet( 'content-length' );

		if ( $size > self::MAX_IMAGE_SIZE_BYTES ) {
			throw new Exception( 'Image size is too large.' );
		}

		return [
			'body' => $image_data['body'],
			'size' => $size,
		];
	}

	/**
	 * Returns a list of batches of assets.
	 *
	 * @param array $assets A list of assets.
	 * @param int   $max_size The maximum size of the payload in bytes.
	 *
	 * @return array A list of batches of assets.
	 * @throws Exception If the image url is not a valid url, if the field type is not supported or the image size is too big.
	 */
	protected function create_batches( array $assets, int $max_size = self::MAX_PAYLOAD_BYTES ): array {
		$batch_size = 0;
		$index      = 0;
		$batches    = [];

		foreach ( $assets as $asset ) {
			if ( $this->get_asset_type_by_field_type( $asset['field_type'] ) === AssetType::IMAGE ) {
				$image_data    = $this->get_image_data( $asset['content'] );
				$asset['body'] = $image_data['body'];
				$batch_size   += $image_data['size'];

				if ( $batch_size > $max_size ) {
					$batches[ ++$index ][] = $asset;
					$batch_size            = $image_data['size'];
					continue;
				}
			}

			$batches[ $index ][] = $asset;
		}

		return $batches;
	}

	/**
	 * Creates the assets so they can be used in the asset groups.
	 *
	 * @param array $assets The assets to create.
	 * @param int   $batch_size The maximum size of the payload in bytes.
	 *
	 * @return array A list of Asset's ARN created.
	 *
	 * @throws Exception If the asset type is not supported or if the image url is not a valid url.
	 * @throws ApiException If any of the operations fail.
	 */
	public function create_assets( array $assets, int $batch_size = self::MAX_PAYLOAD_BYTES ): array {
		if ( empty( $assets ) ) {
			return [];
		}

		$batches = $this->create_batches( $assets, $batch_size );
		$arns    = [];

		foreach ( $batches as $batch ) {
			$operations = [];
			foreach ( $batch as $asset ) {
				$operations[] = $this->create_operation( $asset, self::$temporary_id-- );
			}

			// If the mutate operation fails, it will throw an exception that will be caught by the caller.
			$arns = [ ...$arns, ...$this->mutate( $operations ) ];
		}

		return $arns;
	}

	/**
	 * Returns an operation to create a text asset.
	 *
	 * @param array $data The asset data.
	 * @param int   $temporary_id The temporary ID to use for the asset.
	 *
	 * @return MutateOperation The create asset operation.
	 * @throws Exception If the asset type is not supported.
	 */
	protected function create_operation( array $data, int $temporary_id ): MutateOperation {
		$asset = new Asset(
			[
				'resource_name' => $this->temporary_resource_name( $temporary_id ),
			]
		);

		switch ( $this->get_asset_type_by_field_type( $data['field_type'] ) ) {
			case AssetType::CALL_TO_ACTION:
				$asset->setCallToActionAsset( new CallToActionAsset( [ 'call_to_action' => CallToActionType::number( $data['content'] ) ] ) );
				break;
			case AssetType::IMAGE:
				$asset->setImageAsset( new ImageAsset( [ 'data' => $data['body'] ] ) );
				$asset->setName( basename( $data['content'] ) );
				break;
			case AssetType::TEXT:
				$asset->setTextAsset( new TextAsset( [ 'text' => $data['content'] ] ) );
				break;
			default:
				throw new Exception( 'Asset type not supported' );
		}

		$operation = ( new AssetOperation() )->setCreate( $asset );
		return ( new MutateOperation() )->setAssetOperation( $operation );
	}

	/**
	 * Returns the asset content for the given row.
	 *
	 * @param GoogleAdsRow $row Data row returned from a query request.
	 *
	 * @return string The asset content.
	 */
	protected function get_asset_content( GoogleAdsRow $row ): string {
		/** @var Asset $asset */
		$asset = $row->getAsset();

		switch ( $asset->getType() ) {
			case AssetType::IMAGE:
				return $asset->getImageAsset()->getFullSize()->getUrl();
			case AssetType::TEXT:
				return $asset->getTextAsset()->getText();
			case AssetType::CALL_TO_ACTION:
				// When CallToActionType::UNSPECIFIED is returned, does not have a CallToActionAsset.
				if ( ! $asset->getCallToActionAsset() ) {
					return CallToActionType::UNSPECIFIED;
				}
				return CallToActionType::label( $asset->getCallToActionAsset()->getCallToAction() );
			default:
				return '';
		}
	}

	/**
	 * Convert Asset data to an array.
	 *
	 * @param GoogleAdsRow $row Data row returned from a query request.
	 *
	 * @return array The asset data converted.
	 */
	public function convert_asset( GoogleAdsRow $row ): array {
		return [
			'id'      => $row->getAsset()->getId(),
			'content' => $this->get_asset_content( $row ),
		];
	}

	/**
	 * Send a batch of operations to mutate assets.
	 *
	 * @param MutateOperation[] $operations
	 *
	 * @return array A list of Asset's ARN created.
	 * @throws ApiException If any of the operations fail.
	 */
	protected function mutate( array $operations ): array {
		$arns    = [];
		$request = new MutateGoogleAdsRequest();
		$request->setCustomerId( $this->options->get_ads_id() );
		$request->setMutateOperations( $operations );
		$responses = $this->client->getGoogleAdsServiceClient()->mutate( $request );

		foreach ( $responses->getMutateOperationResponses() as $response ) {
			if ( 'asset_result' === $response->getResponse() ) {
				$asset_result = $response->getAssetResult();
				$arns[]       = $asset_result->getResourceName();
			}
		}

		return $arns;
	}
}
AdsAssetGroup.php000064400000033504151545120240010005 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsAssetGroupQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Google\Ads\GoogleAds\Util\V18\ResourceNames;
use Google\Ads\GoogleAds\V18\Enums\ListingGroupFilterListingSourceEnum\ListingGroupFilterListingSource;
use Google\Ads\GoogleAds\V18\Enums\AssetGroupStatusEnum\AssetGroupStatus;
use Google\Ads\GoogleAds\V18\Enums\ListingGroupFilterTypeEnum\ListingGroupFilterType;
use Google\Ads\GoogleAds\V18\Resources\AssetGroup;
use Google\Ads\GoogleAds\V18\Resources\AssetGroupListingGroupFilter;
use Google\Ads\GoogleAds\V18\Services\AssetGroupListingGroupFilterOperation;
use Google\Ads\GoogleAds\V18\Services\AssetGroupOperation;
use Google\Ads\GoogleAds\V18\Services\GoogleAdsRow;
use Google\Ads\GoogleAds\V18\Services\MutateGoogleAdsRequest;
use Google\Ads\GoogleAds\V18\Services\MutateOperation;
use Google\Ads\GoogleAds\V18\Services\Client\AssetGroupServiceClient;
use Google\ApiCore\ApiException;
use Google\ApiCore\ValidationException;
use Google\Protobuf\FieldMask;
use Exception;
use DateTime;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;

/**
 * Class AdsAssetGroup
 *
 * Used for the Performance Max Campaigns
 * https://developers.google.com/google-ads/api/docs/performance-max/asset-groups
 *
 * @since 1.12.2
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class AdsAssetGroup implements OptionsAwareInterface {

	use ExceptionTrait;
	use OptionsAwareTrait;

	/**
	 * Temporary ID to use within a batch job.
	 * A negative number which is unique for all the created resources.
	 *
	 * @var int
	 */
	protected const TEMPORARY_ID = -3;

	/**
	 * The Google Ads Client.
	 *
	 * @var GoogleAdsClient
	 */
	protected $client;

	/**
	 * The AdsAssetGroupAsset class.
	 *
	 * @var AdsAssetGroupAsset
	 */
	protected $asset_group_asset;

	/**
	 * List of asset group resource names.
	 *
	 * @var string[]
	 */
	protected $asset_groups;

	/**
	 * AdsAssetGroup constructor.
	 *
	 * @param GoogleAdsClient    $client
	 * @param AdsAssetGroupAsset $asset_group_asset
	 */
	public function __construct( GoogleAdsClient $client, AdsAssetGroupAsset $asset_group_asset ) {
		$this->client            = $client;
		$this->asset_group_asset = $asset_group_asset;
	}

	/**
	 * Create an asset group.
	 *
	 * @since 2.4.0
	 *
	 * @param int $campaign_id
	 *
	 * @return int id The asset group id.
	 * @throws ExceptionWithResponseData When an ApiException or Exception is caught.
	 */
	public function create_asset_group( int $campaign_id ): int {
		try {
			$campaign_resource_name = ResourceNames::forCampaign( $this->options->get_ads_id(), $campaign_id );
			$current_date_time      = ( new DateTime( 'now', wp_timezone() ) )->format( 'Y-m-d H:i:s' );
			$asset_group_name       = sprintf(
				/* translators: %s: current date time. */
				__( 'PMax %s', 'google-listings-and-ads' ),
				$current_date_time
			);

			$operations = $this->create_operations( $campaign_resource_name, $asset_group_name );
			return $this->mutate( $operations );

		} catch ( Exception $e ) {
			do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
			$message = $e->getMessage();
			$code    = $e->getCode();
			$data    = [];

			if ( $e instanceof ApiException ) {
				$errors = $this->get_exception_errors( $e );
				/* translators: %s Error message */
				$message = sprintf( __( 'Error creating asset group: %s', 'google-listings-and-ads' ), reset( $errors ) );
				$code    = $this->map_grpc_code_to_http_status_code( $e );
				$data    = [
					'errors' => $errors,
				];
			}

			throw new ExceptionWithResponseData(
				$message,
				$code,
				null,
				$data
			);
		}
	}

	/**
	 * Returns a set of operations to create an asset group.
	 *
	 * @param string $campaign_resource_name
	 * @param string $asset_group_name The asset group name.
	 * @return array
	 */
	public function create_operations( string $campaign_resource_name, string $asset_group_name ): array {
		// Asset must be created before listing group.
		return [
			$this->asset_group_create_operation( $campaign_resource_name, $asset_group_name ),
			$this->listing_group_create_operation(),
		];
	}

	/**
	 * Returns an asset group create operation.
	 *
	 * @param string $campaign_resource_name
	 * @param string $campaign_name
	 *
	 * @return MutateOperation
	 */
	protected function asset_group_create_operation( string $campaign_resource_name, string $campaign_name ): MutateOperation {
		$asset_group = new AssetGroup(
			[
				'resource_name' => $this->temporary_resource_name(),
				'name'          => $campaign_name . ' Asset Group',
				'campaign'      => $campaign_resource_name,
				'status'        => AssetGroupStatus::ENABLED,
			]
		);

		$operation = ( new AssetGroupOperation() )->setCreate( $asset_group );
		return ( new MutateOperation() )->setAssetGroupOperation( $operation );
	}

	/**
	 * Returns an asset group listing group filter create operation.
	 *
	 * @return MutateOperation
	 */
	protected function listing_group_create_operation(): MutateOperation {
		$listing_group = new AssetGroupListingGroupFilter(
			[
				'asset_group'    => $this->temporary_resource_name(),
				'type'           => ListingGroupFilterType::UNIT_INCLUDED,
				'listing_source' => ListingGroupFilterListingSource::SHOPPING,
			]
		);

		$operation = ( new AssetGroupListingGroupFilterOperation() )->setCreate( $listing_group );
		return ( new MutateOperation() )->setAssetGroupListingGroupFilterOperation( $operation );
	}

	/**
	 * Returns an asset group delete operation.
	 *
	 * @param string $campaign_resource_name
	 *
	 * @return MutateOperation[]
	 */
	protected function asset_group_delete_operations( string $campaign_resource_name ): array {
		$operations         = [];
		$this->asset_groups = [];

		$results = ( new AdsAssetGroupQuery() )
			->set_client( $this->client, $this->options->get_ads_id() )
			->where( 'asset_group.campaign', $campaign_resource_name )
			->get_results();

		/** @var GoogleAdsRow $row */
		foreach ( $results->iterateAllElements() as $row ) {
			$resource_name        = $row->getAssetGroup()->getResourceName();
			$this->asset_groups[] = $resource_name;
			$operation            = ( new AssetGroupOperation() )->setRemove( $resource_name );
			$operations[]         = ( new MutateOperation() )->setAssetGroupOperation( $operation );
		}

		return $operations;
	}

	/**
	 * Return a temporary resource name for the asset group.
	 *
	 * @return string
	 */
	protected function temporary_resource_name() {
		return ResourceNames::forAssetGroup( $this->options->get_ads_id(), self::TEMPORARY_ID );
	}

	/**
	 * Get Asset Groups for a specific campaign. Limit to first AdsAssetGroup.
	 *
	 * @since 2.4.0
	 *
	 * @param int  $campaign_id The campaign ID.
	 * @param bool $include_assets Whether to include the assets in the response.
	 *
	 * @return array The asset groups for the campaign.
	 * @throws ExceptionWithResponseData When an ApiException is caught.
	 */
	public function get_asset_groups_by_campaign_id( int $campaign_id, bool $include_assets = true ): array {
		try {
			$asset_groups_converted = [];

			$asset_group_results = ( new AdsAssetGroupQuery() )
				->set_client( $this->client, $this->options->get_ads_id() )
				->add_columns( [ 'asset_group.path1', 'asset_group.path2', 'asset_group.id', 'asset_group.final_urls' ] )
				->where( 'campaign.id', $campaign_id )
				->where( 'asset_group.status', 'REMOVED', '!=' )
				->get_results();

			/** @var GoogleAdsRow $row */
			foreach ( $asset_group_results->getPage()->getIterator() as $row ) {
				$asset_groups_converted[ $row->getAssetGroup()->getId() ] = $this->convert_asset_group( $row );
				break; // Limit to only first asset group.
			}

			if ( $include_assets ) {
				return array_values( $this->get_assets( $asset_groups_converted ) );
			}

			return array_values( $asset_groups_converted );
		} catch ( ApiException $e ) {
			do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );

			$errors = $this->get_exception_errors( $e );
			throw new ExceptionWithResponseData(
				/* translators: %s Error message */
				sprintf( __( 'Error retrieving asset groups: %s', 'google-listings-and-ads' ), reset( $errors ) ),
				$this->map_grpc_code_to_http_status_code( $e ),
				null,
				[ 'errors' => $errors ]
			);
		}
	}

	/**
	 * Get assets for asset groups.
	 *
	 * @since 2.4.0
	 *
	 * @param array $asset_groups The asset groups converted.
	 *
	 * @return array The asset groups with assets.
	 */
	protected function get_assets( array $asset_groups ): array {
		$asset_group_ids = array_keys( $asset_groups );
		$assets          = $this->asset_group_asset->get_assets_by_asset_group_ids( $asset_group_ids );

		foreach ( $asset_group_ids as $asset_group_id ) {
			$asset_groups[ $asset_group_id ]['assets'] = $assets[ $asset_group_id ] ?? (object) [];
		}

		return $asset_groups;
	}

	/**
	 * Edit an asset group.
	 *
	 * @param int   $asset_group_id The asset group ID.
	 * @param array $data The asset group data.
	 * @param array $assets A list of assets data.
	 *
	 * @return int The asset group ID.
	 * @throws ExceptionWithResponseData When an ApiException is caught.
	 */
	public function edit_asset_group( int $asset_group_id, array $data, array $assets = [] ): int {
		try {
			$operations = $this->asset_group_asset->edit_operations( $asset_group_id, $assets );

			// PMax only supports one final URL but it is required to be an array.
			if ( ! empty( $data['final_url'] ) ) {
				$data['final_urls'] = [ $data['final_url'] ];
				unset( $data['final_url'] );
			}

			if ( ! empty( $data ) ) {
				// If the asset group does not contain a final URL, it is required to update first the asset group with the final URL and then the assets.
				$operations = [ $this->edit_operation( $asset_group_id, $data ), ...$operations ];
			}

			if ( ! empty( $operations ) ) {
				$this->mutate( $operations );
			}

			return $asset_group_id;
		} catch ( ApiException $e ) {
			do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );

			if ( $e->getCode() === 413 ) {
				$errors = [ 'Request entity too large' ];
				$code   = $e->getCode();
			} else {
				$errors = $this->get_exception_errors( $e );
				$code   = $this->map_grpc_code_to_http_status_code( $e );
				if ( array_key_exists( 'DUPLICATE_ASSETS_WITH_DIFFERENT_FIELD_VALUE', $errors ) ) {
					$errors['DUPLICATE_ASSETS_WITH_DIFFERENT_FIELD_VALUE'] = __( 'Each image type (landscape, square, portrait or logo) cannot contain duplicated images.', 'google-listings-and-ads' );
				}
			}

			throw new ExceptionWithResponseData(
			/* translators: %s Error message */
				sprintf( __( 'Error editing asset group: %s', 'google-listings-and-ads' ), reset( $errors ) ),
				$code,
				null,
				[
					'errors' => $errors,
					'id'     => $asset_group_id,
				]
			);
		}
	}

	/**
	 * Returns an asset group edit operation.
	 *
	 * @param integer $asset_group_id The Asset Group ID
	 * @param array   $fields The fields to update.
	 *
	 * @return MutateOperation
	 */
	protected function edit_operation( int $asset_group_id, array $fields ): MutateOperation {
		$fields['resource_name'] = ResourceNames::forAssetGroup( $this->options->get_ads_id(), $asset_group_id );
		$asset_group             = new AssetGroup( $fields );
		$operation               = new AssetGroupOperation();
		$operation->setUpdate( $asset_group );
		// We create the FieldMask manually because empty paths (path1 and path2) are not processed by the library.
		// See similar issue here: https://github.com/googleads/google-ads-php/issues/487
		$operation->setUpdateMask( ( new FieldMask() )->setPaths( [ 'resource_name', ...array_keys( $fields ) ] ) );
		return ( new MutateOperation() )->setAssetGroupOperation( $operation );
	}

	/**
	 * Convert Asset Group data to an array.
	 *
	 * @since 2.4.0
	 *
	 * @param GoogleAdsRow $row Data row returned from a query request.
	 *
	 * @return array
	 */
	protected function convert_asset_group( GoogleAdsRow $row ): array {
		return [
			'id'               => $row->getAssetGroup()->getId(),
			'final_url'        => iterator_to_array( $row->getAssetGroup()->getFinalUrls() )[0] ?? '',
			'display_url_path' => [ $row->getAssetGroup()->getPath1(), $row->getAssetGroup()->getPath2() ],
		];
	}

	/**
	 * Send a batch of operations to mutate an asset group.
	 *
	 * @since 2.4.0
	 *
	 * @param MutateOperation[] $operations
	 *
	 * @return int If the asset group operation is present, it will return the asset group id otherwise 0 for other operations.
	 * @throws ApiException If any of the operations fail.
	 * @throws Exception If the resource name is not in the expected format.
	 */
	protected function mutate( array $operations ): int {
		$request = new MutateGoogleAdsRequest();
		$request->setCustomerId( $this->options->get_ads_id() );
		$request->setMutateOperations( $operations );
		$responses = $this->client->getGoogleAdsServiceClient()->mutate( $request );

		foreach ( $responses->getMutateOperationResponses() as $response ) {
			if ( 'asset_group_result' === $response->getResponse() ) {
				$asset_group_result = $response->getAssetGroupResult();
				return $this->parse_asset_group_id( $asset_group_result->getResourceName() );
			}
		}

		return 0;
	}

	/**
	 * Convert ID from a resource name to an int.
	 *
	 * @since 2.4.0
	 *
	 * @param string $name Resource name containing ID number.
	 *
	 * @return int The asset group ID.
	 * @throws Exception When unable to parse resource ID.
	 */
	protected function parse_asset_group_id( string $name ): int {
		try {
			$parts = AssetGroupServiceClient::parseName( $name );
			return absint( $parts['asset_group_id'] );
		} catch ( ValidationException $e ) {
			throw new Exception( __( 'Invalid asset group ID', 'google-listings-and-ads' ) );
		}
	}
}
AdsAssetGroupAsset.php000064400000031607151545120240011007 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsAssetGroupAssetQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Google\Ads\GoogleAds\V18\Services\GoogleAdsRow;
use Google\Ads\GoogleAds\V18\Resources\AssetGroupAsset;
use Google\ApiCore\ApiException;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Google\Ads\GoogleAds\V18\Services\MutateOperation;
use Google\Ads\GoogleAds\V18\Services\AssetGroupAssetOperation;
use Google\Ads\GoogleAds\Util\V18\ResourceNames;




/**
 * Class AdsAssetGroupAsset
 *
 * Use to get assets group assets for specific asset groups.
 * https://developers.google.com/google-ads/api/reference/rpc/v18/AssetGroupAsset
 *
 * @since 2.4.0
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class AdsAssetGroupAsset implements OptionsAwareInterface {

	use ExceptionTrait;
	use OptionsAwareTrait;

	/**
	 * The Google Ads Client.
	 *
	 * @var GoogleAdsClient
	 */
	protected $client;

	/**
	 * Ads Asset class.
	 *
	 * @var AdsAsset
	 */
	protected $asset;

	/**
	 * Temporary ID to use within a batch job.
	 * A negative number which is unique for all the created resources.
	 *
	 * @var int
	 */
	protected static $temporary_id = -4;

	/**
	 * AdsAssetGroupAsset constructor.
	 *
	 * @param GoogleAdsClient $client
	 * @param AdsAsset        $asset
	 */
	public function __construct( GoogleAdsClient $client, AdsAsset $asset ) {
		$this->client = $client;
		$this->asset  = $asset;
	}

	/**
	 * Get the asset field types to use for the asset group assets query.
	 *
	 * @return string[]
	 */
	protected function get_asset_field_types_query(): array {
		return [
			AssetFieldType::name( AssetFieldType::BUSINESS_NAME ),
			AssetFieldType::name( AssetFieldType::CALL_TO_ACTION_SELECTION ),
			AssetFieldType::name( AssetFieldType::DESCRIPTION ),
			AssetFieldType::name( AssetFieldType::HEADLINE ),
			AssetFieldType::name( AssetFieldType::LOGO ),
			AssetFieldType::name( AssetFieldType::LONG_HEADLINE ),
			AssetFieldType::name( AssetFieldType::MARKETING_IMAGE ),
			AssetFieldType::name( AssetFieldType::SQUARE_MARKETING_IMAGE ),
			AssetFieldType::name( AssetFieldType::PORTRAIT_MARKETING_IMAGE ),
		];
	}

	/**
	 * Get Assets for specific asset groups ids.
	 *
	 * @param array $asset_groups_ids The asset groups ids.
	 * @param array $fields           The asset field types to get.
	 *
	 * @return array The assets for the asset groups.
	 * @throws ExceptionWithResponseData When an ApiException is caught.
	 */
	public function get_assets_by_asset_group_ids( array $asset_groups_ids, array $fields = [] ): array {
		try {
			if ( empty( $asset_groups_ids ) ) {
				return [];
			}

			if ( empty( $fields ) ) {
				$fields = $this->get_asset_field_types_query();
			}

			$asset_group_assets = [];
			$asset_results      = ( new AdsAssetGroupAssetQuery() )
				->set_client( $this->client, $this->options->get_ads_id() )
				->add_columns( [ 'asset_group.id' ] )
				->where( 'asset_group.id', $asset_groups_ids, 'IN' )
				->where( 'asset_group_asset.field_type', $fields, 'IN' )
				->where( 'asset_group_asset.status', 'REMOVED', '!=' )
				->get_results();

			/** @var GoogleAdsRow $row */
			foreach ( $asset_results->iterateAllElements() as $row ) {

				/** @var AssetGroupAsset $asset_group_asset */
				$asset_group_asset = $row->getAssetGroupAsset();
				$field_type        = AssetFieldType::label( $asset_group_asset->getFieldType() );

				switch ( $field_type ) {
					case AssetFieldType::BUSINESS_NAME:
					case AssetFieldType::CALL_TO_ACTION_SELECTION:
						$asset_group_assets[ $row->getAssetGroup()->getId() ][ $field_type ] = $this->asset->convert_asset( $row );
						break;
					default:
						$asset_group_assets[ $row->getAssetGroup()->getId() ][ $field_type ][] = $this->asset->convert_asset( $row );
				}
			}

			return $asset_group_assets;
		} catch ( ApiException $e ) {
			do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );

			$errors = $this->get_exception_errors( $e );
			throw new ExceptionWithResponseData(
				/* translators: %s Error message */
				sprintf( __( 'Error retrieving asset groups assets: %s', 'google-listings-and-ads' ), reset( $errors ) ),
				$this->map_grpc_code_to_http_status_code( $e ),
				null,
				[ 'errors' => $errors ]
			);
		}
	}

	/**
	 * Get Assets for specific final URL.
	 *
	 * @param string $url The final url.
	 * @param bool   $only_first_asset_group Whether to return only the first asset group found.
	 *
	 * @return array The assets for the asset groups with a specific final url.
	 * @throws ExceptionWithResponseData When an ApiException is caught.
	 */
	public function get_assets_by_final_url( string $url, bool $only_first_asset_group = false ): array {
		try {

			$asset_group_assets = [];

			// Search urls with and without trailing slash.
			$asset_results = ( new AdsAssetGroupAssetQuery() )
				->set_client( $this->client, $this->options->get_ads_id() )
				->add_columns( [ 'asset_group.id', 'asset_group.path1', 'asset_group.path2' ] )
				->where( 'asset_group.final_urls', [ trailingslashit( $url ), untrailingslashit( $url ) ], 'CONTAINS ANY' )
				->where( 'asset_group_asset.field_type', $this->get_asset_field_types_query(), 'IN' )
				->where( 'asset_group_asset.status', 'REMOVED', '!=' )
				->where( 'asset_group.status', 'REMOVED', '!=' )
				->where( 'campaign.status', 'REMOVED', '!=' )
				->get_results();

			/** @var GoogleAdsRow $row */
			foreach ( $asset_results->iterateAllElements() as $row ) {

				/** @var AssetGroupAsset $asset_group_asset */
				$asset_group_asset = $row->getAssetGroupAsset();

				$field_type = AssetFieldType::label( $asset_group_asset->getFieldType() );
				switch ( $field_type ) {
					case AssetFieldType::BUSINESS_NAME:
					case AssetFieldType::CALL_TO_ACTION_SELECTION:
						$asset_group_assets[ $row->getAssetGroup()->getId() ][ $field_type ] = $this->asset->convert_asset( $row )['content'];
						break;
					default:
						$asset_group_assets[ $row->getAssetGroup()->getId() ][ $field_type ][] = $this->asset->convert_asset( $row )['content'];
				}

				$asset_group_assets[ $row->getAssetGroup()->getId() ]['display_url_path'] = [
					$row->getAssetGroup()->getPath1(),
					$row->getAssetGroup()->getPath2(),
				];
			}

			if ( $only_first_asset_group ) {
				return reset( $asset_group_assets ) ?: [];
			}

			return $asset_group_assets;
		} catch ( ApiException $e ) {
			do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );

			$errors = $this->get_exception_errors( $e );
			throw new ExceptionWithResponseData(
				/* translators: %s Error message */
				sprintf( __( 'Error retrieving asset groups assets by final url: %s', 'google-listings-and-ads' ), reset( $errors ) ),
				$this->map_grpc_code_to_http_status_code( $e ),
				null,
				[ 'errors' => $errors ]
			);
		}
	}

	/**
	 * Get assets to be deleted.
	 *
	 * @param array $assets A list of assets.
	 *
	 * @return array The assets to be deleted.
	 */
	public function get_assets_to_be_deleted( array $assets ): array {
		return array_values(
			array_filter(
				$assets,
				function ( $asset ) {
					return ! empty( $asset['id'] );
				}
			)
		);
	}

	/**
	 * Get assets to be created.
	 *
	 * @param array $assets A list of assets.
	 *
	 * @return array The assets to be created.
	 */
	public function get_assets_to_be_created( array $assets ): array {
		return array_values(
			array_filter(
				$assets,
				function ( $asset ) {
					return ! empty( $asset['content'] );
				}
			)
		);
	}

	/**
	 * Get specific assets by asset types.
	 *
	 * @param int   $asset_group_id The asset group id.
	 * @param array $asset_field_types The asset field types types.
	 *
	 * @return array The assets.
	 */
	protected function get_specific_assets( int $asset_group_id, array $asset_field_types ): array {
		$result             = $this->get_assets_by_asset_group_ids( [ $asset_group_id ], $asset_field_types );
		$asset_group_assets = $result[ $asset_group_id ] ?? [];
		$specific_assets    = [];

		foreach ( $asset_group_assets as $field_type => $assets ) {
			foreach ( $assets as $asset ) {
				$specific_assets[] = array_merge( $asset, [ 'field_type' => $field_type ] );
			}
		}

		return $specific_assets;
	}

	/**
	 * Check if a asset type will be edited.
	 *
	 * @param string $field_type The asset field type.
	 * @param array  $assets The assets.
	 *
	 * @return bool True if the asset type is edited.
	 */
	protected function maybe_asset_type_is_edited( string $field_type, array $assets ): bool {
		return in_array( $field_type, array_column( $assets, 'field_type' ), true );
	}

	/**
	 * Get override asset operations.
	 *
	 * @param int   $asset_group_id The asset group id.
	 * @param array $asset_field_types The asset field types.
	 *
	 * @return array The asset group asset operations.
	 */
	protected function get_override_operations( int $asset_group_id, array $asset_field_types ): array {
		return array_map(
			function ( $asset ) use ( $asset_group_id ) {
				return $this->delete_operation( $asset_group_id, $asset['field_type'], $asset['id'] );
			},
			$this->get_specific_assets( $asset_group_id, $asset_field_types )
		);
	}

	/**
	 * Edit assets group assets.
	 *
	 * @param int   $asset_group_id The asset group id.
	 * @param array $assets The assets to create.
	 *
	 * @return array The asset group asset operations.
	 * @throws Exception If the asset type is not supported.
	 */
	public function edit_operations( int $asset_group_id, array $assets ): array {
		if ( empty( $assets ) ) {
			return [];
		}

		$asset_group_assets_operations        = [];
		$assets_for_creation                  = $this->get_assets_to_be_created( $assets );
		$asset_arns                           = $this->asset->create_assets( $assets_for_creation );
		$total_assets                         = count( $assets_for_creation );
		$delete_asset_group_assets_operations = [];

		if ( $this->maybe_asset_type_is_edited( AssetFieldType::LOGO, $assets ) ) {
			// As we are not working with the LANDSCAPE_LOGO, we delete it so it does not interfere with the maximum quantities of logos.
			$delete_asset_group_assets_operations = $this->get_override_operations( $asset_group_id, [ AssetFieldType::name( AssetFieldType::LANDSCAPE_LOGO ) ] );
		}

		// The asset mutation operation results (ARNs) are returned in the same order as the operations are specified.
		// See: https://youtu.be/9KaVjqW5tVM?t=103
		for ( $i = 0; $i < $total_assets; $i++ ) {
			$asset_group_assets_operations[] = $this->create_operation( $asset_group_id, $assets_for_creation[ $i ]['field_type'], $asset_arns[ $i ] );
		}

		foreach ( $this->get_assets_to_be_deleted( $assets ) as $asset ) {
			$delete_asset_group_assets_operations[] = $this->delete_operation( $asset_group_id, $asset['field_type'], $asset['id'] );
		}

		// The delete operations must be executed first otherwise will cause a conflict with existing assets with identical content.
		// See here: https://github.com/woocommerce/google-listings-and-ads/pull/1870
		return array_merge( $delete_asset_group_assets_operations, $asset_group_assets_operations );
	}


	/**
	 * Creates an operation for an asset group asset.
	 *
	 * @param int    $asset_group_id The ID of the asset group.
	 * @param string $asset_field_type The field type of the asset.
	 * @param string $asset_arn The the asset ARN.
	 *
	 * @return MutateOperation The mutate create operation for the asset group asset.
	 */
	protected function create_operation( int $asset_group_id, string $asset_field_type, string $asset_arn ): MutateOperation {
		$operation             = new AssetGroupAssetOperation();
		$new_asset_group_asset = new AssetGroupAsset(
			[
				'asset'       => $asset_arn,
				'asset_group' => ResourceNames::forAssetGroup( $this->options->get_ads_id(), $asset_group_id ),
				'field_type'  => AssetFieldType::number( $asset_field_type ),
			]
		);

		return ( new MutateOperation() )->setAssetGroupAssetOperation( $operation->setCreate( $new_asset_group_asset ) );
	}

	/**
	 * Returns a delete operation for asset group asset.
	 *
	 * @param int    $asset_group_id The ID of the asset group.
	 * @param string $asset_field_type The field type of the asset.
	 * @param int    $asset_id The ID of the asset.
	 *
	 * @return MutateOperation The remove operation for the asset group asset.
	 */
	protected function delete_operation( int $asset_group_id, string $asset_field_type, int $asset_id ): MutateOperation {
		$asset_group_asset_resource_name = ResourceNames::forAssetGroupAsset( $this->options->get_ads_id(), $asset_group_id, $asset_id, AssetFieldType::name( $asset_field_type ) );
		$operation                       = ( new AssetGroupAssetOperation() )->setRemove( $asset_group_asset_resource_name );
		return ( new MutateOperation() )->setAssetGroupAssetOperation( $operation );
	}
}
AdsCampaign.php000064400000050434151545120240007431 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsCampaignCriterionQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsCampaignQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\MicroTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Google\Ads\GoogleAds\Util\FieldMasks;
use Google\Ads\GoogleAds\Util\V18\ResourceNames;
use Google\Ads\GoogleAds\V18\Common\MaximizeConversionValue;
use Google\Ads\GoogleAds\V18\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType;
use Google\Ads\GoogleAds\V18\Resources\Campaign;
use Google\Ads\GoogleAds\V18\Resources\Campaign\ShoppingSetting;
use Google\Ads\GoogleAds\V18\Services\Client\CampaignServiceClient;
use Google\Ads\GoogleAds\V18\Services\CampaignOperation;
use Google\Ads\GoogleAds\V18\Services\GoogleAdsRow;
use Google\Ads\GoogleAds\V18\Services\MutateGoogleAdsRequest;
use Google\Ads\GoogleAds\V18\Services\MutateOperation;
use Google\ApiCore\ApiException;
use Google\ApiCore\ValidationException;
use Exception;

/**
 * Class AdsCampaign (Performance Max Campaign)
 * https://developers.google.com/google-ads/api/docs/performance-max/overview
 *
 * ContainerAware used for:
 * - AdsAssetGroup
 * - TransientsInterface
 * - WC
 *
 * @since 1.12.2 Refactored to support PMax and (legacy) SSC.
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class AdsCampaign implements ContainerAwareInterface, OptionsAwareInterface {

	use ContainerAwareTrait;
	use ExceptionTrait;
	use OptionsAwareTrait;
	use MicroTrait;

	/**
	 * Temporary ID to use within a batch job.
	 * A negative number which is unique for all the created resources.
	 *
	 * @var int
	 */
	protected const TEMPORARY_ID = -1;

	/**
	 * The Google Ads Client.
	 *
	 * @var GoogleAdsClient
	 */
	protected $client;

	/**
	 * @var AdsCampaignBudget $budget
	 */
	protected $budget;

	/**
	 * @var AdsCampaignCriterion $criterion
	 */
	protected $criterion;

	/**
	 * @var GoogleHelper $google_helper
	 */
	protected $google_helper;

	/**
	 * @var AdsCampaignLabel $campaign_label
	 */
	protected $campaign_label;

	/**
	 * AdsCampaign constructor.
	 *
	 * @param GoogleAdsClient      $client
	 * @param AdsCampaignBudget    $budget
	 * @param AdsCampaignCriterion $criterion
	 * @param GoogleHelper         $google_helper
	 * @param AdsCampaignLabel     $campaign_label
	 */
	public function __construct( GoogleAdsClient $client, AdsCampaignBudget $budget, AdsCampaignCriterion $criterion, GoogleHelper $google_helper, AdsCampaignLabel $campaign_label ) {
		$this->client         = $client;
		$this->budget         = $budget;
		$this->criterion      = $criterion;
		$this->google_helper  = $google_helper;
		$this->campaign_label = $campaign_label;
	}

	/**
	 * Returns a list of campaigns with targeted locations retrieved from campaign criterion.
	 *
	 * @param bool  $exclude_removed Exclude removed campaigns (default true).
	 * @param bool  $fetch_criterion Combine the campaign data with criterion data (default true).
	 * @param array $args Arguments for fetching campaigns, for example: per_page for limiting the number of results.
	 *
	 * @return array
	 * @throws ExceptionWithResponseData When an ApiException is caught.
	 */
	public function get_campaigns( bool $exclude_removed = true, bool $fetch_criterion = true, array $args = [] ): array {
		try {
			$query = ( new AdsCampaignQuery() )->set_client( $this->client, $this->options->get_ads_id() );

			if ( $exclude_removed ) {
				$query->where( 'campaign.status', 'REMOVED', '!=' );
			}

			$count               = 0;
			$campaign_results    = $query->get_results();
			$converted_campaigns = [];

			foreach ( $campaign_results->iterateAllElements() as $row ) {
				++$count;
				$campaign                               = $this->convert_campaign( $row );
				$converted_campaigns[ $campaign['id'] ] = $campaign;

				// Break early if we request a limited result.
				if ( ! empty( $args['per_page'] ) && $count >= $args['per_page'] ) {
					break;
				}
			}

			if ( $exclude_removed ) {
				// Cache campaign count.
				$campaign_count = $campaign_results->getPage()->getResponseObject()->getTotalResultsCount();
				$this->container->get( TransientsInterface::class )->set(
					TransientsInterface::ADS_CAMPAIGN_COUNT,
					$campaign_count,
					HOUR_IN_SECONDS * 12
				);
			}

			if ( $fetch_criterion ) {
				$converted_campaigns = $this->combine_campaigns_and_campaign_criterion_results( $converted_campaigns );
			}

			return array_values( $converted_campaigns );
		} catch ( ApiException $e ) {
			do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );

			$errors = $this->get_exception_errors( $e );
			throw new ExceptionWithResponseData(
				/* translators: %s Error message */
				sprintf( __( 'Error retrieving campaigns: %s', 'google-listings-and-ads' ), reset( $errors ) ),
				$this->map_grpc_code_to_http_status_code( $e ),
				null,
				[ 'errors' => $errors ]
			);
		}
	}

	/**
	 * Retrieve a single campaign with targeted locations retrieved from campaign criterion.
	 *
	 * @param int $id Campaign ID.
	 *
	 * @return array
	 * @throws ExceptionWithResponseData When an ApiException is caught.
	 */
	public function get_campaign( int $id ): array {
		try {
			$campaign_results = ( new AdsCampaignQuery() )->set_client( $this->client, $this->options->get_ads_id() )
				->where( 'campaign.id', $id, '=' )
				->get_results();

			$converted_campaigns = [];

			// Get only the first element from campaign results
			foreach ( $campaign_results->iterateAllElements() as $row ) {
				$campaign                               = $this->convert_campaign( $row );
				$converted_campaigns[ $campaign['id'] ] = $campaign;
				break;
			}

			if ( ! empty( $converted_campaigns ) ) {
				$combined_results = $this->combine_campaigns_and_campaign_criterion_results( $converted_campaigns );
				return reset( $combined_results );
			}

			return [];
		} catch ( ApiException $e ) {
			do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );

			$errors = $this->get_exception_errors( $e );
			throw new ExceptionWithResponseData(
				/* translators: %s Error message */
				sprintf( __( 'Error retrieving campaign: %s', 'google-listings-and-ads' ), reset( $errors ) ),
				$this->map_grpc_code_to_http_status_code( $e ),
				null,
				[
					'errors' => $errors,
					'id'     => $id,
				]
			);
		}
	}

	/**
	 * Create a new campaign.
	 *
	 * @param array $params Request parameters.
	 *
	 * @return array
	 * @throws ExceptionWithResponseData When an ApiException is caught.
	 */
	public function create_campaign( array $params ): array {
		try {
			$base_country = $this->container->get( WC::class )->get_base_country();

			$location_ids = array_map(
				function ( $country_code ) {
					return $this->google_helper->find_country_id_by_code( $country_code );
				},
				$params['targeted_locations']
			);

			$location_ids = array_filter( $location_ids );

			// Operations must be in a specific order to match the temporary ID's.
			$operations = array_merge(
				[ $this->budget->create_operation( $params['name'], $params['amount'] ) ],
				[ $this->create_operation( $params['name'], $base_country ) ],
				$this->container->get( AdsAssetGroup::class )->create_operations(
					$this->temporary_resource_name(),
					$params['name']
				),
				$this->criterion->create_operations(
					$this->temporary_resource_name(),
					$location_ids
				)
			);

			$campaign_id = $this->mutate( $operations );

			if ( isset( $params['label'] ) ) {
				$this->campaign_label->assign_label_to_campaign_by_label_name( $campaign_id, $params['label'] );
			}

			// Clear cached campaign count.
			$this->container->get( TransientsInterface::class )->delete( TransientsInterface::ADS_CAMPAIGN_COUNT );

			return [
				'id'      => $campaign_id,
				'status'  => CampaignStatus::ENABLED,
				'type'    => CampaignType::PERFORMANCE_MAX,
				'country' => $base_country,
			] + $params;
		} catch ( ApiException $e ) {
			do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );

			$errors = $this->get_exception_errors( $e );
			/* translators: %s Error message */
			$message = sprintf( __( 'Error creating campaign: %s', 'google-listings-and-ads' ), reset( $errors ) );

			if ( isset( $errors['DUPLICATE_CAMPAIGN_NAME'] ) ) {
				$message = __( 'A campaign with this name already exists', 'google-listings-and-ads' );
			}

			throw new ExceptionWithResponseData(
				$message,
				$this->map_grpc_code_to_http_status_code( $e ),
				null,
				[ 'errors' => $errors ]
			);
		}
	}

	/**
	 * Edit a campaign.
	 *
	 * @param int   $campaign_id Campaign ID.
	 * @param array $params      Request parameters.
	 *
	 * @return int
	 * @throws ExceptionWithResponseData When an ApiException is caught.
	 */
	public function edit_campaign( int $campaign_id, array $params ): int {
		try {
			$operations      = [];
			$campaign_fields = [];

			if ( ! empty( $params['name'] ) ) {
				$campaign_fields['name'] = $params['name'];
			}

			if ( ! empty( $params['status'] ) ) {
				$campaign_fields['status'] = CampaignStatus::number( $params['status'] );
			}

			if ( ! empty( $params['amount'] ) ) {
				$operations[] = $this->budget->edit_operation( $campaign_id, $params['amount'] );
			}

			if ( ! empty( $campaign_fields ) ) {
				$operations[] = $this->edit_operation( $campaign_id, $campaign_fields );
			}

			if ( ! empty( $operations ) ) {
				return $this->mutate( $operations ) ?: $campaign_id;
			}

			return $campaign_id;
		} catch ( ApiException $e ) {
			do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );

			$errors = $this->get_exception_errors( $e );
			throw new ExceptionWithResponseData(
				/* translators: %s Error message */
				sprintf( __( 'Error editing campaign: %s', 'google-listings-and-ads' ), reset( $errors ) ),
				$this->map_grpc_code_to_http_status_code( $e ),
				null,
				[
					'errors' => $errors,
					'id'     => $campaign_id,
				]
			);
		}
	}

	/**
	 * Delete a campaign.
	 *
	 * @param int $campaign_id Campaign ID.
	 *
	 * @return int
	 * @throws ExceptionWithResponseData When an ApiException is caught.
	 */
	public function delete_campaign( int $campaign_id ): int {
		try {
			$campaign_resource_name = ResourceNames::forCampaign( $this->options->get_ads_id(), $campaign_id );

			$operations = [
				$this->delete_operation( $campaign_resource_name ),
			];

			// Clear cached campaign count.
			$this->container->get( TransientsInterface::class )->delete( TransientsInterface::ADS_CAMPAIGN_COUNT );

			return $this->mutate( $operations );
		} catch ( ApiException $e ) {
			do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );

			$errors = $this->get_exception_errors( $e );
			/* translators: %s Error message */
			$message = sprintf( __( 'Error deleting campaign: %s', 'google-listings-and-ads' ), reset( $errors ) );

			if ( isset( $errors['OPERATION_NOT_PERMITTED_FOR_REMOVED_RESOURCE'] ) ) {
				$message = __( 'This campaign has already been deleted', 'google-listings-and-ads' );
			}

			throw new ExceptionWithResponseData(
				$message,
				$this->map_grpc_code_to_http_status_code( $e ),
				null,
				[
					'errors' => $errors,
					'id'     => $campaign_id,
				]
			);
		}
	}

	/**
	 * Retrieves the status of converting campaigns.
	 * The status is cached for an hour during unconverted.
	 *
	 * - unconverted    - Still need to convert some older campaigns
	 * - converted      - All campaigns are converted to PMax campaigns
	 * - not-applicable - User never had any older campaign types
	 *
	 * @since 2.0.3
	 *
	 * @return string
	 */
	public function get_campaign_convert_status(): string {
		$convert_status = $this->options->get( OptionsInterface::CAMPAIGN_CONVERT_STATUS );

		if ( ! is_array( $convert_status ) || empty( $convert_status['status'] ) ) {
			$convert_status = [ 'status' => 'unknown' ];
		}

		// Refetch if status is unconverted and older than an hour.
		if (
			in_array( $convert_status['status'], [ 'unconverted', 'unknown' ], true ) &&
			( empty( $convert_status['updated'] ) || time() - $convert_status['updated'] > HOUR_IN_SECONDS )
		) {
			$old_campaigns            = 0;
			$old_removed_campaigns    = 0;
			$convert_status['status'] = 'unconverted';

			try {
				foreach ( $this->get_campaigns( false, false ) as $campaign ) {
					if ( CampaignType::PERFORMANCE_MAX !== $campaign['type'] ) {
						if ( CampaignStatus::REMOVED === $campaign['status'] ) {
							++$old_removed_campaigns;
						} else {
							++$old_campaigns;
						}
					}
				}

				// No old campaign types means we don't need to convert.
				if ( ! $old_removed_campaigns && ! $old_campaigns ) {
					$convert_status['status'] = 'not-applicable';
				}

				// All old campaign types have been removed, means we converted.
				if ( ! $old_campaigns && $old_removed_campaigns > 0 ) {
					$convert_status['status'] = 'converted';
				}
			} catch ( Exception $e ) {
				// Error when retrieving campaigns, do not handle conversion.
				$convert_status['status'] = 'unknown';
			}

			$convert_status['updated'] = time();
			$this->options->update( OptionsInterface::CAMPAIGN_CONVERT_STATUS, $convert_status );
		}

		return $convert_status['status'];
	}

	/**
	 * Return a temporary resource name for the campaign.
	 *
	 * @return string
	 */
	protected function temporary_resource_name() {
		return ResourceNames::forCampaign( $this->options->get_ads_id(), self::TEMPORARY_ID );
	}

	/**
	 * Returns a campaign create operation.
	 *
	 * @param string $campaign_name
	 * @param string $country
	 *
	 * @return MutateOperation
	 */
	protected function create_operation( string $campaign_name, string $country ): MutateOperation {
		$campaign = new Campaign(
			[
				'resource_name'             => $this->temporary_resource_name(),
				'name'                      => $campaign_name,
				'advertising_channel_type'  => AdvertisingChannelType::PERFORMANCE_MAX,
				'status'                    => CampaignStatus::number( 'enabled' ),
				'campaign_budget'           => $this->budget->temporary_resource_name(),
				'maximize_conversion_value' => new MaximizeConversionValue(),
				'url_expansion_opt_out'     => false,
				'shopping_setting'          => new ShoppingSetting(
					[
						'merchant_id' => $this->options->get_merchant_id(),
						'feed_label'  => $country,
					]
				),
			]
		);

		$operation = ( new CampaignOperation() )->setCreate( $campaign );
		return ( new MutateOperation() )->setCampaignOperation( $operation );
	}

	/**
	 * Returns a campaign edit operation.
	 *
	 * @param integer $campaign_id
	 * @param array   $fields
	 *
	 * @return MutateOperation
	 */
	protected function edit_operation( int $campaign_id, array $fields ): MutateOperation {
		$fields['resource_name'] = ResourceNames::forCampaign( $this->options->get_ads_id(), $campaign_id );

		$campaign  = new Campaign( $fields );
		$operation = new CampaignOperation();
		$operation->setUpdate( $campaign );
		$operation->setUpdateMask( FieldMasks::allSetFieldsOf( $campaign ) );
		return ( new MutateOperation() )->setCampaignOperation( $operation );
	}

	/**
	 * Returns a campaign delete operation.
	 *
	 * @param string $campaign_resource_name
	 *
	 * @return MutateOperation
	 */
	protected function delete_operation( string $campaign_resource_name ): MutateOperation {
		$operation = ( new CampaignOperation() )->setRemove( $campaign_resource_name );
		return ( new MutateOperation() )->setCampaignOperation( $operation );
	}

	/**
	 * Convert campaign data to an array.
	 *
	 * @param GoogleAdsRow $row Data row returned from a query request.
	 *
	 * @return array
	 */
	protected function convert_campaign( GoogleAdsRow $row ): array {
		$campaign = $row->getCampaign();
		$data     = [
			'id'                 => $campaign->getId(),
			'name'               => $campaign->getName(),
			'status'             => CampaignStatus::label( $campaign->getStatus() ),
			'type'               => CampaignType::label( $campaign->getAdvertisingChannelType() ),
			'targeted_locations' => [],
		];

		$budget = $row->getCampaignBudget();
		if ( $budget ) {
			$data += [
				'amount' => $this->from_micro( $budget->getAmountMicros() ),
			];
		}

		$shopping = $campaign->getShoppingSetting();
		if ( $shopping ) {
			$data += [
				'country' => $shopping->getFeedLabel(),
			];
		}

		return $data;
	}

	/**
	 * Combine converted campaigns data with campaign criterion results data
	 *
	 * @param array $campaigns Campaigns data returned from a query request and converted by convert_campaign function.
	 *
	 * @return array
	 */
	protected function combine_campaigns_and_campaign_criterion_results( array $campaigns ): array {
		if ( empty( $campaigns ) ) {
			return [];
		}

		$campaign_criterion_results = ( new AdsCampaignCriterionQuery() )->set_client( $this->client, $this->options->get_ads_id() )
			->where( 'campaign.id', array_keys( $campaigns ), 'IN' )
			// negative: Whether to target (false) or exclude (true) the criterion.
			->where( 'campaign_criterion.negative', 'false', '=' )
			->where( 'campaign_criterion.status', 'REMOVED', '!=' )
			->where( 'campaign_criterion.location.geo_target_constant', '', 'IS NOT NULL' )
			->get_results();

		/** @var GoogleAdsRow $row */
		foreach ( $campaign_criterion_results->iterateAllElements() as $row ) {
			$campaign    = $row->getCampaign();
			$campaign_id = $campaign->getId();

			if ( ! isset( $campaigns[ $campaign_id ] ) ) {
				continue;
			}

			$campaign_criterion  = $row->getCampaignCriterion();
			$location            = $campaign_criterion->getLocation();
			$geo_target_constant = $location->getGeoTargetConstant();
			$location_id         = $this->parse_geo_target_location_id( $geo_target_constant );
			$country_code        = $this->google_helper->find_country_code_by_id( $location_id );

			if ( $country_code ) {
				$campaigns[ $campaign_id ]['targeted_locations'][] = $country_code;
			}
		}

		return $campaigns;
	}

	/**
	 * Send a batch of operations to mutate a campaign.
	 *
	 * @param MutateOperation[] $operations
	 *
	 * @return int Campaign ID from the MutateOperationResponse.
	 * @throws ApiException If any of the operations fail.
	 */
	protected function mutate( array $operations ): int {
		$request = new MutateGoogleAdsRequest();
		$request->setCustomerId( $this->options->get_ads_id() );
		$request->setMutateOperations( $operations );
		$responses = $this->client->getGoogleAdsServiceClient()->mutate( $request );
		foreach ( $responses->getMutateOperationResponses() as $response ) {
			if ( 'campaign_result' === $response->getResponse() ) {
				$campaign_result = $response->getCampaignResult();
				return $this->parse_campaign_id( $campaign_result->getResourceName() );
			}
		}

		// When editing only the budget there is no campaign mutate result.
		return 0;
	}

	/**
	 * Convert ID from a resource name to an int.
	 *
	 * @param string $name Resource name containing ID number.
	 *
	 * @return int
	 * @throws Exception When unable to parse resource ID.
	 */
	protected function parse_campaign_id( string $name ): int {
		try {
			$parts = CampaignServiceClient::parseName( $name );
			return absint( $parts['campaign_id'] );
		} catch ( ValidationException $e ) {
			throw new Exception( __( 'Invalid campaign ID', 'google-listings-and-ads' ) );
		}
	}

	/**
	 * Convert location ID from a geo target constant resource name to an int.
	 *
	 * @param string $geo_target_constant Resource name containing ID number.
	 *
	 * @return int
	 * @throws Exception When unable to parse resource ID.
	 */
	protected function parse_geo_target_location_id( string $geo_target_constant ): int {
		if ( 1 === preg_match( '#geoTargetConstants/(?<id>\d+)#', $geo_target_constant, $parts ) ) {
			return absint( $parts['id'] );
		} else {
			throw new Exception( __( 'Invalid geo target location ID', 'google-listings-and-ads' ) );
		}
	}
}
AdsCampaignBudget.php000064400000011120151545120240010551 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsCampaignBudgetQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\MicroTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Google\Ads\GoogleAds\Util\FieldMasks;
use Google\Ads\GoogleAds\Util\V18\ResourceNames;
use Google\Ads\GoogleAds\V18\Resources\CampaignBudget;
use Google\Ads\GoogleAds\V18\Services\CampaignBudgetOperation;
use Google\Ads\GoogleAds\V18\Services\Client\CampaignBudgetServiceClient;
use Google\Ads\GoogleAds\V18\Services\MutateOperation;
use Google\ApiCore\ValidationException;
use Exception;

/**
 * Class AdsCampaignBudget
 *
 * @since 1.12.2 Refactored to support PMax and (legacy) SSC.
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class AdsCampaignBudget implements OptionsAwareInterface {

	use MicroTrait;
	use OptionsAwareTrait;

	/**
	 * Temporary ID to use within a batch job.
	 * A negative number which is unique for all the created resources.
	 *
	 * @var int
	 */
	protected const TEMPORARY_ID = -2;

	/**
	 * The Google Ads Client.
	 *
	 * @var GoogleAdsClient
	 */
	protected $client;

	/**
	 * AdsCampaignBudget constructor.
	 *
	 * @param GoogleAdsClient $client
	 */
	public function __construct( GoogleAdsClient $client ) {
		$this->client = $client;
	}

	/**
	 * Returns a new campaign budget create operation.
	 *
	 * @param string $campaign_name New campaign name.
	 * @param float  $amount        Budget amount in the local currency.
	 *
	 * @return MutateOperation
	 */
	public function create_operation( string $campaign_name, float $amount ): MutateOperation {
		$budget = new CampaignBudget(
			[
				'resource_name'     => $this->temporary_resource_name(),
				'name'              => $campaign_name . ' Budget',
				'amount_micros'     => $this->to_micro( $amount ),
				'explicitly_shared' => false,
			]
		);

		$operation = ( new CampaignBudgetOperation() )->setCreate( $budget );
		return ( new MutateOperation() )->setCampaignBudgetOperation( $operation );
	}

	/**
	 * Updates a new campaign budget.
	 *
	 * @param int   $campaign_id Campaign ID.
	 * @param float $amount Budget amount in the local currency.
	 *
	 * @return string Resource name of the updated budget.
	 * @throws Exception If no linked budget has been found.
	 */
	public function edit_operation( int $campaign_id, float $amount ): MutateOperation {
		$budget_id = $this->get_budget_from_campaign( $campaign_id );
		$budget    = new CampaignBudget(
			[
				'resource_name' => ResourceNames::forCampaignBudget( $this->options->get_ads_id(), $budget_id ),
				'amount_micros' => $this->to_micro( $amount ),
			]
		);

		$operation = new CampaignBudgetOperation();
		$operation->setUpdate( $budget );
		$operation->setUpdateMask( FieldMasks::allSetFieldsOf( $budget ) );
		return ( new MutateOperation() )->setCampaignBudgetOperation( $operation );
	}

	/**
	 * Return a temporary resource name for the campaign budget.
	 *
	 * @return string
	 */
	public function temporary_resource_name() {
		return ResourceNames::forCampaignBudget( $this->options->get_ads_id(), self::TEMPORARY_ID );
	}

	/**
	 * Retrieve the linked budget ID from a campaign ID.
	 *
	 * @param int $campaign_id Campaign ID.
	 *
	 * @return int
	 * @throws Exception If no linked budget has been found.
	 */
	protected function get_budget_from_campaign( int $campaign_id ): int {
		$results = ( new AdsCampaignBudgetQuery() )
			->set_client( $this->client, $this->options->get_ads_id() )
			->where( 'campaign.id', $campaign_id )
			->get_results();

		foreach ( $results->iterateAllElements() as $row ) {
			$campaign = $row->getCampaign();
			return $this->parse_campaign_budget_id( $campaign->getCampaignBudget() );
		}

		/* translators: %d Campaign ID */
		throw new Exception( sprintf( __( 'No budget found for campaign %d', 'google-listings-and-ads' ), $campaign_id ) );
	}

	/**
	 * Convert ID from a resource name to an int.
	 *
	 * @param string $name Resource name containing ID number.
	 *
	 * @return int
	 * @throws Exception When unable to parse resource ID.
	 */
	protected function parse_campaign_budget_id( string $name ): int {
		try {
			$parts = CampaignBudgetServiceClient::parseName( $name );
			return absint( $parts['campaign_budget_id'] );
		} catch ( ValidationException $e ) {
			throw new Exception( __( 'Invalid campaign budget ID', 'google-listings-and-ads' ) );
		}
	}
}
AdsCampaignCriterion.php000064400000003664151545120240011313 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Google\Ads\GoogleAds\Util\V18\ResourceNames;
use Google\Ads\GoogleAds\V18\Common\LocationInfo;
use Google\Ads\GoogleAds\V18\Enums\CampaignCriterionStatusEnum\CampaignCriterionStatus;
use Google\Ads\GoogleAds\V18\Resources\CampaignCriterion;
use Google\Ads\GoogleAds\V18\Services\CampaignCriterionOperation;
use Google\Ads\GoogleAds\V18\Services\MutateOperation;

/**
 * Class AdsCampaignCriterion
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class AdsCampaignCriterion {

	use ExceptionTrait;

	/**
	 * Returns a set of operations to create multiple campaign criteria.
	 *
	 * @param string $campaign_resource_name Campaign resource name.
	 * @param array  $location_ids Targeted locations IDs.
	 *
	 * @return array
	 */
	public function create_operations( string $campaign_resource_name, array $location_ids ): array {
		return array_map(
			function ( $location_id ) use ( $campaign_resource_name ) {
				return $this->create_operation( $campaign_resource_name, $location_id );
			},
			$location_ids
		);
	}

	/**
	 * Returns a new campaign criterion create operation.
	 *
	 * @param string $campaign_resource_name Campaign resource name.
	 * @param int    $location_id Targeted location ID.
	 *
	 * @return MutateOperation
	 */
	protected function create_operation( string $campaign_resource_name, int $location_id ): MutateOperation {
		$campaign_criterion = new CampaignCriterion(
			[
				'campaign' => $campaign_resource_name,
				'negative' => false,
				'status'   => CampaignCriterionStatus::ENABLED,
				'location' => new LocationInfo(
					[
						'geo_target_constant' => ResourceNames::forGeoTargetConstant( $location_id ),
					]
				),
			]
		);

		$operation = ( new CampaignCriterionOperation() )->setCreate( $campaign_criterion );
		return ( new MutateOperation() )->setCampaignCriterionOperation( $operation );
	}
}
AdsCampaignLabel.php000064400000010701151545120240010362 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsCampaignLabelQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Google\Ads\GoogleAds\Util\V18\ResourceNames;
use Google\Ads\GoogleAds\V18\Resources\Label;
use Google\Ads\GoogleAds\V18\Resources\CampaignLabel;
use Google\Ads\GoogleAds\V18\Services\LabelOperation;
use Google\Ads\GoogleAds\V18\Services\CampaignLabelOperation;
use Google\Ads\GoogleAds\V18\Services\MutateOperation;
use Google\Ads\GoogleAds\V18\Services\MutateGoogleAdsRequest;

/**
 * Class AdsCampaignLabel
 * https://developers.google.com/google-ads/api/docs/reporting/labels
 *
 * @since 2.8.1
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class AdsCampaignLabel implements OptionsAwareInterface {

	use OptionsAwareTrait;

	/**
	 * Temporary ID to use within a batch job.
	 * A negative number which is unique for all the created resources.
	 *
	 * @var int
	 */
	protected const TEMPORARY_ID = -1;

	/**
	 * The Google Ads Client.
	 *
	 * @var GoogleAdsClient
	 */
	protected $client;


	/**
	 * AdsCampaignLabel constructor.
	 *
	 * @param GoogleAdsClient $client
	 */
	public function __construct( GoogleAdsClient $client ) {
		$this->client = $client;
	}

	/**
	 * Get the label ID by name.
	 *
	 * @param string $name The label name.
	 *
	 * @return null|int The label ID.
	 *
	 * @throws ApiException If the search call fails.
	 */
	protected function get_label_id_by_name( string $name ) {
		$query = new AdsCampaignLabelQuery();
		$query->set_client( $this->client, $this->options->get_ads_id() );
		$query->where( 'label.name', $name, '=' );
		$label_results = $query->get_results();

		foreach ( $label_results->iterateAllElements() as $row ) {
			return $row->getLabel()->getId();
		}

		return null;
	}

	/**
	 * Assign a label to a campaign by label name.
	 *
	 * @param int    $campaign_id The campaign ID.
	 * @param string $label_name  The label name.
	 *
	 * @throws ApiException If searching for the label fails.
	 */
	public function assign_label_to_campaign_by_label_name( int $campaign_id, string $label_name ) {
		$label_id   = $this->get_label_id_by_name( $label_name );
		$operations = [];

		if ( ! $label_id ) {
			$operations[] = $this->create_operation( $label_name );
			$label_id     = self::TEMPORARY_ID;
		}

		$operations[] = $this->assign_label_to_campaign_operation( $campaign_id, $label_id );
		$this->mutate( $operations );
	}

	/**
	 * Create a label operation.
	 *
	 * @param string $name The label name.
	 *
	 * @return MutateOperation
	 */
	protected function create_operation( string $name ): MutateOperation {
		$label = new Label(
			[
				'name'          => $name,
				'resource_name' => $this->temporary_resource_name(),
			]
		);

		$operation = ( new LabelOperation() )->setCreate( $label );
		return ( new MutateOperation() )->setLabelOperation( $operation );
	}

	/**
	 * Return a temporary resource name for the label.
	 *
	 * @return string
	 */
	protected function temporary_resource_name() {
		return ResourceNames::forLabel( $this->options->get_ads_id(), self::TEMPORARY_ID );
	}

	/**
	 * Creates a campaign label operation.
	 *
	 * @param int $campaign_id The campaign ID.
	 * @param int $label_id    The label ID.
	 *
	 * @return MutateOperation
	 */
	protected function assign_label_to_campaign_operation( int $campaign_id, int $label_id ): MutateOperation {
		$label_resource_name = ResourceNames::forLabel( $this->options->get_ads_id(), $label_id );

		$campaign_label = new CampaignLabel(
			[
				'campaign' => ResourceNames::forCampaign( $this->options->get_ads_id(), $campaign_id ),
				'label'    => $label_resource_name,
			]
		);

		$operation = ( new CampaignLabelOperation() )->setCreate( $campaign_label );
		return ( new MutateOperation() )->setCampaignLabelOperation( $operation );
	}

	/**
	 * Mutate the operations.
	 *
	 * @param array $operations The operations to mutate.
	 *
	 * @throws ApiException — Thrown if the API call fails.
	 */
	protected function mutate( array $operations ) {
		$request = new MutateGoogleAdsRequest();
		$request->setCustomerId( $this->options->get_ads_id() );
		$request->setMutateOperations( $operations );
		$this->client->getGoogleAdsServiceClient()->mutate( $request );
	}
}
AdsConversionAction.php000064400000014776151545120240011206 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsConversionActionQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Exception;
use Google\Ads\GoogleAds\V18\Common\TagSnippet;
use Google\Ads\GoogleAds\V18\Enums\ConversionActionCategoryEnum\ConversionActionCategory;
use Google\Ads\GoogleAds\V18\Enums\ConversionActionStatusEnum\ConversionActionStatus;
use Google\Ads\GoogleAds\V18\Enums\ConversionActionTypeEnum\ConversionActionType;
use Google\Ads\GoogleAds\V18\Enums\TrackingCodePageFormatEnum\TrackingCodePageFormat;
use Google\Ads\GoogleAds\V18\Enums\TrackingCodeTypeEnum\TrackingCodeType;
use Google\Ads\GoogleAds\V18\Resources\ConversionAction;
use Google\Ads\GoogleAds\V18\Resources\ConversionAction\ValueSettings;
use Google\Ads\GoogleAds\V18\Services\ConversionActionOperation;
use Google\Ads\GoogleAds\V18\Services\Client\ConversionActionServiceClient;
use Google\Ads\GoogleAds\V18\Services\GoogleAdsRow;
use Google\Ads\GoogleAds\V18\Services\MutateConversionActionResult;
use Google\Ads\GoogleAds\V18\Services\MutateConversionActionsRequest;
use Google\ApiCore\ApiException;

/**
 * Class AdsConversionAction
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class AdsConversionAction implements OptionsAwareInterface {

	use ExceptionTrait;
	use OptionsAwareTrait;

	/**
	 * The Google Ads Client.
	 *
	 * @var GoogleAdsClient
	 */
	protected $client;

	/**
	 * AdsConversionAction constructor.
	 *
	 * @param GoogleAdsClient $client
	 */
	public function __construct( GoogleAdsClient $client ) {
		$this->client = $client;
	}

	/**
	 * Create the 'Google for WooCommerce purchase action' conversion action.
	 *
	 * @return array An array with some conversion action details.
	 * @throws Exception If the conversion action can't be created or retrieved.
	 */
	public function create_conversion_action(): array {
		try {
			$unique = sprintf( '%04x', wp_rand( 0, 0xffff ) );

			$conversion_action_operation = new ConversionActionOperation();
			$conversion_action_operation->setCreate(
				new ConversionAction(
					[
						'name'           => apply_filters(
							'woocommerce_gla_conversion_action_name',
							sprintf(
							/* translators: %1 is a random 4-digit string */
								__( '[%1$s] Google for WooCommerce purchase action', 'google-listings-and-ads' ),
								$unique
							)
						),
						'category'       => ConversionActionCategory::PURCHASE,
						'type'           => ConversionActionType::WEBPAGE,
						'status'         => ConversionActionStatus::ENABLED,
						'value_settings' => new ValueSettings(
							[
								'default_value'            => 0,
								'always_use_default_value' => false,
							]
						),
					]
				)
			);

			// Create the conversion.
			$request = new MutateConversionActionsRequest();
			$request->setCustomerId( $this->options->get_ads_id() );
			$request->setOperations( [ $conversion_action_operation ] );
			$response = $this->client->getConversionActionServiceClient()->mutateConversionActions(
				$request
			);

			/** @var MutateConversionActionResult $added_conversion_action */
			$added_conversion_action = $response->getResults()->offsetGet( 0 );
			return $this->get_conversion_action( $added_conversion_action->getResourceName() );

		} catch ( Exception $e ) {
			do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
			$message = $e->getMessage();
			$code    = $e->getCode();

			if ( $e instanceof ApiException ) {

				if ( $this->has_api_exception_error( $e, 'DUPLICATE_NAME' ) ) {
					$message = __( 'A conversion action with this name already exists', 'google-listings-and-ads' );
				} else {
					$message = $e->getBasicMessage();
				}
				$code = $this->map_grpc_code_to_http_status_code( $e );
			}

			throw new Exception(
				/* translators: %s Error message */
				sprintf( __( 'Error creating conversion action: %s', 'google-listings-and-ads' ), $message ),
				$code
			);
		}
	}

	/**
	 * Retrieve a Conversion Action.
	 *
	 * @param string|int $resource_name The Conversion Action to retrieve (also accepts the Conversion Action ID).
	 *
	 * @return array An array with some conversion action details.
	 * @throws Exception If the Conversion Action can't be retrieved.
	 */
	public function get_conversion_action( $resource_name ): array {
		try {
			// Accept IDs too
			if ( is_numeric( $resource_name ) ) {
				$resource_name = ConversionActionServiceClient::conversionActionName( strval( $this->options->get_ads_id() ), strval( $resource_name ) );
			}

			$results = ( new AdsConversionActionQuery() )->set_client( $this->client, $this->options->get_ads_id() )
				->where( 'conversion_action.resource_name', $resource_name, '=' )
				->get_results();

			// Get only the first element from results.
			foreach ( $results->iterateAllElements() as $row ) {
				return $this->convert_conversion_action( $row );
			}
		} catch ( Exception $e ) {
			do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
			$message = $e->getMessage();
			$code    = $e->getCode();

			if ( $e instanceof ApiException ) {
				$message = $e->getBasicMessage();
				$code    = $this->map_grpc_code_to_http_status_code( $e );
			}

			throw new Exception(
				/* translators: %s Error message */
				sprintf( __( 'Error retrieving conversion action: %s', 'google-listings-and-ads' ), $message ),
				$code
			);
		}
	}

	/**
	 * Convert conversion action data to an array.
	 *
	 * @param GoogleAdsRow $row Data row returned from a query request.
	 *
	 * @return array An array with some conversion action details.
	 */
	private function convert_conversion_action( GoogleAdsRow $row ): array {
		$conversion_action = $row->getConversionAction();
		$return            = [
			'id'     => $conversion_action->getId(),
			'name'   => $conversion_action->getName(),
			'status' => ConversionActionStatus::name( $conversion_action->getStatus() ),
		];
		foreach ( $conversion_action->getTagSnippets() as $t ) {
			/** @var TagSnippet $t */
			if ( $t->getType() !== TrackingCodeType::WEBPAGE ) {
				continue;
			}
			if ( $t->getPageFormat() !== TrackingCodePageFormat::HTML ) {
				continue;
			}
			preg_match( "#send_to': '([^/]+)/([^']+)'#", $t->getEventSnippet(), $matches );
			$return['conversion_id']    = $matches[1];
			$return['conversion_label'] = $matches[2];
			break;
		}
		return $return;
	}
}
AdsReport.php000064400000016331151545120240007163 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsCampaignReportQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsProductReportQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\MicroTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use DateTime;
use Google\Ads\GoogleAds\V18\Common\Segments;
use Google\Ads\GoogleAds\V18\Services\GoogleAdsRow;
use Google\ApiCore\ApiException;

/**
 * Class AdsReport
 *
 * ContainerAware used for:
 * - AdsCampaign
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class AdsReport implements ContainerAwareInterface, OptionsAwareInterface {

	use ContainerAwareTrait;
	use ExceptionTrait;
	use MicroTrait;
	use OptionsAwareTrait;
	use ReportTrait;

	/**
	 * The Google Ads Client.
	 *
	 * @var GoogleAdsClient
	 */
	protected $client;

	/**
	 * Have we completed the conversion to PMax campaigns.
	 *
	 * @var bool
	 */
	protected $has_converted;

	/**
	 * AdsReport constructor.
	 *
	 * @param GoogleAdsClient $client
	 */
	public function __construct( GoogleAdsClient $client ) {
		$this->client = $client;
	}

	/**
	 * Get report data for campaigns.
	 *
	 * @param string $type Report type (campaigns or products).
	 * @param array  $args Query arguments.
	 *
	 * @return array
	 * @throws ExceptionWithResponseData If the report data can't be retrieved.
	 */
	public function get_report_data( string $type, array $args ): array {
		try {
			$this->has_converted = 'converted' === $this->container->get( AdsCampaign::class )->get_campaign_convert_status();

			if ( 'products' === $type ) {
				$query = new AdsProductReportQuery( $args );
			} else {
				$query = new AdsCampaignReportQuery( $args );
			}

			$results = $query
				->set_client( $this->client, $this->options->get_ads_id() )
				->get_results();
			$page    = $results->getPage();

			$this->init_report_totals( $args['fields'] ?? [] );

			// Iterate only this page (iterateAllElements will iterate all pages).
			foreach ( $page->getIterator() as $row ) {
				$this->add_report_row( $type, $row, $args );
			}

			if ( $page->hasNextPage() ) {
				$this->report_data['next_page'] = $page->getNextPageToken();
			}

			// Sort intervals to generate an ordered graph.
			if ( isset( $this->report_data['intervals'] ) ) {
				ksort( $this->report_data['intervals'] );
			}

			$this->remove_report_indexes( [ 'products', 'campaigns', 'intervals' ] );

			return $this->report_data;
		} catch ( ApiException $e ) {
			do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );

			$errors = $this->get_exception_errors( $e );
			throw new ExceptionWithResponseData(
				/* translators: %s Error message */
				sprintf( __( 'Unable to retrieve report data: %s', 'google-listings-and-ads' ), reset( $errors ) ),
				$this->map_grpc_code_to_http_status_code( $e ),
				null,
				[
					'errors'            => $errors,
					'report_type'       => $type,
					'report_query_args' => $args,
				]
			);
		}
	}

	/**
	 * Add data for a report row.
	 *
	 * @param string       $type Report type (campaigns or products).
	 * @param GoogleAdsRow $row  Report row.
	 * @param array        $args Request arguments.
	 */
	protected function add_report_row( string $type, GoogleAdsRow $row, array $args ) {
		$campaign = $row->getCampaign();
		$segments = $row->getSegments();
		$metrics  = $this->get_report_row_metrics( $row, $args );

		if ( 'products' === $type && $segments ) {
			$product_id = $segments->getProductItemId();
			$this->increase_report_data(
				'products',
				(string) $product_id,
				[
					'id'        => $product_id,
					'name'      => $segments->getProductTitle(),
					'subtotals' => $metrics,
				]
			);
		}

		if ( 'campaigns' === $type && $campaign ) {
			$campaign_id   = $campaign->getId();
			$campaign_name = $campaign->getName();
			$campaign_type = CampaignType::label( $campaign->getAdvertisingChannelType() );
			$is_converted  = $this->has_converted && CampaignType::PERFORMANCE_MAX !== $campaign_type;

			$this->increase_report_data(
				'campaigns',
				(string) $campaign_id,
				[
					'id'          => $campaign_id,
					'name'        => $campaign_name,
					'status'      => CampaignStatus::label( $campaign->getStatus() ),
					'isConverted' => $is_converted,
					'subtotals'   => $metrics,
				]
			);
		}

		if ( $segments && ! empty( $args['interval'] ) ) {
			$interval = $this->get_segment_interval( $args['interval'], $segments );

			$this->increase_report_data(
				'intervals',
				$interval,
				[
					'interval'  => $interval,
					'subtotals' => $metrics,
				]
			);
		}

		$this->increase_report_totals( $metrics );
	}

	/**
	 * Get metrics for a report row.
	 *
	 * @param GoogleAdsRow $row  Report row.
	 * @param array        $args Request arguments.
	 *
	 * @return array
	 */
	protected function get_report_row_metrics( GoogleAdsRow $row, array $args ): array {
		$metrics = $row->getMetrics();

		if ( ! $metrics || empty( $args['fields'] ) ) {
			return [];
		}

		$data = [];
		foreach ( $args['fields'] as $field ) {
			switch ( $field ) {
				case 'clicks':
					$data['clicks'] = $metrics->getClicks();
					break;
				case 'impressions':
					$data['impressions'] = $metrics->getImpressions();
					break;
				case 'spend':
					$data['spend'] = $this->from_micro( $metrics->getCostMicros() );
					break;
				case 'sales':
					$data['sales'] = $metrics->getConversionsValue();
					break;
				case 'conversions':
					$data['conversions'] = $metrics->getConversions();
					break;
			}
		}

		return $data;
	}

	/**
	 * Get a unique interval index based on the segments data.
	 *
	 * Types:
	 * day     = <year>-<month>-<day>
	 * week    = <year>-<weeknumber>
	 * month   = <year>-<month>
	 * quarter = <year>-<quarter>
	 * year    = <year>
	 *
	 * @param string   $interval Interval type.
	 * @param Segments $segments Report segment data.
	 *
	 * @return string
	 * @throws InvalidValue When invalid interval type is given.
	 */
	protected function get_segment_interval( string $interval, Segments $segments ): string {
		switch ( $interval ) {
			case 'day':
				$date = new DateTime( $segments->getDate() );
				break;
			case 'week':
				$date = new DateTime( $segments->getWeek() );
				break;
			case 'month':
				$date = new DateTime( $segments->getMonth() );
				break;
			case 'quarter':
				$date = new DateTime( $segments->getQuarter() );
				break;
			case 'year':
				$date = DateTime::createFromFormat( 'Y', (string) $segments->getYear() );
				break;
			default:
				throw InvalidValue::not_in_allowed_list( $interval, [ 'day', 'week', 'month', 'quarter', 'year' ] );
		}
		return TimeInterval::time_interval_id( $interval, $date );
	}
}
AssetFieldType.php000064400000007421151545120240010145 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Google\Ads\GoogleAds\V18\Enums\AssetFieldTypeEnum\AssetFieldType as AdsAssetFieldType;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;
use UnexpectedValueException;


/**
 * Mapping between Google and internal AssetFieldTypes
 * https://developers.google.com/google-ads/api/reference/rpc/v18/AssetFieldTypeEnum.AssetFieldType
 *
 * @since 2.4.0
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class AssetFieldType extends StatusMapping {

	/**
	 * Not specified.
	 *
	 * @var string
	 */
	public const UNSPECIFIED = 'unspecified';

	/**
	 * Used for return value only. Represents value unknown in this version.
	 *
	 * @var string
	 */
	public const UNKNOWN = 'unknown';

	/**
	 * The asset is linked for use as a headline.
	 *
	 * @var string
	 */
	public const HEADLINE = 'headline';

	/**
	 * The asset is linked for use as a description.
	 *
	 * @var string
	 */
	public const DESCRIPTION = 'description';

	/**
	 * The asset is linked for use as a marketing image.
	 *
	 * @var string
	 */
	public const MARKETING_IMAGE = 'marketing_image';

	/**
	 * The asset is linked for use as a long headline.
	 *
	 * @var string
	 */
	public const LONG_HEADLINE = 'long_headline';

	/**
	 * The asset is linked for use as a business name.
	 *
	 * @var string
	 */
	public const BUSINESS_NAME = 'business_name';

	/**
	 * The asset is linked for use as a square marketing image.
	 *
	 * @var string
	 */
	public const SQUARE_MARKETING_IMAGE = 'square_marketing_image';

	/**
	 * The asset is linked for use as a logo.
	 *
	 * @var string
	 */
	public const LOGO = 'logo';

	/**
	 * The asset is linked for use to select a call-to-action.
	 *
	 * @var string
	 */
	public const CALL_TO_ACTION_SELECTION = 'call_to_action_selection';

	/**
	 * The asset is linked for use as a portrait marketing image.
	 *
	 * @var string
	 */
	public const PORTRAIT_MARKETING_IMAGE = 'portrait_marketing_image';

	/**
	 * The asset is linked for use as a landscape logo.
	 *
	 * @var string
	 */
	public const LANDSCAPE_LOGO = 'landscape_logo';

	/**
	 * The asset is linked for use as a YouTube video.
	 *
	 * @var string
	 */
	public const YOUTUBE_VIDEO = 'youtube_video';

	/**
	 * The asset is linked for use as a media bundle.
	 *
	 * @var string
	 */
	public const MEDIA_BUNDLE = 'media_bundle';

	/**
	 * Mapping between status number and it's label.
	 *
	 * @var string
	 */
	protected const MAPPING = [
		AdsAssetFieldType::UNSPECIFIED              => self::UNSPECIFIED,
		AdsAssetFieldType::UNKNOWN                  => self::UNKNOWN,
		AdsAssetFieldType::HEADLINE                 => self::HEADLINE,
		AdsAssetFieldType::DESCRIPTION              => self::DESCRIPTION,
		AdsAssetFieldType::MARKETING_IMAGE          => self::MARKETING_IMAGE,
		AdsAssetFieldType::LONG_HEADLINE            => self::LONG_HEADLINE,
		AdsAssetFieldType::BUSINESS_NAME            => self::BUSINESS_NAME,
		AdsAssetFieldType::SQUARE_MARKETING_IMAGE   => self::SQUARE_MARKETING_IMAGE,
		AdsAssetFieldType::LOGO                     => self::LOGO,
		AdsAssetFieldType::CALL_TO_ACTION_SELECTION => self::CALL_TO_ACTION_SELECTION,
		AdsAssetFieldType::PORTRAIT_MARKETING_IMAGE => self::PORTRAIT_MARKETING_IMAGE,
		AdsAssetFieldType::LANDSCAPE_LOGO           => self::LANDSCAPE_LOGO,
		AdsAssetFieldType::YOUTUBE_VIDEO            => self::YOUTUBE_VIDEO,
		AdsAssetFieldType::MEDIA_BUNDLE             => self::MEDIA_BUNDLE,

	];

	/**
	 * Get the enum name for the given label.
	 *
	 * @param string $label The label.
	 * @return string The enum name.
	 *
	 * @throws UnexpectedValueException If the label does not exist.
	 */
	public static function name( string $label ): string {
		return AdsAssetFieldType::name( self::number( $label ) );
	}
}
BillingSetupStatus.php000064400000002477151545120240011073 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Google\Ads\GoogleAds\V18\Enums\BillingSetupStatusEnum\BillingSetupStatus as AdsBillingSetupStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;

/**
 * Mapping between Google and internal BillingSetupStatus
 * https://developers.google.com/google-ads/api/reference/rpc/v18/BillingSetupStatusEnum.BillingSetupStatus
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class BillingSetupStatus extends StatusMapping {

	/**
	 * Used for return value only. Represents value unknown in this version.
	 *
	 * @var string
	 */
	public const UNKNOWN = 'unknown';

	/**
	 * The billing setup is pending approval.
	 *
	 * @var string
	 */
	public const PENDING = 'pending';

	/**
	 * The billing setup has been approved.
	 *
	 * @var string
	 */
	public const APPROVED = 'approved';

	/**
	 * The billing setup was cancelled by the user prior to approval.
	 *
	 * @var string
	 */
	public const CANCELLED = 'cancelled';

	/**
	 * Mapping between status number and it's label.
	 *
	 * @var string
	 */
	protected const MAPPING = [
		AdsBillingSetupStatus::PENDING   => self::PENDING,
		AdsBillingSetupStatus::APPROVED  => self::APPROVED,
		AdsBillingSetupStatus::CANCELLED => self::CANCELLED,
	];
}
CallToActionType.php000064400000004703151545120240010436 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Google\Ads\GoogleAds\V18\Enums\CallToActionTypeEnum\CallToActionType as AdsCallToActionType;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;


/**
 * Mapping between Google and internal CallToActionType
 * https://developers.google.com/google-ads/api/reference/rpc/v18/CallToActionTypeEnum.CallToActionType
 *
 * @since 2.4.0
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class CallToActionType extends StatusMapping {

	/**
	 * Not specified.
	 *
	 * @var string
	 */
	public const UNSPECIFIED = 'unspecified';

	/**
	 * Represents value unknown in this version.
	 *
	 * @var string
	 */
	public const UNKNOWN = 'unknown';

	/**
	 * The call to action type is learn more.
	 *
	 * @var string
	 */
	public const LEARN_MORE = 'learn_more';

	/**
	 * The call to action type is get quote.
	 *
	 * @var string
	 */
	public const GET_QUOTE = 'get_quote';

	/**
	 * The call to action type is apply now.
	 *
	 * @var string
	 */
	public const APPLY_NOW = 'apply_now';

	/**
	 * The call to action type is sign up.
	 *
	 * @var string
	 */
	public const SIGN_UP = 'sign_up';

	/**
	 * The call to action type is contact us.
	 *
	 * @var string
	 */
	public const CONTACT_US = 'contact_us';

	/**
	 * The call to action type is subscribe.
	 *
	 * @var string
	 */
	public const SUBSCRIBE = 'subscribe';

	/**
	 * The call to action type is download.
	 *
	 * @var string
	 */
	public const DOWNLOAD = 'download';

	/**
	 * The call to action type is book now.
	 *
	 * @var string
	 */
	public const BOOK_NOW = 'book_now';

	/**
	 * The call to action type is shop now.
	 *
	 * @var string
	 */
	public const SHOP_NOW = 'shop_now';

	/**
	 * Mapping between status number and it's label.
	 *
	 * @var string
	 */
	protected const MAPPING = [
		AdsCallToActionType::UNSPECIFIED => self::UNSPECIFIED,
		AdsCallToActionType::UNKNOWN     => self::UNKNOWN,
		AdsCallToActionType::LEARN_MORE  => self::LEARN_MORE,
		AdsCallToActionType::GET_QUOTE   => self::GET_QUOTE,
		AdsCallToActionType::APPLY_NOW   => self::APPLY_NOW,
		AdsCallToActionType::SIGN_UP     => self::SIGN_UP,
		AdsCallToActionType::CONTACT_US  => self::CONTACT_US,
		AdsCallToActionType::SUBSCRIBE   => self::SUBSCRIBE,
		AdsCallToActionType::DOWNLOAD    => self::DOWNLOAD,
		AdsCallToActionType::BOOK_NOW    => self::BOOK_NOW,
		AdsCallToActionType::SHOP_NOW    => self::SHOP_NOW,

	];
}
CampaignStatus.php000064400000002162151545120240010200 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Google\Ads\GoogleAds\V18\Enums\CampaignStatusEnum\CampaignStatus as AdsCampaignStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;

/**
 * Mapping between Google and internal CampaignStatus
 * https://developers.google.com/google-ads/api/reference/rpc/v18/CampaignStatusEnum.CampaignStatus
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class CampaignStatus extends StatusMapping {

	/**
	 * Campaign is currently serving ads depending on budget information.
	 *
	 * @var string
	 */
	public const ENABLED = 'enabled';

	/**
	 * Campaign has been paused by the user.
	 *
	 * @var string
	 */
	public const PAUSED = 'paused';

	/**
	 * Campaign has been removed.
	 *
	 * @var string
	 */
	public const REMOVED = 'removed';

	/**
	 * Mapping between status number and it's label.
	 *
	 * @var string
	 */
	protected const MAPPING = [
		AdsCampaignStatus::ENABLED => self::ENABLED,
		AdsCampaignStatus::PAUSED  => self::PAUSED,
		AdsCampaignStatus::REMOVED => self::REMOVED,
	];
}
CampaignType.php000064400000004752151545120240007645 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Google\Ads\GoogleAds\V18\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType as AdsCampaignType;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;

/**
 * Mapping between Google and internal CampaignTypes
 * https://developers.google.com/google-ads/api/reference/rpc/v18/AdvertisingChannelTypeEnum.AdvertisingChannelType
 *
 * @since 1.12.2
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class CampaignType extends StatusMapping {

	/**
	 * Not specified.
	 *
	 * @var string
	 */
	public const UNSPECIFIED = 'unspecified';

	/**
	 * Used for return value only. Represents value unknown in this version.
	 *
	 * @var string
	 */
	public const UNKNOWN = 'unknown';

	/**
	 * Search Network. Includes display bundled, and Search+ campaigns.
	 *
	 * @var string
	 */
	public const SEARCH = 'search';

	/**
	 * Google Display Network only.
	 *
	 * @var string
	 */
	public const DISPLAY = 'display';

	/**
	 * Shopping campaigns serve on the shopping property and on google.com search results.
	 *
	 * @var string
	 */
	public const SHOPPING = 'shopping';

	/**
	 * Hotel Ads campaigns.
	 *
	 * @var string
	 */
	public const HOTEL = 'hotel';

	/**
	 * Video campaigns.
	 *
	 * @var string
	 */
	public const VIDEO = 'video';

	/**
	 * App Campaigns, and App Campaigns for Engagement, that run across multiple channels.
	 *
	 * @var string
	 */
	public const MULTI_CHANNEL = 'multi_channel';

	/**
	 * Local ads campaigns.
	 *
	 * @var string
	 */
	public const LOCAL = 'local';

	/**
	 * Smart campaigns.
	 *
	 * @var string
	 */
	public const SMART = 'smart';

	/**
	 * Performance Max campaigns.
	 *
	 * @var string
	 */
	public const PERFORMANCE_MAX = 'performance_max';

	/**
	 * Mapping between status number and it's label.
	 *
	 * @var string
	 */
	protected const MAPPING = [
		AdsCampaignType::UNSPECIFIED     => self::UNSPECIFIED,
		AdsCampaignType::UNKNOWN         => self::UNKNOWN,
		AdsCampaignType::SEARCH          => self::SEARCH,
		AdsCampaignType::DISPLAY         => self::DISPLAY,
		AdsCampaignType::SHOPPING        => self::SHOPPING,
		AdsCampaignType::HOTEL           => self::HOTEL,
		AdsCampaignType::VIDEO           => self::VIDEO,
		AdsCampaignType::MULTI_CHANNEL   => self::MULTI_CHANNEL,
		AdsCampaignType::LOCAL           => self::LOCAL,
		AdsCampaignType::SMART           => self::SMART,
		AdsCampaignType::PERFORMANCE_MAX => self::PERFORMANCE_MAX,
	];
}
Connection.php000064400000012736151545120240007364 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\GuzzleHttp\Client;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Http\Client\ClientExceptionInterface;
use Exception;

defined( 'ABSPATH' ) || exit;

/**
 * Class Connection
 *
 * ContainerAware used to access:
 * - Ads
 * - Client
 * - Merchant
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class Connection implements ContainerAwareInterface, OptionsAwareInterface {

	use ContainerAwareTrait;
	use ExceptionTrait;
	use OptionsAwareTrait;

	/**
	 * Get the connection URL for performing a connection redirect.
	 *
	 * @param string $return_url The return URL.
	 * @param string $login_hint Suggested Google account to use for connection.
	 *
	 * @return string
	 * @throws Exception When a ClientException is caught or the response doesn't contain the oauthUrl.
	 */
	public function connect( string $return_url, string $login_hint = '' ): string {
		try {

			$post_body = [ 'returnUrl' => $return_url ];
			if ( ! empty( $login_hint ) ) {
				$post_body['loginHint'] = $login_hint;
			}

			/** @var Client $client */
			$client = $this->container->get( Client::class );
			$result = $client->post(
				$this->get_connection_url(),
				[
					'body' => wp_json_encode( $post_body ),
				]
			);

			$response = json_decode( $result->getBody()->getContents(), true );
			if ( 200 === $result->getStatusCode() && ! empty( $response['oauthUrl'] ) ) {
				$this->options->update( OptionsInterface::GOOGLE_CONNECTED, true );

				return $response['oauthUrl'];
			}

			do_action( 'woocommerce_gla_guzzle_invalid_response', $response, __METHOD__ );

			throw new Exception( __( 'Unable to connect Google account', 'google-listings-and-ads' ) );
		} catch ( ClientExceptionInterface $e ) {
			do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );

			throw new Exception( __( 'Unable to connect Google account', 'google-listings-and-ads' ) );
		}
	}

	/**
	 * Disconnect from the Google account.
	 *
	 * @return string
	 */
	public function disconnect(): string {
		try {
			/** @var Client $client */
			$client = $this->container->get( Client::class );
			$result = $client->delete( $this->get_connection_url() );

			$this->options->update( OptionsInterface::GOOGLE_CONNECTED, false );

			return $result->getBody()->getContents();
		} catch ( ClientExceptionInterface $e ) {
			do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );

			return $e->getMessage();
		} catch ( Exception $e ) {
			do_action( 'woocommerce_gla_exception', $e, __METHOD__ );

			return $e->getMessage();
		}
	}

	/**
	 * Get the status of the connection.
	 *
	 * @return array
	 * @throws Exception When a ClientException is caught or the response contains an error.
	 */
	public function get_status(): array {
		try {
			/** @var Client $client */
			$client   = $this->container->get( Client::class );
			$result   = $client->get( $this->get_connection_url() );
			$response = json_decode( $result->getBody()->getContents(), true );

			if ( 200 === $result->getStatusCode() ) {
				$connected = isset( $response['status'] ) && 'connected' === $response['status'];
				$this->options->update( OptionsInterface::GOOGLE_CONNECTED, $connected );

				return $response;
			}

			do_action( 'woocommerce_gla_guzzle_invalid_response', $response, __METHOD__ );

			$message = $response['message'] ?? __( 'Invalid response when retrieving status', 'google-listings-and-ads' );
			throw new Exception( $message, $result->getStatusCode() );
		} catch ( ClientExceptionInterface $e ) {
			do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );

			throw new Exception( $this->client_exception_message( $e, __( 'Error retrieving status', 'google-listings-and-ads' ) ) );
		}
	}

	/**
	 * Get the reconnect status which checks:
	 * - The Google account is connected
	 * - We have access to the connected MC account
	 * - We have access to the connected Ads account
	 *
	 * @return array
	 * @throws Exception When a ClientException is caught or the response contains an error.
	 */
	public function get_reconnect_status(): array {
		$status = $this->get_status();
		$email  = $status['email'] ?? '';

		if ( ! isset( $status['status'] ) || 'connected' !== $status['status'] ) {
			return $status;
		}

		$merchant_id = $this->options->get_merchant_id();
		if ( $merchant_id ) {
			/** @var Merchant $merchant */
			$merchant = $this->container->get( Merchant::class );

			$status['merchant_account'] = $merchant_id;
			$status['merchant_access']  = $merchant->has_access( $email ) ? 'yes' : 'no';
		}

		$ads_id = $this->options->get_ads_id();
		if ( $ads_id ) {
			/** @var Ads $ads */
			$ads = $this->container->get( Ads::class );

			$status['ads_account'] = $ads_id;
			$status['ads_access']  = $ads->has_access( $email ) ? 'yes' : 'no';
		}

		return $status;
	}

	/**
	 * Get the Google connection URL.
	 *
	 * @return string
	 */
	protected function get_connection_url(): string {
		return "{$this->container->get( 'connect_server_root' )}google/connection/google-mc";
	}
}
ExceptionTrait.php000064400000013627151545120240010227 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\Exception as GoogleServiceException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\GuzzleHttp\Exception\BadResponseException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Http\Client\ClientExceptionInterface;
use Google\ApiCore\ApiException;
use Google\Rpc\Code;
use Exception;

/**
 * Trait ExceptionTrait
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
trait ExceptionTrait {

	/**
	 * Check if the ApiException contains a specific error.
	 *
	 * @param ApiException $exception  Exception to check.
	 * @param string       $error_code Error code we are checking.
	 *
	 * @return bool
	 */
	protected function has_api_exception_error( ApiException $exception, string $error_code ): bool {
		$meta = $exception->getMetadata();
		if ( empty( $meta ) || ! is_array( $meta ) ) {
			return false;
		}

		foreach ( $meta as $data ) {
			if ( empty( $data['errors'] ) || ! is_array( $data['errors'] ) ) {
				continue;
			}

			foreach ( $data['errors'] as $error ) {
				if ( in_array( $error_code, $error['errorCode'], true ) ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Returns a list of detailed errors from an exception instance that extends ApiException
	 * or GoogleServiceException. Other Exception instances will also be converted to an array
	 * in the same structure.
	 *
	 * The following are the example sources of ApiException, GoogleServiceException,
	 * and other Exception in order:
	 *
	 * @link https://github.com/googleads/google-ads-php/blob/v25.0.0/src/Google/Ads/GoogleAds/V18/Services/Client/CustomerServiceClient.php#L303
	 * @link https://github.com/googleapis/google-api-php-client/blob/v2.16.1/src/Http/REST.php#L119-L135
	 * @link https://github.com/googleapis/google-api-php-client/blob/v2.16.1/src/Service/Resource.php#L86-L174
	 *
	 * @param ApiException|GoogleServiceException|Exception $exception Exception to check.
	 *
	 * @return array
	 */
	protected function get_exception_errors( Exception $exception ): array {
		if ( $exception instanceof ApiException ) {
			return $this->get_api_exception_errors( $exception );
		}

		if ( $exception instanceof GoogleServiceException ) {
			return $this->get_google_service_exception_errors( $exception );
		}

		// Fallback for handling other Exception instances.
		$code = $exception->getCode();
		return [ $code => $exception->getMessage() ];
	}

	/**
	 * Returns a list of detailed errors from an ApiException.
	 * If no errors are found the default Exception message is returned.
	 *
	 * @param ApiException $exception Exception to check.
	 *
	 * @return array
	 */
	private function get_api_exception_errors( ApiException $exception ): array {
		$errors = [];
		$meta   = $exception->getMetadata();

		if ( is_array( $meta ) ) {
			foreach ( $meta as $data ) {
				if ( empty( $data['errors'] ) || ! is_array( $data['errors'] ) ) {
					continue;
				}

				foreach ( $data['errors'] as $error ) {
					if ( empty( $error['message'] ) ) {
						continue;
					}

					if ( ! empty( $error['errorCode'] ) && is_array( $error['errorCode'] ) ) {
						$error_code = reset( $error['errorCode'] );
					} else {
						$error_code = 'ERROR';
					}

					$errors[ $error_code ] = $error['message'];
				}
			}
		}

		$errors[ $exception->getStatus() ] = $exception->getBasicMessage();
		return $errors;
	}

	/**
	 * Returns a list of detailed errors from a GoogleServiceException.
	 *
	 * @param GoogleServiceException $exception Exception to check.
	 *
	 * @return array
	 */
	private function get_google_service_exception_errors( GoogleServiceException $exception ): array {
		$errors = [];

		if ( ! is_null( $exception->getErrors() ) ) {
			foreach ( $exception->getErrors() as $error ) {
				if ( ! isset( $error['message'] ) ) {
					continue;
				}

				$error_code            = $error['reason'] ?? 'ERROR';
				$errors[ $error_code ] = $error['message'];
			}
		}

		if ( 0 === count( $errors ) ) {
			$errors['unknown'] = __( 'An unknown error occurred in the Shopping Content Service.', 'google-listings-and-ads' );
		}

		return $errors;
	}

	/**
	 * Get an error message from a ClientException.
	 *
	 * @param ClientExceptionInterface $exception     Exception to check.
	 * @param string                   $default_error Default error message.
	 *
	 * @return string
	 */
	protected function client_exception_message( ClientExceptionInterface $exception, string $default_error ): string {
		if ( $exception instanceof BadResponseException ) {
			$response = json_decode( $exception->getResponse()->getBody()->getContents(), true );
			$message  = $response['message'] ?? false;
			return $message ? $default_error . ': ' . $message : $default_error;
		}
		return $default_error;
	}

	/**
	 * Map a gRPC code to HTTP status code.
	 *
	 * @param ApiException $exception Exception to check.
	 *
	 * @return int The HTTP status code.
	 *
	 * @see Google\Rpc\Code for the list of gRPC codes.
	 */
	protected function map_grpc_code_to_http_status_code( ApiException $exception ) {
		switch ( $exception->getCode() ) {
			case Code::OK:
				return 200;

			case Code::CANCELLED:
				return 499;

			case Code::UNKNOWN:
				return 500;

			case Code::INVALID_ARGUMENT:
				return 400;

			case Code::DEADLINE_EXCEEDED:
				return 504;

			case Code::NOT_FOUND:
				return 404;

			case Code::ALREADY_EXISTS:
				return 409;

			case Code::PERMISSION_DENIED:
				return 403;

			case Code::UNAUTHENTICATED:
				return 401;

			case Code::RESOURCE_EXHAUSTED:
				return 429;

			case Code::FAILED_PRECONDITION:
				return 400;

			case Code::ABORTED:
				return 409;

			case Code::OUT_OF_RANGE:
				return 400;

			case Code::UNIMPLEMENTED:
				return 501;

			case Code::INTERNAL:
				return 500;

			case Code::UNAVAILABLE:
				return 503;

			case Code::DATA_LOSS:
				return 500;

			default:
				return 500;
		}
	}
}
LocationIDTrait.php000064400000003341151545120240010246 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidState;

defined( 'ABSPATH' ) || exit;

/**
 * Trait LocationIDTrait
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
trait LocationIDTrait {

	/**
	 * Mapping data for location IDs.
	 *
	 * @see https://developers.google.com/adwords/api/docs/appendix/geotargeting
	 *
	 * @var string[]
	 */
	protected $mapping = [
		'AL' => 21133,
		'AK' => 21132,
		'AZ' => 21136,
		'AR' => 21135,
		'CA' => 21137,
		'CO' => 21138,
		'CT' => 21139,
		'DE' => 21141,
		'DC' => 21140,
		'FL' => 21142,
		'GA' => 21143,
		'HI' => 21144,
		'ID' => 21146,
		'IL' => 21147,
		'IN' => 21148,
		'IA' => 21145,
		'KS' => 21149,
		'KY' => 21150,
		'LA' => 21151,
		'ME' => 21154,
		'MD' => 21153,
		'MA' => 21152,
		'MI' => 21155,
		'MN' => 21156,
		'MS' => 21158,
		'MO' => 21157,
		'MT' => 21159,
		'NE' => 21162,
		'NV' => 21166,
		'NH' => 21163,
		'NJ' => 21164,
		'NM' => 21165,
		'NY' => 21167,
		'NC' => 21160,
		'ND' => 21161,
		'OH' => 21168,
		'OK' => 21169,
		'OR' => 21170,
		'PA' => 21171,
		'RI' => 21172,
		'SC' => 21173,
		'SD' => 21174,
		'TN' => 21175,
		'TX' => 21176,
		'UT' => 21177,
		'VT' => 21179,
		'VA' => 21178,
		'WA' => 21180,
		'WV' => 21183,
		'WI' => 21182,
		'WY' => 21184,
	];

	/**
	 * Get the location ID for a given state.
	 *
	 * @param string $state
	 *
	 * @return int
	 * @throws InvalidState When the provided state is not found in the mapping.
	 */
	protected function get_state_id( string $state ): int {
		if ( ! array_key_exists( $state, $this->mapping ) ) {
			throw InvalidState::from_state( $state );
		}

		return $this->mapping[ $state ];
	}
}
Merchant.php000064400000035142151545120240007022 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Exception as GoogleException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\Exception as GoogleServiceException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Account;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountAdsLink;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductstatusesCustomBatchResponse;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductstatusesCustomBatchRequest;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Product;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\RequestPhoneVerificationRequest;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\RequestReviewFreeListingsRequest;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\RequestReviewShoppingAdsRequest;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\VerifyPhoneNumberRequest;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Http\Message\ResponseInterface;
use Exception;

defined( 'ABSPATH' ) || exit;

/**
 * Class Merchant
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class Merchant implements OptionsAwareInterface {

	use ExceptionTrait;
	use OptionsAwareTrait;

	/**
	 * The shopping service.
	 *
	 * @var ShoppingContent
	 */
	protected $service;

	/**
	 * Merchant constructor.
	 *
	 * @param ShoppingContent $service
	 */
	public function __construct( ShoppingContent $service ) {
		$this->service = $service;
	}

	/**
	 * @return Product[]
	 */
	public function get_products(): array {
		$products = $this->service->products->listProducts( $this->options->get_merchant_id() );
		$return   = [];

		while ( ! empty( $products->getResources() ) ) {

			foreach ( $products->getResources() as $product ) {
				$return[] = $product;
			}

			if ( empty( $products->getNextPageToken() ) ) {
				break;
			}

			$products = $this->service->products->listProducts(
				$this->options->get_merchant_id(),
				[ 'pageToken' => $products->getNextPageToken() ]
			);
		}

		return $return;
	}


	/**
	 * Claim a website for the user's Merchant Center account.
	 *
	 * @param bool $overwrite Whether to include the overwrite directive.
	 * @return bool
	 * @throws Exception If the website claim fails.
	 */
	public function claimwebsite( bool $overwrite = false ): bool {
		try {
			$id     = $this->options->get_merchant_id();
			$params = $overwrite ? [ 'overwrite' => true ] : [];
			$this->service->accounts->claimwebsite( $id, $id, $params );
			do_action( 'woocommerce_gla_site_claim_success', [ 'details' => 'google_proxy' ] );
		} catch ( GoogleException $e ) {
			do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
			do_action( 'woocommerce_gla_site_claim_failure', [ 'details' => 'google_proxy' ] );

			$error_message = __( 'Unable to claim website.', 'google-listings-and-ads' );
			if ( 403 === $e->getCode() ) {
				$error_message = __( 'Website already claimed, use overwrite to complete the process.', 'google-listings-and-ads' );
			}
			throw new Exception( $error_message, $e->getCode() );
		}
		return true;
	}

	/**
	 * Request verification code to start phone verification.
	 *
	 * @param string $region_code         Two-letter country code (ISO 3166-1 alpha-2) for the phone number, for
	 *                                    example CA for Canadian numbers.
	 * @param string $phone_number        Phone number to be verified.
	 * @param string $verification_method Verification method to receive verification code.
	 * @param string $language_code       Language code IETF BCP 47 syntax (for example, en-US). Language code is used
	 *                                    to provide localized SMS and PHONE_CALL. Default language used is en-US if
	 *                                    not provided.
	 *
	 * @return string The verification ID to use in subsequent calls to
	 *                `Merchant::verify_phone_number`.
	 *
	 * @throws GoogleServiceException If there are any Google API errors.
	 *
	 * @see https://tools.ietf.org/html/bcp47 IETF BCP 47 language codes.
	 * @see https://wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements ISO 3166-1 alpha-2
	 *      officially assigned codes.
	 *
	 * @since 1.5.0
	 */
	public function request_phone_verification( string $region_code, string $phone_number, string $verification_method, string $language_code = 'en-US' ): string {
		$merchant_id = $this->options->get_merchant_id();
		$request     = new RequestPhoneVerificationRequest(
			[
				'phoneRegionCode'         => $region_code,
				'phoneNumber'             => $phone_number,
				'phoneVerificationMethod' => $verification_method,
				'languageCode'            => $language_code,
			]
		);

		try {
			return $this->service->accounts->requestphoneverification( $merchant_id, $merchant_id, $request )->getVerificationId();
		} catch ( GoogleServiceException $e ) {
			do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
			throw $e;
		}
	}

	/**
	 * Validates verification code to verify phone number for the account.
	 *
	 * @param string $verification_id     The verification ID returned by
	 *                                    `Merchant::request_phone_verification`.
	 * @param string $verification_code   The verification code that was sent to the phone number for validation.
	 * @param string $verification_method Verification method used to receive verification code.
	 *
	 * @return string Verified phone number if verification is successful.
	 *
	 * @throws GoogleServiceException If there are any Google API errors.
	 *
	 * @since 1.5.0
	 */
	public function verify_phone_number( string $verification_id, string $verification_code, string $verification_method ): string {
		$merchant_id = $this->options->get_merchant_id();
		$request     = new VerifyPhoneNumberRequest(
			[
				'verificationId'          => $verification_id,
				'verificationCode'        => $verification_code,
				'phoneVerificationMethod' => $verification_method,
			]
		);

		try {
			return $this->service->accounts->verifyphonenumber( $merchant_id, $merchant_id, $request )->getVerifiedPhoneNumber();
		} catch ( GoogleServiceException $e ) {
			do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
			throw $e;
		}
	}

	/**
	 * Retrieve the user's Merchant Center account information.
	 *
	 * @param int $id Optional - the Merchant Center account to retrieve
	 *
	 * @return Account The user's Merchant Center account.
	 * @throws ExceptionWithResponseData If the account can't be retrieved.
	 */
	public function get_account( int $id = 0 ): Account {
		$id = $id ?: $this->options->get_merchant_id();

		try {
			$mc_account = $this->service->accounts->get( $id, $id );
		} catch ( GoogleException $e ) {
			do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );

			$errors = $this->get_exception_errors( $e );

			throw new ExceptionWithResponseData(
				/* translators: %s Error message */
				sprintf( __( 'Unable to retrieve Merchant Center account: %s', 'google-listings-and-ads' ), reset( $errors ) ),
				$e->getCode(),
				null,
				[ 'errors' => $errors ]
			);
		}
		return $mc_account;
	}

	/**
	 * Get hash of the site URL we used during onboarding.
	 * If not available in a local option, it's fetched from the Merchant Center account.
	 *
	 * @since 1.13.0
	 * @return string|null
	 */
	public function get_claimed_url_hash(): ?string {
		$claimed_url_hash = $this->options->get( OptionsInterface::CLAIMED_URL_HASH );

		if ( empty( $claimed_url_hash ) && $this->options->get_merchant_id() ) {
			try {
				$account_url = $this->get_account()->getWebsiteUrl();

				if ( empty( $account_url ) || ! $this->get_accountstatus()->getWebsiteClaimed() ) {
					return null;
				}

				$claimed_url_hash = md5( untrailingslashit( $account_url ) );
				$this->options->update( OptionsInterface::CLAIMED_URL_HASH, $claimed_url_hash );
			} catch ( Exception $e ) {
				return null;
			}
		}

		return $claimed_url_hash;
	}

	/**
	 * Retrieve the user's Merchant Center account information.
	 *
	 * @param int $id Optional - the Merchant Center account to retrieve
	 * @return AccountStatus The user's Merchant Center account status.
	 * @throws Exception If the account can't be retrieved.
	 */
	public function get_accountstatus( int $id = 0 ): AccountStatus {
		$id = $id ?: $this->options->get_merchant_id();

		try {
			$mc_account_status = $this->service->accountstatuses->get( $id, $id );
		} catch ( GoogleException $e ) {
			do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
			throw new Exception( __( 'Unable to retrieve Merchant Center account status.', 'google-listings-and-ads' ), $e->getCode() );
		}
		return $mc_account_status;
	}

	/**
	 * Retrieve a batch of Merchant Center Product Statuses using the provided Merchant Center product IDs.
	 *
	 * @since 1.1.0
	 *
	 * @param string[] $mc_product_ids
	 *
	 * @return ProductstatusesCustomBatchResponse;
	 */
	public function get_productstatuses_batch( array $mc_product_ids ): ProductstatusesCustomBatchResponse {
		$merchant_id = $this->options->get_merchant_id();
		$entries     = [];
		foreach ( $mc_product_ids as $index => $id ) {
			$entries[] = [
				'batchId'    => $index + 1,
				'productId'  => $id,
				'method'     => 'GET',
				'merchantId' => $merchant_id,
			];
		}

		// Retrieve batch.
		$request = new ProductstatusesCustomBatchRequest();
		$request->setEntries( $entries );
		return $this->service->productstatuses->custombatch( $request );
	}

	/**
	 * Update the provided Merchant Center account information.
	 *
	 * @param Account $account The Account data to update.
	 *
	 * @return Account The user's Merchant Center account.
	 * @throws ExceptionWithResponseData If the account can't be updated.
	 */
	public function update_account( Account $account ): Account {
		try {
			$account = $this->service->accounts->update( $account->getId(), $account->getId(), $account );
		} catch ( GoogleException $e ) {
			do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );

			$errors = $this->get_exception_errors( $e );

			throw new ExceptionWithResponseData(
				/* translators: %s Error message */
				sprintf( __( 'Unable to update Merchant Center account: %s', 'google-listings-and-ads' ), reset( $errors ) ),
				$e->getCode(),
				null,
				[ 'errors' => $errors ]
			);
		}
		return $account;
	}

	/**
	 * Link a Google Ads ID to this Merchant account.
	 *
	 * @param int $ads_id Google Ads ID to link.
	 *
	 * @return bool True if the link invitation is waiting for acceptance. False if the link is already active.
	 * @throws ExceptionWithResponseData When unable to retrieve or update account data.
	 */
	public function link_ads_id( int $ads_id ): bool {
		$account   = $this->get_account();
		$ads_links = $account->getAdsLinks() ?? [];

		// Stop early if we already have a link setup.
		foreach ( $ads_links as $link ) {
			if ( $ads_id === absint( $link->getAdsId() ) ) {
				return $link->getStatus() !== 'active';
			}
		}

		$link = new AccountAdsLink();
		$link->setAdsId( $ads_id );
		$link->setStatus( 'active' );
		$account->setAdsLinks( array_merge( $ads_links, [ $link ] ) );
		$this->update_account( $account );

		return true;
	}

	/**
	 * Check if we have access to the merchant account.
	 *
	 * @param string $email Email address of the connected account.
	 *
	 * @return bool
	 */
	public function has_access( string $email ): bool {
		$id = $this->options->get_merchant_id();

		try {
			$account = $this->service->accounts->get( $id, $id );

			foreach ( $account->getUsers() as $user ) {
				if ( $email === $user->getEmailAddress() && $user->getAdmin() ) {
					return true;
				}
			}
		} catch ( GoogleException $e ) {
			do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
		}

		return false;
	}

	/**
	 * Update the Merchant Center ID to use for requests.
	 *
	 * @param int $id  Merchant ID number.
	 *
	 * @return bool
	 */
	public function update_merchant_id( int $id ): bool {
		return $this->options->update( OptionsInterface::MERCHANT_ID, $id );
	}

	/**
	 * Get the review status for an MC account
	 *
	 * @since 2.7.1
	 *
	 * @return array An array with the status for freeListingsProgram and shoppingAdsProgram
	 * @throws Exception When an exception happens in the Google API.
	 */
	public function get_account_review_status() {
		try {
			$id = $this->options->get_merchant_id();
			return [
				'freeListingsProgram' => $this->service->freelistingsprogram->get( $id ),
				'shoppingAdsProgram'  => $this->service->shoppingadsprogram->get( $id ),
			];
		} catch ( GoogleException $e ) {
			do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
			throw new Exception( $e->getMessage(), $e->getCode() );
		}
	}

	/**
	 * Request a review for an MC account
	 *
	 * @since 2.7.1
	 *
	 * @param string $region_code The region code to request the review
	 * @param array  $types The types of programs to request the review
	 *
	 * @return ResponseInterface The Google API response
	 * @throws Exception When the request review produces an exception in the Google side or when
	 * the programs are not supported.
	 */
	public function account_request_review( $region_code, $types ) {
		try {
			$id = $this->options->get_merchant_id();

			if ( in_array( 'freelistingsprogram', $types, true ) ) {
				$request = new RequestReviewFreeListingsRequest();
				$request->setRegionCode( $region_code );
				return $this->service->freelistingsprogram->requestreview( $id, $request );
			} elseif ( in_array( 'shoppingadsprogram', $types, true ) ) {
				$request = new RequestReviewShoppingAdsRequest();
				$request->setRegionCode( $region_code );
				return $this->service->shoppingadsprogram->requestreview( $id, $request );
			} else {
				throw new Exception( 'Program type not supported', 400 );
			}
		} catch ( GoogleException $e ) {
			do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
			throw new Exception( $e->getMessage(), $e->getCode() );
		}
	}
}
MerchantMetrics.php000064400000015644151545120240010356 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsCampaignReportQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsCampaignQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\MerchantFreeListingReportQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\SearchResponse;
use DateTime;
use Exception;
use Google\Ads\GoogleAds\V18\Services\GoogleAdsRow;
use Google\ApiCore\PagedListResponse;

/**
 * Class MerchantMetrics
 *
 * @since   1.7.0
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class MerchantMetrics implements OptionsAwareInterface {

	use OptionsAwareTrait;

	/**
	 * The Google shopping client.
	 *
	 * @var ShoppingContent
	 */
	protected $shopping_client;

	/**
	 * The Google ads client.
	 *
	 * @var GoogleAdsClient
	 */
	protected $ads_client;

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

	/**
	 * @var TransientsInterface
	 */
	protected $transients;

	protected const MAX_QUERY_START_DATE = '2020-01-01';

	/**
	 * MerchantMetrics constructor.
	 *
	 * @param ShoppingContent     $shopping_client
	 * @param GoogleAdsClient     $ads_client
	 * @param WP                  $wp
	 * @param TransientsInterface $transients
	 */
	public function __construct( ShoppingContent $shopping_client, GoogleAdsClient $ads_client, WP $wp, TransientsInterface $transients ) {
		$this->shopping_client = $shopping_client;
		$this->ads_client      = $ads_client;
		$this->wp              = $wp;
		$this->transients      = $transients;
	}

	/**
	 * Get free listing metrics.
	 *
	 * @return array Of metrics or empty if no metrics were available.
	 *      @type int $clicks Number of free clicks.
	 *      @type int $impressions NUmber of free impressions.
	 *
	 * @throws Exception When unable to get clicks data.
	 */
	public function get_free_listing_metrics(): array {
		if ( ! $this->options->get_merchant_id() ) {
			// Merchant account not set up
			return [];
		}

		// Google API requires a date clause to be set but there doesn't seem to be any limits on how wide the range
		$query = ( new MerchantFreeListingReportQuery( [] ) )
			->set_client( $this->shopping_client, $this->options->get_merchant_id() )
			->where_date_between( self::MAX_QUERY_START_DATE, $this->get_tomorrow() )
			->fields( [ 'clicks', 'impressions' ] );

		/** @var SearchResponse $response */
		$response = $query->get_results();

		if ( empty( $response ) || empty( $response->getResults() ) ) {
			return [];
		}

		$report_row = $response->getResults()[0];

		return [
			'clicks'      => (int) $report_row->getMetrics()->getClicks(),
			'impressions' => (int) $report_row->getMetrics()->getImpressions(),
		];
	}

	/**
	 * Get free listing metrics but cached for 12 hours.
	 *
	 * PLEASE NOTE: These metrics will not be 100% accurate since there is no invalidation apart from the 12 hour refresh.
	 *
	 * @return array Of metrics or empty if no metrics were available.
	 *      @type int $clicks Number of free clicks.
	 *      @type int $impressions NUmber of free impressions.
	 *
	 * @throws Exception When unable to get data.
	 */
	public function get_cached_free_listing_metrics(): array {
		$value = $this->transients->get( TransientsInterface::FREE_LISTING_METRICS );

		if ( $value === null ) {
			$value = $this->get_free_listing_metrics();
			$this->transients->set( TransientsInterface::FREE_LISTING_METRICS, $value, HOUR_IN_SECONDS * 12 );
		}

		return $value;
	}

	/**
	 * Get ads metrics across all campaigns.
	 *
	 * @return array Of metrics or empty if no metrics were available.
	 *
	 * @throws Exception When unable to get data.
	 */
	public function get_ads_metrics(): array {
		if ( ! $this->options->get_ads_id() ) {
			// Ads account not set up
			return [];
		}

		// Google API requires a date clause to be set but there doesn't seem to be any limits on how wide the range
		$query = ( new AdsCampaignReportQuery( [] ) )
			->set_client( $this->ads_client, $this->options->get_ads_id() )
			->where_date_between( self::MAX_QUERY_START_DATE, $this->get_tomorrow() )
			->fields( [ 'clicks', 'conversions', 'impressions' ] );

		/** @var PagedListResponse $response */
		$response = $query->get_results();
		$page     = $response->getPage();

		if ( $page && $page->getIterator()->current() ) {
			/** @var GoogleAdsRow $row */
			$row = $page->getIterator()->current();

			$metrics = $row->getMetrics();
			if ( $metrics ) {
				return [
					'clicks'      => $metrics->getClicks(),
					'conversions' => (int) $metrics->getConversions(),
					'impressions' => $metrics->getImpressions(),
				];
			}
		}

		return [];
	}

	/**
	 * Get ads metrics across all campaigns but cached for 12 hours.
	 *
	 * PLEASE NOTE: These metrics will not be 100% accurate since there is no invalidation apart from the 12 hour refresh.
	 *
	 * @return array Of metrics or empty if no metrics were available.
	 *
	 * @throws Exception When unable to get data.
	 */
	public function get_cached_ads_metrics(): array {
		$value = $this->transients->get( TransientsInterface::ADS_METRICS );

		if ( $value === null ) {
			$value = $this->get_ads_metrics();
			$this->transients->set( TransientsInterface::ADS_METRICS, $value, HOUR_IN_SECONDS * 12 );
		}

		return $value;
	}

	/**
	 * Return amount of active campaigns for the connected Ads account.
	 *
	 * @since 2.5.11
	 *
	 * @return int
	 */
	public function get_campaign_count(): int {
		if ( ! $this->options->get_ads_id() ) {
			return 0;
		}

		$campaign_count = 0;
		$cached_count   = $this->transients->get( TransientsInterface::ADS_CAMPAIGN_COUNT );
		if ( null !== $cached_count ) {
			return (int) $cached_count;
		}

		try {
			$query = ( new AdsCampaignQuery() )->set_client( $this->ads_client, $this->options->get_ads_id() );
			$query->where( 'campaign.status', 'REMOVED', '!=' );

			$campaign_results = $query->get_results();

			// Iterate through all paged results (total results count is not set).
			foreach ( $campaign_results->iterateAllElements() as $row ) {
				++$campaign_count;
			}
		} catch ( Exception $e ) {
			$campaign_count = 0;
		}

		$this->transients->set( TransientsInterface::ADS_CAMPAIGN_COUNT, $campaign_count, HOUR_IN_SECONDS * 12 );
		return $campaign_count;
	}

	/**
	 * Get tomorrow's date to ensure we include any metrics from the current day.
	 *
	 * @return string
	 */
	protected function get_tomorrow(): string {
		return ( new DateTime( 'tomorrow', $this->wp->wp_timezone() ) )->format( 'Y-m-d' );
	}
}
MerchantReport.php000064400000020622151545120240010213 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\Admin\API\Reports\TimeInterval;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\MerchantFreeListingReportQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\MerchantProductReportQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\MerchantProductViewReportQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Exception as GoogleException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ReportRow;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Segments;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\MCStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\ShoppingContentDateTrait;
use DateTime;
use Exception;

/**
 * Trait MerchantReportTrait
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class MerchantReport implements OptionsAwareInterface {

	use OptionsAwareTrait;
	use ReportTrait;
	use ShoppingContentDateTrait;

	/**
	 * The shopping service.
	 *
	 * @var ShoppingContent
	 */
	protected $service;

	/**
	 * Product helper class.
	 *
	 * @var ProductHelper
	 */
	protected $product_helper;

	/**
	 * Merchant Report constructor.
	 *
	 * @param ShoppingContent $service
	 * @param ProductHelper   $product_helper
	 */
	public function __construct( ShoppingContent $service, ProductHelper $product_helper ) {
		$this->service        = $service;
		$this->product_helper = $product_helper;
	}

	/**
	 * Get ProductView Query response.
	 *
	 * @param string|null $next_page_token The next page token.
	 * @return array Associative array with product statuses and the next page token.
	 *
	 * @throws Exception If the product view report data can't be retrieved.
	 */
	public function get_product_view_report( $next_page_token = null ): array {
		$batch_size = apply_filters( 'woocommerce_gla_product_view_report_page_size', 500 );

		try {
			$product_view_data = [
				'statuses'        => [],
				'next_page_token' => null,
			];

			$query = new MerchantProductViewReportQuery(
				[
					'next_page' => $next_page_token,
					'per_page'  => $batch_size,
				]
			);

			$response = $query
			->set_client( $this->service, $this->options->get_merchant_id() )
			->get_results();

			$results = $response->getResults() ?? [];

			foreach ( $results as $row ) {

				/** @var ProductView $product_view  */
				$product_view = $row->getProductView();

				$wc_product_id     = $this->product_helper->get_wc_product_id( $product_view->getId() );
				$mc_product_status = $this->convert_aggregated_status_to_mc_status( $product_view->getAggregatedDestinationStatus() );

				// Skip if the product id does not exist
				if ( ! $wc_product_id ) {
					continue;
				}

				$product_view_data['statuses'][ $wc_product_id ] = [
					'mc_id'           => $product_view->getId(),
					'product_id'      => $wc_product_id,
					'status'          => $mc_product_status,
					'expiration_date' => $this->convert_shopping_content_date( $product_view->getExpirationDate() ),
				];

			}

			$product_view_data['next_page_token'] = $response->getNextPageToken();

			return $product_view_data;
		} catch ( GoogleException $e ) {
			do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
			throw new Exception( __( 'Unable to retrieve Product View Report.', 'google-listings-and-ads' ) . $e->getMessage(), $e->getCode() );
		}
	}

	/**
	 * Convert the product view aggregated status to the MC status.
	 *
	 * @param string $status The aggregated status of the product.
	 *
	 * @return string The MC status.
	 */
	protected function convert_aggregated_status_to_mc_status( string $status ): string {
		switch ( $status ) {
			case 'ELIGIBLE':
				return MCStatus::APPROVED;
			case 'ELIGIBLE_LIMITED':
				return MCStatus::PARTIALLY_APPROVED;
			case 'NOT_ELIGIBLE_OR_DISAPPROVED':
				return MCStatus::DISAPPROVED;
			case 'PENDING':
				return MCStatus::PENDING;
			default:
				return MCStatus::NOT_SYNCED;
		}
	}


	/**
	 * Get report data for free listings.
	 *
	 * @param string $type Report type (free_listings or products).
	 * @param array  $args Query arguments.
	 *
	 * @return array
	 * @throws Exception If the report data can't be retrieved.
	 */
	public function get_report_data( string $type, array $args ): array {
		try {
			if ( 'products' === $type ) {
				$query = new MerchantProductReportQuery( $args );
			} else {
				$query = new MerchantFreeListingReportQuery( $args );
			}

			$results = $query
				->set_client( $this->service, $this->options->get_merchant_id() )
				->get_results();

			$this->init_report_totals( $args['fields'] ?? [] );

			foreach ( $results->getResults() as $row ) {
				$this->add_report_row( $type, $row, $args );
			}

			if ( $results->getNextPageToken() ) {
				$this->report_data['next_page'] = $results->getNextPageToken();
			}

			// Sort intervals to generate an ordered graph.
			if ( isset( $this->report_data['intervals'] ) ) {
				ksort( $this->report_data['intervals'] );
			}

			$this->remove_report_indexes( [ 'products', 'free_listings', 'intervals' ] );

			return $this->report_data;
		} catch ( GoogleException $e ) {
			do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ );
			throw new Exception( __( 'Unable to retrieve report data.', 'google-listings-and-ads' ), $e->getCode() );
		}
	}

	/**
	 * Add data for a report row.
	 *
	 * @param string    $type Report type (free_listings or products).
	 * @param ReportRow $row  Report row.
	 * @param array     $args Request arguments.
	 */
	protected function add_report_row( string $type, ReportRow $row, array $args ) {
		$segments = $row->getSegments();
		$metrics  = $this->get_report_row_metrics( $row, $args );

		if ( 'free_listings' === $type ) {
			$this->increase_report_data(
				'free_listings',
				'free',
				[
					'subtotals' => $metrics,
				]
			);
		}

		if ( 'products' === $type && $segments ) {
			$product_id = $segments->getOfferId();
			$this->increase_report_data(
				'products',
				(string) $product_id,
				[
					'id'        => $product_id,
					'subtotals' => $metrics,
				]
			);

			// Retrieve product title and add to report.
			if ( empty( $this->report_data['products'][ $product_id ]['name'] ) ) {
				$name = $this->product_helper->get_wc_product_title( (string) $product_id );
				$this->report_data['products'][ $product_id ]['name'] = $name;
			}
		}

		if ( $segments && ! empty( $args['interval'] ) ) {
			$interval = $this->get_segment_interval( $args['interval'], $segments );

			$this->increase_report_data(
				'intervals',
				$interval,
				[
					'interval'  => $interval,
					'subtotals' => $metrics,
				]
			);
		}

		$this->increase_report_totals( $metrics );
	}

	/**
	 * Get metrics for a report row.
	 *
	 * @param ReportRow $row  Report row.
	 * @param array     $args Request arguments.
	 *
	 * @return array
	 */
	protected function get_report_row_metrics( ReportRow $row, array $args ): array {
		$metrics = $row->getMetrics();

		if ( ! $metrics || empty( $args['fields'] ) ) {
			return [];
		}

		$data = [];
		foreach ( $args['fields'] as $field ) {
			switch ( $field ) {
				case 'clicks':
					$data['clicks'] = (int) $metrics->getClicks();
					break;
				case 'impressions':
					$data['impressions'] = (int) $metrics->getImpressions();
					break;
			}
		}

		return $data;
	}

	/**
	 * Get a unique interval index based on the segments data.
	 *
	 * Types:
	 * day     = <year>-<month>-<day>
	 *
	 * @param string   $interval Interval type.
	 * @param Segments $segments Report segment data.
	 *
	 * @return string
	 * @throws InvalidValue When invalid interval type is given.
	 */
	protected function get_segment_interval( string $interval, Segments $segments ): string {
		if ( 'day' !== $interval ) {
			throw InvalidValue::not_in_allowed_list( $interval, [ 'day' ] );
		}

		$date = $segments->getDate();
		$date = new DateTime( "{$date->getYear()}-{$date->getMonth()}-{$date->getDay()}" );
		return TimeInterval::time_interval_id( $interval, $date );
	}
}
Middleware.php000064400000045130151545120240007334 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidTerm;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidDomainName;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\DateTimeUtility;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\TosAccepted;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\GuzzleHttp\Client;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Container\ContainerExceptionInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Container\NotFoundExceptionInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Http\Client\ClientExceptionInterface;
use DateTime;
use Exception;

defined( 'ABSPATH' ) || exit;

/**
 * Class Middleware
 *
 * Container used for:
 * - Ads
 * - Client
 * - DateTimeUtility
 * - GoogleHelper
 * - Merchant
 * - WC
 * - WP
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class Middleware implements ContainerAwareInterface, OptionsAwareInterface {

	use ContainerAwareTrait;
	use ExceptionTrait;
	use OptionsAwareTrait;
	use PluginHelper;

	/**
	 * Get all Merchant Accounts associated with the connected account.
	 *
	 * @return array
	 * @throws Exception When an Exception is caught.
	 * @since 1.7.0
	 */
	public function get_merchant_accounts(): array {
		try {
			/** @var Client $client */
			$client   = $this->container->get( Client::class );
			$result   = $client->get( $this->get_manager_url( 'merchant-accounts' ) );
			$response = json_decode( $result->getBody()->getContents(), true );
			$accounts = [];

			if ( 200 === $result->getStatusCode() && is_array( $response ) ) {
				foreach ( $response as $account ) {
					$accounts[] = $account;
				}
			}

			return $accounts;
		} catch ( ClientExceptionInterface $e ) {
			do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );

			throw new Exception(
				$this->client_exception_message( $e, __( 'Error retrieving accounts', 'google-listings-and-ads' ) ),
				$e->getCode()
			);
		}
	}

	/**
	 * Create a new Merchant Center account.
	 *
	 * @return int Created merchant account ID
	 *
	 * @throws Exception When an Exception is caught or we receive an invalid response.
	 */
	public function create_merchant_account(): int {
		$user = wp_get_current_user();
		$tos  = $this->mark_tos_accepted( 'google-mc', $user->user_email );
		if ( ! $tos->accepted() ) {
			throw new Exception( __( 'Unable to log accepted TOS', 'google-listings-and-ads' ) );
		}

		$site_url = esc_url_raw( $this->get_site_url() );
		if ( ! wc_is_valid_url( $site_url ) ) {
			throw new Exception( __( 'Invalid site URL.', 'google-listings-and-ads' ) );
		}

		try {
			return $this->create_merchant_account_request(
				$this->new_account_name(),
				$site_url
			);
		} catch ( InvalidTerm $e ) {
			// Try again with a default account name.
			return $this->create_merchant_account_request(
				$this->default_account_name(),
				$site_url
			);
		}
	}

	/**
	 * Send a request to create a merchant account.
	 *
	 * @param string $name Site name
	 * @param string $site_url Website URL
	 *
	 * @return int Created merchant account ID
	 *
	 * @throws Exception   When an Exception is caught or we receive an invalid response.
	 * @throws InvalidTerm When the account name contains invalid terms.
	 * @throws InvalidDomainName When the site URL ends with an invalid top-level domain.
	 * @since 1.5.0
	 */
	protected function create_merchant_account_request( string $name, string $site_url ): int {
		try {
			/** @var Client $client */
			$client = $this->container->get( Client::class );
			$result = $client->post(
				$this->get_manager_url( 'create-merchant' ),
				[
					'body' => wp_json_encode(
						[
							'name'       => $name,
							'websiteUrl' => $site_url,
						]
					),
				]
			);

			$response = json_decode( $result->getBody()->getContents(), true );

			if ( 200 === $result->getStatusCode() && isset( $response['id'] ) ) {
				$id = absint( $response['id'] );
				$this->container->get( Merchant::class )->update_merchant_id( $id );
				return $id;
			}

			do_action( 'woocommerce_gla_guzzle_invalid_response', $response, __METHOD__ );

			$error = $response['message'] ?? __( 'Invalid response when creating account', 'google-listings-and-ads' );
			throw new Exception( $error, $result->getStatusCode() );
		} catch ( ClientExceptionInterface $e ) {
			$message = $this->client_exception_message( $e, __( 'Error creating account', 'google-listings-and-ads' ) );

			if ( preg_match( '/terms?.* are|is not allowed/', $message ) ) {
				throw InvalidTerm::contains_invalid_terms( $name );
			}

			if ( strpos( $message, 'URL ends with an invalid top-level domain name' ) !== false ) {
				throw InvalidDomainName::create_account_failed_invalid_top_level_domain_name(
					$this->strip_url_protocol(
						esc_url_raw( $this->get_site_url() )
					)
				);
			}

			do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );
			throw new Exception( $message, $e->getCode() );
		}
	}

	/**
	 * Link an existing Merchant Center account.
	 *
	 * @param int $id Existing account ID.
	 *
	 * @return int
	 */
	public function link_merchant_account( int $id ): int {
		$this->container->get( Merchant::class )->update_merchant_id( $id );

		return $id;
	}

	/**
	 * Link Merchant Center account to MCA.
	 *
	 * @return bool
	 * @throws Exception When a ClientException is caught or we receive an invalid response.
	 */
	public function link_merchant_to_mca(): bool {
		try {
			/** @var Client $client */
			$client = $this->container->get( Client::class );
			$result = $client->post(
				$this->get_manager_url( 'link-merchant' ),
				[
					'body' => wp_json_encode(
						[
							'accountId' => $this->options->get_merchant_id(),
						]
					),
				]
			);

			$response = json_decode( $result->getBody()->getContents(), true );

			if ( 200 === $result->getStatusCode() && isset( $response['status'] ) && 'success' === $response['status'] ) {
				return true;
			}

			do_action( 'woocommerce_gla_guzzle_invalid_response', $response, __METHOD__ );

			$error = $response['message'] ?? __( 'Invalid response when linking merchant to MCA', 'google-listings-and-ads' );
			throw new Exception( $error, $result->getStatusCode() );
		} catch ( ClientExceptionInterface $e ) {
			do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );

			throw new Exception(
				$this->client_exception_message( $e, __( 'Error linking merchant to MCA', 'google-listings-and-ads' ) ),
				$e->getCode()
			);
		}
	}

	/**
	 * Claim the website for a MCA.
	 *
	 * @param bool $overwrite To enable claim overwriting.
	 * @return bool
	 * @throws Exception When an Exception is caught or we receive an invalid response.
	 */
	public function claim_merchant_website( bool $overwrite = false ): bool {
		try {
			/** @var Client $client */
			$client = $this->container->get( Client::class );
			$result = $client->post(
				$this->get_manager_url( 'claim-website' ),
				[
					'body' => wp_json_encode(
						[
							'accountId' => $this->options->get_merchant_id(),
							'overwrite' => $overwrite,
						]
					),
				]
			);

			$response = json_decode( $result->getBody()->getContents(), true );

			if ( 200 === $result->getStatusCode() && isset( $response['status'] ) && 'success' === $response['status'] ) {
				do_action( 'woocommerce_gla_site_claim_success', [ 'details' => 'google_manager' ] );
				return true;
			}

			do_action( 'woocommerce_gla_guzzle_invalid_response', $response, __METHOD__ );
			do_action( 'woocommerce_gla_site_claim_failure', [ 'details' => 'google_manager' ] );

			$error = $response['message'] ?? __( 'Invalid response when claiming website', 'google-listings-and-ads' );
			throw new Exception( $error, $result->getStatusCode() );
		} catch ( ClientExceptionInterface $e ) {
			do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );
			do_action( 'woocommerce_gla_site_claim_failure', [ 'details' => 'google_manager' ] );

			throw new Exception(
				$this->client_exception_message( $e, __( 'Error claiming website', 'google-listings-and-ads' ) ),
				$e->getCode()
			);
		}
	}

	/**
	 * Create a new Google Ads account.
	 *
	 * @return array
	 * @throws Exception When a ClientException is caught, unsupported store country, or we receive an invalid response.
	 */
	public function create_ads_account(): array {
		try {
			$country = $this->container->get( WC::class )->get_base_country();

			/** @var GoogleHelper $google_helper */
			$google_helper = $this->container->get( GoogleHelper::class );
			if ( ! $google_helper->is_country_supported( $country ) ) {
				throw new Exception( __( 'Store country is not supported', 'google-listings-and-ads' ) );
			}

			$user = wp_get_current_user();
			$tos  = $this->mark_tos_accepted( 'google-ads', $user->user_email );
			if ( ! $tos->accepted() ) {
				throw new Exception( __( 'Unable to log accepted TOS', 'google-listings-and-ads' ) );
			}

			/** @var Client $client */
			$client = $this->container->get( Client::class );
			$result = $client->post(
				$this->get_manager_url( $country . '/create-customer' ),
				[
					'body' => wp_json_encode(
						[
							'descriptive_name' => $this->new_account_name(),
							'currency_code'    => get_woocommerce_currency(),
							'time_zone'        => $this->get_site_timezone_string(),
						]
					),
				]
			);

			$response = json_decode( $result->getBody()->getContents(), true );

			if ( 200 === $result->getStatusCode() && isset( $response['resourceName'] ) ) {
				/** @var Ads $ads */
				$ads = $this->container->get( Ads::class );

				$id = $ads->parse_ads_id( $response['resourceName'] );
				$ads->update_ads_id( $id );
				$ads->use_store_currency();

				$billing_url = $response['invitationLink'] ?? '';
				$ads->update_billing_url( $billing_url );
				$ads->update_ocid_from_billing_url( $billing_url );

				return [
					'id'          => $id,
					'billing_url' => $billing_url,
				];
			}

			do_action( 'woocommerce_gla_guzzle_invalid_response', $response, __METHOD__ );

			$error = $response['message'] ?? __( 'Invalid response when creating account', 'google-listings-and-ads' );
			throw new Exception( $error, $result->getStatusCode() );
		} catch ( ClientExceptionInterface $e ) {
			do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );

			throw new Exception(
				$this->client_exception_message( $e, __( 'Error creating account', 'google-listings-and-ads' ) ),
				$e->getCode()
			);
		}
	}

	/**
	 * Link an existing Google Ads account.
	 *
	 * @param int $id Existing account ID.
	 *
	 * @return array
	 * @throws Exception When a ClientException is caught or we receive an invalid response.
	 */
	public function link_ads_account( int $id ): array {
		try {
			/** @var Client $client */
			$client = $this->container->get( Client::class );
			$result = $client->post(
				$this->get_manager_url( 'link-customer' ),
				[
					'body' => wp_json_encode(
						[
							'client_customer' => $id,
						]
					),
				]
			);

			$response = json_decode( $result->getBody()->getContents(), true );
			$name     = "customers/{$id}";

			if ( 200 === $result->getStatusCode() && isset( $response['resourceName'] ) && 0 === strpos( $response['resourceName'], $name ) ) {
				/** @var Ads $ads */
				$ads = $this->container->get( Ads::class );

				$ads->update_ads_id( $id );
				$ads->request_ads_currency();

				return [ 'id' => $id ];
			}

			do_action( 'woocommerce_gla_guzzle_invalid_response', $response, __METHOD__ );

			$error = $response['message'] ?? __( 'Invalid response when linking account', 'google-listings-and-ads' );
			throw new Exception( $error, $result->getStatusCode() );
		} catch ( ClientExceptionInterface $e ) {
			do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );

			throw new Exception(
				$this->client_exception_message( $e, __( 'Error linking account', 'google-listings-and-ads' ) ),
				$e->getCode()
			);
		}
	}

	/**
	 * Determine whether the TOS have been accepted.
	 *
	 * @param string $service Name of service.
	 *
	 * @return TosAccepted
	 */
	public function check_tos_accepted( string $service ): TosAccepted {
		try {
			/** @var Client $client */
			$client = $this->container->get( Client::class );
			$result = $client->get( $this->get_tos_url( $service ) );

			return new TosAccepted( 200 === $result->getStatusCode(), $result->getBody()->getContents() );
		} catch ( ClientExceptionInterface $e ) {
			do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );

			return new TosAccepted( false, $e->getMessage() );
		}
	}

	/**
	 * Record TOS acceptance for a particular email address.
	 *
	 * @param string $service Name of service.
	 * @param string $email
	 *
	 * @return TosAccepted
	 */
	public function mark_tos_accepted( string $service, string $email ): TosAccepted {
		try {
			/** @var Client $client */
			$client = $this->container->get( Client::class );
			$result = $client->post(
				$this->get_tos_url( $service ),
				[
					'body' => wp_json_encode(
						[
							'email' => $email,
						]
					),
				]
			);

			return new TosAccepted(
				200 === $result->getStatusCode(),
				$result->getBody()->getContents() ?? $result->getReasonPhrase()
			);
		} catch ( ClientExceptionInterface $e ) {
			do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );
			return new TosAccepted( false, $e->getMessage() );
		} catch ( Exception $e ) {
			do_action( 'woocommerce_gla_exception', $e, __METHOD__ );
			return new TosAccepted( false, $e->getMessage() );
		}
	}

	/**
	 * Get the TOS endpoint URL
	 *
	 * @param string $service Name of service.
	 *
	 * @return string
	 */
	protected function get_tos_url( string $service ): string {
		$url = $this->container->get( 'connect_server_root' ) . 'tos';
		return $service ? trailingslashit( $url ) . $service : $url;
	}

	/**
	 * Get the manager endpoint URL
	 *
	 * @param string $name Resource name.
	 *
	 * @return string
	 */
	protected function get_manager_url( string $name = '' ): string {
		$url = $this->container->get( 'connect_server_root' ) . 'google/manager';
		return $name ? trailingslashit( $url ) . $name : $url;
	}

	/**
	 * Get the Google Shopping Data Integration auth endpoint URL
	 *
	 * @return string
	 */
	public function get_sdi_auth_endpoint(): string {
		return $this->container->get( 'connect_server_root' )
				. 'google/google-sdi/v1/credentials/partners/WOO_COMMERCE/merchants/'
				. $this->strip_url_protocol( $this->get_site_url() )
				. '/oauth/redirect:generate'
				. '?merchant_id=' . $this->options->get_merchant_id();
	}

	/**
	 * Generate a descriptive name for a new account.
	 * Use site name if available.
	 *
	 * @return string
	 */
	protected function new_account_name(): string {
		$site_name = get_bloginfo( 'name' );
		return ! empty( $site_name ) ? $site_name : $this->default_account_name();
	}

	/**
	 * Generate a default account name based on the date.
	 *
	 * @return string
	 */
	protected function default_account_name(): string {
		return sprintf(
			/* translators: 1: current date in the format Y-m-d */
			__( 'Account %1$s', 'google-listings-and-ads' ),
			( new DateTime() )->format( 'Y-m-d' )
		);
	}

	/**
	 * Get a timezone string from WP Settings.
	 *
	 * @return string
	 * @throws Exception If the DateTime instantiation fails.
	 */
	protected function get_site_timezone_string(): string {
		/** @var WP $wp */
		$wp       = $this->container->get( WP::class );
		$timezone = $wp->wp_timezone_string();

		/** @var DateTimeUtility $datetime_util */
		$datetime_util = $this->container->get( DateTimeUtility::class );

		return $datetime_util->maybe_convert_tz_string( $timezone );
	}

	/**
	 * This function detects if the current account is a sub-account
	 * This function is cached in the MC_IS_SUBACCOUNT transient
	 *
	 * @return bool True if it's a standalone account.
	 */
	public function is_subaccount(): bool {
		/** @var TransientsInterface $transients */
		$transients    = $this->container->get( TransientsInterface::class );
		$is_subaccount = $transients->get( $transients::MC_IS_SUBACCOUNT );

		if ( is_null( $is_subaccount ) ) {
			$is_subaccount = 0;

			$merchant_id = $this->options->get_merchant_id();
			$accounts    = $this->get_merchant_accounts();

			foreach ( $accounts as $account ) {
				if ( $account['id'] === $merchant_id && $account['subaccount'] ) {
					$is_subaccount = 1;
				}
			}

			$transients->set( $transients::MC_IS_SUBACCOUNT, $is_subaccount );
		}

		// since transients don't support booleans, we save them as 0/1 and do the conversion here
		return boolval( $is_subaccount );
	}

	/**
	 * Performs a request to Google Shopping Data Integration (SDI) to get required information in order to form an auth URL.
	 *
	 * @return array An array with the JSON response from the WCS server.
	 * @throws NotFoundExceptionInterface  When the container was not found.
	 * @throws ContainerExceptionInterface When an error happens while retrieving the container.
	 * @throws Exception When the response status is not successful.
	 * @see google-sdi in google/services inside WCS
	 */
	public function get_sdi_auth_params() {
		try {
			/** @var Client $client */
			$client   = $this->container->get( Client::class );
			$result   = $client->get( $this->get_sdi_auth_endpoint() );
			$response = json_decode( $result->getBody()->getContents(), true );

			if ( 200 !== $result->getStatusCode() ) {
				do_action(
					'woocommerce_gla_partner_app_auth_failure',
					[
						'error'    => 'response',
						'response' => $response,
					]
				);
				do_action( 'woocommerce_gla_guzzle_invalid_response', $response, __METHOD__ );
				$error = $response['message'] ?? __( 'Invalid response authenticating partner app.', 'google-listings-and-ads' );
				throw new Exception( $error, $result->getStatusCode() );
			}

			return $response;

		} catch ( ClientExceptionInterface $e ) {
			do_action( 'woocommerce_gla_guzzle_client_exception', $e, __METHOD__ );

			throw new Exception(
				$this->client_exception_message( $e, __( 'Error authenticating Google Partner APP.', 'google-listings-and-ads' ) ),
				$e->getCode()
			);
		}
	}
}
Query/AdsAccountAccessQuery.php000064400000001027151545120240012555 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

defined( 'ABSPATH' ) || exit;

/**
 * Class AdsAccountAccessQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
class AdsAccountAccessQuery extends AdsQuery {

	/**
	 * AdsAccountAccessQuery constructor.
	 */
	public function __construct() {
		parent::__construct( 'customer_user_access' );
		$this->columns( [ 'customer_user_access.resource_name', 'customer_user_access.access_role' ] );
	}
}
Query/AdsAccountQuery.php000064400000001010151545120240011423 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

defined( 'ABSPATH' ) || exit;

/**
 * Class AdsAccountQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
class AdsAccountQuery extends AdsQuery {

	/**
	 * AdsAccountQuery constructor.
	 */
	public function __construct() {
		parent::__construct( 'customer' );
		$this->columns( [ 'customer.id', 'customer.descriptive_name', 'customer.manager', 'customer.test_account' ] );
	}
}
Query/AdsAssetGroupAssetQuery.php000064400000001201151545120240013125 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

defined( 'ABSPATH' ) || exit;

/**
 * Class AdsAssetGroupAssetQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
class AdsAssetGroupAssetQuery extends AdsQuery {

	/**
	 * AdsAssetGroupAssetQuery constructor.
	 */
	public function __construct() {
		parent::__construct( 'asset_group_asset' );
		$this->columns( [ 'asset.id', 'asset.name', 'asset.type', 'asset.text_asset.text', 'asset.image_asset.full_size.url', 'asset.call_to_action_asset.call_to_action', 'asset_group_asset.field_type' ] );
	}
}
Query/AdsAssetGroupQuery.php000064400000001163151545120240012134 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

defined( 'ABSPATH' ) || exit;

/**
 * Class AdsAssetGroupQuery
 *
 * @since 1.12.2
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
class AdsAssetGroupQuery extends AdsQuery {

	/**
	 * AdsAssetGroupQuery constructor.
	 *
	 * @param array $search_args List of search args, such as pageSize.
	 */
	public function __construct( array $search_args = [] ) {
		parent::__construct( 'asset_group' );
		$this->columns( [ 'asset_group.resource_name' ] );
		$this->search_args = $search_args;
	}
}
Query/AdsBillingStatusQuery.php000064400000001153151545120240012623 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

defined( 'ABSPATH' ) || exit;

/**
 * Class AdsBillingStatusQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
class AdsBillingStatusQuery extends AdsQuery {

	/**
	 * AdsBillingStatusQuery constructor.
	 */
	public function __construct() {
		parent::__construct( 'billing_setup' );
		$this->columns(
			[
				'status'          => 'billing_setup.status',
				'start_date_time' => 'billing_setup.start_date_time',
			]
		);
		$this->set_order( 'start_date_time', 'DESC' );
	}
}
Query/AdsCampaignBudgetQuery.php000064400000000740151545120240012712 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

defined( 'ABSPATH' ) || exit;

/**
 * Class AdsCampaignBudgetQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
class AdsCampaignBudgetQuery extends AdsQuery {

	/**
	 * AdsCampaignBudgetQuery constructor.
	 */
	public function __construct() {
		parent::__construct( 'campaign' );
		$this->columns( [ 'campaign.campaign_budget' ] );
	}
}
Query/AdsCampaignCriterionQuery.php000064400000001052151545120240013433 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

defined( 'ABSPATH' ) || exit;

/**
 * Class AdsCampaignCriterionQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
class AdsCampaignCriterionQuery extends AdsQuery {

	/**
	 * AdsCampaignCriterionQuery constructor.
	 */
	public function __construct() {
		parent::__construct( 'campaign_criterion' );
		$this->columns(
			[
				'campaign.id',
				'campaign_criterion.location.geo_target_constant',
			]
		);
	}
}
Query/AdsCampaignLabelQuery.php000064400000000727151545120240012524 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

defined( 'ABSPATH' ) || exit;

/**
 * Class AdsCampaignLabelQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
class AdsCampaignLabelQuery extends AdsQuery {

	/**
	 * AdsCampaignLabelQuery constructor.
	 */
	public function __construct() {
		parent::__construct( 'label' );
		$this->columns(
			[
				'label.id',
			]
		);
	}
}
Query/AdsCampaignQuery.php000064400000001164151545120240011560 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

defined( 'ABSPATH' ) || exit;

/**
 * Class AdsCampaignQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
class AdsCampaignQuery extends AdsQuery {

	/**
	 * AdsCampaignQuery constructor.
	 */
	public function __construct() {
		parent::__construct( 'campaign' );
		$this->columns(
			[
				'campaign.id',
				'campaign.name',
				'campaign.status',
				'campaign.advertising_channel_type',
				'campaign.shopping_setting.feed_label',
				'campaign_budget.amount_micros',
			]
		);
	}
}
Query/AdsCampaignReportQuery.php000064400000001561151545120240012755 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

defined( 'ABSPATH' ) || exit;

/**
 * Class AdsCampaignReportQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
class AdsCampaignReportQuery extends AdsReportQuery {

	/**
	 * Set the initial columns for this query.
	 */
	protected function set_initial_columns() {
		$this->columns(
			[
				'id'     => 'campaign.id',
				'name'   => 'campaign.name',
				'status' => 'campaign.status',
				'type'   => 'campaign.advertising_channel_type',
			]
		);
	}

	/**
	 * Filter the query by a list of ID's.
	 *
	 * @param array $ids list of ID's to filter by.
	 *
	 * @return $this
	 */
	public function filter( array $ids ): QueryInterface {
		if ( empty( $ids ) ) {
			return $this;
		}

		return $this->where( 'campaign.id', $ids, 'IN' );
	}
}
Query/AdsConversionActionQuery.php000064400000001244151545120240013323 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

defined( 'ABSPATH' ) || exit;

/**
 * Class AdsConversionActionQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
class AdsConversionActionQuery extends AdsQuery {

	/**
	 * AdsConversionActionQuery constructor.
	 */
	public function __construct() {
		parent::__construct( 'conversion_action' );
		$this->columns(
			[
				'id'           => 'conversion_action.id',
				'name'         => 'conversion_action.name',
				'status'       => 'conversion_action.status',
				'tag_snippets' => 'conversion_action.tag_snippets',
			]
		);
	}
}
Query/AdsProductLinkInvitationQuery.php000064400000001110151545120240014333 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

defined( 'ABSPATH' ) || exit;

/**
 * Class AdsProductLinkInvitationQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
class AdsProductLinkInvitationQuery extends AdsQuery {

	/**
	 * AdsProductLinkInvitationQuery constructor.
	 */
	public function __construct() {
		parent::__construct( 'product_link_invitation' );
		$this->columns( [ 'product_link_invitation.merchant_center.merchant_center_id', 'product_link_invitation.status' ] );
	}
}
Query/AdsProductReportQuery.php000064400000001466151545120240012662 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

defined( 'ABSPATH' ) || exit;

/**
 * Class AdsProductReportQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
class AdsProductReportQuery extends AdsReportQuery {

	/**
	 * Set the initial columns for this query.
	 */
	protected function set_initial_columns() {
		$this->columns(
			[
				'id'   => 'segments.product_item_id',
				'name' => 'segments.product_title',
			]
		);
	}

	/**
	 * Filter the query by a list of ID's.
	 *
	 * @param array $ids list of ID's to filter by.
	 *
	 * @return $this
	 */
	public function filter( array $ids ): QueryInterface {
		if ( empty( $ids ) ) {
			return $this;
		}

		return $this->where( 'segments.product_item_id', $ids, 'IN' );
	}
}
Query/AdsQuery.php000064400000005405151545120240010122 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidProperty;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Google\Ads\GoogleAds\V18\Services\GoogleAdsRow;
use Google\Ads\GoogleAds\V18\Services\SearchGoogleAdsRequest;
use Google\Ads\GoogleAds\V18\Services\SearchSettings;
use Google\ApiCore\ApiException;

defined( 'ABSPATH' ) || exit;

/**
 * Class AdsQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
abstract class AdsQuery extends Query {

	/**
	 * Client which handles the query.
	 *
	 * @var GoogleAdsClient
	 */
	protected $client = null;

	/**
	 * Ads Account ID.
	 *
	 * @var int
	 */
	protected $id = null;

	/**
	 * Arguments to add to the search query.
	 *
	 * Note: While we allow pageSize to be set, we do not pass it to the API.
	 * pageSize has been deprecated in the API since V17 and is fixed to 10000 rows.
	 *
	 * @var array
	 */
	protected $search_args = [];

	/**
	 * Set the client which will handle the query.
	 *
	 * @param GoogleAdsClient $client Client instance.
	 * @param int             $id     Account ID.
	 *
	 * @return QueryInterface
	 * @throws InvalidProperty If the ID is empty.
	 */
	public function set_client( GoogleAdsClient $client, int $id ): QueryInterface {
		if ( empty( $id ) ) {
			throw InvalidProperty::not_null( get_class( $this ), 'id' );
		}

		$this->client = $client;
		$this->id     = $id;

		return $this;
	}

	/**
	 * Get the first row from the results.
	 *
	 * @return GoogleAdsRow
	 * @throws ApiException When no results returned or an error occurs.
	 */
	public function get_result(): GoogleAdsRow {
		$results = $this->get_results();

		if ( $results ) {
			foreach ( $results->iterateAllElements() as $row ) {
				return $row;
			}
		}

		throw new ApiException( __( 'No result from query', 'google-listings-and-ads' ), 404, '' );
	}

	/**
	 * Perform the query and save it to the results.
	 *
	 * @throws ApiException If the search call fails.
	 * @throws InvalidProperty If the client is not set.
	 */
	protected function query_results() {
		if ( ! $this->client || ! $this->id ) {
			throw InvalidProperty::not_null( get_class( $this ), 'client' );
		}

		$request = new SearchGoogleAdsRequest();

		if ( ! empty( $this->search_args['pageToken'] ) ) {
			$request->setPageToken( $this->search_args['pageToken'] );
		}

		// Allow us to get the total number of results.
		$request->setSearchSettings(
			new SearchSettings(
				[
					'return_total_results_count' => true,
				]
			)
		);

		$request->setQuery( $this->build_query() );
		$request->setCustomerId( $this->id );

		$this->results = $this->client->getGoogleAdsServiceClient()->search( $request );
	}
}
Query/AdsReportQuery.php000064400000003713151545120240011316 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

use Google\Ads\GoogleAds\V18\Resources\ShoppingPerformanceView;

defined( 'ABSPATH' ) || exit;

/**
 * Class AdsReportQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
abstract class AdsReportQuery extends AdsQuery {

	use ReportQueryTrait;

	/**
	 * AdsReportQuery constructor.
	 * Uses the resource ShoppingPerformanceView.
	 *
	 * @param array $args Query arguments.
	 */
	public function __construct( array $args ) {
		parent::__construct( 'shopping_performance_view' );

		$this->set_initial_columns();
		$this->handle_query_args( $args );
	}

	/**
	 * Add all the requested fields.
	 *
	 * @param array $fields List of fields.
	 *
	 * @return $this
	 */
	public function fields( array $fields ): QueryInterface {
		$map = [
			'clicks'      => 'metrics.clicks',
			'impressions' => 'metrics.impressions',
			'spend'       => 'metrics.cost_micros',
			'sales'       => 'metrics.conversions_value',
			'conversions' => 'metrics.conversions',
		];

		$this->add_columns( array_intersect_key( $map, array_flip( $fields ) ) );

		return $this;
	}

	/**
	 * Add a segment interval to the query.
	 *
	 * @param string $interval Type of interval.
	 *
	 * @return $this
	 */
	public function segment_interval( string $interval ): QueryInterface {
		$map = [
			'day'     => 'segments.date',
			'week'    => 'segments.week',
			'month'   => 'segments.month',
			'quarter' => 'segments.quarter',
			'year'    => 'segments.year',
		];

		if ( isset( $map[ $interval ] ) ) {
			$this->add_columns( [ $interval => $map[ $interval ] ] );
		}

		return $this;
	}

	/**
	 * Set the initial columns for this query.
	 */
	abstract protected function set_initial_columns();

	/**
	 * Filter the query by a list of ID's.
	 *
	 * @param array $ids list of ID's to filter by.
	 *
	 * @return $this
	 */
	abstract public function filter( array $ids ): QueryInterface;
}
Query/MerchantFreeListingReportQuery.php000064400000001247151545120240014504 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

defined( 'ABSPATH' ) || exit;

/**
 * Class MerchantFreeListingReportQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
class MerchantFreeListingReportQuery extends MerchantReportQuery {

	/**
	 * Set the initial columns for this query.
	 */
	protected function set_initial_columns() {}

	/**
	 * Filter the query by a list of ID's.
	 *
	 * @param array $ids list of ID's to filter by.
	 *
	 * @return $this
	 */
	public function filter( array $ids ): QueryInterface {
		// No filtering available for free listings.
		return $this;
	}
}
Query/MerchantProductReportQuery.php000064400000001415151545120240013706 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

defined( 'ABSPATH' ) || exit;

/**
 * Class MerchantProductReportQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
class MerchantProductReportQuery extends MerchantReportQuery {

	/**
	 * Set the initial columns for this query.
	 */
	protected function set_initial_columns() {
		$this->columns(
			[
				'id' => 'segments.offer_id',
			]
		);
	}

	/**
	 * Filter the query by a list of ID's.
	 *
	 * @param array $ids list of ID's to filter by.
	 *
	 * @return $this
	 */
	public function filter( array $ids ): QueryInterface {
		if ( empty( $ids ) ) {
			return $this;
		}

		return $this->where( 'segments.offer_id', $ids, 'IN' );
	}
}
Query/MerchantProductViewReportQuery.php000064400000002217151545120240014542 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

defined( 'ABSPATH' ) || exit;

/**
 * Class MerchantProductViewReportQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
class MerchantProductViewReportQuery extends MerchantQuery {

	use ReportQueryTrait;

	/**
	 * MerchantProductViewReportQuery constructor.
	 *
	 * @param array $args Query arguments.
	 */
	public function __construct( array $args ) {
		parent::__construct( 'ProductView' );
		$this->set_initial_columns();
		$this->handle_query_args( $args );
	}


	/**
	 * Filter the query by a list of ID's.
	 *
	 * @param array $ids list of ID's to filter by.
	 *
	 * @return $this
	 */
	public function filter( array $ids ): QueryInterface {
		// No filtering used for product view report.
		return $this;
	}

	/**
	 * Set the initial columns for this query.
	 */
	protected function set_initial_columns() {
		$this->columns(
			[
				'id'              => 'product_view.id',
				'expiration_date' => 'product_view.expiration_date',
				'status'          => 'product_view.aggregated_destination_status',
			]
		);
	}
}
Query/MerchantQuery.php000064400000004137151545120240011155 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidProperty;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Exception as GoogleException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\SearchRequest;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\SearchResponse;

defined( 'ABSPATH' ) || exit;

/**
 * Class MerchantQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
abstract class MerchantQuery extends Query {

	/**
	 * Client which handles the query.
	 *
	 * @var ShoppingContent
	 */
	protected $client = null;

	/**
	 * Merchant Account ID.
	 *
	 * @var int
	 */
	protected $id = null;

	/**
	 * Arguments to add to the search query.
	 *
	 * @var array
	 */
	protected $search_args = [];

	/**
	 * Set the client which will handle the query.
	 *
	 * @param ShoppingContent $client Client instance.
	 * @param int             $id     Account ID.
	 *
	 * @return QueryInterface
	 */
	public function set_client( ShoppingContent $client, int $id ): QueryInterface {
		$this->client = $client;
		$this->id     = $id;

		return $this;
	}

	/**
	 * Perform the query and save it to the results.
	 *
	 * @throws GoogleException If the search call fails.
	 * @throws InvalidProperty If the client is not set.
	 */
	protected function query_results() {
		if ( ! $this->client || ! $this->id ) {
			throw InvalidProperty::not_null( get_class( $this ), 'client' );
		}

		$request = new SearchRequest();
		$request->setQuery( $this->build_query() );

		if ( ! empty( $this->search_args['pageSize'] ) ) {
			$request->setPageSize( $this->search_args['pageSize'] );
		}

		if ( ! empty( $this->search_args['pageToken'] ) ) {
			$request->setPageToken( $this->search_args['pageToken'] );
		}

		/** @var SearchResponse $this->results */
		$this->results = $this->client->reports->search( $this->id, $request );
	}
}
Query/MerchantReportQuery.php000064400000003444151545120240012351 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

defined( 'ABSPATH' ) || exit;

/**
 * Class MerchantReportQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
abstract class MerchantReportQuery extends MerchantQuery {

	use ReportQueryTrait;

	/**
	 * MerchantReportQuery constructor.
	 *
	 * @param array $args Query arguments.
	 */
	public function __construct( array $args ) {
		parent::__construct( 'MerchantPerformanceView' );

		$this->set_initial_columns();
		$this->handle_query_args( $args );
		$this->where( 'segments.program', 'FREE_PRODUCT_LISTING' );
	}

	/**
	 * Add all the requested fields.
	 *
	 * @param array $fields List of fields.
	 *
	 * @return $this
	 */
	public function fields( array $fields ): QueryInterface {
		$map = [
			'clicks'      => 'metrics.clicks',
			'impressions' => 'metrics.impressions',
		];

		$this->add_columns( array_intersect_key( $map, array_flip( $fields ) ) );

		return $this;
	}

	/**
	 * Add a segment interval to the query.
	 *
	 * @param string $interval Type of interval.
	 *
	 * @return $this
	 */
	public function segment_interval( string $interval ): QueryInterface {
		$map = [
			'day'     => 'segments.date',
			'week'    => 'segments.week',
			'month'   => 'segments.month',
			'quarter' => 'segments.quarter',
			'year'    => 'segments.year',
		];

		if ( isset( $map[ $interval ] ) ) {
			$this->add_columns( [ $interval => $map[ $interval ] ] );
		}

		return $this;
	}

	/**
	 * Set the initial columns for this query.
	 */
	abstract protected function set_initial_columns();

	/**
	 * Filter the query by a list of ID's.
	 *
	 * @param array $ids list of ID's to filter by.
	 *
	 * @return $this
	 */
	abstract public function filter( array $ids ): QueryInterface;
}
Query/Query.php000064400000017526151545120240007501 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidQuery;
use DateTime;

defined( 'ABSPATH' ) || exit;

/**
 * Google Ads Query Language (GAQL)
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
abstract class Query implements QueryInterface {

	/**
	 * Resource name.
	 *
	 * @var string
	 */
	protected $resource;

	/**
	 * Set of columns to retrieve in the query.
	 *
	 * @var array
	 */
	protected $columns = [];

	/**
	 * Where clauses for the query.
	 *
	 * @var array
	 */
	protected $where = [];

	/**
	 * Where relation for multiple clauses.
	 *
	 * @var string
	 */
	protected $where_relation;

	/**
	 * Order sort attribute.
	 *
	 * @var string
	 */
	protected $order = 'ASC';

	/**
	 * Column to order by.
	 *
	 * @var string
	 */
	protected $orderby;

	/**
	 * The result of the query.
	 *
	 * @var mixed
	 */
	protected $results = null;

	/**
	 * Query constructor.
	 *
	 * @param string $resource_name
	 *
	 * @throws InvalidQuery When the resource name is not valid.
	 */
	public function __construct( string $resource_name ) {
		if ( ! preg_match( '/^[a-zA-Z_]+$/', $resource_name ) ) {
			throw InvalidQuery::resource_name();
		}

		$this->resource = $resource_name;
	}

	/**
	 * Set columns to retrieve in the query.
	 *
	 * @param array $columns List of column names.
	 *
	 * @return QueryInterface
	 */
	public function columns( array $columns ): QueryInterface {
		$this->validate_columns( $columns );
		$this->columns = $columns;

		return $this;
	}

	/**
	 * Add a set columns to retrieve in the query.
	 *
	 * @param array $columns List of column names.
	 *
	 * @return QueryInterface
	 */
	public function add_columns( array $columns ): QueryInterface {
		$this->validate_columns( $columns );
		$this->columns = array_merge( $this->columns, array_filter( $columns ) );

		return $this;
	}

	/**
	 * Add a where clause to the query.
	 *
	 * @param string $column  The column name.
	 * @param mixed  $value   The where value.
	 * @param string $compare The comparison to use. Valid values are =, <, >, IN, NOT IN.
	 *
	 * @return QueryInterface
	 */
	public function where( string $column, $value, string $compare = '=' ): QueryInterface {
		$this->validate_compare( $compare );
		$this->where[] = [
			'column'  => $column,
			'value'   => $value,
			'compare' => $compare,
		];

		return $this;
	}

	/**
	 * Add a where date between clause to the query.
	 *
	 * @since 1.7.0
	 *
	 * @link https://developers.google.com/shopping-content/guides/reports/query-language/date-ranges
	 *
	 * @param string $after  Start of date range. In ISO 8601(YYYY-MM-DD) format.
	 * @param string $before End of date range. In ISO 8601(YYYY-MM-DD) format.
	 *
	 * @return QueryInterface
	 */
	public function where_date_between( string $after, string $before ): QueryInterface {
		return $this->where( 'segments.date', [ $after, $before ], 'BETWEEN' );
	}

	/**
	 * Set the where relation for the query.
	 *
	 * @param string $relation
	 *
	 * @return QueryInterface
	 */
	public function set_where_relation( string $relation ): QueryInterface {
		$this->validate_where_relation( $relation );
		$this->where_relation = $relation;

		return $this;
	}

	/**
	 * Set ordering information for the query.
	 *
	 * @param string $column
	 * @param string $order
	 *
	 * @return QueryInterface
	 * @throws InvalidQuery When the given column is not in the list of included columns.
	 */
	public function set_order( string $column, string $order = 'ASC' ): QueryInterface {
		if ( ! array_key_exists( $column, $this->columns ) ) {
			throw InvalidQuery::invalid_order_column( $column );
		}

		$this->orderby = $this->columns[ $column ];
		$this->order   = $this->normalize_order( $order );

		return $this;
	}

	/**
	 * Get the results of the query.
	 *
	 * @return mixed
	 */
	public function get_results() {
		if ( null === $this->results ) {
			$this->query_results();
		}

		return $this->results;
	}

	/**
	 * Perform the query and save it to the results.
	 */
	protected function query_results() {
		$this->results = [];
	}

	/**
	 * Validate a set of columns.
	 *
	 * @param array $columns
	 *
	 * @throws InvalidQuery When one of columns in the set is not valid.
	 */
	protected function validate_columns( array $columns ) {
		array_walk( $columns, [ $this, 'validate_column' ] );
	}

	/**
	 * Validate that a given column is using a valid name.
	 *
	 * @param string $column
	 *
	 * @throws InvalidQuery When the given column is not valid.
	 */
	protected function validate_column( string $column ) {
		if ( ! preg_match( '/^[a-zA-Z0-9\._]+$/', $column ) ) {
			throw InvalidQuery::invalid_column( $column );
		}
	}

	/**
	 * Validate that a compare operator is valid.
	 *
	 * @param string $compare
	 *
	 * @throws InvalidQuery When the compare value is not valid.
	 */
	protected function validate_compare( string $compare ) {
		switch ( $compare ) {
			case '=':
			case '>':
			case '<':
			case '!=':
			case 'IN':
			case 'NOT IN':
			case 'BETWEEN':
			case 'IS NOT NULL':
			case 'CONTAINS ANY':
				// These are all valid.
				return;

			default:
				throw InvalidQuery::from_compare( $compare );
		}
	}


	/**
	 * Validate that a where relation is valid.
	 *
	 * @param string $relation
	 *
	 * @throws InvalidQuery When the relation value is not valid.
	 */
	protected function validate_where_relation( string $relation ) {
		switch ( $relation ) {
			case 'AND':
			case 'OR':
				// These are all valid.
				return;

			default:
				throw InvalidQuery::where_relation( $relation );
		}
	}

	/**
	 * Normalize the string for the order.
	 *
	 * Converts the string to uppercase, and will return only DESC or ASC.
	 *
	 * @param string $order
	 *
	 * @return string
	 */
	protected function normalize_order( string $order ): string {
		$order = strtoupper( $order );

		return 'DESC' === $order ? $order : 'ASC';
	}

	/**
	 * Build the query and return the query string.
	 *
	 * @return string
	 *
	 * @throws InvalidQuery When the set of columns is empty.
	 */
	protected function build_query(): string {
		if ( empty( $this->columns ) ) {
			throw InvalidQuery::empty_columns();
		}

		$columns = join( ',', $this->columns );
		$pieces  = [ "SELECT {$columns} FROM {$this->resource}" ];
		$pieces  = array_merge( $pieces, $this->generate_where_pieces() );

		if ( $this->orderby ) {
			$pieces[] = "ORDER BY {$this->orderby} {$this->order}";
		}

		return join( ' ', $pieces );
	}

	/**
	 * Generate the pieces for the WHERE part of the query.
	 *
	 * @return string[]
	 */
	protected function generate_where_pieces(): array {
		if ( empty( $this->where ) ) {
			return [];
		}

		$where_pieces = [ 'WHERE' ];
		foreach ( $this->where as $where ) {
			$column  = $where['column'];
			$compare = $where['compare'];

			if ( 'IN' === $compare || 'NOT_IN' === $compare || 'CONTAINS ANY' === $compare ) {
				$value = sprintf(
					"('%s')",
					join(
						"','",
						array_map(
							function ( $value ) {
								return $this->escape( $value );
							},
							$where['value']
						)
					)
				);
			} elseif ( 'BETWEEN' === $compare ) {
				$value = "'{$this->escape( $where['value'][0] )}' AND '{$this->escape( $where['value'][1] )}'";
			} elseif ( 'IS NOT NULL' === $compare ) {
				$value = '';
			} else {
				$value = "'{$this->escape( $where['value'] )}'";
			}

			if ( count( $where_pieces ) > 1 ) {
				$where_pieces[] = $this->where_relation ?? 'AND';
			}

			$where_pieces[] = "{$column} {$compare} {$value}";
		}

		return $where_pieces;
	}

	/**
	 * Escape the value to a string which can be used in a query.
	 *
	 * @param mixed $value Original value to escape.
	 *
	 * @return string
	 */
	protected function escape( $value ): string {
		if ( $value instanceof DateTime ) {
			return $value->format( 'Y-m-d' );
		}

		if ( ! is_numeric( $value ) ) {
			return (string) $value;
		}

		return addslashes( (string) $value );
	}
}
Query/QueryInterface.php000064400000002406151545120240011311 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

defined( 'ABSPATH' ) || exit;

/**
 * Interface QueryInterface
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
interface QueryInterface {

	/**
	 * Set columns to retrieve in the query.
	 *
	 * @param array $columns List of column names.
	 *
	 * @return $this
	 */
	public function columns( array $columns ): QueryInterface;

	/**
	 * Add a set columns to retrieve in the query.
	 *
	 * @param array $columns List of column names.
	 *
	 * @return $this
	 */
	public function add_columns( array $columns ): QueryInterface;

	/**
	 * Set a where clause to query.
	 *
	 * @param string $column  The column name.
	 * @param mixed  $value   The where value.
	 * @param string $compare The comparison to use. Valid values are =, <, >, IN, NOT IN.
	 *
	 * @return $this
	 */
	public function where( string $column, $value, string $compare = '=' ): QueryInterface;

	/**
	 * Set the where relation for the query.
	 *
	 * @param string $relation
	 *
	 * @return QueryInterface
	 */
	public function set_where_relation( string $relation ): QueryInterface;

	/**
	 * Get the results of the query.
	 *
	 * @return mixed
	 */
	public function get_results();
}
Query/ReportQueryTrait.php000064400000002477151545120240011700 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;

use DateTime;

defined( 'ABSPATH' ) || exit;

/**
 * Trait ReportQueryTrait
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query
 */
trait ReportQueryTrait {

	/**
	 * Handle the common arguments for this query.
	 *
	 * @param array $args List of arguments which were passed to the query.
	 */
	protected function handle_query_args( array $args ) {
		if ( ! empty( $args['fields'] ) ) {
			$this->fields( $args['fields'] );
		}

		if ( ! empty( $args['interval'] ) ) {
			$this->segment_interval( $args['interval'] );
		}

		if ( ! empty( $args['after'] ) && ! empty( $args['before'] ) ) {
			$after  = $args['after'];
			$before = $args['before'];

			$this->where_date_between(
				$after instanceof DateTime ? $after->format( 'Y-m-d' ) : $after,
				$before instanceof DateTime ? $before->format( 'Y-m-d' ) : $before
			);
		}

		if ( ! empty( $args['ids'] ) ) {
			$this->filter( $args['ids'] );
		}

		if ( ! empty( $args['orderby'] ) ) {
			$this->set_order( $args['orderby'], $args['order'] );
		}

		if ( ! empty( $args['per_page'] ) ) {
			$this->search_args['pageSize'] = $args['per_page'];
		}

		if ( ! empty( $args['next_page'] ) ) {
			$this->search_args['pageToken'] = $args['next_page'];
		}
	}
}
ReportTrait.php000064400000003445151545120240007541 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

/**
 * Trait ReportTrait
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
trait ReportTrait {

	/** @var array $report_data */
	private $report_data = [];

	/**
	 * Increase report data by adding the subtotals.
	 *
	 * @param string $field Field to increase.
	 * @param string $index Unique index.
	 * @param array  $data  Report data.
	 */
	protected function increase_report_data( string $field, string $index, array $data ) {
		if ( ! isset( $this->report_data[ $field ][ $index ] ) ) {
			$this->report_data[ $field ][ $index ] = $data;
		} elseif ( ! empty( $data['subtotals'] ) ) {
			foreach ( $data['subtotals'] as $name => $subtotal ) {
				$this->report_data[ $field ][ $index ]['subtotals'][ $name ] += $subtotal;
			}
		}
	}

	/**
	 * Initialize report totals to 0 values.
	 *
	 * @param array $fields List of field names.
	 */
	protected function init_report_totals( array $fields ) {
		foreach ( $fields as $name ) {
			$this->report_data['totals'][ $name ] = 0;
		}
	}

	/**
	 * Increase report totals.
	 *
	 * @param array $data Totals data.
	 */
	protected function increase_report_totals( array $data ) {
		foreach ( $data as $name => $total ) {
			if ( ! isset( $this->report_data['totals'][ $name ] ) ) {
				$this->report_data['totals'][ $name ] = $total;
			} else {
				$this->report_data['totals'][ $name ] += $total;
			}
		}
	}

	/**
	 * Remove indexes from report data to conform to schema.
	 *
	 * @param array $fields Fields to reindex.
	 */
	protected function remove_report_indexes( array $fields ) {
		foreach ( $fields as $key ) {
			if ( isset( $this->report_data[ $key ] ) ) {
				$this->report_data[ $key ] = array_values( $this->report_data[ $key ] );
			}
		}
	}
}
Settings.php000064400000030004151545120240007051 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\ShippingRateQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\ShippingTimeQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\TargetAudience;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\CountryRatesCollection;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\GoogleAdapter\DBShippingSettingsAdapter;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\GoogleAdapter\WCShippingSettingsAdapter;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\ShippingZone;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountAddress;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountTax;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountTaxTaxRule as TaxRule;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ShippingSettings;

defined( 'ABSPATH' ) || exit;

/**
 * Class Settings
 *
 * Container used for:
 * - OptionsInterface
 * - ShippingRateQuery
 * - ShippingTimeQuery
 * - ShippingZone
 * - ShoppingContent
 * - TargetAudience
 * - WC
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class Settings implements ContainerAwareInterface {

	use ContainerAwareTrait;
	use LocationIDTrait;

	/**
	 * Return a set of formatted settings which can be used in tracking.
	 *
	 * @since 2.5.16
	 *
	 * @return array
	 */
	public function get_settings_for_tracking() {
		$settings = $this->get_settings();

		return [
			'shipping_rate'           => $settings['shipping_rate'] ?? '',
			'offers_free_shipping'    => (bool) ( $settings['offers_free_shipping'] ?? false ),
			'free_shipping_threshold' => (float) ( $settings['free_shipping_threshold'] ?? 0 ),
			'shipping_time'           => $settings['shipping_time'] ?? '',
			'tax_rate'                => $settings['tax_rate'] ?? '',
			'target_countries'        => join( ',', $this->get_target_countries() ),
		];
	}

	/**
	 * Sync the shipping settings with Google.
	 */
	public function sync_shipping() {
		if ( ! $this->should_sync_shipping() ) {
			return;
		}

		$settings = $this->generate_shipping_settings();

		$this->get_shopping_service()->shippingsettings->update(
			$this->get_merchant_id(),
			$this->get_account_id(),
			$settings
		);
	}

	/**
	 * Whether we should synchronize settings with the Merchant Center
	 *
	 * @return bool
	 */
	protected function should_sync_shipping(): bool {
		$shipping_rate = $this->get_settings()['shipping_rate'] ?? '';
		$shipping_time = $this->get_settings()['shipping_time'] ?? '';
		return in_array( $shipping_rate, [ 'flat', 'automatic' ], true ) && 'flat' === $shipping_time;
	}

	/**
	 * Whether we should get the shipping settings from the WooCommerce settings.
	 *
	 * @return bool
	 *
	 * @since 1.12.0
	 */
	public function should_get_shipping_rates_from_woocommerce(): bool {
		return 'automatic' === ( $this->get_settings()['shipping_rate'] ?? '' );
	}

	/**
	 * Generate a ShippingSettings object for syncing the store shipping settings to Merchant Center.
	 *
	 * @return ShippingSettings
	 *
	 * @since 2.1.0
	 */
	protected function generate_shipping_settings(): ShippingSettings {
		$times = $this->get_shipping_times();

		/** @var WC $wc_proxy */
		$wc_proxy = $this->container->get( WC::class );
		$currency = $wc_proxy->get_woocommerce_currency();

		if ( $this->should_get_shipping_rates_from_woocommerce() ) {
			return new WCShippingSettingsAdapter(
				[
					'currency'          => $currency,
					'rates_collections' => $this->get_shipping_rates_collections_from_woocommerce(),
					'delivery_times'    => $times,
					'accountId'         => $this->get_account_id(),
				]
			);
		}

		return new DBShippingSettingsAdapter(
			[
				'currency'       => $currency,
				'db_rates'       => $this->get_shipping_rates_from_database(),
				'delivery_times' => $times,
				'accountId'      => $this->get_account_id(),
			]
		);
	}

	/**
	 * Get the current tax settings from the API.
	 *
	 * @return AccountTax
	 */
	public function get_taxes(): AccountTax {
		return $this->get_shopping_service()->accounttax->get(
			$this->get_merchant_id(),
			$this->get_account_id()
		);
	}

	/**
	 * Whether we should sync tax settings.
	 *
	 * This depends on the store being in the US
	 *
	 * @return bool
	 */
	protected function should_sync_taxes(): bool {
		if ( 'US' !== $this->get_store_country() ) {
			return false;
		}

		return 'destination' === ( $this->get_settings()['tax_rate'] ?? 'destination' );
	}

	/**
	 * Sync tax setting with Google.
	 */
	public function sync_taxes() {
		if ( ! $this->should_sync_taxes() ) {
			return;
		}

		$taxes = new AccountTax();
		$taxes->setAccountId( $this->get_account_id() );

		$tax_rule = new TaxRule();
		$tax_rule->setUseGlobalRate( true );
		$tax_rule->setLocationId( $this->get_state_id( $this->get_store_state() ) );
		$tax_rule->setCountry( $this->get_store_country() );

		$taxes->setRules( [ $tax_rule ] );

		$this->get_shopping_service()->accounttax->update(
			$this->get_merchant_id(),
			$this->get_account_id(),
			$taxes
		);
	}

	/**
	 * Get shipping time data.
	 *
	 * @return array
	 */
	protected function get_shipping_times(): array {
		static $times = null;

		if ( null === $times ) {
			$time_query = $this->container->get( ShippingTimeQuery::class );
			$times      = $time_query->get_all_shipping_times();
		}

		return $times;
	}

	/**
	 * Get shipping rate data.
	 *
	 * @return array
	 */
	protected function get_shipping_rates_from_database(): array {
		$rate_query = $this->container->get( ShippingRateQuery::class );

		return $rate_query->get_results();
	}

	/**
	 * Get shipping rate data from WooCommerce shipping settings.
	 *
	 * @return CountryRatesCollection[] Array of rates collections for each target country specified in settings.
	 */
	protected function get_shipping_rates_collections_from_woocommerce(): array {
		/** @var TargetAudience $target_audience */
		$target_audience  = $this->container->get( TargetAudience::class );
		$target_countries = $target_audience->get_target_countries();
		/** @var ShippingZone $shipping_zone */
		$shipping_zone = $this->container->get( ShippingZone::class );

		$rates = [];
		foreach ( $target_countries as $country ) {
			$location_rates    = $shipping_zone->get_shipping_rates_for_country( $country );
			$rates[ $country ] = new CountryRatesCollection( $country, $location_rates );
		}

		return $rates;
	}

	/**
	 * @return OptionsInterface
	 */
	protected function get_options_object(): OptionsInterface {
		return $this->container->get( OptionsInterface::class );
	}

	/**
	 * Get the Merchant ID
	 *
	 * @return int
	 */
	protected function get_merchant_id(): int {
		return $this->get_options_object()->get( OptionsInterface::MERCHANT_ID );
	}

	/**
	 * Get the account ID.
	 *
	 * @return int
	 */
	protected function get_account_id(): int {
		// todo: there are some cases where this might be different than the Merchant ID.
		return $this->get_merchant_id();
	}

	/**
	 * Get the Shopping Service object.
	 *
	 * @return ShoppingContent
	 */
	protected function get_shopping_service(): ShoppingContent {
		return $this->container->get( ShoppingContent::class );
	}

	/**
	 * Get the country for the store.
	 *
	 * @return string
	 */
	protected function get_store_country(): string {
		return $this->container->get( WC::class )->get_base_country();
	}

	/**
	 * Get the state for the store.
	 *
	 * @return string
	 */
	protected function get_store_state(): string {
		/** @var WC $wc */
		$wc = $this->container->get( WC::class );

		return $wc->get_wc_countries()->get_base_state();
	}

	/**
	 * Get the WooCommerce store physical address.
	 *
	 * @return AccountAddress
	 *
	 * @since 1.4.0
	 */
	public function get_store_address(): AccountAddress {
		/** @var WC $wc */
		$wc = $this->container->get( WC::class );

		$countries   = $wc->get_wc_countries();
		$postal_code = ! empty( $countries->get_base_postcode() ) ? $countries->get_base_postcode() : null;
		$locality    = ! empty( $countries->get_base_city() ) ? $countries->get_base_city() : null;
		$country     = ! empty( $countries->get_base_country() ) ? $countries->get_base_country() : null;
		$region      = ! empty( $countries->get_base_state() ) ? $countries->get_base_state() : null;

		$mc_address = new AccountAddress();
		$mc_address->setPostalCode( $postal_code );
		$mc_address->setLocality( $locality );
		$mc_address->setCountry( $country );

		if ( ! empty( $region ) && ! empty( $country ) ) {
			$mc_address->setRegion( $this->maybe_get_state_name( $region, $country ) );
		}

		$address   = ! empty( $countries->get_base_address() ) ? $countries->get_base_address() : null;
		$address_2 = ! empty( $countries->get_base_address_2() ) ? $countries->get_base_address_2() : null;
		$separator = ! empty( $address ) && ! empty( $address_2 ) ? "\n" : '';
		$address   = sprintf( '%s%s%s', $countries->get_base_address(), $separator, $countries->get_base_address_2() );
		if ( ! empty( $address ) ) {
			$mc_address->setStreetAddress( $address );
		}

		return $mc_address;
	}

	/**
	 * Check whether the address has errors
	 *
	 * @param AccountAddress $address to be validated.
	 *
	 * @return array
	 */
	public function wc_address_errors( AccountAddress $address ): array {
		/** @var WC $wc */
		$wc = $this->container->get( WC::class );

		$countries = $wc->get_wc_countries();

		$locale          = $countries->get_country_locale();
		$locale_settings = $locale[ $address->getCountry() ] ?? [];

		$fields_to_validate = [
			'address_1' => $address->getStreetAddress(),
			'city'      => $address->getLocality(),
			'country'   => $address->getCountry(),
			'postcode'  => $address->getPostalCode(),
		];

		return $this->validate_address( $fields_to_validate, $locale_settings );
	}

	/**
	 * Check whether the required address fields are empty
	 *
	 * @param array $address_fields to be validated.
	 * @param array $locale_settings locale settings
	 * @return array
	 */
	public function validate_address( array $address_fields, array $locale_settings ): array {
		$errors = array_filter(
			$address_fields,
			function ( $field ) use ( $locale_settings, $address_fields ) {
				$is_required = $locale_settings[ $field ]['required'] ?? true;
				return $is_required && empty( $address_fields[ $field ] );
			},
			ARRAY_FILTER_USE_KEY
		);

		return array_keys( $errors );
	}

	/**
	 * Return a state name.
	 *
	 * @param string $state_code State code.
	 * @param string $country    Country code.
	 *
	 * @return string
	 *
	 * @since 1.4.0
	 */
	protected function maybe_get_state_name( string $state_code, string $country ): string {
		/** @var WC $wc */
		$wc = $this->container->get( WC::class );

		$states = $country ? array_filter( (array) $wc->get_wc_countries()->get_states( $country ) ) : [];

		if ( ! empty( $states ) ) {
			$state_code = wc_strtoupper( $state_code );
			if ( isset( $states[ $state_code ] ) ) {
				return $states[ $state_code ];
			}
		}

		return $state_code;
	}

	/**
	 * Get the array of settings for the Merchant Center.
	 *
	 * @return array
	 */
	protected function get_settings(): array {
		$settings = $this->get_options_object()->get( OptionsInterface::MERCHANT_CENTER );
		return is_array( $settings ) ? $settings : [];
	}

	/**
	 * Return a list of target countries or all.
	 *
	 * @return array
	 */
	protected function get_target_countries(): array {
		$target_audience = $this->get_options_object()->get( OptionsInterface::TARGET_AUDIENCE );

		if ( isset( $target_audience['location'] ) && 'all' === $target_audience['location'] ) {
			return [ 'all' ];
		}

		return $target_audience['countries'] ?? [];
	}
}
ShoppingContentDateTrait.php000064400000001437151545120240012205 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Date as ShoppingContentDate;
use DateTime;

defined( 'ABSPATH' ) || exit;

/**
 * Trait ShoppingContentDateTrait
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
trait ShoppingContentDateTrait {

	/**
	 * Convert ShoppingContentDate to DateTime.
	 *
	 * @param ShoppingContentDate $date The Google date.
	 *
	 * @return DateTime|false The date converted or false if the date is invalid.
	 */
	protected function convert_shopping_content_date( ShoppingContentDate $date ) {
		return DateTime::createFromFormat( 'Y-m-d|', "{$date->getYear()}-{$date->getMonth()}-{$date->getDay()}" );
	}
}
SiteVerification.php000064400000014171151545120240010527 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\Exception as GoogleServiceException;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\SiteVerification as SiteVerificationService;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\SiteVerification\SiteVerificationWebResourceResource as WebResource;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\SiteVerification\SiteVerificationWebResourceResourceSite as WebResourceSite;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\SiteVerification\SiteVerificationWebResourceGettokenRequest as GetTokenRequest;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\SiteVerification\SiteVerificationWebResourceGettokenRequestSite as GetTokenRequestSite;
use Exception;

defined( 'ABSPATH' ) || exit;

/**
 * Class SiteVerification
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google
 */
class SiteVerification implements ContainerAwareInterface, OptionsAwareInterface {

	use ContainerAwareTrait;
	use ExceptionTrait;
	use OptionsAwareTrait;
	use PluginHelper;

	/** @var string */
	private const VERIFICATION_METHOD = 'META';

	/** @var string */
	public const VERIFICATION_STATUS_VERIFIED = 'yes';

	/** @var string */
	public const VERIFICATION_STATUS_UNVERIFIED = 'no';

	/**
	 * Performs the three-step process of verifying the current site:
	 * 1. Retrieves the meta tag with the verification token.
	 * 2. Enables the meta tag in the head of the store (handled by SiteVerificationMeta).
	 * 3. Instructs the Site Verification API to verify the meta tag.
	 *
	 * @since 1.12.0
	 *
	 * @param string $site_url Site URL to verify.
	 *
	 * @throws Exception If any step of the site verification process fails.
	 */
	public function verify_site( string $site_url ) {
		if ( ! wc_is_valid_url( $site_url ) ) {
			do_action( 'woocommerce_gla_site_verify_failure', [ 'step' => 'site-url' ] );
			throw new Exception( __( 'Invalid site URL.', 'google-listings-and-ads' ) );
		}

		// Retrieve the meta tag with verification token.
		try {
			$meta_tag = $this->get_token( $site_url );
		} catch ( Exception $e ) {
			do_action( 'woocommerce_gla_site_verify_failure', [ 'step' => 'token' ] );
			throw $e;
		}

		// Store the meta tag in the options table and mark as unverified.
		$site_verification_options = [
			'verified' => self::VERIFICATION_STATUS_UNVERIFIED,
			'meta_tag' => $meta_tag,
		];
		$this->options->update(
			OptionsInterface::SITE_VERIFICATION,
			$site_verification_options
		);

		// Attempt verification.
		try {
			$this->insert( $site_url );
			$site_verification_options['verified'] = self::VERIFICATION_STATUS_VERIFIED;
			$this->options->update( OptionsInterface::SITE_VERIFICATION, $site_verification_options );
			do_action( 'woocommerce_gla_site_verify_success', [] );
		} catch ( Exception $e ) {
			do_action( 'woocommerce_gla_site_verify_failure', [ 'step' => 'meta-tag' ] );
			throw $e;
		}
	}

	/**
	 * Get the META token for site verification.
	 * https://developers.google.com/site-verification/v1/webResource/getToken
	 *
	 * @param string $identifier The URL of the site to verify (including protocol).
	 *
	 * @return string The meta tag to be used for verification.
	 * @throws ExceptionWithResponseData When unable to retrieve meta token.
	 */
	protected function get_token( string $identifier ): string {
		/** @var SiteVerificationService $service */
		$service   = $this->container->get( SiteVerificationService::class );
		$post_body = new GetTokenRequest(
			[
				'verificationMethod' => self::VERIFICATION_METHOD,
				'site'               => new GetTokenRequestSite(
					[
						'type'       => 'SITE',
						'identifier' => $identifier,
					]
				),
			]
		);

		try {
			// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
			$response = $service->webResource->getToken( $post_body );
		} catch ( GoogleServiceException $e ) {
			do_action( 'woocommerce_gla_sv_client_exception', $e, __METHOD__ );

			$errors = $this->get_exception_errors( $e );

			throw new ExceptionWithResponseData(
				/* translators: %s Error message */
				sprintf( __( 'Unable to retrieve site verification token: %s', 'google-listings-and-ads' ), reset( $errors ) ),
				$e->getCode(),
				null,
				[ 'errors' => $errors ]
			);
		}

		return $response->getToken();
	}

	/**
	 * Instructs the Google Site Verification API to verify site ownership
	 * using the META method.
	 *
	 * @param string $identifier The URL of the site to verify (including protocol).
	 *
	 * @throws ExceptionWithResponseData When unable to verify token.
	 */
	protected function insert( string $identifier ) {
		/** @var SiteVerificationService $service */
		$service   = $this->container->get( SiteVerificationService::class );
		$post_body = new WebResource(
			[
				'site' => new WebResourceSite(
					[
						'type'       => 'SITE',
						'identifier' => $identifier,
					]
				),
			]
		);

		try {
			// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
			$service->webResource->insert( self::VERIFICATION_METHOD, $post_body );
		} catch ( GoogleServiceException $e ) {
			do_action( 'woocommerce_gla_sv_client_exception', $e, __METHOD__ );

			$errors = $this->get_exception_errors( $e );

			throw new ExceptionWithResponseData(
				/* translators: %s Error message */
				sprintf( __( 'Unable to insert site verification: %s', 'google-listings-and-ads' ), reset( $errors ) ),
				$e->getCode(),
				null,
				[ 'errors' => $errors ]
			);
		}
	}
}
AccountController.php000064400000014120151555574560010735 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Google;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Connection;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Exception;
use WP_REST_Request as Request;

defined( 'ABSPATH' ) || exit;

/**
 * Class AccountController
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Google
 */
class AccountController extends BaseController {

	/**
	 * @var Connection
	 */
	protected $connection;

	/**
	 * Mapping between the client page name and its path.
	 * The first value is also used as a default,
	 * and changing the order of keys/values may affect things below.
	 *
	 * @var string[]
	 */
	private const NEXT_PATH_MAPPING = [
		'setup-mc'  => '/google/setup-mc',
		'setup-ads' => '/google/setup-ads',
		'reconnect' => '/google/settings&subpath=/reconnect-google-account',
	];

	/**
	 * AccountController constructor.
	 *
	 * @param RESTServer $server
	 * @param Connection $connection
	 */
	public function __construct( RESTServer $server, Connection $connection ) {
		parent::__construct( $server );
		$this->connection = $connection;
	}

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			'google/connect',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_connect_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_connect_params(),
				],
				[
					'methods'             => TransportMethods::DELETABLE,
					'callback'            => $this->get_disconnect_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);
		$this->register_route(
			'google/connected',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_connected_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
			]
		);
		$this->register_route(
			'google/reconnected',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_reconnected_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
			]
		);
	}

	/**
	 * Get the callback function for the connection request.
	 *
	 * @return callable
	 */
	protected function get_connect_callback(): callable {
		return function ( Request $request ) {
			try {
				$next       = $request->get_param( 'next_page_name' );
				$login_hint = $request->get_param( 'login_hint' ) ?: '';
				$path       = self::NEXT_PATH_MAPPING[ $next ];
				return [
					'url' => $this->connection->connect(
						admin_url( "admin.php?page=wc-admin&path={$path}" ),
						$login_hint
					),
				];
			} catch ( Exception $e ) {
				return $this->response_from_exception( $e );
			}
		};
	}

	/**
	 * Get the query params for the connection request.
	 *
	 * @return array
	 */
	protected function get_connect_params(): array {
		return [
			'context'        => $this->get_context_param( [ 'default' => 'view' ] ),
			'next_page_name' => [
				'description'       => __( 'Indicates the next page name mapped to the redirect URL when back from Google authorization.', 'google-listings-and-ads' ),
				'type'              => 'string',
				'default'           => array_key_first( self::NEXT_PATH_MAPPING ),
				'enum'              => array_keys( self::NEXT_PATH_MAPPING ),
				'validate_callback' => 'rest_validate_request_arg',
			],
			'login_hint'     => [
				'description'       => __( 'Indicate the Google account to suggest for authorization.', 'google-listings-and-ads' ),
				'type'              => 'string',
				'validate_callback' => 'is_email',
			],
		];
	}

	/**
	 * Get the callback function for the disconnection request.
	 *
	 * @return callable
	 */
	protected function get_disconnect_callback(): callable {
		return function () {
			$this->connection->disconnect();

			return [
				'status'  => 'success',
				'message' => __( 'Successfully disconnected.', 'google-listings-and-ads' ),
			];
		};
	}

	/**
	 * Get the callback function to determine if Google is currently connected.
	 *
	 * Uses consistent properties to the Jetpack connected callback
	 *
	 * @return callable
	 */
	protected function get_connected_callback(): callable {
		return function () {
			try {
				$status = $this->connection->get_status();
				return [
					'active' => array_key_exists( 'status', $status ) && ( 'connected' === $status['status'] ) ? 'yes' : 'no',
					'email'  => array_key_exists( 'email', $status ) ? $status['email'] : '',
					'scope'  => array_key_exists( 'scope', $status ) ? $status['scope'] : [],
				];
			} catch ( Exception $e ) {
				return $this->response_from_exception( $e );
			}
		};
	}

	/**
	 * Get the callback function to determine if we have access to the dependent services.
	 *
	 * @return callable
	 */
	protected function get_reconnected_callback(): callable {
		return function () {
			try {
				$status           = $this->connection->get_reconnect_status();
				$status['active'] = array_key_exists( 'status', $status ) && ( 'connected' === $status['status'] ) ? 'yes' : 'no';
				unset( $status['status'] );

				return $status;
			} catch ( Exception $e ) {
				return $this->response_from_exception( $e );
			}
		};
	}

	/**
	 * Get the item schema for the controller.
	 *
	 * @return array
	 */
	protected function get_schema_properties(): array {
		return [
			'url' => [
				'type'        => 'string',
				'description' => __( 'The URL for making a connection to Google.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'readonly'    => true,
			],
		];
	}

	/**
	 * Get the item schema name for the controller.
	 *
	 * Used for building the API response schema.
	 *
	 * @return string
	 */
	protected function get_schema_title(): string {
		return 'google_account';
	}
}