Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config/services/controller/shop.xml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
<argument type="service" id="sylius.repository.order" />
<argument type="service" id="sylius.repository.payment" />
<argument type="service" id="sylius_mollie.logger.mollie_logger_action" />
<argument type="service" id="sylius_abstraction.state_machine"/>
<argument type="service" id="doctrine.orm.default_entity_manager"/>
</service>

<service id="sylius_mollie.controller.shop.page_redirect" class="Sylius\MolliePlugin\Controller\Shop\PageRedirectController">
Expand Down
84 changes: 78 additions & 6 deletions src/Controller/Shop/PaymentWebhookController.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@

namespace Sylius\MolliePlugin\Controller\Shop;

use Doctrine\ORM\EntityManagerInterface;
use Mollie\Api\Exceptions\ApiException;
use Mollie\Api\Resources\Payment;
use Mollie\Api\Types\PaymentStatus;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Component\Core\Repository\PaymentRepositoryInterface;
use Sylius\Component\Order\Repository\OrderRepositoryInterface;
use Sylius\Component\Payment\Model\PaymentInterface;
use Sylius\Component\Payment\PaymentTransitions;
use Sylius\MolliePlugin\Client\MollieApiClient;
use Sylius\MolliePlugin\Entity\OrderInterface;
use Sylius\MolliePlugin\Logger\MollieLoggerActionInterface;
Expand All @@ -33,8 +36,10 @@ public function __construct(
private readonly MollieApiClient $mollieApiClient,
private readonly MollieApiClientKeyResolverInterface $apiClientKeyResolver,
private readonly OrderRepositoryInterface $orderRepository,
private readonly PaymentRepositoryInterface $paymentRepository,
private readonly ?PaymentRepositoryInterface $paymentRepository = null,
private readonly ?MollieLoggerActionInterface $logger = null,
private readonly ?StateMachineInterface $stateMachine = null,
private readonly ?EntityManagerInterface $entityManager = null,
) {
if (null === $this->logger) {
trigger_deprecation(
Expand All @@ -44,8 +49,35 @@ public function __construct(
self::class,
);
}

if (null === $this->stateMachine || null === $this->entityManager) {
trigger_deprecation(
'sylius/mollie-plugin',
'3.3',
'Not passing StateMachineInterface and EntityManagerInterface to %s is deprecated and will be required from 4.0. ' .
'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).',
self::class,
);
}

if (null !== $this->paymentRepository) {
trigger_deprecation(
'sylius/mollie-plugin',
'3.3',
'Passing PaymentRepositoryInterface to %s is deprecated and will be removed in 4.0. ' .
'It is only used by the setState() fallback path which is itself deprecated — prefer StateMachineInterface.',
self::class,
);
}
}

/**
* Every exit path returns 200 because Mollie keeps retrying the webhook on any
* non-2xx response — "for any other response we keep trying"
* (https://docs.mollie.com/docs/accepting-payments). When we cannot meaningfully
* act (unknown Mollie id, unknown order, order has no payment, unsupported
* status), a retry would not change the outcome, so we acknowledge and move on.
*/
public function __invoke(Request $request): Response
{
$this->mollieApiClient->setApiKey($this->apiClientKeyResolver->getClientWithKey()->getApiKey());
Expand All @@ -71,24 +103,64 @@ public function __invoke(Request $request): Response
}

$payment = $order->getLastPayment();
$status = $this->getStatus($molliePayment);
if (null === $payment) {
return new JsonResponse(Response::HTTP_OK);
}

if (null !== $this->stateMachine && null !== $this->entityManager) {
$this->applyTransition($payment, $molliePayment->status);
} else {
$this->applyLegacyState($payment, $molliePayment);
}

return new JsonResponse(Response::HTTP_OK);
}

private function applyTransition(PaymentInterface $payment, string $mollieStatus): void
{
$transition = $this->mapMolliePaymentStatusToTransition($mollieStatus);
if (null === $transition) {
return;
}

if (!$this->stateMachine->can($payment, PaymentTransitions::GRAPH, $transition)) {
return;
}

$this->stateMachine->apply($payment, PaymentTransitions::GRAPH, $transition);
$this->entityManager->flush();
}

private function applyLegacyState(PaymentInterface $payment, Payment $molliePayment): void
{
$status = $this->mapMolliePaymentStatusToState($molliePayment);

if ($payment->getState() !== $status && PaymentInterface::STATE_UNKNOWN !== $status) {
$payment->setState($status);
$this->paymentRepository->add($payment);
}
}

return new JsonResponse(Response::HTTP_OK);
private function mapMolliePaymentStatusToTransition(string $status): ?string
{
return match ($status) {
PaymentStatus::STATUS_PENDING, PaymentStatus::STATUS_OPEN => PaymentTransitions::TRANSITION_PROCESS,
PaymentStatus::STATUS_AUTHORIZED => PaymentTransitions::TRANSITION_AUTHORIZE,
PaymentStatus::STATUS_PAID => PaymentTransitions::TRANSITION_COMPLETE,
PaymentStatus::STATUS_CANCELED, PaymentStatus::STATUS_EXPIRED => PaymentTransitions::TRANSITION_CANCEL,
PaymentStatus::STATUS_FAILED => PaymentTransitions::TRANSITION_FAIL,
default => null,
};
}

private function getStatus(Payment $molliePayment): string
private function mapMolliePaymentStatusToState(Payment $molliePayment): string
{
return match ($molliePayment->status) {
PaymentStatus::STATUS_PENDING, PaymentStatus::STATUS_OPEN => PaymentInterface::STATE_PROCESSING,
PaymentStatus::STATUS_AUTHORIZED => PaymentInterface::STATE_AUTHORIZED,
PaymentStatus::STATUS_PAID => PaymentInterface::STATE_COMPLETED,
PaymentStatus::STATUS_CANCELED => PaymentInterface::STATE_CANCELLED,
PaymentStatus::STATUS_EXPIRED, PaymentStatus::STATUS_FAILED => PaymentInterface::STATE_FAILED,
PaymentStatus::STATUS_CANCELED, PaymentStatus::STATUS_EXPIRED => PaymentInterface::STATE_CANCELLED,
PaymentStatus::STATUS_FAILED => PaymentInterface::STATE_FAILED,
default => PaymentInterface::STATE_UNKNOWN,
};
}
Expand Down
199 changes: 199 additions & 0 deletions tests/Unit/Controller/Shop/PaymentWebhookControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
<?php

/*
* This file is part of the Sylius Mollie Plugin 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 Tests\Sylius\MolliePlugin\Unit\Controller\Shop;

use Doctrine\ORM\EntityManagerInterface;
use Mollie\Api\Endpoints\PaymentEndpoint;
use Mollie\Api\Resources\Payment;
use Mollie\Api\Types\PaymentStatus;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Component\Core\Model\PaymentInterface as CorePaymentInterface;
use Sylius\Component\Core\Repository\PaymentRepositoryInterface;
use Sylius\Component\Order\Repository\OrderRepositoryInterface;
use Sylius\Component\Payment\Model\PaymentInterface;
use Sylius\Component\Payment\PaymentTransitions;
use Sylius\MolliePlugin\Client\MollieApiClient;
use Sylius\MolliePlugin\Controller\Shop\PaymentWebhookController;
use Sylius\MolliePlugin\Entity\OrderInterface;
use Sylius\MolliePlugin\Logger\MollieLoggerActionInterface;
use Sylius\MolliePlugin\Resolver\MollieApiClientKeyResolverInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

final class PaymentWebhookControllerTest extends TestCase
{
private MockObject&MollieApiClient $mollieApiClient;

private MockObject&MollieApiClientKeyResolverInterface $apiClientKeyResolver;

private MockObject&OrderRepositoryInterface $orderRepository;

private MockObject&PaymentRepositoryInterface $paymentRepository;

private MockObject&MollieLoggerActionInterface $logger;

private MockObject&StateMachineInterface $stateMachine;

private EntityManagerInterface&MockObject $entityManager;

private MockObject&PaymentEndpoint $paymentEndpoint;

protected function setUp(): void
{
$this->mollieApiClient = $this->createMock(MollieApiClient::class);
$this->apiClientKeyResolver = $this->createMock(MollieApiClientKeyResolverInterface::class);
$this->orderRepository = $this->createMock(OrderRepositoryInterface::class);
$this->paymentRepository = $this->createMock(PaymentRepositoryInterface::class);
$this->logger = $this->createMock(MollieLoggerActionInterface::class);
$this->stateMachine = $this->createMock(StateMachineInterface::class);
$this->entityManager = $this->createMock(EntityManagerInterface::class);

$this->paymentEndpoint = $this->createMock(PaymentEndpoint::class);
$this->mollieApiClient->payments = $this->paymentEndpoint;

$this->mollieApiClient->method('getApiKey')->willReturn('test_key');
$this->apiClientKeyResolver->method('getClientWithKey')->willReturn($this->mollieApiClient);
}

public function testItReturnsOkWhenOrderIsNotFound(): void
{
$controller = $this->createController();

$request = new Request(['id' => 'tr_abc', 'orderId' => '42']);
$this->paymentEndpoint->expects(self::once())->method('get')->willReturn($this->makeMolliePayment(PaymentStatus::STATUS_PAID));
$this->orderRepository->expects(self::once())->method('findOneBy')->with(['id' => '42'])->willReturn(null);
$this->stateMachine->expects(self::never())->method('apply');

$response = $controller->__invoke($request);

self::assertSame(Response::HTTP_OK, $response->getStatusCode());
}

public function testItReturnsOkWhenOrderHasNoPayment(): void
{
$controller = $this->createController();

$request = new Request(['id' => 'tr_abc', 'orderId' => '42']);
$order = $this->createMock(OrderInterface::class);
$order->method('getLastPayment')->willReturn(null);
$this->paymentEndpoint->expects(self::once())->method('get')->willReturn($this->makeMolliePayment(PaymentStatus::STATUS_PAID));
$this->orderRepository->method('findOneBy')->willReturn($order);
$this->stateMachine->expects(self::never())->method('apply');

$response = $controller->__invoke($request);

self::assertSame(Response::HTTP_OK, $response->getStatusCode());
}

public function testItAppliesCompleteTransitionForPaidMolliePayment(): void
{
$controller = $this->createController();

$request = new Request(['id' => 'tr_abc', 'orderId' => '42']);
$payment = $this->createMock(CorePaymentInterface::class);
$order = $this->createMock(OrderInterface::class);
$order->method('getLastPayment')->willReturn($payment);

$this->paymentEndpoint->method('get')->willReturn($this->makeMolliePayment(PaymentStatus::STATUS_PAID));
$this->orderRepository->method('findOneBy')->willReturn($order);

$this->stateMachine->expects(self::once())
->method('can')
->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_COMPLETE)
->willReturn(true);
$this->stateMachine->expects(self::once())
->method('apply')
->with($payment, PaymentTransitions::GRAPH, PaymentTransitions::TRANSITION_COMPLETE);
$this->entityManager->expects(self::once())->method('flush');

$response = $controller->__invoke($request);

self::assertSame(Response::HTTP_OK, $response->getStatusCode());
}

public function testItSkipsTransitionWhenStateMachineRejectsIt(): void
{
$controller = $this->createController();

$request = new Request(['id' => 'tr_abc', 'orderId' => '42']);
$payment = $this->createMock(CorePaymentInterface::class);
$order = $this->createMock(OrderInterface::class);
$order->method('getLastPayment')->willReturn($payment);

$this->paymentEndpoint->method('get')->willReturn($this->makeMolliePayment(PaymentStatus::STATUS_PAID));
$this->orderRepository->method('findOneBy')->willReturn($order);

$this->stateMachine->method('can')->willReturn(false);
$this->stateMachine->expects(self::never())->method('apply');
$this->entityManager->expects(self::never())->method('flush');

$response = $controller->__invoke($request);

self::assertSame(Response::HTTP_OK, $response->getStatusCode());
}

/**
* @group legacy
*/
public function testItFallsBackToSetStateWhenStateMachineIsNotProvided(): void
{
$controller = new PaymentWebhookController(
$this->mollieApiClient,
$this->apiClientKeyResolver,
$this->orderRepository,
$this->paymentRepository,
$this->logger,
);

$request = new Request(['id' => 'tr_abc', 'orderId' => '42']);
$payment = $this->createMock(CorePaymentInterface::class);
$payment->method('getState')->willReturn(PaymentInterface::STATE_NEW);
$payment->expects(self::once())->method('setState')->with(PaymentInterface::STATE_COMPLETED);

$order = $this->createMock(OrderInterface::class);
$order->method('getLastPayment')->willReturn($payment);

$this->paymentEndpoint->method('get')->willReturn($this->makeMolliePayment(PaymentStatus::STATUS_PAID));
$this->orderRepository->method('findOneBy')->willReturn($order);
$this->paymentRepository->expects(self::once())->method('add')->with($payment);

$response = $controller->__invoke($request);

self::assertSame(Response::HTTP_OK, $response->getStatusCode());
}

private function createController(): PaymentWebhookController
{
return new PaymentWebhookController(
$this->mollieApiClient,
$this->apiClientKeyResolver,
$this->orderRepository,
$this->paymentRepository,
$this->logger,
$this->stateMachine,
$this->entityManager,
);
}

private function makeMolliePayment(string $status): Payment
{
$payment = new Payment($this->mollieApiClient);
$payment->id = 'tr_abc';
$payment->status = $status;

return $payment;
}
}
Loading