<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsAssetGroup;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsCampaignAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AssetFieldType;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsCampaignCriterionQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsCampaignQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsMissingEuDeclarationQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsCampaignAssetQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\AdsAssetQuery;
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\V23\ResourceNames;
use Google\Ads\GoogleAds\V23\Common\MaximizeConversionValue;
use Google\Ads\GoogleAds\V23\Enums\AssetTypeEnum\AssetType as AdsAssetType;
use Google\Ads\GoogleAds\V23\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType;
use Google\Ads\GoogleAds\V23\Resources\Campaign;
use Google\Ads\GoogleAds\V23\Enums\EuPoliticalAdvertisingStatusEnum\EuPoliticalAdvertisingStatus;
use Google\Ads\GoogleAds\V23\Resources\Campaign\ShoppingSetting;
use Google\Ads\GoogleAds\V23\Services\Client\CampaignServiceClient;
use Google\Ads\GoogleAds\V23\Services\CampaignOperation;
use Google\Ads\GoogleAds\V23\Services\GoogleAdsRow;
use Google\Ads\GoogleAds\V23\Services\MutateGoogleAdsRequest;
use Google\Ads\GoogleAds\V23\Services\MutateOperation;
use Google\Ads\GoogleAds\V23\Resources\Campaign\AssetAutomationSetting;
use Google\Ads\GoogleAds\V23\Enums\AssetAutomationTypeEnum\AssetAutomationType;
use Google\Ads\GoogleAds\V23\Enums\AssetAutomationStatusEnum\AssetAutomationStatus;
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;
/**
* @var AdsCampaignAsset $campaign_asset
*/
protected $campaign_asset;
/**
* AdsCampaign constructor.
*
* @param GoogleAdsClient $client
* @param AdsCampaignBudget $budget
* @param AdsCampaignCriterion $criterion
* @param GoogleHelper $google_helper
* @param AdsCampaignLabel $campaign_label
* @param AdsCampaignAsset $campaign_asset
*/
public function __construct( GoogleAdsClient $client, AdsCampaignBudget $budget, AdsCampaignCriterion $criterion, GoogleHelper $google_helper, AdsCampaignLabel $campaign_label, AdsCampaignAsset $campaign_asset ) {
$this->client = $client;
$this->budget = $budget;
$this->criterion = $criterion;
$this->google_helper = $google_helper;
$this->campaign_label = $campaign_label;
$this->campaign_asset = $campaign_asset;
}
/**
* 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 ]
);
}
}
/**
* Get campaigns that are missing the EU political advertising declaration.
*
* @return array[] List of campaigns with 'id' and 'name' keys.
* @throws ExceptionWithResponseData When an ApiException is caught.
*/
public function get_campaigns_missing_eu_political_declaration(): array {
try {
$query = ( new AdsMissingEuDeclarationQuery() )->set_client( $this->client, $this->options->get_ads_id() );
$results = $query->get_results();
$campaigns = [];
foreach ( $results->iterateAllElements() as $row ) {
$campaign = $row->getCampaign();
// Skip VIDEO campaigns
if ( AdvertisingChannelType::VIDEO === $campaign->getAdvertisingChannelType() ) {
continue;
}
$campaigns[] = [
'id' => $campaign->getId(),
'name' => $campaign->getName(),
];
}
// When no campaigns need the declaration, record that so the recurring job can stop scheduling
if ( empty( $campaigns ) ) {
$this->options->update( OptionsInterface::ADS_EU_POLITICAL_DECLARATIONS_COMPLETE, true );
}
return $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 missing EU political declaration: %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
$elements = $campaign_results->iterateAllElements();
if ( $elements !== null ) {
foreach ( $elements 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 );
// Create budget operations.
$budget_operations = [ $this->budget->create_operation( $params['name'], $params['amount'] ) ];
// Create campaign operations.
$campaign_operations = [ $this->create_operation( $params['name'], $base_country, $params['eu_political_advertising_confirmation'] ) ];
// Create asset group operations.
$ad_asset_group = $this->container->get( AdsAssetGroup::class );
// If final URL and assets are passed create operations for those.
if ( isset( $params['final_url'] ) && isset( $params['assets'] ) ) {
$asset_group_operations = $ad_asset_group->create_operations_with_assets(
$this->temporary_resource_name(),
$params['name'],
$params['final_url'],
$params['assets']
);
} else {
// Create "empty" asset group operations.
$asset_group_operations = $ad_asset_group->create_operations(
$this->temporary_resource_name(),
$params['name']
);
}
// Location/Targeting criteria operations.
$criteria_operations = $this->criterion->create_operations(
$this->temporary_resource_name(),
$location_ids
);
// Operations must be in a specific order to match the temporary ID's.
$operations = array_merge(
$budget_operations,
$campaign_operations,
$asset_group_operations,
$criteria_operations
);
$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 and highest spend campaign.
$transients = $this->container->get( TransientsInterface::class );
$transients->delete( TransientsInterface::ADS_CAMPAIGN_COUNT );
$transients->delete( TransientsInterface::ADS_HIGHEST_SPEND_CAMPAIGN );
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 ( isset( $params['eu_political_advertising_confirmation'] ) && true === $params['eu_political_advertising_confirmation'] ) {
$campaign_fields['contains_eu_political_advertising'] = EuPoliticalAdvertisingStatus::CONTAINS_EU_POLITICAL_ADVERTISING;
} else {
$campaign_fields['contains_eu_political_advertising'] = EuPoliticalAdvertisingStatus::DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING;
}
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 );
}
$this->container->get( TransientsInterface::class )->delete( TransientsInterface::ADS_HIGHEST_SPEND_CAMPAIGN );
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,
]
);
}
}
/**
* Set the EU political advertising flag for a list of campaigns.
*
* @param array $campaigns Array of [ 'id' => int, 'value' => bool ] entries.
*
* @return array Updated campaign IDs.
* @throws ExceptionWithResponseData When an ApiException is caught.
*/
public function set_eu_political_campaigns( array $campaigns ): array {
try {
$operations = [];
foreach ( $campaigns as $campaign ) {
$status = $campaign['value']
? EuPoliticalAdvertisingStatus::CONTAINS_EU_POLITICAL_ADVERTISING
: EuPoliticalAdvertisingStatus::DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING;
$operations[] = $this->edit_operation(
$campaign['id'],
[ 'contains_eu_political_advertising' => $status ]
);
}
if ( ! empty( $operations ) ) {
$this->mutate( $operations );
}
return array_column( $campaigns, '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 updating EU political advertising flag: %s', 'google-listings-and-ads' ), reset( $errors ) ),
$this->map_grpc_code_to_http_status_code( $e ),
null,
[ 'errors' => $errors ]
);
}
}
/**
* Get full campaign details by campaign ID.
*
* @param array $ids
* @return array
*/
public function get_campaigns_by_ids( array $ids ): array {
if ( empty( $ids ) ) {
return [];
}
$query = ( new AdsCampaignQuery() )
->set_client( $this->client, $this->options->get_ads_id() )
->where( 'campaign.id', $ids, 'IN' );
$results = $query->get_results();
$campaigns = [];
foreach ( $results->iterateAllElements() as $row ) {
$campaign = $this->convert_campaign( $row );
$campaigns[ $campaign['id'] ] = $campaign;
}
return $this->combine_campaigns_and_campaign_criterion_results( $campaigns );
}
/**
* 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 and highest spend campaign.
$transients = $this->container->get( TransientsInterface::class );
$transients->delete( TransientsInterface::ADS_CAMPAIGN_COUNT );
$transients->delete( TransientsInterface::ADS_HIGHEST_SPEND_CAMPAIGN );
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,
]
);
}
}
/**
* Retrieve the enabled campaign with the highest spend amount.
* Result is cached to avoid Ads API requests on every admin page load.
*
* @return array
*/
public function get_highest_spend_campaign(): array {
$transients = $this->container->get( TransientsInterface::class );
$cached = $transients->get( TransientsInterface::ADS_HIGHEST_SPEND_CAMPAIGN );
if ( is_array( $cached ) && array_key_exists( 'campaign', $cached ) ) {
return $cached['campaign'];
}
try {
$campaigns = $this->get_campaigns();
} catch ( Exception $e ) {
return [];
}
$result = array_reduce(
$campaigns,
function ( $highest, $campaign ) {
if ( CampaignStatus::ENABLED === $campaign['status'] && ( empty( $highest ) || $campaign['amount'] > $highest['amount'] ) ) {
return $campaign;
}
return $highest;
},
[]
);
$transients->set(
TransientsInterface::ADS_HIGHEST_SPEND_CAMPAIGN,
[ 'campaign' => $result ],
HOUR_IN_SECONDS * 12
);
return $result;
}
/**
* 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|null $country
* @param bool $is_eu_political
*
* @return MutateOperation
*/
protected function create_operation( string $campaign_name, ?string $country, bool $is_eu_political ): MutateOperation {
$merchant_id = $this->options->get_merchant_id();
$campaign_data = [
'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(),
'asset_automation_settings' => [
new AssetAutomationSetting(
[
'asset_automation_type' => AssetAutomationType::FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION,
'asset_automation_status' => AssetAutomationStatus::OPTED_IN,
]
),
],
'contains_eu_political_advertising' => $is_eu_political ? EuPoliticalAdvertisingStatus::CONTAINS_EU_POLITICAL_ADVERTISING : EuPoliticalAdvertisingStatus::DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING,
];
// Only include shopping_setting if Merchant Center account is connected.
if ( $merchant_id > 0 && $country !== null ) {
$campaign_data['shopping_setting'] = new ShoppingSetting(
[
'merchant_id' => $merchant_id,
'feed_label' => $country,
]
);
} else {
// Turn off brand guidelines for non-shopping campaigns.
$campaign_data['brand_guidelines_enabled'] = false;
}
$campaign = new Campaign( $campaign_data );
$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' => [],
// getStartDateTime() returns a full datetime string (e.g. 2025-01-15 00:00:00) from the Google Ads API v23 start_date_time field.
'start_date' => $campaign->hasStartDateTime() ? substr( $campaign->getStartDateTime(), 0, 10 ) : null,
];
$eu_political_enum = $campaign->getContainsEuPoliticalAdvertising();
$data += [
'eu_political_advertising_confirmation' => EuPoliticalAdvertisingStatus::CONTAINS_EU_POLITICAL_ADVERTISING === $eu_political_enum ? true : false,
];
$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' ) );
}
}
/**
* Build campaign asset link operations for Brand Guidelines.
*
* Derives business name and logo asset IDs from the provided $assets array (existing assets)
* and $assets_for_creation + $created_asset_resource_names (newly created assets). If no assets are
* provided, discovers brand assets from campaign/account/asset group.
*
* @param int $campaign_id Campaign ID.
* @param array $assets Optional. The full assets array from the edit payload.
* @param array $assets_for_creation Optional. Assets that were created (same order as $created_asset_resource_names).
* @param array $created_asset_resource_names Optional. Asset resource names from AdsAsset::create_assets() mutate (same order as $assets_for_creation).
* @return MutateOperation[]
*/
public function get_brand_asset_link_operations( int $campaign_id, array $assets = [], array $assets_for_creation = [], array $created_asset_resource_names = [] ): array {
try {
// Query existing campaign-level brand assets (for replace semantics and limit checks).
$campaign_assets = ( new AdsCampaignAssetQuery() )
->set_client( $this->client, $this->options->get_ads_id() )
->where( 'campaign.id', $campaign_id, '=' )
->where( 'campaign_asset.status', 'REMOVED', '!=' )
->get_results();
$has_business_name = false;
$has_logo = false;
$existing_business_name_resource = null;
$existing_logo_resource = null;
foreach ( $campaign_assets->iterateAllElements() as $row ) {
$campaign_asset = $row->getCampaignAsset();
if ( ! $campaign_asset ) {
continue;
}
$field_type = AssetFieldType::label( $campaign_asset->getFieldType() );
if ( AssetFieldType::BUSINESS_NAME === $field_type ) {
$has_business_name = true;
$existing_business_name_resource = $campaign_asset->getResourceName();
}
if ( AssetFieldType::LOGO === $field_type ) {
$has_logo = true;
$existing_logo_resource = $campaign_asset->getResourceName();
}
}
// Derive brand asset IDs from the provided assets data.
$business_name_ids = [];
$logo_ids = [];
if ( ! empty( $assets ) || ! empty( $assets_for_creation ) ) {
// Extract IDs from existing unchanged assets (assets with 'id' but no 'content' key).
// Note: Deletion operations have 'content' => null, so we use array_key_exists to check key presence, not value.
foreach ( $assets as $asset ) {
if ( ! empty( $asset['id'] ) && ! array_key_exists( 'content', $asset ) && isset( $asset['field_type'] ) ) {
if ( 'business_name' === $asset['field_type'] ) {
$business_name_ids[] = (int) $asset['id'];
} elseif ( 'logo' === $asset['field_type'] ) {
$logo_ids[] = (int) $asset['id'];
}
}
}
// Extract IDs from newly created assets by matching assets_for_creation to created_asset_resource_names.
$total_created = count( $assets_for_creation );
for ( $i = 0; $i < $total_created; $i++ ) {
if ( empty( $created_asset_resource_names[ $i ] ) ) {
continue;
}
$field_type = $assets_for_creation[ $i ]['field_type'] ?? '';
if ( 'business_name' === $field_type || 'logo' === $field_type ) {
$asset_id = $this->parse_asset_id_from_resource_name( $created_asset_resource_names[ $i ] );
if ( $asset_id !== null ) {
if ( 'business_name' === $field_type ) {
$business_name_ids[] = $asset_id;
} else {
$logo_ids[] = $asset_id;
}
}
}
}
}
// When brand asset IDs were derived from the edit payload: replace existing links so changes persist.
if ( ! empty( $business_name_ids ) || ! empty( $logo_ids ) ) {
$operations = [];
if ( ! empty( $business_name_ids ) && $existing_business_name_resource !== null ) {
$operations[] = $this->campaign_asset->create_remove_operation( $existing_business_name_resource );
}
if ( ! empty( $logo_ids ) && $existing_logo_resource !== null ) {
$operations[] = $this->campaign_asset->create_remove_operation( $existing_logo_resource );
}
$ids_to_link_business = ! empty( $business_name_ids ) ? [ $business_name_ids[0] ] : [];
$ids_to_link_logo = ! empty( $logo_ids ) ? [ $logo_ids[0] ] : [];
$link_ops = $this->campaign_asset->create_link_operations( $campaign_id, $ids_to_link_business, $ids_to_link_logo );
return array_merge( $operations, $link_ops );
}
// Discovery path (no IDs provided): only link what's missing to avoid exceeding limit.
if ( $has_business_name && $has_logo ) {
return [];
}
$needs_business_name = ! $has_business_name;
$needs_logo = ! $has_logo;
if ( ! $needs_business_name && ! $needs_logo ) {
return [];
}
// Query for account-level assets: TEXT assets for business name, IMAGE assets for logos
// Use string enum names in the WHERE clause, not numeric values
$account_assets = ( new AdsAssetQuery() )
->set_client( $this->client, $this->options->get_ads_id() )
->where( 'asset.type', [ 'TEXT', 'IMAGE' ], 'IN' )
->where( 'asset.status', 'REMOVED', '!=' )
->get_results();
$business_ids = [];
$logo_ids = [];
foreach ( $account_assets->iterateAllElements() as $row ) {
$asset = $row->getAsset();
if ( ! $asset ) {
continue;
}
$type = AdsAssetType::name( $asset->getType() );
// TEXT assets can be business name (prefer if available)
if ( $needs_business_name && 'TEXT' === $type && empty( $business_ids ) ) {
$business_ids[] = $asset->getId();
}
// IMAGE assets are logos
if ( $needs_logo && 'IMAGE' === $type ) {
$logo_ids[] = $asset->getId();
}
if ( ( ! $needs_business_name || ! empty( $business_ids ) ) && ( ! $needs_logo || ! empty( $logo_ids ) ) ) {
break;
}
}
// If we couldn't find the required assets at account level, we cannot proceed further.
// Note: We avoid querying asset groups here to prevent circular dependency (AdsAssetGroup depends on AdsCampaign).
// The caller should provide assets via the $assets parameter for proper brand asset linking.
if ( empty( $business_ids ) && empty( $logo_ids ) ) {
return [];
}
return $this->campaign_asset->create_link_operations( $campaign_id, $business_ids, $logo_ids );
} catch ( Exception $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
return [];
}
}
/**
* Get campaign-level business name and logo assets for display (when Brand Guidelines is enabled).
* Used to populate the asset group edit form since these assets are linked at campaign level, not asset group level.
*
* @param int $campaign_id Campaign ID.
* @return array{business_name: array{id: int, content: string}|null, logo: array<array{id: int, content: string}>}
*/
public function get_campaign_brand_assets_for_display( int $campaign_id ): array {
$result = [
'business_name' => null,
'logo' => [],
];
try {
$asset_columns = [
'asset.id',
'asset.type',
'asset.text_asset.text',
'asset.image_asset.full_size.url',
'asset.name',
];
$results = ( new AdsCampaignAssetQuery() )
->add_columns( $asset_columns )
->set_client( $this->client, $this->options->get_ads_id() )
->where( 'campaign.id', $campaign_id, '=' )
->where( 'campaign_asset.field_type', [ AssetFieldType::name( AssetFieldType::BUSINESS_NAME ), AssetFieldType::name( AssetFieldType::LOGO ) ], 'IN' )
->where( 'campaign_asset.status', 'REMOVED', '!=' )
->get_results();
$asset = $this->container->get( AdsAsset::class );
foreach ( $results->iterateAllElements() as $row ) {
$campaign_asset = $row->getCampaignAsset();
if ( ! $campaign_asset || ! $row->getAsset() ) {
continue;
}
$field_type = AssetFieldType::label( $campaign_asset->getFieldType() );
$converted = $asset->convert_asset( $row );
if ( AssetFieldType::BUSINESS_NAME === $field_type ) {
$result['business_name'] = $converted;
}
if ( AssetFieldType::LOGO === $field_type ) {
$result['logo'][] = $converted;
}
}
} catch ( Exception $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
}
return $result;
}
/**
* Parse asset ID from resource name.
*
* @param string $resource_name Resource name containing ID.
* @return int|null
*/
protected function parse_asset_id_from_resource_name( string $resource_name ): ?int {
$parts = explode( '/', $resource_name );
return ! empty( $parts ) ? absint( end( $parts ) ) : null;
}
/**
* 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' ) );
}
}
}