diff --git a/src/API/Google/Ads.php b/src/API/Google/Ads.php index bf9c4fdcfb..5ea8a9e4b2 100644 --- a/src/API/Google/Ads.php +++ b/src/API/Google/Ads.php @@ -17,10 +17,15 @@ use Google\Ads\GoogleAds\V23\Enums\AccessRoleEnum\AccessRole; use Google\Ads\GoogleAds\V23\Enums\ProductLinkInvitationStatusEnum\ProductLinkInvitationStatus; use Google\Ads\GoogleAds\V23\Resources\ProductLinkInvitation; +use Google\Ads\GoogleAds\V23\Services\FetchIncentiveRequest; +use Google\Ads\GoogleAds\V23\Services\FetchIncentiveRequest\IncentiveType; +use Google\Ads\GoogleAds\V23\Services\Incentive; +use Google\Ads\GoogleAds\V23\Services\IncentiveOffer\OfferType; use Google\Ads\GoogleAds\V23\Services\ListAccessibleCustomersRequest; use Google\Ads\GoogleAds\V23\Services\UpdateProductLinkInvitationRequest; use Google\ApiCore\ApiException; use Google\ApiCore\ValidationException; +use Google\Type\Money; defined( 'ABSPATH' ) || exit; @@ -220,6 +225,123 @@ public function use_store_currency(): bool { return $this->options->update( OptionsInterface::ADS_ACCOUNT_CURRENCY, get_woocommerce_currency() ); } + /** + * Fetch available incentive offers from the Google Ads API. + * + * @since 3.3.0 + * + * @param string $country_code ISO 3166-1 alpha-2 country code. + * @param string $language_code ISO 639-1 language code. + * + * @return array Structured incentive offer data. Always returns a valid structure, + * falling back to an empty CYO_INCENTIVE response on API errors. + */ + public function fetch_incentives( string $country_code, string $language_code ): array { + $empty_response = [ + 'type' => OfferType::name( OfferType::CYO_INCENTIVE ), + 'termsAndConditionsUrl' => '', + 'incentives' => [], + ]; + + try { + $request = new FetchIncentiveRequest(); + $request->setCountryCode( $country_code ); + $request->setLanguageCode( $language_code ); + + $response = $this->client->getIncentiveServiceClient()->fetchIncentive( $request ); + $offer = $response->getIncentiveOffer(); + + if ( ! $offer || ! $offer->hasType() ) { + return $empty_response; + } + + $result = [ + 'type' => OfferType::name( $offer->getType() ), + 'termsAndConditionsUrl' => $offer->getConsolidatedTermsAndConditionsUrl(), + 'incentives' => [], + ]; + + if ( OfferType::CYO_INCENTIVE === $offer->getType() && $offer->hasCyoIncentives() ) { + $cyo = $offer->getCyoIncentives(); + + $offer_map = [ + 'low' => $cyo->getLowOffer(), + 'medium' => $cyo->getMediumOffer(), + 'high' => $cyo->getHighOffer(), + ]; + + foreach ( $offer_map as $level => $incentive ) { + if ( $incentive ) { + $result['incentives'][] = $this->format_incentive( $incentive, $level ); + } + } + } + + return $result; + } catch ( ApiException $e ) { + do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ ); + + return $empty_response; + } + } + + /** + * Format an Incentive protobuf message into an array for the REST response. + * + * @since 3.3.0 + * + * @param Incentive $incentive The incentive object. + * @param string $level The offer level (low, medium, high). + * + * @return array + */ + protected function format_incentive( Incentive $incentive, string $level ): array { + $data = [ + 'id' => (string) $incentive->getIncentiveId(), + 'type' => IncentiveType::name( $incentive->getType() ), + 'offer' => $level, + 'termsAndConditionsUrl' => $incentive->getIncentiveTermsAndConditionsUrl(), + 'requirement' => [], + ]; + + if ( $incentive->hasRequirement() ) { + $requirement = $incentive->getRequirement(); + + if ( $requirement->hasSpend() ) { + $spend = $requirement->getSpend(); + $data['requirement']['spend'] = [ + 'awardAmount' => $this->format_money( $spend->getAwardAmount() ), + 'requiredAmount' => $this->format_money( $spend->getRequiredAmount() ), + ]; + } + } + + return $data; + } + + /** + * Format a Money protobuf message into an array. + * + * @since 3.3.0 + * + * @param Money|null $money The Money object. + * + * @return array + */ + protected function format_money( ?Money $money ): array { + if ( ! $money ) { + return [ + 'currencyCode' => '', + 'units' => '0', + ]; + } + + return [ + 'currencyCode' => $money->getCurrencyCode(), + 'units' => (string) $money->getUnits(), + ]; + } + /** * Convert ads ID from a resource name to an int. * diff --git a/src/API/Site/Controllers/Ads/IncentivesController.php b/src/API/Site/Controllers/Ads/IncentivesController.php new file mode 100644 index 0000000000..79cde13aed --- /dev/null +++ b/src/API/Site/Controllers/Ads/IncentivesController.php @@ -0,0 +1,184 @@ +ads = $ads; + $this->wc = $wc; + } + + /** + * Register rest routes with WordPress. + */ + public function register_routes(): void { + $this->register_route( + 'ads/incentives', + [ + [ + 'methods' => TransportMethods::READABLE, + 'callback' => $this->get_incentives_callback(), + 'permission_callback' => $this->get_permission_callback(), + ], + 'schema' => $this->get_api_response_schema_callback(), + ] + ); + } + + /** + * @return callable + */ + protected function get_incentives_callback(): callable { + return function ( Request $request ) { + $country_code = $this->wc->get_base_country(); + $language_code = $this->get_language_code(); + + $incentives = $this->ads->fetch_incentives( $country_code, $language_code ); + + return $this->prepare_item_for_response( $incentives, $request ); + }; + } + + /** + * Get the ISO 639-1 language code from the WordPress locale. + * + * @return string + */ + protected function get_language_code(): string { + $locale = get_locale(); + + if ( empty( $locale ) ) { + return 'en'; + } + + return strtolower( substr( $locale, 0, 2 ) ); + } + + /** + * Get the item schema properties for the controller. + * + * @return array + */ + protected function get_schema_properties(): array { + return [ + 'type' => [ + 'type' => 'string', + 'description' => __( 'The offer type.', 'google-listings-and-ads' ), + 'context' => [ 'view' ], + ], + 'termsAndConditionsUrl' => [ + 'type' => 'string', + 'description' => __( 'The consolidated terms and conditions URL.', 'google-listings-and-ads' ), + 'context' => [ 'view' ], + ], + 'incentives' => [ + 'type' => 'array', + 'description' => __( 'The available incentive offers.', 'google-listings-and-ads' ), + 'context' => [ 'view' ], + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'description' => __( 'The incentive ID.', 'google-listings-and-ads' ), + ], + 'type' => [ + 'type' => 'string', + 'description' => __( 'The incentive type.', 'google-listings-and-ads' ), + ], + 'offer' => [ + 'type' => 'string', + 'enum' => [ 'low', 'medium', 'high' ], + 'description' => __( 'The offer level.', 'google-listings-and-ads' ), + ], + 'termsAndConditionsUrl' => [ + 'type' => 'string', + 'description' => __( 'The terms and conditions URL for this incentive.', 'google-listings-and-ads' ), + ], + 'requirement' => [ + 'type' => 'object', + 'properties' => [ + 'spend' => [ + 'type' => 'object', + 'properties' => [ + 'awardAmount' => [ + 'type' => 'object', + 'properties' => [ + 'currencyCode' => [ + 'type' => 'string', + ], + 'units' => [ + 'type' => 'string', + ], + ], + ], + 'requiredAmount' => [ + 'type' => 'object', + 'properties' => [ + 'currencyCode' => [ + 'type' => 'string', + ], + 'units' => [ + '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 'incentives'; + } +} diff --git a/src/Google/Ads/ServiceClientFactoryTrait.php b/src/Google/Ads/ServiceClientFactoryTrait.php index 48da113b7f..07aeec3d43 100644 --- a/src/Google/Ads/ServiceClientFactoryTrait.php +++ b/src/Google/Ads/ServiceClientFactoryTrait.php @@ -23,6 +23,7 @@ use Google\Ads\GoogleAds\V23\Services\Client\AssetGroupListingGroupFilterServiceClient; use Google\Ads\GoogleAds\V23\Services\Client\AssetGroupServiceClient; use Google\Ads\GoogleAds\V23\Services\Client\BillingSetupServiceClient; +use Google\Ads\GoogleAds\V23\Services\Client\IncentiveServiceClient; use Google\Ads\GoogleAds\V23\Services\Client\CampaignBudgetServiceClient; use Google\Ads\GoogleAds\V23\Services\Client\CampaignCriterionServiceClient; use Google\Ads\GoogleAds\V23\Services\Client\CampaignServiceClient; @@ -197,6 +198,13 @@ public function getGoogleAdsServiceClient(): GoogleAdsServiceClient { return new GoogleAdsServiceClient( $this->getGoogleAdsClientOptions() ); } + /** + * @return IncentiveServiceClient + */ + public function getIncentiveServiceClient(): IncentiveServiceClient { + return new IncentiveServiceClient( $this->getGoogleAdsClientOptions() ); + } + /** * @return ProductLinkInvitationServiceClient */ diff --git a/src/Internal/DependencyManagement/RESTServiceProvider.php b/src/Internal/DependencyManagement/RESTServiceProvider.php index d13b64ae4a..c54cc38181 100644 --- a/src/Internal/DependencyManagement/RESTServiceProvider.php +++ b/src/Internal/DependencyManagement/RESTServiceProvider.php @@ -21,6 +21,7 @@ use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\BudgetRecommendationController; use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\CampaignController as AdsCampaignController; use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\IncentiveCreditsController; +use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\IncentivesController; use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\ReportsController as AdsReportsController; use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\SetupCompleteController; use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\AssetGroupController as AdsAssetGroupController; @@ -128,6 +129,7 @@ public function register(): void { $this->share( BudgetRecommendationController::class, Ads::class ); $this->share( GoogleAccountController::class, Connection::class ); $this->share( IncentiveCreditsController::class ); + $this->share( IncentivesController::class, Ads::class, WC::class ); $this->share( JetpackAccountController::class, Manager::class, Middleware::class ); $this->share( MerchantCenterProductStatsController::class, MerchantStatuses::class, ProductSyncStats::class ); $this->share( MerchantCenterIssuesController::class, MerchantStatuses::class, ProductHelper::class ); diff --git a/tests/Unit/API/Site/Controllers/Ads/IncentivesControllerTest.php b/tests/Unit/API/Site/Controllers/Ads/IncentivesControllerTest.php new file mode 100644 index 0000000000..fbd84254a9 --- /dev/null +++ b/tests/Unit/API/Site/Controllers/Ads/IncentivesControllerTest.php @@ -0,0 +1,222 @@ + 'CYO_INCENTIVE', + 'termsAndConditionsUrl' => '', + 'incentives' => [], + ]; + + public function setUp(): void { + parent::setUp(); + + $this->ads = $this->createMock( Ads::class ); + $this->wc = $this->createMock( WC::class ); + + $this->wc->method( 'get_base_country' )->willReturn( 'GB' ); + + $this->controller = new IncentivesController( $this->server, $this->ads, $this->wc ); + $this->controller->register(); + } + + public function test_get_incentives_success() { + $incentives = [ + 'type' => 'CYO_INCENTIVE', + 'termsAndConditionsUrl' => 'https://ads.google.com/intl/en_uk/home/terms-and-conditions/incentives/?bc=UK', + 'incentives' => [ + [ + 'id' => '2378556534', + 'type' => 'ACQUISITION', + 'offer' => 'low', + 'termsAndConditionsUrl' => 'https://ads.google.com/intl/en_uk/home/terms-and-conditions/incentives/?bc=UK&bid=nickel', + 'requirement' => [ + 'spend' => [ + 'awardAmount' => [ + 'currencyCode' => 'GBP', + 'units' => '800', + ], + 'requiredAmount' => [ + 'currencyCode' => 'GBP', + 'units' => '1250', + ], + ], + ], + ], + [ + 'id' => '1995402192', + 'type' => 'ACQUISITION', + 'offer' => 'medium', + 'termsAndConditionsUrl' => 'https://ads.google.com/intl/en_uk/home/terms-and-conditions/incentives/?bc=UK&bid=sodium', + 'requirement' => [ + 'spend' => [ + 'awardAmount' => [ + 'currencyCode' => 'GBP', + 'units' => '1600', + ], + 'requiredAmount' => [ + 'currencyCode' => 'GBP', + 'units' => '3200', + ], + ], + ], + ], + [ + 'id' => '7056154833', + 'type' => 'ACQUISITION', + 'offer' => 'high', + 'termsAndConditionsUrl' => 'https://ads.google.com/intl/en_uk/home/terms-and-conditions/incentives/?bc=UK&bid=technetium', + 'requirement' => [ + 'spend' => [ + 'awardAmount' => [ + 'currencyCode' => 'GBP', + 'units' => '2500', + ], + 'requiredAmount' => [ + 'currencyCode' => 'GBP', + 'units' => '5000', + ], + ], + ], + ], + ], + ]; + + $this->ads->expects( $this->once() ) + ->method( 'fetch_incentives' ) + ->with( 'GB', $this->isType( 'string' ) ) + ->willReturn( $incentives ); + + $response = $this->do_request( self::ROUTE_INCENTIVES, 'GET' ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( $incentives, $response->get_data() ); + } + + public function test_get_incentives_success_usd() { + $incentives = [ + 'type' => 'CYO_INCENTIVE', + 'termsAndConditionsUrl' => 'https://ads.google.com/intl/en_us/home/terms-and-conditions/incentives/?bc=US', + 'incentives' => [ + [ + 'id' => '1234567890', + 'type' => 'ACQUISITION', + 'offer' => 'low', + 'termsAndConditionsUrl' => 'https://ads.google.com/intl/en_us/home/terms-and-conditions/incentives/?bc=US&bid=low', + 'requirement' => [ + 'spend' => [ + 'awardAmount' => [ + 'currencyCode' => 'USD', + 'units' => '500', + ], + 'requiredAmount' => [ + 'currencyCode' => 'USD', + 'units' => '500', + ], + ], + ], + ], + [ + 'id' => '2345678901', + 'type' => 'ACQUISITION', + 'offer' => 'medium', + 'termsAndConditionsUrl' => 'https://ads.google.com/intl/en_us/home/terms-and-conditions/incentives/?bc=US&bid=medium', + 'requirement' => [ + 'spend' => [ + 'awardAmount' => [ + 'currencyCode' => 'USD', + 'units' => '1000', + ], + 'requiredAmount' => [ + 'currencyCode' => 'USD', + 'units' => '1500', + ], + ], + ], + ], + [ + 'id' => '3456789012', + 'type' => 'ACQUISITION', + 'offer' => 'high', + 'termsAndConditionsUrl' => 'https://ads.google.com/intl/en_us/home/terms-and-conditions/incentives/?bc=US&bid=high', + 'requirement' => [ + 'spend' => [ + 'awardAmount' => [ + 'currencyCode' => 'USD', + 'units' => '1500', + ], + 'requiredAmount' => [ + 'currencyCode' => 'USD', + 'units' => '3000', + ], + ], + ], + ], + ], + ]; + + $this->ads->expects( $this->once() ) + ->method( 'fetch_incentives' ) + ->with( 'GB', $this->isType( 'string' ) ) + ->willReturn( $incentives ); + + $response = $this->do_request( self::ROUTE_INCENTIVES, 'GET' ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( $incentives, $response->get_data() ); + } + + public function test_get_incentives_empty_response() { + $this->ads->expects( $this->once() ) + ->method( 'fetch_incentives' ) + ->willReturn( self::EMPTY_RESPONSE ); + + $response = $this->do_request( self::ROUTE_INCENTIVES, 'GET' ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( self::EMPTY_RESPONSE, $response->get_data() ); + } + + public function test_get_incentives_no_incentive_type() { + $no_incentive = [ + 'type' => 'NO_INCENTIVE', + 'termsAndConditionsUrl' => '', + 'incentives' => [], + ]; + + $this->ads->expects( $this->once() ) + ->method( 'fetch_incentives' ) + ->willReturn( $no_incentive ); + + $response = $this->do_request( self::ROUTE_INCENTIVES, 'GET' ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( 'NO_INCENTIVE', $response->get_data()['type'] ); + $this->assertEmpty( $response->get_data()['incentives'] ); + } +}