From c8ba5d03e1f588a07e1ff539b15c05e6c6b98571 Mon Sep 17 00:00:00 2001 From: Can Demiralp Date: Wed, 21 Feb 2024 13:07:24 +0100 Subject: [PATCH] [ECP-8692] Implement usage of pending_payment status for action required payment attempts (#2381) * [ECP-8692] Create an observer to update the order status after checkout * [ECP-8692] Update the order on payment details * [ECP-8692] Implement status update logic after paymentsDetails call * [ECP-8692] Abstract paymentsDetails call and related response handler, refactor Return controller * [ECP-8692] Fix Order unit tests * [ECP-8692] Remove remains of remote tracking * [ECP-8692] Fix unit test * [ECP-8692] Write unit tests for AdyenPaymentsDetails class * [ECP-8692] Write unit tests for GuestAdyenPaymentsDetails class * [ECP-8692] Clean-up code duplications * [ECP-8692] Remove redundant method implementation * [ECP-8692] Write unit tests for Return/Index class * [ECP-8692] Remove redundant property * [ECP-8692] Remove redundant property * [ECP-8692] Fix condition with no redirectResponse * [ECP-8692] Write unit tests for PaymentResponseHandler class * [ECP-8692] Replace the constant for resultCode * [ECP-8692] Write unit tests for PaymentResponseHandler class * [ECP-8692] Finalise TODO * [ECP-8692] Fix unreachable statement * [ECP-8692] Increase the coverage of PaymentResponseHandler class * [ECP-8692] Write unit tests for SetOrderStateAfterPaymentObserver class * [ECP-8692] Increase test coverage * [ECP-8692] Write unit test for Order class * [ECP-8692] Write unit test for PaymentDetails class * [ECP-8692] Fix CS issues --------- Co-authored-by: hossam-adyen <132500300+hossam-adyen@users.noreply.github.com> --- Controller/Return/Index.php | 394 ++++------------ Helper/Order.php | 28 +- Helper/PaymentResponseHandler.php | 249 ++++++---- Helper/PaymentsDetails.php | 79 ++-- Model/Api/AdyenPaymentsDetails.php | 38 +- Model/Api/GuestAdyenPaymentsDetails.php | 11 +- .../SetOrderStateAfterPaymentObserver.php | 77 ++++ Test/Unit/Controller/Return/IndexTest.php | 267 +++++++++++ Test/Unit/Helper/OrderTest.php | 55 ++- Test/Unit/Helper/PaymentDetailsTest.php | 98 ++-- .../Helper/PaymentResponseHandlerTest.php | 434 ++++++++++++++++++ .../Model/Api/AdyenPaymentsDetailsTest.php | 106 +++++ .../Api/GuestAdyenPaymentsDetailsTest.php | 110 +++++ .../SetOrderStateAfterPaymentObserverTest.php | 102 ++++ etc/events.xml | 5 +- 15 files changed, 1565 insertions(+), 488 deletions(-) create mode 100644 Observer/SetOrderStateAfterPaymentObserver.php create mode 100644 Test/Unit/Controller/Return/IndexTest.php create mode 100644 Test/Unit/Helper/PaymentResponseHandlerTest.php create mode 100644 Test/Unit/Model/Api/AdyenPaymentsDetailsTest.php create mode 100644 Test/Unit/Model/Api/GuestAdyenPaymentsDetailsTest.php create mode 100644 Test/Unit/Observer/SetOrderStateAfterPaymentObserverTest.php diff --git a/Controller/Return/Index.php b/Controller/Return/Index.php index c0a52bdf5..a679a6557 100755 --- a/Controller/Return/Index.php +++ b/Controller/Return/Index.php @@ -12,25 +12,19 @@ // phpcs:ignore namespace Adyen\Payment\Controller\Return; -use Adyen\AdyenException; -use Adyen\Payment\Helper\Data; -use Adyen\Payment\Helper\Idempotency; +use Adyen\Payment\Helper\PaymentResponseHandler; +use Adyen\Payment\Helper\PaymentsDetails; use Adyen\Payment\Helper\Quote; use Adyen\Payment\Helper\Config; -use Adyen\Payment\Helper\StateData; -use Adyen\Payment\Helper\Vault; use Adyen\Payment\Logger\AdyenLogger; -use Adyen\Payment\Model\Notification; -use Adyen\Service\Validator\DataArrayValidator; +use Exception; use Magento\Checkout\Model\Session; use Magento\Framework\App\Action\Action; use Magento\Framework\App\Action\Context; use Magento\Framework\Exception\LocalizedException; -use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Framework\Exception\NoSuchEntityException; use Magento\Sales\Model\Order; -use Magento\Sales\Model\Order\Status\HistoryFactory; use Magento\Sales\Model\OrderFactory; -use Magento\Sales\Model\ResourceModel\Order as OrderResource; use Magento\Store\Model\StoreManagerInterface; class Index extends Action @@ -59,73 +53,53 @@ class Index extends Action protected OrderFactory $orderFactory; protected Config $configHelper; protected Order $order; - protected HistoryFactory $orderHistoryFactory; protected Session $session; protected AdyenLogger $adyenLogger; protected StoreManagerInterface $storeManager; private Quote $quoteHelper; private Order\Payment $payment; - private Vault $vaultHelper; - private OrderResource $orderResourceModel; - private StateData $stateDataHelper; - private Data $adyenDataHelper; - private OrderRepositoryInterface $orderRepository; - private Idempotency $idempotencyHelper; + private PaymentsDetails $paymentsDetailsHelper; + private PaymentResponseHandler $paymentResponseHandler; public function __construct( Context $context, OrderFactory $orderFactory, - HistoryFactory $orderHistoryFactory, Session $session, AdyenLogger $adyenLogger, StoreManagerInterface $storeManager, Quote $quoteHelper, - Vault $vaultHelper, - OrderResource $orderResourceModel, - StateData $stateDataHelper, - Data $adyenDataHelper, - OrderRepositoryInterface $orderRepository, - Idempotency $idempotencyHelper, - Config $configHelper + Config $configHelper, + PaymentsDetails $paymentsDetailsHelper, + PaymentResponseHandler $paymentResponseHandler ) { parent::__construct($context); - $this->adyenDataHelper = $adyenDataHelper; $this->orderFactory = $orderFactory; - $this->orderHistoryFactory = $orderHistoryFactory; $this->session = $session; $this->adyenLogger = $adyenLogger; $this->storeManager = $storeManager; $this->quoteHelper = $quoteHelper; - $this->vaultHelper = $vaultHelper; - $this->orderResourceModel = $orderResourceModel; - $this->stateDataHelper = $stateDataHelper; - $this->orderRepository = $orderRepository; $this->configHelper = $configHelper; - $this->idempotencyHelper = $idempotencyHelper; + $this->paymentsDetailsHelper = $paymentsDetailsHelper; + $this->paymentResponseHandler = $paymentResponseHandler; } + /** + * @throws NoSuchEntityException + * @throws LocalizedException + */ public function execute(): void { - $result = false; - // Receive all params as this could be a GET or POST request - $response = $this->getRequest()->getParams(); + $redirectResponse = $this->getRequest()->getParams(); - if ($response) { - $result = $this->validateResponse($response); - $order = $this->order; - $additionalInformation = $order->getPayment()->getAdditionalInformation(); - $resultCode = isset($response['resultCode']) ? $response['resultCode'] : null; - $paymentBrandCode = $additionalInformation['brand_code'] ?? null; - if ($resultCode === 'cancelled' && $paymentBrandCode === 'svs') { - $this->adyenDataHelper->cancelOrder($order); - } + if ($redirectResponse) { + $result = $this->validateRedirectResponse($redirectResponse); // Adjust the success path, fail path, and restore quote based on if it is a multishipping quote if ( - !empty($response['merchantReference']) && - $this->quoteHelper->getIsQuoteMultiShippingWithMerchantReference($response['merchantReference']) + !empty($redirectResponse['merchantReference']) && + $this->quoteHelper->getIsQuoteMultiShippingWithMerchantReference($redirectResponse['merchantReference']) ) { $successPath = $failPath = 'multishipping/checkout/success'; $setQuoteAsActive = true; @@ -135,240 +109,86 @@ public function execute(): void $failPath = $this->configHelper->getAdyenAbstractConfigData('return_path'); $setQuoteAsActive = false; } - } else { - $this->_redirect($this->configHelper->getAdyenAbstractConfigData('return_path')); - } - - if ($result) { - $session = $this->session; - $session->getQuote()->setIsActive($setQuoteAsActive)->save(); - $paymentAction = $this->order->getPayment()->getAdditionalInformation('action'); - $brandCode = $this->order->getPayment()->getAdditionalInformation('brand_code'); - $resultCode = $this->order->getPayment()->getAdditionalInformation('resultCode'); - - // Prevent action component to redirect page again after returning to the shop - if (($brandCode == self::BRAND_CODE_DOTPAY && $resultCode == self::RESULT_CODE_RECEIVED) || - (isset($paymentAction) && $paymentAction['type'] === 'redirect') - ) { - $this->payment->unsAdditionalInformation('action'); - $this->order->save(); - } + if ($result) { + $this->session->getQuote()->setIsActive($setQuoteAsActive)->save(); - // Add OrderIncrementId to redirect parameters for headless support. - $redirectParams = $this->configHelper->getAdyenAbstractConfigData('custom_success_redirect_path') - ? ['_query' => ['utm_nooverride' => '1', 'order_increment_id' => $this->order->getIncrementId()]] - : ['_query' => ['utm_nooverride' => '1']]; - $this->_redirect($successPath, $redirectParams); - } else { - $this->adyenLogger->addAdyenResult( - sprintf( - 'Payment for order %s was unsuccessful, ' . - 'it will be cancelled when the OFFER_CLOSED notification has been processed.', - $this->order->getIncrementId() - ) - ); - $this->replaceCart($response); - $this->_redirect($failPath, ['_query' => ['utm_nooverride' => '1']]); - } - } + // Add OrderIncrementId to redirect parameters for headless support. + $redirectParams = $this->configHelper->getAdyenAbstractConfigData('custom_success_redirect_path') + ? ['_query' => ['utm_nooverride' => '1', 'order_increment_id' => $this->order->getIncrementId()]] + : ['_query' => ['utm_nooverride' => '1']]; + $this->_redirect($successPath, $redirectParams); + } else { + $this->adyenLogger->addAdyenResult( + sprintf( + 'Payment for order %s was unsuccessful, ' . + 'it will be cancelled when the OFFER_CLOSED notification has been processed.', + isset($this->order) ? $this->order->getIncrementId() : + ($redirectResponse['merchantReference'] ?? null) + ) + ); - protected function replaceCart(array $response): void - { - $this->session->restoreQuote(); + $this->session->restoreQuote(); + $this->messageManager->addError(__('Your payment failed, Please try again later')); - if (isset($response['authResult']) && $response['authResult'] == \Adyen\Payment\Model\Notification::CANCELLED) { - $this->messageManager->addError(__('You have cancelled the order. Please try again')); + $this->_redirect($failPath, ['_query' => ['utm_nooverride' => '1']]); + } } else { - $this->messageManager->addError(__('Your payment failed, Please try again later')); + $this->_redirect($this->configHelper->getAdyenAbstractConfigData('return_path')); } } - protected function validateResponse(array $response): bool + /** + * @throws LocalizedException + * @throws Exception + */ + protected function validateRedirectResponse(array $redirectResponse): bool { - $this->adyenLogger->addAdyenResult('Processing ResultUrl'); + $this->adyenLogger->addAdyenResult('Processing redirect response'); + $order = $this->getOrder($redirectResponse['merchantReference'] ?? null); - // send the payload verification payment\details request to validate the response - $response = $this->validatePayloadAndReturnResponse($response); - - $order = $this->order; - - $this->_eventManager->dispatch( - 'adyen_payment_process_resulturl_before', - [ - 'order' => $order, - 'adyen_response' => $response - ] - ); - - // Save PSP reference from the response - if (!empty($response['pspReference'])) { - $this->payment->setAdditionalInformation('pspReference', $response['pspReference']); - } - - // Handle recurring details - $this->vaultHelper->handlePaymentResponseRecurringDetails($this->payment, $response); - - // Save donation token if available in the response - if (!empty($response['donationToken'])) { - $this->payment->setAdditionalInformation('donationToken', $response['donationToken']); + try { + // Make paymentsDetails call to validate the payment + $request["details"] = $redirectResponse; + $paymentsDetailsResponse = $this->paymentsDetailsHelper->initiatePaymentDetails($order, $request); + } catch (Exception $e) { + $paymentsDetailsResponse['error'] = $e->getMessage(); } - // update the order - $result = $this->validateUpdateOrder($order, $response); - - $this->_eventManager->dispatch( - 'adyen_payment_process_resulturl_after', - [ - 'order' => $order, - 'adyen_response' => $response - ] - ); - - return $result; - } - - protected function validateUpdateOrder(Order $order, array $response): bool - { $result = false; - if (!empty($response['authResult'])) { - $authResult = $response['authResult']; - } elseif (!empty($response['resultCode'])) { - $authResult = $response['resultCode']; - } else { - // In case the result is unknown we log the request and don't update the history - $this->adyenLogger->error("Unexpected result query parameter. Response: " . json_encode($response)); - - return $result; - } - - $this->adyenLogger->addAdyenResult('Updating the order'); - - if (isset($response['paymentMethod']['brand'])) { - $paymentMethod = $response['paymentMethod']['brand']; - } - elseif (isset($response['paymentMethod']['type'])) { - $paymentMethod = $response['paymentMethod']['type']; - } - else { - $paymentMethod = ''; - } - - $pspReference = isset($response['pspReference']) ? trim((string) $response['pspReference']) : ''; - - $type = 'Adyen Result URL response:'; - $comment = __( - '%1
authResult: %2
pspReference: %3
paymentMethod: %4', - $type, - $authResult, - $pspReference, - $paymentMethod - ); - - // needed because then we need to save $order objects - $order->setAdyenResulturlEventCode($authResult); - - // Update the payment additional information with the new result code - $orderPayment = $order->getPayment(); - $orderPayment->setAdditionalInformation('resultCode', $authResult); - $this->orderResourceModel->save($order); - - switch (strtoupper((string) $authResult)) { - case Notification::AUTHORISED: - $result = true; - $this->adyenLogger->addAdyenResult('Do nothing wait for the notification'); - break; - case Notification::RECEIVED: - $result = true; - if (strpos((string) $paymentMethod, "alipay_hk") !== false) { - $result = false; - } - $this->adyenLogger->addAdyenResult('Do nothing wait for the notification'); - break; - case Notification::PENDING: - // do nothing wait for the notification - $result = true; - if (strpos((string) $paymentMethod, "bankTransfer") !== false) { - $comment .= "

Waiting for the customer to transfer the money."; - } elseif ($paymentMethod == "sepadirectdebit") { - $comment .= "

This request will be send to the bank at the end of the day."; - } else { - $comment .= "

The payment result is not confirmed (yet). -
Once the payment is authorised, the order status will be updated accordingly. -
If the order is stuck on this status, the payment can be seen as unsuccessful. -
The order can be automatically cancelled based on the OFFER_CLOSED notification. - Please contact Adyen Support to enable this."; - } - $this->adyenLogger->addAdyenResult('Do nothing wait for the notification'); - break; - case Notification::CANCELLED: - case Notification::ERROR: - $this->adyenLogger->addAdyenResult('Cancel or Hold the order on OFFER_CLOSED notification'); - $result = false; - break; - case Notification::REFUSED: - // if refused there will be a AUTHORIZATION : FALSE notification send only exception is idea - $this->adyenLogger->addAdyenResult( - 'Cancel or Hold the order on AUTHORISATION - success = false notification' + // Compare the merchant references + $merchantReference = $paymentsDetailsResponse['merchantReference'] ?? null; + if ($merchantReference) { + if ($order->getIncrementId() === $merchantReference) { + $this->order = $order; + $this->payment = $order->getPayment(); + $this->cleanUpRedirectAction(); + + $result = $this->paymentResponseHandler->handlePaymentsDetailsResponse( + $paymentsDetailsResponse, + $order ); - $result = false; - - if (!$order->canCancel()) { - $order->setState(Order::STATE_NEW); - $this->orderRepository->save($order); - } - $this->adyenDataHelper->cancelOrder($order); - - break; - default: - $this->adyenLogger->addAdyenResult('This event is not supported: ' . $authResult); - $result = false; - break; - } - - $history = $this->orderHistoryFactory->create() - ->setStatus($order->getStatus()) - ->setComment($comment) - ->setEntityName('order') - ->setOrder($order); - - $history->save(); - - // Cleanup state data - try { - $this->stateDataHelper->cleanQuoteStateData($order->getQuoteId(), $authResult); - } catch (\Exception $exception) { - $this->adyenLogger->error(__('Error cleaning the payment state data: %s', $exception->getMessage())); - } - - - return $result; - } - - protected function getOrder(string $incrementId = null): Order - { - if (!isset($this->order)) { - if ($incrementId !== null) { - //TODO Replace with order repository search for best practice - $this->order = $this->orderFactory->create()->loadByIncrementId($incrementId); } else { - $this->order = $this->session->getLastRealOrder(); + $this->adyenLogger->error("Wrong merchantReference was set in the query or in the session"); } + } else { + $this->adyenLogger->error("No merchantReference in the response"); } - return $this->order; + return $result; } - protected function validatePayloadAndReturnResponse(array $result): array + /** + * @throws LocalizedException + */ + private function getOrder(string $incrementId = null): Order { - $client = $this->adyenDataHelper->initializeAdyenClient($this->storeManager->getStore()->getId()); - $service = $this->adyenDataHelper->createAdyenCheckoutService($client); - - $order = $this->getOrder( - !empty($result['merchantReference']) ? $result['merchantReference'] : null - ); + if ($incrementId !== null) { + $order = $this->orderFactory->create()->loadByIncrementId($incrementId); + } else { + $order = $this->session->getLastRealOrder(); + } if (!$order->getId()) { throw new LocalizedException( @@ -376,47 +196,25 @@ protected function validatePayloadAndReturnResponse(array $result): array ); } - $this->payment = $order->getPayment(); - - $request = []; - - // filter details to match the keys - $details = $result; - // TODO build a validator class which also validates the type of the param - $details = DataArrayValidator::getArrayOnlyWithApprovedKeys($details, self::DETAILS_ALLOWED_PARAM_KEYS); - - // Remove helper params in case left in the request - $helperParams = ['isAjax', 'merchantReference']; - foreach ($helperParams as $helperParam) { - if (array_key_exists($helperParam, $details)) { - unset($details[$helperParam]); - } - } - - $request["details"] = $details; - $requestOptions['idempotencyKey'] = $this->idempotencyHelper->generateIdempotencyKey($request); - $requestOptions['headers'] = $this->adyenDataHelper->buildRequestHeaders(); + return $order; + } - try { - $response = $service->paymentsDetails($request, $requestOptions); - $responseMerchantReference = !empty($response['merchantReference']) ? $response['merchantReference'] : null; - $resultMerchantReference = !empty($result['merchantReference']) ? $result['merchantReference'] : null; - $merchantReference = $responseMerchantReference ?: $resultMerchantReference; - if ($merchantReference) { - if ($order->getIncrementId() === $merchantReference) { - $this->order = $order; - } else { - $this->adyenLogger->error("Wrong merchantReference was set in the query or in the session"); - $response['error'] = 'merchantReference mismatch'; - } - } else { - $this->adyenLogger->error("No merchantReference in the response"); - $response['error'] = 'merchantReference is missing from the response'; - } - } catch (AdyenException $e) { - $response['error'] = $e->getMessage(); + /** + * @return void + * @throws Exception + */ + private function cleanUpRedirectAction(): void + { + // Prevent action component to redirect page again after returning to the shop + $paymentAction = $this->order->getPayment()->getAdditionalInformation('action'); + $brandCode = $this->order->getPayment()->getAdditionalInformation('brand_code'); + $resultCode = $this->order->getPayment()->getAdditionalInformation('resultCode'); + + if (($brandCode == self::BRAND_CODE_DOTPAY && $resultCode == self::RESULT_CODE_RECEIVED) || + (isset($paymentAction) && $paymentAction['type'] === 'redirect') + ) { + $this->payment->unsAdditionalInformation('action'); + $this->order->save(); } - - return $response; } } diff --git a/Helper/Order.php b/Helper/Order.php index 0109f66ee..4608cdd5b 100644 --- a/Helper/Order.php +++ b/Helper/Order.php @@ -83,6 +83,8 @@ class Order extends AbstractHelper /** @var AdyenCreditmemoHelper */ private $adyenCreditmemoHelper; + private MagentoOrder\StatusResolver $statusResolver; + public function __construct( Context $context, Builder $transactionBuilder, @@ -100,7 +102,8 @@ public function __construct( OrderPaymentCollectionFactory $adyenOrderPaymentCollectionFactory, PaymentMethods $paymentMethodsHelper, AdyenCreditMemoResourceModel $adyenCreditmemoResourceModel, - AdyenCreditmemoHelper $adyenCreditmemoHelper + AdyenCreditmemoHelper $adyenCreditmemoHelper, + MagentoOrder\StatusResolver $statusResolver ) { parent::__construct($context); $this->transactionBuilder = $transactionBuilder; @@ -119,6 +122,7 @@ public function __construct( $this->paymentMethodsHelper = $paymentMethodsHelper; $this->adyenCreditmemoResourceModel = $adyenCreditmemoResourceModel; $this->adyenCreditmemoHelper = $adyenCreditmemoHelper; + $this->statusResolver = $statusResolver; } /** @@ -370,6 +374,28 @@ public function setPrePaymentAuthorized(MagentoOrder $order): MagentoOrder return $order; } + public function setStatusOrderCreation(OrderInterface $order): OrderInterface + { + $paymentMethod = $order->getPayment()->getMethod(); + + // Fetch the default order status for order creation from the configuration. + $status = $this->configHelper->getConfigData( + 'order_status', + $paymentMethod, + $order->getStoreId() + ); + + if (is_null($status)) { + // If the configuration doesn't exist, use the default status. + $status = $this->statusResolver->getOrderStatusByState($order, MagentoOrder::STATE_NEW); + } + + $order->setStatus($status); + $order->setState(MagentoOrder::STATE_NEW); + + return $order; + } + /** * @param MagentoOrder $order * @param $ignoreHasInvoice diff --git a/Helper/PaymentResponseHandler.php b/Helper/PaymentResponseHandler.php index bd1997bc0..fe8cec13f 100644 --- a/Helper/PaymentResponseHandler.php +++ b/Helper/PaymentResponseHandler.php @@ -14,9 +14,11 @@ use Adyen\Payment\Logger\AdyenLogger; use Exception; use Magento\Framework\Exception\AlreadyExistsException; -use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Exception\InputException; +use Magento\Framework\Exception\NoSuchEntityException; use Magento\Sales\Api\Data\OrderInterface; -use Magento\Sales\Model\Order\Payment; +use Magento\Sales\Model\Order\Status\HistoryFactory; +use Magento\Sales\Model\OrderRepository; use Magento\Sales\Model\ResourceModel\Order; class PaymentResponseHandler @@ -35,7 +37,7 @@ class PaymentResponseHandler const VAULT = 'Magento Vault'; const POS_SUCCESS = 'Success'; - public const ACTION_REQUIRED_STATUSES = [ + const ACTION_REQUIRED_STATUSES = [ self::REDIRECT_SHOPPER, self::IDENTIFY_SHOPPER, self::CHALLENGE_SHOPPER, @@ -46,46 +48,35 @@ class PaymentResponseHandler * @var AdyenLogger */ private AdyenLogger $adyenLogger; - - /** - * @var Vault - */ private Vault $vaultHelper; - - /** - * @var Order - */ private Order $orderResourceModel; - - /** - * @var Data - */ private Data $dataHelper; - - /** - * @var Quote - */ private Quote $quoteHelper; + private \Adyen\Payment\Helper\Order $orderHelper; + private OrderRepository $orderRepository; + private HistoryFactory $orderHistoryFactory; + private StateData $stateDataHelper; - /** - * @param AdyenLogger $adyenLogger - * @param Vault $vaultHelper - * @param Order $orderResourceModel - * @param Data $dataHelper - * @param Quote $quoteHelper - */ public function __construct( AdyenLogger $adyenLogger, Vault $vaultHelper, Order $orderResourceModel, Data $dataHelper, - Quote $quoteHelper + Quote $quoteHelper, + \Adyen\Payment\Helper\Order $orderHelper, + OrderRepository $orderRepository, + HistoryFactory $orderHistoryFactory, + StateData $stateDataHelper ) { $this->adyenLogger = $adyenLogger; $this->vaultHelper = $vaultHelper; $this->orderResourceModel = $orderResourceModel; $this->dataHelper = $dataHelper; $this->quoteHelper = $quoteHelper; + $this->orderHelper = $orderHelper; + $this->orderRepository = $orderRepository; + $this->orderHistoryFactory = $orderHistoryFactory; + $this->stateDataHelper = $stateDataHelper; } public function formatPaymentResponse( @@ -132,86 +123,108 @@ public function formatPaymentResponse( } /** - * @param array $paymentsResponse - * @param Payment $payment - * @param OrderInterface|null $order + * @param array $paymentsDetailsResponse + * @param OrderInterface $order * @return bool - * @throws LocalizedException * @throws AlreadyExistsException + * @throws InputException + * @throws NoSuchEntityException */ - public function handlePaymentResponse( - array $paymentsResponse, - Payment $payment, - OrderInterface $order = null - ):bool { - if (empty($paymentsResponse)) { + public function handlePaymentsDetailsResponse( + array $paymentsDetailsResponse, + OrderInterface $order + ): bool { + if (empty($paymentsDetailsResponse)) { $this->adyenLogger->error("Payment details call failed, paymentsResponse is empty"); return false; } - if (!empty($paymentsResponse['resultCode'])) { - $payment->setAdditionalInformation('resultCode', $paymentsResponse['resultCode']); + $this->adyenLogger->addAdyenResult('Updating the order'); + $payment = $order->getPayment(); + + $authResult = $paymentsDetailsResponse['authResult'] ?? $paymentsDetailsResponse['resultCode'] ?? null; + if (is_null($authResult)) { + // In case the result is unknown we log the request and don't update the history + $this->adyenLogger->error( + "Unexpected result query parameter. Response: " . json_encode($paymentsDetailsResponse) + ); + + return false; } - if (!empty($paymentsResponse['action'])) { - $payment->setAdditionalInformation('action', $paymentsResponse['action']); + $paymentMethod = $paymentsDetailsResponse['paymentMethod']['brand'] ?? + $paymentsDetailsResponse['paymentMethod']['type'] ?? + ''; + + $pspReference = isset($paymentsDetailsResponse['pspReference']) ? + trim((string) $paymentsDetailsResponse['pspReference']) : + ''; + + $type = 'Adyen paymentsDetails response:'; + $comment = __( + '%1
authResult: %2
pspReference: %3
paymentMethod: %4', + $type, + $authResult, + $pspReference, + $paymentMethod + ); + + if (!empty($paymentsDetailsResponse['resultCode'])) { + $payment->setAdditionalInformation('resultCode', $paymentsDetailsResponse['resultCode']); } - if (!empty($paymentsResponse['additionalData'])) { - $payment->setAdditionalInformation('additionalData', $paymentsResponse['additionalData']); + if (!empty($paymentsDetailsResponse['action'])) { + $payment->setAdditionalInformation('action', $paymentsDetailsResponse['action']); } - if (!empty($paymentsResponse['pspReference'])) { - $payment->setAdditionalInformation('pspReference', $paymentsResponse['pspReference']); + if (!empty($paymentsDetailsResponse['additionalData'])) { + $payment->setAdditionalInformation('additionalData', $paymentsDetailsResponse['additionalData']); } - if (!empty($paymentsResponse['details'])) { - $payment->setAdditionalInformation('details', $paymentsResponse['details']); + if (!empty($paymentsDetailsResponse['pspReference'])) { + $payment->setAdditionalInformation('pspReference', $paymentsDetailsResponse['pspReference']); } - switch ($paymentsResponse['resultCode']) { - case self::PRESENT_TO_SHOPPER: - case self::PENDING: - case self::RECEIVED: - case self::IDENTIFY_SHOPPER: - case self::CHALLENGE_SHOPPER: - break; - //We don't need to handle these resultCodes - case self::REDIRECT_SHOPPER: - $this->adyenLogger->addAdyenResult("Customer was redirected."); - if ($order) { - $order->addStatusHistoryComment( - __( - 'Customer was redirected to an external payment page. (In case of card payments the shopper is redirected to the bank for 3D-secure validation.) Once the shopper is authenticated, - the order status will be updated accordingly. -
Make sure that your notifications are being processed! -
If the order is stuck on this status, the shopper abandoned the session. - The payment can be seen as unsuccessful. -
The order can be automatically cancelled based on the OFFER_CLOSED notification. - Please contact Adyen Support to enable this.' - ), - $order->getStatus() - )->save(); - } - break; + if (!empty($paymentsDetailsResponse['details'])) { + $payment->setAdditionalInformation('details', $paymentsDetailsResponse['details']); + } + + if (!empty($paymentsDetailsResponse['donationToken'])) { + $payment->setAdditionalInformation('donationToken', $paymentsDetailsResponse['donationToken']); + } + + // Handle recurring details + $this->vaultHelper->handlePaymentResponseRecurringDetails($payment, $paymentsDetailsResponse); + + // If the response is valid, update the order status. + if (!in_array($paymentsDetailsResponse['resultCode'], PaymentResponseHandler::ACTION_REQUIRED_STATUSES)) { + /* + * Change order state from pending_payment to new and expect authorisation webhook + * if no additional action is required according to /paymentsDetails response. + * Otherwise keep the order state as pending_payment. + */ + $order = $this->orderHelper->setStatusOrderCreation($order); + $this->orderRepository->save($order); + } + + // Cleanup state data if exists. + try { + $this->stateDataHelper->cleanQuoteStateData($order->getQuoteId(), $authResult); + } catch (Exception $exception) { + $this->adyenLogger->error(__('Error cleaning the payment state data: %s', $exception->getMessage())); + } + + switch ($paymentsDetailsResponse['resultCode']) { case self::AUTHORISED: - if (!empty($paymentsResponse['pspReference'])) { + if (!empty($paymentsDetailsResponse['pspReference'])) { // set pspReference as transactionId - $payment->setCcTransId($paymentsResponse['pspReference']); - $payment->setLastTransId($paymentsResponse['pspReference']); + $payment->setCcTransId($paymentsDetailsResponse['pspReference']); + $payment->setLastTransId($paymentsDetailsResponse['pspReference']); // set transaction - $payment->setTransactionId($paymentsResponse['pspReference']); - } - - // Handle recurring details - $this->vaultHelper->handlePaymentResponseRecurringDetails($payment, $paymentsResponse); - - if (!empty($paymentsResponse['donationToken'])) { - $payment->setAdditionalInformation('donationToken', $paymentsResponse['donationToken']); + $payment->setTransactionId($paymentsDetailsResponse['pspReference']); } - $this->orderResourceModel->save($order); try { $this->quoteHelper->disableQuote($order->getQuoteId()); } catch (Exception $e) { @@ -219,8 +232,47 @@ public function handlePaymentResponse( 'quoteId' => $order->getQuoteId() ]); } + + $result = true; + break; + case self::PENDING: + /* Change order state from pending_payment to new and expect authorisation webhook + * if no additional action is required according to /paymentDetails response. */ + $order = $this->orderHelper->setStatusOrderCreation($order); + $this->orderRepository->save($order); + + // do nothing wait for the notification + if (strpos((string) $paymentMethod, "bankTransfer") !== false) { + $comment .= "

Waiting for the customer to transfer the money."; + } elseif ($paymentMethod == "sepadirectdebit") { + $comment .= "

This request will be send to the bank at the end of the day."; + } else { + $comment .= "

The payment result is not confirmed (yet). +
Once the payment is authorised, the order status will be updated accordingly. +
If the order is stuck on this status, the payment can be seen as unsuccessful. +
The order can be automatically cancelled based on the OFFER_CLOSED notification. + Please contact Adyen Support to enable this."; + } + $this->adyenLogger->addAdyenResult('Do nothing wait for the notification'); + + $result = true; + break; + case self::PRESENT_TO_SHOPPER: + case self::IDENTIFY_SHOPPER: + case self::CHALLENGE_SHOPPER: + case self::REDIRECT_SHOPPER: + $this->adyenLogger->addAdyenResult("Additional action is required for the payment."); + $result = true; + break; + case self::RECEIVED: + $result = true; + if (str_contains((string)$paymentMethod, "alipay_hk")) { + $result = false; + } + $this->adyenLogger->addAdyenResult('Do nothing wait for the notification'); break; case self::REFUSED: + case self::CANCELLED: // Cancel order in case result is refused if (null !== $order) { // Set order to new so it can be cancelled @@ -229,17 +281,32 @@ public function handlePaymentResponse( $order->setActionFlag(\Magento\Sales\Model\Order::ACTION_FLAG_CANCEL, true); $this->dataHelper->cancelOrder($order); } - return false; - case self::ERROR: + $result = false; + break; default: $this->adyenLogger->error( - sprintf("Payment details call failed for action, resultCode is %s Raw API responds: %s", - $paymentsResponse['resultCode'], - json_encode($paymentsResponse) + sprintf("Payment details call failed for action, resultCode is %s Raw API responds: %s. + Cancel or Hold the order on OFFER_CLOSED notification.", + $paymentsDetailsResponse['resultCode'], + json_encode($paymentsDetailsResponse) )); - return false; + $result = false; + break; } - return true; + + $history = $this->orderHistoryFactory->create() + ->setStatus($order->getStatus()) + ->setComment($comment) + ->setEntityName('order') + ->setOrder($order); + + $history->save(); + + // needed because then we need to save $order objects + $order->setAdyenResulturlEventCode($authResult); + $this->orderResourceModel->save($order); + + return $result; } } diff --git a/Helper/PaymentsDetails.php b/Helper/PaymentsDetails.php index 117d77298..279708403 100644 --- a/Helper/PaymentsDetails.php +++ b/Helper/PaymentsDetails.php @@ -12,9 +12,11 @@ namespace Adyen\Payment\Helper; use Adyen\AdyenException; +use Adyen\Payment\Helper\Util\DataArrayValidator; use Adyen\Payment\Logger\AdyenLogger; -use Adyen\Service\Validator\DataArrayValidator; use Magento\Checkout\Model\Session; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Exception\NoSuchEntityException; use Magento\Framework\Exception\ValidatorException; use Magento\Sales\Api\Data\OrderInterface; @@ -26,87 +28,68 @@ class PaymentsDetails 'threeDSAuthenticationOnly' ]; + const REQUEST_HELPER_PARAMETERS = [ + 'isAjax', + 'merchantReference' + ]; + private Session $checkoutSession; private Data $adyenHelper; private AdyenLogger $adyenLogger; - private PaymentResponseHandler $paymentResponseHandler; private Idempotency $idempotencyHelper; public function __construct( Session $checkoutSession, Data $adyenHelper, AdyenLogger $adyenLogger, - PaymentResponseHandler $paymentResponseHandler, Idempotency $idempotencyHelper ) { $this->checkoutSession = $checkoutSession; $this->adyenHelper = $adyenHelper; $this->adyenLogger = $adyenLogger; - $this->paymentResponseHandler = $paymentResponseHandler; $this->idempotencyHelper = $idempotencyHelper; } - public function initiatePaymentDetails(OrderInterface $order, string $payload): string + /** + * @throws LocalizedException + * @throws NoSuchEntityException + * @throws ValidatorException + */ + public function initiatePaymentDetails(OrderInterface $order, array $payload): array { - // Decode payload from frontend - $payload = json_decode($payload, true); - - // Validate JSON that has just been parsed if it was in a valid format - if (json_last_error() !== JSON_ERROR_NONE) { - throw new ValidatorException(__('Payment details call failed because the request was not a valid JSON')); - } - - $payment = $order->getPayment(); - $apiPayload = DataArrayValidator::getArrayOnlyWithApprovedKeys($payload, self::PAYMENTS_DETAILS_KEYS); + $request = $this->cleanUpPaymentDetailsPayload($payload); - // Send the request try { $client = $this->adyenHelper->initializeAdyenClient($order->getStoreId()); $service = $this->adyenHelper->createAdyenCheckoutService($client); - $requestOptions['idempotencyKey'] = $this->idempotencyHelper->generateIdempotencyKey($apiPayload); + $requestOptions['idempotencyKey'] = $this->idempotencyHelper->generateIdempotencyKey($request); $requestOptions['headers'] = $this->adyenHelper->buildRequestHeaders(); - $paymentDetails = $service->paymentsDetails($apiPayload, $requestOptions); + $response = $service->paymentsDetails($request, $requestOptions); } catch (AdyenException $e) { $this->adyenLogger->error("Payment details call failed: " . $e->getMessage()); $this->checkoutSession->restoreQuote(); - // accept cancellation request, restore quote - if (!empty($payload['cancelled'])) { - throw $this->createCancelledException(); - } else { - throw new ValidatorException(__('Payment details call failed')); - } + throw new ValidatorException(__('Payment details call failed')); } - // Handle response - if (!$this->paymentResponseHandler->handlePaymentResponse($paymentDetails, $payment, $order)) { - $this->checkoutSession->restoreQuote(); - throw new ValidatorException(__('The payment is REFUSED.')); - } + return $response; + } - $action = null; - if (!empty($paymentDetails['action'])) { - $action = $paymentDetails['action']; - } + private function cleanUpPaymentDetailsPayload(array $payload): array + { + $payload = DataArrayValidator::getArrayOnlyWithApprovedKeys( + $payload, + self::PAYMENTS_DETAILS_KEYS + ); - $additionalData = null; - if (!empty($paymentDetails['additionalData'])) { - $additionalData = $paymentDetails['additionalData']; + foreach (self::REQUEST_HELPER_PARAMETERS as $helperParam) { + if (array_key_exists($helperParam, $payload['details'])) { + unset($payload['details'][$helperParam]); + } } - return json_encode( - $this->paymentResponseHandler->formatPaymentResponse( - $paymentDetails['resultCode'], - $action, - $additionalData - ) - ); - } - - private function createCancelledException(): ValidatorException - { - return new ValidatorException(__('Payment has been cancelled')); + return $payload; } } diff --git a/Model/Api/AdyenPaymentsDetails.php b/Model/Api/AdyenPaymentsDetails.php index 4e2d4c58b..5691a359a 100644 --- a/Model/Api/AdyenPaymentsDetails.php +++ b/Model/Api/AdyenPaymentsDetails.php @@ -12,24 +12,32 @@ namespace Adyen\Payment\Model\Api; use Adyen\Payment\Api\AdyenPaymentsDetailsInterface; +use Adyen\Payment\Helper\PaymentResponseHandler; use Adyen\Payment\Helper\PaymentsDetails; +use Magento\Checkout\Model\Session; use Magento\Framework\Exception\AlreadyExistsException; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Framework\Exception\ValidatorException; use Magento\Sales\Api\OrderRepositoryInterface; class AdyenPaymentsDetails implements AdyenPaymentsDetailsInterface { private OrderRepositoryInterface $orderRepository; - private PaymentsDetails $paymentsDetails; + private PaymentResponseHandler $paymentResponseHandler; + private Session $checkoutSession; public function __construct( OrderRepositoryInterface $orderRepository, - PaymentsDetails $paymentsDetails + PaymentsDetails $paymentsDetails, + PaymentResponseHandler $paymentResponseHandler, + Session $checkoutSession ) { $this->orderRepository = $orderRepository; $this->paymentsDetails = $paymentsDetails; + $this->paymentResponseHandler = $paymentResponseHandler; + $this->checkoutSession = $checkoutSession; } /** @@ -45,6 +53,30 @@ public function initiate(string $payload, string $orderId): string { $order = $this->orderRepository->get(intval($orderId)); - return $this->paymentsDetails->initiatePaymentDetails($order, $payload); + // Decode payload from frontend + $payload = json_decode($payload, true); + + // Validate JSON that has just been parsed if it was in a valid format + if (json_last_error() !== JSON_ERROR_NONE) { + throw new ValidatorException( + __('Payment details call failed because the request was not a valid JSON') + ); + } + + $response = $this->paymentsDetails->initiatePaymentDetails($order, $payload); + + // Handle response + if (!$this->paymentResponseHandler->handlePaymentsDetailsResponse($response, $order)) { + $this->checkoutSession->restoreQuote(); + throw new ValidatorException(__('The payment is REFUSED.')); + } + + return json_encode( + $this->paymentResponseHandler->formatPaymentResponse( + $response['resultCode'], + $response['action'] ?? null, + $response['additionalData'] ?? null + ) + ); } } diff --git a/Model/Api/GuestAdyenPaymentsDetails.php b/Model/Api/GuestAdyenPaymentsDetails.php index def9472b6..da426e647 100644 --- a/Model/Api/GuestAdyenPaymentsDetails.php +++ b/Model/Api/GuestAdyenPaymentsDetails.php @@ -12,7 +12,6 @@ namespace Adyen\Payment\Model\Api; use Adyen\Payment\Api\GuestAdyenPaymentsDetailsInterface; -use Adyen\Payment\Helper\PaymentsDetails; use Magento\Framework\Exception\AlreadyExistsException; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Exception\NoSuchEntityException; @@ -23,19 +22,17 @@ class GuestAdyenPaymentsDetails implements GuestAdyenPaymentsDetailsInterface { private OrderRepositoryInterface $orderRepository; - private QuoteIdMaskFactory $quoteIdMaskFactory; - - private PaymentsDetails $paymentsDetails; + private AdyenPaymentsDetails $adyenPaymentsDetails; public function __construct( OrderRepositoryInterface $orderRepository, QuoteIdMaskFactory $quoteIdMaskFactory, - PaymentsDetails $paymentsDetails + AdyenPaymentsDetails $adyenPaymentsDetails ) { $this->orderRepository = $orderRepository; $this->quoteIdMaskFactory = $quoteIdMaskFactory; - $this->paymentsDetails = $paymentsDetails; + $this->adyenPaymentsDetails = $adyenPaymentsDetails; } /** @@ -61,6 +58,6 @@ public function initiate(string $payload, string $orderId, string $cartId): stri ); } - return $this->paymentsDetails->initiatePaymentDetails($order, $payload); + return $this->adyenPaymentsDetails->initiate($payload, $orderId); } } diff --git a/Observer/SetOrderStateAfterPaymentObserver.php b/Observer/SetOrderStateAfterPaymentObserver.php new file mode 100644 index 000000000..cd7509244 --- /dev/null +++ b/Observer/SetOrderStateAfterPaymentObserver.php @@ -0,0 +1,77 @@ + + */ + +namespace Adyen\Payment\Observer; + +use Adyen\Payment\Helper\PaymentResponseHandler; +use Adyen\Payment\Model\Method\Adapter; +use Exception; +use Magento\Framework\Event\Observer; +use Magento\Framework\Event\ObserverInterface; +use Magento\Framework\Exception\LocalizedException; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Payment; +use Magento\Sales\Model\Order\StatusResolver; + +class SetOrderStateAfterPaymentObserver implements ObserverInterface +{ + private StatusResolver $statusResolver; + + public function __construct( + StatusResolver $statusResolver + ) { + $this->statusResolver = $statusResolver; + } + + /** + * @throws LocalizedException + * @throws Exception + */ + public function execute(Observer $observer) + { + /** @var Payment $payment */ + $payment = $observer->getData('payment'); + $methodInstance = $payment->getMethodInstance(); + + if ($methodInstance instanceof Adapter) { + $order = $payment->getOrder(); + $resultCode = $payment->getAdditionalInformation('resultCode'); + $action = $payment->getAdditionalInformation('action'); + + /* + * Set order status and state to pending_payment if an addition action is required. + * This status will be changed when the shopper completes the action or returns from a redirection. + */ + if (in_array($resultCode, PaymentResponseHandler::ACTION_REQUIRED_STATUSES) && + !is_null($action) + ) { + $actionType = $action['type']; + + $status = $this->statusResolver->getOrderStatusByState( + $payment->getOrder(), + Order::STATE_PENDING_PAYMENT + ); + $order->setState(Order::STATE_PENDING_PAYMENT); + $order->setStatus($status); + + $message = sprintf( + __("%s action is required to complete the payment.
Result code: %s"), + ucfirst($actionType), + $resultCode + ); + + $order->addCommentToStatusHistory($message, $status); + $order->save(); + } + } + } +} diff --git a/Test/Unit/Controller/Return/IndexTest.php b/Test/Unit/Controller/Return/IndexTest.php new file mode 100644 index 000000000..bdad75ca4 --- /dev/null +++ b/Test/Unit/Controller/Return/IndexTest.php @@ -0,0 +1,267 @@ + + */ + +namespace Adyen\Payment\Test\Unit\Controller\Return; + +use Adyen\Payment\Controller\Return\Index; +use Adyen\Payment\Helper\Config; +use Adyen\Payment\Helper\PaymentResponseHandler; +use Adyen\Payment\Helper\PaymentsDetails; +use Adyen\Payment\Helper\Quote; +use Adyen\Payment\Logger\AdyenLogger; +use Adyen\Payment\Test\Unit\AbstractAdyenTestCase; +use Exception; +use Magento\Checkout\Model\Session; +use Magento\Framework\App\Action\Context; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\App\Response\RedirectInterface; +use Magento\Framework\App\ResponseInterface; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Message\ManagerInterface as MessageManagerInterface; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Payment; +use Magento\Sales\Model\OrderFactory; +use Magento\Store\Model\StoreManagerInterface; + +class IndexTest extends AbstractAdyenTestCase +{ + private $indexControllerMock; + private $controllerRequestMock; + private $messageManagerMock; + private $redirectMock; + private $contextResponseMock; + private $quoteMock; + private $orderEntityMock; + private $paymentEntityMock; + + private $contextMock; + private $orderFactoryMock; + private $sessionMock; + private $adyenLoggerMock; + private $storeManagerMock; + private $quoteHelperMock; + private $configHelperMock; + private $paymentsDetailsHelperMock; + private $paymentResponseHandlerMock; + + protected function setUp(): void + { + // Constructor argument mocks + $this->contextMock = $this->createMock(Context::class); + $this->orderFactoryMock = $this->createGeneratedMock(OrderFactory::class, ['create']); + $this->sessionMock = $this->createMock(Session::class); + $this->adyenLoggerMock = $this->createMock(AdyenLogger::class); + $this->storeManagerMock = $this->createMock(StoreManagerInterface::class); + $this->quoteHelperMock = $this->createMock(Quote::class); + $this->configHelperMock = $this->createMock(Config::class); + $this->paymentsDetailsHelperMock = $this->createMock(PaymentsDetails::class); + $this->paymentResponseHandlerMock = $this->createMock(PaymentResponseHandler::class); + + // Extra mock objects and methods + $this->messageManagerMock = $this->createMock(MessageManagerInterface::class); + $this->redirectMock = $this->createMock(RedirectInterface::class); + $this->contextResponseMock = $this->createMock(ResponseInterface::class); + $this->quoteMock = $this->createMock(\Magento\Quote\Model\Quote::class); + $this->paymentEntityMock = $this->createMock(Payment::class); + $this->paymentEntityMock->method('getAdditionalInformation')->will( + $this->returnValueMap([ + ['action', ['type' => 'redirect']], + ['brand_code', Index::BRAND_CODE_DOTPAY], + ['resultCode', Index::RESULT_CODE_RECEIVED] + ]) + ); + $this->orderEntityMock = $this->createMock(Order::class); + $this->orderEntityMock->method('getPayment')->willReturn($this->paymentEntityMock); + $this->controllerRequestMock = $this->createMock(RequestInterface::class); + $this->orderFactoryMock->method('create')->willReturn($this->orderEntityMock); + $this->orderEntityMock->method('loadByIncrementId')->willReturnSelf(); + $this->quoteMock->method('setIsActive')->willReturnSelf(); + $this->sessionMock->method('getLastRealOrder')->willReturn($this->orderEntityMock); + $this->sessionMock->method('getQuote')->willReturn($this->quoteMock); + $this->contextMock->method('getRedirect')->willReturn($this->redirectMock); + $this->contextMock->method('getRequest')->willReturn($this->controllerRequestMock); + $this->contextMock->method('getMessageManager')->willReturn($this->messageManagerMock); + $this->contextMock->method('getResponse')->willReturn($this->contextResponseMock); + $this->configHelperMock->method('getAdyenAbstractConfigData')->will( + $this->returnValueMap([ + ['return_path', null, '/checkout/cart'], + ['custom_success_redirect_path', null, null] + ]) + ); + + $this->indexControllerMock = new Index( + $this->contextMock, + $this->orderFactoryMock, + $this->sessionMock, + $this->adyenLoggerMock, + $this->storeManagerMock, + $this->quoteHelperMock, + $this->configHelperMock, + $this->paymentsDetailsHelperMock, + $this->paymentResponseHandlerMock + ); + } + + private static function testDataProvider(): array + { + return [ + [ + 'redirectResponse' => [ + 'merchantReference' => PHP_INT_MAX, + 'redirectResult' => 'ABCDEFG123456789' + ], + 'paymentsDetailsResponse' => [ + 'merchantReference' => PHP_INT_MAX, + 'resultCode' => 'Authorised' + ], + 'responseHandlerResult' => true, + 'returnPath' => 'checkout/onepage/success', + 'orderId' => PHP_INT_MAX, + 'expectedException' => null + ], + [ + 'redirectResponse' => [ + 'merchantReference' => PHP_INT_MAX, + 'redirectResult' => 'ABCDEFG123456789' + ], + 'paymentsDetailsResponse' => [ + 'merchantReference' => PHP_INT_MAX, + 'resultCode' => 'Authorised' + ], + 'responseHandlerResult' => true, + 'returnPath' => 'multishipping/checkout/success', + 'orderId' => PHP_INT_MAX, + 'expectedException' => null, + 'multishipping' => true + ], + [ + 'redirectResponse' => [ + 'redirectResult' => 'ABCDEFG123456789' + ], + 'paymentsDetailsResponse' => [ + 'merchantReference' => PHP_INT_MAX, + 'resultCode' => 'Authorised' + ], + 'responseHandlerResult' => true, + 'returnPath' => 'checkout/onepage/success', + 'orderId' => PHP_INT_MAX, + 'expectedException' => null + ], + [ + 'redirectResponse' => [ + 'merchantReference' => PHP_INT_MIN, + 'redirectResult' => 'ABCDEFG123456789' + ], + 'paymentsDetailsResponse' => [ + 'merchantReference' => PHP_INT_MAX, + 'resultCode' => 'Authorised' + ], + 'responseHandlerResult' => false, + 'returnPath' => null, + 'orderId' => null, + 'expectedException' => LocalizedException::class + ], + [ + 'redirectResponse' => [ + 'merchantReference' => PHP_INT_MAX, + 'redirectResult' => 'ABCDEFG123456789' + ], + 'paymentsDetailsResponse' => [], + 'responseHandlerResult' => false, + 'returnPath' => '/checkout/cart', + 'orderId' => PHP_INT_MAX, + 'expectedException' => null + ], + [ + 'redirectResponse' => [ + 'merchantReference' => PHP_INT_MIN, + 'redirectResult' => 'ABCDEFG123456789' + ], + 'paymentsDetailsResponse' => [ + 'merchantReference' => PHP_INT_MAX, + 'resultCode' => 'Authorised' + ], + 'responseHandlerResult' => false, + 'returnPath' => '/checkout/cart', + 'orderId' => PHP_INT_MIN, + 'expectedException' => null + ], + [ + 'redirectResponse' => [ + 'merchantReference' => PHP_INT_MIN, + 'redirectResult' => 'ABCDEFG123456789' + ], + 'paymentsDetailsResponse' => [ + 'merchantReference' => null, + 'resultCode' => null + ], + 'responseHandlerResult' => false, + 'returnPath' => '/checkout/cart', + 'orderId' => PHP_INT_MIN, + 'expectedException' => null + ], + [ + 'redirectResponse' => null, + 'paymentsDetailsResponse' => [ + 'merchantReference' => null, + 'resultCode' => null + ], + 'responseHandlerResult' => false, + 'returnPath' => '/checkout/cart', + 'orderId' => PHP_INT_MIN, + 'expectedException' => null + ] + ]; + } + + /** + * @dataProvider testDataProvider + */ + public function testExecute( + $redirectResponse, + $paymentsDetailsResponse, + $responseHandlerResult, + $returnPath, + $orderId, + $expectedException, + $multishipping = false + ) { + if ($expectedException) { + $this->expectException($expectedException); + } else { + $this->redirectMock->expects($this->once())->method('redirect')->with( + $this->contextResponseMock, + $returnPath, + $redirectResponse ? ['_query' => ['utm_nooverride' => '1']] : [] + ); + } + + if ($multishipping) { + $this->quoteHelperMock->method('getIsQuoteMultiShippingWithMerchantReference') + ->willReturn(true); + } + + if (empty($paymentsDetailsResponse)) { + $this->paymentsDetailsHelperMock->method('initiatePaymentDetails') + ->willThrowException(new Exception); + } + + $this->controllerRequestMock->method('getParams')->willReturn($redirectResponse); + $this->orderEntityMock->method('getId')->willReturn($orderId); + $this->orderEntityMock->method('getIncrementId')->willReturn($orderId); + $this->paymentResponseHandlerMock->method('handlePaymentsDetailsResponse') + ->willReturn($responseHandlerResult); + $this->paymentsDetailsHelperMock->method('initiatePaymentDetails') + ->willReturn($paymentsDetailsResponse); + + $this->indexControllerMock->execute(); + } +} diff --git a/Test/Unit/Helper/OrderTest.php b/Test/Unit/Helper/OrderTest.php index abce98e16..35527dca3 100644 --- a/Test/Unit/Helper/OrderTest.php +++ b/Test/Unit/Helper/OrderTest.php @@ -30,6 +30,7 @@ use Magento\Framework\App\Helper\Context; use Magento\Framework\DB\TransactionFactory; use Magento\Framework\Notification\NotifierPool; +use Magento\Sales\Api\Data\OrderInterface; use Magento\Sales\Model\Order as MagentoOrder; use Magento\Sales\Model\Order\Email\Sender\OrderSender; use Magento\Sales\Model\Order\Payment\Transaction\Builder; @@ -638,6 +639,50 @@ public function testSetPrePaymentAuthorizedNoStatus() $this->assertEquals('new', $result->getState()); } + public function testSetStatusOrderCreation() + { + $paymentMethodCode = 'adyen_cc'; + $storeId = 1; + $assignedStatusForStateNew = 'pending'; + + $paymentMock = $this->createMock(MagentoOrder\Payment::class); + $paymentMock->method('getMethod')->willReturn($paymentMethodCode); + + $orderMock = $this->createMock(OrderInterface::class); + $orderMock->method('getPayment')->willReturn($paymentMock); + $orderMock->method('getStoreId')->willReturn($storeId); + + $configHelper = $this->createMock(Config::class); + $configHelper->method('getConfigData')->with('order_status', $paymentMethodCode, $storeId) + ->willReturn(\Magento\Sales\Model\Order::STATE_NEW); + + $statusResolverMock = $this->createMock(MagentoOrder\StatusResolver::class); + $statusResolverMock->method('getOrderStatusByState')->willReturn($assignedStatusForStateNew); + + $dataHelper = $this->createOrderHelper( + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + $statusResolverMock + ); + + $result = $dataHelper->setStatusOrderCreation($orderMock); + + $this->assertInstanceOf(OrderInterface::class, $result); + } protected function createOrderHelper( $orderStatusCollectionFactory = null, @@ -655,7 +700,8 @@ protected function createOrderHelper( $notifierPool = null, $paymentMethodsHelper = null, $adyenCreditmemoResourceModel = null, - $adyenCreditmemoHelper = null + $adyenCreditmemoHelper = null, + $statusResolver = null ): Order { $context = $this->createMock(Context::class); @@ -724,6 +770,10 @@ protected function createOrderHelper( $adyenCreditmemoHelper = $this->createMock(AdyenCreditmemoHelper::class); } + if (is_null($statusResolver)) { + $statusResolver = $this->createMock(MagentoOrder\StatusResolver::class); + } + return new Order( $context, $builder, @@ -741,7 +791,8 @@ protected function createOrderHelper( $orderPaymentCollectionFactory, $paymentMethodsHelper, $adyenCreditmemoResourceModel, - $adyenCreditmemoHelper + $adyenCreditmemoHelper, + $statusResolver ); } } diff --git a/Test/Unit/Helper/PaymentDetailsTest.php b/Test/Unit/Helper/PaymentDetailsTest.php index eef777514..7406661b4 100644 --- a/Test/Unit/Helper/PaymentDetailsTest.php +++ b/Test/Unit/Helper/PaymentDetailsTest.php @@ -10,13 +10,14 @@ */ namespace Adyen\Payment\Test\Unit\Helper; +use Adyen\AdyenException; use Adyen\Payment\Helper\PaymentsDetails; use Adyen\Payment\Test\Unit\AbstractAdyenTestCase; +use Magento\Framework\Exception\ValidatorException; use Magento\Sales\Api\Data\OrderInterface; use Magento\Sales\Model\Order\Payment; use Adyen\Payment\Helper\Data; use Adyen\Payment\Logger\AdyenLogger; -use Adyen\Payment\Helper\PaymentResponseHandler; use Adyen\Payment\Helper\Idempotency; use Magento\Checkout\Model\Session; use Adyen\Service\Checkout; @@ -27,72 +28,95 @@ class PaymentDetailsTest extends AbstractAdyenTestCase private $checkoutSessionMock; private $adyenHelperMock; private $adyenLoggerMock; - private $paymentResponseHandlerMock; private $idempotencyHelperMock; private $paymentDetails; + private $orderMock; + private $paymentMock; + private $checkoutServiceMock; + private $adyenClientMock; + protected function setUp(): void { $this->checkoutSessionMock = $this->createMock(Session::class); $this->adyenHelperMock = $this->createMock(Data::class); $this->adyenLoggerMock = $this->createMock(AdyenLogger::class); - $this->paymentResponseHandlerMock = $this->createMock(PaymentResponseHandler::class); $this->idempotencyHelperMock = $this->createMock(Idempotency::class); + $this->orderMock = $this->createMock(OrderInterface::class); + $this->paymentMock = $this->createMock(Payment::class); + $this->checkoutServiceMock = $this->createMock(Checkout::class); + $this->adyenClientMock = $this->createMock(Client::class); + + $this->orderMock->method('getPayment')->willReturn($this->paymentMock); + $this->orderMock->method('getStoreId')->willReturn(1); + $this->paymentMock->method('getOrder')->willReturn($this->orderMock); + + $this->adyenHelperMock->method('initializeAdyenClient')->willReturn($this->adyenClientMock); + $this->adyenHelperMock->method('createAdyenCheckoutService')->willReturn($this->checkoutServiceMock); + $this->paymentDetails = new PaymentsDetails( $this->checkoutSessionMock, $this->adyenHelperMock, $this->adyenLoggerMock, - $this->paymentResponseHandlerMock, $this->idempotencyHelperMock - ); + ); } - public function testRequestHeadersAreAddedToRequest() + public function testInitiatePaymentDetailsSuccessfully() { - $orderMock = $this->createMock(OrderInterface::class); - $paymentMock = $this->createMock(Payment::class); - $checkoutServiceMock = $this->createMock(Checkout::class); - $adyenClientMock = $this->createMock(Client::class); - $storeId = 1; - $payload = json_encode([ - 'details' => 'some_details', + $payload = [ + 'details' => [ + 'detail_key1' => 'some-details', + 'merchantReference' => '00000000001' + ], 'paymentData' => 'some_payment_data', - 'threeDSAuthenticationOnly' => true - ]); + 'threeDSAuthenticationOnly' => true, + ]; + $requestOptions = [ - 'idempotencyKey' => 'some_idempotency_key', - 'headers' => ['headerKey' => 'headerValue'] + 'idempotencyKey' => 'some_idempotency_key', + 'headers' => ['headerKey' => 'headerValue'] ]; - $paymentDetailsResult = ['resultCode' => 'Authorised', 'action' => null, 'additionalData' => null]; - $orderMock->method('getPayment')->willReturn($paymentMock); - $orderMock->method('getStoreId')->willReturn($storeId); - $paymentMock->method('getOrder')->willReturn($orderMock); + $paymentDetailsResult = ['resultCode' => 'Authorised', 'action' => null, 'additionalData' => null]; - $this->adyenHelperMock->method('initializeAdyenClient')->willReturn($adyenClientMock); - $this->adyenHelperMock->method('createAdyenCheckoutService')->willReturn($checkoutServiceMock); $this->adyenHelperMock->method('buildRequestHeaders')->willReturn($requestOptions['headers']); $this->idempotencyHelperMock->method('generateIdempotencyKey')->willReturn($requestOptions['idempotencyKey']); - $checkoutServiceMock->expects($this->once()) + // testing cleanUpPaymentDetailsPayload() method + $apiPayload = $payload; + unset($apiPayload['details']['merchantReference']); + + $this->checkoutServiceMock->expects($this->once()) ->method('paymentsDetails') - ->with( - $this->equalTo([ - 'details' => 'some_details', - 'paymentData' => 'some_payment_data', - 'threeDSAuthenticationOnly' => true - ]), - $this->equalTo($requestOptions) - ) + ->with($apiPayload, $requestOptions) ->willReturn($paymentDetailsResult); - $this->paymentResponseHandlerMock->method('handlePaymentResponse')->willReturn(true); - $this->paymentResponseHandlerMock->method('formatPaymentResponse')->willReturn($paymentDetailsResult); + $result = $this->paymentDetails->initiatePaymentDetails($this->orderMock, $payload); + + $this->assertIsArray($result); + $this->assertEquals($paymentDetailsResult, $result); + } + + public function testInitiatePaymentDetailsFailure() + { + $this->expectException(ValidatorException::class); + + $payload = [ + 'details' => [ + 'detail_key1' => 'some-details', + 'merchantReference' => '00000000001' + ], + 'paymentData' => 'some_payment_data', + 'threeDSAuthenticationOnly' => true, + ]; + + $this->checkoutServiceMock->method('paymentsDetails')->willThrowException(new AdyenException()); - $result = $this->paymentDetails->initiatePaymentDetails($orderMock, $payload); + $this->adyenLoggerMock->expects($this->atLeastOnce())->method('error'); + $this->checkoutSessionMock->expects($this->atLeastOnce())->method('restoreQuote'); - $this->assertJson($result); - $this->assertEquals(json_encode($paymentDetailsResult), $result); + $this->paymentDetails->initiatePaymentDetails($this->orderMock, $payload); } } diff --git a/Test/Unit/Helper/PaymentResponseHandlerTest.php b/Test/Unit/Helper/PaymentResponseHandlerTest.php new file mode 100644 index 000000000..8a5b0fc76 --- /dev/null +++ b/Test/Unit/Helper/PaymentResponseHandlerTest.php @@ -0,0 +1,434 @@ + + */ +namespace Adyen\Payment\Test\Unit\Helper; + +namespace Adyen\Payment\Test\Unit\Helper; + +use Adyen\Payment\Helper\PaymentResponseHandler; +use Adyen\Payment\Logger\AdyenLogger; +use Adyen\Payment\Helper\Vault; +use Adyen\Payment\Helper\Data; +use Adyen\Payment\Helper\Quote; +use Adyen\Payment\Helper\Order as OrderHelper; +use Adyen\Payment\Test\Unit\AbstractAdyenTestCase; +use Exception; +use Magento\Framework\Exception\AlreadyExistsException; +use Magento\Framework\Exception\InputException; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Sales\Model\Order\Payment; +use Magento\Sales\Model\Order\Status\History; +use Magento\Sales\Model\ResourceModel\Order; +use Magento\Sales\Model\OrderRepository; +use Magento\Sales\Model\Order\Status\HistoryFactory; +use Adyen\Payment\Helper\StateData; + +class PaymentResponseHandlerTest extends AbstractAdyenTestCase +{ + private $paymentMock; + private $orderMock; + private $adyenLoggerMock; + private $vaultHelperMock; + private $orderResourceModelMock; + private $dataHelperMock; + private $quoteHelperMock; + private $orderHelperMock; + private $orderRepositoryMock; + private $orderHistoryFactoryMock; + private $stateDataHelperMock; + + private $paymentResponseHandler; + + protected function setUp(): void + { + $this->paymentMock = $this->createMock(Payment::class); + $this->orderMock = $this->createMock(\Magento\Sales\Model\Order::class); + $this->adyenLoggerMock = $this->createMock(AdyenLogger::class); + $this->vaultHelperMock = $this->createMock(Vault::class); + $this->orderResourceModelMock = $this->createMock(Order::class); + $this->dataHelperMock = $this->createMock(Data::class); + $this->quoteHelperMock = $this->createMock(Quote::class); + $this->orderHelperMock = $this->createMock(OrderHelper::class); + $this->orderRepositoryMock = $this->createMock(OrderRepository::class); + $this->orderHistoryFactoryMock = $this->createGeneratedMock(HistoryFactory::class, [ + 'create' + ]); + $this->stateDataHelperMock = $this->createMock(StateData::class); + + $orderHistory = $this->createMock(History::class); + $orderHistory->method('setStatus')->willReturnSelf(); + $orderHistory->method('setComment')->willReturnSelf(); + $orderHistory->method('setEntityName')->willReturnSelf(); + $orderHistory->method('setOrder')->willReturnSelf(); + + $this->orderHistoryFactoryMock->method('create')->willReturn($orderHistory); + $this->orderMock->method('getQuoteId')->willReturn(1); + $this->orderMock->method('getPayment')->willReturn($this->paymentMock); + $this->orderMock->method('getStatus')->willReturn('pending'); + + $this->orderHelperMock->method('setStatusOrderCreation')->willReturn( $this->orderMock); + + $this->paymentResponseHandler = new PaymentResponseHandler( + $this->adyenLoggerMock, + $this->vaultHelperMock, + $this->orderResourceModelMock, + $this->dataHelperMock, + $this->quoteHelperMock, + $this->orderHelperMock, + $this->orderRepositoryMock, + $this->orderHistoryFactoryMock, + $this->stateDataHelperMock + ); + } + + private static function dataSourceForFormatPaymentResponseFinalResultCodes(): array + { + return [ + ['resultCode' => PaymentResponseHandler::AUTHORISED], + ['resultCode' => PaymentResponseHandler::REFUSED], + ['resultCode' => PaymentResponseHandler::ERROR], + ['resultCode' => PaymentResponseHandler::POS_SUCCESS] + ]; + } + + /** + * @param $resultCode + * @return void + * @dataProvider dataSourceForFormatPaymentResponseFinalResultCodes + */ + public function testFormatPaymentResponseForFinalResultCodes($resultCode) + { + $expectedResult = [ + "isFinal" => true, + "resultCode" => $resultCode + ]; + + // Execute method of the tested class + $result = $this->paymentResponseHandler->formatPaymentResponse($resultCode); + + // Assert conditions + $this->assertEquals($expectedResult, $result); + } + + private static function dataSourceForFormatPaymentResponseActionRequredPayments(): array + { + return [ + ['resultCode' => PaymentResponseHandler::REDIRECT_SHOPPER, 'action' => ['type' => 'qrCode']], + ['resultCode' => PaymentResponseHandler::IDENTIFY_SHOPPER, 'action' => ['type' => 'qrCode']], + ['resultCode' => PaymentResponseHandler::CHALLENGE_SHOPPER, 'action' => ['type' => 'qrCode']], + ['resultCode' => PaymentResponseHandler::PENDING, 'action' => ['type' => 'qrCode']], + ]; + } + + /** + * @param $resultCode + * @param $action + * @return void + * @dataProvider dataSourceForFormatPaymentResponseActionRequredPayments + */ + public function testFormatPaymentResponseForActionRequiredPayments($resultCode, $action) + { + $expectedResult = [ + "isFinal" => false, + "resultCode" => $resultCode, + "action" => $action + ]; + + // Execute method of the tested class + $result = $this->paymentResponseHandler->formatPaymentResponse($resultCode, $action); + + // Assert conditions + $this->assertEquals($expectedResult, $result); + } + + /** + * @return void + */ + public function testFormatPaymentResponseForVoucherPayments() + { + $resultCode = PaymentResponseHandler::PRESENT_TO_SHOPPER; + $action = ['type' => 'voucher']; + + $expectedResult = [ + "isFinal" => true, + "resultCode" => $resultCode, + "action" => $action + ]; + + // Execute method of the tested class + $result = $this->paymentResponseHandler->formatPaymentResponse($resultCode, $action); + + // Assert conditions + $this->assertEquals($expectedResult, $result); + } + + /** + * @return void + */ + public function testFormatPaymentResponseForOfflinePayments() + { + $resultCode = PaymentResponseHandler::RECEIVED; + $additionalData = ['action' => ['voucher']]; + + $expectedResult = [ + "isFinal" => true, + "resultCode" => $resultCode, + "additionalData" => $additionalData + ]; + + // Execute method of the tested class + $result = $this->paymentResponseHandler->formatPaymentResponse($resultCode, null, $additionalData); + + // Assert conditions + $this->assertEquals($expectedResult, $result); + } + + /** + * @return void + */ + public function testFormatPaymentResponseForUnknownResults() + { + $resultCode = 'UNRECOGNISED_RESULT_CODE'; + + $expectedResult = [ + "isFinal" => true, + "resultCode" => PaymentResponseHandler::ERROR + ]; + + // Execute method of the tested class + $result = $this->paymentResponseHandler->formatPaymentResponse($resultCode); + + // Assert conditions + $this->assertEquals($expectedResult, $result); + } + + public function testHandlePaymentsDetailsResponseWithNullResultCode() + { + $orderMock = $this->createMock(\Magento\Sales\Model\Order::class); + + $paymentsDetailsResponse = [ + 'randomData' => 'someRandomValue' + ]; + + $result = $this->paymentResponseHandler->handlePaymentsDetailsResponse( + $paymentsDetailsResponse, + $orderMock + ); + + $this->assertFalse($result); + } + + public function testHandlePaymentsDetailsResponseAuthorised() + { + $paymentsDetailsResponse = [ + 'resultCode' => PaymentResponseHandler::AUTHORISED, + 'pspReference' => 'ABC123456789', + 'paymentMethod' => [ + 'brand' => 'ideal' + ], + 'additionalData' => [ + 'someData' => 'someValue' + ], + 'details' => [ + 'someData' => 'someValue' + ], + 'donationToken' => 'XYZ123456789' + ]; + + $this->quoteHelperMock->method('disableQuote')->willThrowException(new Exception()); + $this->adyenLoggerMock->expects($this->atLeastOnce())->method('error'); + + $result = $this->paymentResponseHandler->handlePaymentsDetailsResponse( + $paymentsDetailsResponse, + $this->orderMock + ); + + $this->assertTrue($result); + } + + + private static function handlePaymentsDetailsPendingProvider(): array + { + return [ + ['paymentMethodCode' => 'bankTransfer'], + ['paymentMethodCode' => 'sepadirectdebit'], + ['paymentMethodCode' => 'multibanco'], + ]; + } + + /** + * @return void + * @throws AlreadyExistsException + * @throws InputException + * @throws NoSuchEntityException + * @dataProvider handlePaymentsDetailsPendingProvider + */ + public function testHandlePaymentsDetailsResponsePending($paymentMethodCode) + { + $this->stateDataHelperMock->method('cleanQuoteStateData') + ->willThrowException(new Exception); + $this->adyenLoggerMock->expects($this->atLeastOnce())->method('error'); + + $paymentsDetailsResponse = [ + 'resultCode' => PaymentResponseHandler::PENDING, + 'pspReference' => 'ABC123456789', + 'paymentMethod' => [ + 'brand' => $paymentMethodCode + ] + ]; + + $result = $this->paymentResponseHandler->handlePaymentsDetailsResponse( + $paymentsDetailsResponse, + $this->orderMock + ); + + $this->assertTrue($result); + } + + private static function handlePaymentsDetailsPendingReceived(): array + { + return [ + ['paymentMethodCode' => 'alipay_hk', 'expectedResult' => false], + ['paymentMethodCode' => 'multibanco', 'expectedResult' => true] + ]; + } + + /** + * @return void + * @throws AlreadyExistsException + * @throws InputException + * @throws NoSuchEntityException + * @dataProvider handlePaymentsDetailsPendingReceived + */ + public function testHandlePaymentsDetailsResponseReceived($paymentMethodCode, $expectedResult) + { + $paymentsDetailsResponse = [ + 'resultCode' => PaymentResponseHandler::RECEIVED, + 'pspReference' => 'ABC123456789', + 'paymentMethod' => [ + 'brand' => $paymentMethodCode + ] + ]; + + $result = $this->paymentResponseHandler->handlePaymentsDetailsResponse( + $paymentsDetailsResponse, + $this->orderMock + ); + + $this->assertEquals($expectedResult, $result); + } + + private static function handlePaymentsDetailsActionRequiredProvider(): array + { + return [ + ['resultCode' => PaymentResponseHandler::PRESENT_TO_SHOPPER], + ['resultCode' => PaymentResponseHandler::IDENTIFY_SHOPPER], + ['resultCode' => PaymentResponseHandler::CHALLENGE_SHOPPER], + ['resultCode' => PaymentResponseHandler::REDIRECT_SHOPPER] + ]; + } + + /** + * @return void + * @throws AlreadyExistsException + * @throws InputException + * @throws NoSuchEntityException + * @dataProvider handlePaymentsDetailsActionRequiredProvider + */ + public function testHandlePaymentsDetailsResponseActionRequired($resultCode) + { + $paymentsDetailsResponse = [ + 'resultCode' => $resultCode, + 'pspReference' => 'ABC123456789', + 'paymentMethod' => [ + 'brand' => 'ideal' + ], + 'action' => [ + 'actionData' => 'actionValue' + ] + ]; + + $this->adyenLoggerMock->expects($this->atLeastOnce())->method('addAdyenResult'); + + $result = $this->paymentResponseHandler->handlePaymentsDetailsResponse( + $paymentsDetailsResponse, + $this->orderMock + ); + + $this->assertTrue($result); + } + + private static function handlePaymentsDetailsActionCancelledOrRefusedProvider(): array + { + return [ + ['resultCode' => PaymentResponseHandler::REFUSED], + ['resultCode' => PaymentResponseHandler::CANCELLED] + ]; + } + + /** + * @return void + * @throws AlreadyExistsException + * @throws InputException + * @throws NoSuchEntityException + * @dataProvider handlePaymentsDetailsActionCancelledOrRefusedProvider + */ + public function testHandlePaymentsDetailsResponseCancelOrRefused($resultCode) + { + $paymentsDetailsResponse = [ + 'resultCode' => $resultCode, + 'pspReference' => 'ABC123456789', + 'paymentMethod' => [ + 'brand' => 'ideal' + ], + 'action' => [ + 'actionData' => 'actionValue' + ] + ]; + + $this->adyenLoggerMock->expects($this->atLeastOnce())->method('addAdyenResult'); + + $result = $this->paymentResponseHandler->handlePaymentsDetailsResponse( + $paymentsDetailsResponse, + $this->orderMock + ); + + $this->assertFalse($result); + } + + public function testHandlePaymentsDetailsResponseInvalid() + { + $paymentsDetailsResponse = [ + 'resultCode' => 'UNRECOGNISED_RESULT_CODE' + ]; + + $this->adyenLoggerMock->expects($this->atLeastOnce())->method('error'); + + $result = $this->paymentResponseHandler->handlePaymentsDetailsResponse( + $paymentsDetailsResponse, + $this->orderMock + ); + + $this->assertFalse($result); + } + + public function testHandlePaymentsDetailsEmptyResponse() + { + $paymentsDetailsResponse = []; + $this->adyenLoggerMock->expects($this->atLeastOnce())->method('error'); + + $result = $this->paymentResponseHandler->handlePaymentsDetailsResponse( + $paymentsDetailsResponse, + $this->orderMock + ); + + $this->assertFalse($result); + } +} diff --git a/Test/Unit/Model/Api/AdyenPaymentsDetailsTest.php b/Test/Unit/Model/Api/AdyenPaymentsDetailsTest.php new file mode 100644 index 000000000..65b195b9c --- /dev/null +++ b/Test/Unit/Model/Api/AdyenPaymentsDetailsTest.php @@ -0,0 +1,106 @@ + + */ + +namespace Adyen\Payment\Test\Unit\Model\Api; + +use Adyen\Payment\Helper\PaymentResponseHandler; +use Adyen\Payment\Helper\PaymentsDetails; +use Adyen\Payment\Model\Api\AdyenPaymentsDetails; +use Adyen\Payment\Test\Unit\AbstractAdyenTestCase; +use Magento\Framework\Exception\ValidatorException; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\OrderRepositoryInterface; + +class AdyenPaymentsDetailsTest extends AbstractAdyenTestCase +{ + private $adyenPaymentsDetails; + private $orderRepositoryMock; + private $paymentsDetailsHelperMock; + private $paymentResponseHandlerHelperMock; + + protected function setUp(): void + { + $this->orderRepositoryMock = $this->createMock(OrderRepositoryInterface::class); + $this->paymentsDetailsHelperMock = $this->createMock(PaymentsDetails::class); + $this->paymentResponseHandlerHelperMock = $this->createPartialMock( + PaymentResponseHandler::class, + ['handlePaymentsDetailsResponse'] + ); + + $objectManager = new ObjectManager($this); + $this->adyenPaymentsDetails = $objectManager->getObject(AdyenPaymentsDetails::class, [ + 'orderRepository' => $this->orderRepositoryMock, + 'paymentsDetails' => $this->paymentsDetailsHelperMock, + 'paymentResponseHandler' => $this->paymentResponseHandlerHelperMock + ]); + } + + public function testSuccessfulCall() + { + $payload = '{"someData":"someValue"}'; + $result = ['resultCode' => 'Authorised']; + $orderId = 1; + + $this->orderRepositoryMock + ->method('get') + ->willReturn($this->createMock(OrderInterface::class)); + + $this->paymentsDetailsHelperMock + ->method('initiatePaymentDetails') + ->willReturn($result); + + $this->paymentResponseHandlerHelperMock + ->method('handlePaymentsDetailsResponse') + ->willReturn(true); + + $response = $this->adyenPaymentsDetails->initiate($payload, $orderId); + + $this->assertJson($response); + $this->assertArrayHasKey('isFinal', json_decode($response, true)); + $this->assertArrayHasKey('resultCode', json_decode($response, true)); + } + + public function testFailingJson() + { + $this->expectException(ValidatorException::class); + + $payload = '{"someData":"someValue"'; + $orderId = 1; + + $this->adyenPaymentsDetails->initiate($payload, $orderId); + } + + public function testInvalidDetailsCall() + { + $this->expectException(ValidatorException::class); + + $payload = '{"someData":"someValue"}'; + $result = ['resultCode' => 'Authorised']; + $orderId = 1; + + $this->orderRepositoryMock + ->method('get') + ->willReturn($this->createMock(OrderInterface::class)); + + $this->paymentsDetailsHelperMock + ->method('initiatePaymentDetails') + ->willReturn($result); + + $this->paymentResponseHandlerHelperMock + ->method('handlePaymentsDetailsResponse') + ->willReturn(false); + + $this->adyenPaymentsDetails->initiate($payload, $orderId); + } + + +} diff --git a/Test/Unit/Model/Api/GuestAdyenPaymentsDetailsTest.php b/Test/Unit/Model/Api/GuestAdyenPaymentsDetailsTest.php new file mode 100644 index 000000000..76d7873d8 --- /dev/null +++ b/Test/Unit/Model/Api/GuestAdyenPaymentsDetailsTest.php @@ -0,0 +1,110 @@ + + */ + +namespace Adyen\Payment\Test\Unit\Model\Api; + +use Adyen\Payment\Model\Api\AdyenPaymentsDetails; +use Adyen\Payment\Model\Api\GuestAdyenPaymentsDetails; +use Adyen\Payment\Test\Unit\AbstractAdyenTestCase; +use Magento\Framework\Exception\NotFoundException; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Quote\Model\QuoteIdMask; +use Magento\Quote\Model\QuoteIdMaskFactory; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\OrderRepositoryInterface; + +class GuestAdyenPaymentsDetailsTest extends AbstractAdyenTestCase +{ + private $guestAdyenPaymentsDetails; + private $orderRepositoryMock; + private $quoteIdMaskFactoryMask; + private $adyenPaymentsDetailsMock; + + protected function setUp(): void + { + $this->orderRepositoryMock = $this->createMock(OrderRepositoryInterface::class); + $this->adyenPaymentsDetailsMock = $this->createMock(AdyenPaymentsDetails::class); + $this->quoteIdMaskFactoryMask = $this->createGeneratedMock(QuoteIdMaskFactory::class, [ + 'create' + ]); + + $objectManager = new ObjectManager($this); + $this->guestAdyenPaymentsDetails = $objectManager->getObject(GuestAdyenPaymentsDetails::class, [ + 'orderRepository' => $this->orderRepositoryMock, + 'adyenPaymentsDetails' => $this->adyenPaymentsDetailsMock, + 'quoteIdMaskFactory' => $this->quoteIdMaskFactoryMask + ]); + } + + public function testSuccessfulCall() + { + $payload = '{"someData":"someValue"}'; + $result = '{"resultCode": "Authorised", "isFinal": "true"}'; + $orderId = 1; + $maskedCartId = 'abcdef123456'; + $cartId = 99; + $orderQuoteId = 99; + + $quoteIdMaskMock = $this->createGeneratedMock(QuoteIdMask::class, [ + 'load', + 'getQuoteId' + ]); + $quoteIdMaskMock->method('load')->willReturn($quoteIdMaskMock); + $quoteIdMaskMock->method('getQuoteId')->willReturn($cartId); + + $this->quoteIdMaskFactoryMask->method('create') + ->willReturn($quoteIdMaskMock); + + $orderMock = $this->createMock(OrderInterface::class); + $orderMock->method('getQuoteId')->willReturn($orderQuoteId); + + $this->orderRepositoryMock->method('get') + ->willReturn($orderMock); + + $this->adyenPaymentsDetailsMock->method('initiate') + ->willReturn($result); + + $response = $this->guestAdyenPaymentsDetails->initiate($payload, $orderId, $maskedCartId); + + $this->assertJson($response); + $this->assertArrayHasKey('isFinal', json_decode($response, true)); + $this->assertArrayHasKey('resultCode', json_decode($response, true)); + } + + public function testWrongCartId() + { + $this->expectException(NotFoundException::class); + + $payload = '{"someData":"someValue"}'; + $orderId = 1; + $maskedCartId = 'abcdef123456'; + $cartId = 99; + $orderQuoteId = 200; + + $quoteIdMaskMock = $this->createGeneratedMock(QuoteIdMask::class, [ + 'load', + 'getQuoteId' + ]); + $quoteIdMaskMock->method('load')->willReturn($quoteIdMaskMock); + $quoteIdMaskMock->method('getQuoteId')->willReturn($cartId); + + $this->quoteIdMaskFactoryMask->method('create') + ->willReturn($quoteIdMaskMock); + + $orderMock = $this->createMock(OrderInterface::class); + $orderMock->method('getQuoteId')->willReturn($orderQuoteId); + + $this->orderRepositoryMock->method('get') + ->willReturn($orderMock); + + $this->guestAdyenPaymentsDetails->initiate($payload, $orderId, $maskedCartId); + } +} diff --git a/Test/Unit/Observer/SetOrderStateAfterPaymentObserverTest.php b/Test/Unit/Observer/SetOrderStateAfterPaymentObserverTest.php new file mode 100644 index 000000000..785f64804 --- /dev/null +++ b/Test/Unit/Observer/SetOrderStateAfterPaymentObserverTest.php @@ -0,0 +1,102 @@ + + */ + +namespace Adyen\Payment\Test\Unit\Observer; + +use Adyen\Payment\Helper\PaymentResponseHandler; +use Adyen\Payment\Model\Method\Adapter; +use Adyen\Payment\Observer\SetOrderStateAfterPaymentObserver; +use Adyen\Payment\Test\Unit\AbstractAdyenTestCase; +use Magento\Framework\Event\Observer; +use Magento\Framework\Exception\LocalizedException; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Payment; + +class SetOrderStateAfterPaymentObserverTest extends AbstractAdyenTestCase +{ + private $setOrderStateAfterPaymentObserver; + private $observerMock; + private $paymentMock; + private $orderMock; + private $statusResolverMock; + + public function setUp(): void + { + $this->observerMock = $this->createMock(Observer::class); + $this->paymentMock = $this->createMock(Payment::class); + $this->orderMock = $this->createMock(Order::class); + $this->statusResolverMock = $this->createMock(Order\StatusResolver::class); + + $paymentMethodInstanceMock = $this->createMock(Adapter::class); + $this->paymentMock->method('getMethodInstance')->willReturn($paymentMethodInstanceMock); + $this->paymentMock->method('getOrder')->willReturn($this->orderMock); + $this->observerMock->method('getData')->with('payment')->willReturn($this->paymentMock); + $this->statusResolverMock->method('getOrderStatusByState') + ->willReturn(Order::STATE_PENDING_PAYMENT); + + $this->setOrderStateAfterPaymentObserver = new SetOrderStateAfterPaymentObserver( + $this->statusResolverMock + ); + } + + private static function resultCodeProvider(): array + { + return [ + [ + 'resultCode' => PaymentResponseHandler::REDIRECT_SHOPPER, + 'action' => ['type' => 'TYPE_PLACEHOLDER'] + ], + [ + 'resultCode' => PaymentResponseHandler::CHALLENGE_SHOPPER, + 'action' => ['type' => 'TYPE_PLACEHOLDER'] + ], + [ + 'resultCode' => PaymentResponseHandler::PENDING, + 'action' => ['type' => 'TYPE_PLACEHOLDER'] + ], + [ + 'resultCode' => PaymentResponseHandler::IDENTIFY_SHOPPER, + 'action' => ['type' => 'TYPE_PLACEHOLDER'] + ], + [ + 'resultCode' => PaymentResponseHandler::AUTHORISED, + 'action' => null, + 'changeStatus' => false + ] + ]; + } + + /** + * @dataProvider resultCodeProvider + * @return void + * @throws LocalizedException + */ + public function testExecute($resultCode, $action, $changeStatus = true) + { + $this->paymentMock->method('getAdditionalInformation')->will( + $this->returnValueMap([ + ['resultCode', $resultCode], + ['action', $action] + ]) + ); + + if ($changeStatus) { + $this->orderMock->expects($this->once())->method('setState'); + $this->orderMock->expects($this->once())->method('save'); + } else { + $this->orderMock->expects($this->never())->method('setState'); + $this->orderMock->expects($this->never())->method('save'); + } + + $this->setOrderStateAfterPaymentObserver->execute($this->observerMock); + + } +} diff --git a/etc/events.xml b/etc/events.xml index 07df15705..2580f623d 100644 --- a/etc/events.xml +++ b/etc/events.xml @@ -278,4 +278,7 @@ - \ No newline at end of file + + + +