[UPMERGE] 2.2 -> 2.3 (#18901)

This PR has been generated automatically.
For more details see
[upmerge_pr.yaml](/Sylius/Sylius/blob/2.3/.github/workflows/upmerge_pr.yaml).

**Remember!** The upmerge should always be merged with using `Merge pull
request` button.

In case of conflicts, please resolve them manually with usign the
following commands:
```
git fetch upstream
gh pr checkout <this-pr-number>
git merge upstream/2.3 -m "Resolve conflicts between 2.2 and 2.3"
```

If you use other name for the upstream remote, please replace `upstream`
with the name of your remote pointing to the `Sylius/Sylius` repository.

Once the conflicts are resolved, please run `git merge --continue` and
push the changes to this PR.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Release Notes

* **New Features**
  * Added XSS protection for autocomplete fields in the admin interface.
* Enhanced PayPal payment integration with improved HTTP client
handling.

* **Bug Fixes**
  * Fixed grid display of product taxon enabled status.
  * Corrected HTML escaping in error messages and tree rendering.
  * Improved cart refresh functionality and error handling.

* **Security Improvements**
* Implemented access control to prevent users from accessing other
users' shopping carts.
* Enhanced redirect logic for currency and locale switching, removing
reliance on HTTP referer header.

* **Configuration**
  * Extended default notification frequency to 24 hours.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jan Góralski 2026-03-17 16:01:03 +01:00 committed by GitHub
commit 385a5e501f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1048 additions and 85 deletions

View file

@ -1,5 +1,22 @@
# CHANGELOG FOR `2.1.X`
## v2.1.12 (2026-03-09)
#### Details
- [#18747](https://github.com/Sylius/Sylius/pull/18747) Fix panther build ([@TheMilek](https://github.com/TheMilek))
- [#18758](https://github.com/Sylius/Sylius/pull/18758) Remove duplicated serialization group field ([@TheMilek](https://github.com/TheMilek))
- [#18785](https://github.com/Sylius/Sylius/pull/18785) Try to fix build after ResourceBundle release ([@TheMilek](https://github.com/TheMilek))
- [#18742](https://github.com/Sylius/Sylius/pull/18742) [Admin] Fix order history address fields not displaying empty values ([@Wojdylak](https://github.com/Wojdylak))
- [#18806](https://github.com/Sylius/Sylius/pull/18806) Fix after new release of PayumBundle ([@TheMilek](https://github.com/TheMilek))
- [#18836](https://github.com/Sylius/Sylius/pull/18836) Upgrade BuildTestAppAction from v3.0.1 to v4 ([@TheMilek](https://github.com/TheMilek))
- [#18832](https://github.com/Sylius/Sylius/pull/18832) [Admin] Fix images not being emitted with Webpack 5.105+ ([@GSadee](https://github.com/GSadee))
- [#18841](https://github.com/Sylius/Sylius/pull/18841) Fix Dutch translation for payment method ([@JordiDekker](https://github.com/JordiDekker))
- [#18871](https://github.com/Sylius/Sylius/pull/18871) Add conflict to api-platform/serializer 4.2.17 ([@TheMilek](https://github.com/TheMilek))
- [#18888](https://github.com/Sylius/Sylius/pull/18888) Remove redundant check with apip4.1.7 in matrix ([@TheMilek](https://github.com/TheMilek))
- [#18887](https://github.com/Sylius/Sylius/pull/18887) [BUGFIX] Make GitHub actions green again 2.1 ([@rust-le](https://github.com/rust-le))
- [#18844](https://github.com/Sylius/Sylius/pull/18844) Fix formatting in UPGRADE-2.0.md ([@LucaGallinari](https://github.com/LucaGallinari))
## v2.1.11 (2026-01-29)
#### Details

View file

@ -1,5 +1,22 @@
# CHANGELOG FOR `2.2.X`
## v2.2.3 (2026-03-09)
#### Details
- [#18747](https://github.com/Sylius/Sylius/pull/18747) Fix panther build ([@TheMilek](https://github.com/TheMilek))
- [#18758](https://github.com/Sylius/Sylius/pull/18758) Remove duplicated serialization group field ([@TheMilek](https://github.com/TheMilek))
- [#18785](https://github.com/Sylius/Sylius/pull/18785) Try to fix build after ResourceBundle release ([@TheMilek](https://github.com/TheMilek))
- [#18742](https://github.com/Sylius/Sylius/pull/18742) [Admin] Fix order history address fields not displaying empty values ([@Wojdylak](https://github.com/Wojdylak))
- [#18806](https://github.com/Sylius/Sylius/pull/18806) Fix after new release of PayumBundle ([@TheMilek](https://github.com/TheMilek))
- [#18836](https://github.com/Sylius/Sylius/pull/18836) Upgrade BuildTestAppAction from v3.0.1 to v4 ([@TheMilek](https://github.com/TheMilek))
- [#18832](https://github.com/Sylius/Sylius/pull/18832) [Admin] Fix images not being emitted with Webpack 5.105+ ([@GSadee](https://github.com/GSadee))
- [#18841](https://github.com/Sylius/Sylius/pull/18841) Fix Dutch translation for payment method ([@JordiDekker](https://github.com/JordiDekker))
- [#18871](https://github.com/Sylius/Sylius/pull/18871) Add conflict to api-platform/serializer 4.2.17 ([@TheMilek](https://github.com/TheMilek))
- [#18888](https://github.com/Sylius/Sylius/pull/18888) Remove redundant check with apip4.1.7 in matrix ([@TheMilek](https://github.com/TheMilek))
- [#18887](https://github.com/Sylius/Sylius/pull/18887) [BUGFIX] Make GitHub actions green again 2.1 ([@rust-le](https://github.com/rust-le))
- [#18844](https://github.com/Sylius/Sylius/pull/18844) Fix formatting in UPGRADE-2.0.md ([@LucaGallinari](https://github.com/LucaGallinari))
## v2.2.2 (2026-01-20)
#### Details

View file

@ -163,7 +163,7 @@ sylius_shop_payum:
- resource: "@SyliusShopBundle/Resources/config/routing/payum.yml"
+ resource: "@SyliusPayumBundle/Resources/config/routing/integrations/sylius_shop.yaml"
sylius_payment_notify:
+sylius_payment_notify:
+ resource: "@SyliusPaymentBundle/Resources/config/routing/integrations/sylius.yaml"
```
@ -202,6 +202,7 @@ return [
+ Symfony\UX\LiveComponent\LiveComponentBundle::class => ['all' => true],
+ Symfony\UX\Autocomplete\AutocompleteBundle::class => ['all' => true],
];
```
* New Symfony/Messenger transports for handling payment requests have been added.
Therefore, you need to add the following configuration to your .env file:

View file

@ -1,3 +1,25 @@
# UPGRADE FROM `2.1.9` TO `2.1.10`
## Redirect behavior changes
`CurrencySwitchController`, `ImpersonateUserController` and `StorageBasedLocaleSwitcher` no longer use the
HTTP `Referer` header for redirects. Instead, they use the `RouterInterface` to generate a redirect URL based on
the `_sylius.redirect` route attribute (defaulting to `sylius_shop_homepage`).
If your application relied on the Referer-based redirect behavior (e.g., to return the user to the page they came from
after switching currency or locale), you can customize the redirect target by overriding the route definition:
```yaml
# config/routes/sylius_shop.yaml
sylius_shop_switch_currency:
path: /{_locale}/switch-currency/{code}
methods: [GET]
defaults:
_controller: sylius.controller.shop.currency_switch:switchAction
_sylius:
redirect: sylius_shop_homepage # or any route name you prefer
```
# UPGRADE FROM `2.1.8` TO `2.1.9`
## Telemetry

View file

@ -1,7 +1,7 @@
{
"name": "sylius/sylius",
"type": "library",
"version": "v2.2.3-dev",
"version": "v2.2.4-dev",
"description": "E-Commerce platform for PHP, based on Symfony framework.",
"homepage": "https://sylius.com",
"license": "MIT",

View file

@ -39,6 +39,8 @@ use Sylius\Component\Product\Resolver\ProductVariantResolverInterface;
use Sylius\Resource\Generator\RandomnessGeneratorInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\HandledStamp;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
final readonly class CartContext implements Context
{
@ -55,6 +57,7 @@ final readonly class CartContext implements Context
private ShippingContext $shippingContext,
private PaymentContext $paymentContext,
private string $guestCartTokenFilePath,
private ?TokenStorageInterface $tokenStorage = null,
) {
}
@ -133,11 +136,13 @@ final readonly class CartContext implements Context
$tokenValue = $this->pickupCart();
}
$this->commandBus->dispatch(new AddItemToCart(
orderTokenValue: $tokenValue,
productVariantCode: $productVariant->getCode(),
quantity: 1,
));
$this->runWithSecurityToken(function () use ($tokenValue, $productVariant): void {
$this->commandBus->dispatch(new AddItemToCart(
orderTokenValue: $tokenValue,
productVariantCode: $productVariant->getCode(),
quantity: 1,
));
});
$this->sharedStorage->set('product', $productVariant->getProduct());
$this->sharedStorage->set('variant', $productVariant);
@ -154,17 +159,19 @@ final readonly class CartContext implements Context
$tokenValue = $this->pickupCart($tokenValue);
}
$this->commandBus->dispatch(new AddItemToCart(
orderTokenValue: $tokenValue,
productVariantCode: $this
->getProductVariantWithProductOptionAndProductOptionValue(
$product,
$productOption,
$productOptionValue,
)
->getCode(),
quantity: 1,
));
$this->runWithSecurityToken(function () use ($tokenValue, $product, $productOption, $productOptionValue): void {
$this->commandBus->dispatch(new AddItemToCart(
orderTokenValue: $tokenValue,
productVariantCode: $this
->getProductVariantWithProductOptionAndProductOptionValue(
$product,
$productOption,
$productOptionValue,
)
->getCode(),
quantity: 1,
));
});
}
#[Given('/^I removed (product "[^"]+") from the (cart)$/')]
@ -279,15 +286,40 @@ final readonly class CartContext implements Context
$tokenValue = $this->pickupCart($tokenValue);
}
$this->commandBus->dispatch(new AddItemToCart(
orderTokenValue: $tokenValue,
productVariantCode: $this->productVariantResolver->getVariant($product)->getCode(),
quantity: $quantity,
));
$this->runWithSecurityToken(function () use ($tokenValue, $product, $quantity): void {
$this->commandBus->dispatch(new AddItemToCart(
orderTokenValue: $tokenValue,
productVariantCode: $this->productVariantResolver->getVariant($product)->getCode(),
quantity: $quantity,
));
});
$this->sharedStorage->set('product', $product);
}
private function runWithSecurityToken(callable $callback): void
{
$previousToken = $this->tokenStorage?->getToken();
if (
null !== $this->tokenStorage &&
$this->sharedStorage->has('user') &&
$this->sharedStorage->get('user') instanceof ShopUserInterface
) {
/** @var ShopUserInterface $user */
$user = $this->sharedStorage->get('user');
$this->tokenStorage->setToken(
new UsernamePasswordToken($user, 'api_shop', $user->getRoles()),
);
}
try {
$callback();
} finally {
$this->tokenStorage?->setToken($previousToken);
}
}
private function doesCartWithTokenExist(string $tokenValue): bool
{
return $this->orderRepository->findCartByTokenValue($tokenValue) !== null;

View file

@ -96,6 +96,7 @@ return static function (ContainerConfigurator $container) {
service('sylius.behat.context.setup.checkout.shipping'),
service('sylius.behat.context.setup.checkout.payment'),
'%sylius.behat.guest_cart_token_file%',
service('security.token_storage'),
])
;

View file

@ -15,6 +15,7 @@ namespace Sylius\Bundle\AdminBundle\Controller;
use Sylius\Bundle\CoreBundle\Security\UserImpersonatorInterface;
use Sylius\Bundle\UserBundle\Provider\UserProviderInterface;
use Sylius\Component\Core\Model\ShopUserInterface;
use Sylius\Component\User\Model\UserInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
@ -49,12 +50,11 @@ final class ImpersonateUserController
$this->addFlash($request, $username);
$redirectUrl = $request->headers->get(
'referer',
$this->router->generate('sylius_admin_customer_show', ['id' => $user->getId()]),
);
$customerId = $user instanceof ShopUserInterface ? $user->getCustomer()->getId() : $user->getId();
return new RedirectResponse($redirectUrl);
return new RedirectResponse(
$this->router->generate('sylius_admin_customer_show', ['id' => $customerId]),
);
}
private function addFlash(Request $request, string $username): void

View file

@ -37,7 +37,7 @@ final class Configuration implements ConfigurationInterface
->defaultTrue()
->end()
->integerNode('frequency')
->defaultValue(60)
->defaultValue(1440) // in minutes, 1440 = 24h
->end()
->end()
->end()

View file

@ -10,6 +10,12 @@
import { Controller } from '@hotwired/stimulus';
import InfiniteTree from 'infinite-tree';
const escapeHtml = (str) => {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
};
export default class extends Controller {
static values = {
treeData: Array,
@ -56,7 +62,7 @@ export default class extends Controller {
const toggler = `<span class="${treeOptions.togglerClass} ${togglerClass}" style="width: ${nodeMargin}px"></span>`;
const checkbox = `<span class="infinite-tree-check" style="width: ${nodeMargin}px;"><input class="form-check-input" type="checkbox" data-action="sylius--admin-bundle--product-taxon-tree#clickNode" ${indeterminate && !checked ? 'data-indeterminate' : ''} ${checked ? 'checked' : ''}></span>`;
const treeNode = `<div class="infinite-tree-node" style="${rtl ? 'margin-right' : 'margin-left'}: ${(depth * nodeMargin)}px">${toggler}${checkbox}<span class="infinite-tree-title">${name}</span></div>`;
const treeNode = `<div class="infinite-tree-node" style="${rtl ? 'margin-right' : 'margin-left'}: ${(depth * nodeMargin)}px">${toggler}${checkbox}<span class="infinite-tree-title">${escapeHtml(name)}</span></div>`;
return `<div
data-id="${id}"

View file

@ -69,6 +69,15 @@ export default class extends Controller {
togglerPrototyp.classList.add('infinite-tree-leaf');
}
const titleElement = itemPrototyp.querySelector('.infinite-tree-title');
if (titleElement) {
titleElement.textContent = name;
}
if (itemPrototyp.dataset.testTreeTaxon !== undefined) {
itemPrototyp.dataset.testTreeTaxon = name;
}
const div = document.createElement('div');
div.textContent = name;
const escapedName = div.innerHTML;

View file

@ -17,6 +17,7 @@ import './scripts/spotlight';
import './scripts/statistics_chart';
import './scripts/sticky-header';
import './scripts/autocomplete-xss-protection';
import './scripts/bootstrap';
const imagesContext = require.context('./images', true, /\.(jpg|jpeg|png|svg|gif|webp)$/);

View file

@ -0,0 +1,43 @@
/*
* 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.
*/
const escapeHtml = (str) => {
if (typeof str !== 'string') return str;
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
};
document.addEventListener('autocomplete:pre-connect', (event) => {
const options = event.detail.options;
if (!options.render) {
return;
}
const labelField = options.labelField || 'text';
const wrapRenderer = (renderer) => {
if (!renderer) return renderer;
return (data, escape) => {
const escaped = { ...data };
if (escaped[labelField]) {
escaped[labelField] = escapeHtml(escaped[labelField]);
}
return renderer(escaped, escape);
};
};
if (options.render.item) {
options.render.item = wrapRenderer(options.render.item);
}
if (options.render.option) {
options.render.option = wrapRenderer(options.render.option);
}
});

View file

@ -44,7 +44,7 @@ sylius_grid:
enabled:
type: twig
label: sylius.ui.enabled
path: product
path: product.enabled
options:
template: "@SyliusAdmin/shared/grid/field/boolean.html.twig"
position:

View file

@ -27,6 +27,8 @@ use Sylius\Bundle\AdminBundle\Provider\LoggedInAdminUserProviderInterface;
use Sylius\Bundle\AdminBundle\SectionResolver\AdminUriBasedSectionResolver;
use Sylius\Bundle\AttributeBundle\Form\Type\AttributeType\SelectAttributeType as SelectAttributeFormType;
use Sylius\Component\Attribute\AttributeType\SelectAttributeType;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Psr18Client;
return static function (ContainerConfigurator $container) {
$container->import('services/*.php');
@ -89,6 +91,17 @@ return static function (ContainerConfigurator $container) {
$services->alias('sylius.http_client', 'Psr\Http\Client\ClientInterface');
$services
->set('sylius_admin.http_client', Psr18Client::class)
->args([inline_service(HttpClient::class)
->factory([HttpClient::class, 'create'])
->args([
['timeout' => 2, 'max_duration' => 2],
])
])
->public()
;
$services
->set('sylius.attribute_type.select', SelectAttributeType::class)
->tag('sylius.attribute.type', ['attribute_type' => 'select', 'label' => 'Select', 'form_type' => SelectAttributeFormType::class, 'configuration_form_type' => SelectAttributeConfigurationType::class])

View file

@ -31,7 +31,7 @@ return static function (ContainerConfigurator $container) {
$services
->set('sylius_admin.provider.notification.hub', HubNotificationProvider::class)
->args([
service('sylius.http_client'),
service('sylius_admin.http_client'),
service('request_stack'),
service('Psr\Http\Message\RequestFactoryInterface'),
service('Psr\Http\Message\StreamFactoryInterface'),

View file

@ -39,6 +39,7 @@
"sylius/ui-bundle": "^2.0",
"sylius/twig-hooks": "^0.9",
"symfony/framework-bundle": "^6.4.1 || ^7.4",
"symfony/http-client": "^6.4 || ^7.4",
"symfony/stimulus-bundle": "^2.25",
"symfony/ux-autocomplete": "^2.25",
"symfony/ux-live-component": "^2.25",

View file

@ -14,16 +14,19 @@ declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\CommandHandler\Cart;
use Sylius\Bundle\ApiBundle\Command\Cart\AddItemToCart;
use Sylius\Bundle\ApiBundle\Context\UserContextInterface;
use Sylius\Bundle\ApiBundle\Exception\ProductVariantUnprocessableException;
use Sylius\Bundle\ApiBundle\Exception\UnprocessableCartException;
use Sylius\Component\Core\Factory\CartItemFactoryInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\OrderItemInterface;
use Sylius\Component\Core\Model\ProductVariantInterface;
use Sylius\Component\Core\Model\ShopUserInterface;
use Sylius\Component\Core\Repository\OrderRepositoryInterface;
use Sylius\Component\Core\Repository\ProductVariantRepositoryInterface;
use Sylius\Component\Order\Modifier\OrderItemQuantityModifierInterface;
use Sylius\Component\Order\Modifier\OrderModifierInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
@ -39,6 +42,7 @@ final readonly class AddItemToCartHandler
private OrderModifierInterface $orderModifier,
private CartItemFactoryInterface $cartItemFactory,
private OrderItemQuantityModifierInterface $orderItemQuantityModifier,
private ?UserContextInterface $userContext = null,
) {
}
@ -58,6 +62,8 @@ final readonly class AddItemToCartHandler
throw new UnprocessableCartException();
}
$this->assertCartAccessible($cart);
/** @var OrderItemInterface $cartItem */
$cartItem = $this->cartItemFactory->createNew();
$cartItem->setVariant($productVariant);
@ -67,4 +73,32 @@ final readonly class AddItemToCartHandler
return $cart;
}
private function assertCartAccessible(OrderInterface $cart): void
{
if (null === $this->userContext) {
return;
}
if ($cart->isCreatedByGuest()) {
return;
}
$cartCustomer = $cart->getCustomer();
if (null === $cartCustomer || null === $cartCustomer->getUser()) {
return;
}
$currentUser = $this->userContext->getUser();
if (
$currentUser instanceof ShopUserInterface &&
$currentUser->getCustomer()?->getId() === $cartCustomer->getId()
) {
return;
}
throw new NotFoundHttpException('Cart not found.');
}
}

View file

@ -13,7 +13,6 @@ declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Doctrine\ORM\Filter;
use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
@ -24,6 +23,8 @@ use Webmozart\Assert\Assert;
final class ProductPriceOrderFilter extends AbstractFilter
{
private const ALLOWED_DIRECTIONS = ['asc', 'desc'];
protected function filterProperty(
string $property,
$value,
@ -37,6 +38,11 @@ final class ProductPriceOrderFilter extends AbstractFilter
return;
}
$direction = strtolower($value['price']);
if (!in_array($direction, self::ALLOWED_DIRECTIONS, true)) {
return;
}
Assert::keyExists($context, ContextKeys::CHANNEL);
$channel = $context[ContextKeys::CHANNEL];
@ -66,7 +72,7 @@ final class ProductPriceOrderFilter extends AbstractFilter
),
)
->andWhere('channelPricing.channelCode = :channelCode')
->orderBy('channelPricing.price', $value['price'])
->orderBy('channelPricing.price', $direction)
->setParameter($enabledParameterName, true)
->setParameter('channelCode', $channel->getCode())
;
@ -81,10 +87,7 @@ final class ProductPriceOrderFilter extends AbstractFilter
'property' => 'price',
'schema' => [
'type' => 'string',
'enum' => [
strtolower(OrderFilterInterface::DIRECTION_ASC),
strtolower(OrderFilterInterface::DIRECTION_DESC),
],
'enum' => self::ALLOWED_DIRECTIONS,
],
],
];

View file

@ -13,7 +13,6 @@ declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Doctrine\ORM\Filter;
use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
@ -21,6 +20,8 @@ use Doctrine\ORM\QueryBuilder;
final class TranslationOrderNameAndLocaleFilter extends AbstractFilter
{
private const ALLOWED_DIRECTIONS = ['asc', 'desc'];
protected function filterProperty(
string $property,
$value,
@ -36,8 +37,13 @@ final class TranslationOrderNameAndLocaleFilter extends AbstractFilter
return;
}
$direction = strtolower($value['translation.name']);
if (!in_array($direction, self::ALLOWED_DIRECTIONS, true)) {
return;
}
$queryBuilder
->orderBy('translation.name', $value['translation.name'])
->orderBy('translation.name', $direction)
;
}
}
@ -51,10 +57,7 @@ final class TranslationOrderNameAndLocaleFilter extends AbstractFilter
'property' => 'translation',
'schema' => [
'type' => 'string',
'enum' => [
strtolower(OrderFilterInterface::DIRECTION_ASC),
strtolower(OrderFilterInterface::DIRECTION_DESC),
],
'enum' => self::ALLOWED_DIRECTIONS,
],
],
/* @see \Sylius\Bundle\ApiBundle\Doctrine\ORM\QueryExtension\Common\TranslationOrderLocaleExtension */

View file

@ -42,6 +42,7 @@ use Sylius\Bundle\ApiBundle\CommandHandler\Payment\AddPaymentRequestHandler;
use Sylius\Bundle\ApiBundle\CommandHandler\Payment\UpdatePaymentRequestHandler;
use Sylius\Bundle\ApiBundle\CommandHandler\Promotion\GeneratePromotionCouponHandler;
use Sylius\Bundle\ApiBundle\CommandHandler\SendContactRequestHandler;
use Sylius\Bundle\ApiBundle\Context\UserContextInterface;
return static function (ContainerConfigurator $container) {
$services = $container->services();
@ -81,6 +82,7 @@ return static function (ContainerConfigurator $container) {
service('sylius.modifier.order'),
service('sylius.factory.order_item'),
service('sylius.modifier.order_item_quantity'),
service(UserContextInterface::class)
])
->tag('messenger.message_handler', ['bus' => 'sylius.command_bus'])
;

View file

@ -50,6 +50,7 @@ final class TranslatableDenormalizer implements DenormalizerInterface, Denormali
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return
is_array($data) &&
Request::METHOD_POST === ($context[ContextKeys::HTTP_REQUEST_METHOD_TYPE] ?? null) &&
!isset($context[self::getAlreadyCalledKey($type)]) &&
is_a($type, TranslatableInterface::class, true)

View file

@ -0,0 +1,180 @@
<?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\Tests\Doctrine\ORM\Filter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\ApiBundle\Doctrine\ORM\Filter\ProductPriceOrderFilter;
use Sylius\Bundle\ApiBundle\Serializer\ContextKeys;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\ProductInterface;
final class ProductPriceOrderFilterTest extends TestCase
{
/** @var MockObject&QueryBuilder */
private MockObject $queryBuilder;
/** @var MockObject&QueryNameGeneratorInterface */
private MockObject $queryNameGenerator;
/** @var MockObject&EntityManagerInterface */
private MockObject $entityManager;
/** @var MockObject&ChannelInterface */
private MockObject $channel;
protected function setUp(): void
{
$this->queryBuilder = $this->createMock(QueryBuilder::class);
$this->queryNameGenerator = $this->createMock(QueryNameGeneratorInterface::class);
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->channel = $this->createMock(ChannelInterface::class);
}
/** @test */
public function it_applies_ascending_order_by_price(): void
{
$this->configureQueryBuilderForValidDirection();
$this->queryBuilder
->expects($this->once())
->method('orderBy')
->with('channelPricing.price', 'asc')
->willReturnSelf()
;
$this->applyFilter('asc');
}
/** @test */
public function it_applies_descending_order_by_price(): void
{
$this->configureQueryBuilderForValidDirection();
$this->queryBuilder
->expects($this->once())
->method('orderBy')
->with('channelPricing.price', 'desc')
->willReturnSelf()
;
$this->applyFilter('desc');
}
/** @test */
public function it_applies_order_with_uppercase_direction(): void
{
$this->configureQueryBuilderForValidDirection();
$this->queryBuilder
->expects($this->once())
->method('orderBy')
->with('channelPricing.price', 'asc')
->willReturnSelf()
;
$this->applyFilter('ASC');
}
/** @test */
public function it_does_not_apply_order_with_invalid_direction(): void
{
$this->queryBuilder
->expects($this->never())
->method('orderBy')
;
$this->applyFilter('INVALID');
}
/** @test */
public function it_does_not_apply_order_with_dql_injection_payload(): void
{
$this->queryBuilder
->expects($this->never())
->method('orderBy')
;
$this->applyFilter('ASC, variant.code DESC');
}
private function configureQueryBuilderForValidDirection(): void
{
$subQueryBuilder = $this->createMock(QueryBuilder::class);
$subQueryBuilder->method('select')->willReturnSelf();
$subQueryBuilder->method('innerJoin')->willReturnSelf();
$subQueryBuilder->method('andWhere')->willReturnSelf();
$subQueryBuilder->method('getDQL')->willReturn('SELECT min(v.position) FROM Product m INNER JOIN m.variants v WHERE m.id = :productId AND v.enabled = :enabled');
$entityRepository = $this->createMock(EntityRepository::class);
$entityRepository->method('createQueryBuilder')->willReturn($subQueryBuilder);
$this->entityManager
->method('getRepository')
->with(ProductInterface::class)
->willReturn($entityRepository)
;
$this->queryBuilder
->method('getEntityManager')
->willReturn($this->entityManager)
;
$this->queryBuilder
->method('getRootAliases')
->willReturn(['o'])
;
$this->queryBuilder->method('addSelect')->willReturnSelf();
$this->queryBuilder->method('innerJoin')->willReturnSelf();
$this->queryBuilder->method('andWhere')->willReturnSelf();
$this->queryBuilder->method('setParameter')->willReturnSelf();
$this->queryBuilder
->method('expr')
->willReturn(new \Doctrine\ORM\Query\Expr())
;
$this->queryNameGenerator
->method('generateParameterName')
->willReturn('param')
;
$this->channel
->method('getCode')
->willReturn('WEB')
;
}
private function applyFilter(string $direction): void
{
$managerRegistry = $this->createMock(ManagerRegistry::class);
$filter = new ProductPriceOrderFilter($managerRegistry);
$filter->apply(
$this->queryBuilder,
$this->queryNameGenerator,
ProductInterface::class,
null,
[
'filters' => ['order' => ['price' => $direction]],
ContextKeys::CHANNEL => $this->channel,
],
);
}
}

View file

@ -0,0 +1,158 @@
<?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\Tests\Doctrine\ORM\Filter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\ApiBundle\Doctrine\ORM\Filter\TranslationOrderNameAndLocaleFilter;
use Sylius\Component\Core\Model\ProductInterface;
final class TranslationOrderNameAndLocaleFilterTest extends TestCase
{
/** @var MockObject&QueryBuilder */
private MockObject $queryBuilder;
/** @var MockObject&QueryNameGeneratorInterface */
private MockObject $queryNameGenerator;
/** @var MockObject&EntityManagerInterface */
private MockObject $entityManager;
/** @var MockObject&ClassMetadata */
private MockObject $classMetadata;
protected function setUp(): void
{
$this->queryBuilder = $this->createMock(QueryBuilder::class);
$this->queryNameGenerator = $this->createMock(QueryNameGeneratorInterface::class);
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->classMetadata = $this->createMock(ClassMetadata::class);
}
/** @test */
public function it_applies_ascending_order_by_translation_name(): void
{
$this->configureQueryBuilderWithTranslationsAssociation();
$this->queryBuilder
->expects($this->once())
->method('orderBy')
->with('translation.name', 'asc')
->willReturnSelf()
;
$this->applyFilter('asc');
}
/** @test */
public function it_applies_descending_order_by_translation_name(): void
{
$this->configureQueryBuilderWithTranslationsAssociation();
$this->queryBuilder
->expects($this->once())
->method('orderBy')
->with('translation.name', 'desc')
->willReturnSelf()
;
$this->applyFilter('desc');
}
/** @test */
public function it_applies_order_with_uppercase_direction(): void
{
$this->configureQueryBuilderWithTranslationsAssociation();
$this->queryBuilder
->expects($this->once())
->method('orderBy')
->with('translation.name', 'desc')
->willReturnSelf()
;
$this->applyFilter('DESC');
}
/** @test */
public function it_does_not_apply_order_with_invalid_direction(): void
{
$this->configureQueryBuilderWithTranslationsAssociation();
$this->queryBuilder
->expects($this->never())
->method('orderBy')
;
$this->applyFilter('INVALID');
}
/** @test */
public function it_does_not_apply_order_with_dql_injection_payload(): void
{
$this->configureQueryBuilderWithTranslationsAssociation();
$this->queryBuilder
->expects($this->never())
->method('orderBy')
;
$this->applyFilter('ASC, variant.code DESC');
}
private function configureQueryBuilderWithTranslationsAssociation(): void
{
$this->queryBuilder
->method('getEntityManager')
->willReturn($this->entityManager)
;
$this->queryBuilder
->method('getRootAliases')
->willReturn(['o'])
;
$this->entityManager
->method('getClassMetadata')
->with(ProductInterface::class)
->willReturn($this->classMetadata)
;
$this->classMetadata
->method('hasAssociation')
->with('translations')
->willReturn(true)
;
}
private function applyFilter(string $direction): void
{
$managerRegistry = $this->createMock(ManagerRegistry::class);
$filter = new TranslationOrderNameAndLocaleFilter($managerRegistry);
$filter->apply(
$this->queryBuilder,
$this->queryNameGenerator,
ProductInterface::class,
null,
[
'filters' => ['order' => ['translation.name' => $direction]],
],
);
}
}

View file

@ -40,6 +40,17 @@ final class TranslatableDenormalizerTest extends TestCase
public function testOnlySupportsTranslatableResource(): void
{
self::assertFalse(
$this->translatableDenormalizer->supportsDenormalization(
'iri-string',
TranslatableInterface::class,
null,
[
ContextKeys::HTTP_REQUEST_METHOD_TYPE => 'POST',
],
),
);
self::assertFalse(
$this->translatableDenormalizer->supportsDenormalization(
[],

View file

@ -0,0 +1,181 @@
<?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\CoreBundle\Doctrine\ORM\Promotion\Modifier;
use Doctrine\DBAL\Connection;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PromotionCouponInterface;
use Sylius\Component\Core\Promotion\Exception\PromotionUsageLimitReachedException;
use Sylius\Component\Core\Promotion\Modifier\OrderPromotionsUsageModifierInterface;
use Sylius\Component\Promotion\Model\PromotionInterface;
final class AtomicOrderPromotionsUsageModifier implements OrderPromotionsUsageModifierInterface
{
private Connection $connection;
public function __construct(Connection $connection)
{
$this->connection = $connection;
}
public function increment(OrderInterface $order): void
{
foreach ($order->getPromotions() as $promotion) {
$this->incrementPromotionUsage($promotion);
}
/** @var PromotionCouponInterface|null $coupon */
$coupon = $order->getPromotionCoupon();
if (null === $coupon) {
return;
}
$this->incrementCouponUsage($coupon, $order);
}
public function decrement(OrderInterface $order): void
{
foreach ($order->getPromotions() as $promotion) {
$this->decrementPromotionUsage($promotion);
}
/** @var PromotionCouponInterface|null $coupon */
$coupon = $order->getPromotionCoupon();
if (null === $coupon) {
return;
}
if (OrderInterface::STATE_CANCELLED === $order->getState() && !$coupon->isReusableFromCancelledOrders()) {
return;
}
$this->decrementCouponUsage($coupon);
}
private function incrementPromotionUsage(PromotionInterface $promotion): void
{
$affected = $this->connection->executeStatement(
'UPDATE sylius_promotion
SET used = used + 1
WHERE id = :id AND (usage_limit IS NULL OR used < usage_limit)',
['id' => $promotion->getId()],
);
if (0 === $affected) {
throw PromotionUsageLimitReachedException::withPromotionCode((string) $promotion->getCode());
}
$newUsed = (int) $this->connection->fetchOne(
'SELECT used FROM sylius_promotion WHERE id = :id',
['id' => $promotion->getId()],
);
$promotion->setUsed($newUsed);
}
private function decrementPromotionUsage(PromotionInterface $promotion): void
{
$this->connection->executeStatement(
'UPDATE sylius_promotion SET used = GREATEST(used - 1, 0) WHERE id = :id',
['id' => $promotion->getId()],
);
$newUsed = (int) $this->connection->fetchOne(
'SELECT used FROM sylius_promotion WHERE id = :id',
['id' => $promotion->getId()],
);
$promotion->setUsed($newUsed);
}
private function incrementCouponUsage(PromotionCouponInterface $coupon, OrderInterface $order): void
{
$row = $this->connection->fetchAssociative(
'SELECT used, usage_limit, per_customer_usage_limit FROM sylius_promotion_coupon WHERE id = :id FOR UPDATE',
['id' => $coupon->getId()],
);
if (false === $row) {
throw PromotionUsageLimitReachedException::withCouponCode((string) $coupon->getCode());
}
if (null !== $row['usage_limit'] && (int) $row['used'] >= (int) $row['usage_limit']) {
throw PromotionUsageLimitReachedException::withCouponCode((string) $coupon->getCode());
}
if (null !== $row['per_customer_usage_limit']) {
$this->assertPerCustomerCouponUsageLimitNotReached(
$coupon,
$order,
(int) $row['per_customer_usage_limit'],
);
}
$this->connection->executeStatement(
'UPDATE sylius_promotion_coupon SET used = used + 1 WHERE id = :id',
['id' => $coupon->getId()],
);
$coupon->setUsed((int) $row['used'] + 1);
}
private function assertPerCustomerCouponUsageLimitNotReached(
PromotionCouponInterface $coupon,
OrderInterface $order,
int $perCustomerUsageLimit,
): void {
$customer = $order->getCustomer();
if (null === $customer || null === $customer->getId()) {
return;
}
$sql = 'SELECT o.id FROM sylius_order o
WHERE o.customer_id = :customerId
AND o.promotion_coupon_id = :couponId
AND o.state != :stateCart';
$params = [
'customerId' => $customer->getId(),
'couponId' => $coupon->getId(),
'stateCart' => OrderInterface::STATE_CART,
];
if ($coupon->isReusableFromCancelledOrders()) {
$sql .= ' AND o.state != :stateCancelled';
$params['stateCancelled'] = OrderInterface::STATE_CANCELLED;
}
$sql .= ' FOR UPDATE';
$count = count($this->connection->fetchAllAssociative($sql, $params));
if ($count >= $perCustomerUsageLimit) {
throw PromotionUsageLimitReachedException::withCouponCode((string) $coupon->getCode());
}
}
private function decrementCouponUsage(PromotionCouponInterface $coupon): void
{
$this->connection->executeStatement(
'UPDATE sylius_promotion_coupon SET used = GREATEST(used - 1, 0) WHERE id = :id',
['id' => $coupon->getId()],
);
$newUsed = (int) $this->connection->fetchOne(
'SELECT used FROM sylius_promotion_coupon WHERE id = :id',
['id' => $coupon->getId()],
);
$coupon->setUsed($newUsed);
}
}

View file

@ -93,7 +93,7 @@ class UserProvider extends BaseUserProvider implements AccountConnectorInterface
*/
private function createUserByOAuthUserResponse(UserResponseInterface $response): SyliusUserInterface
{
/** @var SyliusUserInterface|object $user */
/** @var SyliusUserInterface $user */
$user = $this->userFactory->createNew();
Assert::isInstanceOf($user, SyliusUserInterface::class);

View file

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
use Sylius\Bundle\CoreBundle\Doctrine\ORM\Promotion\Modifier\AtomicOrderPromotionsUsageModifier;
use Sylius\Bundle\CoreBundle\Form\Type\Promotion\Action\ChannelBasedFixedDiscountConfigurationType;
use Sylius\Bundle\CoreBundle\Form\Type\Promotion\Action\ChannelBasedUnitFixedDiscountConfigurationType;
use Sylius\Bundle\CoreBundle\Form\Type\Promotion\Action\ChannelBasedUnitPercentageDiscountConfigurationType;
@ -203,6 +204,12 @@ return static function (ContainerConfigurator $container) {
;
$services->alias(OrderPromotionsUsageModifierInterface::class, 'sylius.modifier.promotion.order_usage')->public();
$services
->set('sylius.modifier.promotion.order_usage.atomic', AtomicOrderPromotionsUsageModifier::class)
->decorate('sylius.modifier.promotion.order_usage')
->args([service('doctrine.dbal.default_connection')])
;
$services
->set('sylius.updater.promotion_rule.has_taxon', HasTaxonRuleUpdater::class)
->args([

View file

@ -40,15 +40,15 @@ use Symfony\Component\DependencyInjection\ContainerBuilder;
final class SyliusCoreBundle extends AbstractResourceBundle
{
public const VERSION = '2.2.3-DEV';
public const VERSION = '2.2.4-DEV';
public const VERSION_ID = '20203';
public const VERSION_ID = '20204';
public const MAJOR_VERSION = '2';
public const MINOR_VERSION = '2';
public const RELEASE_VERSION = '3';
public const RELEASE_VERSION = '4';
public const EXTRA_VERSION = 'DEV';

View file

@ -0,0 +1,35 @@
<?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\PayumBundle\Client;
use Payum\Core\HttpClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
final class PaypalHeaderInjectingClient implements HttpClientInterface
{
public function __construct(private HttpClientInterface $innerClient)
{
}
public function send(RequestInterface $request): ResponseInterface
{
$host = $request->getUri()->getHost();
if (str_contains($host, 'api-3t.paypal.com')) {
$request = $request->withHeader('PayPal-Partner-Attribution-Id', 'SYLIUS_MP_PPCP');
}
return $this->innerClient->send($request);
}
}

View file

@ -0,0 +1,34 @@
<?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\PayumBundle\DependencyInjection\Compiler;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
final class InjectPaypalHttpClientPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->has('payum.builder')) {
return;
}
$payumBuilder = $container->findDefinition('payum.builder');
$payumBuilder->addMethodCall('addGatewayFactoryConfig', [
'paypal_express_checkout',
['payum.http_client' => new Reference('sylius_payum.http_client')],
]);
}
}

View file

@ -14,6 +14,7 @@ declare(strict_types=1);
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
use Psr\Http\Client\ClientInterface;
use Sylius\Bundle\PayumBundle\Client\PaypalHeaderInjectingClient;
use Sylius\Bundle\PayumBundle\HttpClient\HttpClient;
return static function (ContainerConfigurator $container) {
@ -23,4 +24,9 @@ return static function (ContainerConfigurator $container) {
$services = $container->services();
$services->set('sylius_payum.http_client', HttpClient::class)->args([service(ClientInterface::class)]);
$services
->set('sylius_payum.http_client.paypal_header_injecting', PaypalHeaderInjectingClient::class)
->decorate('sylius_payum.http_client')
->args([service('.inner')])
;
};

View file

@ -20,7 +20,7 @@ class DoctrineStorage extends BaseDoctrineStorage
{
protected function doFind($id): ?object
{
/** @var object|GatewayConfigInterface|null $resource */
/** @var object|null $resource */
$resource = parent::doFind($id);
if (null === $resource) {
@ -37,7 +37,7 @@ class DoctrineStorage extends BaseDoctrineStorage
/** @param array<mixed> $criteria */
public function findBy(array $criteria): array
{
/** @var object[]|GatewayConfigInterface[] $resources */
/** @var array<object> $resources */
$resources = parent::findBy($criteria);
return array_filter($resources, static function ($resource) {

View file

@ -15,6 +15,7 @@ namespace Sylius\Bundle\PayumBundle;
use Sylius\Bundle\PayumBundle\DependencyInjection\Compiler\ConditionalGatewayConfigEncryptionCheckerDecoratorPass;
use Sylius\Bundle\PayumBundle\DependencyInjection\Compiler\InjectContainerIntoControllersPass;
use Sylius\Bundle\PayumBundle\DependencyInjection\Compiler\InjectPaypalHttpClientPass;
use Sylius\Bundle\PayumBundle\DependencyInjection\Compiler\UseTweakedDoctrineStoragePass;
use Sylius\Bundle\ResourceBundle\AbstractResourceBundle;
use Sylius\Bundle\ResourceBundle\SyliusResourceBundle;
@ -36,6 +37,7 @@ final class SyliusPayumBundle extends AbstractResourceBundle
$container->addCompilerPass(new ConditionalGatewayConfigEncryptionCheckerDecoratorPass());
$container->addCompilerPass(new InjectContainerIntoControllersPass());
$container->addCompilerPass(new InjectPaypalHttpClientPass());
$container->addCompilerPass(new UseTweakedDoctrineStoragePass());
}
}

View file

@ -19,12 +19,16 @@ use Sylius\Component\Core\Model\ChannelInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
final readonly class CurrencySwitchController
{
use RedirectTrait;
public function __construct(
private CurrencyStorageInterface $currencyStorage,
private ChannelContextInterface $channelContext,
private ?RouterInterface $router = null,
) {
}
@ -35,6 +39,6 @@ final readonly class CurrencySwitchController
$this->currencyStorage->set($channel, $code);
return new RedirectResponse($request->headers->get('referer', $request->getSchemeAndHttpHost()));
return new RedirectResponse($this->getRedirectUrl($request, $this->router));
}
}

View file

@ -0,0 +1,47 @@
<?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\ShopBundle\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\RouterInterface;
trait RedirectTrait
{
/** @param array<string, mixed> $fallbackParameters */
private function getRedirectUrl(Request $request, ?RouterInterface $router, string $fallbackRoute = 'sylius_shop_homepage', array $fallbackParameters = []): string
{
if (null === $router) {
return $request->headers->get('referer', $request->getSchemeAndHttpHost());
}
$redirect = $request->attributes->get('_sylius', [])['redirect'] ?? null;
if (null !== $redirect) {
return $router->generate($redirect);
}
$referer = $request->headers->get('referer');
if (null !== $referer) {
try {
$match = $router->match(parse_url($referer, \PHP_URL_PATH));
$route = $match['_route'];
unset($match['_route'], $match['_controller']);
return $router->generate($route, array_merge($match, $fallbackParameters));
} catch (\Exception $e) {
}
}
return $router->generate($fallbackRoute, $fallbackParameters);
}
}

View file

@ -13,26 +13,28 @@ declare(strict_types=1);
namespace Sylius\Bundle\ShopBundle\Locale;
use Sylius\Bundle\ShopBundle\Controller\RedirectTrait;
use Sylius\Component\Channel\Context\ChannelContextInterface;
use Sylius\Component\Core\Locale\LocaleStorageInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\Matcher\UrlMatcherInterface;
use Symfony\Component\Routing\RouterInterface;
final class StorageBasedLocaleSwitcher implements LocaleSwitcherInterface
{
use RedirectTrait;
public function __construct(
private LocaleStorageInterface $localeStorage,
private ChannelContextInterface $channelContext,
private ?UrlMatcherInterface $urlMatcher = null,
private ?RouterInterface $router = null,
) {
if (null === $this->urlMatcher) {
if (null === $this->router) {
trigger_deprecation(
'sylius/shop-bundle',
'2.1',
'Not passing a "%s" to "%s" is deprecated and will be required in Sylius 3.0.',
UrlMatcherInterface::class,
RouterInterface::class,
self::class,
);
}
@ -41,16 +43,12 @@ final class StorageBasedLocaleSwitcher implements LocaleSwitcherInterface
public function handle(Request $request, string $localeCode): RedirectResponse
{
$this->localeStorage->set($this->channelContext->getChannel(), $localeCode);
$url = $request->headers->get('referer', $request->getSchemeAndHttpHost());
if ($this->urlMatcher) {
try {
$this->urlMatcher->match($url);
} catch (ResourceNotFoundException) {
return new RedirectResponse($request->getSchemeAndHttpHost());
}
}
return new RedirectResponse($url);
return new RedirectResponse($this->getRedirectUrl(
$request,
$this->router,
'sylius_shop_homepage',
['_locale' => $localeCode],
));
}
}

View file

@ -34,7 +34,7 @@ export default class extends Controller {
window.location.reload();
} else {
const errorElement = this.errorPrototypeTarget.cloneNode(true);
errorElement.innerHTML = response.message;
errorElement.textContent = response.message;
this.errorTarget.innerHTML = errorElement.outerHTML;
}
})

View file

@ -41,6 +41,7 @@ return static function (ContainerConfigurator $container) {
->args([
service('sylius.storage.currency'),
service('sylius.context.channel'),
service('router'),
])
;

View file

@ -48,6 +48,10 @@ class SummaryComponent
#[LiveListener(FormComponent::SYLIUS_SHOP_CART_CHANGED)]
public function refreshCart(#[LiveArg] mixed $cartId): void
{
$this->cart = $this->hydrateResource($cartId);
if ($this->cart === null) {
return;
}
$this->cart = $this->hydrateResource($this->cart->getId());
}
}

View file

@ -59,13 +59,7 @@ class WidgetComponent
#[LiveListener(FormComponent::SYLIUS_SHOP_CART_CLEARED)]
public function refreshCart(#[LiveArg] mixed $cartId = null): void
{
if ($cartId === null) {
$this->cart = $this->getCart();
return;
}
$this->cart = $this->hydrateResource($cartId);
$this->cart = $this->getCart();
}
private function getCart(): ?OrderInterface

View file

@ -17,6 +17,7 @@ use Sylius\Bundle\ShopBundle\Modifier\AddressFormValuesModifierInterface;
use Sylius\Bundle\UiBundle\Twig\Component\ResourceFormComponentTrait;
use Sylius\Bundle\UiBundle\Twig\Component\TemplatePropTrait;
use Sylius\Component\Addressing\Model\AddressInterface;
use Sylius\Component\Core\Model\CustomerInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\ShopUserInterface;
use Sylius\Component\Core\Repository\AddressRepositoryInterface;
@ -81,7 +82,12 @@ class FormComponent
#[LiveListener(AddressBookComponent::SYLIUS_SHOP_ADDRESS_UPDATED)]
public function addressFieldUpdated(#[LiveArg] mixed $addressId, #[LiveArg] string $field): void
{
$address = $this->addressRepository->find($addressId);
$customer = $this->customerContext->getCustomer();
if (!$customer instanceof CustomerInterface) {
return;
}
$address = $this->addressRepository->findOneByCustomer((string) $addressId, $customer);
if (null === $address) {
return;
}

View file

@ -7,6 +7,6 @@
{ label: 'sylius.ui.home'|trans, path: path('sylius_shop_homepage')},
{ label: 'sylius.ui.my_account'|trans, path: path('sylius_shop_account_dashboard')},
{ label: 'sylius.ui.order_history'|trans, path: path('sylius_shop_account_order_index')},
{ label: '<span '~sylius_test_html_attribute('order-number')~'>#'~order.number~'</span>', active: true }
{ label: '#'~order.number, active: true, test_attribute: 'order-number' }
]) }}
</div>

View file

@ -3,9 +3,9 @@
{% for item in items %}
<li class="breadcrumb-item fw-normal{{ item.active is defined and item.active ? ' active' }}">
{% if item.path is defined %}
<a class="link-reset" href="{{ item.path }}">{{ item.label|raw }}</a>
<a class="link-reset" href="{{ item.path }}" {{ item.test_attribute is defined ? sylius_test_html_attribute(item.test_attribute) }}>{{ item.label }}</a>
{% else %}
<span class="text-body-tertiary text-break">{{ item.label|raw }}</span>
<span class="text-body-tertiary text-break" {{ item.test_attribute is defined ? sylius_test_html_attribute(item.test_attribute) }}>{{ item.label }}</span>
{% endif %}
</li>
{% endfor %}

View file

@ -0,0 +1,34 @@
<?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\Component\Core\Promotion\Exception;
use Doctrine\ORM\OptimisticLockException;
final class PromotionUsageLimitReachedException extends OptimisticLockException
{
private function __construct(string $message)
{
parent::__construct($message, null);
}
public static function withPromotionCode(string $code): self
{
return new self(sprintf('The "%s" promotion is no longer applicable.', $code));
}
public static function withCouponCode(string $code): self
{
return new self(sprintf('The "%s" promotion coupon is no longer applicable.', $code));
}
}

View file

@ -86,7 +86,7 @@ final class CustomersTest extends JsonApiTestCase
$this->requestGet('/api/v2/shop/customers/' . $customer->getId());
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_NOT_FOUND);
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_UNAUTHORIZED);
}
#[Test]

View file

@ -19,10 +19,12 @@ use Sylius\Bundle\ApiBundle\Command\Cart\PickupCart;
use Sylius\Bundle\ApiBundle\Command\Checkout\UpdateCart;
use Sylius\Component\Core\Model\Address;
use Sylius\Component\Core\Model\ShipmentInterface;
use Sylius\Component\Core\Model\ShopUserInterface;
use Sylius\Component\Core\Repository\ShipmentRepositoryInterface;
use Sylius\Tests\Api\JsonApiTestCase;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
final class ShippingMethodsTest extends JsonApiTestCase
{
@ -185,7 +187,24 @@ final class ShippingMethodsTest extends JsonApiTestCase
productVariantCode: 'MUG_BLUE',
quantity: 3,
);
$commandBus->dispatch($addItemToCartCommand);
$tokenStorage = self::getContainer()->get('security.token_storage');
$previousToken = $tokenStorage->getToken();
$shopUserRepository = self::getContainer()->get('sylius.repository.shop_user');
/** @var ShopUserInterface|null $user */
$user = $shopUserRepository->findOneByEmail($customerEmail);
if (null !== $user) {
$tokenStorage->setToken(
new UsernamePasswordToken($user, 'api_shop', $user->getRoles()),
);
}
try {
$commandBus->dispatch($addItemToCartCommand);
} finally {
$tokenStorage->setToken($previousToken);
}
$address = new Address();
$address->setFirstName('John');

View file

@ -22,12 +22,16 @@ use Sylius\Bundle\ApiBundle\Command\Checkout\CompleteOrder;
use Sylius\Bundle\ApiBundle\Command\Checkout\UpdateCart;
use Sylius\Component\Core\Model\Address;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\ShopUserInterface;
use Sylius\Component\Core\OrderPaymentTransitions;
use Sylius\Component\Core\Repository\OrderRepositoryInterface;
use Sylius\Component\Order\OrderTransitions;
use Sylius\Component\Payment\PaymentTransitions;
use Sylius\Component\User\Repository\UserRepositoryInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\HandledStamp;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Webmozart\Assert\Assert;
trait OrderPlacerTrait
@ -56,7 +60,7 @@ trait OrderPlacerTrait
$this->checkSetUpOrderPlacerCalled();
$this->pickUpCart($tokenValue);
$this->addItemToCart($productVariantCode, $quantity, $tokenValue);
$this->addItemToCart($productVariantCode, $quantity, $tokenValue, $email);
$cart = $this->updateCartWithAddress($tokenValue, $email);
$this->dispatchShippingMethodChooseCommand(
$tokenValue,
@ -86,7 +90,7 @@ trait OrderPlacerTrait
$this->checkSetUpOrderPlacerCalled();
$this->pickUpCart(tokenValue: $tokenValue, email: $email);
$this->addItemToCart($productVariantCode, $quantity, $tokenValue);
$this->addItemToCart($productVariantCode, $quantity, $tokenValue, $email);
$cart = $this->updateCartWithAddressAndCouponCode($tokenValue, $email, $couponCode);
$this->dispatchShippingMethodChooseCommand(
$tokenValue,
@ -203,7 +207,7 @@ trait OrderPlacerTrait
return $tokenValue;
}
protected function addItemToCart(string $productVariantCode, int $quantity, string $tokenValue): string
protected function addItemToCart(string $productVariantCode, int $quantity, string $tokenValue, ?string $email = null): string
{
$addItemToCartCommand = new AddItemToCart(
orderTokenValue: $tokenValue,
@ -211,7 +215,12 @@ trait OrderPlacerTrait
quantity: $quantity,
);
$this->commandBus->dispatch($addItemToCartCommand);
if (null === $email) {
$cart = $this->orderRepository->findCartByTokenValue($tokenValue);
$email = $cart?->getCustomer()?->getEmail();
}
$this->dispatchWithShopUserToken($email, $addItemToCartCommand);
return $tokenValue;
}
@ -261,6 +270,33 @@ trait OrderPlacerTrait
return $order;
}
private function dispatchWithShopUserToken(?string $email, object $command): void
{
/** @var TokenStorageInterface $tokenStorage */
$tokenStorage = self::getContainer()->get('security.token_storage');
$previousToken = $tokenStorage->getToken();
if (null !== $email) {
/** @var UserRepositoryInterface $shopUserRepository */
$shopUserRepository = self::getContainer()->get('sylius.repository.shop_user');
/** @var ShopUserInterface|null $user */
$user = $shopUserRepository->findOneByEmail($email);
if (null !== $user) {
$tokenStorage->setToken(
new UsernamePasswordToken($user, 'api_shop', $user->getRoles()),
);
}
}
try {
$this->commandBus->dispatch($command);
} finally {
$tokenStorage->setToken($previousToken);
}
}
private function checkSetUpOrderPlacerCalled(): void
{
if (!$this->isSetUpOrderPlacerCalled) {