PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

Custom Payment Method w Magento 2 – authorize, capture, refund, webhook

by Henryk Tews / środa, 24 czerwca 2026 / Opublikowano w Magento / Adobe Commerce

Własna metoda płatności w Magento 2 to jeden z bardziej złożonych modułów do napisania od zera. Wymaga implementacji interfejsu MethodInterface, konfiguracji w system.xml, szablonów JS dla checkout i obsługi webhooków zwrotnych od bramki. Pokażę kompletną strukturę modułu integrującego fikcyjną bramkę płatności – od autoryzacji przez capture po obsługę refundacji.

Struktura modułu

Vendor/Payment/
  etc/
    config.xml           - domyślne wartości konfiguracji
    module.xml
    adminhtml/
      system.xml         - konfiguracja w Admin > Stores > Config
    frontend/
      di.xml             - rejestracja JS komponentów checkout
  Model/
    Adapter.php          - klient HTTP do API bramki
    Payment.php          - główna klasa metody płatności
    Response/
      Handler.php        - obsługa odpowiedzi od bramki
  Controller/
    Webhook/
      Index.php          - endpoint dla webhooków (IPN)
  view/
    frontend/
      web/js/
        view/
          payment/
            method-renderer.js  - Knockout komponent w checkout
      layout/
        checkout_index_index.xml

Klasa metody płatności

<?php

declare(strict_types=1);

namespace Vendor\Payment\Model;

use Magento\Payment\Model\Method\AbstractMethod;
use Magento\Framework\Exception\LocalizedException;

class Payment extends AbstractMethod
{
    protected $_code                    = 'vendor_payment';
    protected $_isGateway               = true;
    protected $_canCapture              = true;
    protected $_canCapturePartial       = false;
    protected $_canRefund               = true;
    protected $_canRefundInvoicePartial = true;
    protected $_canVoid                 = true;
    protected $_canAuthorize            = true;

    public function __construct(
        private Adapter $adapter,
        \Magento\Framework\Model\Context $context,
        \Magento\Framework\Registry $registry,
        \Magento\Framework\Api\ExtensionAttributesFactory $extensionFactory,
        \Magento\Framework\Api\AttributeValueFactory $customAttributeFactory,
        \Magento\Payment\Helper\Data $paymentData,
        \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
        \Magento\Payment\Model\Method\Logger $logger,
        array $data = []
    ) {
        parent::__construct(
            $context, $registry, $extensionFactory, $customAttributeFactory,
            $paymentData, $scopeConfig, $logger, null, null, $data
        );
    }

    // Autoryzacja - zarezerwuj środki bez pobierania
    public function authorize(
        \Magento\Payment\Model\InfoInterface $payment,
        $amount
    ): static {
        $order = $payment->getOrder();

        $response = $this->adapter->authorize([
            'amount'       => $amount,
            'currency'     => $order->getOrderCurrencyCode(),
            'order_id'     => $order->getIncrementId(),
            'customer'     => [
                'email'    => $order->getCustomerEmail(),
                'name'     => $order->getCustomerFirstname() . ' ' . $order->getCustomerLastname(),
            ],
            'return_url'   => $this->getConfigData('return_url'),
            'webhook_url'  => $this->getConfigData('webhook_url'),
        ]);

        if (!$response->isSuccess()) {
            throw new LocalizedException(__('Payment authorization failed: %1', $response->getMessage()));
        }

        // Zapisz transaction ID do dalszych operacji
        $payment->setTransactionId($response->getTransactionId());
        $payment->setAdditionalInformation('authorization_id', $response->getAuthorizationId());
        $payment->setIsTransactionClosed(false);

        return $this;
    }

    // Capture - pobierz wcześniej autoryzowane środki
    public function capture(
        \Magento\Payment\Model\InfoInterface $payment,
        $amount
    ): static {
        $authId = $payment->getAdditionalInformation('authorization_id');

        if (empty($authId)) {
            // Brak autoryzacji - wykonaj bezpośredni charge
            return $this->directCharge($payment, $amount);
        }

        $response = $this->adapter->capture([
            'authorization_id' => $authId,
            'amount'           => $amount,
        ]);

        if (!$response->isSuccess()) {
            throw new LocalizedException(__('Payment capture failed: %1', $response->getMessage()));
        }

        $payment->setTransactionId($response->getTransactionId());
        $payment->setAdditionalInformation('capture_id', $response->getCaptureId());
        $payment->setIsTransactionClosed(true);

        return $this;
    }

    // Refund - zwróć środki
    public function refund(
        \Magento\Payment\Model\InfoInterface $payment,
        $amount
    ): static {
        $captureId = $payment->getAdditionalInformation('capture_id')
            ?? $payment->getParentTransactionId();

        if (empty($captureId)) {
            throw new LocalizedException(__('Cannot refund: no capture transaction found'));
        }

        $response = $this->adapter->refund([
            'capture_id' => $captureId,
            'amount'     => $amount,
            'reason'     => 'Customer refund request',
        ]);

        if (!$response->isSuccess()) {
            throw new LocalizedException(__('Refund failed: %1', $response->getMessage()));
        }

        $payment->setTransactionId($response->getRefundTransactionId());
        $payment->setIsTransactionClosed(true);

        return $this;
    }

    private function directCharge(
        \Magento\Payment\Model\InfoInterface $payment,
        float $amount
    ): static {
        $order    = $payment->getOrder();
        $response = $this->adapter->charge([
            'amount'   => $amount,
            'currency' => $order->getOrderCurrencyCode(),
            'order_id' => $order->getIncrementId(),
            'token'    => $payment->getAdditionalInformation('payment_token'),
        ]);

        if (!$response->isSuccess()) {
            throw new LocalizedException(__('Payment failed: %1', $response->getMessage()));
        }

        $payment->setTransactionId($response->getTransactionId());
        $payment->setAdditionalInformation('capture_id', $response->getCaptureId());

        return $this;
    }
}

Adapter HTTP do bramki

<?php

declare(strict_types=1);

namespace Vendor\Payment\Model;

use Magento\Framework\HTTP\Client\Curl;

class Adapter
{
    private string $apiUrl;
    private string $apiKey;

    public function __construct(
        private Curl $curl,
        private \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
        private \Psr\Log\LoggerInterface $logger
    ) {
        $this->apiUrl = $this->scopeConfig->getValue('payment/vendor_payment/api_url');
        $this->apiKey = $this->scopeConfig->getValue('payment/vendor_payment/api_key');
    }

    public function authorize(array $data): Response\ApiResponse
    {
        return $this->request('POST', '/v1/authorizations', $data);
    }

    public function capture(array $data): Response\ApiResponse
    {
        return $this->request('POST', '/v1/captures', $data);
    }

    public function charge(array $data): Response\ApiResponse
    {
        return $this->request('POST', '/v1/charges', $data);
    }

    public function refund(array $data): Response\ApiResponse
    {
        return $this->request('POST', '/v1/refunds', $data);
    }

    private function request(string $method, string $endpoint, array $data): Response\ApiResponse
    {
        $url = $this->apiUrl . $endpoint;

        $this->curl->addHeader('Content-Type', 'application/json');
        $this->curl->addHeader('Authorization', 'Bearer ' . $this->apiKey);
        $this->curl->addHeader('X-Idempotency-Key', $this->generateIdempotencyKey($data));

        $this->logger->debug('Payment API request', ['url' => $url, 'data' => $data]);

        try {
            if ($method === 'POST') {
                $this->curl->post($url, json_encode($data));
            }

            $responseBody = $this->curl->getBody();
            $statusCode   = $this->curl->getStatus();

            $this->logger->debug('Payment API response', ['status' => $statusCode, 'body' => $responseBody]);

            $decoded = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR);
            return new Response\ApiResponse($statusCode, $decoded);

        } catch (\Exception $e) {
            $this->logger->error('Payment API error', ['exception' => $e->getMessage()]);
            throw new \Magento\Framework\Exception\LocalizedException(
                __('Payment gateway connection error')
            );
        }
    }

    private function generateIdempotencyKey(array $data): string
    {
        return hash('sha256', json_encode($data) . microtime());
    }
}

Webhook controller – obsługa IPN

<?php

declare(strict_types=1);

namespace Vendor\Payment\Controller\Webhook;

use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\App\RequestInterface;

class Index implements HttpPostActionInterface, CsrfAwareActionInterface
{
    public function __construct(
        private \Magento\Framework\App\RequestInterface $request,
        private \Magento\Framework\Controller\Result\JsonFactory $jsonFactory,
        private \Magento\Sales\Api\OrderRepositoryInterface $orderRepository,
        private \Magento\Framework\Api\SearchCriteriaBuilder $searchCriteriaBuilder,
        private \Psr\Log\LoggerInterface $logger,
    ) {}

    public function execute(): \Magento\Framework\Controller\Result\Json
    {
        $result  = $this->jsonFactory->create();
        $payload = json_decode($this->request->getContent(), true);

        if (!$this->verifySignature($payload)) {
            return $result->setHttpResponseCode(401)->setData(['error' => 'Invalid signature']);
        }

        try {
            $this->processEvent($payload);
            return $result->setData(['status' => 'ok']);
        } catch (\Exception $e) {
            $this->logger->error('Webhook processing failed', ['exception' => $e->getMessage()]);
            return $result->setHttpResponseCode(500)->setData(['error' => $e->getMessage()]);
        }
    }

    private function processEvent(array $payload): void
    {
        $eventType = $payload['event_type'] ?? '';
        $orderId   = $payload['metadata']['order_id'] ?? '';

        $sc     = $this->searchCriteriaBuilder->addFilter('increment_id', $orderId)->create();
        $orders = $this->orderRepository->getList($sc)->getItems();
        $order  = reset($orders);

        if (!$order) {
            throw new \RuntimeException("Order not found: {$orderId}");
        }

        match($eventType) {
            'payment.authorized' => $this->handleAuthorized($order, $payload),
            'payment.captured'   => $this->handleCaptured($order, $payload),
            'payment.failed'     => $this->handleFailed($order, $payload),
            'payment.refunded'   => $this->handleRefunded($order, $payload),
            default              => $this->logger->info("Unknown event: {$eventType}"),
        };
    }

    private function verifySignature(array $payload): bool
    {
        $signature = $this->request->getHeader('X-Webhook-Signature') ?? '';
        $secret    = $this->getWebhookSecret();
        $computed  = hash_hmac('sha256', json_encode($payload), $secret);
        return hash_equals($computed, $signature);
    }

    // Wymagane przez CsrfAwareActionInterface - wyłącz CSRF dla webhooków
    public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException { return null; }
    public function validateForCsrf(RequestInterface $request): ?bool { return true; }

    private function getWebhookSecret(): string
    {
        return 'webhook_secret'; // z konfiguracji
    }
}

config.xml – domyślna konfiguracja

<?xml version="1.0"?>
<config>
    <default>
        <payment>
            <vendor_payment>
                <active>0</active>
                <model>Vendor\Payment\Model\Payment</model>
                <title>Pay with VendorPay</title>
                <payment_action>authorize_capture</payment_action>
                <api_url>https://api.vendorpay.com</api_url>
                <order_status>pending</order_status>
                <allowspecific>0</allowspecific>
                <sort_order>10</sort_order>
                <can_use_checkout>1</can_use_checkout>
            </vendor_payment>
        </payment>
    </default>
</config>

Podsumowanie

Custom Payment Method w Magento 2 składa się z: klasy implementującej AbstractMethod (authorize, capture, refund), Adaptera HTTP do API bramki, Webhook controllera do obsługi powiadomień zwrotnych i konfiguracji XML. Kluczowe: transactionId musi być zapisany w postmeta płatności, CSRF musi być wyłączony dla webhooka, podpis webhooka zawsze weryfikuj przez hash_equals (nie ==). Pełna implementacja wymaga też JS komponentu w checkout – to temat na osobny wpis.

About Henryk Tews

Co możesz przeczytać następne

PageBuilder – własny typ zawartości, XML config
Własny carrier wysyłkowy – collectRates, śledzenie paczek, generowanie etykiet
Magento 2 konto admina zablokowane przez atak brute force – odblokowanie i ochrona
  • Publikacje
  • O autorze
  • Kontakt

© 2026 Created by

GÓRA
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Zawsze aktywne
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Zarządzaj opcjami
  • Zarządzaj serwisami
  • Zarządzaj {vendor_count} dostawcami
  • Przeczytaj więcej o tych celach
Zobacz preferencje
  • {title}
  • {title}
  • {title}