Pending Payment Order Status Issue and My Approach to fixing it in Magento 2 Extension

It’s been a while from my last post with my thoughts around Magento development. It is time to get back to my regular blog posting routine and share ideas here via the blog as well.

I would like to share my experience with the recent issue I faced. A couple of weeks ago I received a first support email where a customer said about one of the payment integrations provided by Pronko Consulting.

The message was the following:

After order has been successfully placed by a customer, the status is set to Pending Payment instead of Processing. Can you help us to solve the issue?

Customer Support Ticket

My first thought was that something is misconfigured on the merchant’s side and our payment extension should work smoothly. Especially after the recent release and tests performed together with the Magento Open Source 2.3.2 and Magento Commerce 2.3.2 versions.

However, after a few days, we’ve received even more emails about that particular issue with the pending payment order status. Something has been happening and this was the bigger issue, which wasn’t particularly related to one of the merchant’s servers, as I initially concluded.

Let me give you some background about the payment extension. This is a Global Payments (Realex Payments) extensions we offer for Magento 2 Open Source and Magento 2 Commerce customers. The extension provides two connection modes. One is based on remote API integration, and the other one is a Hosted Payment Page or iFrame.

The Remote API version of the connection, upon placing an order performs a remote API request to the Global Payments server and should wait for the response back. Once the response is successful, the order is created with the payment. The order status and state is set to Processing. This status allows to further work with the order and use it for other critical 3rd party integrations with Magento 2 website. One of such integrations could be Enterprise Resource Planning (ERP) or Inventory Management.

The Hosted Payment Page (HPP) works slightly different. Once a customer clicks “Proceed to Pay” button on the checkout payments page, a new order is created with the Pending Payment status and state. It allows to have an Order Increment ID and some other parameters which are required for the HPP iFrame to be opened.

The order status is changed from the Pending Payment to Processing when the successful payment has been taken place or when the Global Payments server returned the successful response sent to a browser.

The response then sent to a backend for further processing. Usually, the Payment Gateway Command should be created in order to validate the response and process the values.

The following is the example of such command:

    public function execute(array $commandSubject)
    {
        /** @var Payment $payment */
        $payment = $commandSubject['payment']->getPayment();

        $baseTotalDue = $payment->getOrder()->getBaseTotalDue();
        switch ($this->config->getPaymentAction()) {
            case PaymentAction::CAPTURE:
                $payment->registerCaptureNotification($baseTotalDue, $this->getSkipFraudDetection());
                break;
            case PaymentAction::AUTHORIZE:
                $payment->registerAuthorizationNotification($baseTotalDue);
                break;
        }
    }

As you can see from the code sample above, the registerCaptureNotification() or the registerAuthorizationNotification() method is called in the custom command (Let’s call this command “CompleteCommand”). It depends on the “payment_action” configuration setting defined in the config.xml file of the payment extension.

Usually, it is expected, these two methods are responsible for changing the order status and state to the new value “Processing”. All payment integrations are based on this logic of the Magento\Sales\Model\Order\Payment\State\RegisterCaptureNotificationCommand::execute() method.

    public function execute(OrderPaymentInterface $payment, $amount, OrderInterface $order)
    {
        $state = Order::STATE_PROCESSING;
        //... Other logic.
    }

This logic has been working fine, until the quite recent fix for the Magento\PayPal module back in April, 2018. The fix is correct, however the effect is huge for all extensions which rely on the logic.

With this fix, a payment extension is responsible for setting the correct Order State and Order Status values. Here is the change:

As you can see, the Order State value, once set is going to be assigned to the $state variable and used for further state and status updates. In case this is the second time an order is processed, let’s say, during the Hosted Payment Page response processing from Global Payments provider the $order->getState() will return exactly same state, which has been set previously “pending_payment”.

We get a situation when multiple orders with successful payments never change their state to “Processing”. I believe this “fix” impacts all 3rd party payment extensions.

How would an extension provider get the change in the Magento 2 Open Source behaviour?

I believe, there should be a release notes page with the critical changes. Unfortunately, none of the Release Notes pages for Magento Open Source 2.3.0, Magento Open Source 2.3.1, and Magento Open Source 2.3.2 contain information about “Processing” logic change.

So, let me summarize the changes required for your custom payment integration extension to be implemented in order to support new logic.

The OrderStateHandler Class

First of all, if you follow the Payment Gateway API approach, you should add the new Handle class. The class should be responsible for retrieving the payment action configuration setting and setting it to the Order object.

/**
 * Copyright © Pronko Consulting (https://www.pronkoconsulting.com)
 * See LICENSE for the license details.
 */

namespace Pronko\Realex\Gateway\Response\Redirect;

use Magento\Payment\Gateway\Data\PaymentDataObjectInterface;
use Magento\Payment\Gateway\Response\HandlerInterface;
use Magento\Sales\Model\Order\Payment;
use Pronko\Realex\Gateway\ConfigInterface;

/**
 * Class OrderStateHandler
 */
class OrderStateHandler implements HandlerInterface
{
    /**
     * @var ConfigInterface
     */
    private $config;

    /**
     * OrderStateHandler constructor.
     * @param ConfigInterface $config
     */
    public function __construct(ConfigInterface $config)
    {
        $this->config = $config;
    }

    /**
     * @param array $handlingSubject
     * @param array $response
     */
    public function handle(array $handlingSubject, array $response)
    {
        $status = $this->config->getValue('order_status');
        /** @var PaymentDataObjectInterface $paymentDataObject */
        $paymentDataObject = $handlingSubject['payment'];
        /** @var Payment $payment */
        $payment = $paymentDataObject->getPayment();
        $payment->getOrder()->setState($status);
    }
}

As you can see from the code, we use an ‘order_status’ configuration key to retrieve the status value. From the PaymentDataObjectInterface object we get an Order’s Payment and get assigned to this payment an Order object. Finally, we set the status value to the Order object.

The OrderStateHandlerTest Unit Test

Next step for me is to add a Unit Test. Obviously, you may heard about Test Driven Development, which I propagate among Magento 2 developers. However, if you are new to Magento 2 development and especially to payment integrations it is going to be hard to write test first before you write a code which you can check on a frontent.

/**
 * Copyright © Pronko Consulting (https://www.pronkoconsulting.com)
 * See LICENSE for the license details.
 */

namespace Pronko\Realex\Test\Unit\Response\Redirect;

use Magento\Payment\Gateway\Data\PaymentDataObjectInterface;
use Magento\Sales\Model\Order;
use Magento\Sales\Model\Order\Payment;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Pronko\Realex\Gateway\ConfigInterface;
use Pronko\Realex\Gateway\Response\Redirect\OrderStateHandler;

/**
 * Class OrderStateHandlerTest
 */
class OrderStateHandlerTest extends TestCase
{
    /**
     * @var ConfigInterface|MockObject
     */
    private $config;

    /**
     * @var OrderStateHandler
     */
    private $object;

    protected function setUp()
    {
        $this->config = $this->getMockForAbstractClass(ConfigInterface::class);
        $this->object = new OrderStateHandler($this->config);
    }

    public function testHandle()
    {
        $response = [];
        $status = 'processing';
        $this->config->expects($this->any())
            ->method('getValue')
            ->with('order_status')
            ->willReturn($status);

        $paymentDataObjectMock = $this->getMockForAbstractClass(PaymentDataObjectInterface::class);

        $paymentMock = $this->getMockBuilder(Payment::class)
            ->disableOriginalConstructor()
            ->setMethods(['getOrder'])
            ->getMock();

        $orderMock = $this->getMockBuilder(Order::class)
            ->disableOriginalConstructor()
            ->setMethods(['setState'])
            ->getMock();

        $orderMock->expects($this->any())
            ->method('setState')
            ->with($status);

        $paymentMock->expects($this->any())
            ->method('getOrder')
            ->willReturn($orderMock);

        $paymentDataObjectMock->expects($this->any())
            ->method('getPayment')
            ->willReturn($paymentMock);

        $handlingSubject = [
            'payment' => $paymentDataObjectMock
        ];

        $this->object->handle($handlingSubject, $response);
    }
}

The test seems quite hard to understand from the first look. Let me walk you through the implementation of this test.

First, we have to create new Test/Unit directory inside our module. Inside this directory we have to create Response/Redirect directory. Usually, once you are inside the Test/Unit directory, the further directory structure is based on the existing module’s structure.

The setUp() method created two objects, first one is a Mock Object for the ConfigInterface interface. As you can notice, I use here getMockForAbstractClass() method from the PHPUnit library. By the way, here I use PHPUnit 6.5.x version. So, the getMockForAbstractClass() method allows to create a stub class and implement an interface, in my case it is the ConfiguInterface interface.

    protected function setUp()
    {
        $this->config = $this->getMockForAbstractClass(ConfigInterface::class);
        $this->object = new OrderStateHandler($this->config);
    }

And the OrderStateHandler object creation is done with the “new” operator. I prefer to use new instead of an ObjectManager::create() for Unit Tests. I try to keep away from an ObjectManager when writing Unit Tests. We also should pass the required config argument into the __construct() method of the OrderStateHandler class.

    public function __construct(ConfigInterface $config)
    {
        $this->config = $config;
    }

The testHandle() method covers only one test case of the method logic. Since there are no if/else conditions and different cases where we should verify with the different tests it is fine to have a single test method called testHandle().

With the mocked version of the ConfigInterface, which I’ve made in the setUp() method I can mock the getValue() method and return the desired status back to the execute() method.

        $status = 'processing';
        $this->config->expects($this->any())
            ->method('getValue')
            ->with('order_status')
            ->willReturn($status);

The next step is to prepare the PaymentDataObject object and the getPayment() method which is used in the execute() method.

        $paymentDataObjectMock = $this->getMockForAbstractClass(PaymentDataObjectInterface::class);

        $paymentDataObjectMock->expects($this->any())
            ->method('getPayment')
            ->willReturn($paymentMock);

Similar to the ConfigInterface mock I created in the setUp() method, the getMockForAbstractClass() method creates the desired mock. Also, we have to prepare the $paymentMock object in order to pass it to the getPayment() method of the PaymentDataObjectInterface instance.

        $paymentMock = $this->getMockBuilder(Payment::class)
            ->disableOriginalConstructor()
            ->setMethods(['getOrder'])
            ->getMock();

For the Payment instance here I use the getMockBuilder() method as this method returns a builder which I use further to configure my Payment Mock instance. As you can notice, I use disableOriginalConstructor() method to disable a real call to the __construct() method of the Payment class. The setMethods() method is used to provide a list of methods which I would like to mock, in my case it is the getOrder() method. Finally, I call the getMock() method to return an instance of the MockObject which is based on the Payment class.

In order to mock the getOrder() method, I have to prepare the Order Mock instance. Let’s do this by the following code:

        $orderMock = $this->getMockBuilder(Order::class)
            ->disableOriginalConstructor()
            ->setMethods(['setState'])
            ->getMock();
 
        $orderMock->expects($this->any())
            ->method('setState')
            ->with($status);

Here, I created an Order Mock instance where the setState method is mocked with the ‘processing’ value which I created earlier in the testExecute() method.

With the mocked Order class I can add the $orderMock to the getOrder() method of the $paymentMock instance.

        $paymentMock->expects($this->any())
            ->method('getOrder')
            ->willReturn($orderMock);

The $paymentMock is ready to be used further in the $paymentDataObjectMock.

        $paymentDataObjectMock->expects($this->any())
            ->method('getPayment')
            ->willReturn($paymentMock);

Finally, the $paymentDataObjectMock instance can be added into the $handlingSubject argument of the OrderStateHandler::handle() method.

        $handlingSubject = [
            'payment' => $paymentDataObjectMock
        ];

After all the preparations of the mocked versions of the PaymentDataObjectInterface, ConfigInterface, Payment and Order classes we can call the handle() method and pass the $handlingSubject and $response arguments.

$this->object->handle($handlingSubject, $response);

In order to run the OrderStateHandlerTest test I decided to modify the composer.json file of the extension and add the shortcut for my Unit Tests of the extension. Here is a snapshot of the composer.json file:

  "scripts": {
    "test-unit": "vendor/bin/phpunit Test/Unit"
  }

It allows to run the “composer test-unit” command from the command line.

The test result in the command line shows the message:

There was 1 risky test:

1) Pronko\Realex\Test\Unit\Response\Redirect\OrderStateHandlerTest::testHandle
This test did not perform any assertions

This is due to a fact, that the handle() method does not return a value. However, all mocked methods I created in the testHandle() method verify the desired logic.

If you have a better idea how an assertion can be performed in this particular case, please leave me a comment below the post.

The OrderStateHandlerTest Integration Test

Usually, I test all changes manually, by using checkout flow to place an order and verify the result. Integration tests in Magento 2 are really slow and I am not a huge fan of writing integration tests. However, for this case I decided to create the Integration test to check that the pending payment state and status of an order is changed during the “Complete” command execution of the Global Payments extension.

The new Test/Integration directory should be created in the module. Inside the Integration folder, let’s create the Gateway/Command directory where the CompleteCommandTest.php file is placed.

I decided to cover two test cases of the CompleteCommand. First test covers the registerCaptureNotification() method and the second one for the registerAuthorizationNotification() method call.

    public function execute(array $commandSubject)
    {
        // more code
        switch ($this->config->getPaymentAction()) {
            case PaymentAction::CAPTURE:
                $payment->registerCaptureNotification($baseTotalDue, $this->getSkipFraudDetection());
                break;
            case PaymentAction::AUTHORIZE:
                $payment->registerAuthorizationNotification($baseTotalDue);
                break;
        }
        // more code
    }

Here is the source code of the CompleteCommandTest Integration Test class.

/**
 * Copyright © Pronko Consulting (https://www.pronkoconsulting.com)
 * See LICENSE for the license details.
 */

namespace Pronko\Realex\Test\Integration\Gateway\Command;

use Magento\Framework\Api\SearchCriteria;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Payment\Gateway\Command\CommandException;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\Sales\Api\OrderRepositoryInterface;
use PHPUnit\Framework\TestCase;
use Pronko\Realex\Gateway\Command\CompleteCommand;
use Magento\Framework\ObjectManagerInterface;
use Magento\TestFramework\Helper\Bootstrap;
use Magento\Payment\Gateway\Data\PaymentDataObjectFactoryInterface;

/**
 * Class CompleteCommandTest
 */
class CompleteCommandTest extends TestCase
{
    /**
     * @var CompleteCommand
     */
    private $object;

    /**
     * @var ObjectManagerInterface
     */
    private $objectManager;

    /**
     * @var PaymentDataObjectFactoryInterface
     */
    private $paymentDataObjectFactory;

    /**
     * @var OrderRepositoryInterface
     */
    private $orderRepository;

    protected function setUp()
    {
        $this->objectManager = Bootstrap::getObjectManager();
        $this->object = $this->objectManager->create(CompleteCommand::class);
        $this->paymentDataObjectFactory = $this->objectManager->create(PaymentDataObjectFactoryInterface::class);
        $this->orderRepository = $this->objectManager->create(OrderRepositoryInterface::class);
    }

    /**
     * @magentoAppArea frontend
     * @magentoAppIsolation enabled
     * @magentoConfigFixture current_store payment/realex/payment_action authorize_capture
     * @magentoConfigFixture current_store payment/realex/secret secret
     * @magentoDataFixture ../../../../app/code/Pronko/Realex/Test/Integration/_files/order.php
     * @dataProvider responseProvider
     * @param array $response
     * @throws CommandException
     */
    public function testRegisterCaptureNotification(array $response)
    {
        $order = $this->getOrder('900000001');

        $this->assertInstanceOf(OrderInterface::class, $order);

        $commandSubject = [
            'response' => $response,
            'payment' => $this->paymentDataObjectFactory->create($order->getPayment())
        ];

        $this->assertEquals('pending_payment', $order->getState());
        $this->assertEquals('pending_payment', $order->getStatus());

        $this->object->execute($commandSubject);

        $this->assertEquals('processing', $order->getState());
        $this->assertEquals('processing', $order->getStatus());
    }

    /**
     * @magentoAppArea frontend
     * @magentoAppIsolation enabled
     * @magentoConfigFixture current_store payment/realex/payment_action authorize
     * @magentoConfigFixture current_store payment/realex/secret secret
     * @magentoDataFixture ../../../../app/code/Pronko/Realex/Test/Integration/_files/order.php
     * @dataProvider responseProvider
     * @param array $response
     * @throws CommandException
     */
    public function testRegisterAuthorizationNotification(array $response)
    {
        $order = $this->getOrder('900000001');

        $this->assertInstanceOf(OrderInterface::class, $order);

        $commandSubject = [
            'response' => $response,
            'payment' => $this->paymentDataObjectFactory->create($order->getPayment())
        ];

        $this->assertEquals('pending_payment', $order->getState());
        $this->assertEquals('pending_payment', $order->getStatus());

        $this->object->execute($commandSubject);

        $this->assertEquals('processing', $order->getState());
        $this->assertEquals('processing', $order->getStatus());
    }

    /**
     * @param string $incrementId
     * @return OrderInterface|null
     */
    private function getOrder($incrementId)
    {
        /** @var SearchCriteria $searchCriteria */
        $searchCriteria = $this->objectManager->create(SearchCriteriaBuilder::class)
            ->addFilter(OrderInterface::INCREMENT_ID, $incrementId)
            ->create();

        $orders = $this->orderRepository->getList($searchCriteria)->getItems();
        /** @var OrderInterface|null $order */
        $order = reset($orders);

        return $order;
    }

    /**
     * @return array
     */
    public function responseProvider()
    {
        return [
            [
                [
                    'result' => '00',
                    'authcode' => '12345',
                    'message' => '[ test system ] AUTHORISED',
                    'pasref' => '15637890489574557',
                    'avspostcoderesult' => 'M',
                    'avsaddressresult' => 'M',
                    'cvnresult' => 'M',
                    'account' => '****',
                    'merchant_id' => 'merchant_id',
                    'order_id' => 'test_realex000000116',
                    'timestamp' => '20190722095026',
                    'amount' => '5000',
                    'card_payment_button' => 'Place Order',
                    'merchant_response_url' => 'https://www.pronkoconsulting.com/realex/redirect/response/',
                    'hpp_lang' => 'en',
                    'shipping_code' => '12312|test',
                    'shipping_co' => 'US',
                    'billing_code' => '12312|test',
                    'billing_co' => 'US',
                    'comment1' => 'Magento Community ver.2.3.2',
                    'comment2' => 'Pronko Realex v2.3.1',
                    'pas_uuid' => 'f0a37818-031d-44ae-9ae0-f9482a70e363',
                    'sha1hash' => 'a173c1d0cf02709719c71cad898fe39c63edd303',
                    'batchid' => '-1',
                ]
            ]
        ];
    }
}

First, the setUp() method, where I prepare all the required object for the integration test. I use the OrderRepositoryInterface instance in order to load a particular Order which is created for this test.

    protected function setUp()
    {
        $this->objectManager = Bootstrap::getObjectManager();
        $this->object = $this->objectManager->create(CompleteCommand::class);
        $this->paymentDataObjectFactory = $this->objectManager->create(PaymentDataObjectFactoryInterface::class);
        $this->orderRepository = $this->objectManager->create(OrderRepositoryInterface::class);
    }

There are two similar test methods created in the test class. Both methods are similar, except one parameter. This parameter is set on the PHPDoc level with the @magentoConfigFixture annotation.

 * @magentoConfigFixture current_store payment/realex/payment_action authorize_capture

In order to specify the area for the test method execution, Magento Framework provides the @magentoAppArea frontend annotation. Also, I use the @magentoAppIsolation enabled to specify that both of my test methods should be executed in the database isolation.

* @magentoAppArea frontend
* @magentoAppIsolation enabled

I also use the @magentoDataFixture to generate a Simple Product, Order Item, Shipping and Billing Address and finally Order records in the database.

* @magentoDataFixture ../../../../app/code/Pronko/Realex/Test/Integration/_files/order.php

Please note, that if you create your own Integration test, you have to locate your data fixture PHP file by using “../../../../”.

The order.php file content:

/**
 * Copyright © Pronko Consulting (https://www.pronkoconsulting.com)
 * See LICENSE for the license details.
 */

use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Model\Order;
use Magento\Sales\Model\Order\Address as OrderAddress;
use Magento\Sales\Model\Order\Item as OrderItem;
use Magento\Sales\Model\Order\Payment;
use Magento\Store\Model\StoreManagerInterface;
use Magento\TestFramework\Helper\Bootstrap;

require __DIR__ . '/../../../../../dev/tests/integration/testsuite/Magento/Sales/_files/default_rollback.php';
require __DIR__ . '/../../../../../dev/tests/integration/testsuite/Magento/Catalog/_files/product_simple.php';
/** @var \Magento\Catalog\Model\Product $product */

$addressPath = '/../../../../../dev/tests/integration/testsuite/Magento/Sales/_files/address_data.php';
$addressData = include __DIR__ . $addressPath;

$objectManager = Bootstrap::getObjectManager();

$billingAddress = $objectManager->create(OrderAddress::class, ['data' => $addressData]);
$billingAddress->setAddressType('billing');

$shippingAddress = clone $billingAddress;
$shippingAddress->setId(null)->setAddressType('shipping');

/** @var Payment $payment */
$payment = $objectManager->create(Payment::class);
$payment->setMethod('realex')
    ->setAdditionalInformation('last_trans_id', '100500');

/** @var OrderItem $orderItem */
$orderItem = $objectManager->create(OrderItem::class);
$orderItem->setProductId($product->getId())
    ->setQtyOrdered(2)
    ->setBasePrice($product->getPrice())
    ->setPrice($product->getPrice())
    ->setRowTotal($product->getPrice())
    ->setProductType('simple')
    ->setName($product->getName());

/** @var Order $order */
$order = $objectManager->create(Order::class);
$order->setIncrementId('900000001')
    ->setState(Order::STATE_PENDING_PAYMENT)
    ->setStatus($order->getConfig()->getStateDefaultStatus(Order::STATE_PENDING_PAYMENT))
    ->setSubtotal(100)
    ->setGrandTotal(100)
    ->setBaseTotalDue(100)
    ->setBaseSubtotal(100)
    ->setBaseGrandTotal(100)
    ->setCustomerIsGuest(true)
    ->setCustomerEmail('customer@null.com')
    ->setBillingAddress($billingAddress)
    ->setShippingAddress($shippingAddress)
    ->setStoreId($objectManager->get(StoreManagerInterface::class)->getStore()->getId())
    ->addItem($orderItem)
    ->setEmailSent(true)
    ->setPayment($payment);

/** @var OrderRepositoryInterface $orderRepository */
$orderRepository = $objectManager->create(OrderRepositoryInterface::class);
$orderRepository->save($order);

Finally, I use the @dataProvider annotation, which is default PHPUnit feature to pass the “response” key-value pairs to the test method.

For both tests I assert the before and after execution of the execute() method to ensure that the Order Status and State values have been changed.

        $this->assertEquals('pending_payment', $order->getState());
        $this->assertEquals('pending_payment', $order->getStatus());

        $this->object->execute($commandSubject);

        $this->assertEquals('processing', $order->getState());
        $this->assertEquals('processing', $order->getStatus());

Execution of the Integration Tests should be configured and performed as part of the Magento 2 platform as it required Magento Framework and Database. Check the How to create Integration Tests in Magento 2.3 video for details.

Final Thoughts

As a result, it took me a good few hours to write the new OrderStateHandler class and cover it with the Unit and Integration tests. From my opinion, the Integration test worth the time as it will be used every time a new version of Magento 2 is released or new logic to the Global Payments extension will be added with the future releases.

The only thing I skipped here in the post is the di.xml configuration for the OrderStateHandler class. This is going to be your homework. Leave me a note in the comments how does your di.xml looks like.


I have an online course on how to build payment integration extensions for Magento 2, feel free to check out the page with the course details Payment Integration in Magento 2.

The new Advanced Training for Magento 2 Backend Developers is scheduled in August 29-30, 2019 in London, UK.


Posted

in

,

by

Comments

Leave a Reply