File "AdsCampaign.php"

Full Path: /home/warrior1/public_html/wp-content/plugins/google-listings-and-ads/src/API/Google/AdsCampaign.php
File size: 18.78 KB
MIME-type: text/x-php
Charset: utf-8

<?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' ) );
		}
	}
}