mirror of
https://github.com/Sylius/Sylius.git
synced 2026-07-05 20:57:12 +00:00
Merge 82628561d9 into 82965303e1
This commit is contained in:
commit
1c8f15b11e
11 changed files with 180 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue