Разработка кастомного плагина оплаты Magento 2
Magento 2 — одна из самых сложных платформ для разработки платёжных модулей. Архитектура di.xml, Interceptors, Observers, отдельный payment provider flow для vault (сохранённые карты) — всё это требует глубокого знания платформы. Типичный кастомный gateway от нуля до production занимает 7–12 рабочих дней.
Структура модуля
app/code/MyCompany/MyPay/
├── Api/
│ └── Data/
│ └── PaymentResponseInterface.php
├── Controller/Payment/
│ ├── Redirect.php
│ └── Callback.php
├── Gateway/
│ ├── Command/
│ │ ├── AuthorizeCommand.php
│ │ └── RefundCommand.php
│ ├── Http/
│ │ ├── Client/Curl.php
│ │ └── TransferFactory.php
│ ├── Request/
│ │ ├── AuthorizationRequest.php
│ │ └── RefundRequest.php
│ ├── Response/
│ │ ├── AuthorizeHandler.php
│ │ └── ValidateHandler.php
│ └── Validator/
│ └── ResponseValidator.php
├── Model/
│ └── Ui/
│ └── ConfigProvider.php
├── view/frontend/
│ ├── layout/checkout_index_index.xml
│ ├── requirejs-config.js
│ └── web/js/view/payment/
│ ├── method-renderer/mypay.js
│ └── mypay-payments.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
<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(
__('Ошибка авторизации платежа: %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: сохранение 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
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 не отправляет CSRF-токен
}
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 компонент
Magento 2 checkout использует Knockout.js. Компонент метода оплаты:
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 (сохранённые карты)
Реализация vault — отдельная задача. Magento предоставляет VaultPaymentInterface, токены хранятся в vault_payment_token. Для провайдера, поддерживающего токенизацию, реализуется TokenizerInterface и отдельный VaultCommand. Это добавляет ещё 3–4 рабочих дня к разработке.
Тестирование
Для интеграционных тестов Magento используется \Magento\TestFramework\TestCase\AbstractController. Важно тестировать весь цикл: создание заказа → редирект → callback → статус заказа. Отдельно — рефанд через admin.







