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.
|
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.
|
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`
|
# UPGRADE FROM `2.1` TO `2.2`
|
||||||
|
|
||||||
## Telemetry
|
## 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\Behat\Context\Context;
|
||||||
use Behat\Step\Given;
|
use Behat\Step\Given;
|
||||||
|
use Behat\Step\When;
|
||||||
use Doctrine\Persistence\ObjectManager;
|
use Doctrine\Persistence\ObjectManager;
|
||||||
use Sylius\Abstraction\StateMachine\StateMachineInterface;
|
use Sylius\Abstraction\StateMachine\StateMachineInterface;
|
||||||
use Sylius\Behat\Service\SharedStorageInterface;
|
use Sylius\Behat\Service\SharedStorageInterface;
|
||||||
|
|
@ -696,6 +697,14 @@ final readonly class OrderContext implements Context
|
||||||
$this->objectManager->flush();
|
$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 /^(this order) has been refunded$/
|
||||||
* @Given the customer has refunded the order with number :order
|
* @Given the customer has refunded the order with number :order
|
||||||
|
|
@ -1124,6 +1133,7 @@ final readonly class OrderContext implements Context
|
||||||
$transitions = [
|
$transitions = [
|
||||||
'new' => [],
|
'new' => [],
|
||||||
'processing' => [PaymentTransitions::TRANSITION_PROCESS],
|
'processing' => [PaymentTransitions::TRANSITION_PROCESS],
|
||||||
|
'authorized' => [PaymentTransitions::TRANSITION_AUTHORIZE],
|
||||||
'completed' => [PaymentTransitions::TRANSITION_COMPLETE],
|
'completed' => [PaymentTransitions::TRANSITION_COMPLETE],
|
||||||
'cancelled' => [PaymentTransitions::TRANSITION_CANCEL],
|
'cancelled' => [PaymentTransitions::TRANSITION_CANCEL],
|
||||||
'failed' => [PaymentTransitions::TRANSITION_FAIL],
|
'failed' => [PaymentTransitions::TRANSITION_FAIL],
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ winzou_state_machine:
|
||||||
refunded: ~
|
refunded: ~
|
||||||
transitions:
|
transitions:
|
||||||
request_payment:
|
request_payment:
|
||||||
from: [cart]
|
from: [cart, authorized, partially_authorized]
|
||||||
to: awaiting_payment
|
to: awaiting_payment
|
||||||
partially_authorize:
|
partially_authorize:
|
||||||
from: [awaiting_payment, partially_authorized]
|
from: [awaiting_payment, partially_authorized]
|
||||||
|
|
|
||||||
|
|
@ -47,3 +47,8 @@ winzou_state_machine:
|
||||||
do: ["@sylius.state_resolver.order_payment", "resolve"]
|
do: ["@sylius.state_resolver.order_payment", "resolve"]
|
||||||
args: ["object.getOrder()"]
|
args: ["object.getOrder()"]
|
||||||
priority: -100
|
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
|
- !php/const Sylius\Component\Core\OrderPaymentStates::STATE_REFUNDED
|
||||||
transitions:
|
transitions:
|
||||||
!php/const Sylius\Component\Core\OrderPaymentTransitions::TRANSITION_REQUEST_PAYMENT:
|
!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
|
to: !php/const Sylius\Component\Core\OrderPaymentStates::STATE_AWAITING_PAYMENT
|
||||||
!php/const Sylius\Component\Core\OrderPaymentTransitions::TRANSITION_PARTIALLY_AUTHORIZE:
|
!php/const Sylius\Component\Core\OrderPaymentTransitions::TRANSITION_PARTIALLY_AUTHORIZE:
|
||||||
from:
|
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.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.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.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>
|
</service>
|
||||||
</services>
|
</services>
|
||||||
</container>
|
</container>
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,7 @@ final class OrderPaymentWorkflowTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
yield ['cancel', 'cancelled'];
|
yield ['cancel', 'cancelled'];
|
||||||
yield ['pay', 'paid'];
|
yield ['pay', 'paid'];
|
||||||
|
yield ['request_payment', 'awaiting_payment'];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function availableTransitionsForPartiallyPaidState(): iterable
|
public static function availableTransitionsForPartiallyPaidState(): iterable
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,15 @@ use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
use Sylius\Abstraction\StateMachine\StateMachineInterface;
|
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\Order;
|
||||||
use Sylius\Component\Core\Model\OrderInterface;
|
use Sylius\Component\Core\Model\OrderInterface;
|
||||||
use Sylius\Component\Core\Model\Payment;
|
use Sylius\Component\Core\Model\Payment;
|
||||||
use Sylius\Component\Core\Model\PaymentInterface;
|
use Sylius\Component\Core\Model\PaymentInterface;
|
||||||
use Sylius\Component\Payment\PaymentTransitions;
|
use Sylius\Component\Payment\PaymentTransitions;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||||
|
|
||||||
final class PaymentWorkflowTest extends KernelTestCase
|
final class PaymentWorkflowTest extends KernelTestCase
|
||||||
{
|
{
|
||||||
|
|
@ -74,6 +77,48 @@ final class PaymentWorkflowTest extends KernelTestCase
|
||||||
yield [PaymentInterface::STATE_COMPLETED, PaymentTransitions::TRANSITION_REFUND, PaymentInterface::STATE_REFUNDED];
|
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
|
private function getStateMachine(): StateMachineInterface
|
||||||
{
|
{
|
||||||
return self::getContainer()->get('sylius_abstraction.state_machine.adapter.symfony_workflow');
|
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;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -299,4 +299,48 @@ final class OrderPaymentStateResolverTest extends TestCase
|
||||||
|
|
||||||
$this->stateResolver->resolve($this->order);
|
$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