HEX
Server: LiteSpeed
System: Linux eko108.isimtescil.net 4.18.0-477.21.1.lve.1.el8.x86_64 #1 SMP Tue Sep 5 23:08:35 UTC 2023 x86_64
User: uyarreklamcomtr (11202)
PHP: 7.4.33
Disabled: opcache_get_status
Upload Files
File: /var/www/vhosts/uyarreklam.com.tr/httpdocs/MerchantCenter.tar
AccountService.php000064400000055711151541650340010207 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;

use Automattic\Jetpack\Connection\Client;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Merchant;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Middleware;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\SiteVerification;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\NotificationsService;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\OAuthService;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\MerchantIssueTable;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\ShippingRateTable;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\ShippingTimeTable;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ApiNotReady;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\CleanupSyncedProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\AdsAccountState;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\MerchantAccountState;
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\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Container\ContainerInterface;
use Exception;
use Jetpack_Options;

defined( 'ABSPATH' ) || exit;

/**
 * Class AccountService
 *
 * Container used to access:
 * - Ads
 * - AdsAccountState
 * - JobRepository
 * - Merchant
 * - MerchantCenterService
 * - MerchantIssueTable
 * - MerchantStatuses
 * - Middleware
 * - SiteVerification
 * - ShippingRateTable
 * - ShippingTimeTable
 *
 * @since 1.12.0
 * @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
 */
class AccountService implements ContainerAwareInterface, OptionsAwareInterface, Service {

	use ContainerAwareTrait;
	use OptionsAwareTrait;
	use PluginHelper;

	/**
	 * @var MerchantAccountState
	 */
	protected $state;

	/**
	 * Perform a website claim with overwrite.
	 *
	 * @var bool
	 */
	protected $overwrite_claim = false;

	/**
	 * Allow switching the existing website URL.
	 *
	 * @var bool
	 */
	protected $allow_switch_url = false;

	/**
	 * AccountService constructor.
	 *
	 * @param MerchantAccountState $state
	 */
	public function __construct( MerchantAccountState $state ) {
		$this->state = $state;
	}

	/**
	 * Get all Merchant Accounts associated with the connected account.
	 *
	 * @return array
	 * @throws Exception When an API error occurs.
	 */
	public function get_accounts(): array {
		return $this->container->get( Middleware::class )->get_merchant_accounts();
	}

	/**
	 * Use an existing MC account. Mark the 'set_id' step as done, update the MC account's website URL,
	 * and sets the Merchant ID.
	 *
	 * @param int $account_id The merchant ID to use.
	 *
	 * @throws ExceptionWithResponseData If there's a website URL conflict, or account data can't be retrieved.
	 */
	public function use_existing_account_id( int $account_id ): void {
		// Reset the process if the provided ID isn't the same as the one stored in options.
		$merchant_id = $this->options->get_merchant_id();
		if ( $merchant_id && $merchant_id !== $account_id ) {
			$this->reset_account_setup();
		}

		$state = $this->state->get();

		// Don't do anything if this step was already finished.
		if ( MerchantAccountState::STEP_DONE === $state['set_id']['status'] ) {
			return;
		}

		try {
			// Make sure the existing account has the correct website URL (or fail).
			$this->maybe_add_merchant_center_url( $account_id );

			// Re-fetch state as it might have changed.
			$state      = $this->state->get();
			$middleware = $this->container->get( Middleware::class );

			// Maybe the existing account is a sub-account!
			$state['set_id']['data']['from_mca'] = false;
			foreach ( $middleware->get_merchant_accounts() as $existing_account ) {
				if ( $existing_account['id'] === $account_id ) {
					$state['set_id']['data']['from_mca'] = $existing_account['subaccount'];
					break;
				}
			}

			$middleware->link_merchant_account( $account_id );
			$state['set_id']['status'] = MerchantAccountState::STEP_DONE;
			$this->state->update( $state );
		} catch ( ExceptionWithResponseData $e ) {
			throw $e;
		} catch ( Exception $e ) {
			throw $this->prepare_exception( $e->getMessage(), [], $e->getCode() );
		}
	}

	/**
	 * Run the process for setting up a Merchant Center account (sub-account or standalone).
	 *
	 * @param int $account_id
	 *
	 * @return array The account ID if setup has completed.
	 * @throws ExceptionWithResponseData When the account is already connected or a setup error occurs.
	 */
	public function setup_account( int $account_id ) {
		// Reset the process if the provided ID isn't the same as the one stored in options.
		$merchant_id = $this->options->get_merchant_id();
		if ( $merchant_id && $merchant_id !== $account_id ) {
			$this->reset_account_setup();
		}

		try {
			return $this->setup_account_steps();
		} catch ( ExceptionWithResponseData | ApiNotReady $e ) {
			throw $e;
		} catch ( Exception $e ) {
			throw $this->prepare_exception( $e->getMessage(), [], $e->getCode() );
		}
	}

	/**
	 * Create or link an account, switching the URL during the set_id step.
	 *
	 * @param int $account_id
	 *
	 * @return array
	 * @throws ExceptionWithResponseData When a setup error occurs.
	 */
	public function switch_url( int $account_id ): array {
		$state            = $this->state->get();
		$switch_necessary = ! empty( $state['set_id']['data']['old_url'] );
		$set_id_status    = $state['set_id']['status'] ?? MerchantAccountState::STEP_PENDING;
		if ( ! $account_id || MerchantAccountState::STEP_DONE === $set_id_status || ! $switch_necessary ) {
			throw $this->prepare_exception(
				__( 'Attempting invalid URL switch.', 'google-listings-and-ads' )
			);
		}

		$this->allow_switch_url = true;
		$this->use_existing_account_id( $account_id );
		return $this->setup_account( $account_id );
	}

	/**
	 * Create or link an account, overwriting the website claim during the claim step.
	 *
	 * @param int $account_id
	 *
	 * @return array
	 * @throws ExceptionWithResponseData When a setup error occurs.
	 */
	public function overwrite_claim( int $account_id ): array {
		$state               = $this->state->get( false );
		$overwrite_necessary = ! empty( $state['claim']['data']['overwrite_required'] );
		$claim_status        = $state['claim']['status'] ?? MerchantAccountState::STEP_PENDING;
		if ( MerchantAccountState::STEP_DONE === $claim_status || ! $overwrite_necessary ) {
			throw $this->prepare_exception(
				__( 'Attempting invalid claim overwrite.', 'google-listings-and-ads' )
			);
		}

		$this->overwrite_claim = true;
		return $this->setup_account( $account_id );
	}

	/**
	 * Get the connected merchant account.
	 *
	 * @return array
	 */
	public function get_connected_status(): array {
		/** @var NotificationsService $notifications_service */
		$notifications_service = $this->container->get( NotificationsService::class );

		$id                    = $this->options->get_merchant_id();
		$wpcom_rest_api_status = $this->options->get( OptionsInterface::WPCOM_REST_API_STATUS );

		// If token is revoked outside the extension. Set the status as error to force the merchant to grant access again.
		if ( $wpcom_rest_api_status === 'approved' && ! $this->is_wpcom_api_status_healthy() ) {
			$wpcom_rest_api_status = OAuthService::STATUS_ERROR;
			$this->options->update( OptionsInterface::WPCOM_REST_API_STATUS, $wpcom_rest_api_status );
		}

		$status = [
			'id'                           => $id,
			'status'                       => $id ? 'connected' : 'disconnected',
			'notification_service_enabled' => $notifications_service->is_enabled(),
			'wpcom_rest_api_status'        => $wpcom_rest_api_status,
		];

		$incomplete = $this->state->last_incomplete_step();
		if ( ! empty( $incomplete ) ) {
			$status['status'] = 'incomplete';
			$status['step']   = $incomplete;
		}

		return $status;
	}

	/**
	 * Return the setup status to determine what step to continue at.
	 *
	 * @return array
	 */
	public function get_setup_status(): array {
		return $this->container->get( MerchantCenterService::class )->get_setup_status();
	}

	/**
	 * Disconnect Merchant Center account
	 */
	public function disconnect() {
		$this->options->delete( OptionsInterface::CONTACT_INFO_SETUP );
		$this->options->delete( OptionsInterface::MC_SETUP_COMPLETED_AT );
		$this->options->delete( OptionsInterface::MERCHANT_ACCOUNT_STATE );
		$this->options->delete( OptionsInterface::MERCHANT_CENTER );
		$this->options->delete( OptionsInterface::SITE_VERIFICATION );
		$this->options->delete( OptionsInterface::TARGET_AUDIENCE );
		$this->options->delete( OptionsInterface::MERCHANT_ID );
		$this->options->delete( OptionsInterface::CLAIMED_URL_HASH );

		$this->container->get( MerchantStatuses::class )->delete();

		$this->container->get( MerchantIssueTable::class )->truncate();
		$this->container->get( ShippingRateTable::class )->truncate();
		$this->container->get( ShippingTimeTable::class )->truncate();

		$this->container->get( JobRepository::class )->get( CleanupSyncedProducts::class )->schedule();

		$this->container->get( TransientsInterface::class )->delete( TransientsInterface::MC_ACCOUNT_REVIEW );
		$this->container->get( TransientsInterface::class )->delete( TransientsInterface::URL_MATCHES );
		$this->container->get( TransientsInterface::class )->delete( TransientsInterface::MC_IS_SUBACCOUNT );
	}

	/**
	 * Performs the steps necessary to initialize a Merchant Center account.
	 * Should always resume up at the last pending or unfinished step. If the Merchant Center account
	 * has already been created, the ID is simply returned.
	 *
	 * @return array The newly created (or pre-existing) Merchant account data.
	 * @throws ExceptionWithResponseData If an error occurs during any step.
	 * @throws Exception                 If the step is unknown.
	 * @throws ApiNotReady               If we should wait to complete the next step.
	 */
	private function setup_account_steps() {
		$state       = $this->state->get();
		$merchant_id = $this->options->get_merchant_id();
		$merchant    = $this->container->get( Merchant::class );
		$middleware  = $this->container->get( Middleware::class );

		foreach ( $state as $name => &$step ) {
			if ( MerchantAccountState::STEP_DONE === $step['status'] ) {
				continue;
			}

			if ( 'link' === $name ) {
				$time_to_wait = $this->state->get_seconds_to_wait_after_created();
				if ( $time_to_wait ) {
					sleep( $time_to_wait );
				}
			}

			try {
				switch ( $name ) {
					case 'set_id':
						// Just in case, don't create another merchant ID.
						if ( ! empty( $merchant_id ) ) {
							break;
						}
						$merchant_id                       = $middleware->create_merchant_account();
						$step['data']['from_mca']          = true;
						$step['data']['created_timestamp'] = time();
						break;
					case 'verify':
						// Skip if previously verified.
						if ( $this->state->is_site_verified() ) {
							break;
						}

						$site_url = esc_url_raw( $this->get_site_url() );
						$this->container->get( SiteVerification::class )->verify_site( $site_url );
						break;
					case 'link':
						$middleware->link_merchant_to_mca();
						break;
					case 'claim':
						// At this step, the website URL is assumed to be correct.
						// If the URL is already claimed, no claim should be attempted.
						if ( $merchant->get_accountstatus( $merchant_id )->getWebsiteClaimed() ) {
							break;
						}

						if ( $this->overwrite_claim ) {
							$middleware->claim_merchant_website( true );
						} else {
							$merchant->claimwebsite();
						}
						break;
					case 'link_ads':
						// Continue to next step if Ads account is not connected yet.
						if ( ! $this->options->get_ads_id() ) {
							// Save step as pending and continue the foreach loop with `continue 2`.
							$state[ $name ]['status'] = MerchantAccountState::STEP_PENDING;
							$this->state->update( $state );
							continue 2;
						}

						$this->link_ads_account();
						break;
					default:
						throw new Exception(
							sprintf(
								/* translators: 1: is a string representing an unknown step name */
								__( 'Unknown merchant account creation step %1$s', 'google-listings-and-ads' ),
								$name
							)
						);
				}
				$step['status']  = MerchantAccountState::STEP_DONE;
				$step['message'] = '';
				$this->state->update( $state );
			} catch ( Exception $e ) {
				$step['status']  = MerchantAccountState::STEP_ERROR;
				$step['message'] = $e->getMessage();

				// URL already claimed.
				if ( 'claim' === $name && 403 === $e->getCode() ) {
					$data = [
						'id'          => $merchant_id,
						'website_url' => $this->strip_url_protocol(
							esc_url_raw( $this->get_site_url() )
						),
					];

					// Sub-account: request overwrite confirmation.
					if ( $state['set_id']['data']['from_mca'] ?? true ) {
						do_action( 'woocommerce_gla_site_claim_overwrite_required', [] );
						$step['data']['overwrite_required'] = true;

						$e = $this->prepare_exception( $e->getMessage(), $data, $e->getCode() );
					} else {
						do_action( 'woocommerce_gla_site_claim_failure', [ 'details' => 'independent_account' ] );

						// Independent account: overwrite not possible.
						$e = $this->prepare_exception(
							__( 'Unable to claim website URL with this Merchant Center Account.', 'google-listings-and-ads' ),
							$data,
							406
						);
					}
				} elseif ( 'link' === $name && 401 === $e->getCode() ) {
					// New sub-account not yet manipulable.
					$state['set_id']['data']['created_timestamp'] = time();

					$e = ApiNotReady::retry_after( MerchantAccountState::MC_DELAY_AFTER_CREATE );
				}

				$this->state->update( $state );
				throw $e;
			}
		}

		return [ 'id' => $merchant_id ];
	}

	/**
	 * Restart the account setup when we are connecting with a different account ID.
	 * Do not allow reset when the full setup process has completed.
	 *
	 * @throws ExceptionWithResponseData When the full setup process is completed.
	 */
	private function reset_account_setup() {
		// Can't reset if the MC connection process has been completed previously.
		if ( $this->container->get( MerchantCenterService::class )->is_setup_complete() ) {
			throw $this->prepare_exception(
				sprintf(
					/* translators: 1: is a numeric account ID */
					__( 'Merchant Center account already connected: %d', 'google-listings-and-ads' ),
					$this->options->get_merchant_id()
				)
			);
		}

		$this->disconnect();
	}

	/**
	 * Ensure the Merchant Center account's Website URL matches the site URL. Update an empty value or
	 * a different, unclaimed URL value. Throw a 409 exception if a different, claimed URL is found.
	 *
	 * @param int $merchant_id The Merchant Center account to update.
	 *
	 * @throws ExceptionWithResponseData If the account URL doesn't match the site URL or the URL is invalid.
	 */
	private function maybe_add_merchant_center_url( int $merchant_id ) {
		$site_url = esc_url_raw( $this->get_site_url() );

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

		/** @var Merchant $merchant */
		$merchant = $this->container->get( Merchant::class );

		/** @var Account $account */
		$account     = $merchant->get_account( $merchant_id );
		$account_url = $account->getWebsiteUrl() ?: '';

		if ( untrailingslashit( $site_url ) !== untrailingslashit( $account_url ) ) {

			$is_website_claimed = $merchant->get_accountstatus( $merchant_id )->getWebsiteClaimed();

			if ( ! empty( $account_url ) && $is_website_claimed && ! $this->allow_switch_url ) {
				$state                              = $this->state->get();
				$state['set_id']['data']['old_url'] = $account_url;
				$state['set_id']['status']          = MerchantAccountState::STEP_ERROR;
				$this->state->update( $state );

				$clean_account_url = $this->strip_url_protocol( $account_url );
				$clean_site_url    = $this->strip_url_protocol( $site_url );

				do_action( 'woocommerce_gla_url_switch_required', [] );

				throw $this->prepare_exception(
					sprintf(
					/* translators: 1: is a website URL (without the protocol) */
						__( 'This Merchant Center account already has a verified and claimed URL, %1$s', 'google-listings-and-ads' ),
						$clean_account_url
					),
					[
						'id'          => $merchant_id,
						'claimed_url' => $clean_account_url,
						'new_url'     => $clean_site_url,
					],
					409
				);
			}

			$account->setWebsiteUrl( $site_url );
			$merchant->update_account( $account );

			// Clear previous hashed URL.
			$this->options->delete( OptionsInterface::CLAIMED_URL_HASH );

			do_action( 'woocommerce_gla_url_switch_success', [] );
		}
	}

	/**
	 * Get the callback function for linking an Ads account.
	 *
	 * @throws Exception When the merchant account hasn't been set yet.
	 */
	private function link_ads_account() {
		if ( ! $this->options->get_merchant_id() ) {
			throw new Exception( 'A Merchant Center account must be connected' );
		}

		$ads_state = $this->container->get( AdsAccountState::class );

		// Create link for Merchant and accept it in Ads.
		$waiting_acceptance = $this->container->get( Merchant::class )->link_ads_id( $this->options->get_ads_id() );

		if ( $waiting_acceptance ) {
			$this->container->get( Ads::class )->accept_merchant_link( $this->options->get_merchant_id() );
		}

		$ads_state->complete_step( 'link_merchant' );
	}

	/**
	 * Prepares an Exception to be thrown with Merchant data:
	 * - Ensure it has the merchant_id value
	 * - Default to a 400 error code
	 *
	 * @param string   $message
	 * @param array    $data
	 * @param int|null $code
	 *
	 * @return ExceptionWithResponseData
	 */
	private function prepare_exception( string $message, array $data = [], ?int $code = null ): ExceptionWithResponseData {
		$merchant_id = $this->options->get_merchant_id();

		if ( $merchant_id && ! isset( $data['id'] ) ) {
			$data['id'] = $merchant_id;
		}

		return new ExceptionWithResponseData( $message, $code ?: 400, null, $data );
	}

	/**
	 * Delete the option regarding WPCOM authorization
	 *
	 * @return bool
	 */
	public function reset_wpcom_api_authorization_data(): bool {
		$this->delete_wpcom_api_auth_nonce();
		$this->delete_wpcom_api_status_transient();
		return $this->options->delete( OptionsInterface::WPCOM_REST_API_STATUS );
	}

	/**
	 * Update the status of the merchant granting access to Google's WPCOM app in the database.
	 * Before updating the status in the DB it will compare the nonce stored in the DB with the nonce passed to the API.
	 *
	 * @param string $status The status of the merchant granting access to Google's WPCOM app.
	 * @param string $nonce  The nonce provided by Google in the URL query parameter when Google redirects back to merchant's site.
	 *
	 * @return bool
	 * @throws ExceptionWithResponseData If the stored nonce / nonce from query param is not provided, or the nonces mismatch.
	 */
	public function update_wpcom_api_authorization( string $status, string $nonce ): bool {
		try {
			$stored_nonce = $this->options->get( OptionsInterface::GOOGLE_WPCOM_AUTH_NONCE );
			if ( empty( $stored_nonce ) ) {
				throw $this->prepare_exception(
					__( 'No stored nonce found in the database, skip updating auth status.', 'google-listings-and-ads' )
				);
			}

			if ( empty( $nonce ) ) {
				throw $this->prepare_exception(
					__( 'Nonce is not provided, skip updating auth status.', 'google-listings-and-ads' )
				);
			}

			if ( $stored_nonce !== $nonce ) {
				$this->delete_wpcom_api_auth_nonce();
				throw $this->prepare_exception(
					__( 'Nonces mismatch, skip updating auth status.', 'google-listings-and-ads' )
				);
			}

			$this->delete_wpcom_api_auth_nonce();

			/**
			* When the WPCOM Authorization status has been updated.
			*
			* @event update_wpcom_api_authorization
			* @property string status The status of the request.
			* @property int|null blog_id The blog ID.
			*/
			do_action(
				'woocommerce_gla_track_event',
				'update_wpcom_api_authorization',
				[
					'status'  => $status,
					'blog_id' => Jetpack_Options::get_option( 'id' ),
				]
			);

			$this->delete_wpcom_api_status_transient();
			return $this->options->update( OptionsInterface::WPCOM_REST_API_STATUS, $status );
		} catch ( ExceptionWithResponseData $e ) {

			/**
			* When the WPCOM Authorization status has been updated with errors.
			*
			* @event update_wpcom_api_authorization
			* @property string status The status of the request.
			* @property int|null blog_id The blog ID.
			*/
			do_action(
				'woocommerce_gla_track_event',
				'update_wpcom_api_authorization',
				[
					'status'  => $e->getMessage(),
					'blog_id' => Jetpack_Options::get_option( 'id' ),
				]
			);

			throw $e;
		}
	}

	/**
	 * Delete the nonce of "verifying Google is the one redirect back to merchant site and set the auth status" in the database.
	 *
	 * @return bool
	 */
	public function delete_wpcom_api_auth_nonce(): bool {
		return $this->options->delete( OptionsInterface::GOOGLE_WPCOM_AUTH_NONCE );
	}

	/**
	 * Deletes the transient storing the WPCOM Status data.
	 */
	public function delete_wpcom_api_status_transient(): void {
		$transients = $this->container->get( TransientsInterface::class );
		$transients->delete( TransientsInterface::WPCOM_API_STATUS );
	}

	/**
	 * Check if the WPCOM API Status is healthy by doing a request to /wc/partners/google/remote-site-status endpoint in WPCOM.
	 *
	 * @return bool True when the status is healthy, false otherwise.
	 */
	public function is_wpcom_api_status_healthy() {
		/** @var TransientsInterface $transients */
		$transients = $this->container->get( TransientsInterface::class );
		$status     = $transients->get( TransientsInterface::WPCOM_API_STATUS );

		if ( ! $status ) {

			$integration_status_args = [
				'method'  => 'GET',
				'timeout' => 30,
				'url'     => 'https://public-api.wordpress.com/wpcom/v2/sites/' . Jetpack_Options::get_option( 'id' ) . '/wc/partners/google/remote-site-status',
				'user_id' => get_current_user_id(),
			];

			$integration_remote_request_response = Client::remote_request( $integration_status_args, null );

			if ( is_wp_error( $integration_remote_request_response ) ) {
				$status = [ 'is_healthy' => false ];
			} else {
				$status = json_decode( wp_remote_retrieve_body( $integration_remote_request_response ), true ) ?? [ 'is_healthy' => false ];
			}

			$transients->set( TransientsInterface::WPCOM_API_STATUS, $status, MINUTE_IN_SECONDS * 30 );
		}

		return isset( $status['is_healthy'] ) && $status['is_healthy'] && $status['is_wc_rest_api_healthy'] && $status['is_partner_token_healthy'];
	}
}
ContactInformation.php000064400000005416151541650340011070 0ustar00<?php


namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Merchant;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Settings;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountBusinessInformation;

/**
 * Class ContactInformation.
 *
 * @since 1.4.0
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
 */
class ContactInformation implements Service {

	/**
	 * @var Merchant
	 */
	protected $merchant;

	/**
	 * @var Settings
	 */
	protected $settings;

	/**
	 * ContactInformation constructor.
	 *
	 * @param Merchant $merchant
	 * @param Settings $settings
	 */
	public function __construct( Merchant $merchant, Settings $settings ) {
		$this->merchant = $merchant;
		$this->settings = $settings;
	}

	/**
	 * Get the contact information for the connected Merchant Center account.
	 *
	 * @return AccountBusinessInformation|null The contact information associated with the Merchant Center account or
	 *                                         null.
	 *
	 * @throws ExceptionWithResponseData If the Merchant Center account can't be retrieved.
	 */
	public function get_contact_information(): ?AccountBusinessInformation {
		$business_information = $this->merchant->get_account()->getBusinessInformation();

		return $business_information ?: null;
	}

	/**
	 * Update the address for the connected Merchant Center account to the store address set in WooCommerce
	 * settings.
	 *
	 * @return AccountBusinessInformation The contact information associated with the Merchant Center account.
	 *
	 * @throws ExceptionWithResponseData If the Merchant Center account can't be retrieved or updated.
	 */
	public function update_address_based_on_store_settings(): AccountBusinessInformation {
		$business_information = $this->get_contact_information() ?: new AccountBusinessInformation();

		$store_address = $this->settings->get_store_address();
		$business_information->setAddress( $store_address );

		$this->update_contact_information( $business_information );

		return $business_information;
	}

	/**
	 * Update the contact information for the connected Merchant Center account.
	 *
	 * @param AccountBusinessInformation $business_information
	 *
	 * @throws ExceptionWithResponseData If the Merchant Center account can't be retrieved or updated.
	 */
	protected function update_contact_information( AccountBusinessInformation $business_information ): void {
		$account = $this->merchant->get_account();
		$account->setBusinessInformation( $business_information );
		$this->merchant->update_account( $account );
	}
}
MerchantCenterAwareInterface.php000064400000000711151541650340012763 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;

defined( 'ABSPATH' ) || exit;

/**
 * Interface MerchantCenterAwareInterface
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
 */
interface MerchantCenterAwareInterface {

	/**
	 * @param MerchantCenterService $merchant_center
	 */
	public function set_merchant_center_object( MerchantCenterService $merchant_center ): void;
}
MerchantCenterAwareTrait.php000064400000001134151541650340012146 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;

defined( 'ABSPATH' ) || exit;

/**
 * Trait MerchantCenterAwareTrait
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
 */
trait MerchantCenterAwareTrait {

	/**
	 * The MerchantCenterService object.
	 *
	 * @var MerchantCenterService
	 */
	protected $merchant_center;


	/**
	 * @param MerchantCenterService $merchant_center
	 */
	public function set_merchant_center_object( MerchantCenterService $merchant_center ): void {
		$this->merchant_center = $merchant_center;
	}
}
MerchantCenterService.php000064400000032364151541650340011514 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsService;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Merchant;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Settings;
use Automattic\WooCommerce\GoogleListingsAndAds\API\WP\NotificationsService;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\ShippingRateQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\ShippingTimeQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\MerchantAccountState;
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\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\AddressUtility;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountAddress;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountBusinessInformation;
use DateTime;

defined( 'ABSPATH' ) || exit;

/**
 * Class MerchantCenterService
 *
 * ContainerAware used to access:
 * - AddressUtility
 * - AdsService
 * - ContactInformation
 * - Merchant
 * - MerchantAccountState
 * - MerchantStatuses
 * - Settings
 * - ShippingRateQuery
 * - ShippingTimeQuery
 * - TransientsInterface
 * - WC
 * - WP
 * - TargetAudience
 * - GoogleHelper
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
 */
class MerchantCenterService implements ContainerAwareInterface, OptionsAwareInterface, Service {

	use ContainerAwareTrait;
	use OptionsAwareTrait;
	use PluginHelper;

	/**
	 * MerchantCenterService constructor.
	 */
	public function __construct() {
		add_filter(
			'woocommerce_gla_custom_merchant_issues',
			function ( array $issues, DateTime $cache_created_time ) {
				return $this->maybe_add_contact_info_issue( $issues, $cache_created_time );
			},
			10,
			2
		);
	}

	/**
	 * Get whether Merchant Center setup is completed.
	 *
	 * @return bool
	 */
	public function is_setup_complete(): bool {
		return boolval( $this->options->get( OptionsInterface::MC_SETUP_COMPLETED_AT, false ) );
	}

	/**
	 * Get whether Merchant Center is connected.
	 *
	 * @return bool
	 */
	public function is_connected(): bool {
		return $this->is_google_connected() && $this->is_setup_complete();
	}

	/**
	 * Get whether the dependent Google account is connected.
	 *
	 * @return bool
	 */
	public function is_google_connected(): bool {
		return boolval( $this->options->get( OptionsInterface::GOOGLE_CONNECTED, false ) );
	}

	/**
	 * Whether we are able to sync data to the Merchant Center account.
	 * Account must be connected and the URL we claimed with must match the site URL.
	 * URL matches is stored in a transient to prevent it from being refetched in cases
	 * where the site is unable to access account data.
	 *
	 * @since 1.13.0
	 * @return boolean
	 */
	public function is_ready_for_syncing(): bool {
		if ( ! $this->is_connected() ) {
			return false;
		}

		/** @var TransientsInterface $transients */
		$transients  = $this->container->get( TransientsInterface::class );
		$url_matches = $transients->get( TransientsInterface::URL_MATCHES );

		if ( null === $url_matches ) {
			$claimed_url_hash = $this->container->get( Merchant::class )->get_claimed_url_hash();
			$site_url_hash    = md5( untrailingslashit( $this->get_site_url() ) );
			$url_matches      = apply_filters( 'woocommerce_gla_ready_for_syncing', $claimed_url_hash === $site_url_hash ) ? 'yes' : 'no';
			$transients->set( TransientsInterface::URL_MATCHES, $url_matches, HOUR_IN_SECONDS * 12 );
		}

		return 'yes' === $url_matches;
	}

	/**
	 * Whether we should push data into MC. Only if:
	 * - MC is ready for syncing {@see is_ready_for_syncing}
	 * - Notifications Service is not enabled
	 *
	 * @return bool
	 * @since 2.8.0
	 */
	public function should_push(): bool {
		return $this->is_ready_for_syncing();
	}


	/**
	 * Get whether the country is supported by the Merchant Center.
	 *
	 * @return bool True if the country is in the list of MC-supported countries.
	 *
	 * @since 1.9.0
	 */
	public function is_store_country_supported(): bool {
		$country = $this->container->get( WC::class )->get_base_country();

		/** @var GoogleHelper $google_helper */
		$google_helper = $this->container->get( GoogleHelper::class );

		return $google_helper->is_country_supported( $country );
	}

	/**
	 * Get whether the language is supported by the Merchant Center.
	 *
	 * @param  string $language Optional - to check a language other than the site language.
	 * @return bool True if the language is in the list of MC-supported languages.
	 */
	public function is_language_supported( string $language = '' ): bool {
		// Default to base site language
		if ( empty( $language ) ) {
			$language = substr( $this->container->get( WP::class )->get_locale(), 0, 2 );
		}

		/** @var GoogleHelper $google_helper */
		$google_helper = $this->container->get( GoogleHelper::class );

		return array_key_exists(
			strtolower( $language ),
			$google_helper->get_mc_supported_languages()
		);
	}

	/**
	 * Get whether the contact information has been setup.
	 *
	 * @since 1.4.0
	 *
	 * @return bool
	 */
	public function is_contact_information_setup(): bool {
		if ( true === boolval( $this->options->get( OptionsInterface::CONTACT_INFO_SETUP, false ) ) ) {
			return true;
		}

		// Additional check for users that have already gone through on-boarding.
		if ( $this->is_setup_complete() ) {
			$is_mc_setup = $this->is_mc_contact_information_setup();
			$this->options->update( OptionsInterface::CONTACT_INFO_SETUP, $is_mc_setup );
			return $is_mc_setup;
		}

		return false;
	}

	/**
	 * Return if the given country is supported to have promotions on Google.
	 *
	 * @param string $country
	 *
	 * @return bool
	 */
	public function is_promotion_supported_country( string $country = '' ): bool {
		// Default to WooCommerce store country
		if ( empty( $country ) ) {
			$country = $this->container->get( WC::class )->get_base_country();
		}

		/** @var GoogleHelper $google_helper */
		$google_helper = $this->container->get( GoogleHelper::class );

		return in_array( $country, $google_helper->get_mc_promotion_supported_countries(), true );
	}

	/**
	 * Return the setup status to determine what step to continue at.
	 *
	 * @return array
	 */
	public function get_setup_status(): array {
		if ( $this->is_setup_complete() ) {
			return [ 'status' => 'complete' ];
		}

		$step = 'accounts';

		if (
			$this->connected_account() &&
			$this->container->get( AdsService::class )->connected_account() &&
			$this->is_mc_contact_information_setup()
		) {
			$step = 'product_listings';

			if ( $this->saved_target_audience() && $this->saved_shipping_and_tax_options() ) {
				$step = 'paid_ads';
			}
		}

		return [
			'status' => 'incomplete',
			'step'   => $step,
		];
	}

	/**
	 * Check if account has been connected.
	 *
	 * @return bool
	 */
	protected function connected_account(): bool {
		$id = $this->options->get_merchant_id();
		return $id && ! $this->container->get( MerchantAccountState::class )->last_incomplete_step();
	}

	/**
	 * Check if target audience has been saved (with a valid selection of countries).
	 *
	 * @return bool
	 */
	protected function saved_target_audience(): bool {
		$audience = $this->options->get( OptionsInterface::TARGET_AUDIENCE );

		if ( empty( $audience ) || ! isset( $audience['location'] ) ) {
			return false;
		}

		$empty_selection = 'selected' === $audience['location'] && empty( $audience['countries'] );
		return ! $empty_selection;
	}

	/**
	 * Checks if we should add an issue when the contact information is not setup.
	 *
	 * @since 1.4.0
	 *
	 * @param array    $issues             The current array of custom issues
	 * @param DateTime $cache_created_time The time of the cache/issues generation.
	 *
	 * @return array
	 */
	protected function maybe_add_contact_info_issue( array $issues, DateTime $cache_created_time ): array {
		if ( $this->is_setup_complete() && ! $this->is_contact_information_setup() ) {
			$issues[] = [
				'product_id' => 0,
				'product'    => 'All products',
				'code'       => 'missing_contact_information',
				'issue'      => __( 'No contact information.', 'google-listings-and-ads' ),
				'action'     => __( 'Add store contact information', 'google-listings-and-ads' ),
				'action_url' => $this->get_settings_url(),
				'created_at' => $cache_created_time->format( 'Y-m-d H:i:s' ),
				'type'       => MerchantStatuses::TYPE_ACCOUNT,
				'severity'   => 'error',
				'source'     => 'filter',
			];
		}

		return $issues;
	}

	/**
	 * Check if the Merchant Center contact information has been setup already.
	 *
	 * @since 1.4.0
	 *
	 * @return boolean
	 */
	protected function is_mc_contact_information_setup(): bool {
		$is_setup = [
			'address' => false,
		];

		try {
			$contact_info = $this->container->get( ContactInformation::class )->get_contact_information();
		} catch ( ExceptionWithResponseData $exception ) {
			do_action(
				'woocommerce_gla_debug_message',
				'Error retrieving Merchant Center account\'s business information.',
				__METHOD__
			);

			return false;
		}

		if ( $contact_info instanceof AccountBusinessInformation ) {
			/** @var Settings $settings */
			$settings = $this->container->get( Settings::class );

			if ( $contact_info->getAddress() instanceof AccountAddress && $settings->get_store_address() instanceof AccountAddress ) {
				$is_setup['address'] = $this->container->get( AddressUtility::class )->compare_addresses(
					$contact_info->getAddress(),
					$settings->get_store_address()
				);
			}
		}

		return $is_setup['address'];
	}

	/**
	 * Check if the taxes + shipping rate and time + free shipping settings have been saved.
	 *
	 * @return bool If all required settings have been provided.
	 *
	 * @since 1.4.0
	 */
	protected function saved_shipping_and_tax_options(): bool {
		$merchant_center_settings = $this->options->get( OptionsInterface::MERCHANT_CENTER, [] );
		$target_countries         = $this->container->get( TargetAudience::class )->get_target_countries();

		// Tax options saved if: not US (no taxes) or tax_rate has been set
		if ( in_array( 'US', $target_countries, true ) && empty( $merchant_center_settings['tax_rate'] ) ) {
			return false;
		}

		// Shipping time saved if: 'manual' OR records for all countries
		if ( isset( $merchant_center_settings['shipping_time'] ) && 'manual' === $merchant_center_settings['shipping_time'] ) {
			$saved_shipping_time = true;
		} else {
			$shipping_time_rows = $this->container->get( ShippingTimeQuery::class )->get_results();

			// Get the name of countries that have saved shipping times.
			$saved_time_countries = array_column( $shipping_time_rows, 'country' );

			// Check if all target countries have a shipping time.
			$saved_shipping_time = count( $shipping_time_rows ) === count( $target_countries ) &&
				empty( array_diff( $target_countries, $saved_time_countries ) );
		}

		// Shipping rates saved if: 'manual', 'automatic', OR there are records for all countries
		if (
			isset( $merchant_center_settings['shipping_rate'] ) &&
			in_array( $merchant_center_settings['shipping_rate'], [ 'manual', 'automatic' ], true )
		) {
			$saved_shipping_rate = true;
		} else {
			// Get the list of saved shipping rates grouped by country.
			/**
			 * @var ShippingRateQuery $shipping_rate_query
			 */
			$shipping_rate_query = $this->container->get( ShippingRateQuery::class );
			$shipping_rate_query->group_by( 'country' );
			$shipping_rate_rows = $shipping_rate_query->get_results();

			// Get the name of countries that have saved shipping rates.
			$saved_rates_countries = array_column( $shipping_rate_rows, 'country' );

			// Check if all target countries have a shipping rate.
			$saved_shipping_rate = count( $shipping_rate_rows ) === count( $target_countries ) &&
				empty( array_diff( $target_countries, $saved_rates_countries ) );
		}

		return $saved_shipping_rate && $saved_shipping_time;
	}

	/**
	 * Determine whether there are any account-level issues.
	 *
	 * @since 1.11.0
	 * @return bool
	 */
	public function has_account_issues(): bool {
		$issues = $this->container->get( MerchantStatuses::class )->get_issues( MerchantStatuses::TYPE_ACCOUNT );

		return isset( $issues['issues'] ) && count( $issues['issues'] ) >= 1;
	}

	/**
	 * Determine whether there is at least one synced product.
	 *
	 * @since 1.11.0
	 * @return bool
	 */
	public function has_at_least_one_synced_product(): bool {
		$statuses = $this->container->get( MerchantStatuses::class )->get_product_statistics();

		return isset( $statuses['statistics']['active'] ) && $statuses['statistics']['active'] >= 1;
	}
}
MerchantStatuses.php000064400000111362151541650340010562 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Merchant;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\ProductMetaQueryHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\MerchantIssueQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Table\MerchantIssueTable;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\Transients;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductMetaHandler;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\MCStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductStatus as GoogleProductStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\DeleteAllProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateAllProducts;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateMerchantProductStatuses;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use DateTime;
use Exception;
use WC_Product;

/**
 * Class MerchantStatuses.
 * Note: this class uses vanilla WP methods get_post, get_post_meta, update_post_meta
 *
 * ContainerAware used to retrieve
 * - JobRepository
 * - Merchant
 * - MerchantCenterService
 * - MerchantIssueQuery
 * - MerchantIssueTable
 * - ProductHelper
 * - ProductRepository
 * - TransientsInterface
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
 */
class MerchantStatuses implements Service, ContainerAwareInterface, OptionsAwareInterface {

	use OptionsAwareTrait;
	use ContainerAwareTrait;
	use PluginHelper;


	/**
	 * The lifetime of the status-related data.
	 */
	public const STATUS_LIFETIME = 12 * HOUR_IN_SECONDS;

	/**
	 * The types of issues.
	 */
	public const TYPE_ACCOUNT = 'account';
	public const TYPE_PRODUCT = 'product';

	/**
	 * Issue severity levels.
	 */
	public const SEVERITY_WARNING = 'warning';
	public const SEVERITY_ERROR   = 'error';

	/**
	 * @var DateTime $cache_created_time For cache age operations.
	 */
	protected $cache_created_time;

	/**
	 * @var array Reference array of countries associated to each product+issue combo.
	 */
	protected $product_issue_countries = [];

	/**
	 * @var array Transient with timestamp and product statuses as reported by Merchant Center.
	 */
	protected $mc_statuses;

	/**
	 * @var array Statuses for each product id and parent id.
	 */
	protected $product_statuses = [
		'products' => [],
		'parents'  => [],
	];

	/**
	 * @var array Default product stats.
	 */
	public const DEFAULT_PRODUCT_STATS = [
		MCStatus::APPROVED           => 0,
		MCStatus::PARTIALLY_APPROVED => 0,
		MCStatus::EXPIRING           => 0,
		MCStatus::PENDING            => 0,
		MCStatus::DISAPPROVED        => 0,
		MCStatus::NOT_SYNCED         => 0,
		'parents'                    => [],
	];

	/**
	 * @var array Initial intermediate data for product status counts.
	 */
	protected $initial_intermediate_data = self::DEFAULT_PRODUCT_STATS;

	/**
	 * @var WC_Product[] Lookup of WooCommerce Product Objects.
	 */
	protected $product_data_lookup = [];

	/**
	 * MerchantStatuses constructor.
	 */
	public function __construct() {
		$this->cache_created_time = new DateTime();
	}

	/**
	 * Get the Product Statistics (updating caches if necessary). This is the
	 * number of product IDs with each status (approved and partially approved are combined as active).
	 *
	 * @param bool $force_refresh Force refresh of all product status data.
	 *
	 * @return array The product status statistics.
	 * @throws Exception If no Merchant Center account is connected, or account status is not retrievable.
	 */
	public function get_product_statistics( bool $force_refresh = false ): array {
		$job               = $this->maybe_refresh_status_data( $force_refresh );
		$failure_rate_msg  = $job->get_failure_rate_message();
		$this->mc_statuses = $this->container->get( TransientsInterface::class )->get( Transients::MC_STATUSES );

		// If the failure rate is too high, return an error message so the UI can stop polling.
		if ( $failure_rate_msg && null === $this->mc_statuses ) {
			return [
				'timestamp'  => $this->cache_created_time->getTimestamp(),
				'statistics' => null,
				'loading'    => false,
				'error'      => __( 'The scheduled job has been paused due to a high failure rate.', 'google-listings-and-ads' ),
			];
		}

		if ( $job->is_scheduled() || null === $this->mc_statuses ) {
			return [
				'timestamp'  => $this->cache_created_time->getTimestamp(),
				'statistics' => null,
				'loading'    => true,
				'error'      => null,
			];
		}

		if ( ! empty( $this->mc_statuses['error'] ) ) {
			return $this->mc_statuses;
		}

		$counting_stats = $this->mc_statuses['statistics'];
		$counting_stats = array_merge(
			[ 'active' => $counting_stats[ MCStatus::PARTIALLY_APPROVED ] + $counting_stats[ MCStatus::APPROVED ] ],
			$counting_stats
		);
		unset( $counting_stats[ MCStatus::PARTIALLY_APPROVED ], $counting_stats[ MCStatus::APPROVED ] );

		return array_merge(
			$this->mc_statuses,
			[ 'statistics' => $counting_stats ]
		);
	}

	/**
	 * Retrieve the Merchant Center issues and total count. Refresh if the cache issues have gone stale.
	 * Issue details are reduced, and for products, grouped by type.
	 * Issues can be filtered by type, severity and searched by name or ID (if product type) and paginated.
	 * Count takes into account the type filter, but not the pagination.
	 *
	 * In case there are issues with severity Error we hide the other issues with lower severity.
	 *
	 * @param string|null $type To filter by issue type if desired.
	 * @param int         $per_page The number of issues to return (0 for no limit).
	 * @param int         $page The page to start on (1-indexed).
	 * @param bool        $force_refresh Force refresh of all product status data.
	 *
	 * @return array With two indices, results (may be paged), count (considers type) and loading (indicating whether the data is loading).
	 * @throws Exception If the account state can't be retrieved from Google.
	 */
	public function get_issues( ?string $type = null, int $per_page = 0, int $page = 1, bool $force_refresh = false ): array {
		$job = $this->maybe_refresh_status_data( $force_refresh );

		// Get only error issues
		$severity_error_issues = $this->fetch_issues( $type, $per_page, $page, true );

		// In case there are error issues we show only those, otherwise we show all the issues.
		$issues            = $severity_error_issues['total'] > 0 ? $severity_error_issues : $this->fetch_issues( $type, $per_page, $page );
		$issues['loading'] = $job->is_scheduled();

		return $issues;
	}

	/**
	 * Clears the status cache data.
	 *
	 * @since 1.1.0
	 */
	public function clear_cache(): void {
		$job_repository          = $this->container->get( JobRepository::class );
		$update_all_products_job = $job_repository->get( UpdateAllProducts::class );
		$delete_all_products_job = $job_repository->get( DeleteAllProducts::class );

		// Clear the cache if we are not in the middle of updating/deleting all products. Otherwise, we might update the product stats for each individual batch.
		// See: ClearProductStatsCache::register
		if ( $update_all_products_job->can_schedule( null ) && $delete_all_products_job->can_schedule( null ) ) {
			$this->container->get( TransientsInterface::class )->delete( TransientsInterface::MC_STATUSES );
		}
	}

	/**
	 * Delete the intermediate product status count data.
	 *
	 * @since 2.6.4
	 */
	protected function delete_product_statuses_count_intermediate_data(): void {
		$this->options->delete( OptionsInterface::PRODUCT_STATUSES_COUNT_INTERMEDIATE_DATA );
	}

	/**
	 * Delete the stale issues from the database.
	 *
	 * @since 2.6.4
	 */
	protected function delete_stale_issues(): void {
		$this->container->get( MerchantIssueTable::class )->delete_stale( $this->cache_created_time );
	}

	/**
	 * Delete the stale mc statuses from the database.
	 *
	 * @since 2.6.4
	 */
	protected function delete_stale_mc_statuses(): void {
		$product_meta_query_helper = $this->container->get( ProductMetaQueryHelper::class );
		$product_meta_query_helper->delete_all_values( ProductMetaHandler::KEY_MC_STATUS );
	}

	/**
	 * Clear the product statuses cache and delete stale issues.
	 *
	 * @since 2.6.4
	 */
	public function clear_product_statuses_cache_and_issues(): void {
		$this->delete_stale_issues();
		$this->delete_stale_mc_statuses();
		$this->delete_product_statuses_count_intermediate_data();
	}

	/**
	 * Check if the Merchant Center account is connected and throw an exception if it's not.
	 *
	 * @since 2.6.4
	 *
	 * @throws Exception If the Merchant Center account is not connected.
	 */
	protected function check_mc_is_connected() {
		$mc_service = $this->container->get( MerchantCenterService::class );

		if ( ! $mc_service->is_connected() ) {

			// Return a 401 to redirect to reconnect flow if the Google account is not connected.
			if ( ! $mc_service->is_google_connected() ) {
				throw new Exception( __( 'Google account is not connected.', 'google-listings-and-ads' ), 401 );
			}

			throw new Exception( __( 'Merchant Center account is not set up.', 'google-listings-and-ads' ) );
		}
	}

	/**
	 * Maybe start the job to refresh the status and issues data.
	 *
	 * @param bool $force_refresh Force refresh of all status-related data.
	 *
	 * @return UpdateMerchantProductStatuses The job to update the statuses.
	 *
	 * @throws Exception If no Merchant Center account is connected, or account status is not retrievable.
	 * @throws NotFoundExceptionInterface  If the class is not found in the container.
	 * @throws ContainerExceptionInterface If the container throws an exception.
	 */
	public function maybe_refresh_status_data( bool $force_refresh = false ): UpdateMerchantProductStatuses {
		$this->check_mc_is_connected();

		// Only refresh if the current data has expired.
		$this->mc_statuses = $this->container->get( TransientsInterface::class )->get( Transients::MC_STATUSES );
		$job               = $this->container->get( JobRepository::class )->get( UpdateMerchantProductStatuses::class );

		// If force_refresh is true or if not transient, return empty array and scheduled the job to update the statuses.
		if ( ! $job->is_scheduled() && ( $force_refresh || ( ! $force_refresh && null === $this->mc_statuses ) ) ) {
			// Delete the transient before scheduling the job because some errors, like the failure rate message, can occur before the job is executed.
			$this->clear_cache();
			// Schedule job to update the statuses. If the failure rate is too high, the job will not be scheduled.
			$job->schedule();
		}

		return $job;
	}

	/**
	 * Delete the cached statistics and issues.
	 */
	public function delete(): void {
		$this->container->get( TransientsInterface::class )->delete( Transients::MC_STATUSES );
		$this->container->get( MerchantIssueTable::class )->truncate();
	}

	/**
	 * Fetch the cached issues from the database.
	 *
	 * @param string|null $type To filter by issue type if desired.
	 * @param int         $per_page The number of issues to return (0 for no limit).
	 * @param int         $page The page to start on (1-indexed).
	 * @param bool        $only_errors Filters only the issues with error and critical severity.
	 *
	 * @return array The requested issues and the total count of issues.
	 * @throws InvalidValue If the type filter is invalid.
	 */
	protected function fetch_issues( ?string $type = null, int $per_page = 0, int $page = 1, bool $only_errors = false ): array {
		/** @var MerchantIssueQuery $issue_query */
		$issue_query = $this->container->get( MerchantIssueQuery::class );

		// Ensure account issues are shown first.
		$issue_query->set_order( 'type' );
		$issue_query->set_order( 'product' );
		$issue_query->set_order( 'issue' );

		// Filter by type if valid.
		if ( in_array( $type, $this->get_valid_issue_types(), true ) ) {
			$issue_query->where( 'type', $type );
		} elseif ( null !== $type ) {
			throw InvalidValue::not_in_allowed_list( 'type filter', $this->get_valid_issue_types() );
		}

		// Result pagination.
		if ( $per_page > 0 ) {
			$issue_query->set_limit( $per_page );
			$issue_query->set_offset( $per_page * ( $page - 1 ) );
		}

		if ( $only_errors ) {
			$issue_query->where( 'severity', [ 'error', 'critical' ], 'IN' );
		}

		$issues = [];
		foreach ( $issue_query->get_results() as $row ) {
			$issue = [
				'type'       => $row['type'],
				'product_id' => intval( $row['product_id'] ),
				'product'    => $row['product'],
				'issue'      => $row['issue'],
				'code'       => $row['code'],
				'action'     => $row['action'],
				'action_url' => $row['action_url'],
				'severity'   => $this->get_issue_severity( $row ),
			];
			if ( $issue['product_id'] ) {
				$issue['applicable_countries'] = json_decode( $row['applicable_countries'], true );
			} else {
				unset( $issue['product_id'] );
			}
			$issues[] = $issue;
		}

		return [
			'issues' => $issues,
			'total'  => $issue_query->get_count(),
		];
	}

	/**
	 * Get MC product issues from a list of Product View statuses.
	 *
	 * @param array $statuses The list of Product View statuses.
	 * @throws NotFoundExceptionInterface  If the class is not found in the container.
	 * @throws ContainerExceptionInterface If the container throws an exception.
	 *
	 * @return array The list of product issues.
	 */
	protected function get_product_issues( array $statuses ): array {
		/** @var Merchant $merchant */
		$merchant = $this->container->get( Merchant::class );
		/** @var ProductHelper $product_helper */
		$product_helper      = $this->container->get( ProductHelper::class );
		$visibility_meta_key = $this->prefix_meta_key( ProductMetaHandler::KEY_VISIBILITY );

		$google_ids     = array_column( $statuses, 'mc_id' );
		$product_issues = [];
		$created_at     = $this->cache_created_time->format( 'Y-m-d H:i:s' );
		$entries        = $merchant->get_productstatuses_batch( $google_ids )->getEntries() ?? [];
		foreach ( $entries as $response_entry ) {
			/** @var GoogleProductStatus $mc_product_status */
			$mc_product_status = $response_entry->getProductStatus();
			$mc_product_id     = $mc_product_status->getProductId();
			$wc_product_id     = $product_helper->get_wc_product_id( $mc_product_id );
			$wc_product        = $this->product_data_lookup[ $wc_product_id ] ?? null;

			// Skip products not synced by this extension.
			if ( ! $wc_product ) {
				do_action(
					'woocommerce_gla_debug_message',
					sprintf( 'Merchant Center product %s not found in this WooCommerce store.', $mc_product_id ),
					__METHOD__ . ' in remove_invalid_statuses()',
				);
				continue;
			}
			// Unsynced issues shouldn't be shown.
			if ( ChannelVisibility::DONT_SYNC_AND_SHOW === $wc_product->get_meta( $visibility_meta_key ) ) {
				continue;
			}

			// Confirm there are issues for this product.
			if ( empty( $mc_product_status->getItemLevelIssues() ) ) {
				continue;
			}

			$product_issue_template = [
				'product'              => html_entity_decode( $wc_product->get_name(), ENT_QUOTES ),
				'product_id'           => $wc_product_id,
				'created_at'           => $created_at,
				'applicable_countries' => [],
				'source'               => 'mc',
			];
			foreach ( $mc_product_status->getItemLevelIssues() as $item_level_issue ) {
				if ( 'merchant_action' !== $item_level_issue->getResolution() ) {
					continue;
				}
				$hash_key = $wc_product_id . '__' . md5( $item_level_issue->getDescription() );

				$this->product_issue_countries[ $hash_key ] = array_merge(
					$this->product_issue_countries[ $hash_key ] ?? [],
					$item_level_issue->getApplicableCountries()
				);

				$product_issues[ $hash_key ] = $product_issue_template + [
					'code'       => $item_level_issue->getCode(),
					'issue'      => $item_level_issue->getDescription(),
					'action'     => $item_level_issue->getDetail(),
					'action_url' => $item_level_issue->getDocumentation(),
					'severity'   => $item_level_issue->getServability(),
				];
			}
		}

		return $product_issues;
	}

	/**
	 * Refresh the account , pre-sync product validation and custom merchant issues.
	 *
	 * @since 2.6.4
	 *
	 * @throws Exception  If the account state can't be retrieved from Google.
	 */
	public function refresh_account_and_presync_issues(): void {
		// Update account-level issues.
		$this->refresh_account_issues();

		// Update pre-sync product validation issues.
		$this->refresh_presync_product_issues();

		// Include any custom merchant issues.
		$this->refresh_custom_merchant_issues();
	}

	/**
	 * Retrieve all account-level issues and store them in the database.
	 *
	 * @throws Exception If the account state can't be retrieved from Google.
	 */
	protected function refresh_account_issues(): void {
		/** @var Merchant $merchant */
		$merchant       = $this->container->get( Merchant::class );
		$account_issues = [];
		$created_at     = $this->cache_created_time->format( 'Y-m-d H:i:s' );
		$issues         = $merchant->get_accountstatus()->getAccountLevelIssues() ?? [];
		foreach ( $issues as $issue ) {
			$key = md5( $issue->getTitle() );

			if ( isset( $account_issues[ $key ] ) ) {
				$account_issues[ $key ]['applicable_countries'][] = $issue->getCountry();
			} else {
				$account_issues[ $key ] = [
					'product_id'           => 0,
					'product'              => __( 'All products', 'google-listings-and-ads' ),
					'code'                 => $issue->getId(),
					'issue'                => $issue->getTitle(),
					'action'               => $issue->getDetail(),
					'action_url'           => $issue->getDocumentation(),
					'created_at'           => $created_at,
					'type'                 => self::TYPE_ACCOUNT,
					'severity'             => $issue->getSeverity(),
					'source'               => 'mc',
					'applicable_countries' => [ $issue->getCountry() ],
				];

				$account_issues[ $key ] = $this->maybe_override_issue_values( $account_issues[ $key ] );
			}
		}

		// Sort and encode countries
		$account_issues = array_map(
			function ( $issue ) {
				sort( $issue['applicable_countries'] );
				$issue['applicable_countries'] = wp_json_encode(
					array_unique(
						$issue['applicable_countries']
					)
				);
				return $issue;
			},
			$account_issues
		);

		/** @var MerchantIssueQuery $issue_query */
		$issue_query = $this->container->get( MerchantIssueQuery::class );
		$issue_query->update_or_insert( $account_issues );
	}

	/**
	 * Custom issues can be added to the merchant issues table.
	 *
	 * @since 1.2.0
	 */
	protected function refresh_custom_merchant_issues() {
		$custom_issues = apply_filters( 'woocommerce_gla_custom_merchant_issues', [], $this->cache_created_time );

		if ( empty( $custom_issues ) ) {
			return;
		}

		/** @var MerchantIssueQuery $issue_query */
		$issue_query = $this->container->get( MerchantIssueQuery::class );
		$issue_query->update_or_insert( $custom_issues );
	}

	/**
	 * Refresh product issues in the merchant issues table.
	 *
	 * @param array $product_issues Array of product issues.
	 * @throws InvalidQuery If an invalid column name is provided.
	 * @throws NotFoundExceptionInterface  If the class is not found in the container.
	 * @throws ContainerExceptionInterface If the container throws an exception.
	 */
	protected function refresh_product_issues( array $product_issues ): void {
		// Alphabetize all product/issue country lists.
		array_walk(
			$this->product_issue_countries,
			function ( &$countries ) {
				sort( $countries );
			}
		);

		// Product issue cleanup: sorting (by product ID) and encode applicable countries.
		ksort( $product_issues );
		$product_issues = array_map(
			function ( $unique_key, $issue ) {
				$issue['applicable_countries'] = wp_json_encode( $this->product_issue_countries[ $unique_key ] );
				return $issue;
			},
			array_keys( $product_issues ),
			$product_issues
		);

		/** @var MerchantIssueQuery $issue_query */
		$issue_query = $this->container->get( MerchantIssueQuery::class );
		$issue_query->update_or_insert( array_values( $product_issues ) );
	}

	/**
	 * Include local presync product validation issues in the merchant issues table.
	 */
	protected function refresh_presync_product_issues(): void {
		/** @var MerchantIssueQuery $issue_query */
		$issue_query  = $this->container->get( MerchantIssueQuery::class );
		$created_at   = $this->cache_created_time->format( 'Y-m-d H:i:s' );
		$issue_action = __( 'Update this attribute in your product data', 'google-listings-and-ads' );

		/** @var ProductMetaQueryHelper $product_meta_query_helper */
		$product_meta_query_helper = $this->container->get( ProductMetaQueryHelper::class );
		// Get all MC statuses.
		$all_errors = $product_meta_query_helper->get_all_values( ProductMetaHandler::KEY_ERRORS );

		$chunk_size     = apply_filters( 'woocommerce_gla_merchant_status_presync_issues_chunk', 500 );
		$product_issues = [];
		foreach ( $all_errors as $product_id => $presync_errors ) {
			// Don't create issues with empty descriptions
			// or for variable parents (they contain issues of all children).
			$error = $presync_errors[ array_key_first( $presync_errors ) ];
			if ( empty( $error ) || ! is_string( $error ) ) {
				continue;
			}

			$product = get_post( $product_id );
			// Don't store pre-sync errors for unpublished (draft, trashed) products.
			if ( 'publish' !== get_post_status( $product ) ) {
				continue;
			}

			foreach ( $presync_errors as $text ) {
				$issue_parts      = $this->parse_presync_issue_text( $text );
				$product_issues[] = [
					'product'              => $product->post_title,
					'product_id'           => $product_id,
					'code'                 => $issue_parts['code'],
					'severity'             => self::SEVERITY_ERROR,
					'issue'                => $issue_parts['issue'],
					'action'               => $issue_action,
					'action_url'           => 'https://support.google.com/merchants/answer/10538362?hl=en&ref_topic=6098333',
					'applicable_countries' => '["all"]',
					'source'               => 'pre-sync',
					'created_at'           => $created_at,
				];
			}

			// Do update-or-insert in chunks.
			if ( count( $product_issues ) >= $chunk_size ) {
				$issue_query->update_or_insert( $product_issues );
				$product_issues = [];
			}
		}

		// Handle any leftover issues.
		$issue_query->update_or_insert( $product_issues );
	}

	/**
	 * Process product status statistics.
	 *
	 * @param array $product_view_statuses Product View statuses.
	 * @see MerchantReport::get_product_view_report
	 *
	 * @throws NotFoundExceptionInterface  If the class is not found in the container.
	 * @throws ContainerExceptionInterface If the container throws an exception.
	 */
	public function process_product_statuses( array $product_view_statuses ): void {
		$this->mc_statuses         = [];
		$product_repository        = $this->container->get( ProductRepository::class );
		$this->product_data_lookup = $product_repository->find_by_ids_as_associative_array( array_column( $product_view_statuses, 'product_id' ) );

		$this->product_statuses = [
			'products' => [],
			'parents'  => [],
		];

		foreach ( $product_view_statuses as $product_status ) {

			$wc_product_id     = $product_status['product_id'];
			$mc_product_status = $product_status['status'];
			$wc_product        = $this->product_data_lookup[ $wc_product_id ] ?? null;

			if ( ! $wc_product || ! $wc_product_id ) {
				// Skip if the product does not exist in WooCommerce.
				do_action(
					'woocommerce_gla_debug_message',
					sprintf( 'Merchant Center product %s not found in this WooCommerce store.', $wc_product_id ),
					__METHOD__,
				);
				continue;
			}

			if ( $this->product_is_expiring( $product_status['expiration_date'] ) ) {
				$mc_product_status = MCStatus::EXPIRING;
			}

			// Products is used later for global product status statistics.
			$this->product_statuses['products'][ $wc_product_id ][ $mc_product_status ] = 1 + ( $this->product_statuses['products'][ $wc_product_id ][ $mc_product_status ] ?? 0 );

			// Aggregate parent statuses for mc_status postmeta.
			$wc_parent_id = $wc_product->get_parent_id();
			if ( ! $wc_parent_id ) {
				continue;
			}
			$this->product_statuses['parents'][ $wc_parent_id ][ $mc_product_status ] = 1 + ( $this->product_statuses['parents'][ $wc_parent_id ][ $mc_product_status ] ?? 0 );

		}

		$parent_keys               = array_values( array_keys( $this->product_statuses['parents'] ) );
		$parent_products           = $product_repository->find_by_ids_as_associative_array( $parent_keys );
		$this->product_data_lookup = $this->product_data_lookup + $parent_products;

		// Update each product's mc_status and then update the global statistics.
		$this->update_products_meta_with_mc_status();
		$this->update_intermediate_product_statistics();

		$product_issues = $this->get_product_issues( $product_view_statuses );
		$this->refresh_product_issues( $product_issues );
	}

	/**
	 * Whether a product is expiring.
	 *
	 * @param DateTime $expiration_date
	 *
	 * @return bool Whether the product is expiring.
	 */
	protected function product_is_expiring( DateTime $expiration_date ): bool {
		if ( ! $expiration_date ) {
			return false;
		}

		// Products are considered expiring if they will expire within 3 days.
		return time() + 3 * DAY_IN_SECONDS > $expiration_date->getTimestamp();
	}

	/**
	 * Sum and update the intermediate product status statistics. It will group
	 * the variations for the same parent.
	 *
	 * For the case that one variation is approved and the other disapproved:
	 * 1. Give each status a priority.
	 * 2. Store the last highest priority status in `$parent_statuses`.
	 * 3. Compare if a higher priority status is found for that variable product.
	 * 4. Loop through the `$parent_statuses` array at the end to add the final status counts.
	 *
	 * @return array Product status statistics.
	 */
	protected function update_intermediate_product_statistics(): array {
		$product_statistics = self::DEFAULT_PRODUCT_STATS;

		// If the option is set, use it to sum the total quantity.
		$product_statistics_intermediate_data = $this->options->get( OptionsInterface::PRODUCT_STATUSES_COUNT_INTERMEDIATE_DATA );
		if ( $product_statistics_intermediate_data ) {
			$product_statistics              = $product_statistics_intermediate_data;
			$this->initial_intermediate_data = $product_statistics;
		}

		$product_statistics_priority = [
			MCStatus::APPROVED           => 6,
			MCStatus::PARTIALLY_APPROVED => 5,
			MCStatus::EXPIRING           => 4,
			MCStatus::PENDING            => 3,
			MCStatus::DISAPPROVED        => 2,
			MCStatus::NOT_SYNCED         => 1,
		];

		$parent_statuses = [];

		foreach ( $this->product_statuses['products'] as $product_id => $statuses ) {
			foreach ( $statuses as $status => $num_products ) {
				$product = $this->product_data_lookup[ $product_id ] ?? null;

				if ( ! $product ) {
					continue;
				}

				$parent_id = $product->get_parent_id();

				if ( ! $parent_id ) {
					$product_statistics[ $status ] += $num_products;
				} elseif ( ! isset( $parent_statuses[ $parent_id ] ) ) {
					$parent_statuses[ $parent_id ] = $status;
				} else {
					$current_parent_status = $parent_statuses[ $parent_id ];
					if ( $product_statistics_priority[ $status ] < $product_statistics_priority[ $current_parent_status ] ) {
						$parent_statuses[ $parent_id ] = $status;
					}
				}
			}
		}

		foreach ( $parent_statuses as $parent_id => $new_parent_status ) {
			$current_parent_intermediate_data_status = $product_statistics_intermediate_data['parents'][ $parent_id ] ?? null;

			if ( $current_parent_intermediate_data_status === $new_parent_status ) {
				continue;
			}

			if ( ! $current_parent_intermediate_data_status ) {
				$product_statistics[ $new_parent_status ]   += 1;
				$product_statistics['parents'][ $parent_id ] = $new_parent_status;
				continue;
			}

			// Check if the new parent status has higher priority than the previous one.
			if ( $product_statistics_priority[ $new_parent_status ] < $product_statistics_priority[ $current_parent_intermediate_data_status ] ) {
				$product_statistics[ $current_parent_intermediate_data_status ] -= 1;
				$product_statistics[ $new_parent_status ]                       += 1;
				$product_statistics['parents'][ $parent_id ]                     = $new_parent_status;
			} else {
				$product_statistics['parents'][ $parent_id ] = $current_parent_intermediate_data_status;
			}
		}

		$this->options->update( OptionsInterface::PRODUCT_STATUSES_COUNT_INTERMEDIATE_DATA, $product_statistics );

		return $product_statistics;
	}

	/**
	 * Calculate the total count of products in the MC using the statistics.
	 *
	 * @since 2.6.4
	 *
	 * @param array $statistics
	 *
	 * @return int
	 */
	protected function calculate_total_synced_product_statistics( array $statistics ): int {
		if ( ! count( $statistics ) ) {
			return 0;
		}

		$synced_status_values = array_values( array_diff( $statistics, [ $statistics[ MCStatus::NOT_SYNCED ] ] ) );
		return array_sum( $synced_status_values );
	}

	/**
	 * Handle the failure of the Merchant Center statuses fetching.
	 *
	 * @since 2.6.4
	 *
	 * @param string $error_message The error message.
	 *
	 * @throws NotFoundExceptionInterface  If the class is not found in the container.
	 * @throws ContainerExceptionInterface If the container throws an exception.
	 */
	public function handle_failed_mc_statuses_fetching( string $error_message = '' ): void {
		// Reset the intermediate data to the initial state when starting the job.
		$this->options->update( OptionsInterface::PRODUCT_STATUSES_COUNT_INTERMEDIATE_DATA, $this->initial_intermediate_data );
		// Let's remove any issue created during the failed fetch.
		$this->container->get( MerchantIssueTable::class )->delete_specific_product_issues( array_keys( $this->product_data_lookup ) );

		$mc_statuses = [
			'timestamp'  => $this->cache_created_time->getTimestamp(),
			'statistics' => null,
			'loading'    => false,
			'error'      => $error_message,
		];

		$this->container->get( TransientsInterface::class )->set(
			Transients::MC_STATUSES,
			$mc_statuses,
			$this->get_status_lifetime()
		);
	}


	/**
	 * Handle the completion of the Merchant Center statuses fetching.
	 *
	 * @since 2.6.4
	 */
	public function handle_complete_mc_statuses_fetching() {
		$intermediate_data = $this->options->get( OptionsInterface::PRODUCT_STATUSES_COUNT_INTERMEDIATE_DATA, self::DEFAULT_PRODUCT_STATS );

		unset( $intermediate_data['parents'] );

		$total_synced_products = $this->calculate_total_synced_product_statistics( $intermediate_data );

		/** @var ProductRepository $product_repository */
		$product_repository                        = $this->container->get( ProductRepository::class );
		$intermediate_data[ MCStatus::NOT_SYNCED ] = count(
			$product_repository->find_all_product_ids()
		) - $total_synced_products;

		$mc_statuses = [
			'timestamp'  => $this->cache_created_time->getTimestamp(),
			'statistics' => $intermediate_data,
			'loading'    => false,
			'error'      => null,
		];

		$this->container->get( TransientsInterface::class )->set(
			Transients::MC_STATUSES,
			$mc_statuses,
			$this->get_status_lifetime()
		);

		$this->delete_product_statuses_count_intermediate_data();
	}


	/**
	 * Update the Merchant Center status for each product.
	 */
	protected function update_products_meta_with_mc_status() {
		// Generate a product_id=>mc_status array.
		$new_product_statuses = [];
		foreach ( $this->product_statuses as $types ) {
			foreach ( $types as $product_id => $statuses ) {
				if ( isset( $statuses[ MCStatus::PENDING ] ) ) {
					$new_product_statuses[ $product_id ] = MCStatus::PENDING;
				} elseif ( isset( $statuses[ MCStatus::EXPIRING ] ) ) {
					$new_product_statuses[ $product_id ] = MCStatus::EXPIRING;
				} elseif ( isset( $statuses[ MCStatus::APPROVED ] ) ) {
					if ( count( $statuses ) > 1 ) {
						$new_product_statuses[ $product_id ] = MCStatus::PARTIALLY_APPROVED;
					} else {
						$new_product_statuses[ $product_id ] = MCStatus::APPROVED;
					}
				} else {
					$new_product_statuses[ $product_id ] = array_key_first( $statuses );
				}
			}
		}

		foreach ( $new_product_statuses as $product_id => $new_status ) {
			$product = $this->product_data_lookup[ $product_id ] ?? null;

			// At this point, the product should exist in WooCommerce but in the case that product is not found, log it.
			if ( ! $product ) {
				do_action(
					'woocommerce_gla_debug_message',
					sprintf( 'Merchant Center product with WooCommerce ID %d is not found in this store.', $product_id ),
					__METHOD__,
				);
				continue;
			}

			$product->add_meta_data( $this->prefix_meta_key( ProductMetaHandler::KEY_MC_STATUS ), $new_status, true );
			// We use save_meta_data so we don't trigger the woocommerce_update_product hook and the Syncer Hooks.
			$product->save_meta_data();
		}
	}

	/**
	 * Allows a hook to modify the lifetime of the statuses data.
	 *
	 * @return int
	 */
	protected function get_status_lifetime(): int {
		return apply_filters( 'woocommerce_gla_mc_status_lifetime', self::STATUS_LIFETIME );
	}

	/**
	 * Valid issues types for issue type filter.
	 *
	 * @return string[]
	 */
	protected function get_valid_issue_types(): array {
		return [
			self::TYPE_ACCOUNT,
			self::TYPE_PRODUCT,
		];
	}

	/**
	 * Parse the code and formatted issue text out of the presync validation error text.
	 *
	 * Converts the error strings:
	 * "[attribute] Error message." > "Error message [attribute]"
	 *
	 * Note:
	 * If attribute is an array the name can be "[attribute[0]]".
	 * So we need to match the additional set of square brackets.
	 *
	 * @param string $text
	 *
	 * @return string[] With indexes `code` and `issue`
	 */
	protected function parse_presync_issue_text( string $text ): array {
		$matches = [];
		preg_match( '/^\[([^\]]+\]?)\]\s*(.+)$/', $text, $matches );
		if ( count( $matches ) !== 3 ) {
			return [
				'code'  => 'presync_error_attrib_' . md5( $text ),
				'issue' => $text,
			];
		}

		// Convert attribute name "imageLink" to "image".
		if ( 'imageLink' === $matches[1] ) {
			$matches[1] = 'image';
		}

		// Convert attribute name "additionalImageLinks[]" to "galleryImage".
		if ( str_starts_with( $matches[1], 'additionalImageLinks' ) ) {
			$matches[1] = 'galleryImage';
		}

		$matches[2] = trim( $matches[2], ' .' );
		return [
			'code'  => 'presync_error_' . $matches[1],
			'issue' => "{$matches[2]} [{$matches[1]}]",
		];
	}

	/**
	 * Return a standardized Merchant Issue severity value.
	 *
	 * @param array $row
	 *
	 * @return string
	 */
	protected function get_issue_severity( array $row ): string {
		$is_warning = in_array(
			$row['severity'],
			[
				'warning',
				'suggestion',
				'demoted',
				'unaffected',
			],
			true
		);

		return $is_warning ? self::SEVERITY_WARNING : self::SEVERITY_ERROR;
	}

	/**
	 * In very rare instances, issue values need to be overridden manually.
	 *
	 * @param array $issue
	 *
	 * @return array The original issue with any possibly overridden values.
	 */
	private function maybe_override_issue_values( array $issue ): array {
		/**
		 * Code 'merchant_quality_low' for matching the original issue.
		 * Ref: https://developers.google.com/shopping-content/guides/account-issues#merchant_quality_low
		 *
		 * Issue string "Account isn't eligible for free listings" for matching
		 * the updated copy after Free and Enhanced Listings merge.
		 *
		 * TODO: Remove the condition of matching the $issue['issue']
		 *       if its issue code is the same as 'merchant_quality_low'
		 *       after Google replaces the issue title on their side.
		 */
		if ( 'merchant_quality_low' === $issue['code'] || "Account isn't eligible for free listings" === $issue['issue'] ) {
			$issue['issue']      = 'Show products on additional surfaces across Google through free listings';
			$issue['severity']   = self::SEVERITY_WARNING;
			$issue['action_url'] = 'https://support.google.com/merchants/answer/9199328?hl=en';
		}

		/**
		 * Reference: https://github.com/woocommerce/google-listings-and-ads/issues/1688
		 */
		if ( 'home_page_issue' === $issue['code'] ) {
			$issue['issue']      = 'Website claim is lost, need to re verify and claim your website. Please reference the support link';
			$issue['action_url'] = 'https://woocommerce.com/document/google-for-woocommerce/faq/#reverify-website';
		}

		return $issue;
	}

	/**
	 * Getter for get_cache_created_time
	 *
	 * @return DateTime The DateTime stored in cache_created_time
	 */
	public function get_cache_created_time(): DateTime {
		return $this->cache_created_time;
	}
}
PhoneVerification.php000064400000012127151541650340010700 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Merchant;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\ISOUtility;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\PhoneNumber;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\Exception as GoogleServiceException;

defined( 'ABSPATH' ) || exit;

/**
 * Class PhoneVerification
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
 *
 * @since 1.5.0
 */
class PhoneVerification implements Service {

	public const VERIFICATION_METHOD_SMS        = 'SMS';
	public const VERIFICATION_METHOD_PHONE_CALL = 'PHONE_CALL';

	/**
	 * @var Merchant
	 */
	protected $merchant;

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

	/**
	 * @var ISOUtility
	 */
	protected $iso_utility;

	/**
	 * PhoneVerification constructor.
	 *
	 * @param Merchant   $merchant
	 * @param WP         $wp
	 * @param ISOUtility $iso_utility
	 */
	public function __construct( Merchant $merchant, WP $wp, ISOUtility $iso_utility ) {
		$this->merchant    = $merchant;
		$this->wp          = $wp;
		$this->iso_utility = $iso_utility;
	}

	/**
	 * 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 PhoneNumber $phone_number        Phone number to be verified.
	 * @param string      $verification_method Verification method to receive verification code.
	 *
	 * @return string The verification ID to use in subsequent calls to
	 *                `PhoneVerification::verify_phone_number`.
	 *
	 * @throws PhoneVerificationException If there are any errors requesting verification.
	 * @throws InvalidValue If an invalid input provided.
	 */
	public function request_phone_verification( string $region_code, PhoneNumber $phone_number, string $verification_method ): string {
		$this->validate_verification_method( $verification_method );
		$this->validate_phone_region( $region_code );

		try {
			return $this->merchant->request_phone_verification( $region_code, $phone_number->get(), $verification_method, $this->get_language_code() );
		} catch ( GoogleServiceException $e ) {
			throw $this->map_google_exception( $e );
		}
	}

	/**
	 * Validates verification code to verify phone number for the account.
	 *
	 * @param string $verification_id     The verification ID returned by
	 *                                    `PhoneVerification::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 void
	 *
	 * @throws PhoneVerificationException If there are any errors verifying the phone number.
	 * @throws InvalidValue If an invalid input provided.
	 */
	public function verify_phone_number( string $verification_id, string $verification_code, string $verification_method ): void {
		$this->validate_verification_method( $verification_method );

		try {
			$this->merchant->verify_phone_number( $verification_id, $verification_code, $verification_method );
		} catch ( GoogleServiceException $e ) {
			throw $this->map_google_exception( $e );
		}
	}

	/**
	 * @param string $method
	 *
	 * @throws InvalidValue If the verification method is invalid.
	 */
	protected function validate_verification_method( string $method ) {
		$allowed = [ self::VERIFICATION_METHOD_SMS, self::VERIFICATION_METHOD_PHONE_CALL ];
		if ( ! in_array( $method, $allowed, true ) ) {
			throw InvalidValue::not_in_allowed_list( $method, $allowed );
		}
	}

	/**
	 * @param string $region_code
	 *
	 * @throws InvalidValue If the phone region code is not a valid ISO 3166-1 alpha-2 country code.
	 */
	protected function validate_phone_region( string $region_code ) {
		if ( ! $this->iso_utility->is_iso3166_alpha2_country_code( $region_code ) ) {
			throw new InvalidValue( 'Invalid phone region! Phone region must be a two letter ISO 3166-1 alpha-2 country code.' );
		}
	}

	/**
	 * @return string
	 */
	protected function get_language_code(): string {
		return $this->iso_utility->wp_locale_to_bcp47( $this->wp->get_user_locale() );
	}

	/**
	 * @param GoogleServiceException $exception
	 *
	 * @return PhoneVerificationException
	 */
	protected function map_google_exception( GoogleServiceException $exception ): PhoneVerificationException {
		$code    = $exception->getCode();
		$message = $exception->getMessage();
		$reason  = '';

		$errors = $exception->getErrors();
		if ( ! empty( $errors ) ) {
			$error   = $errors[ array_key_first( $errors ) ];
			$message = $error['message'] ?? '';
			$reason  = $error['reason'] ?? '';
		}

		return new PhoneVerificationException( $message, $code, $exception, [ 'reason' => $reason ] );
	}
}
PhoneVerificationException.php000064400000000646151541650340012562 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;

defined( 'ABSPATH' ) || exit;

/**
 * Class PhoneVerificationException
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product
 *
 * @since 1.5.0
 */
class PhoneVerificationException extends ExceptionWithResponseData {}
PolicyComplianceCheck.php000064400000012444151541650340011456 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\TargetAudience;


defined( 'ABSPATH' ) || exit;

/**
 * Class PolicyComplianceCheck
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
 *
 * @since 2.1.4
 */
class PolicyComplianceCheck implements Service {

	use PluginHelper;

	/**
	 * The WC proxy object.
	 *
	 * @var wc
	 */
	protected $wc;

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

	/**
	 * @var TargetAudience
	 */
	protected $target_audience;

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

	/**
	 * Check if the store website is accessed by all users for the controller.
	 *
	 * @return bool
	 */
	public function is_accessible(): bool {
		$all_allowed_countries = $this->wc->get_allowed_countries();
		$target_countries      = $this->target_audience->get_target_countries();

		foreach ( $target_countries as $country ) {
			if ( ! array_key_exists( $country, $all_allowed_countries ) ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Check if the store sample product landing pages lead to a 404 error.
	 *
	 * @return bool
	 */
	public function has_page_not_found_error(): bool {
		$url      = $this->get_landing_page_url();
		$response = wp_remote_get( $url );
		if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
			return true;
		}
		return false;
	}

	/**
	 * Check if the store sample product landing pages has redirects through 3P domains.
	 *
	 * @return bool
	 */
	public function has_redirects(): bool {
		$url      = $this->get_landing_page_url();
		$response = wp_remote_get( $url, [ 'redirection' => 0 ] );
		$code     = wp_remote_retrieve_response_code( $response );
		if ( $code >= 300 && $code <= 399 ) {
			return true;
		}
		return false;
	}

	/**
	 * Returns a product page URL, uses homepage as a fallback.
	 *
	 * @return string Landing page URL.
	 */
	private function get_landing_page_url(): string {
		$products = wc_get_products(
			[
				'limit'  => 1,
				'status' => 'publish',
			]
		);
		if ( ! empty( $products ) ) {
			return $products[0]->get_permalink();
		}

		return $this->get_site_url();
	}

	/**
	 * Check if the merchant set the restrictions in robots.txt or not in the store.
	 *
	 * @return bool
	 */
	public function has_restriction(): bool {
		return ! $this->robots_allowed( $this->get_site_url() );
	}

	/**
	 * Check if the robots.txt has restrictions or not in the store.
	 *
	 * @param string $url
	 * @return bool
	 */
	private function robots_allowed( $url ) {
		$agents = [ preg_quote( '*', '/' ) ];
		$agents = implode( '|', $agents );

		// location of robots.txt file
		$response = wp_remote_get( trailingslashit( $url ) . 'robots.txt' );

		if ( is_wp_error( $response ) ) {
			return true;
		}

		$body      = wp_remote_retrieve_body( $response );
		$robotstxt = preg_split( "/\r\n|\n|\r/", $body );

		if ( empty( $robotstxt ) ) {
			return true;
		}

		$rule_applies = false;
		foreach ( $robotstxt as $line ) {
			$line = trim( $line );
			if ( ! $line ) {
				continue;
			}

			// following rules only apply if User-agent matches '*'
			if ( preg_match( '/^\s*User-agent:\s*(.*)/i', $line, $match ) ) {
				$rule_applies = '*' === $match[1];

			}

			if ( $rule_applies && preg_match( '/^\s*Disallow:\s*(.*)/i', $line, $regs ) ) {
				if ( ! $regs[1] ) {
					return true;
				}
				if ( '/' === trim( $regs[1] ) ) {
					return false;
				}
			}
		}

		return true;
	}

	/**
	 * Check if the payment gateways is empty or not for the controller.
	 *
	 * @return bool
	 */
	public function has_payment_gateways(): bool {
		$gateways = $this->wc->get_available_payment_gateways();
		if ( empty( $gateways ) ) {
			return false;
		}
		return true;
	}

	/**
	 * Check if the store is using SSL for the controller.
	 *
	 * @return bool
	 */
	public function get_is_store_ssl(): bool {
		return 'https' === wp_parse_url( $this->get_site_url(), PHP_URL_SCHEME );
	}


	/**
	 * Check if the store has refund return policy page for the controller.
	 *
	 * @return bool
	 */
	public function has_refund_return_policy_page(): bool {
		// Check the slug as it's translated by the "woocommerce" text domain name.
		// phpcs:ignore WordPress.WP.I18n.TextDomainMismatch
		if ( $this->the_slug_exists( _x( 'refund_returns', 'Page slug', 'woocommerce' ) ) ) {
			return true;
		}
		return false;
	}

	/**
	 * Check if the slug exists or not.
	 *
	 * @param string $post_name
	 * @return bool
	 */
	protected function the_slug_exists( string $post_name ): bool {
		$args = [
			'name'        => $post_name,
			'post_type'   => 'page',
			'post_status' => 'publish',
			'numberposts' => 1,
		];

		if ( get_posts( $args ) ) {
			return true;
		}
		return false;
	}
}
TargetAudience.php000064400000004317151541650340010152 0ustar00<?php


namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;

/**
 * Class TargetAudience.
 *
 * @since 1.12.0
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
 */
class TargetAudience implements Service {

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

	/**
	 * @var OptionsInterface
	 */
	protected $options;

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

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

	/**
	 * @return string[] List of target countries specified in options. Defaults to WooCommerce store base country.
	 */
	public function get_target_countries(): array {
		$target_countries = [ $this->wc->get_base_country() ];

		$target_audience = $this->options->get( OptionsInterface::TARGET_AUDIENCE );
		if ( empty( $target_audience['location'] ) && empty( $target_audience['countries'] ) ) {
			return $target_countries;
		}

		$location = strtolower( $target_audience['location'] );
		if ( 'all' === $location ) {
			$target_countries = $this->google_helper->get_mc_supported_countries();
		} elseif ( 'selected' === $location && ! empty( $target_audience['countries'] ) ) {
			$target_countries = $target_audience['countries'];
		}

		return $target_countries;
	}

	/**
	 * Return the main target country (default Store country).
	 * If the store country is not included then use the first target country.
	 *
	 * @return string
	 */
	public function get_main_target_country(): string {
		$target_countries = $this->get_target_countries();
		$shop_country     = $this->wc->get_base_country();

		return in_array( $shop_country, $target_countries, true ) ? $shop_country : $target_countries[0];
	}
}
AccountController.php000064400000017167151556464630010750 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ApiNotReady;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\AccountService;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Exception;
use WP_REST_Request as Request;
use WP_REST_Response as Response;

defined( 'ABSPATH' ) || exit;

/**
 * Class AccountController
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class AccountController extends BaseController {

	/**
	 * Service used to access / update Ads account data.
	 *
	 * @var AccountService
	 */
	protected $account;

	/**
	 * AccountController constructor.
	 *
	 * @param RESTServer     $server
	 * @param AccountService $account
	 */
	public function __construct( RESTServer $server, AccountService $account ) {
		parent::__construct( $server );
		$this->account = $account;
	}

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			'mc/accounts',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_accounts_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
				[
					'methods'             => TransportMethods::CREATABLE,
					'callback'            => $this->setup_account_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_schema_properties(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);
		$this->register_route(
			'mc/accounts/claim-overwrite',
			[
				[
					'methods'             => TransportMethods::CREATABLE,
					'callback'            => $this->overwrite_claim_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_schema_properties(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);
		$this->register_route(
			'mc/accounts/switch-url',
			[
				[
					'methods'             => TransportMethods::CREATABLE,
					'callback'            => $this->switch_url_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_schema_properties(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);
		$this->register_route(
			'mc/connection',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_connected_merchant_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
				[
					'methods'             => TransportMethods::DELETABLE,
					'callback'            => $this->disconnect_merchant_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
			]
		);
		$this->register_route(
			'mc/setup',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_setup_merchant_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
			]
		);
	}

	/**
	 * Get the callback function for the list accounts request.
	 *
	 * @return callable
	 */
	protected function get_accounts_callback(): callable {
		return function ( Request $request ) {
			try {
				return array_map(
					function ( $account ) use ( $request ) {
						$data = $this->prepare_item_for_response( $account, $request );
						return $this->prepare_response_for_collection( $data );
					},
					$this->account->get_accounts()
				);
			} catch ( Exception $e ) {
				return $this->response_from_exception( $e );
			}
		};
	}

	/**
	 * Get the callback for creating or linking an account, overwriting the website claim during the claim step.
	 *
	 * @return callable
	 */
	protected function overwrite_claim_callback(): callable {
		return $this->setup_account_callback( 'overwrite_claim' );
	}

	/**
	 * Get the callback for creating or linking an account, switching the URL during the set_id step.
	 *
	 * @return callable
	 */
	protected function switch_url_callback(): callable {
		return $this->setup_account_callback( 'switch_url' );
	}

	/**
	 * Get the callback function for creating or linking an account.
	 *
	 * @param string $action Action to call while setting up account (default is normal setup).
	 * @return callable
	 */
	protected function setup_account_callback( string $action = 'setup_account' ): callable {
		return function ( Request $request ) use ( $action ) {
			try {
				$account_id = absint( $request['id'] );

				if ( $account_id && 'setup_account' === $action ) {
					$this->account->use_existing_account_id( $account_id );
				}

				$account = $this->account->{$action}( $account_id );

				return $this->prepare_item_for_response( $account, $request );
			} catch ( ApiNotReady $e ) {
				return $this->get_time_to_wait_response( $e );
			} catch ( Exception $e ) {
				return $this->response_from_exception( $e );
			}
		};
	}

	/**
	 * Get the callback function for the connected merchant account.
	 *
	 * @return callable
	 */
	protected function get_connected_merchant_callback(): callable {
		return function () {
			return $this->account->get_connected_status();
		};
	}

	/**
	 * Get the callback function for the merchant setup status.
	 *
	 * @return callable
	 */
	protected function get_setup_merchant_callback(): callable {
		return function () {
			return $this->account->get_setup_status();
		};
	}

	/**
	 * Get the callback function for disconnecting a merchant.
	 *
	 * @return callable
	 */
	protected function disconnect_merchant_callback(): callable {
		return function () {
			$this->account->disconnect();

			return [
				'status'  => 'success',
				'message' => __( 'Merchant Center account successfully disconnected.', 'google-listings-and-ads' ),
			];
		};
	}

	/**
	 * Get the item schema for the controller.
	 *
	 * @return array
	 */
	protected function get_schema_properties(): array {
		return [
			'id'         => [
				'type'              => 'number',
				'description'       => __( 'Merchant Center Account ID.', 'google-listings-and-ads' ),
				'context'           => [ 'view', 'edit' ],
				'validate_callback' => 'rest_validate_request_arg',
				'required'          => false,
			],
			'subaccount' => [
				'type'        => 'boolean',
				'description' => __( 'Is a MCA sub account.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'readonly'    => true,
			],
			'name'       => [
				'type'        => 'string',
				'description' => __( 'The Merchant Center Account name.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'required'    => false,
			],
			'domain'     => [
				'type'        => 'string',
				'description' => __( 'The domain registered with the Merchant Center Account.', '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 'account';
	}

	/**
	 * Return a 503 Response with Retry-After header and message.
	 *
	 * @param ApiNotReady $wait Exception containing the time to wait.
	 *
	 * @return Response
	 */
	private function get_time_to_wait_response( ApiNotReady $wait ): Response {
		$data = $wait->get_response_data( true );

		return new Response(
			$data,
			$wait->getCode() ?: 503,
			[
				'Retry-After' => $data['retry_after'],
			]
		);
	}
}
AttributeMappingCategoriesController.php000064400000006520151556464630014630 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WP_REST_Request as Request;
use Exception;

defined( 'ABSPATH' ) || exit;

/**
 * Class for handling API requests for getting category tree in Attribute Mapping
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class AttributeMappingCategoriesController extends BaseController {


	/**
	 * AttributeMappingCategoriesController constructor.
	 *
	 * @param RESTServer $server
	 */
	public function __construct( RESTServer $server ) {
		parent::__construct( $server );
	}

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			'mc/mapping/categories',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_categories_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			],
		);
	}


	/**
	 * Callback function for getting the category tree
	 *
	 * @return callable
	 */
	protected function get_categories_callback(): callable {
		return function ( Request $request ) {
			try {
				$cats = $this->get_category_tree();
				return array_map(
					function ( $cats ) use ( $request ) {
						$response = $this->prepare_item_for_response( $cats, $request );

						return $this->prepare_response_for_collection( $response );
					},
					$cats
				);
			} catch ( Exception $e ) {
				return $this->response_from_exception( $e );
			}
		};
	}

	/**
	 * Get the item schema properties for the controller.
	 *
	 * @return array The Schema properties
	 */
	protected function get_schema_properties(): array {
		return [
			'id'     => [
				'description'       => __( 'The Category ID.', 'google-listings-and-ads' ),
				'type'              => 'integer',
				'validate_callback' => 'rest_validate_request_arg',
				'readonly'          => true,
			],
			'name'   => [
				'description'       => __( 'The category name.', 'google-listings-and-ads' ),
				'type'              => 'string',
				'validate_callback' => 'rest_validate_request_arg',
				'readonly'          => true,
			],
			'parent' => [
				'description'       => __( 'The category parent.', 'google-listings-and-ads' ),
				'type'              => 'integer',
				'validate_callback' => 'rest_validate_request_arg',
				'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 'attribute_mapping_categories';
	}

	/**
	 * Function to get all the categories
	 *
	 * @return array The categories
	 */
	private function get_category_tree(): array {
		$categories = get_categories(
			[
				'taxonomy'   => 'product_cat',
				'hide_empty' => false,
			]
		);

		return array_map(
			function ( $category ) {
				return [
					'id'     => $category->term_id,
					'name'   => $category->name,
					'parent' => $category->parent,
				];
			},
			$categories
		);
	}
}
BatchShippingTrait.php000064400000002273151556464630011027 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use WP_REST_Request as Request;
use WP_REST_Response as Response;

defined( 'ABSPATH' ) || exit;

/**
 * Trait BatchShippingTrait
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
trait BatchShippingTrait {
	/**
	 * Get the callback for deleting shipping items via batch.
	 *
	 * @return callable
	 */
	protected function get_batch_delete_shipping_callback(): callable {
		return function ( Request $request ) {
			$country_codes = $request->get_param( 'country_codes' );

			$responses = [];
			$errors    = [];
			foreach ( $country_codes as $country_code ) {
				$route          = "/{$this->get_namespace()}/{$this->route_base}/{$country_code}";
				$delete_request = new Request( 'DELETE', $route );

				$response = $this->server->dispatch_request( $delete_request );
				if ( 200 !== $response->get_status() ) {
					$errors[] = $response->get_data();
				} else {
					$responses[] = $response->get_data();
				}
			}

			return new Response(
				[
					'errors'  => $errors,
					'success' => $responses,
				],
			);
		};
	}
}
ConnectionController.php000064400000003307151556464630011442 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;

defined( 'ABSPATH' ) || exit;

/**
 * Class ConnectionController
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class ConnectionController extends BaseController {

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			'mc/connect',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_connect_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);
	}

	/**
	 * Get the callback function for the connection request.
	 *
	 * @return callable
	 */
	protected function get_connect_callback(): callable {
		return function () {
			return [
				'url' => 'example.com',
			];
		};
	}

	/**
	 * Get the schema for settings endpoints.
	 *
	 * @return array
	 */
	protected function get_schema_properties(): array {
		return [
			'url' => [
				'description' => __( 'Action that should be completed after connection.', 'google-listings-and-ads' ),
				'type'        => 'string',
				'context'     => [ 'view', 'edit' ],
				'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 'merchant_center_connection';
	}
}
ContactInformationController.php000064400000023506151556464630013147 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Settings;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\ContactInformation;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Automattic\WooCommerce\GoogleListingsAndAds\Utility\AddressUtility;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\PhoneNumber;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountAddress;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\AccountBusinessInformation;
use Exception;
use WP_REST_Request as Request;
use WP_REST_Response as Response;

defined( 'ABSPATH' ) || exit;

/**
 * Class ContactInformationController
 *
 * @since 1.4.0
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class ContactInformationController extends BaseOptionsController {

	/**
	 * @var ContactInformation $contact_information
	 */
	protected $contact_information;

	/**
	 * @var Settings
	 */
	protected $settings;

	/**
	 * @var AddressUtility
	 */
	protected $address_utility;

	/**
	 * ContactInformationController constructor.
	 *
	 * @param RESTServer         $server
	 * @param ContactInformation $contact_information
	 * @param Settings           $settings
	 * @param AddressUtility     $address_utility
	 */
	public function __construct( RESTServer $server, ContactInformation $contact_information, Settings $settings, AddressUtility $address_utility ) {
		parent::__construct( $server );
		$this->contact_information = $contact_information;
		$this->settings            = $settings;
		$this->address_utility     = $address_utility;
	}

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			'mc/contact-information',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_contact_information_endpoint_read_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
				[
					'methods'             => TransportMethods::CREATABLE,
					'callback'            => $this->get_contact_information_endpoint_edit_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_update_args(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);
	}

	/**
	 * Get a callback for the contact information endpoint.
	 *
	 * @return callable
	 */
	protected function get_contact_information_endpoint_read_callback(): callable {
		return function ( Request $request ) {
			try {
				return $this->get_contact_information_response(
					$this->contact_information->get_contact_information(),
					$request
				);
			} catch ( Exception $e ) {
				return $this->response_from_exception( $e );
			}
		};
	}

	/**
	 * Get a callback for the edit contact information endpoint.
	 *
	 * @return callable
	 */
	protected function get_contact_information_endpoint_edit_callback(): callable {
		return function ( Request $request ) {
			try {
				return $this->get_contact_information_response(
					$this->contact_information->update_address_based_on_store_settings(),
					$request
				);
			} catch ( Exception $e ) {
				return $this->response_from_exception( $e );
			}
		};
	}

	/**
	 * Get the schema for contact information endpoints.
	 *
	 * @return array
	 */
	protected function get_schema_properties(): array {
		return [
			'id'                        => [
				'type'              => 'integer',
				'description'       => __( 'The Merchant Center account ID.', 'google-listings-and-ads' ),
				'context'           => [ 'view', 'edit' ],
				'validate_callback' => 'rest_validate_request_arg',
			],
			'phone_number'              => [
				'type'        => 'string',
				'description' => __( 'The phone number associated with the Merchant Center account.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
			],
			'phone_verification_status' => [
				'type'        => 'string',
				'description' => __( 'The verification status of the phone number associated with the Merchant Center account.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'enum'        => [ 'verified', 'unverified' ],
			],
			'mc_address'                => [
				'type'        => 'object',
				'description' => __( 'The address associated with the Merchant Center account.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'properties'  => $this->get_address_schema(),
			],
			'wc_address'                => [
				'type'        => 'object',
				'description' => __( 'The WooCommerce store address.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'properties'  => $this->get_address_schema(),
			],
			'is_mc_address_different'   => [
				'type'        => 'boolean',
				'description' => __( 'Whether the Merchant Center account address is different than the WooCommerce store address.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
			],
			'wc_address_errors'         => [
				'type'        => 'array',
				'description' => __( 'The errors associated with the WooCommerce address', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
			],
		];
	}

	/**
	 * Get the schema for addresses returned by the contact information endpoints.
	 *
	 * @return array[]
	 */
	protected function get_address_schema(): array {
		return [
			'street_address' => [
				'description' => __( 'Street-level part of the address.', 'google-listings-and-ads' ),
				'type'        => 'string',
				'context'     => [ 'view' ],
			],
			'locality'       => [
				'description' => __( 'City, town or commune. May also include dependent localities or sublocalities (e.g. neighborhoods or suburbs).', 'google-listings-and-ads' ),
				'type'        => 'string',
				'context'     => [ 'view' ],
			],
			'region'         => [
				'description' => __( 'Top-level administrative subdivision of the country. For example, a state like California ("CA") or a province like Quebec ("QC").', 'google-listings-and-ads' ),
				'type'        => 'string',
				'context'     => [ 'view' ],
			],
			'postal_code'    => [
				'description' => __( 'Postal code or ZIP (e.g. "94043").', 'google-listings-and-ads' ),
				'type'        => 'string',
				'context'     => [ 'view' ],
			],
			'country'        => [
				'description' => __( 'CLDR country code (e.g. "US").', 'google-listings-and-ads' ),
				'type'        => 'string',
				'context'     => [ 'view' ],
			],
		];
	}

	/**
	 * Get the arguments for the update endpoint.
	 *
	 * @return array
	 */
	public function get_update_args(): array {
		return [
			'context' => $this->get_context_param( [ 'default' => 'view' ] ),
		];
	}

	/**
	 * Get the prepared REST response with Merchant Center account ID and contact information.
	 *
	 * @param AccountBusinessInformation|null $contact_information
	 * @param Request                         $request
	 *
	 * @return Response
	 */
	protected function get_contact_information_response( ?AccountBusinessInformation $contact_information, Request $request ): Response {
		$phone_number              = null;
		$phone_verification_status = null;
		$mc_address                = null;
		$wc_address                = null;
		$is_address_diff           = false;

		if ( $this->settings->get_store_address() instanceof AccountAddress ) {
			$wc_address      = $this->settings->get_store_address();
			$is_address_diff = true;
		}

		if ( $contact_information instanceof AccountBusinessInformation ) {
			if ( ! empty( $contact_information->getPhoneNumber() ) ) {
				try {
					$phone_number              = PhoneNumber::cast( $contact_information->getPhoneNumber() )->get();
					$phone_verification_status = strtolower( $contact_information->getPhoneVerificationStatus() );
				} catch ( InvalidValue $exception ) {
					// log and fail silently
					do_action( 'woocommerce_gla_exception', $exception, __METHOD__ );
				}
			}

			if ( $contact_information->getAddress() instanceof AccountAddress ) {
				$mc_address      = $contact_information->getAddress();
				$is_address_diff = true;
			}

			if ( null !== $mc_address && null !== $wc_address ) {
				$is_address_diff = ! $this->address_utility->compare_addresses( $contact_information->getAddress(), $this->settings->get_store_address() );
			}
		}

		$wc_address_errors = $this->settings->wc_address_errors( $wc_address );

		return $this->prepare_item_for_response(
			[
				'id'                        => $this->options->get_merchant_id(),
				'phone_number'              => $phone_number,
				'phone_verification_status' => $phone_verification_status,
				'mc_address'                => self::serialize_address( $mc_address ),
				'wc_address'                => self::serialize_address( $wc_address ),
				'is_mc_address_different'   => $is_address_diff,
				'wc_address_errors'         => $wc_address_errors,
			],
			$request
		);
	}

	/**
	 * @param AccountAddress|null $address
	 *
	 * @return array|null
	 */
	protected static function serialize_address( ?AccountAddress $address ): ?array {
		if ( null === $address ) {
			return null;
		}

		return [
			'street_address' => $address->getStreetAddress(),
			'locality'       => $address->getLocality(),
			'region'         => $address->getRegion(),
			'postal_code'    => $address->getPostalCode(),
			'country'        => $address->getCountry(),
		];
	}

	/**
	 * Get the item schema name for the controller.
	 *
	 * Used for building the API response schema.
	 *
	 * @return string
	 */
	protected function get_schema_title(): string {
		return 'merchant_center_contact_information';
	}
}
IssuesController.php000064400000015561151556464630010623 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantStatuses;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Exception;
use WP_REST_Request as Request;

defined( 'ABSPATH' ) || exit;

/**
 * Class IssuesController
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class IssuesController extends BaseOptionsController {

	/**
	 * @var MerchantStatuses
	 */
	protected $merchant_statuses;

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

	/**
	 * IssuesController constructor.
	 *
	 * @param RESTServer       $server
	 * @param MerchantStatuses $merchant_statuses
	 * @param ProductHelper    $product_helper
	 */
	public function __construct( RESTServer $server, MerchantStatuses $merchant_statuses, ProductHelper $product_helper ) {
		parent::__construct( $server );
		$this->merchant_statuses = $merchant_statuses;
		$this->product_helper    = $product_helper;
	}

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			'mc/issues(/(?P<type_filter>[a-z]+))?',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_issues_read_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_collection_params(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			],
		);
	}

	/**
	 * Get the callback function for returning account and product issues.
	 *
	 * @return callable
	 */
	protected function get_issues_read_callback(): callable {
		return function ( Request $request ) {
			$type_filter = $request['type_filter'];
			$per_page    = intval( $request['per_page'] );
			$page        = max( 1, intval( $request['page'] ) );

			try {
				$results         = $this->merchant_statuses->get_issues( $type_filter, $per_page, $page );
				$results['page'] = $page;
			} catch ( Exception $e ) {
				return $this->response_from_exception( $e );
			}

			// Replace variation IDs with parent ID (for Edit links).
			foreach ( $results['issues'] as &$issue ) {
				$issue = apply_filters( 'woocommerce_gla_merchant_issue_override', $issue );

				if ( empty( $issue['product_id'] ) ) {
					continue;
				}
				try {
					$issue['product_id'] = $this->product_helper->maybe_swap_for_parent_id( $issue['product_id'] );
				} catch ( InvalidValue $e ) {
					// Don't include invalid products
					do_action(
						'woocommerce_gla_debug_message',
						sprintf( 'Merchant Center product ID %s not found in this WooCommerce store.', $issue['product_id'] ),
						__METHOD__,
					);

					continue;
				}
			}

			return $this->prepare_item_for_response( $results, $request );
		};
	}

	/**
	 * Get the item schema properties for the controller.
	 *
	 * @return array
	 */
	protected function get_schema_properties(): array {
		return [
			'issues'  => [
				'type'        => 'array',
				'description' => __( 'The issues related to the Merchant Center account.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'readonly'    => true,
				'items'       => [
					'type'       => 'object',
					'properties' => [
						'type'                 => [
							'type'        => 'string',
							'description' => __( 'Issue type.', 'google-listings-and-ads' ),
							'context'     => [ 'view' ],
						],
						'product'              => [
							'type'        => 'string',
							'description' => __( 'Affected product.', 'google-listings-and-ads' ),
							'context'     => [ 'view' ],
						],
						'product_id'           => [
							'type'        => 'numeric',
							'description' => __( 'The WooCommerce product ID.', 'google-listings-and-ads' ),
							'context'     => [ 'view' ],
						],
						'code'                 => [
							'type'        => 'string',
							'description' => __( 'Internal Google code for issue.', 'google-listings-and-ads' ),
							'context'     => [ 'view' ],
						],
						'issue'                => [
							'type'        => 'string',
							'description' => __( 'Descriptive text of the issue.', 'google-listings-and-ads' ),
							'context'     => [ 'view' ],
						],
						'action'               => [
							'type'        => 'string',
							'description' => __( 'Descriptive text of action to take.', 'google-listings-and-ads' ),
							'context'     => [ 'view' ],
						],
						'action_url'           => [
							'type'        => 'string',
							'description' => __( 'Documentation URL for issue and/or action.', 'google-listings-and-ads' ),
							'context'     => [ 'view' ],
						],
						'severity'             => [
							'type'        => 'string',
							'description' => __( 'Severity level of the issue: warning or error.', 'google-listings-and-ads' ),
							'context'     => [ 'view' ],
						],
						'applicable_countries' => [
							'type'        => 'array',
							'description' => __( 'Country codes of the product audience.', 'google-listings-and-ads' ),
							'context'     => [ 'view' ],
						],
					],
				],
			],
			'total'   => [
				'type'     => 'numeric',
				'context'  => [ 'view' ],
				'readonly' => true,
			],
			'page'    => [
				'type'     => 'numeric',
				'context'  => [ 'view' ],
				'readonly' => true,
			],
			'loading' => [
				'type'        => 'boolean',
				'description' => __( 'Whether the product issues are loading.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'readonly'    => true,
			],
		];
	}


	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	public function get_collection_params(): array {
		return [
			'context'  => $this->get_context_param( [ 'default' => 'view' ] ),
			'page'     => [
				'description'       => __( 'Page of data to retrieve.', 'google-listings-and-ads' ),
				'type'              => 'integer',
				'default'           => 1,
				'minimum'           => 1,
				'sanitize_callback' => 'absint',
				'validate_callback' => 'rest_validate_request_arg',
			],
			'per_page' => [
				'description'       => __( 'Maximum number of rows to be returned in result data.', 'google-listings-and-ads' ),
				'type'              => 'integer',
				'default'           => 0,
				'minimum'           => 0,
				'sanitize_callback' => 'absint',
				'validate_callback' => 'rest_validate_request_arg',
			],
		];
	}

	/**
	 * Get the item schema name for the controller.
	 *
	 * Used for building the API response schema.
	 *
	 * @return string
	 */
	protected function get_schema_title(): string {
		return 'merchant_issues';
	}
}
PhoneVerificationController.php000064400000012051151556464630012753 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\EmptySchemaPropertiesTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\PhoneVerification;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\PhoneNumber;
use Exception;
use WP_REST_Request as Request;
use WP_REST_Response as Response;

defined( 'ABSPATH' ) || exit;

/**
 * Class PhoneVerificationController
 *
 * @since 1.5.0
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class PhoneVerificationController extends BaseOptionsController {

	use EmptySchemaPropertiesTrait;

	/**
	 * @var PhoneVerification
	 */
	protected $phone_verification;

	/**
	 * PhoneVerificationController constructor.
	 *
	 * @param RESTServer        $server
	 * @param PhoneVerification $phone_verification Phone verification service.
	 */
	public function __construct( RESTServer $server, PhoneVerification $phone_verification ) {
		parent::__construct( $server );
		$this->phone_verification = $phone_verification;
	}

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$verification_method = [
			'description'       => __( 'Method used to verify the phone number.', 'google-listings-and-ads' ),
			'enum'              => [
				PhoneVerification::VERIFICATION_METHOD_SMS,
				PhoneVerification::VERIFICATION_METHOD_PHONE_CALL,
			],
			'required'          => true,
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		];

		$this->register_route(
			'/mc/phone-verification/request',
			[
				[
					'methods'             => TransportMethods::CREATABLE,
					'callback'            => $this->get_request_phone_verification_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => [
						'phone_region_code'   => [
							'description'       => __( 'Two-letter country code (ISO 3166-1 alpha-2) for the phone number.', 'google-listings-and-ads' ),
							'required'          => true,
							'type'              => 'string',
							'validate_callback' => 'rest_validate_request_arg',
						],
						'phone_number'        => [
							'description'       => __( 'The phone number to verify.', 'google-listings-and-ads' ),
							'required'          => true,
							'type'              => 'string',
							'validate_callback' => 'rest_validate_request_arg',
						],
						'verification_method' => $verification_method,
					],
				],
			]
		);

		$this->register_route(
			'/mc/phone-verification/verify',
			[
				[
					'methods'             => TransportMethods::CREATABLE,
					'callback'            => $this->get_verify_phone_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => [
						'verification_id'     => [
							'description'       => __( 'The verification ID returned by the /request call.', 'google-listings-and-ads' ),
							'required'          => true,
							'type'              => 'string',
							'validate_callback' => 'rest_validate_request_arg',
						],
						'verification_code'   => [
							'description'       => __( 'The verification code that was sent to the phone number for validation.', 'google-listings-and-ads' ),
							'required'          => true,
							'type'              => 'string',
							'validate_callback' => 'rest_validate_request_arg',
						],
						'verification_method' => $verification_method,
					],
				],
			]
		);
	}

	/**
	 * Get callback for requesting phone verification endpoint.
	 *
	 * @return callable
	 */
	protected function get_request_phone_verification_callback(): callable {
		return function ( Request $request ) {
			try {
				$verification_id = $this->phone_verification->request_phone_verification(
					$request->get_param( 'phone_region_code' ),
					new PhoneNumber( $request->get_param( 'phone_number' ) ),
					$request->get_param( 'verification_method' ),
				);
				return [
					'verification_id' => $verification_id,
				];
			} catch ( Exception $e ) {
				return $this->response_from_exception( $e );
			}
		};
	}

	/**
	 * Get callback for verifying a phone number.
	 *
	 * @return callable
	 */
	protected function get_verify_phone_callback(): callable {
		return function ( Request $request ) {
			try {
				$this->phone_verification->verify_phone_number(
					$request->get_param( 'verification_id' ),
					$request->get_param( 'verification_code' ),
					$request->get_param( 'verification_method' ),
				);
				return new Response( null, 204 );
			} catch ( Exception $e ) {
				return $this->response_from_exception( $e );
			}
		};
	}

	/**
	 * Get the item schema name for the controller.
	 *
	 * @return string
	 */
	protected function get_schema_title(): string {
		return 'phone_verification';
	}
}
PolicyComplianceCheckController.php000064400000011104151556464630013525 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\CountryCodeTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\PolicyComplianceCheck;
use Exception;
use WP_REST_Response as Response;


defined( 'ABSPATH' ) || exit;

/**
 * Class PolicyComplianceCheckController
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class PolicyComplianceCheckController extends BaseController {

	use CountryCodeTrait;

	/**
	 * The PolicyComplianceCheck object.
	 *
	 * @var PolicyComplianceCheck
	 */
	protected $policy_compliance_check;

	/**
	 * PolicyComplianceCheckController constructor.
	 *
	 * @param RESTServer            $server
	 * @param PolicyComplianceCheck $policy_compliance_check
	 */
	public function __construct( RESTServer $server, PolicyComplianceCheck $policy_compliance_check ) {
		parent::__construct( $server );
		$this->policy_compliance_check = $policy_compliance_check;
	}

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			'mc/policy_check',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_policy_check_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
			]
		);
	}

	/**
	 * Get the allowed countries, payment gateways info, store ssl and refund return policy page for the controller.
	 *
	 * @return callable
	 */
	protected function get_policy_check_callback(): callable {
		return function () {
			try {
				return new Response(
					[
						'allowed_countries'    => $this->policy_compliance_check->is_accessible(),
						'robots_restriction'   => $this->policy_compliance_check->has_restriction(),
						'page_not_found_error' => $this->policy_compliance_check->has_page_not_found_error(),
						'page_redirects'       => $this->policy_compliance_check->has_redirects(),
						'payment_gateways'     => $this->policy_compliance_check->has_payment_gateways(),
						'store_ssl'            => $this->policy_compliance_check->get_is_store_ssl(),
						'refund_returns'       => $this->policy_compliance_check->has_refund_return_policy_page(),
					]
				);

			} catch ( Exception $e ) {
				return $this->response_from_exception( $e );
			}
		};
	}


	/**
	 * Get the schema for policy compliance check endpoints.
	 *
	 * @return array
	 */
	protected function get_schema_properties(): array {
		return [
			'allowed_countries'    => [
				'type'        => 'boolean',
				'description' => __( 'The store website could be accessed or not by all users.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
			],
			'robots_restriction'   => [
				'type'        => 'boolean',
				'description' => __( 'The merchant set the restrictions in robots.txt or not in the store.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
			],
			'page_not_found_error' => [
				'type'        => 'boolean',
				'description' => __( 'The sample of product landing pages leads to a 404 error.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
			],
			'page_redirects'       => [
				'type'        => 'boolean',
				'description' => __( 'The sample of product landing pages have redirects through 3P domains.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
			],
			'payment_gateways'     => [
				'type'        => 'boolean',
				'description' => __( 'The payment gateways associated with onboarding policy checking.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
			],
			'store_ssl'            => [
				'type'        => 'boolean',
				'description' => __( 'The store ssl associated with onboarding policy checking.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
			],
			'refund_returns'       => [
				'type'        => 'boolean',
				'description' => __( 'The refund returns policy associated with onboarding policy checking.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
			],
			'schema'               => $this->get_api_response_schema_callback(),
		];
	}

	/**
	 * Get the item schema name for the controller.
	 *
	 * Used for building the API response schema.
	 *
	 * @return string
	 */
	protected function get_schema_title(): string {
		return 'policy_check';
	}
}
ProductFeedController.php000064400000014342151556464630011550 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\ProductFeedQueryHelper;
use Exception;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;

defined( 'ABSPATH' ) || exit;

/**
 * Class ProductFeedController
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class ProductFeedController extends BaseController {

	/**
	 * @var ProductFeedQueryHelper
	 */
	protected $query_helper;

	/**
	 * ProductFeedController constructor.
	 *
	 * @param RESTServer             $server
	 * @param ProductFeedQueryHelper $query_helper
	 */
	public function __construct( RESTServer $server, ProductFeedQueryHelper $query_helper ) {
		parent::__construct( $server );
		$this->query_helper = $query_helper;
	}

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			'mc/product-feed',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_product_feed_read_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			],
		);
	}

	/**
	 * Get the callback function for returning the product feed.
	 *
	 * @return callable
	 */
	protected function get_product_feed_read_callback(): callable {
		return function ( Request $request ) {
			try {
				return [
					'products' => $this->query_helper->get( $request ),
					'total'    => $this->query_helper->count( $request ),
					'page'     => $request['per_page'] > 0 && $request['page'] > 0 ? $request['page'] : 1,
				];
			} catch ( Exception $e ) {
				return $this->response_from_exception( $e );
			}
		};
	}

	/**
	 * Get the item schema properties for the controller.
	 *
	 * @return array
	 */
	protected function get_schema_properties(): array {
		return [
			'products' => [
				'type'        => 'array',
				'description' => __( 'The store\'s products.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'readonly'    => true,
				'items'       => [
					'type'       => 'object',
					'properties' => [
						'id'        => [
							'type'        => 'numeric',
							'description' => __( 'Product ID.', 'google-listings-and-ads' ),
							'context'     => [ 'view' ],
						],
						'title'     => [
							'type'        => 'string',
							'description' => __( 'Product title.', 'google-listings-and-ads' ),
							'context'     => [ 'view' ],
						],
						'visible'   => [
							'type'        => 'boolean',
							'description' => __( 'Whether the product is set to be visible in the Merchant Center', 'google-listings-and-ads' ),
							'context'     => [ 'view' ],
						],
						'status'    => [
							'type'        => 'string',
							'description' => __( 'The current sync status of the product.', 'google-listings-and-ads' ),
							'context'     => [ 'view' ],
						],
						'image_url' => [
							'type'        => 'string',
							'description' => __( 'The image url of the product.', 'google-listings-and-ads' ),
							'context'     => [ 'view' ],
						],
						'price'     => [
							'type'        => 'string',
							'description' => __( 'The price of the product.', 'google-listings-and-ads' ),
							'context'     => [ 'view' ],
						],
						'errors'    => [
							'type'        => 'array',
							'description' => __( 'Errors preventing the product from being synced to the Merchant Center.', 'google-listings-and-ads' ),
							'context'     => [ 'view' ],
						],
					],
				],
			],
			'total'    => [
				'type'     => 'numeric',
				'context'  => [ 'view' ],
				'readonly' => true,
			],
			'page'     => [
				'type'     => 'numeric',
				'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 'product_feed';
	}

	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	public function get_collection_params(): array {
		return [
			'context'  => $this->get_context_param( [ 'default' => 'view' ] ),
			'page'     => [
				'description'       => __( 'Page of data to retrieve.', 'google-listings-and-ads' ),
				'type'              => 'integer',
				'default'           => 1,
				'minimum'           => 1,
				'sanitize_callback' => 'absint',
				'validate_callback' => 'rest_validate_request_arg',
			],
			'per_page' => [
				'description'       => __( 'Maximum number of rows to be returned in result data.', 'google-listings-and-ads' ),
				'type'              => 'integer',
				'default'           => 0,
				'minimum'           => 0,
				'sanitize_callback' => 'absint',
				'validate_callback' => 'rest_validate_request_arg',
			],
			'search'   => [
				'description'       => __( 'Text to search for in product names.', 'google-listings-and-ads' ),
				'type'              => 'string',
				'validate_callback' => 'rest_validate_request_arg',
			],
			'ids'      => [
				'description'       => __( 'Limit result to items with specified ids (comma-separated).', 'google-listings-and-ads' ),
				'type'              => 'array',
				'sanitize_callback' => 'wp_parse_list',
				'validate_callback' => 'rest_validate_request_arg',
				'items'             => [
					'type' => 'integer',
				],
			],
			'orderby'  => [
				'description'       => __( 'Sort collection by attribute.', 'google-listings-and-ads' ),
				'type'              => 'string',
				'default'           => 'title',
				'enum'              => [ 'title', 'id', 'visible', 'status' ],
				'validate_callback' => 'rest_validate_request_arg',
			],
			'order'    => [
				'description'       => __( 'Order sort attribute ascending or descending.', 'google-listings-and-ads' ),
				'type'              => 'string',
				'default'           => 'ASC',
				'enum'              => [ 'ASC', 'DESC' ],
				'validate_callback' => 'rest_validate_request_arg',
			],
		];
	}
}
ProductStatisticsController.php000064400000013475151556464630013045 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\ProductSyncStats;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantStatuses;
use WP_REST_Response as Response;
use WP_REST_Request as Request;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Exception;

defined( 'ABSPATH' ) || exit;

/**
 * Class ProductStatisticsController
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class ProductStatisticsController extends BaseOptionsController {

	/**
	 * The MerchantProducts object.
	 *
	 * @var MerchantStatuses
	 */
	protected $merchant_statuses;

	/**
	 * Helper class to count scheduled sync jobs.
	 *
	 * @var ProductSyncStats
	 */
	protected $sync_stats;


	/**
	 * ProductStatisticsController constructor.
	 *
	 * @param RESTServer       $server
	 * @param MerchantStatuses $merchant_statuses
	 * @param ProductSyncStats $sync_stats
	 */
	public function __construct( RESTServer $server, MerchantStatuses $merchant_statuses, ProductSyncStats $sync_stats ) {
		parent::__construct( $server );
		$this->merchant_statuses = $merchant_statuses;
		$this->sync_stats        = $sync_stats;
	}

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			'mc/product-statistics',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_product_statistics_read_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			],
		);
		$this->register_route(
			'mc/product-statistics/refresh',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_product_statistics_refresh_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			],
		);
	}

	/**
	 * Get the callback function for returning product statistics.
	 *
	 * @return callable
	 */
	protected function get_product_statistics_read_callback(): callable {
		return function ( Request $request ) {
			return $this->get_product_status_stats( $request );
		};
	}
	/**
	 * Get the callback function for getting re-calculated product statistics.
	 *
	 * @return callable
	 */
	protected function get_product_statistics_refresh_callback(): callable {
		return function ( Request $request ) {
			return $this->get_product_status_stats( $request, true );
		};
	}

	/**
	 * Get the overall product status statistics array.
	 *
	 * @param Request $request
	 * @param bool    $force_refresh True to force a refresh of the product status statistics.
	 *
	 * @return Response
	 */
	protected function get_product_status_stats( Request $request, bool $force_refresh = false ): Response {
		try {
			$response = $this->merchant_statuses->get_product_statistics( $force_refresh );

			$response['scheduled_sync'] = $this->sync_stats->get_count();

			return $this->prepare_item_for_response( $response, $request );
		} catch ( Exception $e ) {
			return $this->response_from_exception( $e );
		}
	}

	/**
	 * Get the item schema properties for the controller.
	 *
	 * @return array
	 */
	protected function get_schema_properties(): array {
		return [
			'timestamp'      => [
				'type'        => 'number',
				'description' => __( 'Timestamp reflecting when the product status statistics were last generated.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'readonly'    => true,
			],
			'statistics'     => [
				'type'        => 'object',
				'description' => __( 'Merchant Center product status statistics.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'readonly'    => true,
				'properties'  => [
					'active'      => [
						'type'        => 'integer',
						'description' => __( 'Active products.', 'google-listings-and-ads' ),
						'context'     => [ 'view' ],
					],
					'expiring'    => [
						'type'        => 'integer',
						'description' => __( 'Expiring products.', 'google-listings-and-ads' ),
						'context'     => [ 'view' ],
					],
					'pending'     => [
						'type'        => 'number',
						'description' => __( 'Pending products.', 'google-listings-and-ads' ),
						'context'     => [ 'view' ],
					],
					'disapproved' => [
						'type'        => 'number',
						'description' => __( 'Disapproved products.', 'google-listings-and-ads' ),
						'context'     => [ 'view' ],
					],
					'not_synced'  => [
						'type'        => 'number',
						'description' => __( 'Products not uploaded.', 'google-listings-and-ads' ),
						'context'     => [ 'view' ],
					],
				],
			],
			'scheduled_sync' => [
				'type'        => 'number',
				'description' => __( 'Amount of scheduled jobs which will sync products to Google.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'readonly'    => true,
			],
			'loading'        => [
				'type'        => 'boolean',
				'description' => __( 'Whether the product statistics are loading.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'readonly'    => true,
			],
			'error'          => [
				'type'        => 'string',
				'description' => __( 'Error message in case of failure', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'readonly'    => true,
				'default'     => null,
			],
		];
	}

	/**
	 * Get the item schema name for the controller.
	 *
	 * Used for building the API response schema.
	 *
	 * @return string
	 */
	protected function get_schema_title(): string {
		return 'product_statistics';
	}
}
ProductVisibilityController.php000064400000013050151556464630013027 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\MerchantIssueQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductMetaHandler;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use Exception;
use WP_REST_Request as Request;
use WP_REST_Response as Response;

defined( 'ABSPATH' ) || exit;

/**
 * Class ProductVisibilityController
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class ProductVisibilityController extends BaseController {

	use PluginHelper;

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

	/**
	 * @var MerchantIssueQuery $issue_query
	 */
	protected $issue_query;

	/**
	 * ProductVisibilityController constructor.
	 *
	 * @param RESTServer         $server
	 * @param ProductHelper      $product_helper
	 * @param MerchantIssueQuery $issue_query
	 */
	public function __construct( RESTServer $server, ProductHelper $product_helper, MerchantIssueQuery $issue_query ) {
		parent::__construct( $server );
		$this->product_helper = $product_helper;
		$this->issue_query    = $issue_query;
	}

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			'mc/product-visibility',
			[
				[
					'methods'             => TransportMethods::EDITABLE,
					'callback'            => $this->get_update_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_update_args(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);
	}

	/**
	 * Get a callback for updating products' channel visibility.
	 *
	 * @return callable
	 */
	protected function get_update_callback(): callable {
		return function ( Request $request ) {
			$ids     = $request->get_param( 'ids' );
			$visible = $request->get_param( 'visible' );

			$success = [];
			$errors  = [];
			foreach ( $ids as $product_id ) {
				$product_id = intval( $product_id );
				if ( ! $this->change_product_visibility( $product_id, $visible ) ) {
					$errors[] = $product_id;
					continue;
				}

				if ( ! $visible ) {
					$this->issue_query->delete( 'product_id', $product_id );
				}
				$success[] = $product_id;
			}

			sort( $success );
			sort( $errors );

			return new Response(
				[
					'success' => $success,
					'errors'  => $errors,
				],
				count( $errors ) ? 400 : 200
			);
		};
	}

	/**
	 * Get the item schema for the controller.
	 *
	 * @return array
	 */
	protected function get_schema_properties(): array {
		return [
			'success' => [
				'type'              => 'array',
				'description'       => __( 'Products whose visibility was changed successfully.', 'google-listings-and-ads' ),
				'context'           => [ 'view' ],
				'validate_callback' => 'rest_validate_request_arg',
				'items'             => [
					'type' => 'numeric',
				],
			],
			'errors'  => [
				'type'              => 'array',
				'description'       => __( 'Products whose visibility was not changed.', 'google-listings-and-ads' ),
				'context'           => [ 'view' ],
				'validate_callback' => 'rest_validate_request_arg',
				'items'             => [
					'type' => 'numeric',
				],
			],
		];
	}

	/**
	 * Get the arguments for the update endpoint.
	 *
	 * @return array
	 */
	public function get_update_args(): array {
		return [
			'context' => $this->get_context_param( [ 'default' => 'view' ] ),
			'ids'     => [
				'description'       => __( 'IDs of the products to update.', 'google-listings-and-ads' ),
				'type'              => 'array',
				'sanitize_callback' => 'wp_parse_slug_list',
				'validate_callback' => 'rest_validate_request_arg',
				'items'             => [
					'type' => 'integer',
				],
			],
			'visible' => [
				'description'       => __( 'New Visibility status for the specified products.', 'google-listings-and-ads' ),
				'type'              => 'boolean',
				'validate_callback' => 'rest_validate_request_arg',
			],
		];
	}

	/**
	 * Get the item schema name for the controller.
	 *
	 * Used for building the API response schema.
	 *
	 * @return string
	 */
	protected function get_schema_title(): string {
		return 'product_visibility';
	}

	/**
	 * Update a product's Merchant Center visibility setting (or parent product, for variations).
	 *
	 * @param int  $product_id
	 * @param bool $new_visibility True for visible, false for not visible.
	 *
	 * @return bool True if the product was found and updated correctly.
	 */
	protected function change_product_visibility( int $product_id, bool $new_visibility ): bool {
		try {
			$product = $this->product_helper->get_wc_product( $product_id );
			$product = $this->product_helper->maybe_swap_for_parent( $product );
			// Use $product->save() instead of ProductMetaHandler to trigger MC sync.
			$product->update_meta_data(
				$this->prefix_meta_key( ProductMetaHandler::KEY_VISIBILITY ),
				$new_visibility ? ChannelVisibility::SYNC_AND_SHOW : ChannelVisibility::DONT_SYNC_AND_SHOW
			);
			$product->save();

			return true;
		} catch ( Exception $e ) {
			do_action( 'woocommerce_gla_exception', $e, __METHOD__ );

			return false;
		}
	}
}
ReportsController.php000064400000012522151556464630011000 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\MerchantReport;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseReportsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Exception;
use WP_REST_Request as Request;

defined( 'ABSPATH' ) || exit;

/**
 * Class ReportsController
 *
 * ContainerAware used for:
 * - MerchantReport
 * - WP (in parent class)
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class ReportsController extends BaseReportsController {

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			'mc/reports/programs',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_programs_report_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_collection_params(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);

		$this->register_route(
			'mc/reports/products',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_products_report_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_collection_params(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);
	}

	/**
	 * Get the callback function for the programs report request.
	 *
	 * @return callable
	 */
	protected function get_programs_report_callback(): callable {
		return function ( Request $request ) {
			try {
				/** @var MerchantReport $merchant */
				$merchant = $this->container->get( MerchantReport::class );
				$data     = $merchant->get_report_data( 'free_listings', $this->prepare_query_arguments( $request ) );
				return $this->prepare_item_for_response( $data, $request );
			} catch ( Exception $e ) {
				return $this->response_from_exception( $e );
			}
		};
	}

	/**
	 * Get the callback function for the products report request.
	 *
	 * @return callable
	 */
	protected function get_products_report_callback(): callable {
		return function ( Request $request ) {
			try {
				/** @var MerchantReport $merchant */
				$merchant = $this->container->get( MerchantReport::class );
				$data     = $merchant->get_report_data( 'products', $this->prepare_query_arguments( $request ) );
				return $this->prepare_item_for_response( $data, $request );
			} catch ( Exception $e ) {
				return $this->response_from_exception( $e );
			}
		};
	}

	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	public function get_collection_params(): array {
		$params = parent::get_collection_params();

		$params['interval'] = [
			'description'       => __( 'Time interval to use for segments in the returned data.', 'google-listings-and-ads' ),
			'type'              => 'string',
			'enum'              => [
				'day',
			],
			'validate_callback' => 'rest_validate_request_arg',
		];
		return $params;
	}

	/**
	 * Get the item schema for the controller.
	 *
	 * @return array
	 */
	protected function get_schema_properties(): array {
		return [
			'free_listings' => [
				'type'  => 'array',
				'items' => [
					'type'       => 'object',
					'properties' => [
						'subtotals' => $this->get_totals_schema(),
					],
				],
			],
			'products'      => [
				'type'  => 'array',
				'items' => [
					'type'       => 'object',
					'properties' => [
						'id'        => [
							'type'        => 'string',
							'description' => __( 'Product ID.', 'google-listings-and-ads' ),
							'context'     => [ 'view' ],
						],
						'name'      => [
							'type'        => 'string',
							'description' => __( 'Product name.', 'google-listings-and-ads' ),
							'context'     => [ 'view', 'edit' ],
						],
						'subtotals' => $this->get_totals_schema(),
					],
				],
			],
			'intervals'     => [
				'type'  => 'array',
				'items' => [
					'type'       => 'object',
					'properties' => [
						'interval'  => [
							'type'        => 'string',
							'description' => __( 'ID of this report segment.', 'google-listings-and-ads' ),
							'context'     => [ 'view' ],
						],
						'subtotals' => $this->get_totals_schema(),
					],
				],
			],
			'totals'        => $this->get_totals_schema(),
			'next_page'     => [
				'type'        => 'string',
				'description' => __( 'Token to retrieve the next page of results.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
			],
		];
	}

	/**
	 * Return schema for total fields.
	 *
	 * @return array
	 */
	protected function get_totals_schema(): array {
		return [
			'type'       => 'object',
			'properties' => [
				'clicks'      => [
					'type'        => 'integer',
					'description' => __( 'Clicks.', 'google-listings-and-ads' ),
					'context'     => [ 'view' ],
				],
				'impressions' => [
					'type'        => 'integer',
					'description' => __( 'Impressions.', 'google-listings-and-ads' ),
					'context'     => [ 'view' ],
				],
			],
		];
	}

	/**
	 * Get the item schema name for the controller.
	 *
	 * Used for building the API response schema.
	 *
	 * @return string
	 */
	protected function get_schema_title(): string {
		return 'reports';
	}
}
RequestReviewController.php000064400000024531151556464630012157 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Merchant;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Middleware;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\RequestReviewStatuses;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
use Exception;

defined( 'ABSPATH' ) || exit;

/**
 * Class RequestReviewController
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class RequestReviewController extends BaseOptionsController {


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

	/**
	 * RequestReviewController constructor.
	 *
	 * @param RESTServer            $server
	 * @param Middleware            $middleware
	 * @param Merchant              $merchant
	 * @param RequestReviewStatuses $request_review_statuses
	 * @param TransientsInterface   $transients
	 */
	public function __construct( RESTServer $server, Middleware $middleware, Merchant $merchant, RequestReviewStatuses $request_review_statuses, TransientsInterface $transients ) {
		parent::__construct( $server );
		$this->middleware              = $middleware;
		$this->merchant                = $merchant;
		$this->request_review_statuses = $request_review_statuses;
		$this->transients              = $transients;
	}

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		/**
		 * GET information regarding the current Account Status
		 */
		$this->register_route(
			'mc/review',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_review_read_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			],
		);

		/**
		 * POST a request review for the current account
		 */
		$this->register_route(
			'mc/review',
			[
				[
					'methods'             => TransportMethods::CREATABLE,
					'callback'            => $this->post_review_request_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
			],
		);
	}

	/**
	 * Get the callback function for returning the review status.
	 *
	 * @return callable
	 */
	protected function get_review_read_callback(): callable {
		return function ( Request $request ) {
			try {
				return $this->prepare_item_for_response( $this->get_review_status(), $request );
			} catch ( Exception $e ) {
				return new Response( [ 'message' => $e->getMessage() ], $e->getCode() ?: 400 );
			}
		};
	}

	/**
	 * Get the callback function after requesting a review.
	 *
	 * @return callable
	 */
	protected function post_review_request_callback(): callable {
		return function () {
			try {

				// getting the current account status
				$account_review_status = $this->get_review_status();

				// Abort if it's in cool down period
				if ( $account_review_status['cooldown'] ) {
					do_action(
						'woocommerce_gla_request_review_failure',
						[
							'error'                 => 'cooldown',
							'account_review_status' => $account_review_status,
						]
					);
					throw new Exception( __( 'Your account is under cool down period and cannot request a new review.', 'google-listings-and-ads' ), 400 );
				}

				// Abort if there is no eligible region available
				if ( ! count( $account_review_status['reviewEligibleRegions'] ) ) {
					do_action(
						'woocommerce_gla_request_review_failure',
						[
							'error'                 => 'ineligible',
							'account_review_status' => $account_review_status,
						]
					);
					throw new Exception( __( 'Your account is not eligible for a new request review.', 'google-listings-and-ads' ), 400 );
				}

				$this->account_request_review( $account_review_status['reviewEligibleRegions'] );
				return $this->set_under_review_status();

			} catch ( Exception $e ) {
				/**
				 * Catch potential errors in any specific region API call.
				 *
				 * Notice due some inconsistencies with Google API we are not considering [Bad Request -> ...already under review...]
				 * as an exception. This is because we suspect that calling the API of a region is triggering other regions requests as well.
				 * This makes all the calls after the first to fail as they will be under review.
				 *
				 * The undesired call of this function for accounts under review is already prevented in a previous stage, so, there is no danger doing this.
				 */
				if ( strpos( $e->getMessage(), 'under review' ) !== false ) {
					return $this->set_under_review_status();
				}
				return new Response( [ 'message' => $e->getMessage() ], $e->getCode() ?: 400 );
			}
		};
	}

	/**
	 * Set Under review Status in the cache and return the response
	 *
	 * @return Response With the Under review status
	 */
	private function set_under_review_status() {
		$new_status = [
			'issues'                => [],
			'cooldown'              => 0,
			'status'                => $this->request_review_statuses::UNDER_REVIEW,
			'reviewEligibleRegions' => [],
		];

		// Update Account status when successful response
		$this->set_cached_review_status( $new_status );

		return new Response( $new_status );
	}

	/**
	 * Get the item schema properties for the controller.
	 *
	 * @return array
	 */
	protected function get_schema_properties(): array {
		return [
			'status'                => [
				'type'        => 'string',
				'description' => __( 'The status of the last review.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'readonly'    => true,
			],
			'cooldown'              => [
				'type'        => 'integer',
				'description' => __( 'Timestamp indicating if the user is in cool down period.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'readonly'    => true,
			],
			'issues'                => [
				'type'        => 'array',
				'description' => __( 'The issues related to the Merchant Center to be reviewed and addressed before approval.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'readonly'    => true,
				'items'       => [
					'type' => 'string',
				],
			],
			'reviewEligibleRegions' => [
				'type'        => 'array',
				'description' => __( 'The region codes in which is allowed to request a new review.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'readonly'    => true,
				'items'       => [
					'type' => 'string',
				],
			],
		];
	}


	/**
	 * Get the item schema name for the controller.
	 *
	 * Used for building the API response schema.
	 *
	 * @return string
	 */
	protected function get_schema_title(): string {
		return 'merchant_account_review';
	}

	/**
	 * Save the Account Review Status data inside a transient for caching purposes.
	 *
	 * @param array $value The Account Review Status data to save in the transient
	 */
	private function set_cached_review_status( $value ): void {
		$this->transients->set(
			TransientsInterface::MC_ACCOUNT_REVIEW,
			$value,
			$this->request_review_statuses->get_account_review_lifetime()
		);
	}

	/**
	 * Get the Account Review Status data inside a transient for caching purposes.
	 *
	 * @return null|array Returns NULL in case no data is available or an array with the Account Review Status data otherwise.
	 */
	private function get_cached_review_status(): ?array {
		return $this->transients->get(
			TransientsInterface::MC_ACCOUNT_REVIEW,
		);
	}

	/**
	 * Get the Account Review Status. We attempt to get the cached version or create a request otherwise.
	 *
	 * @return null|array Returns NULL in case no data is available or an array with the Account Review Status data otherwise.
	 * @throws Exception If the get_account_review_status API call fails.
	 */
	private function get_review_status(): ?array {
		$review_status = $this->get_cached_review_status();

		if ( is_null( $review_status ) ) {
			$response      = $this->get_account_review_status();
			$review_status = $this->request_review_statuses->get_statuses_from_response( $response );
			$this->set_cached_review_status( $review_status );
		}

		return $review_status;
	}

	/**
	 * Get Account Review Status
	 *
	 * @return array the response data
	 * @throws Exception When there is an invalid response.
	 */
	public function get_account_review_status() {
		try {
			if ( ! $this->middleware->is_subaccount() ) {
				return [];
			}

			$response = $this->merchant->get_account_review_status();
			do_action( 'woocommerce_gla_request_review_response', $response );
			return $response;
		} catch ( Exception $e ) {
			do_action( 'woocommerce_gla_exception', $e, __METHOD__ );
			throw new Exception(
				$e->getMessage() ?? __( 'Error getting account review status.', 'google-listings-and-ads' ),
				$e->getCode()
			);
		}
	}


	/**
	 * Request a new account review
	 *
	 * @param array $regions Regions to request a review.
	 * @return array With a successful message
	 * @throws Exception When there is an invalid response.
	 */
	public function account_request_review( $regions ) {
		try {

			// For each region we request a new review
			foreach ( $regions as $region_code => $region_types ) {

				$result = $this->merchant->account_request_review( $region_code, $region_types );

				if ( 200 !== $result->getStatusCode() ) {
					do_action(
						'woocommerce_gla_request_review_failure',
						[
							'error'       => 'response',
							'region_code' => $region_code,
							'response'    => $result,
						]
					);
					do_action( 'woocommerce_gla_guzzle_invalid_response', $result, __METHOD__ );
					$error = $response['message'] ?? __( 'Invalid response getting requesting a new review.', 'google-listings-and-ads' );
					throw new Exception( $error, $result->getStatusCode() );
				}
			}

			// Otherwise, return a successful message and update the account status
			return [
				'message' => __( 'A new review has been successfully requested', 'google-listings-and-ads' ),
			];

		} catch ( Exception $e ) {
			do_action( 'woocommerce_gla_exception', $e, __METHOD__ );
			throw new Exception(
				$e->getMessage() ?? __( 'Error requesting a new review.', 'google-listings-and-ads' ),
				$e->getCode()
			);
		}
	}
}
SettingsController.php000064400000012231151556464630011137 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\ShippingZone;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WP_REST_Request as Request;

defined( 'ABSPATH' ) || exit;

/**
 * Class SettingsController
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class SettingsController extends BaseOptionsController {

	/**
	 * @var ShippingZone
	 */
	protected $shipping_zone;

	/**
	 * SettingsController constructor.
	 *
	 * @param RESTServer   $server
	 * @param ShippingZone $shipping_zone
	 */
	public function __construct( RESTServer $server, ShippingZone $shipping_zone ) {
		parent::__construct( $server );
		$this->shipping_zone = $shipping_zone;
	}

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			'mc/settings',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_settings_endpoint_read_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
				[
					'methods'             => TransportMethods::EDITABLE,
					'callback'            => $this->get_settings_endpoint_edit_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);
	}

	/**
	 * Get a callback for the settings endpoint.
	 *
	 * @return callable
	 */
	protected function get_settings_endpoint_read_callback(): callable {
		return function () {
			$data                         = $this->options->get( OptionsInterface::MERCHANT_CENTER, [] );
			$data['shipping_rates_count'] = $this->shipping_zone->get_shipping_rates_count();
			$schema                       = $this->get_schema_properties();
			$items                        = [];
			foreach ( $schema as $key => $property ) {
				$items[ $key ] = $data[ $key ] ?? $property['default'] ?? null;
			}

			return $items;
		};
	}

	/**
	 * Get a callback for editing the settings endpoint.
	 *
	 * @return callable
	 */
	protected function get_settings_endpoint_edit_callback(): callable {
		return function ( Request $request ) {
			$schema  = $this->get_schema_properties();
			$options = $this->options->get( OptionsInterface::MERCHANT_CENTER, [] );
			if ( ! is_array( $options ) ) {
				$options = [];
			}

			foreach ( $schema as $key => $property ) {
				if ( ! in_array( 'edit', $property['context'] ?? [], true ) ) {
					continue;
				}
				$options[ $key ] = $request->get_param( $key ) ?? $options[ $key ] ?? $property['default'] ?? null;
			}

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

			return [
				'status'  => 'success',
				'message' => __( 'Merchant Center Settings successfully updated.', 'google-listings-and-ads' ),
				'data'    => $options,
			];
		};
	}

	/**
	 * Get the schema for settings endpoints.
	 *
	 * @return array
	 */
	protected function get_schema_properties(): array {
		return [
			'shipping_rate'        => [
				'type'              => 'string',
				'description'       => __(
					'Whether shipping rate is a simple flat rate or needs to be configured manually in the Merchant Center.',
					'google-listings-and-ads'
				),
				'context'           => [ 'view', 'edit' ],
				'validate_callback' => 'rest_validate_request_arg',
				'enum'              => [
					'automatic',
					'flat',
					'manual',
				],
			],
			'shipping_time'        => [
				'type'              => 'string',
				'description'       => __(
					'Whether shipping time is a simple flat time or needs to be configured manually in the Merchant Center.',
					'google-listings-and-ads'
				),
				'context'           => [ 'view', 'edit' ],
				'validate_callback' => 'rest_validate_request_arg',
				'enum'              => [
					'flat',
					'manual',
				],
			],
			'tax_rate'             => [
				'type'              => 'string',
				'description'       => __(
					'Whether tax rate is destination based or need to be configured manually in the Merchant Center.',
					'google-listings-and-ads'
				),
				'context'           => [ 'view', 'edit' ],
				'validate_callback' => 'rest_validate_request_arg',
				'enum'              => [
					'destination',
					'manual',
				],
				'default'           => 'destination',
			],
			'shipping_rates_count' => [
				'type'              => 'number',
				'description'       => __(
					'The number of shipping rates in WC ready to be used in the Merchant Center.',
					'google-listings-and-ads'
				),
				'context'           => [ 'view' ],
				'validate_callback' => 'rest_validate_request_arg',
				'default'           => 0,
			],
		];
	}

	/**
	 * Get the item schema name for the controller.
	 *
	 * Used for building the API response schema.
	 *
	 * @return string
	 */
	protected function get_schema_title(): string {
		return 'merchant_center_settings';
	}
}
SettingsSyncController.php000064400000007063151556464630012003 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Settings;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\EmptySchemaPropertiesTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\WPErrorTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Exception;
use WP_REST_Request as Request;
use WP_REST_Response as Response;

defined( 'ABSPATH' ) || exit;

/**
 * Class SettingsSyncController
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class SettingsSyncController extends BaseController {

	use EmptySchemaPropertiesTrait;
	use WPErrorTrait;

	/** @var Settings */
	protected $settings;

	/**
	 * SettingsSyncController constructor.
	 *
	 * @param RESTServer $server
	 * @param Settings   $settings
	 */
	public function __construct( RESTServer $server, Settings $settings ) {
		parent::__construct( $server );
		$this->settings = $settings;
	}

	/**
	 * Registers the routes for the objects of the controller.
	 */
	public function register_routes() {
		$this->register_route(
			'mc/settings/sync',
			[
				[
					'methods'             => TransportMethods::CREATABLE,
					'callback'            => $this->get_sync_endpoint_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
			]
		);
	}

	/**
	 * Get the callback for syncing shipping.
	 *
	 * @return callable
	 */
	protected function get_sync_endpoint_callback(): callable {
		return function ( Request $request ) {
			try {
				$this->settings->sync_taxes();
				$this->settings->sync_shipping();

				do_action( 'woocommerce_gla_mc_settings_sync' );

				/**
				 * MerchantCenter onboarding has been successfully completed.
				 *
				 * @event gla_mc_setup_completed
				 * @property string shipping_rate           Shipping rate setup `automatic`, `manual`, `flat`.
				 * @property bool   offers_free_shipping    Free Shipping is available.
				 * @property float  free_shipping_threshold Minimum amount to avail of free shipping.
				 * @property string shipping_time           Shipping time setup `flat`, `manual`.
				 * @property string tax_rate                Tax rate setup `destination`, `manual`.
				 * @property string target_countries        List of target countries or `all`.
				 */
				do_action(
					'woocommerce_gla_track_event',
					'mc_setup_completed',
					$this->settings->get_settings_for_tracking()
				);

				return new Response(
					[
						'status'  => 'success',
						'message' => __( 'Successfully synchronized settings with Google.', 'google-listings-and-ads' ),
					],
					201
				);
			} catch ( Exception $e ) {
				do_action( 'woocommerce_gla_exception', $e, __METHOD__ );

				try {
					$decoded = $this->json_decode_message( $e->getMessage() );
					$data    = [
						'status'  => $decoded['code'] ?? 500,
						'message' => $decoded['message'] ?? '',
						'data'    => $decoded,
					];
				} catch ( Exception $e2 ) {
					$data = [
						'status' => 500,
					];
				}

				return $this->error_from_exception(
					$e,
					'gla_setting_sync_error',
					$data
				);
			}
		};
	}

	/**
	 * Get the item schema name for the controller.
	 *
	 * Used for building the API response schema.
	 *
	 * @return string
	 */
	protected function get_schema_title(): string {
		return 'settings_sync';
	}
}
ShippingRateBatchController.php000064400000010127151556464630012700 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use WP_REST_Request as Request;
use WP_REST_Response as Response;

defined( 'ABSPATH' ) || exit;

/**
 * Class ShippingRateBatchController
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class ShippingRateBatchController extends ShippingRateController {

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			"{$this->route_base}/batch",
			[
				[
					'methods'             => TransportMethods::CREATABLE,
					'callback'            => $this->get_batch_create_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_batch_create_args_schema(),
				],
				[
					'methods'             => TransportMethods::DELETABLE,
					'callback'            => $this->get_batch_delete_shipping_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_batch_delete_args_schema(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);
	}

	/**
	 * Get the callback for creating items via batch.
	 *
	 * @return callable
	 */
	protected function get_batch_create_callback(): callable {
		return function ( Request $request ) {
			$rates = $request->get_param( 'rates' );

			$responses = [];
			$errors    = [];
			foreach ( $rates as $rate ) {
				$new_request = new Request( 'POST', "/{$this->get_namespace()}/{$this->route_base}" );
				$new_request->set_body_params( $rate );

				$response = $this->server->dispatch_request( $new_request );
				if ( 201 !== $response->get_status() ) {
					$errors[] = $response->get_data();
				} else {
					$responses[] = $response->get_data();
				}
			}

			return new Response(
				[
					'errors'  => $errors,
					'success' => $responses,
				],
				201
			);
		};
	}

	/**
	 * Get the callback for deleting shipping items via batch.
	 *
	 * @return callable
	 *
	 * @since 1.12.0
	 */
	protected function get_batch_delete_shipping_callback(): callable {
		return function ( Request $request ) {
			$ids = $request->get_param( 'ids' );

			$responses = [];
			$errors    = [];
			foreach ( $ids as $id ) {
				$route          = "/{$this->get_namespace()}/{$this->route_base}/{$id}";
				$delete_request = new Request( 'DELETE', $route );

				$response = $this->server->dispatch_request( $delete_request );
				if ( 200 !== $response->get_status() ) {
					$errors[] = $response->get_data();
				} else {
					$responses[] = $response->get_data();
				}
			}

			return new Response(
				[
					'errors'  => $errors,
					'success' => $responses,
				],
			);
		};
	}

	/**
	 * Get the argument schema for a batch create request.
	 *
	 * @return array
	 *
	 * @since 1.12.0
	 */
	protected function get_batch_create_args_schema(): array {
		return [
			'rates' => [
				'type'              => 'array',
				'minItems'          => 1,
				'uniqueItems'       => true,
				'description'       => __( 'Array of shipping rates to create.', 'google-listings-and-ads' ),
				'validate_callback' => 'rest_validate_request_arg',
				'items'             => [
					'type'                 => 'object',
					'additionalProperties' => false,
					'properties'           => $this->get_schema_properties(),
				],
			],
		];
	}

	/**
	 * Get the argument schema for a batch delete request.
	 *
	 * @return array
	 *
	 * @since 1.12.0
	 */
	protected function get_batch_delete_args_schema(): array {
		return [
			'ids' => [
				'type'        => 'array',
				'description' => __( 'Array of unique shipping rate identification numbers.', 'google-listings-and-ads' ),
				'context'     => [ 'edit' ],
				'minItems'    => 1,
				'required'    => true,
				'uniqueItems' => 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 'batch_shipping_rates';
	}
}
ShippingRateController.php000064400000020133151556464630011734 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\ShippingRateSchemaTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\ShippingRateQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ISO3166AwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WP_REST_Request as Request;
use WP_REST_Response as Response;

defined( 'ABSPATH' ) || exit;

/**
 * Class ShippingRateController
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class ShippingRateController extends BaseController implements ISO3166AwareInterface {

	use ShippingRateSchemaTrait;

	/**
	 * The base for routes in this controller.
	 *
	 * @var string
	 */
	protected $route_base = 'mc/shipping/rates';

	/**
	 * @var ShippingRateQuery
	 */
	protected $query;

	/**
	 * ShippingRateController constructor.
	 *
	 * @param RESTServer        $server
	 * @param ShippingRateQuery $query
	 */
	public function __construct( RESTServer $server, ShippingRateQuery $query ) {
		parent::__construct( $server );
		$this->query = $query;
	}

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			$this->route_base,
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_read_all_rates_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
				[
					'methods'             => TransportMethods::CREATABLE,
					'callback'            => $this->get_create_rate_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_schema_properties(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);

		$this->register_route(
			"{$this->route_base}/(?P<id>[\d]+)",
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_read_rate_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => [ 'id' => $this->get_schema_properties()['id'] ],
				],
				[
					'methods'             => TransportMethods::EDITABLE,
					'callback'            => $this->get_update_rate_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_schema_properties(),
				],
				[
					'methods'             => TransportMethods::DELETABLE,
					'callback'            => $this->get_delete_rate_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => [ 'id' => $this->get_schema_properties()['id'] ],
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);
	}

	/**
	 * Get the callback function for returning the endpoint results.
	 *
	 * @return callable
	 */
	protected function get_read_all_rates_callback(): callable {
		return function ( Request $request ) {
			$rates = $this->get_all_shipping_rates();

			return array_map(
				function ( $rate ) use ( $request ) {
					$response = $this->prepare_item_for_response( $rate, $request );

					return $this->prepare_response_for_collection( $response );
				},
				$rates
			);
		};
	}

	/**
	 * @return callable
	 */
	protected function get_read_rate_callback(): callable {
		return function ( Request $request ) {
			$id   = (string) $request->get_param( 'id' );
			$rate = $this->get_shipping_rate_by_id( $id );
			if ( empty( $rate ) ) {
				return new Response(
					[
						'message' => __( 'No rate available.', 'google-listings-and-ads' ),
						'id'      => $id,
					],
					404
				);
			}

			return $this->prepare_item_for_response( $rate, $request );
		};
	}

	/**
	 * @return callable
	 *
	 * @since 1.12.0
	 */
	protected function get_update_rate_callback(): callable {
		return function ( Request $request ) {
			$id = (string) $request->get_param( 'id' );

			$rate = $this->get_shipping_rate_by_id( $id );
			if ( empty( $rate ) ) {
				return new Response(
					[
						'message' => __( 'No rate found with the given ID.', 'google-listings-and-ads' ),
						'id'      => $id,
					],
					404
				);
			}

			$data = $this->prepare_item_for_database( $request );
			$this->create_query()->update(
				$data,
				[
					'id' => $id,
				]
			);

			return new Response( '', 204 );
		};
	}

	/**
	 * Get the callback function for creating a new shipping rate.
	 *
	 * @return callable
	 */
	protected function get_create_rate_callback(): callable {
		return function ( Request $request ) {
			$shipping_rate_query = $this->create_query();

			try {
				$data    = $this->prepare_item_for_database( $request );
				$country = $data['country'];

				$existing_query = $this->create_query()->where( 'country', $country );
				$existing       = ! empty( $existing_query->get_results() );

				if ( $existing ) {
					$rate_id = $existing_query->get_results()[0]['id'];
					$shipping_rate_query->update( $data, [ 'id' => $rate_id ] );
				} else {
					$shipping_rate_query->insert( $data );
					$rate_id = $shipping_rate_query->last_insert_id();
				}
			} catch ( InvalidQuery $e ) {
				return $this->error_from_exception(
					$e,
					'gla_error_creating_shipping_rate',
					[
						'code'    => 400,
						'message' => $e->getMessage(),
					]
				);
			}

			// Fetch updated/inserted rate to return in response.
			$rate_response = $this->prepare_item_for_response(
				$this->get_shipping_rate_by_id( (string) $rate_id ),
				$request
			);

			return new Response(
				[
					'status'  => 'success',
					'message' => sprintf(
						/* translators: %s is the country code in ISO 3166-1 alpha-2 format. */
						__( 'Successfully added rate for country: "%s".', 'google-listings-and-ads' ),
						$country
					),
					'rate'    => $rate_response->get_data(),
				],
				201
			);
		};
	}

	/**
	 * @return callable
	 */
	protected function get_delete_rate_callback(): callable {
		return function ( Request $request ) {
			try {
				$id = (string) $request->get_param( 'id' );

				$rate = $this->get_shipping_rate_by_id( $id );
				if ( empty( $rate ) ) {
					return new Response(
						[
							'message' => __( 'No rate found with the given ID.', 'google-listings-and-ads' ),
							'id'      => $id,
						],
						404
					);
				}

				$this->create_query()->delete( 'id', $id );

				return [
					'status'  => 'success',
					'message' => __( 'Successfully deleted rate.', 'google-listings-and-ads' ),
				];
			} catch ( InvalidQuery $e ) {
				return $this->error_from_exception(
					$e,
					'gla_error_deleting_shipping_rate',
					[
						'code'    => 400,
						'message' => $e->getMessage(),
					]
				);
			}
		};
	}

	/**
	 * Returns the list of all shipping rates stored in the database grouped by their respective country code.
	 *
	 * @return array Array of shipping rates grouped by country code.
	 */
	protected function get_all_shipping_rates(): array {
		return $this->create_query()
					->set_order( 'country', 'ASC' )
					->get_results();
	}

	/**
	 * @param string $id
	 *
	 * @return array|null The shipping rate properties as an array or null if it doesn't exist.
	 */
	protected function get_shipping_rate_by_id( string $id ): ?array {
		$results = $this->create_query()->where( 'id', $id )->get_results();

		return ! empty( $results ) ? $results[0] : null;
	}

	/**
	 * Return a new instance of the shipping rate query object.
	 *
	 * @return ShippingRateQuery
	 */
	protected function create_query(): ShippingRateQuery {
		return clone $this->query;
	}

	/**
	 * @return array
	 */
	protected function get_schema_properties(): array {
		return $this->get_shipping_rate_schema();
	}

	/**
	 * Get the item schema name for the controller.
	 *
	 * Used for building the API response schema.
	 *
	 * @return string
	 */
	protected function get_schema_title(): string {
		return 'shipping_rates';
	}
}
ShippingRateSuggestionsController.php000064400000007755151556464630014206 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\ShippingRateSchemaTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ISO3166AwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\ShippingSuggestionService;
use WP_REST_Request as Request;

defined( 'ABSPATH' ) || exit;

/**
 * Class ShippingRateSuggestionsController
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 *
 * @since 1.12.0
 */
class ShippingRateSuggestionsController extends BaseController implements ISO3166AwareInterface {

	use ShippingRateSchemaTrait;

	/**
	 * The base for routes in this controller.
	 *
	 * @var string
	 */
	protected $route_base = 'mc/shipping/rates/suggestions';

	/**
	 * @var ShippingSuggestionService
	 */
	protected $shipping_suggestion;

	/**
	 * ShippingRateSuggestionsController constructor.
	 *
	 * @param RESTServer                $server
	 * @param ShippingSuggestionService $shipping_suggestion
	 */
	public function __construct( RESTServer $server, ShippingSuggestionService $shipping_suggestion ) {
		parent::__construct( $server );
		$this->shipping_suggestion = $shipping_suggestion;
	}

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			"{$this->route_base}",
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_suggestions_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => [
						'country_codes' => [
							'type'              => 'array',
							'description'       => __( 'Array of country codes in ISO 3166-1 alpha-2 format.', 'google-listings-and-ads' ),
							'context'           => [ 'edit' ],
							'sanitize_callback' => $this->get_country_code_sanitize_callback(),
							'validate_callback' => $this->get_country_code_validate_callback(),
							'minItems'          => 1,
							'required'          => true,
							'uniqueItems'       => true,
							'items'             => [
								'type' => 'string',
							],
						],
					],
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);
	}

	/**
	 * Get the callback function for returning the endpoint results.
	 *
	 * @return callable
	 */
	protected function get_suggestions_callback(): callable {
		return function ( Request $request ) {
			$country_codes = $request->get_param( 'country_codes' );
			$rates_output  = [];
			foreach ( $country_codes as $country_code ) {
				$suggestions = $this->shipping_suggestion->get_suggestions( $country_code );

				// Prepare the output.
				$suggestions = array_map(
					function ( $suggestion ) use ( $request ) {
						$response = $this->prepare_item_for_response( $suggestion, $request );

						return $this->prepare_response_for_collection( $response );
					},
					$suggestions
				);

				// Merge the suggestions for all countries into one array.
				$rates_output = array_merge( $rates_output, $suggestions );
			}

			return $rates_output;
		};
	}

	/**
	 * @return array
	 */
	protected function get_schema_properties(): array {
		$schema = $this->get_shipping_rate_schema();

		// Suggested shipping rates don't have an id.
		unset( $schema['id'] );

		// All properties are read-only.
		return array_map(
			function ( $property ) {
				$property['readonly'] = true;
				$property['context']  = [ 'view' ];

				return $property;
			},
			$schema
		);
	}

	/**
	 * Get the item schema name for the controller.
	 *
	 * Used for building the API response schema.
	 *
	 * @return string
	 */
	protected function get_schema_title(): string {
		return 'shipping_rates_suggestions';
	}
}
ShippingTimeBatchController.php000064400000005132151556464630012703 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BatchSchemaTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use WP_REST_Request as Request;
use WP_REST_Response as Response;

defined( 'ABSPATH' ) || exit;

/**
 * Class ShippingTimeBatchController
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class ShippingTimeBatchController extends ShippingTimeController {

	use BatchSchemaTrait;
	use BatchShippingTrait;

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			"{$this->route_base}/batch",
			[
				[
					'methods'             => TransportMethods::CREATABLE,
					'callback'            => $this->get_batch_create_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_item_schema(),
				],
				[
					'methods'             => TransportMethods::DELETABLE,
					'callback'            => $this->get_batch_delete_shipping_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_item_delete_schema(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);
	}

	/**
	 * Get the callback for creating items via batch.
	 *
	 * @return callable
	 */
	protected function get_batch_create_callback(): callable {
		return function ( Request $request ) {
			$country_codes = $request->get_param( 'country_codes' );
			$time          = $request->get_param( 'time' );
			$max_time      = $request->get_param( 'max_time' );

			$responses = [];
			$errors    = [];
			foreach ( $country_codes as $country_code ) {
				$new_request = new Request( 'POST', "/{$this->get_namespace()}/{$this->route_base}" );
				$new_request->set_body_params(
					[
						'country_code' => $country_code,
						'time'         => $time,
						'max_time'     => $max_time,
					]
				);

				$response = $this->server->dispatch_request( $new_request );
				if ( 201 !== $response->get_status() ) {
					$errors[] = $response->get_data();
				} else {
					$responses[] = $response->get_data();
				}
			}

			return new Response(
				[
					'errors'  => $errors,
					'success' => $responses,
				],
				201
			);
		};
	}

	/**
	 * Get the item schema name for the controller.
	 *
	 * Used for building the API response schema.
	 *
	 * @return string
	 */
	protected function get_schema_title(): string {
		return 'batch_shipping_times';
	}
}
ShippingTimeController.php000064400000023537151556464630011752 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\CountryCodeTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\ShippingTimeQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ISO3166AwareInterface;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Class ShippingTimeController
 *
 * ContainerAware used for:
 * - ShippingTimeQuery
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class ShippingTimeController extends BaseController implements ContainerAwareInterface, ISO3166AwareInterface {

	use ContainerAwareTrait;
	use CountryCodeTrait;

	/**
	 * The base for routes in this controller.
	 *
	 * @var string
	 */
	protected $route_base = 'mc/shipping/times';

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			$this->route_base,
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_read_times_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
				[
					'methods'             => TransportMethods::CREATABLE,
					'callback'            => $this->get_create_time_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_args_schema(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);

		$this->register_route(
			"{$this->route_base}/(?P<country_code>\\w{2})",
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_read_time_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_schema_properties(),
				],
				[
					'methods'             => TransportMethods::DELETABLE,
					'callback'            => $this->get_delete_time_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);
	}

	/**
	 * Get the callback function for reading times.
	 *
	 * @return callable
	 */
	protected function get_read_times_callback(): callable {
		return function ( Request $request ) {
			$times = $this->get_all_shipping_times();
			$items = [];
			foreach ( $times as $time ) {
				$data = $this->prepare_item_for_response(
					[
						'country_code' => $time['country'],
						'time'         => $time['time'],
						'max_time'     => $time['max_time'],
					],
					$request
				);

				$items[ $time['country'] ] = $this->prepare_response_for_collection( $data );
			}

			return $items;
		};
	}

	/**
	 * Get the callback function for reading a single time.
	 *
	 * @return callable
	 */
	protected function get_read_time_callback(): callable {
		return function ( Request $request ) {
			$country = $request->get_param( 'country_code' );
			$time    = $this->get_shipping_time_for_country( $country );
			if ( empty( $time ) ) {
				return new Response(
					[
						'message' => __( 'No time available.', 'google-listings-and-ads' ),
						'country' => $country,
					],
					404
				);
			}

			return $this->prepare_item_for_response(
				[
					'country_code' => $time[0]['country'],
					'time'         => $time[0]['time'],
					'max_time'     => $time[0]['max_time'],
				],
				$request
			);
		};
	}

	/**
	 * Get the callback to crate a new time.
	 *
	 * @return callable
	 */
	protected function get_create_time_callback(): callable {
		return function ( Request $request ) {
			$query        = $this->get_query_object();
			$country_code = $request->get_param( 'country_code' );
			$existing     = ! empty( $query->where( 'country', $country_code )->get_results() );

			try {
				$data = [
					'country'  => $country_code,
					'time'     => $request->get_param( 'time' ),
					'max_time' => $request->get_param( 'max_time' ),
				];
				if ( $existing ) {
					$query->update(
						$data,
						[
							'id' => $query->get_results()[0]['id'],
						]
					);
				} else {
					$query->insert( $data );
				}

				return new Response(
					[
						'status'  => 'success',
						'message' => sprintf(
							/* translators: %s is the country code in ISO 3166-1 alpha-2 format. */
							__( 'Successfully added time for country: "%s".', 'google-listings-and-ads' ),
							$country_code
						),
					],
					201
				);
			} catch ( InvalidQuery $e ) {
				return $this->error_from_exception(
					$e,
					'gla_error_creating_shipping_time',
					[
						'code'    => 400,
						'message' => $e->getMessage(),
					]
				);
			}
		};
	}

	/**
	 * Get the callback function for deleting a time.
	 *
	 * @return callable
	 */
	protected function get_delete_time_callback(): callable {
		return function ( Request $request ) {
			try {
				$country_code = $request->get_param( 'country_code' );
				$this->get_query_object()->delete( 'country', $country_code );

				return [
					'status'  => 'success',
					'message' => sprintf(
					/* translators: %s is the country code in ISO 3166-1 alpha-2 format. */
						__( 'Successfully deleted the time for country: "%s".', 'google-listings-and-ads' ),
						$country_code
					),
				];
			} catch ( InvalidQuery $e ) {
				return $this->error_from_exception(
					$e,
					'gla_error_deleting_shipping_time',
					[
						'code'    => 400,
						'message' => $e->getMessage(),
					]
				);
			}
		};
	}

	/**
	 * @return array
	 */
	protected function get_all_shipping_times(): array {
		return $this->get_query_object()->set_limit( 100 )->get_results();
	}

	/**
	 * @param string $country
	 *
	 * @return array
	 */
	protected function get_shipping_time_for_country( string $country ): array {
		return $this->get_query_object()->where( 'country', $country )->get_results();
	}

	/**
	 * Get the shipping time query object.
	 *
	 * @return ShippingTimeQuery
	 */
	protected function get_query_object(): ShippingTimeQuery {
		return $this->container->get( ShippingTimeQuery::class );
	}

	/**
	 * Get the item schema for the controller.
	 *
	 * @return array
	 */
	protected function get_schema_properties(): array {
		return [
			'country_code' => [
				'type'              => 'string',
				'description'       => __( 'Country code in ISO 3166-1 alpha-2 format.', 'google-listings-and-ads' ),
				'context'           => [ 'view', 'edit' ],
				'sanitize_callback' => $this->get_country_code_sanitize_callback(),
				'validate_callback' => $this->get_country_code_validate_callback(),
				'required'          => true,
			],
			'time'         => [
				'type'              => 'integer',
				'description'       => __( 'The minimum shipping time in days.', 'google-listings-and-ads' ),
				'context'           => [ 'view', 'edit' ],
				'validate_callback' => [ $this, 'validate_shipping_times' ],
			],
			'max_time'     => [
				'type'              => 'integer',
				'description'       => __( 'The maximum shipping time in days.', 'google-listings-and-ads' ),
				'context'           => [ 'view', 'edit' ],
				'validate_callback' => [ $this, 'validate_shipping_times' ],
			],
		];
	}

	/**
	 * Get the args schema for the controller.
	 *
	 * @return array
	 */
	protected function get_args_schema(): array {
		$schema                         = $this->get_schema_properties();
		$schema['time']['required']     = true;
		$schema['max_time']['required'] = true;
		return $schema;
	}

	/**
	 * Validate the shipping times.
	 *
	 * @param mixed   $value
	 * @param Request $request
	 * @param string  $param
	 *
	 * @return WP_Error|true
	 */
	public function validate_shipping_times( $value, $request, $param ) {
		$time     = $request->get_param( 'time' );
		$max_time = $request->get_param( 'max_time' );

		if ( rest_is_integer( $value ) === false ) {
			return new WP_Error(
				'rest_invalid_type',
				/* translators: 1: Parameter, 2: Type name. */
				sprintf( __( '%1$s is not of type %2$s.', 'google-listings-and-ads' ), $param, 'integer' ),
				[ 'param' => $param ]
			);
		}

		if ( $value < 0 ) {
			return new WP_Error( 'invalid_shipping_times', __( 'Shipping times cannot be negative.', 'google-listings-and-ads' ), [ 'param' => $param ] );
		}

		if ( $time > $max_time ) {
			return new WP_Error( 'invalid_shipping_times', __( 'The minimum shipping time cannot be greater than the maximum shipping time.', 'google-listings-and-ads' ), [ 'param' => $param ] );
		}

		return 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 'shipping_times';
	}

	/**
	 * Retrieves all of the registered additional fields for a given object-type.
	 *
	 * @param string $object_type Optional. The object type.
	 *
	 * @return array Registered additional fields (if any), empty array if none or if the object type could
	 *               not be inferred.
	 */
	protected function get_additional_fields( $object_type = null ): array {
		$fields            = parent::get_additional_fields( $object_type );
		$fields['country'] = [
			'schema'       => [
				'type'        => 'string',
				'description' => __( 'Country in which the shipping time applies.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'readonly'    => true,
			],
			'get_callback' => function ( $fields ) {
				return $this->iso3166_data_provider->alpha2( $fields['country_code'] )['name'];
			},
		];

		return $fields;
	}
}
SupportedCountriesController.php000064400000010101151556464630013212 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\CountryCodeTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\EmptySchemaPropertiesTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use WP_REST_Request as Request;

defined( 'ABSPATH' ) || exit;

/**
 * Class SupportedCountriesController
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers
 */
class SupportedCountriesController extends BaseController {

	use CountryCodeTrait;
	use EmptySchemaPropertiesTrait;

	/**
	 * The WC proxy object.
	 *
	 * @var WC
	 */
	protected $wc;

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

	/**
	 * SupportedCountriesController constructor.
	 *
	 * @param RESTServer   $server
	 * @param WC           $wc
	 * @param GoogleHelper $google_helper
	 */
	public function __construct( RESTServer $server, WC $wc, GoogleHelper $google_helper ) {
		parent::__construct( $server );
		$this->wc            = $wc;
		$this->google_helper = $google_helper;
	}

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			'mc/countries',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_countries_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_query_args(),
				],
			]
		);
	}

	/**
	 * Get the callback function for returning supported countries.
	 *
	 * @return callable
	 */
	protected function get_countries_callback(): callable {
		return function ( Request $request ) {
			$return = [
				'countries' => $this->get_supported_countries( $request ),
			];

			if ( $request->get_param( 'continents' ) ) {
				$return['continents'] = $this->get_supported_continents();
			}

			return $return;
		};
	}

	/**
	 * Get the array of supported countries.
	 *
	 * @return array
	 */
	protected function get_supported_countries(): array {
		$all_countries = $this->wc->get_countries();
		$mc_countries  = $this->google_helper->get_mc_supported_countries_currencies();

		$supported = [];
		foreach ( $mc_countries as $country => $currency ) {
			if ( ! array_key_exists( $country, $all_countries ) ) {
				continue;
			}

			$supported[ $country ] = [
				'name'     => $all_countries[ $country ],
				'currency' => $currency,
			];
		}

		uasort(
			$supported,
			function ( $a, $b ) {
				return $a['name'] <=> $b['name'];
			}
		);

		return $supported;
	}

	/**
	 * Get the array of supported continents.
	 *
	 * @return array
	 */
	protected function get_supported_continents(): array {
		$all_continents = $this->wc->get_continents();

		foreach ( $all_continents as $continent_code => $continent ) {
			$supported_countries_of_continent = $this->google_helper->get_supported_countries_from_continent( $continent_code );

			if ( empty( $supported_countries_of_continent ) ) {
				unset( $all_continents[ $continent_code ] );
			} else {
				$all_continents[ $continent_code ]['countries'] = array_values( $supported_countries_of_continent );
			}
		}

		return $all_continents;
	}

	/**
	 * Get the item schema name for the controller.
	 *
	 * Used for building the API response schema.
	 *
	 * @return string
	 */
	protected function get_schema_title(): string {
		return 'supported_countries';
	}

	/**
	 * Get the arguments for the query endpoint.
	 *
	 * @return array
	 */
	protected function get_query_args(): array {
		return [
			'continents' => [
				'description'       => __( 'Include continents data if set to true.', 'google-listings-and-ads' ),
				'type'              => 'boolean',
				'validate_callback' => 'rest_validate_request_arg',
			],
		];
	}
}
SyncableProductsCountController.php000064400000007025151556464630013641 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\JobRepository;
use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateSyncableProductsCount;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use WP_REST_Request as Request;
use WP_REST_Response as Response;

defined( 'ABSPATH' ) || exit;

/**
 * Class SyncableProductsCountController
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class SyncableProductsCountController extends BaseOptionsController {

	/**
	 * @var JobRepository
	 */
	protected $job_repository;

	/**
	 * SyncableProductsCountController constructor.
	 *
	 * @param RESTServer    $server
	 * @param JobRepository $job_repository
	 */
	public function __construct( RESTServer $server, JobRepository $job_repository ) {
		parent::__construct( $server );
		$this->job_repository = $job_repository;
	}


	/**
	 * Registers the routes for the objects of the controller.
	 */
	public function register_routes() {
		$this->register_route(
			'mc/syncable-products-count',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_syncable_products_count_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
				[
					'methods'             => TransportMethods::CREATABLE,
					'callback'            => $this->update_syncable_products_count_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
			]
		);
	}

	/**
	 * Get the callback function for marking setup complete.
	 *
	 * @return callable
	 */
	protected function get_syncable_products_count_callback(): callable {
		return function ( Request $request ) {
			$response = [
				'count' => null,
			];

			$count = $this->options->get( OptionsInterface::SYNCABLE_PRODUCTS_COUNT );

			if ( isset( $count ) ) {
				$response['count'] = (int) $count;
			}

			return $this->prepare_item_for_response( $response, $request );
		};
	}

	/**
	 * Get the callback for syncing shipping.
	 *
	 * @return callable
	 */
	protected function update_syncable_products_count_callback(): callable {
		return function ( Request $request ) {
			$this->options->delete( OptionsInterface::SYNCABLE_PRODUCTS_COUNT );
			$this->options->delete( OptionsInterface::SYNCABLE_PRODUCTS_COUNT_INTERMEDIATE_DATA );

			$job = $this->job_repository->get( UpdateSyncableProductsCount::class );
			$job->schedule();

			return new Response(
				[
					'status'  => 'success',
					'message' => __( 'Successfully scheduled a job to update the number of syncable products.', 'google-listings-and-ads' ),
				],
				201
			);
		};
	}

	/**
	 * Get the item schema properties for the controller.
	 *
	 * @return array
	 */
	protected function get_schema_properties(): array {
		return [
			'count' => [
				'type'        => 'number',
				'description' => __( 'The number of products that are ready to be synced to Google.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
			],
		];
	}

	/**
	 * Get the item schema name for the controller.
	 *
	 * Used for building the API response schema.
	 *
	 * @return string
	 */
	protected function get_schema_title(): string {
		return 'syncable_products_count';
	}
}
TargetAudienceController.php000064400000020277151556464630012234 0ustar00<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter;

use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseOptionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\CountryCodeTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ISO3166AwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\RESTServer;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Automattic\WooCommerce\GoogleListingsAndAds\Shipping\ShippingZone;
use Locale;
use WP_REST_Request as Request;
use WP_REST_Response as Response;
use function wp_get_available_translations;

defined( 'ABSPATH' ) || exit;

/**
 * Class TargetAudienceController
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\MerchantCenter
 */
class TargetAudienceController extends BaseOptionsController implements ISO3166AwareInterface {

	use CountryCodeTrait;

	/**
	 * The WP proxy object.
	 *
	 * @var WP
	 */
	protected $wp;

	/**
	 * @var ShippingZone
	 */
	protected $shipping_zone;

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

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

	/**
	 * TargetAudienceController constructor.
	 *
	 * @param RESTServer   $server
	 * @param WP           $wp
	 * @param WC           $wc
	 * @param ShippingZone $shipping_zone
	 * @param GoogleHelper $google_helper
	 */
	public function __construct( RESTServer $server, WP $wp, WC $wc, ShippingZone $shipping_zone, GoogleHelper $google_helper ) {
		parent::__construct( $server );
		$this->wp            = $wp;
		$this->wc            = $wc;
		$this->shipping_zone = $shipping_zone;
		$this->google_helper = $google_helper;
	}

	/**
	 * Register rest routes with WordPress.
	 */
	public function register_routes(): void {
		$this->register_route(
			'mc/target_audience',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_read_audience_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
				[
					'methods'             => TransportMethods::CREATABLE,
					'callback'            => $this->get_update_audience_callback(),
					'permission_callback' => $this->get_permission_callback(),
					'args'                => $this->get_schema_properties(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);
		$this->register_route(
			'mc/target_audience/suggestions',
			[
				[
					'methods'             => TransportMethods::READABLE,
					'callback'            => $this->get_suggest_audience_callback(),
					'permission_callback' => $this->get_permission_callback(),
				],
				'schema' => $this->get_api_response_schema_callback(),
			]
		);
	}

	/**
	 * Get the callback function for reading the target audience data.
	 *
	 * @return callable
	 */
	protected function get_read_audience_callback(): callable {
		return function ( Request $request ) {
			return $this->prepare_item_for_response( $this->get_target_audience_option(), $request );
		};
	}

	/**
	 * Get the callback function for suggesting the target audience data.
	 *
	 * @return callable
	 *
	 * @since 1.9.0
	 */
	protected function get_suggest_audience_callback(): callable {
		return function ( Request $request ) {
			return $this->prepare_item_for_response( $this->get_target_audience_suggestion(), $request );
		};
	}

	/**
	 * Get the callback function for updating the target audience data.
	 *
	 * @return callable
	 */
	protected function get_update_audience_callback(): callable {
		return function ( Request $request ) {
			$data = $this->prepare_item_for_database( $request );
			$this->update_target_audience_option( $data );
			$this->prepare_item_for_response( $data, $request );

			return new Response(
				[
					'status'  => 'success',
					'message' => __( 'Successfully updated the Target Audience settings.', 'google-listings-and-ads' ),
				],
				201
			);
		};
	}

	/**
	 * Retrieves all of the registered additional fields for a given object-type.
	 *
	 * @param string $object_type Optional. The object type.
	 *
	 * @return array Registered additional fields (if any), empty array if none or if the object type could
	 *               not be inferred.
	 */
	protected function get_additional_fields( $object_type = null ): array {
		$fields = parent::get_additional_fields( $object_type );

		// Fields are expected to be an array with a 'get_callback' callable that returns the field value.
		$fields['locale']   = [
			'schema'       => [
				'type'        => 'string',
				'description' => __( 'The locale for the site.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'readonly'    => true,
			],
			'get_callback' => function () {
				return $this->wp->get_locale();
			},
		];
		$fields['language'] = [
			'schema'       => [
				'type'        => 'string',
				'description' => __( 'The language to use for product listings.', 'google-listings-and-ads' ),
				'context'     => [ 'view' ],
				'readonly'    => true,
			],
			'get_callback' => $this->get_language_callback(),
		];

		return $fields;
	}

	/**
	 * Get the option data for the target audience.
	 *
	 * @return array
	 */
	protected function get_target_audience_option(): array {
		return $this->options->get( OptionsInterface::TARGET_AUDIENCE, [] );
	}

	/**
	 * Get the suggested values for the target audience option.
	 *
	 * @return string[]
	 *
	 * @since 1.9.0
	 */
	protected function get_target_audience_suggestion(): array {
		$countries    = $this->shipping_zone->get_shipping_countries();
		$base_country = $this->wc->get_base_country();
		// Add WooCommerce store country if it's supported and not already in the list.
		if ( ! in_array( $base_country, $countries, true ) && $this->google_helper->is_country_supported( $base_country ) ) {
			$countries[] = $base_country;
		}

		return [
			'location'  => 'selected',
			'countries' => $countries,
		];
	}

	/**
	 * Update the option data for the target audience.
	 *
	 * @param array $data
	 *
	 * @return bool
	 */
	protected function update_target_audience_option( array $data ): bool {
		return $this->options->update( OptionsInterface::TARGET_AUDIENCE, $data );
	}

	/**
	 * Get the item schema for the controller.
	 *
	 * @return array
	 */
	protected function get_schema_properties(): array {
		return [
			'location'  => [
				'type'              => 'string',
				'description'       => __( 'Location where products will be shown.', 'google-listings-and-ads' ),
				'context'           => [ 'edit', 'view' ],
				'validate_callback' => 'rest_validate_request_arg',
				'required'          => true,
				'enum'              => [
					'all',
					'selected',
				],
			],
			'countries' => [
				'type'              => 'array',
				'description'       => __(
					'Array of country codes in ISO 3166-1 alpha-2 format.',
					'google-listings-and-ads'
				),
				'context'           => [ 'edit', 'view' ],
				'sanitize_callback' => $this->get_country_code_sanitize_callback(),
				'validate_callback' => $this->get_country_code_validate_callback(),
			],
		];
	}

	/**
	 * Get the item schema name for the controller.
	 *
	 * Used for building the API response schema.
	 *
	 * @return string
	 */
	protected function get_schema_title(): string {
		return 'target_audience';
	}

	/**
	 * Get the callback to provide the language in use for the site.
	 *
	 * @return callable
	 */
	protected function get_language_callback(): callable {
		$locale = $this->wp->get_locale();

		// Default to using the Locale class if it is available.
		if ( class_exists( Locale::class ) ) {
			return function () use ( $locale ): string {
				return Locale::getDisplayLanguage( $locale, $locale );
			};
		}

		return function () use ( $locale ): string {
			// en_US isn't provided by the translations API.
			if ( 'en_US' === $locale ) {
				return 'English';
			}

			require_once ABSPATH . 'wp-admin/includes/translation-install.php';

			return wp_get_available_translations()[ $locale ]['native_name'] ?? $locale;
		};
	}
}