mirror of
https://github.com/Sylius/Sylius.git
synced 2026-07-05 20:57:12 +00:00
[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:
commit
385a5e501f
48 changed files with 1048 additions and 85 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
])
|
||||
;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ final class Configuration implements ConfigurationInterface
|
|||
->defaultTrue()
|
||||
->end()
|
||||
->integerNode('frequency')
|
||||
->defaultValue(60)
|
||||
->defaultValue(1440) // in minutes, 1440 = 24h
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)$/);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
[],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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')],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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')])
|
||||
;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
src/Sylius/Bundle/ShopBundle/Controller/RedirectTrait.php
Normal file
47
src/Sylius/Bundle/ShopBundle/Controller/RedirectTrait.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ return static function (ContainerConfigurator $container) {
|
|||
->args([
|
||||
service('sylius.storage.currency'),
|
||||
service('sylius.context.channel'),
|
||||
service('router'),
|
||||
])
|
||||
;
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue