<?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\Proxies\WC; use Google\Ads\GoogleAds\Util\FieldMasks; use Google\Ads\GoogleAds\Util\V11\ResourceNames; use Google\Ads\GoogleAds\V11\Common\MaximizeConversionValue; use Google\Ads\GoogleAds\V11\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType; use Google\Ads\GoogleAds\V11\Resources\Campaign; use Google\Ads\GoogleAds\V11\Resources\Campaign\ShoppingSetting; use Google\Ads\GoogleAds\V11\Services\CampaignServiceClient; use Google\Ads\GoogleAds\V11\Services\CampaignOperation; use Google\Ads\GoogleAds\V11\Services\GoogleAdsRow; use Google\Ads\GoogleAds\V11\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 * - WC * * @since 1.12.2 Refactored to support PMax and (legacy) SSC. * * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Google */ class AdsCampaign implements ContainerAwareInterface, OptionsAwareInterface { use ApiExceptionTrait; use ContainerAwareTrait; 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; /** * AdsCampaign constructor. * * @param GoogleAdsClient $client * @param AdsCampaignBudget $budget * @param AdsCampaignCriterion $criterion * @param GoogleHelper $google_helper */ public function __construct( GoogleAdsClient $client, AdsCampaignBudget $budget, AdsCampaignCriterion $criterion, GoogleHelper $google_helper ) { $this->client = $client; $this->budget = $budget; $this->criterion = $criterion; $this->google_helper = $google_helper; } /** * 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). * * @return array * @throws ExceptionWithResponseData When an ApiException is caught. */ public function get_campaigns( bool $exclude_removed = true, bool $fetch_criterion = true ): array { try { $query = ( new AdsCampaignQuery() )->set_client( $this->client, $this->options->get_ads_id() ); if ( $exclude_removed ) { $query->where( 'campaign.status', 'REMOVED', '!=' ); } $campaign_results = $query->get_results(); $converted_campaigns = []; foreach ( $campaign_results->iterateAllElements() as $row ) { $campaign = $this->convert_campaign( $row ); $converted_campaigns[ $campaign['id'] ] = $campaign; } 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_api_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_api_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 ); 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_api_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_api_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 ), ]; return $this->mutate( $operations ); } catch ( ApiException $e ) { do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ ); $errors = $this->get_api_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' => true, 'shopping_setting' => new ShoppingSetting( [ 'merchant_id' => $this->options->get_merchant_id(), 'sales_country' => $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->getSalesCountry(), ]; } 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 { $responses = $this->client->getGoogleAdsServiceClient()->mutate( $this->options->get_ads_id(), $operations ); 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' ) ); } } }