Repository: PrestaShop/PrestaShop (PHP)

Issue:
Create a product-level discount
# Context
A product-level discount is applied only to the selected products, regardless of the order's content (cart + shipping fees).

# Default values
Information:
- Name automatically generated (= code)
- No description 

Dates:
- Start date = Creation date
- Never expires (no expiration date)

Eligibility:
- All customers
- No limit

Trigger:
- None

Result:
- -50% on the following standard product: 

Usability:
- Code automatically generated (= name)
- No limit of use per customer
- Incompatible with other discounts

Status:
- Enabled

# Tests
## Discount List
**GIVEN:** the discount list
**WHEN:** I create a catalog discount by clicking on the `Create discount` button
**THEN:** the discount is added to the discount list in the back office (but the discount is not added to the voucher list in the front office)

## Discount Application
**GIVEN:** a cart without the product `X`
**WHEN:** I want to apply the discount (by clicking on the `Add` button)
**THEN:** the discount is not applied, and the following error message is displayed instead:  `You cannot use this voucher with these products`

**GIVEN:** a cart that contains only product `X`
**WHEN:** I want to apply the discount (by clicking on the `Add` button)
**THEN:** the discount is applied

**GIVEN:** a cart that containts product `X` and product `Y`
**WHEN:** I want to apply the discount (by clicking on the `Add` button)
**THEN:** the discount is applied only to the product `X`

**GIVEN:** an order with a cart that contains product X and shipping fees
**WHEN:** I want to apply the discount (by clicking on the `Add` button)
**THEN:** the discount is applied only to the product `X`

## Discount Consumption
**GIVEN:** the cart details screen
**WHEN:** I click on the `Proceed to checkout` button
**THEN:** the discount is not consumed

**GIVEN:** the `Personal Information` step in the checkout process
**WHEN:** I click on the `Continue` button
**THEN:** the discount is not consumed

**GIVEN:** the `Addresses` step in the checkout process
**WHEN:** I click on the `Continue` button
**THEN:** the discount is not consumed

**GIVEN:** the `Shipping Method` step in the checkout process
**WHEN:** I click on the `Continue` button
**THEN:** the discount is not consumed

**GIVEN:** the `Payment` step in the checkout process
**WHEN:** I click on the `Place order` button
**THEN:** the discount is consumed (count incremented by 1 in the discount list)

# Sub-tasks
- [ ] Command CQRS
- [ ] Integration in the BO
- [ ] Check/Implement behavior on FO



Fix (patch):
diff --git a/src/Adapter/CartRule/CartRuleBuilder.php b/src/Adapter/CartRule/CartRuleBuilder.php
index 0a79fa4288b2c..215a1197f85c3 100644
--- a/src/Adapter/CartRule/CartRuleBuilder.php
+++ b/src/Adapter/CartRule/CartRuleBuilder.php
@@ -31,6 +31,7 @@
 use PrestaShop\PrestaShop\Core\Domain\Discount\Command\AddCartLevelDiscountCommand;
 use PrestaShop\PrestaShop\Core\Domain\Discount\Command\AddDiscountCommand;
 use PrestaShop\PrestaShop\Core\Domain\Discount\Command\AddFreeShippingDiscountCommand;
+use PrestaShop\PrestaShop\Core\Domain\Discount\Command\AddProductLevelDiscountCommand;
 use PrestaShop\PrestaShop\Core\Util\DateTime\DateTime as DateTimeUtil;
 
 class CartRuleBuilder
@@ -70,6 +71,16 @@ public function build(AddDiscountCommand $command): CartRule
             }
         }
 
+        if ($command instanceof AddProductLevelDiscountCommand) {
+            if ($command->getPercentDiscount()) {
+                $cartRule->reduction_percent = (float) (string) $command->getPercentDiscount();
+                $cartRule->reduction_amount = 0;
+                $cartRule->reduction_currency = 0;
+                $cartRule->reduction_tax = false;
+            }
+            $cartRule->reduction_product = $command->getReductionProduct();
+        }
+
         return $cartRule;
     }
 }
diff --git a/src/Adapter/Discount/CommandHandler/AddProductLevelDiscountHandler.php b/src/Adapter/Discount/CommandHandler/AddProductLevelDiscountHandler.php
new file mode 100644
index 0000000000000..e330917b74ed3
--- /dev/null
+++ b/src/Adapter/Discount/CommandHandler/AddProductLevelDiscountHandler.php
@@ -0,0 +1,52 @@
+<?php
+/**
+ * Copyright since 2007 PrestaShop SA and Contributors
+ * PrestaShop is an International Registered Trademark & Property of PrestaShop SA
+ *
+ * NOTICE OF LICENSE
+ *
+ * This source file is subject to the Open Software License (OSL 3.0)
+ * that is bundled with this package in the file LICENSE.md.
+ * It is also available through the world-wide-web at this URL:
+ * https://opensource.org/licenses/OSL-3.0
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@prestashop.com so we can send you a copy immediately.
+ *
+ * DISCLAIMER
+ *
+ * Do not edit or add to this file if you wish to upgrade PrestaShop to newer
+ * versions in the future. If you wish to customize PrestaShop for your
+ * needs please refer to https://devdocs.prestashop.com/ for more information.
+ *
+ * @author    PrestaShop SA and Contributors <contact@prestashop.com>
+ * @copyright Since 2007 PrestaShop SA and Contributors
+ * @license   https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
+ */
+
+namespace PrestaShop\PrestaShop\Adapter\Discount\CommandHandler;
+
+use PrestaShop\PrestaShop\Adapter\CartRule\CartRuleBuilder;
+use PrestaShop\PrestaShop\Adapter\Discount\Repository\DiscountRepository;
+use PrestaShop\PrestaShop\Core\CommandBus\Attributes\AsCommandHandler;
+use PrestaShop\PrestaShop\Core\Domain\Discount\Command\AddProductLevelDiscountCommand;
+use PrestaShop\PrestaShop\Core\Domain\Discount\CommandHandler\AddProductLevelDiscountHandlerInterface;
+use PrestaShop\PrestaShop\Core\Domain\Discount\ValueObject\DiscountId;
+
+#[AsCommandHandler]
+class AddProductLevelDiscountHandler implements AddProductLevelDiscountHandlerInterface
+{
+    public function __construct(
+        private readonly DiscountRepository $cartRuleRepository,
+        private readonly CartRuleBuilder $cartRuleBuilder
+    ) {
+    }
+
+    public function handle(AddProductLevelDiscountCommand $command): DiscountId
+    {
+        $BuiltCartRule = $this->cartRuleBuilder->build($command);
+        $discount = $this->cartRuleRepository->add($BuiltCartRule);
+
+        return new DiscountId((int) $discount->id);
+    }
+}
diff --git a/src/Adapter/Discount/QueryHandler/GetDiscountForEditingHandler.php b/src/Adapter/Discount/QueryHandler/GetDiscountForEditingHandler.php
index d73e91cd12e64..a27f5687d401f 100644
--- a/src/Adapter/Discount/QueryHandler/GetDiscountForEditingHandler.php
+++ b/src/Adapter/Discount/QueryHandler/GetDiscountForEditingHandler.php
@@ -67,7 +67,8 @@ public function handle(GetDiscountForEditing $query): DiscountForEditing
             new DecimalNumber($cartRule->reduction_percent),
             new DecimalNumber($cartRule->reduction_amount),
             $cartRule->reduction_currency,
-            $cartRule->reduction_tax
+            $cartRule->reduction_tax,
+            $cartRule->reduction_product,
         );
     }
 }
diff --git a/src/Core/Domain/Discount/Command/AddProductLevelDiscountCommand.php b/src/Core/Domain/Discount/Command/AddProductLevelDiscountCommand.php
new file mode 100644
index 0000000000000..415df70c5dbb7
--- /dev/null
+++ b/src/Core/Domain/Discount/Command/AddProductLevelDiscountCommand.php
@@ -0,0 +1,83 @@
+<?php
+/**
+ * Copyright since 2007 PrestaShop SA and Contributors
+ * PrestaShop is an International Registered Trademark & Property of PrestaShop SA
+ *
+ * NOTICE OF LICENSE
+ *
+ * This source file is subject to the Open Software License (OSL 3.0)
+ * that is bundled with this package in the file LICENSE.md.
+ * It is also available through the world-wide-web at this URL:
+ * https://opensource.org/licenses/OSL-3.0
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@prestashop.com so we can send you a copy immediately.
+ *
+ * DISCLAIMER
+ *
+ * Do not edit or add to this file if you wish to upgrade PrestaShop to newer
+ * versions in the future. If you wish to customize PrestaShop for your
+ * needs please refer to https://devdocs.prestashop.com/ for more information.
+ *
+ * @author    PrestaShop SA and Contributors <contact@prestashop.com>
+ * @copyright Since 2007 PrestaShop SA and Contributors
+ * @license   https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
+ */
+
+namespace PrestaShop\PrestaShop\Core\Domain\Discount\Command;
+
+use PrestaShop\Decimal\DecimalNumber;
+use PrestaShop\PrestaShop\Core\Domain\Discount\Exception\DiscountConstraintException;
+use PrestaShop\PrestaShop\Core\Domain\Discount\ValueObject\DiscountType;
+
+class AddProductLevelDiscountCommand extends AddDiscountCommand
+{
+    private ?DecimalNumber $percentDiscount = null;
+    private int $reductionProduct = 0;
+
+    public function __construct(
+    ) {
+        parent::__construct(DiscountType::PRODUCTS_DISCOUNT);
+    }
+
+    public function getPercentDiscount(): ?DecimalNumber
+    {
+        return $this->percentDiscount;
+    }
+
+    /**
+     * @throws DiscountConstraintException
+     */
+    public function setPercentDiscount(DecimalNumber $percentDiscount): self
+    {
+        if ($percentDiscount->isLowerThanZero() || $percentDiscount->isGreaterThan(new DecimalNumber('100.0'))) {
+            throw new DiscountConstraintException(sprintf('Invalid percent reduction "%s".', $percentDiscount), DiscountConstraintException::INVALID_REDUCTION_PERCENT);
+        }
+        $this->percentDiscount = $percentDiscount;
+
+        return $this;
+    }
+
+    public function getReductionProduct(): int
+    {
+        return $this->reductionProduct;
+    }
+
+    /**
+     * @param int $reductionProduct
+     *
+     * @return $this
+     *
+     * This can have several values
+     *  0 => The discount is not a Product discount
+     * -1 => The discounted product is the cheapest of the cart
+     * -2 => The discount is applied on a selection of product // this case is not yet handled.
+     * >0 => The productId of the discounted product
+     */
+    public function setReductionProduct(int $reductionProduct): self
+    {
+        $this->reductionProduct = $reductionProduct;
+
+        return $this;
+    }
+}
diff --git a/src/Core/Domain/Discount/CommandHandler/AddProductLevelDiscountHandlerInterface.php b/src/Core/Domain/Discount/CommandHandler/AddProductLevelDiscountHandlerInterface.php
new file mode 100644
index 0000000000000..e552810704914
--- /dev/null
+++ b/src/Core/Domain/Discount/CommandHandler/AddProductLevelDiscountHandlerInterface.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * Copyright since 2007 PrestaShop SA and Contributors
+ * PrestaShop is an International Registered Trademark & Property of PrestaShop SA
+ *
+ * NOTICE OF LICENSE
+ *
+ * This source file is subject to the Open Software License (OSL 3.0)
+ * that is bundled with this package in the file LICENSE.md.
+ * It is also available through the world-wide-web at this URL:
+ * https://opensource.org/licenses/OSL-3.0
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@prestashop.com so we can send you a copy immediately.
+ *
+ * DISCLAIMER
+ *
+ * Do not edit or add to this file if you wish to upgrade PrestaShop to newer
+ * versions in the future. If you wish to customize PrestaShop for your
+ * needs please refer to https://devdocs.prestashop.com/ for more information.
+ *
+ * @author    PrestaShop SA and Contributors <contact@prestashop.com>
+ * @copyright Since 2007 PrestaShop SA and Contributors
+ * @license   https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
+ */
+
+namespace PrestaShop\PrestaShop\Core\Domain\Discount\CommandHandler;
+
+use PrestaShop\PrestaShop\Core\Domain\Discount\Command\AddProductLevelDiscountCommand;
+use PrestaShop\PrestaShop\Core\Domain\Discount\ValueObject\DiscountId;
+
+interface AddProductLevelDiscountHandlerInterface
+{
+    public function handle(AddProductLevelDiscountCommand $command): DiscountId;
+}
diff --git a/src/Core/Domain/Discount/QueryResult/DiscountForEditing.php b/src/Core/Domain/Discount/QueryResult/DiscountForEditing.php
index f5e10a7c77af3..0dcb5af958e1a 100644
--- a/src/Core/Domain/Discount/QueryResult/DiscountForEditing.php
+++ b/src/Core/Domain/Discount/QueryResult/DiscountForEditing.php
@@ -50,6 +50,7 @@ public function __construct(
         private readonly ?DecimalNumber $amountDiscount,
         private readonly ?int $currencyId,
         private readonly ?bool $taxIncluded,
+        private readonly ?int $reductionProduct,
     ) {
     }
 
@@ -137,4 +138,9 @@ public function isTaxIncluded(): ?bool
     {
         return $this->taxIncluded;
     }
+
+    public function getReductionProduct(): ?int
+    {
+        return $this->reductionProduct;
+    }
 }
diff --git a/src/PrestaShopBundle/Resources/config/services/adapter/discount.yml b/src/PrestaShopBundle/Resources/config/services/adapter/discount.yml
index efa6fc9949739..16a567e8abc94 100644
--- a/src/PrestaShopBundle/Resources/config/services/adapter/discount.yml
+++ b/src/PrestaShopBundle/Resources/config/services/adapter/discount.yml
@@ -12,6 +12,11 @@ services:
     public: false
     autoconfigure: true
 
+  PrestaShop\PrestaShop\Adapter\Discount\CommandHandler\AddProductLevelDiscountHandler:
+    autowire: true
+    public: false
+    autoconfigure: true
+
   PrestaShop\PrestaShop\Adapter\Discount\QueryHandler\GetDiscountForEditingHandler:
     autowire: true
     public: false