diff --git a/Helper/CheckoutAnalytics.php b/Helper/CheckoutAnalytics.php new file mode 100644 index 000000000..1131776ea --- /dev/null +++ b/Helper/CheckoutAnalytics.php @@ -0,0 +1,297 @@ + + */ + +namespace Adyen\Payment\Helper; + +use Adyen\AdyenException; +use Adyen\Payment\Logger\AdyenLogger; +use Exception; +use Magento\Framework\Exception\InvalidArgumentException; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\HTTP\ClientInterface; +use Magento\Framework\UrlInterface; +use Magento\Store\Model\StoreManagerInterface; + +class CheckoutAnalytics +{ + const CHECKOUT_ANALYTICS_TEST_ENDPOINT = + 'https://checkoutanalytics-test.adyen.com//checkoutanalytics/v3/analytics'; + const CHECKOUT_ANALYTICS_LIVE_ENDPOINT = + 'https://checkoutanalytics.adyen.com//checkoutanalytics/v3/analytics'; + const CHECKOUT_ATTEMPT_ID = 'checkoutAttemptId'; + const FLAVOR_COMPONENT = 'component'; + const INTEGRATOR_ADYEN = 'Adyen'; + const EXTRA_PARAMS_INIT_ENDPOINT = [ + 'version', + 'channel', + 'platform', + 'component', + 'deviceModel', + 'deviceBrand', + 'systemVersion' + ]; + const MESSAGE_PARAMS = [ + 'errors', + 'info', + 'logs' + ]; + + /** + * @param Config $configHelper + * @param Data $adyenHelper + * @param StoreManagerInterface $storeManager + * @param AdyenLogger $adyenLogger + * @param Locale $locale + * @param UrlInterface $urlHelper + * @param ClientInterface $curl + */ + public function __construct( + private readonly Config $configHelper, + private readonly Data $adyenHelper, + private readonly StoreManagerInterface $storeManager, + private readonly AdyenLogger $adyenLogger, + private readonly Locale $locale, + private readonly UrlInterface $urlHelper, + private readonly ClientInterface $curl + ) { } + + /** + * Makes the initial API call to CheckoutAnalytics to obtain checkoutAttemptId + * + * @param array $extraParams + * @return string|null + */ + public function initiateCheckoutAttempt(array $extraParams = []): ?string + { + try { + $request = $this->buildInitiateCheckoutRequest($extraParams); + $endpoint = $this->getInitiateAnalyticsUrl(); + + $response = $this->sendRequest($endpoint, $request); + + if ($this->validateInitiateCheckoutAttemptResponse($response)) { + return $response[self::CHECKOUT_ATTEMPT_ID]; + } + } catch (Exception $exception) { + $errorMessage = __('Error while initiating checkout attempt: %s.', $exception->getMessage()); + $this->adyenLogger->error($errorMessage); + } + + return null; + } + + /** + * Sends info, log or error messages to CheckoutAnalytics + * + * @param string $checkoutAttemptId + * @param array $message Contains `info`, `log` and `errors` objects for payload + * @param string|null $channel + * @param string|null $platform + * @return void + */ + public function sendAnalytics( + string $checkoutAttemptId, + array $message, + string $channel = null, + string $platform = null + ): void { + try { + $request = $this->buildSendAnalyticsRequest($message, $channel, $platform); + $endpoint = $this->getSendAnalyticsUrl($checkoutAttemptId); + + $this->sendRequest($endpoint, $request); + } catch (Exception $exception) { + $errorMessage = __('Error while sending checkout analytic metrics: %s', $exception->getMessage()); + $this->adyenLogger->error($errorMessage); + } + } + + /** + * Builds the endpoint URL for sending analytics messages to CheckoutAnalytics + * + * @param string $checkoutAttemptId + * @return string + * @throws NoSuchEntityException + * @throws AdyenException + */ + private function getSendAnalyticsUrl(string $checkoutAttemptId): string + { + $storeId = $this->storeManager->getStore()->getId(); + $isDemoMode = $this->configHelper->isDemoMode($storeId); + $mode = $isDemoMode ? 'test' : 'live'; + $clientKey = $this->configHelper->getClientKey($mode, $storeId); + + if (is_null($clientKey)) { + throw new AdyenException("clientKey is not configured!"); + } + + return sprintf( + "%s/%s?clientKey=%s", + $this->getEndpointUrl($isDemoMode), + $checkoutAttemptId, + $clientKey + ); + } + + /** + * Builds the request for sending analytics messages to CheckoutAnalytics + * + * @param array $message Contains `info`, `log` and `errors` objects for payload + * @param string|null $channel + * @param string|null $platform + * @return array + * @throws InvalidArgumentException + */ + private function buildSendAnalyticsRequest( + array $message, + string $channel = null, + string $platform = null + ): array { + if (empty($message)) { + throw new InvalidArgumentException(__('Message can not be empty!')); + } + + $request = [ + 'channel' => $channel ?? 'Web', + 'platform' => $platform ?? 'Web' + ]; + + $isMessageParamAdded = false; + + foreach (self::MESSAGE_PARAMS as $key) { + if (isset($message[$key])) { + $request[$key] = $message[$key]; + $isMessageParamAdded = true; + } + } + + if (!$isMessageParamAdded) { + throw new InvalidArgumentException(__('Message does not contain required fields!')); + } + + return $request; + } + + /** + * Generates the endpoint URL for initializing the checkout attempt + * + * @return string + * @throws AdyenException + * @throws NoSuchEntityException + */ + private function getInitiateAnalyticsUrl(): string + { + $storeId = $this->storeManager->getStore()->getId(); + $isDemoMode = $this->configHelper->isDemoMode($storeId); + $mode = $isDemoMode ? 'test' : 'live'; + $clientKey = $this->configHelper->getClientKey($mode, $storeId); + + if (is_null($clientKey)) { + throw new AdyenException("clientKey is not configured!"); + } + + return sprintf("%s?clientKey=%s", $this->getEndpointUrl($isDemoMode), $clientKey); + } + + /** + * Builds the request array for initiate checkout attempt + * For extra fields, see constant EXTRA_PARAMS_INIT_ENDPOINT. + * + * @param array $extraParams + * @return array + * @throws NoSuchEntityException + */ + private function buildInitiateCheckoutRequest(array $extraParams = []): array + { + $storeId = $this->storeManager->getStore()->getId(); + $platformData = $this->adyenHelper->getMagentoDetails(); + $storeLocale = $this->adyenHelper->getStoreLocale($storeId); + $mappedLocale = $this->locale->mapLocaleCode($storeLocale); + $url = $this->urlHelper->getCurrentUrl(); + + $request = [ + 'locale' => $mappedLocale, + 'flavor' => self::FLAVOR_COMPONENT, + 'referrer' => $url, + 'applicationInfo' => [ + 'merchantApplication' => [ + 'name' => $this->adyenHelper->getModuleName(), + 'version' => $this->adyenHelper->getModuleVersion() + ], + 'externalPlatform' => [ + 'name' => $platformData['name'], + 'version' => $platformData['version'], + 'integrator' => self::INTEGRATOR_ADYEN + ] + ] + ]; + + foreach (self::EXTRA_PARAMS_INIT_ENDPOINT as $key) { + if (array_key_exists($key, $extraParams)) { + $request[$key] = $extraParams[$key]; + } + } + + return $request; + } + + /** + * @param $response + * @return bool + * @throws InvalidArgumentException + */ + private function validateInitiateCheckoutAttemptResponse($response): bool + { + if(!array_key_exists('checkoutAttemptId', $response)) { + throw new InvalidArgumentException(__('checkoutAttemptId is missing in the response!')); + } + + return true; + } + + /** + * Returns the CheckoutAnalytics endpoint URL depending on the store mode + * + * @param bool $isDemoMode + * @return string + */ + private function getEndpointUrl(bool $isDemoMode): string + { + if ($isDemoMode) { + $apiUrl = self::CHECKOUT_ANALYTICS_TEST_ENDPOINT; + } else { + $apiUrl = self::CHECKOUT_ANALYTICS_LIVE_ENDPOINT; + } + + return $apiUrl; + } + + /** + * Sends the payload to the given endpoint using Magento cUrl client + * + * @param string $endpoint + * @param array $payload + * @return array|null + */ + private function sendRequest(string $endpoint, array $payload): ?array + { + $this->curl->addHeader('Content-Type', 'application/json'); + + $this->curl->post($endpoint, json_encode($payload)); + $result = $this->curl->getBody(); + + if (empty($result)) { + return null; + } else { + return json_decode($result, true); + } + } +} diff --git a/Test/Unit/Helper/CheckoutAnalyticsTest.php b/Test/Unit/Helper/CheckoutAnalyticsTest.php new file mode 100644 index 000000000..d1edee7fb --- /dev/null +++ b/Test/Unit/Helper/CheckoutAnalyticsTest.php @@ -0,0 +1,236 @@ + + */ + +namespace Adyen\Payment\Test\Unit\Helper; + +use Adyen\Payment\Helper\CheckoutAnalytics; +use Adyen\Payment\Helper\Config; +use Adyen\Payment\Helper\Data; +use Adyen\Payment\Helper\Locale; +use Adyen\Payment\Logger\AdyenLogger; +use Adyen\Payment\Test\Unit\AbstractAdyenTestCase; +use Magento\Framework\HTTP\ClientInterface; +use Magento\Framework\UrlInterface; +use Magento\Store\Api\Data\StoreInterface; +use Magento\Store\Model\StoreManagerInterface; + +class CheckoutAnalyticsTest extends AbstractAdyenTestCase +{ + private $configHelperMock; + private $adyenHelper; + private $storeManagerMock; + private $loggerMock; + private $localeMock; + private $urlHelperMock; + private $httpClient; + + const STORE_ID = 1; + const STORE_LOCALE = 'nl_NL'; + const CLIENT_KEY = 'client_key_mock_XYZ1234567890'; + + protected function setUp(): void + { + $this->configHelperMock = $this->createMock(Config::class); + $this->adyenHelper = $this->createMock(Data::class); + $this->storeManagerMock = $this->createMock(StoreManagerInterface::class); + $this->loggerMock = $this->createMock(AdyenLogger::class); + $this->localeMock = $this->createPartialMock(Locale::class, []); + $this->urlHelperMock = $this->createMock(UrlInterface::class); + $this->httpClient = $this->createMock(ClientInterface::class); + + $storeMock = $this->createMock(StoreInterface::class); + $storeMock->method('getId')->willReturn(self::STORE_ID); + + $this->storeManagerMock->method('getStore')->willReturn($storeMock); + $this->adyenHelper->method('getStoreLocale') + ->with(self::STORE_ID) + ->willReturn(self::STORE_LOCALE); + $this->adyenHelper->method('getMagentoDetails')->willReturn([ + 'name' => 'Adobe Commerce', + 'version' => '2.x.x' + ]); + } + + protected function generateClass(): CheckoutAnalytics + { + return new CheckoutAnalytics( + $this->configHelperMock, + $this->adyenHelper, + $this->storeManagerMock, + $this->loggerMock, + $this->localeMock, + $this->urlHelperMock, + $this->httpClient + ); + } + + public function testSuccessfulInitiateCheckoutAttemptWithoutExtraParams() { + $this->configHelperMock->method('isDemoMode') + ->with(self::STORE_ID) + ->willReturn(false); + + $this->configHelperMock->method('getClientKey') + ->with('live', self::STORE_ID) + ->willReturn(self::CLIENT_KEY); + + $expectedResponse = '{"checkoutAttemptId":"test_response"}'; + $this->httpClient->method('getBody')->willReturn($expectedResponse); + + $checkoutAnalytics = $this->generateClass(); + + $this->assertEquals('test_response', $checkoutAnalytics->initiateCheckoutAttempt()); + } + + public function testSuccessfulInitiateCheckoutAttemptWithExtraParams() { + $this->configHelperMock->method('isDemoMode') + ->with(self::STORE_ID) + ->willReturn(true); + + $this->configHelperMock->method('getClientKey') + ->with('test', self::STORE_ID) + ->willReturn(self::CLIENT_KEY); + + $expectedResponse = '{"checkoutAttemptId":"test_response"}'; + $this->httpClient->method('getBody')->willReturn($expectedResponse); + + $checkoutAnalytics = $this->generateClass(); + + $extraParams = [ + 'version' => '1.0.0', + 'channel' => 'Web', + 'platform' => 'Web', + 'component' => 'plugin', + 'deviceModel' => 'testDeviceModel', + 'deviceBrand' => 'testBrand', + 'systemVersion' => '1.0.0' + ]; + + $this->assertEquals('test_response', $checkoutAnalytics->initiateCheckoutAttempt($extraParams)); + } + + public function testInitiateCheckoutAttemptIncorrectResponse() { + $this->configHelperMock->method('isDemoMode') + ->with(self::STORE_ID) + ->willReturn(false); + + $this->configHelperMock->method('getClientKey') + ->with('live', self::STORE_ID) + ->willReturn(self::CLIENT_KEY); + + $expectedResponse = '{"someOtherKey":"test_response"}'; + $this->httpClient->method('getBody')->willReturn($expectedResponse); + + $this->loggerMock->expects($this->once())->method('error'); + + $checkoutAnalytics = $this->generateClass(); + $checkoutAnalytics->initiateCheckoutAttempt(); + } + + public function testInitiateCheckoutAttemptMissingClientKey() { + $this->configHelperMock->method('isDemoMode') + ->with(self::STORE_ID) + ->willReturn(false); + + $this->loggerMock->expects($this->once())->method('error'); + + $checkoutAnalytics = $this->generateClass(); + $result = $checkoutAnalytics->initiateCheckoutAttempt(); + + $this->assertNull($result); + } + + public function testSuccessfulSendAnalytics() { + $this->configHelperMock->method('isDemoMode') + ->with(self::STORE_ID) + ->willReturn(false); + + $this->configHelperMock->method('getClientKey') + ->with('live', self::STORE_ID) + ->willReturn(self::CLIENT_KEY); + + $checkoutAttemptId = 'attempt_0123456789'; + $message = [ + 'errors' => [ + 'key' => 'value' + ] + ]; + $expectedRequest = [ + 'channel' => 'Web', + 'platform' => 'Web', + 'errors' => [ + 'key' => 'value' + ] + ]; + $expectedUrl = sprintf( + "%s/%s?clientKey=%s", + 'https://checkoutanalytics.adyen.com//checkoutanalytics/v3/analytics', + $checkoutAttemptId, + self::CLIENT_KEY + ); + + $this->httpClient->expects($this->once()) + ->method('post') + ->with($expectedUrl, json_encode($expectedRequest)); + + $checkoutAnalytics = $this->generateClass(); + $checkoutAnalytics->sendAnalytics($checkoutAttemptId, $message, 'Web', 'Web'); + } + + public function testSendAnalyticsWithMissingClientKey() { + $this->configHelperMock->method('isDemoMode') + ->with(self::STORE_ID) + ->willReturn(false); + + $checkoutAttemptId = 'attempt_0123456789'; + $message = [ + 'errors' => [ + 'key' => 'value' + ] + ]; + + $this->loggerMock->expects($this->once())->method('error'); + + $checkoutAnalytics = $this->generateClass(); + $checkoutAnalytics->sendAnalytics($checkoutAttemptId, $message, 'Web', 'Web'); + } + + public function testSendAnalyticsWithEmptyMessageParams() { + $this->configHelperMock->method('isDemoMode') + ->with(self::STORE_ID) + ->willReturn(false); + + $checkoutAttemptId = 'attempt_0123456789'; + $message = []; + + $this->loggerMock->expects($this->once())->method('error'); + + $checkoutAnalytics = $this->generateClass(); + $checkoutAnalytics->sendAnalytics($checkoutAttemptId, $message, 'Web', 'Web'); + } + + public function testSendAnalyticsWithIncorrectMessageParams() { + $this->configHelperMock->method('isDemoMode') + ->with(self::STORE_ID) + ->willReturn(false); + + $checkoutAttemptId = 'attempt_0123456789'; + $message = [ + 'wrongKey' => [ + 'key' => 'value' + ] + ]; + + $this->loggerMock->expects($this->once())->method('error'); + + $checkoutAnalytics = $this->generateClass(); + $checkoutAnalytics->sendAnalytics($checkoutAttemptId, $message, 'Web', 'Web'); + } +}