[PaymentRequest] Automatically cancel all pending payment requests when the payment method gets changed (#15950)

| Q | A |

|-----------------|--------------------------------------------------------------|
| Branch? | payment-request <!-- see the comment below --> |
| Bug fix? | no |
| New feature? | yes |
| BC breaks? | no |
| License | MIT |

<!--
 - Bug fixes must be submitted against the 1.12 branch
 - Features and deprecations must be submitted against the 1.13 branch
 - Make sure that the correct base branch is set

To be sure you are not breaking any Backward Compatibilities, check the
documentation:

https://docs.sylius.com/en/latest/book/organization/backward-compatibility-promise.html
-->
This commit is contained in:
Grzegorz Sadowski 2024-10-07 11:06:14 +02:00 committed by GitHub
commit e443ce97ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 631 additions and 16 deletions

View file

@ -0,0 +1,29 @@
@paying_for_order
Feature: Cancelling payment request when payment method is changed
In order to pay with the correct payment method
As an Administrator
I want the customer's payment requests with the previous payment method to be cancelled
Background:
Given the store operates on a single channel in "United States"
And the store has a product "PHP T-Shirt"
And the store ships everywhere for Free
And the store allows paying with "Cash on Delivery"
And the store also allows paying with "Bank Transfer"
And I am a logged in customer
And I placed an order "#00000001"
And I bought a single "PHP T-Shirt"
And I addressed it to "Lucifer Morningstar", "Seaside Fwy", "90802" "Los Angeles" in the "United States" with identical billing address
And I chose "Free" shipping method with "Cash on Delivery" payment
And there is a "new" "authorize" payment request for order "#00000001" using the "Cash on Delivery" payment method
And there is also a "processing" "status" payment request for order "#00000001" using the "Cash on Delivery" payment method
And there is also a "completed" "capture" payment request for order "#00000001" using the "Cash on Delivery" payment method
And there is an administrator "sylius@example.com" identified by "sylius"
@api @ui
Scenario: Cancelling only not finalized payment requests when the payment method has changed
When I browse my orders
And I change my payment method to "Bank Transfer"
Then the administrator should see the payment request with action "authorize" for "Cash on Delivery" payment method and state "cancelled"
And the administrator should see the payment request with action "status" for "Cash on Delivery" payment method and state "cancelled"
And the administrator should see the payment request with action "capture" for "Cash on Delivery" payment method and state "completed"

View file

@ -0,0 +1,28 @@
@paying_for_order
Feature: Cancelling payment request when payment method is changed
In order to pay with the correct payment method
As a Customer
I want my payment requests with the previous payment method to be cancelled
Background:
Given the store operates on a single channel in "United States"
And the store has a product "PHP T-Shirt"
And the store ships everywhere for Free
And the store allows paying with "Cash on Delivery"
And the store also allows paying with "Bank Transfer"
And I am a logged in customer
And I placed an order "#00000001"
And I bought a single "PHP T-Shirt"
And I addressed it to "Lucifer Morningstar", "Seaside Fwy", "90802" "Los Angeles" in the "United States" with identical billing address
And I chose "Free" shipping method with "Cash on Delivery" payment
And there is a "new" "authorize" payment request for order "#00000001" using the "Cash on Delivery" payment method
And there is also a "processing" "status" payment request for order "#00000001" using the "Cash on Delivery" payment method
And there is also a "completed" "capture" payment request for order "#00000001" using the "Cash on Delivery" payment method
@api @no-ui
Scenario: Cancelling only not finalized payment requests when the payment method has changed
When I view the summary of my order "#00000001"
And I change payment method to "Bank Transfer" after checkout
Then my payment request with action "authorize" for payment method "Cash on Delivery" should have state "cancelled"
And my payment request with action "status" for payment method "Cash on Delivery" should have state "cancelled"
And my payment request with action "capture" for payment method "Cash on Delivery" should have state "completed"

View file

@ -15,17 +15,16 @@ namespace Sylius\Behat\Context\Api\Admin;
use Behat\Behat\Context\Context;
use Sylius\Behat\Client\ApiClientInterface;
use Sylius\Behat\Client\RequestFactoryInterface;
use Sylius\Behat\Client\ResponseCheckerInterface;
use Sylius\Behat\Context\Api\Resources;
use Sylius\Behat\Service\Converter\IriConverterInterface;
use Sylius\Component\Core\Formatter\StringInflector;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\CustomerInterface;
use Sylius\Behat\Service\SharedSecurityServiceInterface;
use Sylius\Behat\Service\SharedStorageInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Sylius\Component\Payment\PaymentTransitions;
use Sylius\Component\Payment\Repository\PaymentRequestRepositoryInterface;
use Symfony\Component\HttpFoundation\Request as HttpRequest;
use Symfony\Component\HttpFoundation\Request as HTTPRequest;
use Webmozart\Assert\Assert;
final readonly class ManagingPaymentRequestsContext implements Context
@ -35,6 +34,9 @@ final readonly class ManagingPaymentRequestsContext implements Context
private ResponseCheckerInterface $responseChecker,
private IriConverterInterface $iriConverter,
private PaymentRequestRepositoryInterface $paymentRequestRepository,
private RequestFactoryInterface $requestFactory,
private SharedSecurityServiceInterface $sharedSecurityService,
private SharedStorageInterface $sharedStorage,
) {
}
@ -46,7 +48,7 @@ final readonly class ManagingPaymentRequestsContext implements Context
$this->client->subResourceIndex(
Resources::PAYMENTS,
Resources::PAYMENT_REQUESTS,
(string) $order->getLastPayment()->getId()
(string) $order->getLastPayment()->getId(),
);
}
@ -122,10 +124,12 @@ final readonly class ManagingPaymentRequestsContext implements Context
*/
public function itsMethodShouldBe(PaymentMethodInterface $paymentMethod): void
{
Assert::true($this->responseChecker->hasValue(
$this->client->getLastResponse(),
'method',
$this->iriConverter->getIriFromResourceInSection($paymentMethod, 'admin'))
Assert::true(
$this->responseChecker->hasValue(
$this->client->getLastResponse(),
'method',
$this->iriConverter->getIriFromResourceInSection($paymentMethod, 'admin'),
),
);
}
@ -153,4 +157,32 @@ final readonly class ManagingPaymentRequestsContext implements Context
Assert::isEmpty($this->responseChecker->getValue($this->client->getLastResponse(), 'responseData'));
}
/**
* @Then the administrator should see the payment request with action :action for :paymentMethod payment method and state :state
*/
public function administratorShouldSeeThePaymentRequestWithActionAndState(string $action, PaymentMethodInterface $paymentMethod, string $state): void
{
$adminUser = $this->sharedStorage->get('administrator');
/** @var OrderInterface $order */
$this->sharedSecurityService->performActionAsAdminUser($adminUser, function () {
$order = $this->sharedStorage->get('order');
$request = $this->requestFactory->custom('/api/v2/admin/payments/' . $order->getLastPayment()->getId() . '/payment-requests', HTTPRequest::METHOD_GET, [], $this->client->getToken());
$this->client->executeCustomRequest($request);
});
Assert::true(
$this->responseChecker->hasItemWithValue($this->client->getLastResponse(), 'action', $action),
sprintf('Payment request should have action %s', $action),
);
Assert::true(
$this->responseChecker->hasItemWithValue($this->client->getLastResponse(), 'method', $this->iriConverter->getIriFromResourceInSection($paymentMethod, 'admin')),
sprintf('Payment request should have payment method %s', $paymentMethod->getCode()),
);
Assert::true(
$this->responseChecker->hasItemWithValue($this->client->getLastResponse(), 'state', $state),
sprintf('Payment request should have state %s', $state),
);
}
}

View file

@ -68,7 +68,7 @@ final readonly class ManagingPaymentsContext implements Context
$this->client->subResourceIndex(
Resources::PAYMENTS,
Resources::PAYMENT_REQUESTS,
(string) $this->responseChecker->getCollection($response)[0]['id']
(string) $this->responseChecker->getCollection($response)[0]['id'],
);
}

View file

@ -110,6 +110,8 @@ final readonly class OrderContext implements Context
public function iViewTheSummaryOfMyOrder(OrderInterface $order): void
{
$this->shopClient->show(Resources::ORDERS, $order->getTokenValue());
$this->sharedStorage->set('cart_token', $order->getTokenValue());
}
/**

View file

@ -1,19 +1,29 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Behat\Context\Api\Shop;
use Behat\Behat\Context\Context;
use Sylius\Behat\Client\ApiClientInterface;
use Sylius\Behat\Client\Request;
use Sylius\Behat\Client\RequestFactoryInterface;
use Sylius\Behat\Client\ResponseCheckerInterface;
use Sylius\Behat\Context\Api\Resources;
use Sylius\Behat\Service\SharedStorageInterface;
use Sylius\Bundle\PayumBundle\Model\GatewayConfigInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Sylius\Component\Core\Repository\PaymentMethodRepositoryInterface;
use Sylius\Component\Payment\Repository\PaymentRequestRepositoryInterface;
use Symfony\Component\HttpFoundation\Request as HTTPRequest;
use Webmozart\Assert\Assert;
final readonly class PaymentRequestContext implements Context
{
@ -22,6 +32,7 @@ final readonly class PaymentRequestContext implements Context
private ApiClientInterface $client,
private ResponseCheckerInterface $responseChecker,
private RequestFactoryInterface $requestFactory,
private PaymentRequestRepositoryInterface $paymentRequestRepository,
) {
}
@ -81,4 +92,31 @@ final readonly class PaymentRequestContext implements Context
$this->client->executeCustomRequest($request);
}
/**
* @Then my payment request with action :action for payment method :paymentMethod should have state :state
*/
public function myPaymentRequestShouldBeCancelled(string $action, PaymentMethodInterface $paymentMethod, string $state): void
{
$request = $this->getRequestForPaymentRequestWithAction($action);
Assert::notNull($request, sprintf('Payment request with action %s not found', $action));
$this->client->executeCustomRequest($request);
$response = $this->client->getLastResponse();
Assert::same($this->responseChecker->getValue($response, 'action'), $action, sprintf('Payment request should have action %s', $action));
Assert::true(str_contains($this->responseChecker->getValue($response, 'method'), $paymentMethod->getCode()), sprintf('Payment request should have payment method %s', $paymentMethod->getCode()));
Assert::same($this->responseChecker->getValue($response, 'state'), $state, sprintf('Payment request should have state %s', $state));
}
private function getRequestForPaymentRequestWithAction(string $action): ?Request
{
$orderToken = $this->sharedStorage->get('cart_token');
$order = $this->client->show(Resources::ORDERS, $orderToken);
$payments = $this->responseChecker->getValue($order, 'payments');
$paymentId = end($payments)['id'];
$paymentRequest = $this->paymentRequestRepository->findOneBy(['payment' => $paymentId, 'action' => $action]);
return $paymentRequest ? $this->requestFactory->custom('/api/v2/shop/payment-requests/' . $paymentRequest->getHash(), HttpRequest::METHOD_GET, [], $this->client->getToken()) : null;
}
}

View file

@ -14,15 +14,23 @@ declare(strict_types=1);
namespace Sylius\Behat\Context\Setup;
use Behat\Behat\Context\Context;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Bundle\ApiBundle\Command\Payment\AddPaymentRequest;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Sylius\Component\Payment\Factory\PaymentRequestFactoryInterface;
use Sylius\Component\Payment\Model\PaymentRequestInterface;
use Sylius\Component\Payment\PaymentRequestTransitions;
use Sylius\Component\Payment\Repository\PaymentRequestRepositoryInterface;
use Symfony\Component\Messenger\MessageBusInterface;
final readonly class PaymentRequestContext implements Context
{
public function __construct(
private MessageBusInterface $commandBus,
private PaymentRequestRepositoryInterface $paymentRequestRepository,
private PaymentRequestFactoryInterface $paymentRequestFactory,
private StateMachineInterface $stateMachine,
) {
}
@ -32,7 +40,7 @@ final readonly class PaymentRequestContext implements Context
public function thePaymentRequestActionHasBeenExecutedForOrderWithThePaymentMethod(
string $action,
OrderInterface $order,
PaymentMethodInterface $paymentMethod
PaymentMethodInterface $paymentMethod,
): void {
$addPaymentRequest = new AddPaymentRequest(
paymentId: $order->getLastPayment()->getId(),
@ -42,4 +50,37 @@ final readonly class PaymentRequestContext implements Context
$this->commandBus->dispatch($addPaymentRequest);
}
/**
* @Given there is (also) a :state :action payment request for order :order using the :paymentMethod payment method
*/
public function thePaymentRequestActionHasBeenExecutedForOrderWithThePaymentMethodAndState(
string $state = null,
string $action,
OrderInterface $order,
PaymentMethodInterface $paymentMethod,
): void {
$paymentRequest = $this->paymentRequestFactory->create($order->getLastPayment(), $paymentMethod);
if ($state !== PaymentRequestInterface::STATE_NEW) {
$this->stateMachine->apply(
$paymentRequest,
PaymentRequestTransitions::GRAPH,
$this->getTransitionForState($state),
);
}
$paymentRequest->setAction($action);
$this->paymentRequestRepository->add($paymentRequest);
}
private function getTransitionForState(string $state): string
{
return match ($state) {
'completed' => PaymentRequestTransitions::TRANSITION_COMPLETE,
'processing' => PaymentRequestTransitions::TRANSITION_PROCESS,
default => throw new \InvalidArgumentException(sprintf('Invalid state "%s" provided.', $state)),
};
}
}

View file

@ -16,6 +16,8 @@ namespace Sylius\Behat\Context\Ui\Admin;
use Behat\Behat\Context\Context;
use Sylius\Behat\Page\Admin\Payment\PaymentRequest\IndexPageInterface;
use Sylius\Behat\Page\Admin\Payment\PaymentRequest\ShowPageInterface;
use Sylius\Behat\Service\SharedSecurityServiceInterface;
use Sylius\Behat\Service\SharedStorageInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Sylius\Component\Payment\Repository\PaymentRequestRepositoryInterface;
@ -27,13 +29,15 @@ final readonly class ManagingPaymentRequestsContext implements Context
private IndexPageInterface $indexPage,
private ShowPageInterface $showPage,
private PaymentRequestRepositoryInterface $paymentRequestRepository,
private SharedStorageInterface $sharedStorage,
private SharedSecurityServiceInterface $sharedSecurityService,
) {
}
/**
* @When I browse payment requests of an order :order
*/
public function iBrowseOrdersOfACustomer(OrderInterface $order): void
public function iBrowsePaymentRequestsOfACustomer(OrderInterface $order): void
{
$this->indexPage->open(['paymentId' => $order->getLastPayment()->getId()]);
}
@ -48,7 +52,7 @@ final readonly class ManagingPaymentRequestsContext implements Context
$this->showPage->open([
'hash' => $paymentRequest->getHash(),
'paymentId' => $payment->getId()
'paymentId' => $payment->getId(),
]);
}
@ -126,4 +130,22 @@ final readonly class ManagingPaymentRequestsContext implements Context
{
Assert::same($this->showPage->getFieldText('response_data'), json_encode([]));
}
/**
* @Then the administrator should see the payment request with action :action for :method payment method and state :state
*/
public function administratorShouldSeeThePaymentRequestWithActionAndState(string $action, string $paymentMethod, string $state): void
{
$adminUser = $this->sharedStorage->get('administrator');
$this->sharedSecurityService->performActionAsAdminUser($adminUser, function () {
$this->iBrowsePaymentRequestsOfACustomer($this->sharedStorage->get('order'));
});
Assert::true($this->indexPage->isSingleResourceOnPage([
'action' => $action,
'state' => $state,
'method' => $paymentMethod,
]));
}
}

View file

@ -309,6 +309,9 @@
<argument type="service" id="Sylius\Behat\Client\ResponseCheckerInterface" />
<argument type="service" id="api_platform.symfony.iri_converter" />
<argument type="service" id="sylius.repository.payment_request" />
<argument type="service" id="sylius.behat.request_factory" />
<argument type="service" id="sylius.behat.api.shared_security" />
<argument type="service" id="sylius.behat.shared_storage" />
</service>
</services>
</container>

View file

@ -166,6 +166,7 @@
<argument type="service" id="sylius.behat.api_platform_client.shop" />
<argument type="service" id="Sylius\Behat\Client\ResponseCheckerInterface" />
<argument type="service" id="sylius.behat.request_factory" />
<argument type="service" id="sylius.repository.payment_request" />
</service>
<service id="sylius.behat.context.api.shop.payment" class="Sylius\Behat\Context\Api\Shop\PaymentContext">

View file

@ -322,6 +322,9 @@
<service id="sylius.behat.context.setup.payment_request" class="Sylius\Behat\Context\Setup\PaymentRequestContext">
<argument type="service" id="sylius.command_bus" />
<argument type="service" id="sylius.repository.payment_request" />
<argument type="service" id="sylius.factory.payment_request" />
<argument type="service" id="sylius_abstraction.state_machine" />
</service>
</services>
</container>

View file

@ -618,6 +618,8 @@
<argument type="service" id="sylius.behat.page.admin.payment.payment_request.index" />
<argument type="service" id="sylius.behat.page.admin.payment.payment_request.show"/>
<argument type="service" id="sylius.repository.payment_request"/>
<argument type="service" id="sylius.behat.shared_storage" />
<argument type="service" id="sylius.behat.shared_security" />
</service>
</services>
</container>

View file

@ -25,6 +25,7 @@ default:
- sylius.behat.context.transform.user
- sylius.behat.context.transform.zone
- sylius.behat.context.setup.admin_user
- sylius.behat.context.setup.cart
- sylius.behat.context.setup.channel
- sylius.behat.context.setup.currency
@ -34,6 +35,7 @@ default:
- sylius.behat.context.setup.locale
- sylius.behat.context.setup.order
- sylius.behat.context.setup.payment
- sylius.behat.context.setup.payment_request
- sylius.behat.context.setup.product
- sylius.behat.context.setup.shipping
- sylius.behat.context.setup.shop_api_security
@ -41,10 +43,12 @@ default:
- sylius.behat.context.setup.user
- sylius.behat.context.setup.zone
- sylius.behat.context.api.admin.managing_payment_requests
- sylius.behat.context.api.shop.cart
- sylius.behat.context.api.shop.checkout
- sylius.behat.context.api.shop.checkout.complete
- sylius.behat.context.api.shop.checkout.order_details
- sylius.behat.context.api.shop.customer
- sylius.behat.context.api.shop.order
- sylius.behat.context.api.shop.payment_request
- sylius.behat.context.api.shop.response

View file

@ -24,12 +24,14 @@ default:
- sylius.behat.context.transform.user
- sylius.behat.context.transform.zone
- sylius.behat.context.setup.admin_user
- sylius.behat.context.setup.channel
- sylius.behat.context.setup.currency
- sylius.behat.context.setup.geographical
- sylius.behat.context.setup.locale
- sylius.behat.context.setup.order
- sylius.behat.context.setup.payment
- sylius.behat.context.setup.payment_request
- sylius.behat.context.setup.product
- sylius.behat.context.setup.shipping
- sylius.behat.context.setup.shop_security
@ -37,6 +39,7 @@ default:
- sylius.behat.context.setup.user
- sylius.behat.context.setup.zone
- sylius.behat.context.ui.admin.managing_payment_requests
- sylius.behat.context.ui.shop.account
- sylius.behat.context.ui.shop.cart
- sylius.behat.context.ui.shop.checkout

View file

@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\PaymentBundle\Canceller;
use Doctrine\Persistence\ObjectManager;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Component\Payment\Canceller\PaymentRequestCancellerInterface;
use Sylius\Component\Payment\Model\PaymentRequestInterface;
use Sylius\Component\Payment\PaymentRequestTransitions;
use Sylius\Component\Payment\Repository\PaymentRequestRepositoryInterface;
final class PaymentRequestCanceller implements PaymentRequestCancellerInterface
{
/**
* @param PaymentRequestRepositoryInterface<PaymentRequestInterface> $paymentRequestRepository
* @param array<string> $paymentRequestStatesToBeCancelled
*/
public function __construct(
private PaymentRequestRepositoryInterface $paymentRequestRepository,
private StateMachineInterface $stateMachine,
private ObjectManager $objectManager,
private array $paymentRequestStatesToBeCancelled,
) {
}
public function cancelPaymentRequests(mixed $paymentId, string $paymentMethodCode): void
{
$paymentRequests = $this->paymentRequestRepository->findByPaymentIdAndStates($paymentId, $this->paymentRequestStatesToBeCancelled);
foreach ($paymentRequests as $paymentRequest) {
if ($paymentRequest->getMethod()->getCode() !== $paymentMethodCode) {
$this->stateMachine->apply(
$paymentRequest,
PaymentRequestTransitions::GRAPH,
PaymentRequestTransitions::TRANSITION_CANCEL,
);
$this->objectManager->persist($paymentRequest);
$this->objectManager->flush();
}
}
}
}

View file

@ -57,6 +57,14 @@ final class Configuration implements ConfigurationInterface
->end()
->end()
->end()
->arrayNode('payment_request')
->addDefaultsIfNotSet()
->children()
->arrayNode('states_that_should_be_cancelled_when_payment_request_has_changed')
->scalarPrototype()->end()
->end()
->end()
->end()
->scalarNode('driver')->defaultValue(SyliusResourceBundle::DRIVER_DOCTRINE_ORM)->end()
->arrayNode('gateways')
->useAttributeAsKey('name')

View file

@ -34,6 +34,7 @@ final class SyliusPaymentExtension extends AbstractResourceExtension
$container->setParameter('sylius.payment_gateways', $config['gateways']);
$container->setParameter('sylius.gateway_config.validation_groups', $config['gateway_config']['validation_groups']);
$container->setParameter('sylius.payment_request.states_that_should_be_cancelled_when_payment_request_has_changed', $config['payment_request']['states_that_should_be_cancelled_when_payment_request_has_changed']);
$this->registerAutoconfiguration($container);
}

View file

@ -73,4 +73,21 @@ class PaymentRequestRepository extends EntityRepository implements PaymentReques
return count($paymentRequests) > 0;
}
/**
* @param array<string> $states
*
* @return array<PaymentRequestInterface>
*/
public function findByPaymentIdAndStates(mixed $paymentId, array $states): array
{
return $this->createQueryBuilder('o')
->where('o.payment = :paymentId')
->andWhere('o.state IN (:states)')
->setParameter('paymentId', $paymentId)
->setParameter('states', $states)
->getQuery()
->getResult()
;
}
}

View file

@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\PaymentBundle\EventListener;
use Doctrine\ORM\Event\PostUpdateEventArgs;
use Sylius\Component\Payment\Canceller\PaymentRequestCancellerInterface;
use Sylius\Component\Payment\Model\PaymentInterface;
final class PaymentMethodChangeEventListener
{
public function __construct(private PaymentRequestCancellerInterface $paymentRequestCanceller)
{
}
public function postUpdate(PostUpdateEventArgs $args): void
{
$entity = $args->getObject();
if (!$entity instanceof PaymentInterface) {
return;
}
/** @var array<string, array<int, mixed>> $changeSet */
$changeSet = $args->getObjectManager()->getUnitOfWork()->getEntityChangeSet($entity);
if (array_key_exists('method', $changeSet)) {
[$oldMethod, $newMethod] = $changeSet['method'];
if ($oldMethod !== $newMethod) {
$this->paymentRequestCanceller->cancelPaymentRequests($entity->getId(), $newMethod->getCode());
}
}
}
}

View file

@ -10,3 +10,9 @@ jms_serializer:
sylius-payment:
namespace_prefix: "Sylius\\Component\\Payment"
path: "@SyliusPaymentBundle/Resources/config/serializer"
sylius_payment:
payment_request:
states_that_should_be_cancelled_when_payment_request_has_changed:
- !php/const Sylius\Component\Payment\Model\PaymentRequestInterface::STATE_PROCESSING
- !php/const Sylius\Component\Payment\Model\PaymentRequestInterface::STATE_NEW

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
This file is part of the Sylius package.
(c) Sylius Sp. z o.o.
For the full copyright and license information, please view the LICENSE
file that was distributed with this source code.
-->
<container
xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"
>
<services>
<service id="sylius.canceller.payment_request" class="Sylius\Bundle\PaymentBundle\Canceller\PaymentRequestCanceller">
<argument type="service" id="sylius.repository.payment_request" />
<argument type="service" id="sylius_abstraction.state_machine" />
<argument type="service" id="doctrine.orm.entity_manager" />
<argument>%sylius.payment_request.states_that_should_be_cancelled_when_payment_request_has_changed%</argument>
</service>
<service id="Sylius\Component\Payment\Canceller\PaymentRequestCancellerInterface" alias="sylius.canceller.payment_request" />
</services>
</container>

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
This file is part of the Sylius package.
(c) Sylius Sp. z o.o.
For the full copyright and license information, please view the LICENSE
file that was distributed with this source code.
-->
<container
xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"
>
<services>
<defaults public="true" />
<service id="sylius.listener.payment_method_change" class="Sylius\Bundle\PaymentBundle\EventListener\PaymentMethodChangeEventListener">
<argument type="service" id="sylius.canceller.payment_request" />
<tag name="doctrine.event_listener" event="postUpdate" />
</service>
</services>
</container>

View file

@ -19,6 +19,7 @@ use Sylius\Bundle\PaymentBundle\Attribute\AsPaymentMethodsResolver;
use Sylius\Bundle\PaymentBundle\DependencyInjection\SyliusPaymentExtension;
use Sylius\Bundle\PaymentBundle\Tests\Stub\GatewayConfigurationTypeStub;
use Sylius\Bundle\PaymentBundle\Tests\Stub\PaymentMethodsResolverStub;
use Sylius\Component\Payment\Model\PaymentRequestInterface;
use Symfony\Component\DependencyInjection\Definition;
final class SyliusPaymentExtensionTest extends AbstractExtensionTestCase
@ -62,6 +63,7 @@ final class SyliusPaymentExtensionTest extends AbstractExtensionTestCase
['type' => 'test', 'label' => 'Test', 'priority' => 15],
);
}
/** @test */
public function it_loads_gateway_config_validation_groups_parameter_value_properly(): void
{
@ -80,6 +82,24 @@ final class SyliusPaymentExtensionTest extends AbstractExtensionTestCase
);
}
/** @test */
public function it_loads_parameter_with_payment_request_states_that_should_be_cancelled_when_payment_method_is_changed(): void
{
$this->load([
'payment_request' => [
'states_that_should_be_cancelled_when_payment_request_has_changed' => [
PaymentRequestInterface::STATE_NEW,
PaymentRequestInterface::STATE_PROCESSING,
],
],
]);
$this->assertContainerBuilderHasParameter(
'sylius.payment_request.states_that_should_be_cancelled_when_payment_request_has_changed',
[PaymentRequestInterface::STATE_NEW, PaymentRequestInterface::STATE_PROCESSING],
);
}
protected function getContainerExtensions(): array
{
return [new SyliusPaymentExtension()];

View file

@ -0,0 +1,107 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\PaymentBundle\Tests\EventListener;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\PostUpdateEventArgs;
use Doctrine\ORM\UnitOfWork;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\PaymentBundle\EventListener\PaymentMethodChangeEventListener;
use Sylius\Component\Payment\Canceller\PaymentRequestCancellerInterface;
use Sylius\Component\Payment\Model\PaymentInterface;
use Sylius\Component\Payment\Model\PaymentMethodInterface;
final class PaymentMethodChangeEventListenerTest extends TestCase
{
/** @test */
public function it_updates_payment_request_when_the_payment_has_changed_payment_method(): void
{
$paymentRequestCanceller = $this->createMock(PaymentRequestCancellerInterface::class);
$payment = $this->createMock(PaymentInterface::class);
$oldMethod = $this->createMock(PaymentMethodInterface::class);
$newMethod = $this->createMock(PaymentMethodInterface::class);
$entityManager = $this->createMock(EntityManagerInterface::class);
$unitOfWork = $this->createMock(UnitOfWork::class);
$payment->expects($this->once())
->method('getId')
->willReturn(1);
$newMethod->expects($this->once())
->method('getCode')
->willReturn('newMethodCode');
$unitOfWork->expects($this->once())
->method('getEntityChangeSet')
->with($payment)
->willReturn(['method' => [$oldMethod, $newMethod]]);
$entityManager->expects($this->once())
->method('getUnitOfWork')
->willReturn($unitOfWork);
$args = new PostUpdateEventArgs($payment, $entityManager);
$paymentRequestCanceller->expects($this->once())
->method('cancelPaymentRequests')
->with(1, 'newMethodCode');
$listener = new PaymentMethodChangeEventListener($paymentRequestCanceller);
$listener->postUpdate($args);
}
/** @test */
public function it_does_not_update_if_entity_is_not_payment(): void
{
$paymentRequestCanceller = $this->createMock(PaymentRequestCancellerInterface::class);
$nonPaymentEntity = new \stdClass();
$entityManager = $this->createMock(EntityManagerInterface::class);
$args = new PostUpdateEventArgs($nonPaymentEntity, $entityManager);
$paymentRequestCanceller->expects($this->never())
->method('cancelPaymentRequests');
$listener = new PaymentMethodChangeEventListener($paymentRequestCanceller);
$listener->postUpdate($args);
}
/** @test */
public function it_does_not_update_if_payment_method_did_not_change(): void
{
$paymentRequestCanceller = $this->createMock(PaymentRequestCancellerInterface::class);
$payment = $this->createMock(PaymentInterface::class);
$oldMethod = $this->createMock(PaymentMethodInterface::class);
$newMethod = $oldMethod;
$entityManager = $this->createMock(EntityManagerInterface::class);
$unitOfWork = $this->createMock(UnitOfWork::class);
$unitOfWork->expects($this->once())
->method('getEntityChangeSet')
->with($payment)
->willReturn(['method' => [$oldMethod, $newMethod]]);
$entityManager->expects($this->once())
->method('getUnitOfWork')
->willReturn($unitOfWork);
$args = new PostUpdateEventArgs($payment, $entityManager);
$paymentRequestCanceller->expects($this->never())
->method('cancelPaymentRequests');
$listener = new PaymentMethodChangeEventListener($paymentRequestCanceller);
$listener->postUpdate($args);
}
}

View file

@ -0,0 +1,68 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\PaymentBundle\Canceller;
use Doctrine\Persistence\ObjectManager;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Component\Payment\Model\PaymentMethodInterface;
use Sylius\Component\Payment\Model\PaymentRequestInterface;
use Sylius\Component\Payment\Repository\PaymentRequestRepositoryInterface;
final class PaymentRequestCancellerSpec extends ObjectBehavior
{
function let(PaymentRequestRepositoryInterface $paymentRequestRepository, StateMachineInterface $stateMachine, ObjectManager $objectManager): void
{
$this->beConstructedWith($paymentRequestRepository, $stateMachine, $objectManager, [PaymentRequestInterface::STATE_NEW, PaymentRequestInterface::STATE_PROCESSING]);
}
function it_cancels_payment_requests_if_the_payment_method_code_is_different(
PaymentRequestRepositoryInterface $paymentRequestRepository,
PaymentRequestInterface $paymentRequest1,
PaymentRequestInterface $paymentRequest2,
PaymentMethodInterface $paymentMethod1,
PaymentMethodInterface $paymentMethod2,
StateMachineInterface $stateMachine,
ObjectManager $objectManager,
): void {
$paymentRequestRepository->findByPaymentIdAndStates(1, [PaymentRequestInterface::STATE_NEW, PaymentRequestInterface::STATE_PROCESSING])
->willReturn([$paymentRequest1, $paymentRequest2]);
$paymentRequest1->getMethod()->willReturn($paymentMethod1);
$paymentMethod1->getCode()->willReturn('payment_method_with_different_code');
$paymentRequest2->getMethod()->willReturn($paymentMethod2);
$paymentMethod2->getCode()->willReturn('payment_method_code');
$stateMachine->apply($paymentRequest1, 'sylius_payment_request', 'cancel')->shouldBeCalled();
$stateMachine->apply($paymentRequest2, 'sylius_payment_request', 'cancel')->shouldNotBeCalled();
$objectManager->persist($paymentRequest1)->shouldBeCalled();
$objectManager->persist($paymentRequest2)->shouldNotBeCalled();
$objectManager->flush()->shouldBeCalledOnce();
$this->cancelPaymentRequests(1, 'payment_method_code');
}
function it_does_not_cancel_payment_requests_if_none_found(
PaymentRequestRepositoryInterface $paymentRequestRepository,
StateMachineInterface $stateMachine,
): void {
$paymentRequestRepository->findByPaymentIdAndStates(1, [PaymentRequestInterface::STATE_NEW, PaymentRequestInterface::STATE_PROCESSING])
->willReturn([]);
$stateMachine->apply(Argument::cetera())->shouldNotBeCalled();
$this->cancelPaymentRequests(1, 'payment_method_code');
}
}

View file

@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Component\Payment\Canceller;
interface PaymentRequestCancellerInterface
{
public function cancelPaymentRequests(mixed $paymentId, string $paymentMethodCode): void;
}

View file

@ -29,4 +29,11 @@ interface PaymentRequestRepositoryInterface extends RepositoryInterface
public function createQueryBuilderForPayment(string $paymentId): QueryBuilder;
public function duplicateExists(PaymentRequestInterface $paymentRequest): bool;
/**
* @param array<string> $states
*
* @return array<PaymentRequestInterface>
*/
public function findByPaymentIdAndStates(mixed $paymentId, array $states): array;
}