This commit is contained in:
Mateusz 2026-06-23 08:26:59 +02:00 committed by GitHub
commit 8dfec5858c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 461 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

@ -13,6 +13,11 @@
<constraint-mapping xmlns="http://symfony.com/schema/dic/constraint-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/constraint-mapping http://symfony.com/schema/dic/services/constraint-mapping-1.0.xsd">
<class name="Sylius\Bundle\ApiBundle\Command\Payment\AddPaymentRequest">
<constraint name="Sylius\Bundle\ApiBundle\Validator\Constraints\OrderPaymentRequestEligibility">
<option name="groups">
<value>sylius</value>
</option>
</constraint>
<constraint name="Sylius\Bundle\ApiBundle\Validator\Constraints\ChosenPaymentRequestActionEligibility">
<option name="groups">
<value>sylius</value>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<constraint-mapping xmlns="http://symfony.com/schema/dic/constraint-mapping" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/constraint-mapping http://symfony.com/schema/dic/services/constraint-mapping-1.0.xsd">
<class name="Sylius\Bundle\ApiBundle\Command\Payment\UpdatePaymentRequest">
<constraint name="Sylius\Bundle\ApiBundle\Validator\Constraints\OrderPaymentRequestEligibility">
<option name="groups">
<value>sylius</value>
</option>
</constraint>
</class>
</constraint-mapping>

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());
}
}

View file

@ -14,4 +14,5 @@ Sylius\Component\Core\Model\Order:
state: "new"
paymentState: "awaiting_payment"
shippingState: "ready"
checkoutState: "completed"
tokenValue: "token"

View file

@ -0,0 +1,18 @@
Sylius\Component\Core\Model\Payment:
payment_not_placed:
method: "@payment_method_cash_on_delivery"
order: "@order_not_placed"
state: "new"
amount: 1000
currencyCode: "USD"
Sylius\Component\Core\Model\Order:
order_not_placed:
channel: "@channel_web"
currencyCode: "USD"
localeCode: "en_US"
state: "cart"
paymentState: "cart"
shippingState: "cart"
checkoutState: "cart"
tokenValue: "notPlacedOrderToken"

View file

@ -14,6 +14,7 @@ Sylius\Component\Core\Model\Order:
state: "new"
paymentState: "awaiting_payment"
shippingState: "ready"
checkoutState: "completed"
tokenValue: "token"
__calls:
- setCustomerWithAuthorization: ["@customer_oliver"]

View file

@ -14,5 +14,6 @@ Sylius\Component\Core\Model\Order:
state: "new"
paymentState: "awaiting_payment"
shippingState: "ready"
checkoutState: "completed"
tokenValue: "token"
customer: "@customer_oliver"

View file

@ -0,0 +1,7 @@
Sylius\Component\Payment\Model\PaymentRequest:
payment_request_not_placed:
__construct: [ '@payment_not_placed', '@payment_method_cash_on_delivery' ]
state: "new"
action: "capture"
payload: null
responseData: []

View file

@ -191,6 +191,58 @@ final class PaymentRequestsTest extends JsonApiTestCase
]);
}
#[Test]
public function it_does_not_create_a_payment_request_for_an_order_that_is_not_placed(): void
{
$fixtures = $this->loadFixturesFromFiles([
'channel/channel.yaml',
'payment_method.yaml',
'payment_request/order_not_placed.yaml',
]);
/** @var PaymentInterface $payment */
$payment = $fixtures['payment_not_placed'];
$this->client->request(
method: 'POST',
uri: '/api/v2/shop/orders/notPlacedOrderToken/payment-requests',
server: $this->headerBuilder()->withJsonLdAccept()->withJsonLdContentType()->build(),
content: json_encode([
'paymentId' => $payment->getId(),
'paymentMethodCode' => 'CASH_ON_DELIVERY',
], \JSON_THROW_ON_ERROR),
);
$this->assertResponseContainsViolations([
['propertyPath' => '', 'message' => 'Payment requests can only be created for placed orders.'],
]);
}
#[Test]
public function it_does_not_update_a_payment_request_for_an_order_that_is_not_placed(): void
{
$fixtures = $this->loadFixturesFromFiles([
'channel/channel.yaml',
'payment_method.yaml',
'payment_request/order_not_placed.yaml',
'payment_request/payment_request_not_placed.yaml',
]);
/** @var PaymentRequestInterface $paymentRequest */
$paymentRequest = $fixtures['payment_request_not_placed'];
$this->client->request(
method: 'PUT',
uri: sprintf('/api/v2/shop/payment-requests/%s', $paymentRequest->getHash()),
server: $this->headerBuilder()->withJsonLdAccept()->withJsonLdContentType()->build(),
content: json_encode(['payload' => ['some' => 'payload']], \JSON_THROW_ON_ERROR),
);
$this->assertResponseContainsViolations([
['propertyPath' => '', 'message' => 'Payment requests can only be created for placed orders.'],
]);
}
/**
* @param array<string> $fixturesPaths
*