PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

Własna metoda płatności – authorize/capture/refund, JS renderer, PCI DSS

by Henryk Tews / wtorek, 09 sierpnia 2022 / Opublikowano w Magento 2

Implementacja własnej metody płatności w Magento 2 to jeden z trudniejszych tematów – łączy PHP, XML, JavaScript i znajomość procesu checkout. Większość tutoriali zatrzymuje się na „hello world” który pojawia się na liście metod. Pokazuję kompletną implementację: od struktury modułu, przez walidację, po integrację z zewnętrznym gateway’em płatniczym.

Architektura płatności w Magento 2

Każda metoda płatności w Magento 2 składa się z kilku warstw:

  • Model PHP – logika biznesowa (autoryzacja, capture, refund)
  • Konfiguracja XML – rejestracja metody i ustawienia domyślne
  • JavaScript Mixin – komponent checkout (knockout.js)
  • Renderer – widok metody na liście w checkout

Struktura modułu

Vendor/PaymentGateway/
  etc/
    config.xml           - wartości domyślne
    adminhtml/
      system.xml         - konfiguracja w panelu admina
    module.xml
    payment.xml          - rejestracja metody
  Model/
    Payment.php          - główny model metody
    Api/
      PaymentGatewayClient.php  - klient HTTP do gateway'a
  Gateway/
    Request/
      AuthorizationRequest.php
      CaptureRequest.php
    Response/
      AuthorizationHandler.php
    Validator/
      ResponseValidator.php
  view/
    frontend/
      layout/
        checkout_index_index.xml
      web/
        js/
          view/
            payment/
              method-renderer/
                gateway-method.js
        template/
          payment/
            gateway-form.html
  registration.php

Model płatności – PHP

<?php

declare(strict_types=1);

namespace Vendor\PaymentGateway\Model;

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

class Payment extends AbstractMethod
{
    // Unikalny kod metody - używany wszędzie w XML
    public const CODE = 'vendor_payment_gateway';

    protected $_code = self::CODE;

    // Możliwości metody
    protected $_isGateway               = true;
    protected $_canCapture              = true;
    protected $_canCapturePartial       = false;
    protected $_canRefund               = true;
    protected $_canRefundInvoicePartial = false;
    protected $_canAuthorize            = true;
    protected $_canVoid                 = true;

    public function __construct(
        \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,
        private \Vendor\PaymentGateway\Model\Api\PaymentGatewayClient $gatewayClient,
        array $data = []
    ) {
        parent::__construct(
            $context, $registry, $extensionFactory, $customAttributeFactory,
            $paymentData, $scopeConfig, $logger, null, null, $data
        );
    }

    // Walidacja przed wyświetleniem metody - np. sprawdź walutę
    public function isAvailable(\Magento\Quote\Api\Data\CartInterface $quote = null): bool
    {
        if ($quote === null) {
            return false;
        }

        // Metoda dostępna tylko dla PLN i EUR
        $currency = $quote->getQuoteCurrencyCode();
        if (!in_array($currency, ['PLN', 'EUR'], true)) {
            return false;
        }

        return parent::isAvailable($quote);
    }

    // Autoryzacja płatności
    public function authorize(InfoInterface $payment, $amount): static
    {
        if (!$this->canAuthorize()) {
            throw new LocalizedException(__('Authorize action is not available.'));
        }

        $order      = $payment->getOrder();
        $customerId = $order->getCustomerEmail();

        try {
            $response = $this->gatewayClient->authorize([
                'amount'      => (int) round($amount * 100), // w groszach
                'currency'    => $order->getOrderCurrencyCode(),
                'customer'    => $customerId,
                'order_id'    => $order->getIncrementId(),
                'description' => "Zamówienie #{$order->getIncrementId()}",
            ]);

            if (!$response->isSuccess()) {
                throw new LocalizedException(
                    __('Płatność odrzucona: %1', $response->getErrorMessage())
                );
            }

            // Zapisz token transakcji do późniejszego capture/refund
            $payment->setTransactionId($response->getTransactionId());
            $payment->setIsTransactionClosed(false);
            $payment->setAdditionalInformation('gateway_token', $response->getToken());

        } catch (\Exception $e) {
            $this->logger->critical($e);
            throw new LocalizedException(__('Błąd podczas autoryzacji płatności.'));
        }

        return $this;
    }

    // Capture - obciążenie karty po autoryzacji
    public function capture(InfoInterface $payment, $amount): static
    {
        if (!$this->canCapture()) {
            throw new LocalizedException(__('Capture action is not available.'));
        }

        $transactionId = $payment->getParentTransactionId();
        $token         = $payment->getAdditionalInformation('gateway_token');

        try {
            $response = $this->gatewayClient->capture([
                'transaction_id' => $transactionId,
                'token'          => $token,
                'amount'         => (int) round($amount * 100),
            ]);

            if (!$response->isSuccess()) {
                throw new LocalizedException(
                    __('Capture odrzucony: %1', $response->getErrorMessage())
                );
            }

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

        } catch (\Exception $e) {
            $this->logger->critical($e);
            throw new LocalizedException(__('Błąd podczas pobierania płatności.'));
        }

        return $this;
    }

    // Zwrot płatności
    public function refund(InfoInterface $payment, $amount): static
    {
        $captureId = $payment->getParentTransactionId();

        try {
            $response = $this->gatewayClient->refund([
                'capture_id' => $captureId,
                'amount'     => (int) round($amount * 100),
                'reason'     => 'customer_request',
            ]);

            if (!$response->isSuccess()) {
                throw new LocalizedException(
                    __('Zwrot odrzucony: %1', $response->getErrorMessage())
                );
            }

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

        } catch (\Exception $e) {
            $this->logger->critical($e);
            throw new LocalizedException(__('Błąd podczas zwrotu płatności.'));
        }

        return $this;
    }
}

Konfiguracja – config.xml i system.xml

<!-- etc/config.xml -->
<?xml version="1.0"?>
<config>
    <default>
        <payment>
            <vendor_payment_gateway>
                <active>0</active>
                <title>Płatność kartą (Vendor Gateway)</title>
                <order_status>pending</order_status>
                <payment_action>authorize</payment_action>
                <allowspecific>0</allowspecific>
                <model>Vendor\PaymentGateway\Model\Payment</model>
                <api_url>https://api.gateway.example.com/v1</api_url>
                <debug>0</debug>
            </vendor_payment_gateway>
        </payment>
    </default>
</config>
<!-- etc/adminhtml/system.xml -->
<?xml version="1.0"?>
<config>
    <system>
        <section id="payment">
            <group id="vendor_payment_gateway" translate="label" type="text" sortOrder="100" showInDefault="1" showInWebsite="1" showInStore="0">
                <label>Vendor Payment Gateway</label>
                <field id="active" translate="label" type="select" sortOrder="10" showInDefault="1" showInWebsite="1">
                    <label>Aktywna</label>
                    <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
                </field>
                <field id="title" translate="label" type="text" sortOrder="20" showInDefault="1" showInWebsite="1">
                    <label>Tytuł</label>
                </field>
                <field id="api_key" translate="label" type="obscure" sortOrder="30" showInDefault="1" showInWebsite="1">
                    <label>Klucz API</label>
                    <backend_model>Magento\Config\Model\Config\Backend\Encrypted</backend_model>
                </field>
                <field id="payment_action" translate="label" type="select" sortOrder="40" showInDefault="1" showInWebsite="1">
                    <label>Akcja płatności</label>
                    <source_model>Magento\Payment\Model\Source\PaymentAction</source_model>
                </field>
                <field id="debug" translate="label" type="select" sortOrder="50" showInDefault="1" showInWebsite="1">
                    <label>Tryb debug</label>
                    <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
                </field>
            </group>
        </section>
    </system>
</config>

Komponent JavaScript – knockout.js

// view/frontend/web/js/view/payment/method-renderer/gateway-method.js
define([
    'Magento_Checkout/js/view/payment/default',
    'mage/translate'
], function (Component, $t) {
    'use strict';

    return Component.extend({
        defaults: {
            template: 'Vendor_PaymentGateway/payment/gateway-form',
            cardNumber: '',
            cardExpiry: '',
            cardCvv: ''
        },

        // Kod metody - musi zgadzać się z Payment::CODE
        getCode: function () {
            return 'vendor_payment_gateway';
        },

        // Dane do przesłania do Magento przy złożeniu zamówienia
        getData: function () {
            return {
                method: this.getCode(),
                additional_data: {
                    // Nigdy nie przesyłaj surowych danych karty do backendu!
                    // Tokenizuj przez SDK gateway'a po stronie JS
                    card_token: this.getCardToken()
                }
            };
        },

        getCardToken: function () {
            // Tu wywołujesz SDK gateway'a (np. Stripe.js, Przelewy24 SDK)
            // które tokenizuje dane karty po stronie klienta
            // Backend nigdy nie widzi surowego numeru karty - PCI DSS compliance
            return window.gatewayToken || '';
        },

        validate: function () {
            if (!this.getCardToken()) {
                this.messageContainer.addErrorMessage({
                    message: $t('Proszę wprowadzić dane karty.')
                });
                return false;
            }
            return true;
        }
    });
});

Rejestracja renderera w layout XML

<!-- view/frontend/layout/checkout_index_index.xml -->
<?xml version="1.0"?>
<page>
    <body>
        <referenceBlock name="checkout.root">
            <arguments>
                <argument name="jsLayout" xsi:type="array">
                    <item name="components" xsi:type="array">
                        <item name="checkout" xsi:type="array">
                            <item name="children" xsi:type="array">
                                <item name="steps" xsi:type="array">
                                    <item name="children" xsi:type="array">
                                        <item name="billing-step" xsi:type="array">
                                            <item name="children" xsi:type="array">
                                                <item name="payment" xsi:type="array">
                                                    <item name="children" xsi:type="array">
                                                        <item name="renders" xsi:type="array">
                                                            <item name="children" xsi:type="array">
                                                                <item name="vendor-payment-gateway" xsi:type="array">
                                                                    <item name="component" xsi:type="string">Vendor_PaymentGateway/js/view/payment/gateway-methods</item>
                                                                    <item name="methods" xsi:type="array">
                                                                        <item name="vendor_payment_gateway" xsi:type="array">
                                                                            <item name="isBillingAddressRequired" xsi:type="boolean">true</item>
                                                                        </item>
                                                                    </item>
                                                                </item>
                                                            </item>
                                                        </item>
                                                    </item>
                                                </item>
                                            </item>
                                        </item>
                                    </item>
                                </item>
                            </item>
                        </item>
                    </item>
                </argument>
            </arguments>
        </referenceBlock>
    </body>
</page>

Najważniejsze zasady przy implementacji płatności

  • Nigdy nie przechowuj surowych danych karty – tokenizuj przez SDK gateway’a po stronie JS (PCI DSS)
  • Szyfruj klucze API – używaj Magento\Config\Model\Config\Backend\Encrypted dla pól wrażliwych
  • Loguj wszystko – szczególnie odpowiedzi z gateway’a, z maskowaniem danych wrażliwych
  • Obsługuj wyjątki przez LocalizedException – inne wyjątki mogą ujawniać szczegóły techniczne klientowi
  • Zawsze testuj scenariusze negatywne – odrzucona karta, timeout, błąd sieci

Podsumowanie

Własna metoda płatności w Magento 2 to jeden z bardziej złożonych komponentów do zaimplementowania – łączy PHP, XML, JavaScript i zewnętrzne API. Kluczem jest dobre zrozumienie przepływu authorize-capture-refund i świadome podejście do bezpieczeństwa (tokenizacja, szyfrowanie kluczy). Gotowe rozwiązania jak Stripe czy Przelewy24 warto traktować jako wzorzec – ich moduły to świetna lektura przed pisaniem własnego.

About Henryk Tews

Co możesz przeczytać następne

Wzorce GoF w Magento 2 – gdzie je znaleźć i jak działają
Extension Attributes – pełna implementacja z batch loadingiem, REST API, testy
GraphQL batch loading – BatchServiceContractResolverInterface, cache z tagami, N+1
  • 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}