Add payment plugin

Overview

Payments are an essential part of the checkout process. That's the reason why Shopware 6 offers an easy platform on which you can build payment plugins.

Prerequisites

The examples mentioned in this guide are built upon our Plugin base guide.
If you want to understand the payment process in detail, head to our Payment Concept.
Here's a video on payment extensions and payment handlers from our free online training "Backend Development".

Creating a custom payment handler

In order to create your own payment method with your plugin, you have to add a custom payment handler.
You can create your own payment handler by implementing one of the following interfaces:
Interface
DI container tag
Usage
SynchronousPaymentHandlerInterface
shopware.payment.method.sync
Payment can be handled locally, e.g. pre-payment
AsynchronousPaymentHandlerInterface
shopware.payment.method.async
A redirect to an external payment provider is required, e.g. PayPal
PreparedPaymentHandlerInterface
shopware.payment.method.prepared
The payment was prepared beforehand and will only be validated and captured by your implementation
RefundPaymentHandlerInterface
shopware.payment.method.refund
The payment allows refund handling
Depending on the interface, those methods are required:
  • pay: This method will be called after an order has been placed. You receive a Shopware\Core\Checkout\Payment\Cart\AsyncPaymentTransactionStruct or a Shopware\Core\Checkout\Payment\Cart\SyncPaymentTransactionStruct which contains the transactionId, order details, the amount of the transaction, a return URL, payment method information and language information. Please be aware, Shopware 6 supports multiple transactions, and you have to use the amount provided and not the total order amount. If you're using the AsynchronousPaymentHandlerInterface, the pay method has to return a RedirectResponse to redirect the customer to an external payment provider. Note: The AsyncPaymentTransactionStruct contains a return URL. This represents the URL that the external payment provider needs to know, so they can also redirect your customer back to your shop. If an error occurs while e.g. calling the API of your external payment provider, you should throw an AsyncPaymentProcessException. Shopware 6 will handle this exception and set the transaction to the cancelled state. The same happens if you are using the SynchronousPaymentHandlerInterface: throw a SyncPaymentProcessException in an error case.
  • finalize: The finalize method is only required if you implemented the AsynchronousPaymentHandlerInterface, returned a RedirectResponse in your pay method and the customer has been redirected from the payment provider back to Shopware 6. You must check here if the payment was successful or not and update the order transaction state accordingly. Similar to the pay action you are able to throw exceptions if some error cases occur. Throw the CustomerCanceledAsyncPaymentException if the customer canceled the payment process on the payment provider site. If another general error occurs throw the AsyncPaymentFinalizeException e.g. if your call to the payment provider API fails. Shopware 6 will handle these exceptions and will set the transaction to the cancelled state.
  • validate: This method will be called before an order was placed and should check, if a given prepared payment is valid. The payment handler has to verify the given payload with the payment service, because Shopware cannot ensure that the transaction created by the frontend is valid for the current cart. Throw an ValidatePreparedPaymentException to fail the validation in your implementation.
  • capture: This method will be called after an order was placed, but only if the validation did not fail and stop the payment flow before. At this point, the order was created and the payment handler will be called again to charge the payment. When the charge was successful, the payment handler should update the transaction state to paid. The user will be forwarded to the finish page. Throw an CapturePreparedPaymentException on any errors to fail the capture process and the after order process will be active, so the customer can complete the payment again.
  • refund: This method is called, whenever a successful transaction is claimed to be refunded. The implementation of the refund handler should validate the legitimacy of the refund and call the PSP to refund the given transaction. Throw a RefundException to let the refund fail.
All methods get the \Shopware\Core\System\SalesChannel\SalesChannelContext injected. Please note, that this class contains properties, which are nullable. If you want to use this information, you have to ensure in your code that they are set and not NULL.

Registering the service

Before we're going to have a look at some examples, we need to register our new service to the Dependency Injection container. We'll use a class called ExamplePayment here.
<plugin root>/src/Resources/config/services.xml
1
<?xml version="1.0" ?>
2
3
<container xmlns="http://symfony.com/schema/dic/services"
4
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
5
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
6
7
<services>
8
<service id="Swag\PaymentPlugin\Service\ExamplePayment">
9
<argument type="service" id="Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStateHandler"/>
10
<tag name="shopware.payment.method.sync" />
11
<!-- <tag name="shopware.payment.method.async" />-->
12
<!-- <tag name="shopware.payment.method.prepared" />-->
13
<!-- <tag name="shopware.payment.method.refund" />-->
14
</service>
15
</services>
16
</container>
Copied!
We inject the OrderTransactionStateHandler in this example, as it is helpful for changing an order's transaction state, e.g. to paid. The payment handler has to be marked as such as well, hence the tag shopware.payment.method.sync, shopware.payment.method.async or shopware.payment.method.prepared respectively for a synchronous, an asynchronous or a prepared payment handler.
Now let's start with the actual examples.

Synchronous example

The following will be a synchronous example, so no redirect will happen and the payment can be handled in the shop itself. Therefore, you don't have to return a RedirectResponse in the pay method and no finalize method is necessary either.
Therefore, changing the stateId of the order should already be done in the pay method, since there will be no finalize method. If you have to execute some logic which might fail, e.g. a call to an external API, you should throw a SyncPaymentProcessException. Shopware 6 will handle this exception and set the transaction to the cancelled state.
<plugin root>/src/Service/ExamplePayment.php
1
<?php declare(strict_types=1);
2
3
namespace Swag\BasicExample\Service;
4
5
use Shopware\Core\Checkout\Payment\Cart\PaymentHandler\SynchronousPaymentHandlerInterface;
6
use Shopware\Core\Checkout\Payment\Cart\SyncPaymentTransactionStruct;
7
use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStateHandler;
8
use Shopware\Core\Framework\Validation\DataBag\RequestDataBag;
9
use Shopware\Core\System\SalesChannel\SalesChannelContext;
10
11
class ExamplePayment implements SynchronousPaymentHandlerInterface
12
{
13
private OrderTransactionStateHandler $transactionStateHandler;
14
15
public function __construct(OrderTransactionStateHandler $transactionStateHandler)
16
{
17
$this->transactionStateHandler = $transactionStateHandler;
18
}
19
20
public function pay(SyncPaymentTransactionStruct $transaction, RequestDataBag $dataBag, SalesChannelContext $salesChannelContext): void
21
{
22
$context = $salesChannelContext->getContext();
23
$this->transactionStateHandler->paid($transaction->getOrderTransaction()->getId(), $context);
24
}
25
}
Copied!
All it does now is to set the state of the order transaction to paid.

Asynchronous example

In the asynchronous example, the customer gets redirected to an external payment provider, which then in return has to redirect your customer back to your shop. Therefore, you first need to redirect your customer to the payment provider by returning a RedirectResponse.
Also you need a finalize method to properly handle your customer, when he was returned back to your shop. This is where you check the payment state and set the order transaction state accordingly.
Let's have a look at an example implementation of your custom asynchronous payment handler:
<plugin root>/src/Service/ExamplePayment.php
1
<?php declare(strict_types=1);
2
3
namespace Swag\BasicExample\Service;
4
5
use Shopware\Core\Checkout\Payment\Cart\AsyncPaymentTransactionStruct;
6
use Shopware\Core\Checkout\Payment\Cart\PaymentHandler\AsynchronousPaymentHandlerInterface;
7
use Shopware\Core\Checkout\Payment\Exception\AsyncPaymentProcessException;
8
use Shopware\Core\Checkout\Payment\Exception\CustomerCanceledAsyncPaymentException;
9
use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStateHandler;
10
use Shopware\Core\Framework\Validation\DataBag\RequestDataBag;
11
use Shopware\Core\System\SalesChannel\SalesChannelContext;
12
use Symfony\Component\HttpFoundation\RedirectResponse;
13
use Symfony\Component\HttpFoundation\Request;
14
15
class ExamplePayment implements AsynchronousPaymentHandlerInterface
16
{
17
private OrderTransactionStateHandler $transactionStateHandler;
18
19
public function __construct(OrderTransactionStateHandler $transactionStateHandler) {
20
$this->transactionStateHandler = $transactionStateHandler;
21
}
22
23
/**
24
* @throws AsyncPaymentProcessException
25
*/
26
public function pay(AsyncPaymentTransactionStruct $transaction, RequestDataBag $dataBag, SalesChannelContext $salesChannelContext): RedirectResponse
27
{
28
// Method that sends the return URL to the external gateway and gets a redirect URL back
29
try {
30
$redirectUrl = $this->sendReturnUrlToExternalGateway($transaction->getReturnUrl());
31
} catch (\Exception $e) {
32
throw new AsyncPaymentProcessException(
33
$transaction->getOrderTransaction()->getId(),
34
'An error occurred during the communication with external payment gateway' . PHP_EOL . $e->getMessage()
35
);
36
}
37
38
// Redirect to external gateway
39
return new RedirectResponse($redirectUrl);
40
}
41
42
/**
43
* @throws CustomerCanceledAsyncPaymentException
44
*/
45
public function finalize(AsyncPaymentTransactionStruct $transaction, Request $request, SalesChannelContext $salesChannelContext): void
46
{
47
$transactionId = $transaction->getOrderTransaction()->getId();
48
49
// Example check if the user cancelled. Might differ for each payment provider
50
if ($request->query->getBoolean('cancel')) {
51
throw new CustomerCanceledAsyncPaymentException(
52
$transactionId,
53
'Customer canceled the payment on the PayPal page'
54
);
55
}
56
57
// Example check for the actual status of the payment. Might differ for each payment provider
58
$paymentState = $request->query->getAlpha('status');
59
60
$context = $salesChannelContext->getContext();
61
if ($paymentState === 'completed') {
62
// Payment completed, set transaction status to "paid"
63
$this->transactionStateHandler->paid($transaction->getOrderTransaction()->getId(), $context);
64
} else {
65
// Payment not completed, set transaction status to "open"
66
$this->transactionStateHandler->reopen($transaction->getOrderTransaction()->getId(), $context);
67
}
68
}
69
70
private function sendReturnUrlToExternalGateway(string $getReturnUrl): string
71
{
72
$paymentProviderUrl = '';
73
74
// Do some API Call to your payment provider
75
76
return $paymentProviderUrl;
77
}
78
}
Copied!
Let's start with the pay method. You'll have to start with letting your external payment provider know, where he should redirect your customer in return when the payment was done. This is usually done by making an API call and transmitting the return URL, which you can fetch from the passed AsyncPaymentTransactionStruct by using the method getReturnUrl. Since this is just an example, the method sendReturnUrlToExternalGateway is empty. Fill in your logic in there in order to actually send the return URL to the external payment provider. The last thing you need to do, is to redirect your customer to the external payment provider via a RedirectResponse.
Once your customer is done at the external payment provider, he will be redirected back to your shop. This is where the finalize method will be executed. In here you have to check whether or not the payment process was successful. If e.g. the customer cancelled the payment process, you'll have to throw a CustomerCanceledAsyncPaymentException exception.
Otherwise, you can proceed to check if the payment status was successful. If that's the case, set the order's transaction state to paid. If not, you could e.g. reopen the order's transaction.

Prepared payments example

To improve the payment workflow on headless systems or reduce orders without payment, payment handlers can implement an additional interface to support pre-created payments. The client (e.g. a single page application) can prepare the payment directly with the payment service (not through Shopware) and pass a transaction reference (token) to Shopware to complete the payment.
This comes in two steps: The handler has to validate the payment beforehand, or throw an exception, if the validation fails. After completing the checkout, Shopware calls the handler again, to actually charge the payment.
Let's have a look at a simple example:
<plugin root>/src/ExamplePayment.php
1
<?php declare(strict_types=1);
2
3
namespace Swag\BasicExample\Service;
4
5
use Shopware\Core\Checkout\Cart\Cart;
6
use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStateHandler;
7
use Shopware\Core\Checkout\Payment\Cart\PaymentHandler\PreparedPaymentHandlerInterface;
8
use Shopware\Core\Checkout\Payment\Cart\PreparedPaymentTransactionStruct;
9
use Shopware\Core\Checkout\Payment\Exception\CapturePreparedPaymentException;
10
use Shopware\Core\Checkout\Payment\Exception\ValidatePreparedPaymentException;
11
use Shopware\Core\Framework\Struct\ArrayStruct;
12
use Shopware\Core\Framework\Struct\Struct;
13
use Shopware\Core\Framework\Validation\DataBag\RequestDataBag;
14
use Shopware\Core\System\SalesChannel\SalesChannelContext;
15
16
class ExamplePayment implements PreparedPaymentHandlerInterface
17
{
18
private OrderTransactionStateHandler $stateHandler;
19
20
public function __construct(OrderTransactionStateHandler $stateHandler)
21
{
22
$this->stateHandler = $stateHandler;
23
}
24
25
public function validate(
26
Cart $cart,
27
RequestDataBag $requestDataBag,
28
SalesChannelContext $context
29
): Struct {
30
if (!$requestDataBag->has('my-payment-token')) {
31
// this will fail the validation
32
throw new ValidatePreparedPaymentException('No token supplied');
33
}
34
35
$token = $requestDataBag->get('my-payment-token');
36
$paymentData = $this->getPaymentDataFromProvider($token);
37
38
if (!$paymentData) {
39
// no payment data simulates an error response from our payment provider in this example
40
throw new ValidatePreparedPaymentException('Unkown payment for token ' . $token);
41
}
42
43
// other checks could include comparing the cart value with the actual payload of your PSP
44
45
// return the payment details: these will be given as $preOrderPaymentStruct to the capture method
46
return new ArrayStruct($paymentData);
47
}
48
49
public function capture(
50
PreparedPaymentTransactionStruct $transaction,
51
RequestDataBag $requestDataBag,
52
SalesChannelContext $context,
53
Struct $preOrderPaymentStruct
54
): void {
55
56
// you can find all the order specific information in the PreparedPaymentTransactionStruct
57
$order = $transaction->getOrder();
58
$orderTransaction = $transaction->getOrderTransaction();
59
60
// call you PSP and capture the transaction as usual
61
// do not forget to change the transaction's state here:
62
$this->stateHandler->paid($orderTransaction->getId(), $context->getContext());
63
64
// or in case of an error:
65
$this->stateHandler->fail($orderTransaction->getId(), $context->getContext());
66
throw new CapturePreparedPaymentException($orderTransaction->getId(), 'Capture failed.')
67
}
68
69
private function getPaymentDataFromProvider(string $token): array
70
{
71
// call your payment provider instead and return your real payment details
72
return [];
73
}
74
}
Copied!

Refund example

To allow easy refund handling, Shopware introduced a centralized way of handling refund for transactions.
For this, have your payment handler implement the RefundPaymentHandlerInterface.
Let's have a look at a short example, on how to implement such payment handlers.
<plugin root>/src/ExamplePayment.php
1
<?php declare(strict_types=1);
2
3
namespace Swag\BasicExample\Service;
4
5
use Shopware\Core\Checkout\Order\Aggregate\OrderTransactionCaptureRefund\OrderTransactionCaptureRefundEntity;
6
use Shopware\Core\Checkout\Order\Aggregate\OrderTransactionCaptureRefund\OrderTransactionCaptureRefundStateHandler;
7
use Shopware\Core\Checkout\Order\Aggregate\OrderTransactionCaptureRefundPosition\OrderTransactionCaptureRefundPositionEntity;
8
use Shopware\Core\Checkout\Payment\Cart\PaymentHandler\RefundPaymentHandlerInterface;
9
use Shopware\Core\Checkout\Payment\Exception\RefundException;
10
use Shopware\Core\Framework\Context;
11
12
class ExamplePayment implements RefundPaymentHandlerInterface
13
{
14
private OrderTransactionCaptureRefundStateHandler $stateHandler;
15
16
public function __construct(OrderTransactionCaptureRefundStateHandler $stateHandler)
17
{
18
$this->stateHandler = $stateHandler;
19
}
20
21
public function refund(OrderTransactionCaptureRefundEntity $refund, Context $context): void
22
{
23
if ($refund->getAmount() > 100.00) {
24
// this will stop the refund process and set the refunds state to `failed`
25
throw new RefundException($refund->getId(), 'Refunds over 100 € are not allowed');
26
}
27
28
// a refund can have multiple positions, with different order line items and amounts
29
/** @var OrderTransactionCaptureRefundPositionEntity $position */
30
foreach ($refund->getPositions() as $position) {
31
$amount = $position->getAmount()->getTotalPrice();
32
$reason = $position->getReason();
33
$lineItem = $position->getOrderLineItem();
34
35
// let's say, you allow a position, which was delivered, however broken
36
if ($reason === 'malfunction') {
37
// you can call your PSP here to refund
38
39
try {
40
$this->callPSPForRefund($amount, $reason, $lineItem->getId());
41
} catch (\Exception $e) {
42
// something went wrong at PSP side, throw a refund exception
43
// this will set the refund state to `failed`
44
throw new RefundException($refund->getId(), 'Something went wrong');
45
}
46
}
47
}
48
49
// let Shopware know, that the refund was successful
50
$this->stateHandler->complete($refund->getId(), $context);
51
}
52
53
private function callPSPForRefund(float $amount, string $reason, string $id): void
54
{
55
// call you PSP here and process the response
56
// throw an exception to stop the refund process
57
}
58
}
Copied!
As you can see, you have full control on how to handle the refund request and which positions to refund.

Setting up new payment method

The handler itself is not used yet, since there is no payment method actually using the handler created above. In short: Your handler is not handling any payment method so far. The payment method can be added to the system while installing your plugin.
An example for your plugin could look like this:
<plugin root>/src/SwagBasicExample.php
1
<?php declare(strict_types=1);
2
3
namespace Swag\BasicExample;
4
5
use Shopware\Core\Framework\Context;
6
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
7
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
8
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
9
use Shopware\Core\Framework\Plugin;
10
use Shopware\Core\Framework\Plugin\Context\ActivateContext;
11
use Shopware\Core\Framework\Plugin\Context\DeactivateContext;
12
use Shopware\Core\Framework\Plugin\Context\InstallContext;
13
use Shopware\Core\Framework\Plugin\Context\UninstallContext;
14
use Shopware\Core\Framework\Plugin\Util\PluginIdProvider;
15
use Swag\BasicExample\Service\ExamplePayment;
16
17
class SwagBasicExample extends Plugin
18
{
19
public function install(InstallContext $context): void
20
{
21
$this->addPaymentMethod($context->getContext());
22
}
23
24
public function uninstall(UninstallContext $context): void
25
{
26
// Only set the payment method to inactive when uninstalling. Removing the payment method would
27
// cause data consistency issues, since the payment method might have been used in several orders
28
$this->setPaymentMethodIsActive(false, $context->getContext());
29
}
30
31
public function activate(ActivateContext $context): void
32
{
33
$this->setPaymentMethodIsActive(true, $context->getContext());
34
parent::activate($context);
35
}
36
37
public function deactivate(DeactivateContext $context): void
38
{
39
$this->setPaymentMethodIsActive(false, $context->getContext());
40
parent::deactivate($context);
41
}
42
43
private function addPaymentMethod(Context $context): void
44
{
45
$paymentMethodExists = $this->getPaymentMethodId();
46
47
// Payment method exists already, no need to continue here
48
if ($paymentMethodExists) {
49
return;
50
}
51
52
/** @var PluginIdProvider $pluginIdProvider */
53
$pluginIdProvider = $this->container->get(PluginIdProvider::class);
54
$pluginId = $pluginIdProvider->getPluginIdByBaseClass(get_class($this), $context);
55
56
$examplePaymentData = [
57
// payment handler will be selected by the identifier
58
'handlerIdentifier' => ExamplePayment::class,
59
'name' => 'Example payment',
60
'description' => 'Example payment description',
61
'pluginId' => $pluginId,
62
];
63
64
/** @var EntityRepositoryInterface $paymentRepository */
65
$paymentRepository = $this->container->get('payment_method.repository');
66
$paymentRepository->create([$examplePaymentData], $context);
67
}
68
69
private function setPaymentMethodIsActive(bool $active, Context $context): void
70
{
71
/** @var EntityRepositoryInterface $paymentRepository */
72
$paymentRepository = $this->container->get('payment_method.repository');
73
74
$paymentMethodId = $this->getPaymentMethodId();
75
76
// Payment does not even exist, so nothing to (de-)activate here
77
if (!$paymentMethodId) {
78
return;
79
}
80
81
$paymentMethod = [
82
'id' => $paymentMethodId,
83
'active' => $active,
84
];
85
86
$paymentRepository->update([$paymentMethod], $context);
87
}
88
89
private function getPaymentMethodId(): ?string
90
{
91
/** @var EntityRepositoryInterface $paymentRepository */
92
$paymentRepository = $this->container->get('payment_method.repository');
93
94
// Fetch ID for update
95
$paymentCriteria = (new Criteria())->addFilter(new EqualsFilter('handlerIdentifier', ExamplePayment::class));
96
return $paymentRepository->searchIds($paymentCriteria, Context::createDefaultContext())->firstId();
97
}
98
}
Copied!
In the install method, you actually start by creating a new payment method, if it doesn't exist yet. If you don't know what's happening in there, you might want to have a look at our guide regarding Writing data.
However, do not do the opposite in the uninstall method and remove the payment method. This might lead to data inconsistency, if the payment method was used in some orders. Instead, only deactivate the method!
The activate method and deactivate method just do that, activating and deactivating the payment method respectively.

Identify your payment

You can identify your payment by the entity property formattedHandlerIdentifier. It shortens the original handler identifier (php class reference): Custom/Payment/SEPAPayment to handler_custom_sepapayment The syntax for the shortening can be looked up in Shopware\Core\Checkout\Payment\DataAbstractionLayer\PaymentHandlerIdentifierSubscriber.