File: /var/www/vhosts/uyarreklam.com.tr/httpdocs/Google.tar
Ads/GoogleAdsClient.php 0000644 00000002660 15154304735 0011002 0 ustar 00 <?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.php 0000644 00000015464 15154304735 0013100 0 ustar 00 <?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.php 0000644 00000004432 15154304736 0012203 0 ustar 00 <?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.php 0000644 00000002651 15154304736 0010675 0 ustar 00 <?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.php 0000644 00000003342 15154304736 0012461 0 ustar 00 <?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.php 0000644 00000001750 15154304736 0012245 0 ustar 00 <?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.php 0000644 00000001675 15154304736 0011377 0 ustar 00 <?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.php 0000644 00000002614 15154304736 0010700 0 ustar 00 <?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.php 0000644 00000041206 15154304736 0007751 0 ustar 00 <?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.php 0000644 00000066245 15154304737 0007657 0 ustar 00 <?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.php 0000644 00000000663 15154304737 0012450 0 ustar 00 <?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.php 0000644 00000001056 15154304737 0011630 0 ustar 00 <?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.php 0000644 00000020237 15154304737 0011370 0 ustar 00 <?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.php 0000644 00000003114 15154304737 0011731 0 ustar 00 <?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.php 0000644 00000005365 15154304737 0011073 0 ustar 00 <?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.php 0000644 00000013731 15154304737 0011641 0 ustar 00 <?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.php 0000644 00000002520 15154304737 0011343 0 ustar 00 <?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.php 0000644 00000025013 15154512024 0005764 0 ustar 00 <?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.php 0000644 00000021774 15154512024 0006776 0 ustar 00 <?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.php 0000644 00000033504 15154512024 0010005 0 ustar 00 <?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.php 0000644 00000031607 15154512024 0011007 0 ustar 00 <?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.php 0000644 00000050434 15154512024 0007431 0 ustar 00 <?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.php 0000644 00000011120 15154512024 0010551 0 ustar 00 <?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.php 0000644 00000003664 15154512024 0011313 0 ustar 00 <?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.php 0000644 00000010701 15154512024 0010362 0 ustar 00 <?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.php 0000644 00000014776 15154512024 0011206 0 ustar 00 <?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.php 0000644 00000016331 15154512024 0007163 0 ustar 00 <?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.php 0000644 00000007421 15154512024 0010145 0 ustar 00 <?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.php 0000644 00000002477 15154512024 0011073 0 ustar 00 <?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.php 0000644 00000004703 15154512024 0010436 0 ustar 00 <?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.php 0000644 00000002162 15154512024 0010200 0 ustar 00 <?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.php 0000644 00000004752 15154512024 0007645 0 ustar 00 <?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.php 0000644 00000012736 15154512024 0007364 0 ustar 00 <?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.php 0000644 00000013627 15154512024 0010227 0 ustar 00 <?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.php 0000644 00000003341 15154512024 0010246 0 ustar 00 <?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.php 0000644 00000035142 15154512024 0007022 0 ustar 00 <?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.php 0000644 00000015644 15154512024 0010356 0 ustar 00 <?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.php 0000644 00000020622 15154512024 0010213 0 ustar 00 <?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.php 0000644 00000045130 15154512024 0007334 0 ustar 00 <?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.php 0000644 00000001027 15154512024 0012555 0 ustar 00 <?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.php 0000644 00000001010 15154512024 0011423 0 ustar 00 <?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.php 0000644 00000001201 15154512024 0013125 0 ustar 00 <?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.php 0000644 00000001163 15154512024 0012134 0 ustar 00 <?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.php 0000644 00000001153 15154512024 0012623 0 ustar 00 <?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.php 0000644 00000000740 15154512024 0012712 0 ustar 00 <?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.php 0000644 00000001052 15154512024 0013433 0 ustar 00 <?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.php 0000644 00000000727 15154512024 0012524 0 ustar 00 <?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.php 0000644 00000001164 15154512024 0011560 0 ustar 00 <?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.php 0000644 00000001561 15154512024 0012755 0 ustar 00 <?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.php 0000644 00000001244 15154512024 0013323 0 ustar 00 <?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.php 0000644 00000001110 15154512024 0014333 0 ustar 00 <?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.php 0000644 00000001466 15154512024 0012662 0 ustar 00 <?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.php 0000644 00000005405 15154512024 0010122 0 ustar 00 <?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.php 0000644 00000003713 15154512024 0011316 0 ustar 00 <?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.php 0000644 00000001247 15154512024 0014504 0 ustar 00 <?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.php 0000644 00000001415 15154512024 0013706 0 ustar 00 <?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.php 0000644 00000002217 15154512024 0014542 0 ustar 00 <?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.php 0000644 00000004137 15154512024 0011155 0 ustar 00 <?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.php 0000644 00000003444 15154512024 0012351 0 ustar 00 <?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.php 0000644 00000017526 15154512024 0007501 0 ustar 00 <?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.php 0000644 00000002406 15154512024 0011311 0 ustar 00 <?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.php 0000644 00000002477 15154512024 0011700 0 ustar 00 <?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.php 0000644 00000003445 15154512024 0007541 0 ustar 00 <?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.php 0000644 00000030004 15154512024 0007051 0 ustar 00 <?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.php 0000644 00000001437 15154512024 0012205 0 ustar 00 <?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.php 0000644 00000014171 15154512024 0010527 0 ustar 00 <?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.php 0000644 00000014120 15155557456 0010735 0 ustar 00 <?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';
}
}