This commit is contained in:
Tomasz Kaliński 2026-06-23 05:42:29 +00:00 committed by GitHub
commit 1c8f15b11e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 180 additions and 2 deletions

View file

@ -22,6 +22,44 @@
or `sylius.live_component.*` tag did not receive the `twig.component` tag.
The priority has been lowered to `50` to ensure Symfony's autoconfiguration runs first.
## Order payment state recovery after authorized payment cancellation
When an authorized `Payment` transitions to `cancelled` (e.g. the merchant voids the authorization
via the payment gateway), the `Order.paymentState` now automatically recovers from `authorized` to
`awaiting_payment`, allowing the customer to retry payment.
Previously the order was left in an inconsistent state — `payment_state = authorized` even though
the authorization no longer existed — and the customer could not retry.
### Impact on custom code
**Symfony Workflow** — the `sylius_order_payment` workflow gains two new source states for the
`request_payment` transition:
```yaml
# Before
request_payment:
from: [cart]
to: awaiting_payment
# After
request_payment:
from: [cart, authorized, partially_authorized]
to: awaiting_payment
```
If you override this transition in your application config, add `authorized` and
`partially_authorized` to the `from` list.
**winzou_state_machine** — the same change applies to
`config/app/winzou_state_machine/sylius_order_payment.yml`.
**Custom `OrderPaymentStateResolver`** — if you have overridden `getTargetTransition()`, add
handling for the recovery case: when only `cart` or `new` payments exist on the order (all previous
payments are cancelled/failed), return `OrderPaymentTransitions::TRANSITION_REQUEST_PAYMENT`.
---
# UPGRADE FROM `2.1` TO `2.2`
## Telemetry

View file

@ -0,0 +1,22 @@
@managing_orders
Feature: Recovering order payment state after an authorized payment is cancelled
In order to allow the customer to retry payment after an authorization is voided by the gateway
As an Administrator
I want the order payment state to return to "Awaiting payment" when an authorized payment is cancelled
Background:
Given the store operates on a single channel in "United States"
And the store ships everywhere for Free
And the store has a product "PHP T-Shirt"
And the store allows paying with "Cash on Delivery"
And the payment method "Cash on Delivery" requires authorization before capturing
And there is a "authorized" "#00000001" order with "PHP T-Shirt" product
And I am logged in as an administrator
@api @ui
Scenario: Order payment state recovers to awaiting payment after the authorized payment is cancelled by the gateway
When the payment of order "#00000001" is cancelled by the gateway
And I browse orders
Then this order should have order payment state "Awaiting payment"
When I view the summary of the order "#00000001"
Then there should be only 2 payments

View file

@ -15,6 +15,7 @@ namespace Sylius\Behat\Context\Setup;
use Behat\Behat\Context\Context;
use Behat\Step\Given;
use Behat\Step\When;
use Doctrine\Persistence\ObjectManager;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Behat\Service\SharedStorageInterface;
@ -696,6 +697,14 @@ final readonly class OrderContext implements Context
$this->objectManager->flush();
}
#[When('the payment of order :order is cancelled by the gateway')]
public function thePaymentOfOrderIsCancelledByTheGateway(OrderInterface $order): void
{
$this->applyPaymentTransitionOnOrder($order, PaymentTransitions::TRANSITION_CANCEL);
$this->objectManager->flush();
}
/**
* @Given /^(this order) has been refunded$/
* @Given the customer has refunded the order with number :order
@ -1124,6 +1133,7 @@ final readonly class OrderContext implements Context
$transitions = [
'new' => [],
'processing' => [PaymentTransitions::TRANSITION_PROCESS],
'authorized' => [PaymentTransitions::TRANSITION_AUTHORIZE],
'completed' => [PaymentTransitions::TRANSITION_COMPLETE],
'cancelled' => [PaymentTransitions::TRANSITION_CANCEL],
'failed' => [PaymentTransitions::TRANSITION_FAIL],

View file

@ -16,7 +16,7 @@ winzou_state_machine:
refunded: ~
transitions:
request_payment:
from: [cart]
from: [cart, authorized, partially_authorized]
to: awaiting_payment
partially_authorize:
from: [awaiting_payment, partially_authorized]

View file

@ -47,3 +47,8 @@ winzou_state_machine:
do: ["@sylius.state_resolver.order_payment", "resolve"]
args: ["object.getOrder()"]
priority: -100
sylius_resolve_state_after_cancel_or_fail:
on: ["cancel", "fail"]
do: ["@sylius.state_resolver.order_payment", "resolve"]
args: ["object.getOrder()"]
priority: -100

View file

@ -20,7 +20,10 @@ framework:
- !php/const Sylius\Component\Core\OrderPaymentStates::STATE_REFUNDED
transitions:
!php/const Sylius\Component\Core\OrderPaymentTransitions::TRANSITION_REQUEST_PAYMENT:
from: !php/const Sylius\Component\Core\OrderPaymentStates::STATE_CART
from:
- !php/const Sylius\Component\Core\OrderPaymentStates::STATE_CART
- !php/const Sylius\Component\Core\OrderPaymentStates::STATE_AUTHORIZED
- !php/const Sylius\Component\Core\OrderPaymentStates::STATE_PARTIALLY_AUTHORIZED
to: !php/const Sylius\Component\Core\OrderPaymentStates::STATE_AWAITING_PAYMENT
!php/const Sylius\Component\Core\OrderPaymentTransitions::TRANSITION_PARTIALLY_AUTHORIZE:
from:

View file

@ -29,6 +29,8 @@
<tag name="kernel.event_listener" event="workflow.sylius_payment.completed.process" priority="100" />
<tag name="kernel.event_listener" event="workflow.sylius_payment.completed.refund" priority="100" />
<tag name="kernel.event_listener" event="workflow.sylius_payment.completed.authorize" priority="100" />
<tag name="kernel.event_listener" event="workflow.sylius_payment.completed.cancel" priority="50" />
<tag name="kernel.event_listener" event="workflow.sylius_payment.completed.fail" priority="50" />
</service>
</services>
</container>

View file

@ -137,6 +137,7 @@ final class OrderPaymentWorkflowTest extends KernelTestCase
{
yield ['cancel', 'cancelled'];
yield ['pay', 'paid'];
yield ['request_payment', 'awaiting_payment'];
}
public static function availableTransitionsForPartiallyPaidState(): iterable

View file

@ -17,12 +17,15 @@ use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Bundle\CoreBundle\EventListener\Workflow\Payment\ProcessOrderListener;
use Sylius\Bundle\CoreBundle\EventListener\Workflow\Payment\ResolveOrderPaymentStateListener;
use Sylius\Component\Core\Model\Order;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\Payment;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Payment\PaymentTransitions;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
final class PaymentWorkflowTest extends KernelTestCase
{
@ -74,6 +77,48 @@ final class PaymentWorkflowTest extends KernelTestCase
yield [PaymentInterface::STATE_COMPLETED, PaymentTransitions::TRANSITION_REFUND, PaymentInterface::STATE_REFUNDED];
}
#[Test]
public function it_fires_resolve_order_payment_state_listener_after_process_order_listener_on_cancel(): void
{
/** @var EventDispatcherInterface $dispatcher */
$dispatcher = self::getContainer()->get('event_dispatcher');
$cancelListeners = $dispatcher->getListeners('workflow.sylius_payment.completed.cancel');
$listenerClasses = array_map(
static fn (mixed $listener): string => is_array($listener) ? get_class($listener[0]) : get_class($listener),
$cancelListeners,
);
$processOrderIndex = array_search(ProcessOrderListener::class, $listenerClasses, true);
$resolveStateIndex = array_search(ResolveOrderPaymentStateListener::class, $listenerClasses, true);
$this->assertNotFalse($processOrderIndex, 'ProcessOrderListener must be subscribed to completed.cancel');
$this->assertNotFalse($resolveStateIndex, 'ResolveOrderPaymentStateListener must be subscribed to completed.cancel');
$this->assertGreaterThan($processOrderIndex, $resolveStateIndex, 'ResolveOrderPaymentStateListener must run after ProcessOrderListener (higher index = lower priority)');
}
#[Test]
public function it_fires_resolve_order_payment_state_listener_after_process_order_listener_on_fail(): void
{
/** @var EventDispatcherInterface $dispatcher */
$dispatcher = self::getContainer()->get('event_dispatcher');
$failListeners = $dispatcher->getListeners('workflow.sylius_payment.completed.fail');
$listenerClasses = array_map(
static fn (mixed $listener): string => is_array($listener) ? get_class($listener[0]) : get_class($listener),
$failListeners,
);
$processOrderIndex = array_search(ProcessOrderListener::class, $listenerClasses, true);
$resolveStateIndex = array_search(ResolveOrderPaymentStateListener::class, $listenerClasses, true);
$this->assertNotFalse($processOrderIndex, 'ProcessOrderListener must be subscribed to completed.fail');
$this->assertNotFalse($resolveStateIndex, 'ResolveOrderPaymentStateListener must be subscribed to completed.fail');
$this->assertGreaterThan($processOrderIndex, $resolveStateIndex, 'ResolveOrderPaymentStateListener must run after ProcessOrderListener (higher index = lower priority)');
}
private function getStateMachine(): StateMachineInterface
{
return self::getContainer()->get('sylius_abstraction.state_machine.adapter.symfony_workflow');

View file

@ -105,6 +105,14 @@ final class OrderPaymentStateResolver implements StateResolverInterface
return OrderPaymentTransitions::TRANSITION_REQUEST_PAYMENT;
}
$hasReplayablePayment =
!$this->getPaymentsWithState($order, PaymentInterface::STATE_CART)->isEmpty()
|| !$this->getPaymentsWithState($order, PaymentInterface::STATE_NEW)->isEmpty();
if ($hasReplayablePayment) {
return OrderPaymentTransitions::TRANSITION_REQUEST_PAYMENT;
}
return null;
}

View file

@ -299,4 +299,48 @@ final class OrderPaymentStateResolverTest extends TestCase
$this->stateResolver->resolve($this->order);
}
public function testShouldMarkOrderAsAwaitingPaymentWhenCancelledPaymentExistsAlongsideNewCartPayment(): void
{
$this->firstPayment->expects($this->exactly(5))->method('getState')->willReturn(PaymentInterface::STATE_CANCELLED);
$this->secondPayment->expects($this->exactly(5))->method('getState')->willReturn(PaymentInterface::STATE_CART);
$this->order
->expects($this->exactly(6))
->method('getPayments')
->willReturn(new ArrayCollection([$this->firstPayment, $this->secondPayment]));
$this->order->expects($this->exactly(3))->method('getTotal')->willReturn(10000);
$this->stateMachine
->expects($this->once())
->method('can')
->with($this->order, OrderPaymentTransitions::GRAPH, OrderPaymentTransitions::TRANSITION_REQUEST_PAYMENT)
->willReturn(true);
$this->stateMachine
->expects($this->once())
->method('apply')
->with($this->order, OrderPaymentTransitions::GRAPH, OrderPaymentTransitions::TRANSITION_REQUEST_PAYMENT);
$this->stateResolver->resolve($this->order);
}
public function testShouldMarkOrderAsAwaitingPaymentWhenFailedPaymentExistsAlongsideNewCartPayment(): void
{
$this->firstPayment->expects($this->exactly(5))->method('getState')->willReturn(PaymentInterface::STATE_FAILED);
$this->secondPayment->expects($this->exactly(5))->method('getState')->willReturn(PaymentInterface::STATE_CART);
$this->order
->expects($this->exactly(6))
->method('getPayments')
->willReturn(new ArrayCollection([$this->firstPayment, $this->secondPayment]));
$this->order->expects($this->exactly(3))->method('getTotal')->willReturn(10000);
$this->stateMachine
->expects($this->once())
->method('can')
->with($this->order, OrderPaymentTransitions::GRAPH, OrderPaymentTransitions::TRANSITION_REQUEST_PAYMENT)
->willReturn(true);
$this->stateMachine
->expects($this->once())
->method('apply')
->with($this->order, OrderPaymentTransitions::GRAPH, OrderPaymentTransitions::TRANSITION_REQUEST_PAYMENT);
$this->stateResolver->resolve($this->order);
}
}