diff --git a/core/lib/Thelia/Config/Resources/coupon.xml b/core/lib/Thelia/Config/Resources/coupon.xml
index 530a36d56..06e8f7bb6 100644
--- a/core/lib/Thelia/Config/Resources/coupon.xml
+++ b/core/lib/Thelia/Config/Resources/coupon.xml
@@ -66,9 +66,10 @@
-
-
-
+
+
+
+
diff --git a/core/lib/Thelia/Coupon/Type/FreeProduct.php b/core/lib/Thelia/Coupon/Type/FreeProduct.php
new file mode 100644
index 000000000..7b06f5dd9
--- /dev/null
+++ b/core/lib/Thelia/Coupon/Type/FreeProduct.php
@@ -0,0 +1,347 @@
+
+ */
+class FreeProduct extends AbstractRemoveOnProducts
+{
+ const OFFERED_PRODUCT_ID = 'offered_product_id';
+ const OFFERED_CATEGORY_ID = 'offered_category_id';
+
+ /** @var string Service Id */
+ protected $serviceId = 'thelia.coupon.type.free_product';
+
+ protected $offeredProductId;
+ protected $offeredCategoryId;
+
+ /**
+ * This constant is user to mark a free product as in the process of being added to the cart,
+ * but the CartItem ID is not yet been defined.
+ */
+ const ADD_TO_CART_IN_PROCESS = -1;
+
+ /**
+ * @inheritdoc
+ */
+ public function setFieldsValue($effects)
+ {
+ $this->offeredProductId = $effects[self::OFFERED_PRODUCT_ID];
+ $this->offeredCategoryId = $effects[self::OFFERED_CATEGORY_ID];
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getCartItemDiscount($cartItem)
+ {
+ // This method is not used, we use our own implementation of exec();
+ return 0;
+ }
+
+ /**
+ * @return string The session variable where the cart item IDs for the free products are stored
+ */
+ protected function getSessionVarName() {
+ return "coupon.free_product.cart_items." . $this->getCode();
+ }
+ /**
+ * Return the cart item id which contains the free product related to a given product
+ *
+ * @param Product $product the product in the cart which triggered the discount
+ *
+ * @return bool|int|CartItem the cart item which contains the free product, or false if the product is no longer in the cart, or ADD_TO_CART_IN_PROCESS if the adding process is not finished
+ */
+ protected function getRelatedCartItem($product) {
+
+ $cartItemIdList = $this->facade->getRequest()->getSession()->get(
+ $this->getSessionVarName(),
+ array()
+ );
+
+ if (isset($cartItemIdList[$product->getId()])) {
+
+ $cartItemId = $cartItemIdList[$product->getId()];
+
+ if ($cartItemId == self::ADD_TO_CART_IN_PROCESS) {
+ return self::ADD_TO_CART_IN_PROCESS;
+ }
+ else if (null !== $cartItem = CartItemQuery::create()->findPk($cartItemId)) {
+ return $cartItem;
+ }
+ }
+ else {
+ // Maybe the product we're offering is already in the cart ? Search it.
+ $cartItems = $this->facade->getCart()->getCartItems();
+
+ /** @var CartItem $cartItem */
+ foreach ($cartItems as $cartItem) {
+ if ($cartItem->getProduct()->getId() == $this->offeredProductId) {
+
+ // We found the product. Store its cart item as the free product container.
+ $this->setRelatedCartItem($product, $cartItem->getId());
+
+ return $cartItem;
+ }
+ }
+
+ }
+
+ return false;
+ }
+
+ /**
+ * Set the cart item id which contains the free product related to a given product
+ *
+ * @param Product $product the product in the cart which triggered the discount
+ * @param bool|int $cartItemId the cart item ID which contains the free product, or just true if the free product is not yet added.
+ */
+ protected function setRelatedCartItem($product, $cartItemId) {
+
+ $cartItemIdList = $this->facade->getRequest()->getSession()->get(
+ $this->getSessionVarName(),
+ array()
+ );
+
+ if (! is_array($cartItemIdList)) $cartItemIdList = array();
+
+ $cartItemIdList[$product->getId()] = $cartItemId;
+
+ $this->facade->getRequest()->getSession()->set(
+ $this->getSessionVarName(),
+ $cartItemIdList
+ );
+ }
+
+ /**
+ * Get the product id / cart item id list.
+ *
+ * @return array an array where the free product ID is the key, and the related cart item id the value.
+ */
+ protected function getFreeProductsCartItemIds() {
+ return $this->facade->getRequest()->getSession()->get(
+ $this->getSessionVarName(),
+ array()
+ );
+ }
+
+ /**
+ * Clear the session variable.
+ */
+ protected function clearFreeProductsCartItemIds() {
+ return $this->facade->getRequest()->getSession()->remove($this->getSessionVarName());
+ }
+
+ /**
+ * We overload this method here to remove the free products when the
+ * coupons conditions are no longer met.
+ *
+ * @inheritdoc
+ */
+ public function isMatching() {
+ $match = parent::isMatching();
+
+ if (! $match) {
+ // Cancel coupon effect (but no not remove the product)
+ $this->clearFreeProductsCartItemIds();
+ }
+
+ return $match;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function exec()
+ {
+ $discount = 0;
+
+ $cartItems = $this->facade->getCart()->getCartItems();
+
+ /** @var Product $eligibleProduct */
+ $eligibleProduct = null;
+
+ /** @var CartItem $cartItem */
+ foreach ($cartItems as $cartItem) {
+ if (in_array($cartItem->getProduct()->getId(), $this->product_list)) {
+ if (! $cartItem->getPromo() || $this->isAvailableOnSpecialOffers()) {
+ $eligibleProduct = $cartItem;
+ break;
+ }
+ }
+ }
+
+ if ($eligibleProduct !== null) {
+
+ // Get the cart item for the eligible product
+ $freeProductCartItem = $this->getRelatedCartItem($eligibleProduct);
+
+ // We add the free product it only if it not yet in the cart.
+ if ($freeProductCartItem === false) {
+
+ if (null !== $freeProduct = ProductQuery::create()->findPk($this->offeredProductId)) {
+
+ // Store in the session that the free product is added to the cart,
+ // so that we don't enter the following infinite loop :
+ //
+ // 1) exec() adds a product by firing a CART_ADDITEM event,
+ // 2) the event is processed by Action\Coupon::updateOrderDiscount(),
+ // 3) Action\Coupon::updateOrderDiscount() calls CouponManager::getDiscount()
+ // 4) CouponManager::getDiscount() calls exec() -> Infinite loop !!
+
+ // Store a marker first, we do not have the cart item id yet.
+ $this->setRelatedCartItem($eligibleProduct, self::ADD_TO_CART_IN_PROCESS);
+
+ $cartEvent = new CartEvent($this->facade->getCart());
+
+ $cartEvent->setNewness(true);
+ $cartEvent->setAppend(false);
+ $cartEvent->setQuantity(1);
+ $cartEvent->setProductSaleElementsId($freeProduct->getDefaultSaleElements()->getId());
+ $cartEvent->setProduct($this->offeredProductId);
+
+ $this->facade->getDispatcher()->dispatch(TheliaEvents::CART_ADDITEM, $cartEvent);
+
+ // Store the final cart item ID.
+ $this->setRelatedCartItem($eligibleProduct, $cartEvent->getCartItem()->getId());
+
+ $freeProductCartItem = $cartEvent->getCartItem();
+
+ // Setting product price is dangerous, as the customer could change the ordered quantity of this product.
+ // We will instead add the product price to the order discount.
+ // $cartEvent->getCartItem()->setPrice(0)->save();
+ }
+ }
+
+ if ($freeProductCartItem instanceof CartItem) {
+
+ $taxCountry = $this->facade->getDeliveryCountry();
+
+ // The discount is the product price.
+ $discount = $freeProductCartItem->getPromo() ?
+ $freeProductCartItem->getPromoPrice() : $freeProductCartItem->getPrice();
+ }
+ }
+ // No eligible product was found !
+ else {
+ // Remove all free products for this coupon, but no not remove the product from the cart.
+ $this->clearFreeProductsCartItemIds();
+ }
+
+ return $discount;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ protected function getFieldList()
+ {
+ return $this->getBaseFieldList([self::OFFERED_CATEGORY_ID, self::OFFERED_PRODUCT_ID]);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ protected function checkCouponFieldValue($fieldName, $fieldValue)
+ {
+ $this->checkBaseCouponFieldValue($fieldName, $fieldValue);
+
+ if ($fieldName === self::OFFERED_PRODUCT_ID) {
+
+ if (floatval($fieldValue) < 0) {
+ throw new \InvalidArgumentException(
+ Translator::getInstance()->trans(
+ 'Please select the offered product'
+ )
+ );
+ }
+ }
+ else if ($fieldName === self::OFFERED_CATEGORY_ID) {
+ if (empty($fieldValue)) {
+ throw new \InvalidArgumentException(
+ Translator::getInstance()->trans(
+ 'Please select the category of the offred product'
+ )
+ );
+ }
+ }
+
+ return $fieldValue;
+ }
+
+
+ /**
+ * Get I18n name
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->facade
+ ->getTranslator()
+ ->trans('Free product when buying one or more selected products', array(), 'coupon');
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getToolTip()
+ {
+ $toolTip = $this->facade
+ ->getTranslator()
+ ->trans(
+ 'This coupon adds a free product to the cart if one of the selected products is in the cart.',
+ array(),
+ 'coupon'
+ );
+
+ return $toolTip;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function drawBackOfficeInputs()
+ {
+ return $this->drawBaseBackOfficeInputs("coupon/type-fragments/free-product.html", [
+ 'offered_category_field_name' => $this->makeCouponFieldName(self::OFFERED_CATEGORY_ID),
+ 'offered_category_value' => $this->offeredCategoryId,
+
+ 'offered_product_field_name' => $this->makeCouponFieldName(self::OFFERED_PRODUCT_ID),
+ 'offered_product_value' => $this->offeredProductId
+ ]);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function clear()
+ {
+ // Clear the session variable when the coupon is cleared.
+ $this->clearFreeProductsCartItemIds();
+ }
+}
\ No newline at end of file