Skip to content

Commit 2c0981a

Browse files
committed
Use state machine in PaymentWebhookController
1 parent 45b0156 commit 2c0981a

3 files changed

Lines changed: 272 additions & 6 deletions

File tree

config/services/controller/shop.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@
6464
<argument type="service" id="sylius.repository.order" />
6565
<argument type="service" id="sylius.repository.payment" />
6666
<argument type="service" id="sylius_mollie.logger.mollie_logger_action" />
67+
<argument type="service" id="sylius_abstraction.state_machine"/>
68+
<argument type="service" id="doctrine.orm.default_entity_manager"/>
6769
</service>
6870

6971
<service id="sylius_mollie.controller.shop.page_redirect" class="Sylius\MolliePlugin\Controller\Shop\PageRedirectController">

src/Controller/Shop/PaymentWebhookController.php

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@
1313

1414
namespace Sylius\MolliePlugin\Controller\Shop;
1515

16+
use Doctrine\ORM\EntityManagerInterface;
1617
use Mollie\Api\Exceptions\ApiException;
1718
use Mollie\Api\Resources\Payment;
1819
use Mollie\Api\Types\PaymentStatus;
20+
use Sylius\Abstraction\StateMachine\StateMachineInterface;
1921
use Sylius\Component\Core\Repository\PaymentRepositoryInterface;
2022
use Sylius\Component\Order\Repository\OrderRepositoryInterface;
2123
use Sylius\Component\Payment\Model\PaymentInterface;
24+
use Sylius\Component\Payment\PaymentTransitions;
2225
use Sylius\MolliePlugin\Client\MollieApiClient;
2326
use Sylius\MolliePlugin\Entity\OrderInterface;
2427
use Sylius\MolliePlugin\Logger\MollieLoggerActionInterface;
@@ -33,8 +36,10 @@ public function __construct(
3336
private readonly MollieApiClient $mollieApiClient,
3437
private readonly MollieApiClientKeyResolverInterface $apiClientKeyResolver,
3538
private readonly OrderRepositoryInterface $orderRepository,
36-
private readonly PaymentRepositoryInterface $paymentRepository,
39+
private readonly ?PaymentRepositoryInterface $paymentRepository = null,
3740
private readonly ?MollieLoggerActionInterface $logger = null,
41+
private readonly ?StateMachineInterface $stateMachine = null,
42+
private readonly ?EntityManagerInterface $entityManager = null,
3843
) {
3944
if (null === $this->logger) {
4045
trigger_deprecation(
@@ -44,6 +49,26 @@ public function __construct(
4449
self::class,
4550
);
4651
}
52+
53+
if (null === $this->stateMachine || null === $this->entityManager) {
54+
trigger_deprecation(
55+
'sylius/mollie-plugin',
56+
'3.3',
57+
'Not passing StateMachineInterface and EntityManagerInterface to %s is deprecated and will be required from 4.0. ' .
58+
'State changes currently fall back to direct Payment::setState() which bypasses state machine guards and after-callbacks (e.g. auto-creation of a new payment on fail/cancel).',
59+
self::class,
60+
);
61+
}
62+
63+
if (null !== $this->paymentRepository) {
64+
trigger_deprecation(
65+
'sylius/mollie-plugin',
66+
'3.3',
67+
'Passing PaymentRepositoryInterface to %s is deprecated and will be removed in 4.0. ' .
68+
'It is only used by the setState() fallback path which is itself deprecated — prefer StateMachineInterface.',
69+
self::class,
70+
);
71+
}
4772
}
4873

4974
public function __invoke(Request $request): Response
@@ -71,24 +96,64 @@ public function __invoke(Request $request): Response
7196
}
7297

7398
$payment = $order->getLastPayment();
74-
$status = $this->getStatus($molliePayment);
99+
if (null === $payment) {
100+
return new JsonResponse(Response::HTTP_OK);
101+
}
102+
103+
if (null !== $this->stateMachine && null !== $this->entityManager) {
104+
$this->applyTransition($payment, $molliePayment->status);
105+
} else {
106+
$this->applyLegacyState($payment, $molliePayment);
107+
}
108+
109+
return new JsonResponse(Response::HTTP_OK);
110+
}
111+
112+
private function applyTransition(PaymentInterface $payment, string $mollieStatus): void
113+
{
114+
$transition = $this->mapMolliePaymentStatusToTransition($mollieStatus);
115+
if (null === $transition) {
116+
return;
117+
}
118+
119+
if (!$this->stateMachine->can($payment, PaymentTransitions::GRAPH, $transition)) {
120+
return;
121+
}
122+
123+
$this->stateMachine->apply($payment, PaymentTransitions::GRAPH, $transition);
124+
$this->entityManager->flush();
125+
}
126+
127+
private function applyLegacyState(PaymentInterface $payment, Payment $molliePayment): void
128+
{
129+
$status = $this->mapMolliePaymentStatusToState($molliePayment);
75130

76131
if ($payment->getState() !== $status && PaymentInterface::STATE_UNKNOWN !== $status) {
77132
$payment->setState($status);
78133
$this->paymentRepository->add($payment);
79134
}
135+
}
80136

81-
return new JsonResponse(Response::HTTP_OK);
137+
private function mapMolliePaymentStatusToTransition(string $status): ?string
138+
{
139+
return match ($status) {
140+
PaymentStatus::STATUS_PENDING, PaymentStatus::STATUS_OPEN => PaymentTransitions::TRANSITION_PROCESS,
141+
PaymentStatus::STATUS_AUTHORIZED => PaymentTransitions::TRANSITION_AUTHORIZE,
142+
PaymentStatus::STATUS_PAID => PaymentTransitions::TRANSITION_COMPLETE,
143+
PaymentStatus::STATUS_CANCELED, PaymentStatus::STATUS_EXPIRED => PaymentTransitions::TRANSITION_CANCEL,
144+
PaymentStatus::STATUS_FAILED => PaymentTransitions::TRANSITION_FAIL,
145+
default => null,
146+
};
82147
}
83148

84-
private function getStatus(Payment $molliePayment): string
149+
private function mapMolliePaymentStatusToState(Payment $molliePayment): string
85150
{
86151
return match ($molliePayment->status) {
87152
PaymentStatus::STATUS_PENDING, PaymentStatus::STATUS_OPEN => PaymentInterface::STATE_PROCESSING,
88153
PaymentStatus::STATUS_AUTHORIZED => PaymentInterface::STATE_AUTHORIZED,
89154
PaymentStatus::STATUS_PAID => PaymentInterface::STATE_COMPLETED,
90-
PaymentStatus::STATUS_CANCELED => PaymentInterface::STATE_CANCELLED,
91-
PaymentStatus::STATUS_EXPIRED, PaymentStatus::STATUS_FAILED => PaymentInterface::STATE_FAILED,
155+
PaymentStatus::STATUS_CANCELED, PaymentStatus::STATUS_EXPIRED => PaymentInterface::STATE_CANCELLED,
156+
PaymentStatus::STATUS_FAILED => PaymentInterface::STATE_FAILED,
92157
default => PaymentInterface::STATE_UNKNOWN,
93158
};
94159
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Sylius Mollie Plugin package.
5+
*
6+
* (c) Sylius Sp. z o.o.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Tests\Sylius\MolliePlugin\Unit\Controller\Shop;
15+
16+
use Doctrine\ORM\EntityManagerInterface;
17+
use Mollie\Api\Endpoints\PaymentEndpoint;
18+
use Mollie\Api\Resources\Payment;
19+
use Mollie\Api\Types\PaymentStatus;
20+
use PHPUnit\Framework\MockObject\MockObject;
21+
use PHPUnit\Framework\TestCase;
22+
use Sylius\Abstraction\StateMachine\StateMachineInterface;
23+
use Sylius\Component\Core\Model\PaymentInterface as CorePaymentInterface;
24+
use Sylius\Component\Core\Repository\PaymentRepositoryInterface;
25+
use Sylius\Component\Order\Repository\OrderRepositoryInterface;
26+
use Sylius\Component\Payment\Model\PaymentInterface;
27+
use Sylius\Component\Payment\PaymentTransitions;
28+
use Sylius\MolliePlugin\Client\MollieApiClient;
29+
use Sylius\MolliePlugin\Controller\Shop\PaymentWebhookController;
30+
use Sylius\MolliePlugin\Entity\OrderInterface;
31+
use Sylius\MolliePlugin\Logger\MollieLoggerActionInterface;
32+
use Sylius\MolliePlugin\Resolver\MollieApiClientKeyResolverInterface;
33+
use Symfony\Component\HttpFoundation\Request;
34+
use Symfony\Component\HttpFoundation\Response;
35+
36+
final class PaymentWebhookControllerTest extends TestCase
37+
{
38+
private MockObject&MollieApiClient $mollieApiClient;
39+
40+
private MockObject&MollieApiClientKeyResolverInterface $apiClientKeyResolver;
41+
42+
private MockObject&OrderRepositoryInterface $orderRepository;
43+
44+
private MockObject&PaymentRepositoryInterface $paymentRepository;
45+
46+
private MockObject&MollieLoggerActionInterface $logger;
47+
48+
private MockObject&StateMachineInterface $stateMachine;
49+
50+
private EntityManagerInterface&MockObject $entityManager;
51+
52+
private MockObject&PaymentEndpoint $paymentEndpoint;
53+
54+
protected function setUp(): void
55+
{
56+
$this->mollieApiClient = $this->createMock(MollieApiClient::class);
57+
$this->apiClientKeyResolver = $this->createMock(MollieApiClientKeyResolverInterface::class);
58+
$this->orderRepository = $this->createMock(OrderRepositoryInterface::class);
59+
$this->paymentRepository = $this->createMock(PaymentRepositoryInterface::class);
60+
$this->logger = $this->createMock(MollieLoggerActionInterface::class);
61+
$this->stateMachine = $this->createMock(StateMachineInterface::class);
62+
$this->entityManager = $this->createMock(EntityManagerInterface::class);
63+
64+
$this->paymentEndpoint = $this->createMock(PaymentEndpoint::class);
65+
$this->mollieApiClient->payments = $this->paymentEndpoint;
66+
67+
$this->mollieApiClient->method('getApiKey')->willReturn('test_key');
68+
$this->apiClientKeyResolver->method('getClientWithKey')->willReturn($this->mollieApiClient);
69+
}
70+
71+
public function testItReturnsOkWhenOrderIsNotFound(): void
72+
{
73+
$controller = $this->createController();
74+
75+
$request = new Request(['id' => 'tr_abc', 'orderId' => '42']);
76+
$this->paymentEndpoint->expects(self::once())->method('get')->willReturn($this->makeMolliePayment(PaymentStatus::STATUS_PAID));
77+
$this->orderRepository->expects(self::once())->method('findOneBy')->with(['id' => '42'])->willReturn(null);
78+
$this->stateMachine->expects(self::never())->method('apply');
79+
80+
$response = $controller->__invoke($request);
81+
82+
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
83+
}
84+
85+
public function testItReturnsOkWhenOrderHasNoPayment(): void
86+
{
87+
$controller = $this->createController();
88+
89+
$request = new Request(['id' => 'tr_abc', 'orderId' => '42']);
90+
$order = $this->createMock(OrderInterface::class);
91+
$order->method('getLastPayment')->willReturn(null);
92+
$this->paymentEndpoint->expects(self::once())->method('get')->willReturn($this->makeMolliePayment(PaymentStatus::STATUS_PAID));
93+
$this->orderRepository->method('findOneBy')->willReturn($order);
94+
$this->stateMachine->expects(self::never())->method('apply');
95+
96+
$response = $controller->__invoke($request);
97+
98+
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
99+
}
100+
101+
public function testItAppliesCompleteTransitionForPaidMolliePayment(): void
102+
{
103+
$controller = $this->createController();
104+
105+
$request = new Request(['id' => 'tr_abc', 'orderId' => '42']);
106+
$payment = $this->createMock(CorePaymentInterface::class);
107+
$order = $this->createMock(OrderInterface::class);
108+
$order->method('getLastPayment')->willReturn($payment);
109+
110+
$this->paymentEndpoint->method('get')->willReturn($this->makeMolliePayment(PaymentStatus::STATUS_PAID));
111+
$this->orderRepository->method('findOneBy')->willReturn($order);
112+
113+
$this->stateMachine->expects(self::once())
114+
->method('can')
115+
->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_COMPLETE)
116+
->willReturn(true);
117+
$this->stateMachine->expects(self::once())
118+
->method('apply')
119+
->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_COMPLETE);
120+
$this->entityManager->expects(self::once())->method('flush');
121+
122+
$response = $controller->__invoke($request);
123+
124+
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
125+
}
126+
127+
public function testItSkipsTransitionWhenStateMachineRejectsIt(): void
128+
{
129+
$controller = $this->createController();
130+
131+
$request = new Request(['id' => 'tr_abc', 'orderId' => '42']);
132+
$payment = $this->createMock(CorePaymentInterface::class);
133+
$order = $this->createMock(OrderInterface::class);
134+
$order->method('getLastPayment')->willReturn($payment);
135+
136+
$this->paymentEndpoint->method('get')->willReturn($this->makeMolliePayment(PaymentStatus::STATUS_PAID));
137+
$this->orderRepository->method('findOneBy')->willReturn($order);
138+
139+
$this->stateMachine->method('can')->willReturn(false);
140+
$this->stateMachine->expects(self::never())->method('apply');
141+
$this->entityManager->expects(self::never())->method('flush');
142+
143+
$response = $controller->__invoke($request);
144+
145+
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
146+
}
147+
148+
/**
149+
* @group legacy
150+
*/
151+
public function testItFallsBackToSetStateWhenStateMachineIsNotProvided(): void
152+
{
153+
$controller = new PaymentWebhookController(
154+
$this->mollieApiClient,
155+
$this->apiClientKeyResolver,
156+
$this->orderRepository,
157+
$this->paymentRepository,
158+
$this->logger,
159+
);
160+
161+
$request = new Request(['id' => 'tr_abc', 'orderId' => '42']);
162+
$payment = $this->createMock(CorePaymentInterface::class);
163+
$payment->method('getState')->willReturn(PaymentInterface::STATE_NEW);
164+
$payment->expects(self::once())->method('setState')->with(PaymentInterface::STATE_COMPLETED);
165+
166+
$order = $this->createMock(OrderInterface::class);
167+
$order->method('getLastPayment')->willReturn($payment);
168+
169+
$this->paymentEndpoint->method('get')->willReturn($this->makeMolliePayment(PaymentStatus::STATUS_PAID));
170+
$this->orderRepository->method('findOneBy')->willReturn($order);
171+
$this->paymentRepository->expects(self::once())->method('add')->with($payment);
172+
173+
$response = $controller->__invoke($request);
174+
175+
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
176+
}
177+
178+
private function createController(): PaymentWebhookController
179+
{
180+
return new PaymentWebhookController(
181+
$this->mollieApiClient,
182+
$this->apiClientKeyResolver,
183+
$this->orderRepository,
184+
$this->paymentRepository,
185+
$this->logger,
186+
$this->stateMachine,
187+
$this->entityManager,
188+
);
189+
}
190+
191+
private function makeMolliePayment(string $status): Payment
192+
{
193+
$payment = new Payment($this->mollieApiClient);
194+
$payment->id = 'tr_abc';
195+
$payment->status = $status;
196+
197+
return $payment;
198+
}
199+
}

0 commit comments

Comments
 (0)