From 32f003d5da64910203e770f5f0dd4fdce2163adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Kali=C5=84ski?= Date: Thu, 25 Jun 2026 13:31:15 +0200 Subject: [PATCH 1/5] Introduce channel-based configuration for promotion rules and actions --- ...sedPercentageDiscountConfigurationType.php | 38 +++++ ...nnelBasedCartQuantityConfigurationType.php | 38 +++++ ...lBasedContainsProductConfigurationType.php | 37 +++++ ...nelBasedCustomerGroupConfigurationType.php | 37 +++++ .../ChannelBasedHasTaxonConfigurationType.php | 37 +++++ .../ChannelBasedNthOrderConfigurationType.php | 37 +++++ ...lBasedShippingCountryConfigurationType.php | 37 +++++ .../config/app/sylius/sylius_promotion.yml | 24 +++ .../Resources/config/services/promotion.php | 57 +++++++ .../config/validation/PromotionAction.xml | 40 +++++ .../config/validation/PromotionRule.xml | 157 ++++++++++++++++++ .../Resources/translations/messages.en.yml | 8 + .../PerChannelPromotionActionCommand.php | 67 ++++++++ .../Checker/Rule/PerChannelRuleChecker.php | 50 ++++++ 14 files changed, 664 insertions(+) create mode 100644 src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Action/ChannelBasedPercentageDiscountConfigurationType.php create mode 100644 src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedCartQuantityConfigurationType.php create mode 100644 src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedContainsProductConfigurationType.php create mode 100644 src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedCustomerGroupConfigurationType.php create mode 100644 src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedHasTaxonConfigurationType.php create mode 100644 src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedNthOrderConfigurationType.php create mode 100644 src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedShippingCountryConfigurationType.php create mode 100644 src/Sylius/Component/Core/Promotion/Action/PerChannelPromotionActionCommand.php create mode 100644 src/Sylius/Component/Core/Promotion/Checker/Rule/PerChannelRuleChecker.php diff --git a/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Action/ChannelBasedPercentageDiscountConfigurationType.php b/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Action/ChannelBasedPercentageDiscountConfigurationType.php new file mode 100644 index 0000000000..ff562e01b3 --- /dev/null +++ b/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Action/ChannelBasedPercentageDiscountConfigurationType.php @@ -0,0 +1,38 @@ +setDefaults([ + 'entry_type' => PercentageDiscountConfigurationType::class, + 'entry_options' => fn (ChannelInterface $channel) => [ + 'label' => $channel->getName(), + ], + ]); + } + + public function getParent(): string + { + return ChannelCollectionType::class; + } +} diff --git a/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedCartQuantityConfigurationType.php b/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedCartQuantityConfigurationType.php new file mode 100644 index 0000000000..2cc9a87753 --- /dev/null +++ b/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedCartQuantityConfigurationType.php @@ -0,0 +1,38 @@ +setDefaults([ + 'entry_type' => CartQuantityConfigurationType::class, + 'entry_options' => fn (ChannelInterface $channel) => [ + 'label' => $channel->getName(), + ], + ]); + } + + public function getParent(): string + { + return ChannelCollectionType::class; + } +} diff --git a/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedContainsProductConfigurationType.php b/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedContainsProductConfigurationType.php new file mode 100644 index 0000000000..57ede6a21d --- /dev/null +++ b/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedContainsProductConfigurationType.php @@ -0,0 +1,37 @@ +setDefaults([ + 'entry_type' => ContainsProductConfigurationType::class, + 'entry_options' => fn (ChannelInterface $channel) => [ + 'label' => $channel->getName(), + ], + ]); + } + + public function getParent(): string + { + return ChannelCollectionType::class; + } +} diff --git a/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedCustomerGroupConfigurationType.php b/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedCustomerGroupConfigurationType.php new file mode 100644 index 0000000000..84f2fbce7d --- /dev/null +++ b/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedCustomerGroupConfigurationType.php @@ -0,0 +1,37 @@ +setDefaults([ + 'entry_type' => CustomerGroupConfigurationType::class, + 'entry_options' => fn (ChannelInterface $channel) => [ + 'label' => $channel->getName(), + ], + ]); + } + + public function getParent(): string + { + return ChannelCollectionType::class; + } +} diff --git a/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedHasTaxonConfigurationType.php b/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedHasTaxonConfigurationType.php new file mode 100644 index 0000000000..6763e48bf5 --- /dev/null +++ b/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedHasTaxonConfigurationType.php @@ -0,0 +1,37 @@ +setDefaults([ + 'entry_type' => HasTaxonConfigurationType::class, + 'entry_options' => fn (ChannelInterface $channel) => [ + 'label' => $channel->getName(), + ], + ]); + } + + public function getParent(): string + { + return ChannelCollectionType::class; + } +} diff --git a/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedNthOrderConfigurationType.php b/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedNthOrderConfigurationType.php new file mode 100644 index 0000000000..9b15ce040d --- /dev/null +++ b/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedNthOrderConfigurationType.php @@ -0,0 +1,37 @@ +setDefaults([ + 'entry_type' => NthOrderConfigurationType::class, + 'entry_options' => fn (ChannelInterface $channel) => [ + 'label' => $channel->getName(), + ], + ]); + } + + public function getParent(): string + { + return ChannelCollectionType::class; + } +} diff --git a/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedShippingCountryConfigurationType.php b/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedShippingCountryConfigurationType.php new file mode 100644 index 0000000000..0141e1b2da --- /dev/null +++ b/src/Sylius/Bundle/CoreBundle/Form/Type/Promotion/Rule/ChannelBasedShippingCountryConfigurationType.php @@ -0,0 +1,37 @@ +setDefaults([ + 'entry_type' => ShippingCountryConfigurationType::class, + 'entry_options' => fn (ChannelInterface $channel) => [ + 'label' => $channel->getName(), + ], + ]); + } + + public function getParent(): string + { + return ChannelCollectionType::class; + } +} diff --git a/src/Sylius/Bundle/CoreBundle/Resources/config/app/sylius/sylius_promotion.yml b/src/Sylius/Bundle/CoreBundle/Resources/config/app/sylius/sylius_promotion.yml index 47ac5ce8ad..983b0857c8 100644 --- a/src/Sylius/Bundle/CoreBundle/Resources/config/app/sylius/sylius_promotion.yml +++ b/src/Sylius/Bundle/CoreBundle/Resources/config/app/sylius/sylius_promotion.yml @@ -44,9 +44,15 @@ sylius_promotion: order_percentage_discount: - 'sylius' - 'sylius_promotion_action_order_percentage_discount' + order_percentage_discount_per_channel: + - 'sylius' + - 'sylius_promotion_action_order_percentage_discount_per_channel' shipping_percentage_discount: - 'sylius' - 'sylius_promotion_action_shipping_percentage_discount' + shipping_percentage_discount_per_channel: + - 'sylius' + - 'sylius_promotion_action_shipping_percentage_discount_per_channel' order_fixed_discount: - 'sylius' - 'sylius_promotion_action_order_fixed_discount' @@ -62,18 +68,36 @@ sylius_promotion: customer_group: - 'sylius' - 'sylius_promotion_rule_customer_group' + customer_group_per_channel: + - 'sylius' + - 'sylius_promotion_rule_customer_group_per_channel' nth_order: - 'sylius' - 'sylius_promotion_rule_nth_order' + nth_order_per_channel: + - 'sylius' + - 'sylius_promotion_rule_nth_order_per_channel' shipping_country: - 'sylius' - 'sylius_promotion_rule_shipping_country' + shipping_country_per_channel: + - 'sylius' + - 'sylius_promotion_rule_shipping_country_per_channel' has_taxon: - 'sylius' - 'sylius_promotion_rule_has_taxon' + has_taxon_per_channel: + - 'sylius' + - 'sylius_promotion_rule_has_taxon_per_channel' total_of_items_from_taxon: - 'sylius' - 'sylius_promotion_rule_total_of_items_from_taxon' contains_product: - 'sylius' - 'sylius_promotion_rule_contains_product' + contains_product_per_channel: + - 'sylius' + - 'sylius_promotion_rule_contains_product_per_channel' + cart_quantity_per_channel: + - 'sylius' + - 'sylius_promotion_rule_cart_quantity_per_channel' diff --git a/src/Sylius/Bundle/CoreBundle/Resources/config/services/promotion.php b/src/Sylius/Bundle/CoreBundle/Resources/config/services/promotion.php index aba1a809f8..47e1257050 100644 --- a/src/Sylius/Bundle/CoreBundle/Resources/config/services/promotion.php +++ b/src/Sylius/Bundle/CoreBundle/Resources/config/services/promotion.php @@ -15,9 +15,16 @@ 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\ChannelBasedPercentageDiscountConfigurationType; use Sylius\Bundle\CoreBundle\Form\Type\Promotion\Action\ChannelBasedUnitFixedDiscountConfigurationType; use Sylius\Bundle\CoreBundle\Form\Type\Promotion\Action\ChannelBasedUnitPercentageDiscountConfigurationType; +use Sylius\Bundle\CoreBundle\Form\Type\Promotion\Rule\ChannelBasedCartQuantityConfigurationType; +use Sylius\Bundle\CoreBundle\Form\Type\Promotion\Rule\ChannelBasedContainsProductConfigurationType; +use Sylius\Bundle\CoreBundle\Form\Type\Promotion\Rule\ChannelBasedCustomerGroupConfigurationType; +use Sylius\Bundle\CoreBundle\Form\Type\Promotion\Rule\ChannelBasedHasTaxonConfigurationType; use Sylius\Bundle\CoreBundle\Form\Type\Promotion\Rule\ChannelBasedItemTotalConfigurationType; +use Sylius\Bundle\CoreBundle\Form\Type\Promotion\Rule\ChannelBasedNthOrderConfigurationType; +use Sylius\Bundle\CoreBundle\Form\Type\Promotion\Rule\ChannelBasedShippingCountryConfigurationType; use Sylius\Bundle\CoreBundle\Form\Type\Promotion\Rule\ChannelBasedTotalOfItemsFromTaxonConfigurationType; use Sylius\Bundle\CoreBundle\Form\Type\Promotion\Rule\ContainsProductConfigurationType; use Sylius\Bundle\CoreBundle\Form\Type\Promotion\Rule\CustomerGroupConfigurationType; @@ -32,6 +39,7 @@ use Sylius\Component\Core\Factory\PromotionRuleFactory; use Sylius\Component\Core\Factory\PromotionRuleFactoryInterface; use Sylius\Component\Core\Promotion\Action\FixedDiscountPromotionActionCommand; use Sylius\Component\Core\Promotion\Action\PercentageDiscountPromotionActionCommand; +use Sylius\Component\Core\Promotion\Action\PerChannelPromotionActionCommand; use Sylius\Component\Core\Promotion\Action\ShippingPercentageDiscountPromotionActionCommand; use Sylius\Component\Core\Promotion\Action\UnitFixedDiscountPromotionActionCommand; use Sylius\Component\Core\Promotion\Action\UnitPercentageDiscountPromotionActionCommand; @@ -46,6 +54,7 @@ use Sylius\Component\Core\Promotion\Checker\Rule\CustomerGroupRuleChecker; use Sylius\Component\Core\Promotion\Checker\Rule\HasTaxonRuleChecker; use Sylius\Component\Core\Promotion\Checker\Rule\ItemTotalRuleChecker; use Sylius\Component\Core\Promotion\Checker\Rule\NthOrderRuleChecker; +use Sylius\Component\Core\Promotion\Checker\Rule\PerChannelRuleChecker; use Sylius\Component\Core\Promotion\Checker\Rule\ShippingCountryRuleChecker; use Sylius\Component\Core\Promotion\Checker\Rule\TotalOfItemsFromTaxonRuleChecker; use Sylius\Component\Core\Promotion\Checker\TaxonInPromotionRuleChecker; @@ -85,23 +94,47 @@ return static function (ContainerConfigurator $container) { ->tag('sylius.promotion_rule_checker', ['type' => 'customer_group', 'label' => 'sylius.form.promotion_rule.customer_group', 'form_type' => CustomerGroupConfigurationType::class]) ; + $services + ->set('sylius.checker.promotion_rule.customer_group_per_channel', PerChannelRuleChecker::class) + ->args([service('sylius.checker.promotion_rule.customer_group')]) + ->tag('sylius.promotion_rule_checker', ['type' => 'customer_group_per_channel', 'label' => 'sylius.form.promotion_rule.customer_group_per_channel', 'form_type' => ChannelBasedCustomerGroupConfigurationType::class]) + ; + $services ->set('sylius.checker.promotion_rule.nth_order', NthOrderRuleChecker::class) ->args([service('sylius.repository.order')]) ->tag('sylius.promotion_rule_checker', ['type' => 'nth_order', 'label' => 'sylius.form.promotion_rule.nth_order', 'form_type' => NthOrderConfigurationType::class]) ; + $services + ->set('sylius.checker.promotion_rule.nth_order_per_channel', PerChannelRuleChecker::class) + ->args([service('sylius.checker.promotion_rule.nth_order')]) + ->tag('sylius.promotion_rule_checker', ['type' => 'nth_order_per_channel', 'label' => 'sylius.form.promotion_rule.nth_order_per_channel', 'form_type' => ChannelBasedNthOrderConfigurationType::class]) + ; + $services ->set('sylius.checker.promotion_rule.shipping_country', ShippingCountryRuleChecker::class) ->args([service('sylius.repository.country')]) ->tag('sylius.promotion_rule_checker', ['type' => 'shipping_country', 'label' => 'sylius.form.promotion_rule.shipping_country', 'form_type' => ShippingCountryConfigurationType::class]) ; + $services + ->set('sylius.checker.promotion_rule.shipping_country_per_channel', PerChannelRuleChecker::class) + ->args([service('sylius.checker.promotion_rule.shipping_country')]) + ->tag('sylius.promotion_rule_checker', ['type' => 'shipping_country_per_channel', 'label' => 'sylius.form.promotion_rule.shipping_country_per_channel', 'form_type' => ChannelBasedShippingCountryConfigurationType::class]) + ; + $services ->set('sylius.checker.promotion_rule.has_taxon', HasTaxonRuleChecker::class) ->tag('sylius.promotion_rule_checker', ['type' => 'has_taxon', 'label' => 'sylius.form.promotion_rule.has_at_least_one_from_taxons', 'form_type' => HasTaxonConfigurationType::class]) ; + $services + ->set('sylius.checker.promotion_rule.has_taxon_per_channel', PerChannelRuleChecker::class) + ->args([service('sylius.checker.promotion_rule.has_taxon')]) + ->tag('sylius.promotion_rule_checker', ['type' => 'has_taxon_per_channel', 'label' => 'sylius.form.promotion_rule.has_at_least_one_from_taxons_per_channel', 'form_type' => ChannelBasedHasTaxonConfigurationType::class]) + ; + $services ->set('sylius.checker.promotion_rule.total_of_items_from_taxon', TotalOfItemsFromTaxonRuleChecker::class) ->args([service('sylius.repository.taxon')]) @@ -113,6 +146,12 @@ return static function (ContainerConfigurator $container) { ->tag('sylius.promotion_rule_checker', ['type' => 'contains_product', 'label' => 'sylius.form.promotion_rule.contains_product', 'form_type' => ContainsProductConfigurationType::class]) ; + $services + ->set('sylius.checker.promotion_rule.contains_product_per_channel', PerChannelRuleChecker::class) + ->args([service('sylius.checker.promotion_rule.contains_product')]) + ->tag('sylius.promotion_rule_checker', ['type' => 'contains_product_per_channel', 'label' => 'sylius.form.promotion_rule.contains_product_per_channel', 'form_type' => ChannelBasedContainsProductConfigurationType::class]) + ; + $services ->set('sylius.checker.promotion_rule.item_total', ItemTotalRuleChecker::class) ->tag('sylius.promotion_rule_checker', ['type' => 'item_total', 'label' => 'sylius.form.promotion_rule.item_total', 'form_type' => ChannelBasedItemTotalConfigurationType::class]) @@ -123,6 +162,12 @@ return static function (ContainerConfigurator $container) { ->tag('sylius.promotion_rule_checker', ['type' => 'cart_quantity', 'label' => 'sylius.form.promotion_rule.cart_quantity', 'form_type' => CartQuantityConfigurationType::class]) ; + $services + ->set('sylius.checker.promotion_rule.cart_quantity_per_channel', PerChannelRuleChecker::class) + ->args([service('sylius.checker.promotion_rule.cart_quantity')]) + ->tag('sylius.promotion_rule_checker', ['type' => 'cart_quantity_per_channel', 'label' => 'sylius.form.promotion_rule.cart_quantity_per_channel', 'form_type' => ChannelBasedCartQuantityConfigurationType::class]) + ; + $services ->set('sylius.command.promotion_action.fixed_discount', FixedDiscountPromotionActionCommand::class) ->args([ @@ -156,6 +201,12 @@ return static function (ContainerConfigurator $container) { ->tag('sylius.promotion_action', ['type' => 'order_percentage_discount', 'label' => 'sylius.form.promotion_action.order_percentage_discount', 'form_type' => PercentageDiscountConfigurationType::class]) ; + $services + ->set('sylius.command.promotion_action.percentage_discount_per_channel', PerChannelPromotionActionCommand::class) + ->args([service('sylius.command.promotion_action.percentage_discount')]) + ->tag('sylius.promotion_action', ['type' => 'order_percentage_discount_per_channel', 'label' => 'sylius.form.promotion_action.order_percentage_discount_per_channel', 'form_type' => ChannelBasedPercentageDiscountConfigurationType::class]) + ; + $services ->set('sylius.command.promotion_action.unit_percentage_discount', UnitPercentageDiscountPromotionActionCommand::class) ->args([ @@ -173,6 +224,12 @@ return static function (ContainerConfigurator $container) { ->tag('sylius.promotion_action', ['type' => 'shipping_percentage_discount', 'label' => 'sylius.form.promotion_action.shipping_percentage_discount', 'form_type' => PercentageDiscountConfigurationType::class]) ; + $services + ->set('sylius.command.promotion_action.shipping_percentage_discount_per_channel', PerChannelPromotionActionCommand::class) + ->args([service('sylius.command.promotion_action.shipping_percentage_discount')]) + ->tag('sylius.promotion_action', ['type' => 'shipping_percentage_discount_per_channel', 'label' => 'sylius.form.promotion_action.shipping_percentage_discount_per_channel', 'form_type' => ChannelBasedPercentageDiscountConfigurationType::class]) + ; + $services ->set('sylius.checker.promotion.promotion_coupon_per_customer_usage_limit_eligibility', PromotionCouponPerCustomerUsageLimitEligibilityChecker::class) ->args([service('sylius.repository.order')]) diff --git a/src/Sylius/Bundle/CoreBundle/Resources/config/validation/PromotionAction.xml b/src/Sylius/Bundle/CoreBundle/Resources/config/validation/PromotionAction.xml index 5f18de8d03..95b62cc7d7 100644 --- a/src/Sylius/Bundle/CoreBundle/Resources/config/validation/PromotionAction.xml +++ b/src/Sylius/Bundle/CoreBundle/Resources/config/validation/PromotionAction.xml @@ -280,6 +280,46 @@ + + + + + + + diff --git a/src/Sylius/Bundle/CoreBundle/Resources/config/validation/PromotionRule.xml b/src/Sylius/Bundle/CoreBundle/Resources/config/validation/PromotionRule.xml index b5ccc3aef5..078af69cf2 100644 --- a/src/Sylius/Bundle/CoreBundle/Resources/config/validation/PromotionRule.xml +++ b/src/Sylius/Bundle/CoreBundle/Resources/config/validation/PromotionRule.xml @@ -178,6 +178,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Sylius/Bundle/PromotionBundle/Resources/translations/messages.en.yml b/src/Sylius/Bundle/PromotionBundle/Resources/translations/messages.en.yml index d7225d4934..748d56532a 100644 --- a/src/Sylius/Bundle/PromotionBundle/Resources/translations/messages.en.yml +++ b/src/Sylius/Bundle/PromotionBundle/Resources/translations/messages.en.yml @@ -35,7 +35,9 @@ sylius: item_percentage_discount: Item percentage discount order_fixed_discount: Order fixed discount order_percentage_discount: Order percentage discount + order_percentage_discount_per_channel: Order percentage discount (per channel) shipping_percentage_discount: Shipping percentage discount + shipping_percentage_discount_per_channel: Shipping percentage discount (per channel) filters: Filters fixed_discount_configuration: amount: Amount @@ -56,12 +58,18 @@ sylius: price_range: Price range filter promotion_rule: cart_quantity: Cart quantity + cart_quantity_per_channel: Cart quantity (per channel) contains_product: Contains product + contains_product_per_channel: Contains product (per channel) customer_group: Customer group + customer_group_per_channel: Customer group (per channel) has_at_least_one_from_taxons: Has at least one from taxons + has_at_least_one_from_taxons_per_channel: Has at least one from taxons (per channel) item_total: Item total nth_order: Nth order + nth_order_per_channel: Nth order (per channel) shipping_country: Shipping country + shipping_country_per_channel: Shipping country (per channel) taxonomy: Taxonomy total_price_of_items_from_taxon: Total price of items from taxon cart_quantity_configuration: diff --git a/src/Sylius/Component/Core/Promotion/Action/PerChannelPromotionActionCommand.php b/src/Sylius/Component/Core/Promotion/Action/PerChannelPromotionActionCommand.php new file mode 100644 index 0000000000..3ece77cf64 --- /dev/null +++ b/src/Sylius/Component/Core/Promotion/Action/PerChannelPromotionActionCommand.php @@ -0,0 +1,67 @@ + $configuration */ + public function execute(PromotionSubjectInterface $subject, array $configuration, PromotionInterface $promotion): bool + { + if (!$subject instanceof OrderInterface) { + throw new UnexpectedTypeException($subject, OrderInterface::class); + } + + $channel = $subject->getChannel(); + if ($channel === null) { + return false; + } + + $channelCode = $channel->getCode(); + if (!isset($configuration[$channelCode])) { + return false; + } + + return $this->decorated->execute($subject, $configuration[$channelCode], $promotion); + } + + /** @param array $configuration */ + public function revert(PromotionSubjectInterface $subject, array $configuration, PromotionInterface $promotion): void + { + if (!$subject instanceof OrderInterface) { + throw new UnexpectedTypeException($subject, OrderInterface::class); + } + + $channel = $subject->getChannel(); + if ($channel === null) { + return; + } + + $channelCode = $channel->getCode(); + if (!isset($configuration[$channelCode])) { + return; + } + + $this->decorated->revert($subject, $configuration[$channelCode], $promotion); + } +} diff --git a/src/Sylius/Component/Core/Promotion/Checker/Rule/PerChannelRuleChecker.php b/src/Sylius/Component/Core/Promotion/Checker/Rule/PerChannelRuleChecker.php new file mode 100644 index 0000000000..33ce69d0b6 --- /dev/null +++ b/src/Sylius/Component/Core/Promotion/Checker/Rule/PerChannelRuleChecker.php @@ -0,0 +1,50 @@ + $configuration + * + * @throws UnsupportedTypeException + */ + public function isEligible(PromotionSubjectInterface $subject, array $configuration): bool + { + if (!$subject instanceof OrderInterface) { + throw new UnsupportedTypeException($subject, OrderInterface::class); + } + + $channel = $subject->getChannel(); + if ($channel === null) { + return false; + } + + $channelCode = $channel->getCode(); + if (!isset($configuration[$channelCode])) { + return false; + } + + return $this->decorated->isEligible($subject, $configuration[$channelCode]); + } +} From bdbf5d95782d9ace9f6a7c7606baf7ef6eab8eb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Kali=C5=84ski?= Date: Thu, 25 Jun 2026 13:31:50 +0200 Subject: [PATCH 2/5] Add unit tests for PerChannelPromotionActionCommand and PerChannelRuleChecker --- .../PerChannelPromotionActionCommandTest.php | 126 ++++++++++++++++++ .../Rule/PerChannelRuleCheckerTest.php | 99 ++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 src/Sylius/Component/Core/tests/Promotion/Action/PerChannelPromotionActionCommandTest.php create mode 100644 src/Sylius/Component/Core/tests/Promotion/Checker/Rule/PerChannelRuleCheckerTest.php diff --git a/src/Sylius/Component/Core/tests/Promotion/Action/PerChannelPromotionActionCommandTest.php b/src/Sylius/Component/Core/tests/Promotion/Action/PerChannelPromotionActionCommandTest.php new file mode 100644 index 0000000000..b132cda400 --- /dev/null +++ b/src/Sylius/Component/Core/tests/Promotion/Action/PerChannelPromotionActionCommandTest.php @@ -0,0 +1,126 @@ +decorated = $this->createMock(PromotionActionCommandInterface::class); + $this->channel = $this->createMock(ChannelInterface::class); + $this->order = $this->createMock(OrderInterface::class); + $this->promotion = $this->createMock(PromotionInterface::class); + $this->command = new PerChannelPromotionActionCommand($this->decorated); + } + + public function testShouldImplementPromotionActionCommandInterface(): void + { + $this->assertInstanceOf(PromotionActionCommandInterface::class, $this->command); + } + + public function testShouldExecuteDecoratedCommandWithTheChannelConfiguration(): void + { + $this->order->expects($this->once())->method('getChannel')->willReturn($this->channel); + $this->channel->expects($this->once())->method('getCode')->willReturn('WEB_US'); + $this->decorated->expects($this->once()) + ->method('execute') + ->with($this->order, ['percentage' => 0.2], $this->promotion) + ->willReturn(true) + ; + + $this->assertTrue($this->command->execute($this->order, ['WEB_US' => ['percentage' => 0.2]], $this->promotion)); + } + + public function testShouldNotExecuteDecoratedCommandIfThereIsNoConfigurationForTheOrderChannel(): void + { + $this->order->expects($this->once())->method('getChannel')->willReturn($this->channel); + $this->channel->expects($this->once())->method('getCode')->willReturn('WEB_US'); + $this->decorated->expects($this->never())->method('execute'); + + $this->assertFalse($this->command->execute($this->order, ['WEB_EU' => ['percentage' => 0.2]], $this->promotion)); + } + + public function testShouldNotExecuteDecoratedCommandWhenOrderHasNoChannel(): void + { + $this->order->expects($this->once())->method('getChannel')->willReturn(null); + $this->decorated->expects($this->never())->method('execute'); + + $this->assertFalse($this->command->execute($this->order, ['WEB_US' => ['percentage' => 0.2]], $this->promotion)); + } + + public function testShouldThrowExceptionWhenExecutingIfPromotionSubjectIsNotOrder(): void + { + $this->expectException(UnexpectedTypeException::class); + + $this->command->execute($this->createMock(PromotionSubjectInterface::class), [], $this->promotion); + } + + public function testShouldRevertDecoratedCommandWithTheChannelConfiguration(): void + { + $this->order->expects($this->once())->method('getChannel')->willReturn($this->channel); + $this->channel->expects($this->once())->method('getCode')->willReturn('WEB_US'); + $this->decorated->expects($this->once()) + ->method('revert') + ->with($this->order, ['percentage' => 0.2], $this->promotion) + ; + + $this->command->revert($this->order, ['WEB_US' => ['percentage' => 0.2]], $this->promotion); + } + + public function testShouldNotRevertDecoratedCommandIfThereIsNoConfigurationForTheOrderChannel(): void + { + $this->order->expects($this->once())->method('getChannel')->willReturn($this->channel); + $this->channel->expects($this->once())->method('getCode')->willReturn('WEB_US'); + $this->decorated->expects($this->never())->method('revert'); + + $this->command->revert($this->order, ['WEB_EU' => ['percentage' => 0.2]], $this->promotion); + } + + public function testShouldNotRevertDecoratedCommandWhenOrderHasNoChannel(): void + { + $this->order->expects($this->once())->method('getChannel')->willReturn(null); + $this->decorated->expects($this->never())->method('revert'); + + $this->command->revert($this->order, ['WEB_US' => ['percentage' => 0.2]], $this->promotion); + } + + public function testShouldThrowExceptionWhenRevertingIfPromotionSubjectIsNotOrder(): void + { + $this->expectException(UnexpectedTypeException::class); + + $this->command->revert($this->createMock(PromotionSubjectInterface::class), [], $this->promotion); + } +} diff --git a/src/Sylius/Component/Core/tests/Promotion/Checker/Rule/PerChannelRuleCheckerTest.php b/src/Sylius/Component/Core/tests/Promotion/Checker/Rule/PerChannelRuleCheckerTest.php new file mode 100644 index 0000000000..2729374de0 --- /dev/null +++ b/src/Sylius/Component/Core/tests/Promotion/Checker/Rule/PerChannelRuleCheckerTest.php @@ -0,0 +1,99 @@ +decorated = $this->createMock(RuleCheckerInterface::class); + $this->channel = $this->createMock(ChannelInterface::class); + $this->order = $this->createMock(OrderInterface::class); + $this->ruleChecker = new PerChannelRuleChecker($this->decorated); + } + + public function testShouldImplementRuleCheckerInterface(): void + { + $this->assertInstanceOf(RuleCheckerInterface::class, $this->ruleChecker); + } + + public function testShouldDelegateToDecoratedCheckerWithTheChannelConfiguration(): void + { + $this->order->expects($this->once())->method('getChannel')->willReturn($this->channel); + $this->channel->expects($this->once())->method('getCode')->willReturn('WEB_US'); + $this->decorated->expects($this->once()) + ->method('isEligible') + ->with($this->order, ['nth' => 5]) + ->willReturn(true) + ; + + $this->assertTrue($this->ruleChecker->isEligible($this->order, ['WEB_US' => ['nth' => 5]])); + } + + public function testShouldReturnDecoratedCheckerResultForTheOrderChannel(): void + { + $this->order->expects($this->once())->method('getChannel')->willReturn($this->channel); + $this->channel->expects($this->once())->method('getCode')->willReturn('WEB_US'); + $this->decorated->expects($this->once()) + ->method('isEligible') + ->with($this->order, ['nth' => 5]) + ->willReturn(false) + ; + + $this->assertFalse($this->ruleChecker->isEligible($this->order, ['WEB_US' => ['nth' => 5]])); + } + + public function testShouldReturnFalseAndNotDelegateIfThereIsNoConfigurationForTheOrderChannel(): void + { + $this->order->expects($this->once())->method('getChannel')->willReturn($this->channel); + $this->channel->expects($this->once())->method('getCode')->willReturn('WEB_US'); + $this->decorated->expects($this->never())->method('isEligible'); + + $this->assertFalse($this->ruleChecker->isEligible($this->order, ['WEB_EU' => ['nth' => 5]])); + } + + public function testShouldReturnFalseAndNotDelegateWhenOrderHasNoChannel(): void + { + $this->order->expects($this->once())->method('getChannel')->willReturn(null); + $this->decorated->expects($this->never())->method('isEligible'); + + $this->assertFalse($this->ruleChecker->isEligible($this->order, ['WEB_US' => ['nth' => 5]])); + } + + public function testShouldThrowExceptionIfPromotionSubjectIsNotOrder(): void + { + $this->expectException(UnsupportedTypeException::class); + + $this->ruleChecker->isEligible($this->createMock(PromotionSubjectInterface::class), []); + } +} From f40c4e5d7eefcbee515d4353672a63e38b21a850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Kali=C5=84ski?= Date: Thu, 25 Jun 2026 13:32:03 +0200 Subject: [PATCH 3/5] Introduce Behat scenarios and setup for channel-based promotion rules and actions --- ...with_per_channel_rules_and_actions.feature | 55 ++++++++++++++++ .../Behat/Context/Setup/PromotionContext.php | 66 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 features/shop/promotion/receiving_discount/receiving_discount_with_per_channel_rules_and_actions.feature diff --git a/features/shop/promotion/receiving_discount/receiving_discount_with_per_channel_rules_and_actions.feature b/features/shop/promotion/receiving_discount/receiving_discount_with_per_channel_rules_and_actions.feature new file mode 100644 index 0000000000..e03a417823 --- /dev/null +++ b/features/shop/promotion/receiving_discount/receiving_discount_with_per_channel_rules_and_actions.feature @@ -0,0 +1,55 @@ +@receiving_discount +Feature: Receiving discount with promotion rules and actions configured independently per channel + In order to pay the proper amount while buying promoted goods with different rules and actions per channel + As a Customer + I want to have per-channel promotion rules and actions applied correctly in my current channel + + Background: + Given the store operates on a channel named "Web-US" in "USD" currency and with hostname "united.states" + And the store operates on another channel named "Web-GB" in "GBP" currency and with hostname "great.britain" + And the store classifies its products as "T-Shirts" and "Mugs" + And the store has a product "PHP T-Shirt" priced at "$100.00" in "Web-US" channel + And this product is also priced at "£80.00" in "Web-GB" channel + And the product "PHP T-Shirt" belongs to taxon "T-Shirts" + And the store has a product "Symfony T-Shirt" priced at "$60.00" in "Web-US" channel + And this product is also priced at "£40.00" in "Web-GB" channel + And there is a promotion "Holiday promotion" + And I am a logged in customer + + @api @todo-ui + Scenario: Receiving an order percentage discount configured independently per channel + Given the promotion gives "10%" discount to every order in the "Web-US" channel and "5%" discount to every order in the "Web-GB" channel + When I changed my current channel to "Web-US" + And I added product "PHP T-Shirt" to the cart + And I check the details of my cart + Then my cart total should be "$90.00" + And my discount should be "-$10.00" + + @api @todo-ui + Scenario: Order percentage discount applies the value configured for the current channel + Given the promotion gives "10%" discount to every order in the "Web-US" channel and "5%" discount to every order in the "Web-GB" channel + When I changed my current channel to "Web-GB" + And I added product "PHP T-Shirt" to the cart + And I check the details of my cart + Then my cart total should be "£76.00" + And my discount should be "-£4.00" + + @api @todo-ui + Scenario: Receiving a discount when the cart contains the product configured for the current channel + Given the promotion gives "$10.00" discount to every order in the "Web-US" channel and "£5.00" discount to every order in the "Web-GB" channel + And the promotion applies to orders containing product "PHP T-Shirt" in the "Web-US" channel and product "Symfony T-Shirt" in the "Web-GB" channel + When I changed my current channel to "Web-US" + And I added product "PHP T-Shirt" to the cart + And I check the details of my cart + Then my cart total should be "$90.00" + And my discount should be "-$10.00" + + @api @todo-ui + Scenario: Receiving a discount when the cart has a product from the taxon configured for the current channel + Given the promotion gives "$10.00" discount to every order in the "Web-US" channel and "£5.00" discount to every order in the "Web-GB" channel + And the promotion applies to orders with a product from taxon "T-Shirts" in the "Web-US" channel and from taxon "Mugs" in the "Web-GB" channel + When I changed my current channel to "Web-US" + And I added product "PHP T-Shirt" to the cart + And I check the details of my cart + Then my cart total should be "$90.00" + And my discount should be "-$10.00" diff --git a/src/Sylius/Behat/Context/Setup/PromotionContext.php b/src/Sylius/Behat/Context/Setup/PromotionContext.php index 64a3a179b2..15d21f975f 100644 --- a/src/Sylius/Behat/Context/Setup/PromotionContext.php +++ b/src/Sylius/Behat/Context/Setup/PromotionContext.php @@ -404,6 +404,72 @@ final readonly class PromotionContext implements Context $this->objectManager->flush(); } + #[Given('/^([^"]+) gives ("[^"]+%") discount to every order in the ("[^"]+" channel) and ("[^"]+%") discount to every order in the ("[^"]+" channel)$/')] + public function thisPromotionGivesPercentageDiscountToEveryOrderInTheChannelAndInTheChannel( + PromotionInterface $promotion, + float $firstDiscount, + ChannelInterface $firstChannel, + float $secondDiscount, + ChannelInterface $secondChannel, + ): void { + $action = $this->actionFactory->createNew(); + $action->setType('order_percentage_discount_per_channel'); + $action->setConfiguration([ + $firstChannel->getCode() => ['percentage' => $firstDiscount], + $secondChannel->getCode() => ['percentage' => $secondDiscount], + ]); + + $promotion->addChannel($firstChannel); + $promotion->addChannel($secondChannel); + $promotion->addAction($action); + + $this->objectManager->flush(); + } + + #[Given('/^(the promotion) applies to orders containing (product "[^"]+") in the ("[^"]+" channel) and (product "[^"]+") in the ("[^"]+" channel)$/')] + public function thePromotionAppliesToOrdersContainingProductInTheChannelAndInTheChannel( + PromotionInterface $promotion, + ProductInterface $firstProduct, + ChannelInterface $firstChannel, + ProductInterface $secondProduct, + ChannelInterface $secondChannel, + ): void { + $rule = $this->ruleFactory->createNew(); + $rule->setType('contains_product_per_channel'); + $rule->setConfiguration([ + $firstChannel->getCode() => ['product_code' => $firstProduct->getCode()], + $secondChannel->getCode() => ['product_code' => $secondProduct->getCode()], + ]); + + $promotion->addChannel($firstChannel); + $promotion->addChannel($secondChannel); + $promotion->addRule($rule); + + $this->objectManager->flush(); + } + + #[Given('/^(the promotion) applies to orders with a product from (taxon "[^"]+") in the ("[^"]+" channel) and from (taxon "[^"]+") in the ("[^"]+" channel)$/')] + public function thePromotionAppliesToOrdersWithAProductFromTaxonInTheChannelAndInTheChannel( + PromotionInterface $promotion, + TaxonInterface $firstTaxon, + ChannelInterface $firstChannel, + TaxonInterface $secondTaxon, + ChannelInterface $secondChannel, + ): void { + $rule = $this->ruleFactory->createNew(); + $rule->setType('has_taxon_per_channel'); + $rule->setConfiguration([ + $firstChannel->getCode() => ['taxons' => [$firstTaxon->getCode()]], + $secondChannel->getCode() => ['taxons' => [$secondTaxon->getCode()]], + ]); + + $promotion->addChannel($firstChannel); + $promotion->addChannel($secondChannel); + $promotion->addRule($rule); + + $this->objectManager->flush(); + } + #[Given('/^(this promotion) gives ("(?:€|£|\$)[^"]+") off on every product in the ("[^"]+" channel) and ("(?:€|£|\$)[^"]+") off in the ("[^"]+" channel)$/')] public function thisPromotionGivesFixedDiscountOnEveryProductInTheChannelAndInTheChannel( PromotionInterface $promotion, From fc5ae5e69f38770ae9328f32c4bb5eefaf4af2e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Kali=C5=84ski?= Date: Thu, 25 Jun 2026 17:35:08 +0200 Subject: [PATCH 4/5] Validate channel-based promotion configuration and add tests --- .../PerChannelPromotionActionCommand.php | 4 ++-- .../Checker/Rule/PerChannelRuleChecker.php | 2 +- .../PerChannelPromotionActionCommandTest.php | 18 ++++++++++++++++++ .../Checker/Rule/PerChannelRuleCheckerTest.php | 9 +++++++++ ...romotion_with_invalid_actions_response.json | 2 +- ..._promotion_with_invalid_rules_response.json | 2 +- 6 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/Sylius/Component/Core/Promotion/Action/PerChannelPromotionActionCommand.php b/src/Sylius/Component/Core/Promotion/Action/PerChannelPromotionActionCommand.php index 3ece77cf64..7e83c1755c 100644 --- a/src/Sylius/Component/Core/Promotion/Action/PerChannelPromotionActionCommand.php +++ b/src/Sylius/Component/Core/Promotion/Action/PerChannelPromotionActionCommand.php @@ -38,7 +38,7 @@ final class PerChannelPromotionActionCommand implements PromotionActionCommandIn } $channelCode = $channel->getCode(); - if (!isset($configuration[$channelCode])) { + if (!isset($configuration[$channelCode]) || !is_array($configuration[$channelCode])) { return false; } @@ -58,7 +58,7 @@ final class PerChannelPromotionActionCommand implements PromotionActionCommandIn } $channelCode = $channel->getCode(); - if (!isset($configuration[$channelCode])) { + if (!isset($configuration[$channelCode]) || !is_array($configuration[$channelCode])) { return; } diff --git a/src/Sylius/Component/Core/Promotion/Checker/Rule/PerChannelRuleChecker.php b/src/Sylius/Component/Core/Promotion/Checker/Rule/PerChannelRuleChecker.php index 33ce69d0b6..4ebe866aea 100644 --- a/src/Sylius/Component/Core/Promotion/Checker/Rule/PerChannelRuleChecker.php +++ b/src/Sylius/Component/Core/Promotion/Checker/Rule/PerChannelRuleChecker.php @@ -41,7 +41,7 @@ final class PerChannelRuleChecker implements RuleCheckerInterface } $channelCode = $channel->getCode(); - if (!isset($configuration[$channelCode])) { + if (!isset($configuration[$channelCode]) || !is_array($configuration[$channelCode])) { return false; } diff --git a/src/Sylius/Component/Core/tests/Promotion/Action/PerChannelPromotionActionCommandTest.php b/src/Sylius/Component/Core/tests/Promotion/Action/PerChannelPromotionActionCommandTest.php index b132cda400..b4679c50bf 100644 --- a/src/Sylius/Component/Core/tests/Promotion/Action/PerChannelPromotionActionCommandTest.php +++ b/src/Sylius/Component/Core/tests/Promotion/Action/PerChannelPromotionActionCommandTest.php @@ -81,6 +81,15 @@ final class PerChannelPromotionActionCommandTest extends TestCase $this->assertFalse($this->command->execute($this->order, ['WEB_US' => ['percentage' => 0.2]], $this->promotion)); } + public function testShouldNotExecuteDecoratedCommandWhenChannelConfigurationIsNotAnArray(): void + { + $this->order->expects($this->once())->method('getChannel')->willReturn($this->channel); + $this->channel->expects($this->once())->method('getCode')->willReturn('WEB_US'); + $this->decorated->expects($this->never())->method('execute'); + + $this->assertFalse($this->command->execute($this->order, ['WEB_US' => 'not-an-array'], $this->promotion)); + } + public function testShouldThrowExceptionWhenExecutingIfPromotionSubjectIsNotOrder(): void { $this->expectException(UnexpectedTypeException::class); @@ -117,6 +126,15 @@ final class PerChannelPromotionActionCommandTest extends TestCase $this->command->revert($this->order, ['WEB_US' => ['percentage' => 0.2]], $this->promotion); } + public function testShouldNotRevertDecoratedCommandWhenChannelConfigurationIsNotAnArray(): void + { + $this->order->expects($this->once())->method('getChannel')->willReturn($this->channel); + $this->channel->expects($this->once())->method('getCode')->willReturn('WEB_US'); + $this->decorated->expects($this->never())->method('revert'); + + $this->command->revert($this->order, ['WEB_US' => 'not-an-array'], $this->promotion); + } + public function testShouldThrowExceptionWhenRevertingIfPromotionSubjectIsNotOrder(): void { $this->expectException(UnexpectedTypeException::class); diff --git a/src/Sylius/Component/Core/tests/Promotion/Checker/Rule/PerChannelRuleCheckerTest.php b/src/Sylius/Component/Core/tests/Promotion/Checker/Rule/PerChannelRuleCheckerTest.php index 2729374de0..7af2815cb4 100644 --- a/src/Sylius/Component/Core/tests/Promotion/Checker/Rule/PerChannelRuleCheckerTest.php +++ b/src/Sylius/Component/Core/tests/Promotion/Checker/Rule/PerChannelRuleCheckerTest.php @@ -90,6 +90,15 @@ final class PerChannelRuleCheckerTest extends TestCase $this->assertFalse($this->ruleChecker->isEligible($this->order, ['WEB_US' => ['nth' => 5]])); } + public function testShouldReturnFalseAndNotDelegateWhenChannelConfigurationIsNotAnArray(): void + { + $this->order->expects($this->once())->method('getChannel')->willReturn($this->channel); + $this->channel->expects($this->once())->method('getCode')->willReturn('WEB_US'); + $this->decorated->expects($this->never())->method('isEligible'); + + $this->assertFalse($this->ruleChecker->isEligible($this->order, ['WEB_US' => 'not-an-array'])); + } + public function testShouldThrowExceptionIfPromotionSubjectIsNotOrder(): void { $this->expectException(UnsupportedTypeException::class); diff --git a/tests/Api/Responses/admin/promotion/post_promotion_with_invalid_actions_response.json b/tests/Api/Responses/admin/promotion/post_promotion_with_invalid_actions_response.json index c19db0971d..e3e9848591 100644 --- a/tests/Api/Responses/admin/promotion/post_promotion_with_invalid_actions_response.json +++ b/tests/Api/Responses/admin/promotion/post_promotion_with_invalid_actions_response.json @@ -38,7 +38,7 @@ }, { "propertyPath": "actions[5].type", - "message": "Promotion action type is invalid. Available action types are order_fixed_discount, unit_fixed_discount, order_percentage_discount, unit_percentage_discount, shipping_percentage_discount.", + "message": "Promotion action type is invalid. Available action types are order_fixed_discount, unit_fixed_discount, order_percentage_discount, order_percentage_discount_per_channel, unit_percentage_discount, shipping_percentage_discount, shipping_percentage_discount_per_channel.", "code": null } ], diff --git a/tests/Api/Responses/admin/promotion/post_promotion_with_invalid_rules_response.json b/tests/Api/Responses/admin/promotion/post_promotion_with_invalid_rules_response.json index 6239dc0372..41670c22c8 100644 --- a/tests/Api/Responses/admin/promotion/post_promotion_with_invalid_rules_response.json +++ b/tests/Api/Responses/admin/promotion/post_promotion_with_invalid_rules_response.json @@ -53,7 +53,7 @@ }, { "propertyPath": "rules[8].type", - "message": "Promotion rule type is invalid. Available rule types are customer_group, nth_order, shipping_country, has_taxon, total_of_items_from_taxon, contains_product, item_total, cart_quantity.", + "message": "Promotion rule type is invalid. Available rule types are customer_group, customer_group_per_channel, nth_order, nth_order_per_channel, shipping_country, shipping_country_per_channel, has_taxon, has_taxon_per_channel, total_of_items_from_taxon, contains_product, contains_product_per_channel, item_total, cart_quantity, cart_quantity_per_channel.", "code": null } ], From bae54d9620404efb7f796dff3c5b42f47d78df86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Kali=C5=84ski?= Date: Thu, 25 Jun 2026 20:58:27 +0200 Subject: [PATCH 5/5] Update UPGRADE-2.3.md to document --- UPGRADE-2.3.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/UPGRADE-2.3.md b/UPGRADE-2.3.md index 474fe538f5..d0514f239f 100644 --- a/UPGRADE-2.3.md +++ b/UPGRADE-2.3.md @@ -166,6 +166,29 @@ | `ShippingBundle\...\ShippingMethodCalculatorExists` | `invalidShippingCalculator` | `invalidShippingCalculatorMessage` | | `ShippingBundle\...\ShippingMethodRule` | `invalidType` | `invalidTypeMessage` | +## Promotion + +1. New **opt-in per-channel** promotion rule and action types have been added. They let a single + promotion rule/action hold independent configuration per channel, with the `configuration` array + keyed by channel code (e.g. `['WEB_US' => ['count' => 2], 'WEB_GB' => ['count' => 5]]`): + + - Rules: `cart_quantity_per_channel`, `customer_group_per_channel`, `nth_order_per_channel`, + `shipping_country_per_channel`, `has_taxon_per_channel`, `contains_product_per_channel`. + - Actions: `order_percentage_discount_per_channel`, `shipping_percentage_discount_per_channel`. + + These are **additional** types registered alongside the existing plain ones. Existing promotions + and the plain rule/action types are unchanged, and **no core service is replaced**, so the change + is fully backward compatible and requires no action. + + Implementation: two generic decorators in `Sylius\Component\Core\Promotion` unwrap the current + channel's configuration slice and delegate to the standard checker/command: + + - `Sylius\Component\Core\Promotion\Checker\Rule\PerChannelRuleChecker` + - `Sylius\Component\Core\Promotion\Action\PerChannelPromotionActionCommand` + + The admin forms use new `ChannelBased*ConfigurationType` form types built on top of + `Sylius\Bundle\CoreBundle\Form\Type\ChannelCollectionType`. + ## Deprecations 1. Passing a `Sylius\Component\Core\Calculator\ProductVariantPricesCalculatorInterface` directly to the following catalog-facing classes is deprecated since Sylius 2.3.