Skip to content

Commit

Permalink
[PAYSHIP-3046] Order capture retry (#1275)
Browse files Browse the repository at this point in the history
* Added redirect to payment page after all capture retries fail

* added order creation

* Added exception code for errors

* Added retry in maasland http client

* Removed wrong imports

* Removed retries from MaaslandHttpClient and added custom ttl for PayPal orders by status

* CS fixes

* Removed redundant test

* Updated exception code handling for ajax requests

* Added translation for changed loader

* Added order status check before fetch

* Added redirects from payment controller

* Fixed payment controller access and redirection

* Changed the wrong Query to correct one

* Fixed incorrect order status issues

* Removed capture status check in payment controller

* Fixed review notes

* CS fixes

* Fixed redirect to guest customer

* Fix for edge case when PayPal order update fails
  • Loading branch information
L3RAZ authored Nov 14, 2024
1 parent 2a94cd3 commit ddc5bae
Show file tree
Hide file tree
Showing 15 changed files with 240 additions and 68 deletions.
2 changes: 1 addition & 1 deletion config.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<module>
<name>ps_checkout</name>
<displayName><![CDATA[PrestaShop Checkout]]></displayName>
<version><![CDATA[8.4.2.1]]></version>
<version><![CDATA[8.4.2.2]]></version>
<description><![CDATA[Provide the most commonly used payment methods to your customers in this all-in-one module, and manage all your sales in a centralized interface.]]></description>
<author><![CDATA[PrestaShop]]></author>
<tab><![CDATA[payments_gateways]]></tab>
Expand Down
2 changes: 1 addition & 1 deletion config/cache.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ services:
- '@=service("PrestaShop\\ModuleLibCacheDirectoryProvider\\Cache\\CacheDirectoryProvider").getPath()'

ps_checkout.cache.paypal.order:
class: 'Symfony\Component\Cache\Simple\ChainCache'
class: 'PrestaShop\Module\PrestashopCheckout\PayPal\Order\Cache\PayPalOrderCache'
public: true
arguments:
- [
Expand Down
88 changes: 61 additions & 27 deletions controllers/front/payment.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@
use PrestaShop\Module\PrestashopCheckout\PayPal\Order\Command\CapturePayPalOrderCommand;
use PrestaShop\Module\PrestashopCheckout\PayPal\Order\Entity\PayPalOrder;
use PrestaShop\Module\PrestashopCheckout\PayPal\Order\Exception\PayPalOrderException;
use PrestaShop\Module\PrestashopCheckout\PayPal\Order\Query\GetPayPalOrderForCheckoutCompletedQuery;
use PrestaShop\Module\PrestashopCheckout\PayPal\Order\Query\GetPayPalOrderForCheckoutCompletedQueryResult;
use PrestaShop\Module\PrestashopCheckout\PayPal\Order\ValueObject\PayPalOrderId;
use PrestaShop\Module\PrestashopCheckout\PayPal\PayPalOrderProvider;
use PrestaShop\Module\PrestashopCheckout\Repository\PaymentTokenRepository;
use PrestaShop\Module\PrestashopCheckout\Repository\PayPalOrderRepository;

Expand All @@ -48,9 +49,14 @@ class Ps_CheckoutPaymentModuleFrontController extends AbstractFrontController
*/
private $paypalOrderId;

/**
* @var CommandBusInterface
*/
private $commandBus;

public function checkAccess()
{
return $this->context->customer && $this->context->customer->isLogged() && $this->context->cart;
return $this->context->customer && $this->context->cart;
}

public function initContent()
Expand Down Expand Up @@ -79,33 +85,41 @@ public function postProcess()
try {
$this->paypalOrderId = new PayPalOrderId($orderId);

$this->commandBus = $this->module->getService('ps_checkout.bus.command');

/** @var PayPalOrderRepository $payPalOrderRepository */
$payPalOrderRepository = $this->module->getService(PayPalOrderRepository::class);
/** @var PayPalOrderProvider $payPalOrderProvider */
$payPalOrderProvider = $this->module->getService(PayPalOrderProvider::class);
/** @var CommandBusInterface $commandBus */
$commandBus = $this->module->getService('ps_checkout.bus.command');
/** @var Psr\SimpleCache\CacheInterface $payPalOrderCache */
$payPalOrderCache = $this->module->getService('ps_checkout.cache.paypal.order');

$payPalOrder = $payPalOrderRepository->getPayPalOrderById($this->paypalOrderId);

$orders = new PrestaShopCollection(Order::class);
$orders->where('id_cart', '=', $payPalOrder->getIdCart());

if ($orders->count()) {
if ($this->context->customer->isLogged()) {
$this->redirectToOrderHistoryPage();
} else {
$payPalOrderQueryResult = $this->getPayPalOrder($orderId);
$payPalOrderFromCache = $payPalOrderQueryResult->getPayPalOrder();

$this->redirectToOrderConfirmationPage($payPalOrder->getIdCart(), $payPalOrderFromCache['purchase_units'][0]['payments']['captures'][0]['id'], $payPalOrderFromCache['status']);
}
}

if ($payPalOrder->getIdCart() !== $this->context->cart->id) {
throw new Exception('PayPal order does not belong to this customer');
$this->redirectToOrderPage();
}

$payPalOrderFromCache = $payPalOrderProvider->getById($payPalOrder->getId()->getValue());
$payPalOrderQueryResult = $this->getPayPalOrder($orderId);
$payPalOrderFromCache = $payPalOrderQueryResult->getPayPalOrder();

if ($payPalOrderFromCache['status'] === 'COMPLETED') {
$this->createOrder($payPalOrderFromCache, $payPalOrder);
}

if ($payPalOrderFromCache['status'] === 'PAYER_ACTION_REQUIRED') {
// Delete from cache so when user is redirected from 3DS authentication page the order is fetched from PayPal
if ($payPalOrderCache->has($this->paypalOrderId->getValue())) {
$payPalOrderCache->delete($this->paypalOrderId->getValue());
}

$this->redirectTo3DSVerification($payPalOrderFromCache);
}

Expand All @@ -117,15 +131,21 @@ public function postProcess()
$this->redirectTo3DSVerification($payPalOrderFromCache);
break;
case Card3DSecure::PROCEED:
$commandBus->handle(new CapturePayPalOrderCommand($this->paypalOrderId->getValue(), array_keys($payPalOrderFromCache['payment_source'])[0]));
$payPalOrderFromCache = $payPalOrderCache->get($this->paypalOrderId->getValue());
$this->commandBus->handle(new CapturePayPalOrderCommand($orderId, array_keys($payPalOrderFromCache['payment_source'])[0]));
$payPalOrderFromCache = $payPalOrderCache->get($orderId);
$this->createOrder($payPalOrderFromCache, $payPalOrder);
break;
case Card3DSecure::NO_DECISION:
default:
break;
}
}

if ($payPalOrderFromCache['status'] === 'APPROVED') {
$this->commandBus->handle(new CapturePayPalOrderCommand($orderId, array_keys($payPalOrderFromCache['payment_source'])[0]));
$payPalOrderFromCache = $payPalOrderCache->get($orderId);
$this->createOrder($payPalOrderFromCache, $payPalOrder);
}
} catch (Exception $exception) {
$this->context->smarty->assign('error', $exception->getMessage());
}
Expand All @@ -143,19 +163,14 @@ public function postProcess()
*/
private function createOrder($payPalOrderFromCache, $payPalOrder)
{
/** @var CommandBusInterface $commandBus */
$commandBus = $this->module->getService('ps_checkout.bus.command');

$capture = $payPalOrderFromCache['purchase_units'][0]['payments']['captures'][0];
if ($capture['status'] === 'COMPLETED') {
$commandBus->handle(new CreateOrderCommand($payPalOrder->getId()->getValue(), $capture));
if ($payPalOrder->getPaymentTokenId() && $payPalOrder->checkCustomerIntent(PayPalOrder::CUSTOMER_INTENT_FAVORITE)) {
/** @var PaymentTokenRepository $paymentTokenRepository */
$paymentTokenRepository = $this->module->getService(PaymentTokenRepository::class);
$paymentTokenRepository->setTokenFavorite($payPalOrder->getPaymentTokenId());
}
$this->redirectToOrderConfirmationPage($payPalOrder->getIdCart(), $capture['id'], $payPalOrderFromCache['status']);
$capture = isset($payPalOrderFromCache['purchase_units'][0]['payments']['captures'][0]) ? $payPalOrderFromCache['purchase_units'][0]['payments']['captures'][0] : null;
$this->commandBus->handle(new CreateOrderCommand($payPalOrder->getId()->getValue(), $capture));
if ($payPalOrder->getPaymentTokenId() && $payPalOrder->checkCustomerIntent(PayPalOrder::CUSTOMER_INTENT_FAVORITE)) {
/** @var PaymentTokenRepository $paymentTokenRepository */
$paymentTokenRepository = $this->module->getService(PaymentTokenRepository::class);
$paymentTokenRepository->setTokenFavorite($payPalOrder->getPaymentTokenId());
}
$this->redirectToOrderConfirmationPage($payPalOrder->getIdCart(), $capture ? $capture['id'] : null, $payPalOrderFromCache['status']);
}

/**
Expand Down Expand Up @@ -218,4 +233,23 @@ private function redirectToOrderConfirmationPage($cartId, $captureId, $payPalOrd
));
}
}

/**
* @param string $orderId
*
* @return GetPayPalOrderForCheckoutCompletedQueryResult
*
* @throws PayPalOrderException
*/
private function getPayPalOrder($orderId)
{
$payPalOrderQuery = new GetPayPalOrderForCheckoutCompletedQuery($orderId);

return $this->commandBus->handle($payPalOrderQuery);
}

private function redirectToOrderHistoryPage()
{
Tools::redirect($this->context->link->getPageLink('history'));
}
}
3 changes: 3 additions & 0 deletions controllers/front/validate.php
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,9 @@ private function handleException(Exception $exception)
'body' => [
'error' => [
'message' => $exceptionMessageForCustomer,
'code' => (int) $exception->getCode() < 400 && $exception->getPrevious() !== null
? (int) $exception->getPrevious()->getCode()
: (int) $exception->getCode(),
],
],
'exceptionCode' => $exception->getCode(),
Expand Down
5 changes: 3 additions & 2 deletions ps_checkout.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ class Ps_checkout extends PaymentModule

// Needed in order to retrieve the module version easier (in api call headers) than instanciate
// the module each time to get the version
const VERSION = '8.4.2.1';
const VERSION = '8.4.2.2';

const INTEGRATION_DATE = '2024-04-01';

Expand All @@ -137,7 +137,7 @@ public function __construct()

// We cannot use the const VERSION because the const is not computed by addons marketplace
// when the zip is uploaded
$this->version = '8.4.2.1';
$this->version = '8.4.2.2';
$this->author = 'PrestaShop';
$this->currencies = true;
$this->currencies_mode = 'checkbox';
Expand Down Expand Up @@ -1110,6 +1110,7 @@ public function hookActionFrontControllerSetMedia()
'checkout.form.error.label' => $this->l('There was an error during the payment. Please try again or contact the support.'),
'loader-component.label.header' => $this->l('Thanks for your purchase!'),
'loader-component.label.body' => $this->l('Please wait, we are processing your payment'),
'loader-component.label.body.longer' => $this->l('This is taking longer than expected. Please wait...'),
'error.paypal-sdk.contingency.cancel' => $this->l('Card holder authentication canceled, please choose another payment method or try again.'),
'error.paypal-sdk.contingency.error' => $this->l('An error occurred on card holder authentication, please choose another payment method or try again.'),
'error.paypal-sdk.contingency.failure' => $this->l('Card holder authentication failed, please choose another payment method or try again.'),
Expand Down
2 changes: 1 addition & 1 deletion src/Checkout/CheckoutChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public function __construct(LoggerInterface $logger)
public function continueWithAuthorization($cartId, $orderPayPal)
{
if ($orderPayPal['status'] === 'COMPLETED') {
throw new PsCheckoutException(sprintf('PayPal Order %s is already captured', $orderPayPal['id']));
throw new PsCheckoutException(sprintf('PayPal Order %s is already captured', $orderPayPal['id']), PsCheckoutException::PAYPAL_ORDER_ALREADY_CAPTURED);
}

$paymentSource = isset($orderPayPal['payment_source']) ? key($orderPayPal['payment_source']) : '';
Expand Down
52 changes: 35 additions & 17 deletions src/Checkout/EventSubscriber/CheckoutEventSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,36 +118,46 @@ public function updatePaymentMethodSelected(CheckoutCompletedEvent $event)
* @throws PrestaShopDatabaseException
* @throws PrestaShopException
* @throws PsCheckoutException
* @throws HttpTimeoutException
*/
public function proceedToPayment(CheckoutCompletedEvent $event)
{
$payPalOrderId = $event->getPayPalOrderId()->getValue();

/** @var GetPayPalOrderForCheckoutCompletedQueryResult $getPayPalOrderForCheckoutCompletedQueryResult */
$getPayPalOrderForCheckoutCompletedQueryResult = $this->commandBus->handle(new GetPayPalOrderForCheckoutCompletedQuery(
$payPalOrderId
));

$payPalOrder = $getPayPalOrderForCheckoutCompletedQueryResult->getPayPalOrder();

try {
/** @var GetPayPalOrderForCheckoutCompletedQueryResult $getPayPalOrderForCheckoutCompletedQueryResult */
$getPayPalOrderForCheckoutCompletedQueryResult = $this->commandBus->handle(new GetPayPalOrderForCheckoutCompletedQuery(
$event->getPayPalOrderId()->getValue()
));
} catch (HttpTimeoutException $exception) {
$this->commandBus->handle(new CreateOrderCommand($event->getPayPalOrderId()->getValue()));

return;
}
$this->checkoutChecker->continueWithAuthorization($event->getCartId()->getValue(), $payPalOrder);
} catch (PsCheckoutException $exception) {
if ($exception->getCode() === PsCheckoutException::PAYPAL_ORDER_ALREADY_CAPTURED) {
$capture = isset($payPalOrder['purchase_units'][0]['payments']['captures'][0]) ? $payPalOrder['purchase_units'][0]['payments']['captures'][0] : null;
$this->commandBus->handle(new CreateOrderCommand($payPalOrderId, $capture));

$this->checkoutChecker->continueWithAuthorization($event->getCartId()->getValue(), $getPayPalOrderForCheckoutCompletedQueryResult->getPayPalOrder());
return;
} else {
throw $exception;
}
}

try {
$this->commandBus->handle(
new CapturePayPalOrderCommand(
$event->getPayPalOrderId()->getValue(),
$payPalOrderId,
$event->getFundingSource()
)
);
} catch (PayPalException $exception) {
if ($exception->getCode() === PayPalException::ORDER_NOT_APPROVED) {
$this->commandBus->handle(new CreateOrderCommand($event->getPayPalOrderId()->getValue()));
$this->commandBus->handle(new CreateOrderCommand($payPalOrderId));

return;
} elseif ($exception->getCode() === PayPalException::RESOURCE_NOT_FOUND) {
$psCheckoutCart = $this->psCheckoutCartRepository->findOneByPayPalOrderId($event->getPayPalOrderId()->getValue());
$psCheckoutCart = $this->psCheckoutCartRepository->findOneByPayPalOrderId($payPalOrderId);

if (Validate::isLoadedObject($psCheckoutCart)) {
$psCheckoutCart->paypal_status = PsCheckoutCart::STATUS_CANCELED;
Expand All @@ -156,14 +166,22 @@ public function proceedToPayment(CheckoutCompletedEvent $event)

throw $exception;
} elseif ($exception->getCode() === PayPalException::ORDER_ALREADY_CAPTURED) {
if (isset($payPalOrder['purchase_units'][0]['payments']['captures'][0])) {
$capture = $payPalOrder['purchase_units'][0]['payments']['captures'][0];
} else {
$payPalOrderQuery = new GetPayPalOrderForCheckoutCompletedQuery($payPalOrderId);

/** @var GetPayPalOrderForCheckoutCompletedQueryResult $getPayPalOrderForCheckoutCompletedQueryResult */
$getPayPalOrderForCheckoutCompletedQueryResult = $this->commandBus->handle($payPalOrderQuery);
$payPalOrder = $getPayPalOrderForCheckoutCompletedQueryResult->getPayPalOrder();
$capture = isset($payPalOrder['purchase_units'][0]['payments']['captures'][0]) ? $payPalOrder['purchase_units'][0]['payments']['captures'][0] : null;
}
$this->commandBus->handle(new CreateOrderCommand($payPalOrderId, $capture));

return;
} else {
throw $exception;
}
} catch (HttpTimeoutException $exception) {
$this->commandBus->handle(new CreateOrderCommand($event->getPayPalOrderId()->getValue()));

return;
}
}
}
2 changes: 2 additions & 0 deletions src/Exception/PsCheckoutException.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,6 @@ class PsCheckoutException extends \Exception
const CART_ADDRESS_DELIVERY_INVALID = 57;
const CART_DELIVERY_OPTION_INVALID = 58;
const PSCHECKOUT_HTTP_UNAUTHORIZED = 59;

const PAYPAL_ORDER_ALREADY_CAPTURED = 60;
}
52 changes: 52 additions & 0 deletions src/PayPal/Order/Cache/PayPalOrderCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Academic Free License version 3.0
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/AFL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to [email protected] so we can send you a copy immediately.
*
* @author PrestaShop SA and Contributors <[email protected]>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0
*/

namespace PrestaShop\Module\PrestashopCheckout\PayPal\Order\Cache;

use PsCheckoutCart;
use Symfony\Component\Cache\Simple\ChainCache;

class PayPalOrderCache extends ChainCache
{
const CACHE_TTL = [
PsCheckoutCart::STATUS_CREATED => 600,
PsCheckoutCart::STATUS_PAYER_ACTION_REQUIRED => 600,
PsCheckoutCart::STATUS_APPROVED => 600,
PsCheckoutCart::STATUS_VOIDED => 3600,
PsCheckoutCart::STATUS_SAVED => 3600,
PsCheckoutCart::STATUS_CANCELED => 3600,
PsCheckoutCart::STATUS_COMPLETED => 3600,
];

/**
* {@inheritdoc}
*
* @return bool
*/
public function set($key, $value, $ttl = null)
{
if (!$ttl && isset($value['status']) && isset(self::CACHE_TTL[$value['status']])) {
$ttl = self::CACHE_TTL[$value['status']];
}

return parent::set($key, $value, $ttl);
}
}
28 changes: 28 additions & 0 deletions src/PayPal/Order/Cache/index.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Academic Free License version 3.0
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/AFL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to [email protected] so we can send you a copy immediately.
*
* @author PrestaShop SA and Contributors <[email protected]>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0
*/
header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT');

header('Cache-Control: no-store, no-cache, must-revalidate');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');

header('Location: ../');
exit;
Loading

0 comments on commit ddc5bae

Please sign in to comment.