Custom Magento 2 Payment Plugin Development

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    823
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    848
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

Magento 2 Custom Payment Plugin Development

Magento 2 is one of most complex platforms for payment module development. Architecture with di.xml, Interceptors, Observers, separate payment provider flow for vault (saved cards) — all require deep platform knowledge. Typical custom gateway from zero to production takes 7–12 business days.

Module Structure

app/code/MyCompany/MyPay/
├── Api/Data/PaymentResponseInterface.php
├── Controller/Payment/
│   ├── Redirect.php
│   └── Callback.php
├── Gateway/
│   ├── Command/
│   │   ├── AuthorizeCommand.php
│   │   └── RefundCommand.php
│   ├── Http/Client/Curl.php
│   ├── Request/AuthorizationRequest.php
│   ├── Response/AuthorizeHandler.php
│   └── Validator/ResponseValidator.php
├── Model/Ui/ConfigProvider.php
├── view/frontend/
│   ├── layout/checkout_index_index.xml
│   └── web/js/view/payment/method-renderer/mypay.js
├── etc/
│   ├── config.xml
│   ├── di.xml
│   └── payment.xml
├── registration.php
└── composer.json

payment.xml

<?xml version="1.0"?>
<payment xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="urn:magento:framework:Payment/etc/payment.xsd">
    <groups>
        <group id="mypay">
            <label>MyPay</label>
        </group>
    </groups>
    <methods>
        <method name="mypay">
            <allow_multiple_address>0</allow_multiple_address>
        </method>
    </methods>
</payment>

di.xml: Gateway Assembly

<virtualType name="MyPayGatewayFacade" type="Magento\Payment\Model\Method\Adapter">
    <arguments>
        <argument name="code" xsi:type="const">MyCompany\MyPay\Model\Ui\ConfigProvider::CODE</argument>
        <argument name="formBlockType" xsi:type="string">Magento\Payment\Block\Form</argument>
        <argument name="infoBlockType" xsi:type="string">Magento\Payment\Block\Info</argument>
        <argument name="valueHandlerPool" xsi:type="object">MyPayValueHandlerPool</argument>
        <argument name="commandPool" xsi:type="object">MyPayCommandPool</argument>
    </arguments>
</virtualType>

<virtualType name="MyPayCommandPool" type="Magento\Payment\Gateway\Command\CommandPool">
    <arguments>
        <argument name="commands" xsi:type="array">
            <item name="authorize" xsi:type="string">MyCompany\MyPay\Gateway\Command\AuthorizeCommand</item>
            <item name="refund"    xsi:type="string">MyCompany\MyPay\Gateway\Command\RefundCommand</item>
            <item name="void"      xsi:type="string">MyCompany\MyPay\Gateway\Command\VoidCommand</item>
        </argument>
    </arguments>
</virtualType>

AuthorizeCommand

namespace MyCompany\MyPay\Gateway\Command;

use Magento\Payment\Gateway\CommandInterface;
use Magento\Payment\Gateway\Command\Result\ArrayResultFactory;

class AuthorizeCommand implements CommandInterface
{
    public function __construct(
        private readonly \Magento\Payment\Gateway\Http\ClientInterface $client,
        private readonly \Magento\Payment\Gateway\Http\TransferFactoryInterface $transferFactory,
        private readonly \Magento\Payment\Gateway\Request\BuilderInterface $requestBuilder,
        private readonly \Magento\Payment\Gateway\Response\HandlerInterface $handler,
        private readonly \Magento\Payment\Gateway\Validator\ValidatorInterface $validator,
    ) {}

    public function execute(array $commandSubject): void
    {
        $transferO = $this->transferFactory->create(
            $this->requestBuilder->build($commandSubject)
        );

        $response = $this->client->placeRequest($transferO);

        $result = $this->validator->validate(array_merge($commandSubject, ['response' => $response]));
        if (!$result->isValid()) {
            throw new \Magento\Payment\Gateway\Command\CommandException(
                __('Payment authorization error: %1', implode('; ', $result->getFailsDescription()))
            );
        }

        $this->handler->handle($commandSubject, $response);
    }
}

Request Builder

class AuthorizationRequest implements BuilderInterface
{
    public function build(array $buildSubject): array
    {
        $payment = SubjectReader::readPayment($buildSubject);
        $order   = $payment->getPayment()->getOrder();

        return [
            'amount'      => (int) round($order->getGrandTotal() * 100),
            'currency'    => strtoupper($order->getOrderCurrencyCode()),
            'order_id'    => $order->getIncrementId(),
            'customer'    => [
                'email' => $order->getCustomerEmail(),
                'name'  => $order->getCustomerFirstname() . ' ' . $order->getCustomerLastname(),
            ],
            'callback_url'=> $this->urlBuilder->getUrl('mypay/payment/callback'),
            'success_url' => $this->urlBuilder->getUrl('checkout/onepage/success'),
        ];
    }
}

Response Handler: saving transaction ID

class AuthorizeHandler implements HandlerInterface
{
    public function handle(array $handlingSubject, array $response): void
    {
        $payment = SubjectReader::readPayment($handlingSubject)->getPayment();

        $payment->setTransactionId($response['payment_id']);
        $payment->setAdditionalInformation('payment_url', $response['payment_url']);
        $payment->setIsTransactionClosed(false);
        $payment->setShouldCloseParentTransaction(false);
    }
}

Callback Controller

namespace MyCompany\MyPay\Controller\Payment;

class Callback extends \Magento\Framework\App\Action\Action implements \Magento\Framework\App\CsrfAwareActionInterface
{
    public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException
    {
        return null; // Webhook doesn't send CSRF token
    }

    public function validateForCsrf(RequestInterface $request): ?bool
    {
        return true;
    }

    public function execute(): void
    {
        $raw  = file_get_contents('php://input');
        $data = json_decode($raw, true);

        if (!$this->signatureValidator->validate($raw, $_SERVER['HTTP_X_SIGNATURE'] ?? '')) {
            http_response_code(403);
            exit;
        }

        $order = $this->orderRepository->get(
            $this->orderFactory->create()->loadByIncrementId($data['order_id'])->getId()
        );

        if ($data['status'] === 'succeeded') {
            $payment = $order->getPayment();
            $payment->setTransactionId($data['payment_id'])->capture(null);
            $order->setState(\Magento\Sales\Model\Order::STATE_PROCESSING)
                  ->setStatus(\Magento\Sales\Model\Order::STATE_PROCESSING);
        }

        $this->orderRepository->save($order);
        $this->getResponse()->setBody('OK');
    }
}

Frontend: Knockout.js Component

Magento 2 checkout uses Knockout.js. Payment method component:

define(['Magento_Checkout/js/view/payment/default', 'mage/url'], function (Component, url) {
    'use strict';
    return Component.extend({
        defaults: { template: 'MyCompany_MyPay/payment/mypay' },
        redirectAfterPlaceOrder: false,

        afterPlaceOrder: function () {
            window.location.replace(url.build('mypay/payment/redirect'));
        },

        getData: function () {
            return {
                method: this.item.method,
                additional_data: {},
            };
        },
    });
});

Vault (Saved Cards)

Vault implementation is separate task. Magento provides VaultPaymentInterface, tokens stored in vault_payment_token. For provider supporting tokenization, implement TokenizerInterface and separate VaultCommand. Adds 3–4 more business days to development.

Testing

For integration tests Magento uses \Magento\TestFramework\TestCase\AbstractController. Important to test full cycle: order creation → redirect → callback → order status. Separately — refund via admin.