Add validator to restrict payment requests to placed orders only

This commit is contained in:
Mateusz 2026-03-19 09:13:07 +01:00
parent f8a27841f2
commit 4453e365a3
7 changed files with 354 additions and 0 deletions

View file

@ -16,10 +16,12 @@ namespace Sylius\Bundle\ApiBundle\Command\Payment;
use Sylius\Bundle\ApiBundle\Attribute\OrderTokenValueAware;
use Sylius\Bundle\ApiBundle\Attribute\PaymentRequestActionAware;
use Sylius\Bundle\ApiBundle\Command\IriToIdentifierConversionAwareInterface;
use Sylius\Bundle\ApiBundle\Validator\Constraints\OrderPaymentRequestEligibility;
/** @experimental */
#[OrderTokenValueAware]
#[PaymentRequestActionAware]
#[OrderPaymentRequestEligibility]
class AddPaymentRequest implements IriToIdentifierConversionAwareInterface
{
public function __construct(

View file

@ -15,9 +15,11 @@ namespace Sylius\Bundle\ApiBundle\Command\Payment;
use Sylius\Bundle\ApiBundle\Attribute\PaymentRequestHashAware;
use Sylius\Bundle\ApiBundle\Command\IriToIdentifierConversionAwareInterface;
use Sylius\Bundle\ApiBundle\Validator\Constraints\OrderPaymentRequestEligibility;
/** @experimental */
#[PaymentRequestHashAware]
#[OrderPaymentRequestEligibility]
class UpdatePaymentRequest implements IriToIdentifierConversionAwareInterface
{
public function __construct(

View file

@ -201,6 +201,12 @@
<tag name="validator.constraint_validator" alias="sylius_api_chosen_payment_request_action_eligibility" />
</service>
<service id="sylius_api.validator.order_payment_request_eligibility" class="Sylius\Bundle\ApiBundle\Validator\Constraints\OrderPaymentRequestEligibilityValidator">
<argument type="service" id="sylius.repository.order" />
<argument type="service" id="sylius.repository.payment_request" />
<tag name="validator.constraint_validator" alias="sylius_api_order_payment_request_eligibility" />
</service>
<service id="sylius_api.validator.order_address_requirement" class="Sylius\Bundle\ApiBundle\Validator\Constraints\OrderAddressRequirementValidator">
<argument type="service" id="sylius.repository.order" />
<tag name="validator.constraint_validator" alias="sylius_order_address_requirement_validator" />

View file

@ -28,6 +28,7 @@ sylius:
not_exist: 'The payment method with %code% code does not exist.'
payment_request:
action_not_available: 'The payment request (method code: %code% and payment id: %id%) has no handler. Please choose another payment method.'
invalid_order_checkout_state: 'Payment requests can only be created for placed orders.'
not_available: 'The payment request is not supporting this action.'
product:
not_exist: 'The product %productName% does not exist.'

View file

@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Sylius 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 Sylius\Bundle\ApiBundle\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/** @experimental */
#[\Attribute]
final class OrderPaymentRequestEligibility extends Constraint
{
public string $message = 'sylius.payment_request.invalid_order_checkout_state';
public function validatedBy(): string
{
return 'sylius_api_order_payment_request_eligibility';
}
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}

View file

@ -0,0 +1,94 @@
<?php
/*
* This file is part of the Sylius 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 Sylius\Bundle\ApiBundle\Validator\Constraints;
use Sylius\Bundle\ApiBundle\Command\Payment\AddPaymentRequest;
use Sylius\Bundle\ApiBundle\Command\Payment\UpdatePaymentRequest;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Core\OrderCheckoutStates;
use Sylius\Component\Core\Repository\OrderRepositoryInterface;
use Sylius\Component\Payment\Model\PaymentRequestInterface;
use Sylius\Component\Payment\Repository\PaymentRequestRepositoryInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Webmozart\Assert\Assert;
/** @experimental */
final class OrderPaymentRequestEligibilityValidator extends ConstraintValidator
{
/**
* @param OrderRepositoryInterface<OrderInterface> $orderRepository
* @param PaymentRequestRepositoryInterface<PaymentRequestInterface> $paymentRequestRepository
*/
public function __construct(
private OrderRepositoryInterface $orderRepository,
private PaymentRequestRepositoryInterface $paymentRequestRepository,
) {
}
public function validate(mixed $value, Constraint $constraint): void
{
Assert::isInstanceOf($constraint, OrderPaymentRequestEligibility::class);
if ($value instanceof AddPaymentRequest) {
$this->validateAddPaymentRequest($value, $constraint);
return;
}
if ($value instanceof UpdatePaymentRequest) {
$this->validateUpdatePaymentRequest($value, $constraint);
return;
}
Assert::true(false, sprintf('Expected an instance of %s or %s. Got: %s', AddPaymentRequest::class, UpdatePaymentRequest::class, get_debug_type($value)));
}
private function validateAddPaymentRequest(AddPaymentRequest $value, OrderPaymentRequestEligibility $constraint): void
{
/** @var OrderInterface|null $order */
$order = $this->orderRepository->findOneBy(['tokenValue' => $value->orderTokenValue]);
if (null === $order) {
return;
}
if ($order->getCheckoutState() !== OrderCheckoutStates::STATE_COMPLETED) {
$this->context->addViolation($constraint->message);
}
}
private function validateUpdatePaymentRequest(UpdatePaymentRequest $value, OrderPaymentRequestEligibility $constraint): void
{
/** @var PaymentRequestInterface|null $paymentRequest */
$paymentRequest = $this->paymentRequestRepository->find($value->hash);
if (null === $paymentRequest) {
return;
}
/** @var PaymentInterface $payment */
$payment = $paymentRequest->getPayment();
/** @var OrderInterface|null $order */
$order = $payment->getOrder();
if (null === $order) {
return;
}
if ($order->getCheckoutState() !== OrderCheckoutStates::STATE_COMPLETED) {
$this->context->addViolation($constraint->message);
}
}
}

View file

@ -0,0 +1,216 @@
<?php
/*
* This file is part of the Sylius 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\Bundle\ApiBundle\Validator\Constraints;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\ApiBundle\Command\Payment\AddPaymentRequest;
use Sylius\Bundle\ApiBundle\Command\Payment\UpdatePaymentRequest;
use Sylius\Bundle\ApiBundle\Validator\Constraints\OrderPaymentRequestEligibility;
use Sylius\Bundle\ApiBundle\Validator\Constraints\OrderPaymentRequestEligibilityValidator;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Core\OrderCheckoutStates;
use Sylius\Component\Core\Repository\OrderRepositoryInterface;
use Sylius\Component\Payment\Model\PaymentRequestInterface;
use Sylius\Component\Payment\Repository\PaymentRequestRepositoryInterface;
use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
final class OrderPaymentRequestEligibilityValidatorTest extends TestCase
{
/** @var OrderRepositoryInterface<OrderInterface>&MockObject */
private MockObject $orderRepositoryMock;
/** @var PaymentRequestRepositoryInterface<PaymentRequestInterface>&MockObject */
private MockObject $paymentRequestRepositoryMock;
private MockObject $executionContextMock;
private OrderPaymentRequestEligibilityValidator $validator;
protected function setUp(): void
{
$this->orderRepositoryMock = $this->createMock(OrderRepositoryInterface::class);
$this->paymentRequestRepositoryMock = $this->createMock(PaymentRequestRepositoryInterface::class);
$this->executionContextMock = $this->createMock(ExecutionContextInterface::class);
$this->validator = new OrderPaymentRequestEligibilityValidator(
$this->orderRepositoryMock,
$this->paymentRequestRepositoryMock,
);
$this->validator->initialize($this->executionContextMock);
}
public function test_it_implements_constraint_validator_interface(): void
{
self::assertInstanceOf(ConstraintValidatorInterface::class, $this->validator);
}
public function test_it_throws_an_exception_if_value_is_not_a_supported_command(): void
{
self::expectException(\InvalidArgumentException::class);
$this->validator->validate('not_a_command', new OrderPaymentRequestEligibility());
}
public function test_it_does_nothing_for_add_payment_request_when_order_is_not_found(): void
{
$command = new AddPaymentRequest('ORDER_TOKEN', 1, 'PAYMENT_METHOD_CODE');
$this->orderRepositoryMock
->expects($this->once())
->method('findOneBy')
->with(['tokenValue' => 'ORDER_TOKEN'])
->willReturn(null);
$this->executionContextMock->expects($this->never())->method('addViolation');
$this->validator->validate($command, new OrderPaymentRequestEligibility());
}
public function test_it_does_nothing_for_add_payment_request_when_order_checkout_state_is_completed(): void
{
$command = new AddPaymentRequest('ORDER_TOKEN', 1, 'PAYMENT_METHOD_CODE');
/** @var OrderInterface&MockObject $orderMock */
$orderMock = $this->createMock(OrderInterface::class);
$orderMock->method('getCheckoutState')->willReturn(OrderCheckoutStates::STATE_COMPLETED);
$this->orderRepositoryMock
->expects($this->once())
->method('findOneBy')
->with(['tokenValue' => 'ORDER_TOKEN'])
->willReturn($orderMock);
$this->executionContextMock->expects($this->never())->method('addViolation');
$this->validator->validate($command, new OrderPaymentRequestEligibility());
}
public function test_it_adds_violation_for_add_payment_request_when_order_checkout_state_is_not_completed(): void
{
$command = new AddPaymentRequest('ORDER_TOKEN', 1, 'PAYMENT_METHOD_CODE');
/** @var OrderInterface&MockObject $orderMock */
$orderMock = $this->createMock(OrderInterface::class);
$orderMock->method('getCheckoutState')->willReturn('cart');
$this->orderRepositoryMock
->expects($this->once())
->method('findOneBy')
->with(['tokenValue' => 'ORDER_TOKEN'])
->willReturn($orderMock);
$this->executionContextMock
->expects($this->once())
->method('addViolation')
->with('sylius.payment_request.invalid_order_checkout_state');
$this->validator->validate($command, new OrderPaymentRequestEligibility());
}
public function test_it_does_nothing_for_update_payment_request_when_payment_request_is_not_found(): void
{
$command = new UpdatePaymentRequest('PAYMENT_REQUEST_HASH');
$this->paymentRequestRepositoryMock
->expects($this->once())
->method('find')
->with('PAYMENT_REQUEST_HASH')
->willReturn(null);
$this->executionContextMock->expects($this->never())->method('addViolation');
$this->validator->validate($command, new OrderPaymentRequestEligibility());
}
public function test_it_does_nothing_for_update_payment_request_when_order_is_not_found(): void
{
$command = new UpdatePaymentRequest('PAYMENT_REQUEST_HASH');
/** @var PaymentInterface&MockObject $paymentMock */
$paymentMock = $this->createMock(PaymentInterface::class);
$paymentMock->method('getOrder')->willReturn(null);
/** @var PaymentRequestInterface&MockObject $paymentRequestMock */
$paymentRequestMock = $this->createMock(PaymentRequestInterface::class);
$paymentRequestMock->method('getPayment')->willReturn($paymentMock);
$this->paymentRequestRepositoryMock
->expects($this->once())
->method('find')
->with('PAYMENT_REQUEST_HASH')
->willReturn($paymentRequestMock);
$this->executionContextMock->expects($this->never())->method('addViolation');
$this->validator->validate($command, new OrderPaymentRequestEligibility());
}
public function test_it_does_nothing_for_update_payment_request_when_order_checkout_state_is_completed(): void
{
$command = new UpdatePaymentRequest('PAYMENT_REQUEST_HASH');
/** @var OrderInterface&MockObject $orderMock */
$orderMock = $this->createMock(OrderInterface::class);
$orderMock->method('getCheckoutState')->willReturn(OrderCheckoutStates::STATE_COMPLETED);
/** @var PaymentInterface&MockObject $paymentMock */
$paymentMock = $this->createMock(PaymentInterface::class);
$paymentMock->method('getOrder')->willReturn($orderMock);
/** @var PaymentRequestInterface&MockObject $paymentRequestMock */
$paymentRequestMock = $this->createMock(PaymentRequestInterface::class);
$paymentRequestMock->method('getPayment')->willReturn($paymentMock);
$this->paymentRequestRepositoryMock
->expects($this->once())
->method('find')
->with('PAYMENT_REQUEST_HASH')
->willReturn($paymentRequestMock);
$this->executionContextMock->expects($this->never())->method('addViolation');
$this->validator->validate($command, new OrderPaymentRequestEligibility());
}
public function test_it_adds_violation_for_update_payment_request_when_order_checkout_state_is_not_completed(): void
{
$command = new UpdatePaymentRequest('PAYMENT_REQUEST_HASH');
/** @var OrderInterface&MockObject $orderMock */
$orderMock = $this->createMock(OrderInterface::class);
$orderMock->method('getCheckoutState')->willReturn('addressed');
/** @var PaymentInterface&MockObject $paymentMock */
$paymentMock = $this->createMock(PaymentInterface::class);
$paymentMock->method('getOrder')->willReturn($orderMock);
/** @var PaymentRequestInterface&MockObject $paymentRequestMock */
$paymentRequestMock = $this->createMock(PaymentRequestInterface::class);
$paymentRequestMock->method('getPayment')->willReturn($paymentMock);
$this->paymentRequestRepositoryMock
->expects($this->once())
->method('find')
->with('PAYMENT_REQUEST_HASH')
->willReturn($paymentRequestMock);
$this->executionContextMock
->expects($this->once())
->method('addViolation')
->with('sylius.payment_request.invalid_order_checkout_state');
$this->validator->validate($command, new OrderPaymentRequestEligibility());
}
}