Skip to content

Commit

Permalink
NEXT-25815 - Recurring payment handler
Browse files Browse the repository at this point in the history
  • Loading branch information
lernhart committed Jul 18, 2023
1 parent 91596f5 commit c8a1e2e
Show file tree
Hide file tree
Showing 57 changed files with 1,998 additions and 51 deletions.
16 changes: 16 additions & 0 deletions changelog/_unreleased/2023-06-13-recurring-payment-handler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: Recurring payment handler
issue: NEXT-25815
author: Lennart Tinkloh
author_email: l.tinkloh@shopware.com
author_github: @lernhart
---
# Core
* Added `Shopware\Core\Checkout\Payment\Cart\PaymentHandler\RecurringPaymentHandlerInterface` to handle recurring payment captures from subscriptions.
* Added `Shopware\Core\Checkout\Payment\Cart\PaymentHandler\PaymentHandlerRegistry::getRecurringPaymentHandler` to retrieve the recurring payment handler for a given payment method.
* Added `Shopware\Core\Checkout\Payment\Cart\PaymentRecurringProcessor`, which is responsible for processing recurring payments and calling the payment handler.
* Added `Shopware\Core\Framework\App\Payment\Handler\AppPaymentHandler::captureRecurring`, which handles app payment method and calls the app endpoint with the recurring payload.
* Added `Shopware\Core\Checkout\Payment\Cart\RecurringPaymentTransactionStruct`, which is the payload sent to app endpoints during recurring capture for app payment methods.
* Added `Shopware\Core\Checkout\Payment\Exception\RecurringPaymentProcessException` to signalize errors occurring during recurring payment captures.
* Added `shopware.payment.method.recurring` service tag to allow plugins to add recurring payment methods.
* Added `recurring_url` to app manifests to allow apps to add a recurring captured payment method.
10 changes: 0 additions & 10 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -7595,16 +7595,6 @@ parameters:
count: 1
path: src/Core/Framework/App/Manifest/Xml/MainModule.php

-
message: "#^Method Shopware\\\\Core\\\\Framework\\\\App\\\\Manifest\\\\Xml\\\\PaymentMethod\\:\\:__construct\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#"
count: 1
path: src/Core/Framework/App/Manifest/Xml/PaymentMethod.php

-
message: "#^Method Shopware\\\\Core\\\\Framework\\\\App\\\\Manifest\\\\Xml\\\\PaymentMethod\\:\\:parse\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Core/Framework/App/Manifest/Xml/PaymentMethod.php

-
message: "#^Method Shopware\\\\Core\\\\Framework\\\\App\\\\Manifest\\\\Xml\\\\Payments\\:\\:__construct\\(\\) has parameter \\$paymentMethods with no value type specified in iterable type array\\.$#"
count: 1
Expand Down
5 changes: 5 additions & 0 deletions phpstan-v66-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ parameters:
count: 4
path: src/Core/Checkout/Cart/Order/Api/OrderRecalculationController.php

-
message: "#^Expected domain exception class Shopware\\\\Core\\\\Checkout\\\\Payment\\\\PaymentException, got Shopware\\\\Core\\\\Checkout\\\\Order\\\\OrderException$#"
count: 2
path: src/Core/Checkout/Payment/Cart/PaymentRecurringProcessor.php

-
message: "#^Throwing new exceptions within classes are not allowed\\. Please use domain exception pattern\\. See https\\://github\\.com/shopware/platform/blob/v6\\.4\\.20\\.0/adr/2022\\-02\\-24\\-domain\\-exceptions\\.md$#"
count: 1
Expand Down
14 changes: 14 additions & 0 deletions src/Core/Checkout/DependencyInjection/payment.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@
<argument type="service" id="logger"/>
<argument type="service" id="order.repository"/>
<argument type="service" id="Shopware\Core\System\SalesChannel\Context\SalesChannelContextService"/>
<argument type="service" id="Shopware\Core\Checkout\Payment\Cart\PaymentTransactionStructFactory"/>
</service>

<service id="Shopware\Core\Checkout\Payment\PreparedPaymentService">
<argument type="service" id="Shopware\Core\Checkout\Payment\Cart\PaymentHandler\PaymentHandlerRegistry"/>
<argument type="service" id="app_payment_method.repository"/>
<argument type="service" id="logger"/>
<argument type="service" id="Shopware\Core\System\StateMachine\Loader\InitialStateIdLoader"/>
<argument type="service" id="Shopware\Core\Checkout\Payment\Cart\PaymentTransactionStructFactory"/>
</service>

<service id="Shopware\Core\Checkout\Payment\Controller\PaymentController" public="true">
Expand All @@ -50,13 +52,16 @@
</call>
</service>

<service id="Shopware\Core\Checkout\Payment\Cart\PaymentTransactionStructFactory"/>

<service id="Shopware\Core\Checkout\Payment\Cart\PaymentTransactionChainProcessor">
<argument type="service" id="Shopware\Core\Checkout\Payment\Cart\Token\JWTFactoryV2"/>
<argument type="service" id="order.repository"/>
<argument type="service" id="router"/>
<argument type="service" id="Shopware\Core\Checkout\Payment\Cart\PaymentHandler\PaymentHandlerRegistry"/>
<argument type="service" id="Shopware\Core\System\SystemConfig\SystemConfigService"/>
<argument type="service" id="Shopware\Core\System\StateMachine\Loader\InitialStateIdLoader"/>
<argument type="service" id="Shopware\Core\Checkout\Payment\Cart\PaymentTransactionStructFactory"/>
</service>

<service id="Shopware\Core\Checkout\Payment\Cart\PaymentRefundProcessor" public="true">
Expand All @@ -65,6 +70,14 @@
<argument type="service" id="Shopware\Core\Checkout\Payment\Cart\PaymentHandler\PaymentHandlerRegistry"/>
</service>

<service id="Shopware\Core\Checkout\Payment\Cart\PaymentRecurringProcessor" public="true">
<argument type="service" id="order.repository"/>
<argument type="service" id="Shopware\Core\System\StateMachine\Loader\InitialStateIdLoader"/>
<argument type="service" id="Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStateHandler"/>
<argument type="service" id="Shopware\Core\Checkout\Payment\Cart\PaymentHandler\PaymentHandlerRegistry"/>
<argument type="service" id="Shopware\Core\Checkout\Payment\Cart\PaymentTransactionStructFactory"/>
</service>

<service id="shopware.jwt_signer" class="Lcobucci\JWT\Signer\Rsa\Sha256"/>

<service id="shopware.jwt_config" class="Lcobucci\JWT\Configuration">
Expand All @@ -85,6 +98,7 @@
<argument type="tagged_locator" tag="shopware.payment.method.async"/>
<argument type="tagged_locator" tag="shopware.payment.method.prepared"/>
<argument type="tagged_locator" tag="shopware.payment.method.refund"/>
<argument type="tagged_locator" tag="shopware.payment.method.recurring"/>
<argument type="service" id="Doctrine\DBAL\Connection"/>
</service>

Expand Down
11 changes: 11 additions & 0 deletions src/Core/Checkout/Order/OrderException.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class OrderException extends HttpException
final public const ORDER_ORDER_DELIVERY_NOT_FOUND_CODE = 'CHECKOUT__ORDER_ORDER_DELIVERY_NOT_FOUND';
final public const ORDER_ORDER_NOT_FOUND_CODE = 'CHECKOUT__ORDER_ORDER_NOT_FOUND';
final public const ORDER_MISSING_ORDER_NUMBER_CODE = 'CHECKOUT__ORDER_MISSING_ORDER_NUMBER';
final public const ORDER_MISSING_TRANSACTIONS_CODE = 'CHECKOUT__ORDER_MISSING_TRANSACTIONS';
final public const ORDER_ORDER_TRANSACTION_NOT_FOUND_CODE = 'CHECKOUT__ORDER_ORDER_TRANSACTION_NOT_FOUND';
final public const ORDER_PAYMENT_METHOD_UNAVAILABLE = 'CHECKOUT__ORDER_PAYMENT_METHOD_NOT_AVAILABLE';
final public const ORDER_ORDER_ALREADY_PAID_CODE = 'CHECKOUT__ORDER_ORDER_ALREADY_PAID';
Expand Down Expand Up @@ -102,6 +103,16 @@ public static function orderNotFound(string $orderId): self
);
}

public static function missingTransactions(string $orderId): self
{
return new self(
Response::HTTP_NOT_FOUND,
self::ORDER_MISSING_TRANSACTIONS_CODE,
'Order with id {{ orderId }} has no transactions.',
['orderId' => $orderId]
);
}

public static function missingOrderNumber(string $orderId): self
{
return new self(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php declare(strict_types=1);

namespace Shopware\Core\Checkout\Payment\Cart;

use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionEntity;
use Shopware\Core\Checkout\Order\OrderEntity;
use Shopware\Core\Framework\Log\Package;

/**
* This factory is intended to be decorated in order to manipulate the structs that are used in the payment process ny the payment handlers
*/
#[Package('checkout')]
abstract class AbstractPaymentTransactionStructFactory
{
abstract public function getDecorated(): AbstractPaymentTransactionStructFactory;

abstract public function sync(OrderTransactionEntity $orderTransaction, OrderEntity $order): SyncPaymentTransactionStruct;

abstract public function async(OrderTransactionEntity $orderTransaction, OrderEntity $order, string $returnUrl): AsyncPaymentTransactionStruct;

abstract public function prepared(OrderTransactionEntity $orderTransaction, OrderEntity $order): PreparedPaymentTransactionStruct;

abstract public function recurring(OrderTransactionEntity $orderTransaction, OrderEntity $order): RecurringPaymentTransactionStruct;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,27 @@

use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionEntity;
use Shopware\Core\Checkout\Order\OrderEntity;
use Shopware\Core\Checkout\Payment\Cart\Recurring\RecurringDataStruct;
use Shopware\Core\Framework\Log\Package;

#[Package('checkout')]
class AsyncPaymentTransactionStruct extends SyncPaymentTransactionStruct
{
/**
* @deprecated tag:v6.6.0 - Will be strongly typed
*
* @var string
*/
protected $returnUrl;

public function __construct(
OrderTransactionEntity $orderTransaction,
OrderEntity $order,
string $returnUrl
string $returnUrl,
protected ?RecurringDataStruct $recurringData = null
) {
parent::__construct($orderTransaction, $order);
parent::__construct($orderTransaction, $order, $recurringData);

$this->returnUrl = $returnUrl;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@

namespace Shopware\Core\Checkout\Payment\Cart\PaymentHandler;

use Shopware\Core\Checkout\Payment\Cart\RecurringPaymentTransactionStruct;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\Log\Package;

#[Package('checkout')]
class InvoicePayment extends DefaultPayment
class InvoicePayment extends DefaultPayment implements RecurringPaymentHandlerInterface
{
public function captureRecurring(RecurringPaymentTransactionStruct $transaction, Context $context): void
{
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ class PaymentHandlerRegistry
* @param ServiceProviderInterface<PaymentHandlerInterface> $asyncHandlers
* @param ServiceProviderInterface<PaymentHandlerInterface> $preparedHandlers
* @param ServiceProviderInterface<PaymentHandlerInterface> $refundHandlers
* @param ServiceProviderInterface<PaymentHandlerInterface> $recurringHandlers
*/
public function __construct(
ServiceProviderInterface $syncHandlers,
ServiceProviderInterface $asyncHandlers,
ServiceProviderInterface $preparedHandlers,
ServiceProviderInterface $refundHandlers,
ServiceProviderInterface $recurringHandlers,
private readonly Connection $connection
) {
foreach (\array_keys($syncHandlers->getProvidedServices()) as $serviceId) {
Expand All @@ -51,6 +53,11 @@ public function __construct(
$handler = $refundHandlers->get($serviceId);
$this->handlers[(string) $serviceId] = $handler;
}

foreach (\array_keys($recurringHandlers->getProvidedServices()) as $serviceId) {
$handler = $recurringHandlers->get($serviceId);
$this->handlers[(string) $serviceId] = $handler;
}
}

public function getPaymentMethodHandler(
Expand All @@ -65,7 +72,8 @@ public function getPaymentMethodHandler(
app_payment_method.finalize_url,
app_payment_method.capture_url,
app_payment_method.validate_url,
app_payment_method.refund_url
app_payment_method.refund_url,
app_payment_method.recurring_url
')
->from('payment_method')
->leftJoin(
Expand Down Expand Up @@ -148,6 +156,17 @@ public function getRefundPaymentHandler(string $paymentMethodId): ?RefundPayment
return $handler;
}

public function getRecurringPaymentHandler(string $paymentMethodId): ?RecurringPaymentHandlerInterface
{
$handler = $this->getPaymentMethodHandler($paymentMethodId, RecurringPaymentHandlerInterface::class);

if (!$handler instanceof RecurringPaymentHandlerInterface) {
return null;
}

return $handler;
}

/**
* @param array<string, mixed> $appPaymentMethod
*/
Expand All @@ -168,6 +187,12 @@ private function resolveAppPaymentMethodHandler(
return null;
}
}

if (\is_a(RecurringPaymentHandlerInterface::class, $expectedHandlerType, true)) {
if (empty($appPaymentMethod['recurring_url'])) {
return null;
}
}
}

if (empty($appPaymentMethod['finalize_url'])) {
Expand Down
7 changes: 6 additions & 1 deletion src/Core/Checkout/Payment/Cart/PaymentHandler/PrePayment.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@

namespace Shopware\Core\Checkout\Payment\Cart\PaymentHandler;

use Shopware\Core\Checkout\Payment\Cart\RecurringPaymentTransactionStruct;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\Log\Package;

#[Package('checkout')]
class PrePayment extends DefaultPayment
class PrePayment extends DefaultPayment implements RecurringPaymentHandlerInterface
{
public function captureRecurring(RecurringPaymentTransactionStruct $transaction, Context $context): void
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php declare(strict_types=1);

namespace Shopware\Core\Checkout\Payment\Cart\PaymentHandler;

use Shopware\Core\Checkout\Payment\Cart\RecurringPaymentTransactionStruct;
use Shopware\Core\Checkout\Payment\PaymentException;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\Log\Package;

#[Package('checkout')]
interface RecurringPaymentHandlerInterface extends PaymentHandlerInterface
{
/**
* The captureRecurring function is called for every recurring payment of a subscription.
* A successful billing agreement with the payment provider should exist at this moment.
* Initial billing agreements should be handled via the other payment methods
* (@see SynchronousPaymentHandlerInterface, AsynchronousPaymentHandlerInterface for instance).
* The handler should only be called in the background by scheduled tasks, etc.
*
* Throw a @see PaymentException::recurringInterrupted() exception if an error ocurres while processing the payment
*
* @throws PaymentException
*/
public function captureRecurring(RecurringPaymentTransactionStruct $transaction, Context $context): void;
}
89 changes: 89 additions & 0 deletions src/Core/Checkout/Payment/Cart/PaymentRecurringProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php declare(strict_types=1);

namespace Shopware\Core\Checkout\Payment\Cart;

use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStateHandler;
use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStates;
use Shopware\Core\Checkout\Order\OrderEntity;
use Shopware\Core\Checkout\Order\OrderException;
use Shopware\Core\Checkout\Payment\Cart\PaymentHandler\PaymentHandlerRegistry;
use Shopware\Core\Checkout\Payment\PaymentException;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\System\StateMachine\Loader\InitialStateIdLoader;

#[Package('checkout')]
class PaymentRecurringProcessor
{
/**
* @internal
*/
public function __construct(
private readonly EntityRepository $orderRepository,
private readonly InitialStateIdLoader $initialStateIdLoader,
private readonly OrderTransactionStateHandler $stateHandler,
private readonly PaymentHandlerRegistry $paymentHandlerRegistry,
private readonly AbstractPaymentTransactionStructFactory $paymentTransactionStructFactory,
) {
}

public function processRecurring(string $orderId, Context $context): void
{
$criteria = new Criteria([$orderId]);
$criteria->addAssociation('transactions.stateMachineState');
$criteria->addAssociation('transactions.paymentMethod');
$criteria->addAssociation('orderCustomer.customer');
$criteria->addAssociation('orderCustomer.salutation');
$criteria->addAssociation('transactions.paymentMethod.appPaymentMethod.app');
$criteria->addAssociation('language');
$criteria->addAssociation('currency');
$criteria->addAssociation('deliveries.shippingOrderAddress.country');
$criteria->addAssociation('billingAddress.country');
$criteria->addAssociation('lineItems');
$criteria->getAssociation('transactions')->addSorting(new FieldSorting('createdAt'));

/** @var OrderEntity $order */
$order = $this->orderRepository->search($criteria, $context)->first();

if (!$order) {
throw OrderException::orderNotFound($orderId);
}

$transactions = $order->getTransactions();
if ($transactions === null) {
throw OrderException::missingTransactions($orderId);
}

$transactions = $transactions->filterByStateId(
$this->initialStateIdLoader->get(OrderTransactionStates::STATE_MACHINE)
);

$transaction = $transactions->last();
if ($transaction === null) {
return;
}

$paymentMethod = $transaction->getPaymentMethod();
if ($paymentMethod === null) {
throw PaymentException::unknownPaymentMethod($transaction->getPaymentMethodId());
}

$paymentHandler = $this->paymentHandlerRegistry->getRecurringPaymentHandler($paymentMethod->getId());
if (!$paymentHandler) {
throw PaymentException::unknownPaymentMethod($paymentMethod->getHandlerIdentifier());
}

$struct = $this->paymentTransactionStructFactory->recurring($transaction, $order);

try {
$paymentHandler->captureRecurring($struct, $context);
} catch (PaymentException $e) {
$this->stateHandler->fail($transaction->getId(), $context);

throw $e;
}
}
}
Loading

0 comments on commit c8a1e2e

Please sign in to comment.