Files
prestashop/classes/CartRule.php
2026-04-09 18:31:51 +02:00

2220 lines
109 KiB
PHP

<?php
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
use PrestaShop\PrestaShop\Adapter\ContainerFinder;
use PrestaShop\PrestaShop\Adapter\Discount\Application\DiscountApplicationService;
use PrestaShop\PrestaShop\Core\Domain\Discount\DiscountSettings;
use PrestaShop\PrestaShop\Core\Domain\Discount\ValueObject\DiscountType;
use PrestaShop\PrestaShop\Core\FeatureFlag\FeatureFlagSettings;
use PrestaShop\PrestaShop\Core\FeatureFlag\FeatureFlagStateCheckerInterface;
/**
* Class CartRuleCore.
*/
class CartRuleCore extends ObjectModel
{
/* Filters used when retrieving the cart rules applied to a cart of when calculating the value of a reduction */
public const FILTER_ACTION_ALL = 1;
public const FILTER_ACTION_SHIPPING = 2;
public const FILTER_ACTION_REDUCTION = 3;
public const FILTER_ACTION_GIFT = 4;
public const FILTER_ACTION_ALL_NOCAP = 5;
public const BO_ORDER_CODE_PREFIX = 'BO_ORDER_';
public const AT_LEAST_ONE_PRODUCT_RULE = 'at_least_one_product_rule';
public const ALL_PRODUCT_RULES = 'all_product_rules';
/**
* This variable controls that a free gift is offered only once, even when multi-shippping is activated
* and the same product is delivered in both addresses.
*
* @var array
*/
protected static $only_one_gift = [];
public $id;
public $name;
public $id_customer;
/**
* @var string|null
*/
public $date_from;
/**
* @var string|null
*/
public $date_to;
public $description;
public $quantity = 1;
public $quantity_per_user = 1;
public $priority = 1;
/** @var bool */
public $partial_use = true;
public $code;
public $minimum_amount;
/** @var bool */
public $minimum_amount_tax;
public $minimum_amount_currency;
/** @var bool */
public $minimum_amount_shipping;
public $minimum_product_quantity;
/** @var bool */
public $country_restriction;
/** @var bool */
public $carrier_restriction;
/** @var bool */
public $group_restriction;
/** @var bool */
public $cart_rule_restriction;
/** @var bool */
public $product_restriction;
/** @var bool */
public $shop_restriction;
/** @var bool */
public $free_shipping;
public $reduction_percent;
public $reduction_amount;
/**
* @var bool is this voucher value tax included (false = tax excluded value)
*/
public $reduction_tax;
/** @var int */
public $reduction_currency;
public $reduction_product;
/** @var bool */
public $reduction_exclude_special;
public $gift_product;
public $gift_product_attribute;
/** @var bool */
public $highlight;
/** @var bool */
public $active = true;
public $date_add;
public $date_upd;
public $id_cart_rule_type;
protected ?FeatureFlagStateCheckerInterface $featureFlagManager = null;
protected static $cartAmountCache = [];
/**
* @see ObjectModel::$definition
*/
public static $definition = [
'table' => 'cart_rule',
'primary' => 'id_cart_rule',
'multilang' => true,
'fields' => [
'id_customer' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedId'],
'date_from' => ['type' => self::TYPE_DATE, 'validate' => 'isDate', 'required' => true],
'date_to' => ['type' => self::TYPE_DATE, 'validate' => 'isDate', 'required' => true],
'description' => ['type' => self::TYPE_STRING, 'validate' => 'isCleanHtml', 'size' => DiscountSettings::MAX_DESCRIPTION_LENGTH],
'quantity' => ['type' => self::TYPE_INT, 'allow_null' => true, 'validate' => 'isUnsignedInt'],
'quantity_per_user' => ['type' => self::TYPE_INT, 'allow_null' => true, 'validate' => 'isUnsignedInt'],
'priority' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'],
'partial_use' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
'code' => ['type' => self::TYPE_STRING, 'validate' => 'isCleanHtml', 'size' => 254],
'minimum_amount' => ['type' => self::TYPE_FLOAT, 'validate' => 'isFloat'],
'minimum_amount_tax' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
'minimum_amount_currency' => ['type' => self::TYPE_INT, 'validate' => 'isInt'],
'minimum_amount_shipping' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
'minimum_product_quantity' => ['type' => self::TYPE_INT, 'validate' => 'isInt'],
'country_restriction' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
'carrier_restriction' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
'group_restriction' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
'cart_rule_restriction' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
'product_restriction' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
'shop_restriction' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
'free_shipping' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
'reduction_percent' => ['type' => self::TYPE_FLOAT, 'validate' => 'isPercentage'],
'reduction_amount' => ['type' => self::TYPE_FLOAT, 'validate' => 'isFloat'],
'reduction_tax' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
'reduction_currency' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedId'],
'reduction_product' => ['type' => self::TYPE_INT, 'validate' => 'isInt'],
'reduction_exclude_special' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
'gift_product' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedId'],
'gift_product_attribute' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedId'],
'highlight' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
'active' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
'date_add' => ['type' => self::TYPE_DATE, 'validate' => 'isDate'],
'date_upd' => ['type' => self::TYPE_DATE, 'validate' => 'isDate'],
'id_cart_rule_type' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedId'],
/* Lang fields */
'name' => [
'type' => self::TYPE_HTML,
'lang' => true,
'required' => false,
'size' => DiscountSettings::MAX_NAME_LENGTH,
'validate' => 'defaultLanguageRequiredWhenActive',
],
],
];
/**
* Get the discount type string from id_cart_rule_type
*
* @return string|null
*/
public function getType(): ?string
{
static $typeCache = [];
if (!$this->id_cart_rule_type) {
return null;
}
if (!isset($typeCache[$this->id_cart_rule_type])) {
$result = Db::getInstance()->getValue('
SELECT discount_type
FROM ' . _DB_PREFIX_ . 'cart_rule_type
WHERE id_cart_rule_type = ' . (int) $this->id_cart_rule_type
);
$typeCache[$this->id_cart_rule_type] = $result ?: null;
}
return $typeCache[$this->id_cart_rule_type];
}
public static function resetStaticCache()
{
static::$cartAmountCache = [];
}
/**
* Adds current CartRule as a new Object to the database.
*
* @param bool $autodate Automatically set `date_upd` and `date_add` columns
* @param bool $null_values Whether we want to use NULL values instead of empty quotes values
*
* @return bool Indicates whether the CartRule has been successfully added
*
* @throws PrestaShopDatabaseException
* @throws PrestaShopException
*/
public function add($autodate = true, $null_values = false)
{
if (!$this->reduction_currency) {
$this->reduction_currency = Currency::getDefaultCurrencyId();
}
if (!parent::add($autodate, $null_values)) {
return false;
}
Configuration::updateGlobalValue('PS_CART_RULE_FEATURE_ACTIVE', '1');
return true;
}
/**
* Updates the current object in the database.
*
* @param bool $null_values Whether we want to use NULL values instead of empty quotes values
*
* @return bool Indicates whether the CartRule has been successfully updated
*
* @throws PrestaShopDatabaseException
* @throws PrestaShopException
*/
public function update($null_values = false)
{
Cache::clean('getContextualValue_' . $this->id . '_*');
if (!$this->reduction_currency) {
$this->reduction_currency = Currency::getDefaultCurrencyId();
}
if (!parent::update($null_values)) {
return false;
}
Configuration::updateGlobalValue(
'PS_CART_RULE_FEATURE_ACTIVE',
CartRule::isCurrentlyUsed($this->def['table'], true)
);
return true;
}
/**
* Deletes current CartRule from the database.
*
* @return bool True if delete was successful
*
* @throws PrestaShopException
*/
public function delete()
{
if (!parent::delete()) {
return false;
}
Configuration::updateGlobalValue(
'PS_CART_RULE_FEATURE_ACTIVE',
CartRule::isCurrentlyUsed($this->def['table'], true)
);
$r = Db::getInstance()->delete('cart_cart_rule', '`id_cart_rule` = ' . (int) $this->id);
$r &= Db::getInstance()->delete('cart_rule_carrier', '`id_cart_rule` = ' . (int) $this->id);
$r &= Db::getInstance()->delete('cart_rule_shop', '`id_cart_rule` = ' . (int) $this->id);
$r &= Db::getInstance()->delete('cart_rule_group', '`id_cart_rule` = ' . (int) $this->id);
$r &= Db::getInstance()->delete('cart_rule_country', '`id_cart_rule` = ' . (int) $this->id);
$r &= Db::getInstance()->delete('cart_rule_combination', '`id_cart_rule_1` = ' . (int) $this->id . ' OR `id_cart_rule_2` = ' . (int) $this->id);
$r &= Db::getInstance()->delete('cart_rule_product_rule_group', '`id_cart_rule` = ' . (int) $this->id);
$r &= Db::getInstance()->delete('cart_rule_product_rule', 'NOT EXISTS (SELECT 1 FROM `' . _DB_PREFIX_ . 'cart_rule_product_rule_group`
WHERE `' . _DB_PREFIX_ . 'cart_rule_product_rule`.`id_product_rule_group` = `' . _DB_PREFIX_ . 'cart_rule_product_rule_group`.`id_product_rule_group`)');
$r &= Db::getInstance()->delete('cart_rule_product_rule_value', 'NOT EXISTS (SELECT 1 FROM `' . _DB_PREFIX_ . 'cart_rule_product_rule`
WHERE `' . _DB_PREFIX_ . 'cart_rule_product_rule_value`.`id_product_rule` = `' . _DB_PREFIX_ . 'cart_rule_product_rule`.`id_product_rule`)');
$r &= Db::getInstance()->delete('cart_rule_compatible_types', '`id_cart_rule` = ' . (int) $this->id);
return (bool) $r;
}
/**
* Copy conditions from one CartRule to another.
*
* @param int $id_cart_rule_source Source CartRule ID
* @param int $id_cart_rule_destination Destination CartRule ID
*/
public static function copyConditions($id_cart_rule_source, $id_cart_rule_destination)
{
Db::getInstance()->execute('
INSERT INTO `' . _DB_PREFIX_ . 'cart_rule_shop` (`id_cart_rule`, `id_shop`)
(SELECT ' . (int) $id_cart_rule_destination . ', id_shop FROM `' . _DB_PREFIX_ . 'cart_rule_shop` WHERE `id_cart_rule` = ' . (int) $id_cart_rule_source . ')');
Db::getInstance()->execute('
INSERT INTO `' . _DB_PREFIX_ . 'cart_rule_carrier` (`id_cart_rule`, `id_carrier`)
(SELECT ' . (int) $id_cart_rule_destination . ', id_carrier FROM `' . _DB_PREFIX_ . 'cart_rule_carrier` WHERE `id_cart_rule` = ' . (int) $id_cart_rule_source . ')');
Db::getInstance()->execute('
INSERT INTO `' . _DB_PREFIX_ . 'cart_rule_group` (`id_cart_rule`, `id_group`)
(SELECT ' . (int) $id_cart_rule_destination . ', id_group FROM `' . _DB_PREFIX_ . 'cart_rule_group` WHERE `id_cart_rule` = ' . (int) $id_cart_rule_source . ')');
Db::getInstance()->execute('
INSERT INTO `' . _DB_PREFIX_ . 'cart_rule_country` (`id_cart_rule`, `id_country`)
(SELECT ' . (int) $id_cart_rule_destination . ', id_country FROM `' . _DB_PREFIX_ . 'cart_rule_country` WHERE `id_cart_rule` = ' . (int) $id_cart_rule_source . ')');
Db::getInstance()->execute('
INSERT INTO `' . _DB_PREFIX_ . 'cart_rule_combination` (`id_cart_rule_1`, `id_cart_rule_2`)
(SELECT ' . (int) $id_cart_rule_destination . ', IF(id_cart_rule_1 != ' . (int) $id_cart_rule_source . ', id_cart_rule_1, id_cart_rule_2) FROM `' . _DB_PREFIX_ . 'cart_rule_combination`
WHERE `id_cart_rule_1` = ' . (int) $id_cart_rule_source . ' OR `id_cart_rule_2` = ' . (int) $id_cart_rule_source . ')');
// Todo : should be changed soon, be must be copied too
// Db::getInstance()->execute('DELETE FROM `'._DB_PREFIX_.'cart_rule_product_rule` WHERE `id_cart_rule` = '.(int)$this->id);
// Db::getInstance()->execute('DELETE FROM `'._DB_PREFIX_.'cart_rule_product_rule_value` WHERE `id_product_rule` NOT IN (SELECT `id_product_rule` FROM `'._DB_PREFIX_.'cart_rule_product_rule`)');
// Copy products/category filters
$products_rules_group_source = Db::getInstance()->executeS('
SELECT id_product_rule_group,quantity FROM `' . _DB_PREFIX_ . 'cart_rule_product_rule_group`
WHERE `id_cart_rule` = ' . (int) $id_cart_rule_source . ' ');
foreach ($products_rules_group_source as $product_rule_group_source) {
Db::getInstance()->execute('
INSERT INTO `' . _DB_PREFIX_ . 'cart_rule_product_rule_group` (`id_cart_rule`, `quantity`)
VALUES (' . (int) $id_cart_rule_destination . ',' . (int) $product_rule_group_source['quantity'] . ')');
$id_product_rule_group_destination = Db::getInstance()->Insert_ID();
$products_rules_source = Db::getInstance()->executeS('
SELECT id_product_rule,type FROM `' . _DB_PREFIX_ . 'cart_rule_product_rule`
WHERE `id_product_rule_group` = ' . (int) $product_rule_group_source['id_product_rule_group'] . ' ');
foreach ($products_rules_source as $product_rule_source) {
Db::getInstance()->execute('
INSERT INTO `' . _DB_PREFIX_ . 'cart_rule_product_rule` (`id_product_rule_group`, `type`)
VALUES (' . (int) $id_product_rule_group_destination . ',"' . pSQL($product_rule_source['type']) . '")');
$id_product_rule_destination = Db::getInstance()->Insert_ID();
$products_rules_values_source = Db::getInstance()->executeS('
SELECT id_item FROM `' . _DB_PREFIX_ . 'cart_rule_product_rule_value`
WHERE `id_product_rule` = ' . (int) $product_rule_source['id_product_rule'] . ' ');
foreach ($products_rules_values_source as $product_rule_value_source) {
Db::getInstance()->execute('
INSERT INTO `' . _DB_PREFIX_ . 'cart_rule_product_rule_value` (`id_product_rule`, `id_item`)
VALUES (' . (int) $id_product_rule_destination . ',' . (int) $product_rule_value_source['id_item'] . ')');
}
}
}
}
/**
* Retrieves the CartRule ID associated with the given voucher code.
*
* @param string $code Voucher code
*
* @return int|bool CartRule ID
* false if not found
*/
public static function getIdByCode($code)
{
if (!Validate::isCleanHtml($code)) {
return false;
}
return Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue(
'SELECT `id_cart_rule` FROM `' . _DB_PREFIX_ . 'cart_rule` WHERE `code` = \'' . pSQL($code) . '\''
);
}
/**
* Check if some cart rules exists today for the given customer.
*
* @param int $idCustomer
*
* @return bool
*/
public static function haveCartRuleToday($idCustomer)
{
static $haveCartRuleToday = [];
if (!isset($haveCartRuleToday[$idCustomer])) {
$start_date = date('Y-m-d 00:00:00');
$end_date = date('Y-m-d 23:59:59');
$sql = 'SELECT 1 FROM `' . _DB_PREFIX_ . 'cart_rule` ' .
'WHERE ((date_to >= "' . $start_date .
'" AND date_to <= "' . $end_date .
'") OR (date_from >= "' . $start_date .
'" AND date_from <= "' . $end_date .
'") OR (date_from < "' . $start_date .
'" AND date_to > "' . $end_date .
'")) AND `id_customer` IN (0,' . (int) $idCustomer . ')';
$haveCartRuleToday[$idCustomer] = Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue($sql);
}
return !empty($haveCartRuleToday[$idCustomer]);
}
/**
* Get CartRules for the given Customer.
*
* @param int $id_lang Language ID
* @param int $id_customer Customer ID
* @param bool $active Active vouchers only
* @param bool $includeGeneric Include generic vouchers that don't have specific customer
* @param bool $inStock Vouchers that have "total quantity" remaining
* @param CartCore|null $cart Cart
* @param bool $free_shipping_only Free shipping only
* @param bool $highlight_only Highlighted vouchers only
*
* @return array
*
* @throws PrestaShopDatabaseException
*/
public static function getCustomerCartRules(
$id_lang,
$id_customer,
$active = false,
$includeGeneric = true,
$inStock = false,
?CartCore $cart = null,
$free_shipping_only = false,
$highlight_only = false
) {
if (!CartRule::isFeatureActive() || !CartRule::haveCartRuleToday($id_customer)) {
return [];
}
// Basic part of the query, we are selecting all cart rules
$sql = '
SELECT SQL_NO_CACHE * FROM `' . _DB_PREFIX_ . 'cart_rule` cr
LEFT JOIN `' . _DB_PREFIX_ . 'cart_rule_lang` crl
ON (cr.`id_cart_rule` = crl.`id_cart_rule` AND crl.`id_lang` = ' . (int) $id_lang . ')';
// We will definitely include vouchers for this specific customer
$sql .= ' WHERE (cr.`id_customer` = ' . (int) $id_customer;
// And if required, all the generic ones, that don't have any specific customer set
if ($includeGeneric && (int) $id_customer !== 0) {
$sql .= ' OR cr.`id_customer` = 0';
}
$sql .= ')';
// Then, conditions for date, voucher active property and total amount of vouchers in stock
$sql .= ' AND NOW() BETWEEN cr.date_from AND cr.date_to
' . ($active ? 'AND cr.`active` = 1' : '') . '
' . ($inStock ? 'AND (cr.`quantity` > 0 OR cr.`quantity` is null)' : '');
// If we want to select only vouchers that have free shipping as the action
if ($free_shipping_only) {
$sql .= ' AND free_shipping = 1 AND carrier_restriction = 1';
}
// If we want to select only vouchers with "Highlight" option activated
if ($highlight_only) {
$sql .= ' AND highlight = 1 AND code NOT LIKE "' . pSQL(CartRule::BO_ORDER_CODE_PREFIX) . '%"';
}
$result = Db::getInstance(_PS_USE_SQL_SLAVE_)->executeS($sql, true, false);
if (empty($result)) {
return [];
}
/*
* Remove cart rule that does not match the customer groups.
* Even if empty $id_customer was provided, we will still get
* a visitor group.
*/
$customerGroups = Customer::getGroupsStatic($id_customer);
foreach ($result as $key => $cart_rule) {
if ($cart_rule['group_restriction']) {
$cartRuleGroups = Db::getInstance()->executeS('SELECT id_group FROM ' . _DB_PREFIX_ . 'cart_rule_group WHERE id_cart_rule = ' . (int) $cart_rule['id_cart_rule']);
foreach ($cartRuleGroups as $cartRuleGroup) {
if (in_array($cartRuleGroup['id_group'], $customerGroups)) {
continue 2;
}
}
unset($result[$key]);
}
}
foreach ($result as &$cart_rule) {
if ($cart_rule['quantity_per_user']) {
$quantity_used = Order::getDiscountsCustomer((int) $id_customer, (int) $cart_rule['id_cart_rule']);
if (isset($cart, $cart->id)) {
$quantity_used += $cart->getDiscountsCustomer((int) $cart_rule['id_cart_rule']);
}
$cart_rule['quantity_for_user'] = $cart_rule['quantity_per_user'] - $quantity_used;
} else {
$cart_rule['quantity_for_user'] = 0;
}
}
unset($cart_rule);
foreach ($result as $key => $cart_rule) {
if ($cart_rule['shop_restriction']) {
$cartRuleShops = Db::getInstance()->executeS('SELECT id_shop FROM ' . _DB_PREFIX_ . 'cart_rule_shop WHERE id_cart_rule = ' . (int) $cart_rule['id_cart_rule']);
foreach ($cartRuleShops as $cartRuleShop) {
if (Shop::isFeatureActive() && ($cartRuleShop['id_shop'] == Context::getContext()->shop->id)) {
continue 2;
}
}
unset($result[$key]);
}
}
if (isset($cart, $cart->id)) {
foreach ($result as $key => $cart_rule) {
if ($cart_rule['product_restriction']) {
$cr = new CartRule((int) $cart_rule['id_cart_rule']);
$r = $cr->checkProductRestrictionsFromCart(Context::getContext()->cart, false, false);
if ($r !== false) {
continue;
}
unset($result[$key]);
}
}
}
/*
* Now, we check the country restrictions on this cart rule.
* The rule is will be displayed, if the customer has at least one
* address with country in the allowed list.
*
* If the customer has no addresses, we won't display anything.
*/
foreach ($result as $key => $cart_rule) {
if ($cart_rule['country_restriction']) {
/*
* If the rule has country restriction and there is no customer ID
* provided, it doesn't make sense to check anything else.
*
* This customer can't have any addresses, thus no cart rule will be valid.
*/
if (empty($id_customer)) {
unset($result[$key]);
continue;
}
/*
* Now, when we are sure that we have some sensible customer ID to validate upon,
* we can check if he has any valid addresses that intersect with the allowed countries
* in the cart rule. So he will be able to use it.
*/
$validAddressExists = Db::getInstance()->getValue('
SELECT crc.id_cart_rule
FROM ' . _DB_PREFIX_ . 'cart_rule_country crc
INNER JOIN ' . _DB_PREFIX_ . 'address a
ON a.id_customer = ' . (int) $id_customer . ' AND
a.deleted = 0 AND
a.id_country = crc.id_country
WHERE crc.id_cart_rule = ' . (int) $cart_rule['id_cart_rule']
);
if (empty($validAddressExists)) {
unset($result[$key]);
}
}
}
return $result;
}
/**
* Get all (inactive too) CartRules for a given customer
*
* @param int $customerId
*
* @return array
*/
public static function getAllCustomerCartRules(
int $customerId
): array {
$query = new DbQuery();
$query->select('cr.*, crl.name');
$query->from('cart_rule', 'cr');
$query->where('cr.id_customer = ' . $customerId);
$query->leftJoin('cart_rule_lang', 'crl', 'cr.id_cart_rule = crl.id_cart_rule AND crl.id_lang = ' . (int) Configuration::get('PS_LANG_DEFAULT'));
$query->orderBy('cr.active DESC, cr.id_customer DESC');
$result = Db::getInstance()->executeS($query);
if (!$result) {
return [];
}
foreach ($result as &$cart_rule) {
if ($cart_rule['quantity_per_user']) {
$quantity_used = Order::getDiscountsCustomer($customerId, (int) $cart_rule['id_cart_rule']);
$cart_rule['quantity_for_user'] = $cart_rule['quantity_per_user'] - $quantity_used;
} else {
$cart_rule['quantity_for_user'] = 0;
}
}
return $result;
}
public static function getCustomerHighlightedDiscounts(
$languageId,
$customerId,
CartCore $cart
) {
return static::getCustomerCartRules(
$languageId,
$customerId,
$active = true,
$includeGeneric = true,
$inStock = true,
$cart,
$freeShippingOnly = false,
$highlightOnly = true
);
}
/**
* Check if the CartRule has been used by the given Customer.
*
* @param int $id_customer Customer ID
*
* @return bool Indicates if the CartRule has been used by a Customer
* The Cart must have been converted into an Order, otherwise it doesn't count
*/
public function usedByCustomer($id_customer)
{
return (bool) Db::getInstance()->getValue('
SELECT id_cart_rule
FROM `' . _DB_PREFIX_ . 'order_cart_rule` ocr
LEFT JOIN `' . _DB_PREFIX_ . 'orders` o ON ocr.`id_order` = o.`id_order`
WHERE ocr.`deleted` = 0 AND ocr.`id_cart_rule` = ' . (int) $this->id . '
AND o.`id_customer` = ' . (int) $id_customer);
}
/**
* Check if the CartRule exists.
*
* @param string $code CartRule code
*
* @return bool Indicates whether the CartRule can be found
*/
public static function cartRuleExists($code)
{
if (!CartRule::isFeatureActive()) {
return false;
}
return (bool) Db::getInstance()->getValue('
SELECT `id_cart_rule`
FROM `' . _DB_PREFIX_ . 'cart_rule`
WHERE `code` = \'' . pSQL($code) . '\'', false);
}
/**
* Delete CartRules by Customer ID.
*
* @param int $id_customer Customer ID
*
* @return bool Indicates if the CartRules were successfully deleted
*/
public static function deleteByIdCustomer($id_customer)
{
// Remove cart rules only if we got some sensible ID of a customer.
// If we would pass zero further below, it would delete all non-customer-restricted cart rules.
if (empty($id_customer)) {
return false;
}
$return = true;
$cart_rules = new PrestaShopCollection('CartRule');
$cart_rules->where('id_customer', '=', $id_customer);
foreach ($cart_rules as $cart_rule) {
$return &= $cart_rule->delete();
}
return $return;
}
/**
* @return array
*/
public function getProductRuleGroups()
{
if (!Validate::isLoadedObject($this) || $this->product_restriction == 0) {
return [];
}
$productRuleGroups = [];
$result = Db::getInstance()->executeS('SELECT * FROM ' . _DB_PREFIX_ . 'cart_rule_product_rule_group WHERE id_cart_rule = ' . (int) $this->id);
foreach ($result as $row) {
if (!isset($productRuleGroups[$row['id_product_rule_group']])) {
$productRuleGroups[$row['id_product_rule_group']] = ['id_product_rule_group' => $row['id_product_rule_group'], 'quantity' => $row['quantity'], 'type' => $row['type']];
}
$productRuleGroups[$row['id_product_rule_group']]['product_rules'] = $this->getProductRules($row['id_product_rule_group']);
}
return $productRuleGroups;
}
/**
* @param int $id_product_rule_group
*
* @return array ('type' => ? , 'values' => ?)
*/
public function getProductRules($id_product_rule_group)
{
if (!Validate::isLoadedObject($this) || $this->product_restriction == 0) {
return [];
}
$productRules = [];
$results = Db::getInstance()->executeS('
SELECT *
FROM ' . _DB_PREFIX_ . 'cart_rule_product_rule pr
LEFT JOIN ' . _DB_PREFIX_ . 'cart_rule_product_rule_value prv ON pr.id_product_rule = prv.id_product_rule
WHERE pr.id_product_rule_group = ' . (int) $id_product_rule_group);
foreach ($results as $row) {
if (!isset($productRules[$row['id_product_rule']])) {
$productRules[$row['id_product_rule']] = ['type' => $row['type'], 'values' => []];
}
$productRules[$row['id_product_rule']]['values'][] = $row['id_item'];
}
return $productRules;
}
/**
* Check if this CartRule can be applied.
*
* @param Context $context Context instance to use
* @param bool $alreadyInCart Special validation flag to use, that has different conditions for vouchers already in a cart
* @param bool $display_error If true, method returns nothing if valid or an error message. If false, always returns a boolean
* @param bool $check_carrier Disable this flag if you want to validate the cart rule for a different carrier than assigned to the cart
* @param bool $useOrderPrices When true use the Order saved prices instead of the most recent ones from catalog
*
* @return bool|mixed|string
*/
public function checkValidity(Context $context, $alreadyInCart = false, $display_error = true, $check_carrier = true, $useOrderPrices = false)
{
if (!CartRule::isFeatureActive()) {
return false;
}
$cart = $context->cart;
/*
* Custom cart rule validation from modules. Allows to create infinite possibilities of rules.
*
* If null is provided, nothing happens and built-in validation is ran. Useful if you want your own conditions,
* but also want to retain functionality of the core.
*
* If true is provided, the validation ends here and the rule is VALID, ignoring the rest of core validation.
*
* If false is provided, the validation ends here and the rule is not VALID, ignoring the rest of core validation.
* In this case, it's recommended to properly alter the isValidatedByModulesError error message so the user knows why.
*/
$isValidatedByModules = null;
$isValidatedByModulesError = $this->trans('This voucher is not valid.', [], 'Shop.Notifications.Error');
Hook::exec(
'actionValidateCartRule',
[
'cart_rule' => $this,
'cart' => $cart,
'alreadyInCart' => $alreadyInCart,
'display_error' => $display_error,
'check_carrier' => $check_carrier,
'useOrderPrices' => $useOrderPrices,
'isValidatedByModules' => &$isValidatedByModules,
'isValidatedByModulesError' => &$isValidatedByModulesError,
]
);
// @phpstan-ignore-next-line
if ($isValidatedByModules === false) {
return (!$display_error) ? false : $isValidatedByModulesError;
}
// @phpstan-ignore-next-line
if ($isValidatedByModules === true) {
return (!$display_error) ? true : null;
}
/*
* All these checks are relevant only for non-ordered carts. When an order has been already
* created from the cart, we do not check this. $useOrderPrices is a bit confusing as a name,
* but it indicates that we are validating a cart that has already been ordered.
*/
if (!$useOrderPrices) {
// We verify that the cart rule is active
if (!$this->active) {
return (!$display_error) ? false : $this->trans('This voucher is disabled', [], 'Shop.Notifications.Error');
}
// We verify the total available quantity
if ($this->quantity !== null && !$this->quantity) {
return (!$display_error) ? false : $this->trans('This voucher has already been used', [], 'Shop.Notifications.Error');
}
// We verify the date range
if (strtotime($this->date_from) > time()) {
return (!$display_error) ? false : $this->trans('This voucher is not valid yet', [], 'Shop.Notifications.Error');
}
if (strtotime($this->date_to) < time()) {
return (!$display_error) ? false : $this->trans('This voucher has expired', [], 'Shop.Notifications.Error');
}
// We verify the quantity per user, if customer is already assigned to the cart
if ($cart->id_customer) {
// First, we check if the customer has already used this cart rule in past orders
$quantityUsed = Db::getInstance()->getValue('
SELECT count(*)
FROM `' . _DB_PREFIX_ . 'orders` o
LEFT JOIN `' . _DB_PREFIX_ . 'order_cart_rule` ocr ON o.`id_order` = ocr.`id_order`
WHERE o.`id_customer` = ' . $cart->id_customer . '
AND ocr.`deleted` = 0
AND ocr.`id_cart_rule` = ' . (int) $this->id . '
AND ' . (int) Configuration::get('PS_OS_ERROR') . ' != o.`current_state`
');
if ($alreadyInCart) {
// Sometimes a cart rule is already in a cart, but the cart is not yet attached to an order (when logging
// in for example), these cart rules are not taken into account by the query above:
// so we count cart rules that are already linked to the current cart but not attached to an order yet.
$quantityUsed += (int) Db::getInstance()->getValue('
SELECT count(*)
FROM `' . _DB_PREFIX_ . 'cart_cart_rule` ccr
INNER JOIN `' . _DB_PREFIX_ . 'cart` c ON c.id_cart = ccr.id_cart
LEFT JOIN `' . _DB_PREFIX_ . 'orders` o ON o.id_cart = c.id_cart
WHERE c.id_customer = ' . $cart->id_customer . ' AND c.id_cart = ' . (int) $cart->id . ' AND ccr.id_cart_rule = ' . (int) $this->id . ' AND o.id_order IS NULL
');
} else {
// When checking the cart rules present in that cart the request result is accurate
// When we check if using the cart rule one more time is valid then we increment this value
++$quantityUsed;
}
if ($this->quantity_per_user !== null && $quantityUsed > $this->quantity_per_user) {
return (!$display_error) ? false : $this->trans('You cannot use this voucher anymore (usage limit reached)', [], 'Shop.Notifications.Error');
}
}
}
// Get an intersection of the customer groups and the cart rule groups (if the customer is not logged in, the default group is Visitors)
if ($this->group_restriction) {
$id_cart_rule = (int) Db::getInstance()->getValue('
SELECT crg.id_cart_rule
FROM ' . _DB_PREFIX_ . 'cart_rule_group crg
WHERE crg.id_cart_rule = ' . (int) $this->id . '
AND crg.id_group ' . ($cart->id_customer ? 'IN (SELECT cg.id_group FROM ' . _DB_PREFIX_ . 'customer_group cg WHERE cg.id_customer = ' . (int) $cart->id_customer . ')' : '= ' . (int) Configuration::get('PS_UNIDENTIFIED_GROUP')));
if (!$id_cart_rule) {
return (!$display_error) ? false : $this->trans('You cannot use this voucher', [], 'Shop.Notifications.Error');
}
}
// Check if the customer delivery address is usable with the cart rule
if ($this->country_restriction) {
if (!$cart->id_address_delivery) {
return (!$display_error) ? false : $this->trans('You must choose a delivery address before applying this voucher to your order', [], 'Shop.Notifications.Error');
}
$id_cart_rule = (int) Db::getInstance()->getValue('
SELECT crc.id_cart_rule
FROM ' . _DB_PREFIX_ . 'cart_rule_country crc
WHERE crc.id_cart_rule = ' . (int) $this->id . '
AND crc.id_country = (SELECT a.id_country FROM ' . _DB_PREFIX_ . 'address a WHERE a.id_address = ' . (int) $cart->id_address_delivery . ' LIMIT 1)');
if (!$id_cart_rule) {
return (!$display_error) ? false : $this->trans('You cannot use this voucher in your country of delivery', [], 'Shop.Notifications.Error');
}
}
// Check if the carrier chosen by the customer is usable with the cart rule
if ($this->carrier_restriction && $check_carrier) {
if (!$cart->id_carrier) {
return (!$display_error) ? false : $this->trans('You must choose a carrier before applying this voucher to your order', [], 'Shop.Notifications.Error');
}
$id_cart_rule = (int) Db::getInstance()->getValue('
SELECT crc.id_cart_rule
FROM ' . _DB_PREFIX_ . 'cart_rule_carrier crc
INNER JOIN ' . _DB_PREFIX_ . 'carrier c ON (c.id_reference = crc.id_carrier AND c.deleted = 0)
WHERE crc.id_cart_rule = ' . (int) $this->id . '
AND c.id_carrier = ' . (int) $cart->id_carrier);
if (!$id_cart_rule) {
return (!$display_error) ? false : $this->trans('You cannot use this voucher with this carrier', [], 'Shop.Notifications.Error');
}
}
if ($this->reduction_exclude_special) {
$products = $cart->getProducts();
$is_ok = false;
foreach ($products as $product) {
if (!$product['reduction_applies']) {
$is_ok = true;
break;
}
}
if (!$is_ok) {
return (!$display_error) ? false : $this->trans('You cannot use this voucher on products on sale', [], 'Shop.Notifications.Error');
}
}
// Check if the cart rules appliy to the shop browsed by the customer
if ($this->shop_restriction && $context->shop->id && Shop::isFeatureActive()) {
$id_cart_rule = (int) Db::getInstance()->getValue('
SELECT crs.id_cart_rule
FROM ' . _DB_PREFIX_ . 'cart_rule_shop crs
WHERE crs.id_cart_rule = ' . (int) $this->id . '
AND crs.id_shop = ' . (int) $context->shop->id);
if (!$id_cart_rule) {
return (!$display_error) ? false : $this->trans('You cannot use this voucher', [], 'Shop.Notifications.Error');
}
}
// Check if the products chosen by the customer are usable with the cart rule
if ($this->product_restriction) {
$r = $this->checkProductRestrictionsFromCart($context->cart, false, $display_error, $alreadyInCart);
if ($r !== false && $display_error) {
return $r;
} elseif (!$r && !$display_error) {
return false;
}
}
// Check if the minimal product quantity is met (if defined)
if ($this->minimum_product_quantity) {
if ($cart->nbProducts() < $this->minimum_product_quantity) {
return (!$display_error) ? false : $this->trans('You cannot use this voucher with these products', [], 'Shop.Notifications.Error');
}
}
// Check if the cart rule is only usable by a specific customer, and if the current customer is the right one
if ($this->id_customer && $cart->id_customer != $this->id_customer) {
if (!Context::getContext()->customer->isLogged()) {
return (!$display_error) ? false : ($this->trans('You cannot use this voucher', [], 'Shop.Notifications.Error') . ' - ' . $this->trans('Please log in first', [], 'Shop.Notifications.Error'));
}
return (!$display_error) ? false : $this->trans('You cannot use this voucher', [], 'Shop.Notifications.Error');
}
/*
* Now, we need to check if the cart rule meets the minimum requirements to use it.
*/
if ($this->minimum_amount && $check_carrier) {
// Minimum amount is converted to the contextual currency
$minimum_amount = $this->minimum_amount;
if ($this->minimum_amount_currency != Context::getContext()->currency->id) {
$minimum_amount = Tools::convertPriceFull($minimum_amount, new Currency($this->minimum_amount_currency), Context::getContext()->currency);
}
// Let's get the full cart total first, add shipping price if the rule was configured like this.
$cartTotal = $cart->getOrderTotal(
$this->minimum_amount_tax,
Cart::ONLY_PRODUCTS_WITHOUT_GIFTS,
null,
null,
false,
$useOrderPrices
);
if ($this->minimum_amount_shipping) {
$cartTotal += $cart->getOrderTotal(
$this->minimum_amount_tax,
Cart::ONLY_SHIPPING,
null,
null,
false,
$useOrderPrices
);
}
if ($cartTotal < $minimum_amount) {
return (!$display_error) ? false : $this->trans('The minimum amount to benefit from this promo code is %s.', [Tools::getContextLocale($context)->formatPrice($minimum_amount, $context->currency->iso_code)], 'Shop.Notifications.Error');
}
}
/* This loop checks:
- if the voucher is already in the cart
- if a non compatible voucher is in the cart
- if there are products in the cart (gifts excluded)
Important note: this MUST be the last check, because if the tested cart rule has priority over a non combinable one in the cart, we will switch them
*/
$nb_products = Cart::getNbProducts($cart->id);
$otherCartRules = [];
if ($check_carrier) {
$otherCartRules = $cart->getCartRules(CartRule::FILTER_ACTION_ALL, false);
}
$nbOfCartRules = 0;
if (count($otherCartRules)) {
foreach ($otherCartRules as $otherCartRule) {
if ($otherCartRule['id_cart_rule'] != $this->id) {
++$nbOfCartRules;
}
if ($otherCartRule['id_cart_rule'] == $this->id && !$alreadyInCart) {
return (!$display_error) ? false : $this->trans('This voucher is already in your cart', [], 'Shop.Notifications.Error');
}
// We try to check how many gifts are already in the cart, with this product ID, combination ID and no customization.
$giftProductQuantity = $cart->getProductQuantity($otherCartRule['gift_product'], $otherCartRule['gift_product_attribute'], 0);
if ($otherCartRule['gift_product'] && !empty($giftProductQuantity['quantity'])) {
--$nb_products;
}
if ($this->cart_rule_restriction && $otherCartRule['cart_rule_restriction'] && $otherCartRule['id_cart_rule'] != $this->id) {
$combinable = Db::getInstance()->getValue('
SELECT id_cart_rule_1
FROM ' . _DB_PREFIX_ . 'cart_rule_combination
WHERE (id_cart_rule_1 = ' . (int) $this->id . ' AND id_cart_rule_2 = ' . (int) $otherCartRule['id_cart_rule'] . ')
OR (id_cart_rule_2 = ' . (int) $this->id . ' AND id_cart_rule_1 = ' . (int) $otherCartRule['id_cart_rule'] . ')');
if (!$combinable) {
$cart_rule = new CartRule((int) $otherCartRule['id_cart_rule'], $cart->id_lang);
// The cart rules are not combinable and the cart rule currently in the cart has priority over the one tested
if ($cart_rule->priority <= $this->priority) {
return (!$display_error) ? false : $this->trans('This voucher is not combinable with an other voucher already in your cart: %s', [htmlspecialchars($cart_rule->name)], 'Shop.Notifications.Error');
} else {
// But if the cart rule that is tested has priority over the one in the cart, we remove the one in the cart and keep this new one
$cart->removeCartRule($cart_rule->id);
}
}
}
}
}
if (!$nb_products) {
return (!$display_error) ? false : $this->trans('Cart is empty', [], 'Shop.Notifications.Error');
}
// Check if order cart rule was removed from back office
$removed_order_cartRule_id = (int) Db::getInstance()->getValue('
SELECT ocr.`id_order_cart_rule`
FROM `' . _DB_PREFIX_ . 'order_cart_rule` ocr
LEFT JOIN `' . _DB_PREFIX_ . 'orders` o ON ocr.`id_order` = o.`id_order`
WHERE ocr.`id_cart_rule` = ' . (int) $this->id . '
AND ocr.`deleted` = 1
AND o.`id_cart` = ' . $cart->id);
if ($removed_order_cartRule_id) {
return (!$display_error) ? false : $this->trans('You cannot use this voucher because it has manually been removed.', [], 'Shop.Notifications.Error');
}
// This part introduces the new business rules for the discount rework they are only taking effect when the discount feature flag is enabled
if ($this->isDiscountFeatureFlagEnabled()) {
// Use DiscountApplicationService to determine which discounts to apply and their priority order
$existingCartRuleIds = array_filter(
array_column($otherCartRules, 'id_cart_rule'),
function ($id) {
return $id != $this->id;
}
);
try {
$containerFinder = new ContainerFinder(Context::getContext());
$container = $containerFinder->getContainer();
$applicationService = $container->get(DiscountApplicationService::class);
$result = $applicationService->determineDiscountsToApply($this->id, $existingCartRuleIds);
if (!$result->canApply()) {
$errorMessage = $result->getRejectionReason()
?? 'This voucher is not combinable with other vouchers in your cart';
return (!$display_error) ? false : $this->trans($errorMessage, [], 'Shop.Notifications.Error');
}
// Remove conflicting discounts that were replaced by higher priority ones
foreach ($result->getDiscountsToRemove() as $ruleIdToRemove) {
$cart->removeCartRule($ruleIdToRemove);
}
// Note: The actual application order is determined by the result
// The cart will apply discounts in the priority order specified by $result->getDiscountsToApply()
} catch (Exception $e) {
// Fallback: if service is not available or discount has no type, skip compatibility check
}
}
if (!$display_error) {
return true;
}
}
/**
* Checks if the products chosen by the customer are usable with the cart rule.
*
* @param CartCore $cart
* @param bool $returnProducts [default=false]
* If true, this method will return an array of eligible products.
* Otherwise, it returns TRUE on success and string|false on errors (depending on the value of $displayError)
* @param bool $displayError [default=false]
* If true, this method will return an error message instead of FALSE on errors.
* Otherwise, it returns FALSE on errors
* @param bool $alreadyInCart
*
* @return array|bool|string
*
* @throws PrestaShopDatabaseException
*/
public function checkProductRestrictionsFromCart(CartCore $cart, $returnProducts = false, $displayError = true, $alreadyInCart = false)
{
// Prepare a list of products to return, if the caller wishes so and provided returnProducts = true
$selected_products = [];
// Do all of this only if the cart rule actually has some restrictions
if ($this->product_restriction) {
// Load products in cart and return if it's empty, there is no point in checking anything else
$products = $cart->getProducts();
if (empty($products)) {
return (!$displayError) ? false : $this->trans('You cannot use this voucher in an empty cart', [], 'Shop.Notifications.Error');
}
// Now we load all RULE GROUP.
$product_rule_groups = $this->getProductRuleGroups();
foreach ($product_rule_groups as $id_product_rule_group => $product_rule_group) {
$product_rule_group_type = $product_rule_group['type'] ?? self::AT_LEAST_ONE_PRODUCT_RULE;
/*
* Rule group is a set of rules that the cart must meet for this cart rule to be applied.
* These groups have an AND relationship. If you create two groups for given cart rule,
* the cart must meet the conditions of both of them to be applied.
*
* Also, at least $product_rule_group['quantity'] must meet these rules.
*/
$eligible_products_list = [];
foreach ($products as $product) {
$eligible_products_list[] = (int) $product['id_product'] . '-' . (int) $product['id_product_attribute'];
}
// Now, we load the RULES inside the RULE GROUP
$product_rules = $this->getProductRules($id_product_rule_group);
$countRulesProduct = count($product_rules);
$failedProductRules = 0;
foreach ($product_rules as $product_rule) {
/*
* For the cart RULE GROUP to be validated, we have two behaviours depending on $product_rule_group_type:
* - AT_LEAST_ONE_PRODUCT_RULE: at least on of the RULES inside the RULE GROUP must meet the conditions
* - ALL_PRODUCT_RULES: all the RULES inside the RULE GROUP must meet the conditions
*/
switch ($product_rule['type']) {
case 'attributes':
// Skip if no eligible products to avoid SQL syntax error
if (empty($eligible_products_list)) {
$count_matching_products = 0;
$matching_products_list = [];
} else {
// Build the matching list of "{productId}-{combinationId}" compatible with the IN mysql operator, this will result in a string looking like:
// "23-45", "23-46", "42-0"
$combinationInValue = implode(',', array_map(fn ($combinationIdentifier) => '"' . $combinationIdentifier . '"', $eligible_products_list));
$cart_attributes = Db::getInstance()->executeS('
SELECT cp.quantity, cp.`id_product`, pac.`id_attribute`, cp.`id_product_attribute`
FROM `' . _DB_PREFIX_ . 'cart_product` cp
LEFT JOIN `' . _DB_PREFIX_ . 'product_attribute_combination` pac ON cp.id_product_attribute = pac.id_product_attribute
WHERE cp.`id_cart` = ' . (int) $cart->id . '
AND CONCAT(cp.`id_product`, "-", cp.`id_product_attribute`) IN (' . $combinationInValue . ')');
$count_matching_products = 0;
$matching_products_list = [];
foreach ($cart_attributes as $cart_attribute) {
if (in_array($cart_attribute['id_attribute'], $product_rule['values'])) {
$count_matching_products += $cart_attribute['quantity'];
if (
$alreadyInCart
&& $this->gift_product == $cart_attribute['id_product']
&& $this->gift_product_attribute == $cart_attribute['id_product_attribute']) {
--$count_matching_products;
}
$matching_products_list[] = $cart_attribute['id_product'] . '-' . $cart_attribute['id_product_attribute'];
}
}
}
if ($count_matching_products < $product_rule_group['quantity']) {
++$failedProductRules;
break;
}
$eligible_products_list = $this->filterProducts($eligible_products_list, $matching_products_list, $product_rule['type']);
break;
case 'products':
// Skip if no eligible products to avoid SQL syntax error
if (empty($eligible_products_list)) {
$count_matching_products = 0;
$matching_products_list = [];
} else {
$cart_products = Db::getInstance()->executeS('
SELECT cp.quantity, cp.`id_product`
FROM `' . _DB_PREFIX_ . 'cart_product` cp
WHERE cp.`id_cart` = ' . (int) $cart->id . '
AND cp.`id_product` IN (' . implode(',', array_map('intval', $eligible_products_list)) . ')');
$count_matching_products = 0;
$matching_products_list = [];
foreach ($cart_products as $cart_product) {
if (in_array($cart_product['id_product'], $product_rule['values'])) {
$count_matching_products += $cart_product['quantity'];
if ($alreadyInCart && $this->gift_product == $cart_product['id_product']) {
--$count_matching_products;
}
$matching_products_list[] = $cart_product['id_product'] . '-0';
}
}
}
if ($count_matching_products < $product_rule_group['quantity']) {
++$failedProductRules;
break;
}
$eligible_products_list = $this->filterProducts($eligible_products_list, $matching_products_list, $product_rule['type']);
break;
case 'combinations':
// Build the matching list of "{productId}-{combinationId}" compatible with the IN mysql operator, this will result in a string looking like:
// "23-45", "23-46", "42-0"
$combinationInValue = implode(',', array_map(fn ($combinationIdentifier) => '"' . $combinationIdentifier . '"', $eligible_products_list));
$cart_combinations = Db::getInstance()->executeS('
SELECT cp.quantity, cp.`id_product`, cp.`id_product_attribute`
FROM `' . _DB_PREFIX_ . 'cart_product` cp
WHERE cp.`id_cart` = ' . (int) $cart->id . '
AND CONCAT(cp.`id_product`, "-", cp.`id_product_attribute`) IN (' . $combinationInValue . ')');
$count_matching_combinations = 0;
$matching_combinations_list = [];
foreach ($cart_combinations as $cart_combination) {
if (in_array($cart_combination['id_product_attribute'], $product_rule['values'])) {
$count_matching_combinations += $cart_combination['quantity'];
// Todo: Handle correct check when combination gift is handled
if ($alreadyInCart && $this->gift_product == $cart_combination['id_product']) {
--$count_matching_combinations;
}
$matching_combinations_list[] = $cart_combination['id_product'] . '-' . $cart_combination['id_product_attribute'];
}
}
if ($count_matching_combinations < $product_rule_group['quantity']) {
++$failedProductRules;
break;
}
$eligible_products_list = $this->filterProducts($eligible_products_list, $matching_combinations_list, $product_rule['type']);
break;
case 'categories':
// Skip if no eligible products to avoid SQL syntax error
if (empty($eligible_products_list)) {
$count_matching_products = 0;
$matching_products_list = [];
} else {
$cart_categories = Db::getInstance()->executeS('
SELECT cp.quantity, cp.`id_product`, cp.`id_product_attribute`, catp.`id_category`
FROM `' . _DB_PREFIX_ . 'cart_product` cp
LEFT JOIN `' . _DB_PREFIX_ . 'category_product` catp ON cp.id_product = catp.id_product
WHERE cp.`id_cart` = ' . (int) $cart->id . '
AND cp.`id_product` IN (' . implode(',', array_map('intval', $eligible_products_list)) . ')
AND cp.`id_product` <> ' . (int) $this->gift_product);
$count_matching_products = 0;
$matching_products_list = [];
foreach ($cart_categories as $cart_category) {
if (in_array($cart_category['id_category'], $product_rule['values'])
/*
* We also check that the product is not already in the matching product list,
* because there are doubles in the query results (when the product is in multiple categories)
*/
&& !in_array($cart_category['id_product'] . '-' . $cart_category['id_product_attribute'], $matching_products_list)) {
$count_matching_products += $cart_category['quantity'];
$matching_products_list[] = $cart_category['id_product'] . '-' . $cart_category['id_product_attribute'];
}
}
}
if ($count_matching_products < $product_rule_group['quantity']) {
++$failedProductRules;
break;
}
// Attribute id is not important for this filter in the global list, so the ids are replaced by 0
foreach ($matching_products_list as &$matching_product) {
$matching_product = preg_replace('/^([0-9]+)-[0-9]+$/', '$1-0', $matching_product);
}
$eligible_products_list = $this->filterProducts($eligible_products_list, $matching_products_list, $product_rule['type']);
break;
case 'manufacturers':
// Skip if no eligible products to avoid SQL syntax error
if (empty($eligible_products_list)) {
$count_matching_products = 0;
$matching_products_list = [];
} else {
$cart_manufacturers = Db::getInstance()->executeS('
SELECT cp.quantity, cp.`id_product`, p.`id_manufacturer`
FROM `' . _DB_PREFIX_ . 'cart_product` cp
LEFT JOIN `' . _DB_PREFIX_ . 'product` p ON cp.id_product = p.id_product
WHERE cp.`id_cart` = ' . (int) $cart->id . '
AND cp.`id_product` IN (' . implode(',', array_map('intval', $eligible_products_list)) . ')');
$count_matching_products = 0;
$matching_products_list = [];
foreach ($cart_manufacturers as $cart_manufacturer) {
if (in_array($cart_manufacturer['id_manufacturer'], $product_rule['values'])) {
$count_matching_products += $cart_manufacturer['quantity'];
$matching_products_list[] = $cart_manufacturer['id_product'] . '-0';
}
}
}
if ($count_matching_products < $product_rule_group['quantity']) {
++$failedProductRules;
break;
}
$eligible_products_list = $this->filterProducts($eligible_products_list, $matching_products_list, $product_rule['type']);
break;
case 'suppliers':
// Skip if no eligible products to avoid SQL syntax error
if (empty($eligible_products_list)) {
$count_matching_products = 0;
$matching_products_list = [];
} else {
$cart_suppliers = Db::getInstance()->executeS('
SELECT cp.quantity, cp.`id_product`, p.`id_supplier`
FROM `' . _DB_PREFIX_ . 'cart_product` cp
LEFT JOIN `' . _DB_PREFIX_ . 'product` p ON cp.id_product = p.id_product
WHERE cp.`id_cart` = ' . (int) $cart->id . '
AND cp.`id_product` IN (' . implode(',', array_map('intval', $eligible_products_list)) . ')');
$count_matching_products = 0;
$matching_products_list = [];
foreach ($cart_suppliers as $cart_supplier) {
if (in_array($cart_supplier['id_supplier'], $product_rule['values'])) {
$count_matching_products += $cart_supplier['quantity'];
$matching_products_list[] = $cart_supplier['id_product'] . '-0';
}
}
}
if ($count_matching_products < $product_rule_group['quantity']) {
++$failedProductRules;
break;
}
$eligible_products_list = $this->filterProducts($eligible_products_list, $matching_products_list, $product_rule['type']);
break;
case 'features':
// Skip if no eligible products to avoid SQL syntax error
if (empty($eligible_products_list)) {
$count_matching_products = 0;
$matching_products_list = [];
} else {
// Build the matching list of "{productId}-{productFeatureValueId}" compatible with the IN mysql operator for features matching, this will result in a string looking like:
// "23-45", "23-46", "42-0"
$featureInValue = implode(',', array_map(fn ($featureIdentifier) => '"' . $featureIdentifier . '"', $eligible_products_list));
$cart_features = Db::getInstance()->executeS('
SELECT cp.quantity, cp.`id_product`, f.`id_feature`, f.`id_feature_value`, cp.`id_product_attribute`
FROM `' . _DB_PREFIX_ . 'cart_product` cp
LEFT JOIN `' . _DB_PREFIX_ . 'feature_product` f ON cp.id_product = f.id_product
WHERE cp.`id_cart` = ' . (int) $cart->id . '
AND CONCAT(cp.`id_product`, "-", cp.`id_product_attribute`) IN (' . $featureInValue . ')');
$count_matching_products = 0;
$matching_products_list = [];
foreach ($cart_features as $cart_feature) {
if (in_array($cart_feature['id_feature'], $product_rule['values']) || in_array($cart_feature['id_feature_value'], $product_rule['values'])) {
$count_matching_products += $cart_feature['quantity'];
$matching_products_list[] = $cart_feature['id_product'] . '-' . $cart_feature['id_product_attribute'];
}
}
}
if ($count_matching_products < $product_rule_group['quantity']) {
if ($countRulesProduct === 1) {
return (!$displayError) ? false : $this->trans('You cannot use this voucher with these products', [], 'Shop.Notifications.Error');
} else {
++$failedProductRules;
break;
}
}
$eligible_products_list = $this->filterProducts($eligible_products_list, $matching_products_list, $product_rule['type']);
break;
default:
return (!$displayError) ? false : $this->trans('Unknown type of product restriction', [], 'Shop.Notifications.Error');
}
// If the product rule type was ALL_PRODUCT_RULES and at least one rule failed, then it means the condition was not fulfilled,
// no need to check for remaining product rules
if ($product_rule_group_type === self::ALL_PRODUCT_RULES && $failedProductRules) {
return (!$displayError) ? false : $this->trans('You cannot use this voucher with these products', [], 'Shop.Notifications.Error');
}
}
// If all product rules have failed it means that none of them worked, so the condition is not fulfilled (it's true for ALL_PRODUCT_RULES and AT_LEAST_ONE_PRODUCT_RULE)
if ($failedProductRules == $countRulesProduct) {
return (!$displayError) ? false : $this->trans('You cannot use this voucher with these products', [], 'Shop.Notifications.Error');
}
// Merge all eligible products for each product rule group
$selected_products = array_merge($selected_products, $eligible_products_list);
}
}
if ($returnProducts) {
return $selected_products;
}
return (!$displayError) ? true : false;
}
protected function getProductsMatchingSelection(array $packageProducts, CartCore $cart): array
{
$matchingProducts = [];
if ($this->reduction_product == -2) {
$selectedProducts = $this->checkProductRestrictionsFromCart($cart, true);
if (is_array($selectedProducts)) {
foreach ($packageProducts as $product) {
if ((in_array($product['id_product'] . '-' . $product['id_product_attribute'], $selectedProducts)
|| in_array($product['id_product'] . '-0', $selectedProducts))
&& (($this->reduction_exclude_special && !$product['reduction_applies']) || !$this->reduction_exclude_special)) {
$matchingProducts[] = $product;
}
}
}
}
return $matchingProducts;
}
/**
* The reduction value is POSITIVE.
*
* @param bool $use_tax Apply taxes
* @param Context $context Context instance
* @param bool $use_cache Allow using cache to avoid multiple free gift using multishipping
*
* @return float|int|string
*/
public function getContextualValue($use_tax, ?Context $context = null, $filter = null, $package = null, $use_cache = true)
{
if (!CartRule::isFeatureActive()) {
return 0;
}
/*
* Custom cart rule value from modules. Allows to create infinite possibilities of rules.
*
* If a module is applying a custom value using actionApplyCartRule, it should also apply
* the same value here.
*/
$contextualValueFromModules = null;
Hook::exec(
'actionGetCartRuleContextualValue',
[
'cart_rule' => $this,
'use_tax' => $use_tax,
'context' => $context,
'filter' => $filter,
'package' => $package,
'use_cache' => $use_cache,
'contextualValueFromModules' => &$contextualValueFromModules,
]
);
// @phpstan-ignore-next-line
if ($contextualValueFromModules !== null) {
return $contextualValueFromModules;
}
// set base price that will be used for percent reductions
if (!empty($context->virtualTotalTaxIncluded) && !empty($context->virtualTotalTaxExcluded)) {
$basePriceForPercentReduction = $use_tax ? $context->virtualTotalTaxIncluded : $context->virtualTotalTaxExcluded;
}
if (!$context) {
$context = Context::getContext();
}
if (!$filter) {
$filter = CartRule::FILTER_ACTION_ALL;
}
$all_products = $context->cart->getProducts();
$package_products = (null === $package ? $all_products : $package['products']);
$all_cart_rules_ids = $context->cart->getOrderedCartRulesIds();
if (!array_key_exists($context->cart->id, static::$cartAmountCache)) {
if (!Configuration::get('PS_TAX')) {
static::$cartAmountCache[$context->cart->id]['te'] = $context->cart->getOrderTotal(false, Cart::ONLY_PRODUCTS);
static::$cartAmountCache[$context->cart->id]['ti'] = static::$cartAmountCache[$context->cart->id]['te'];
} else {
static::$cartAmountCache[$context->cart->id]['ti'] = $context->cart->getOrderTotal(true, Cart::ONLY_PRODUCTS);
static::$cartAmountCache[$context->cart->id]['te'] = $context->cart->getOrderTotal(false, Cart::ONLY_PRODUCTS);
}
}
$cart_amount_te = static::$cartAmountCache[$context->cart->id]['te'];
$cart_amount_ti = static::$cartAmountCache[$context->cart->id]['ti'];
$reduction_value = 0;
$cache_id = 'getContextualValue_' . (int) $this->id . '_' . (int) $use_tax . '_' . (int) $context->cart->id . '_' . (int) $filter;
foreach ($package_products as $product) {
$cache_id .= '_' . (int) $product['id_product'] . '_' . (int) $product['id_product_attribute'] . (isset($product['in_stock']) ? '_' . (int) $product['in_stock'] : '');
}
if (Cache::isStored($cache_id)) {
return Cache::retrieve($cache_id);
}
// Free shipping on selected carriers
$reduction_carrier = 0;
if ($this->free_shipping && in_array($filter, [CartRule::FILTER_ACTION_ALL, CartRule::FILTER_ACTION_ALL_NOCAP, CartRule::FILTER_ACTION_SHIPPING])) {
if (!$this->carrier_restriction) {
$reduction_carrier += $context->cart->getOrderTotal($use_tax, Cart::ONLY_SHIPPING, null === $package ? null : $package['products'], null === $package ? null : $package['id_carrier']);
} else {
$data = Db::getInstance()->executeS('
SELECT crc.id_cart_rule, c.id_carrier
FROM ' . _DB_PREFIX_ . 'cart_rule_carrier crc
INNER JOIN ' . _DB_PREFIX_ . 'carrier c ON (c.id_reference = crc.id_carrier AND c.deleted = 0)
WHERE crc.id_cart_rule = ' . (int) $this->id . '
AND c.id_carrier = ' . (int) $context->cart->id_carrier);
if ($data) {
foreach ($data as $cart_rule) {
$reduction_carrier += $context->cart->getCarrierCost((int) $cart_rule['id_carrier'], $use_tax, $context->country);
}
}
}
$reduction_value += $reduction_carrier;
}
if (in_array($filter, [CartRule::FILTER_ACTION_ALL, CartRule::FILTER_ACTION_ALL_NOCAP, CartRule::FILTER_ACTION_REDUCTION])) {
$order_package_products_total = 0;
if ($this->isDiscountFeatureFlagEnabled()) {
if ($this->getType() === DiscountType::ORDER_LEVEL && $this->reduction_percent > 0.00 && $this->reduction_product == 0) {
$order_products_total = $context->cart->getOrderTotal($use_tax, Cart::ONLY_PRODUCTS, $package_products);
$order_shipping_total = $context->cart->getOrderTotal($use_tax, Cart::ONLY_SHIPPING, $package_products);
$order_total = $order_products_total + $order_shipping_total;
$reduction_value += $order_total * $this->reduction_percent / 100;
return $reduction_value;
}
}
if ((float) $this->reduction_amount > 0
|| (float) $this->reduction_percent && $this->reduction_product == 0) {
$order_package_products_total = $context->cart->getOrderTotal($use_tax, Cart::ONLY_PRODUCTS, $package_products);
}
// Discount (%) on the whole order
if ((float) $this->reduction_percent && $this->reduction_product == 0) {
// Do not give a reduction on free products!
$order_total = $order_package_products_total;
$basePriceContainsDiscount = isset($basePriceForPercentReduction) && $order_total === $basePriceForPercentReduction;
foreach ($context->cart->getCartRules(CartRule::FILTER_ACTION_GIFT, false) as $cart_rule) {
$freeProductsPrice = Tools::ps_round($cart_rule['obj']->getContextualValue($use_tax, $context, CartRule::FILTER_ACTION_GIFT, $package), Context::getContext()->getComputingPrecision());
if ($basePriceContainsDiscount && isset($basePriceForPercentReduction)) {
// Gifts haven't been excluded yet, we need to do it
$basePriceForPercentReduction -= $freeProductsPrice;
}
$order_total -= $freeProductsPrice;
}
// Remove products that are on special
if ($this->reduction_exclude_special) {
foreach ($package_products as $product) {
if ($product['reduction_applies']) {
$roundTotal = $use_tax ? $product['total_wt'] : $product['total'];
$excludedReduction = Tools::ps_round($roundTotal, Context::getContext()->getComputingPrecision());
$order_total -= $excludedReduction;
if ($basePriceContainsDiscount && isset($basePriceForPercentReduction)) {
$basePriceForPercentReduction -= $excludedReduction;
}
}
}
}
// set base price on which percentage reduction will be applied
$basePriceForPercentReduction = $basePriceForPercentReduction ?? $order_total;
$reduction_value += $basePriceForPercentReduction * $this->reduction_percent / 100;
}
// Discount (%) on a specific product
if ((float) $this->reduction_percent && $this->reduction_product > 0) {
foreach ($package_products as $product) {
if ($product['id_product'] == $this->reduction_product && (($this->reduction_exclude_special && !$product['reduction_applies']) || !$this->reduction_exclude_special)) {
$reduction_value += ($use_tax ? $product['total_wt'] : $product['total']) * $this->reduction_percent / 100;
}
}
}
// Discount (%) on the cheapest product
if ((float) $this->reduction_percent && $this->reduction_product == -1) {
// First search for cheapest product
$cheapestProduct = $this->getCheapestProduct($all_products, $package_products, $use_tax);
if ($cheapestProduct) {
$cheapestProductPrice = $use_tax ? $cheapestProduct['price_with_reduction'] : $cheapestProduct['price_with_reduction_without_tax'];
// For product level discount, the percent discount is applied on all targeted products
if ($this->isDiscountFeatureFlagEnabled() && $this->getType() === DiscountType::PRODUCT_LEVEL) {
$reduction_value += $cheapestProduct['cart_quantity'] * $cheapestProductPrice * $this->reduction_percent / 100;
} else {
$reduction_value += $cheapestProductPrice * $this->reduction_percent / 100;
}
}
}
// Discount (%) on the selection of products
if ((float) $this->reduction_percent && $this->reduction_product == -2) {
$selected_products_reduction = 0;
// Let's get products this cart rule applies to.
$selectedProducts = $this->getProductsMatchingSelection($package_products, $context->cart);
foreach ($selectedProducts as $product) {
$productPrice = $use_tax ? $product['price_with_reduction'] : $product['price_with_reduction_without_tax'];
$selected_products_reduction += $productPrice * $product['cart_quantity'];
}
$reduction_value += $selected_products_reduction * $this->reduction_percent / 100;
}
// Discount (¤)
if ((float) $this->reduction_amount > 0) {
$prorata = 1;
if (null !== $package && count($all_products)) {
$total_products = $use_tax ? $cart_amount_ti : $cart_amount_te;
if ($total_products) {
$prorata = $order_package_products_total / $total_products;
}
}
$reduction_amount = (float) $this->reduction_amount;
// If the cart rule is restricted to one product it can't exceed this product price
if ($this->reduction_product > 0) {
foreach ($all_products as $product) {
if ($product['id_product'] == $this->reduction_product) {
$productPrice = $this->reduction_tax ? $product['price_wt'] : $product['price'];
$max_reduction_amount = (int) $product['cart_quantity'] * (float) $productPrice;
$reduction_amount = min($reduction_amount, $max_reduction_amount);
break;
}
}
}
// If we need to convert the voucher value to the cart currency
if (isset($context->currency) && $this->reduction_currency != $context->currency->id) {
$voucherCurrency = new Currency($this->reduction_currency);
// First we convert the voucher value to the default currency
if ($reduction_amount == 0 || $voucherCurrency->conversion_rate == 0) {
$reduction_amount = 0;
} else {
$reduction_amount /= $voucherCurrency->conversion_rate;
}
// Then we convert the voucher value in the default currency into the cart currency
$reduction_amount *= $context->currency->conversion_rate;
$reduction_amount = Tools::ps_round($reduction_amount, Context::getContext()->getComputingPrecision());
}
// Special rule for product_level discount that are able to handle cheapest product and product segments
if ($this->isDiscountFeatureFlagEnabled() && $this->getType() === DiscountType::PRODUCT_LEVEL && ($this->reduction_product == -1 || $this->reduction_product == -2)) {
// Find matching products
if ($this->reduction_product == -1) {
$cheapestProduct = $this->getCheapestProduct($all_products, $package_products, $use_tax);
$selectedProducts = $cheapestProduct ? [$cheapestProduct] : [];
} else {
$selectedProducts = $this->getProductsMatchingSelection($package_products, $context->cart);
}
// Now apply the reduction for each product based on its quantity
foreach ($selectedProducts as $product) {
// We reset on each loop and use the initial reduction, in case it is modified for taxes
$productReduction = $reduction_amount;
// Adapt the amount depending on cart rule reduction_tax AND the asked use_tax (only when they are not the same)
if ($this->reduction_tax !== $use_tax) {
$productTaxRate = ($product['rate'] ?? 0) / 100;
if ($this->reduction_tax && !$use_tax) {
$productReduction = $productReduction / (1 + $productTaxRate);
} elseif (!$this->reduction_tax && $use_tax) {
$productReduction = $productReduction * (1 + $productTaxRate);
}
}
$productPrice = $use_tax ? $product['price_with_reduction'] : $product['price_with_reduction_without_tax'];
$productReduction = min($productReduction, $productPrice);
$reduction_value += $productReduction * $product['cart_quantity'];
}
} // If it has the same tax application that you need, then it's the right value, whatever the product!
elseif ($this->reduction_tax == $use_tax) {
// The reduction cannot exceed the products total, except when we do not want it to be limited (for the partial use calculation)
if ($filter != CartRule::FILTER_ACTION_ALL_NOCAP) {
$cart_amount = $use_tax ? $cart_amount_ti : $cart_amount_te;
$reduction_amount = min($reduction_amount, $cart_amount);
}
$reduction_value += $prorata * $reduction_amount;
} else {
if ($this->reduction_product > 0) {
foreach ($all_products as $product) {
if ($product['id_product'] == $this->reduction_product) {
$product_price_ti = $product['price_wt'];
$product_price_te = $product['price'];
$product_vat_amount = $product_price_ti - $product_price_te;
if ($product_vat_amount == 0 || $product_price_te == 0) {
$product_vat_rate = 0;
} else {
$product_vat_rate = $product_vat_amount / $product_price_te;
}
if ($this->reduction_tax && !$use_tax) {
$reduction_value += $prorata * $reduction_amount / (1 + $product_vat_rate);
} elseif (!$this->reduction_tax && $use_tax) {
$reduction_value += $prorata * $reduction_amount * (1 + $product_vat_rate);
}
}
}
} elseif ($this->reduction_product == 0) {
// Discount (¤) on the whole order
$cart_amount_te = null;
$cart_amount_ti = null;
$cart_average_vat_rate = $context->cart->getAverageProductsTaxRate($cart_amount_te, $cart_amount_ti);
// The reduction cannot exceed the products total, except when we do not want it to be limited (for the partial use calculation)
if ($filter != CartRule::FILTER_ACTION_ALL_NOCAP) {
if ($this->isDiscountFeatureFlagEnabled() && $this->getType() === DiscountType::ORDER_LEVEL) {
$max_reduction_amount = $this->reduction_tax
? $cart_amount_ti + $context->cart->getOrderTotal(true, Cart::ONLY_SHIPPING, $package_products)
: $cart_amount_te + $context->cart->getOrderTotal(false, Cart::ONLY_SHIPPING, $package_products);
$reduction_amount = min(
$reduction_amount,
$max_reduction_amount,
);
} else {
$reduction_amount = min($reduction_amount, $this->reduction_tax ? $cart_amount_ti : $cart_amount_te);
}
}
if ($this->reduction_tax && !$use_tax) {
$reduction_value += $prorata * $reduction_amount / (1 + $cart_average_vat_rate);
} elseif (!$this->reduction_tax && $use_tax) {
$reduction_value += $prorata * $reduction_amount * (1 + $cart_average_vat_rate);
}
}
/*
* Reduction on the cheapest or on the selection is not really meaningful and has been disabled in the backend, it only applies with percent
* Please keep this code, so it won't be considered as a bug
* elseif ($this->reduction_product == -1)
* elseif ($this->reduction_product == -2)
*/
}
// Take care of the other cart rules values if the filter allow it
if ($filter != CartRule::FILTER_ACTION_ALL_NOCAP) {
// Cart values
$cart = Context::getContext()->cart;
if (!Validate::isLoadedObject($cart)) {
$cart = new Cart();
}
$cart_average_vat_rate = $cart->getAverageProductsTaxRate();
$current_cart_amount = $use_tax ? $cart_amount_ti : $cart_amount_te;
foreach ($all_cart_rules_ids as $current_cart_rule_id) {
if ((int) $current_cart_rule_id['id_cart_rule'] == (int) $this->id) {
break;
}
$previous_cart_rule = new CartRule((int) $current_cart_rule_id['id_cart_rule']);
$previous_reduction_amount = $previous_cart_rule->reduction_amount;
if ($previous_cart_rule->reduction_tax && !$use_tax) {
$previous_reduction_amount = $prorata * $previous_reduction_amount / (1 + $cart_average_vat_rate);
} elseif (!$previous_cart_rule->reduction_tax && $use_tax) {
$previous_reduction_amount = $prorata * $previous_reduction_amount * (1 + $cart_average_vat_rate);
}
$current_cart_amount = max($current_cart_amount - (float) $previous_reduction_amount, 0);
}
if ($this->isDiscountFeatureFlagEnabled() && $this->getType() === DiscountType::ORDER_LEVEL) {
$current_cart_amount += $this->reduction_tax
? $context->cart->getOrderTotal(true, Cart::ONLY_SHIPPING, $package_products)
: $context->cart->getOrderTotal(false, Cart::ONLY_SHIPPING, $package_products);
}
$reduction_value = min($reduction_value, $current_cart_amount);
}
}
}
// Free gift
if ((int) $this->gift_product && in_array($filter, [CartRule::FILTER_ACTION_ALL, CartRule::FILTER_ACTION_ALL_NOCAP, CartRule::FILTER_ACTION_GIFT])) {
$id_address = (null === $package ? 0 : $package['id_address']);
foreach ($package_products as $product) {
if ($product['id_product'] == $this->gift_product && ($product['id_product_attribute'] == $this->gift_product_attribute || !(int) $this->gift_product_attribute)) {
// The free gift coupon must be applied to one product only (needed for multi-shipping which manage multiple product lists)
if (!isset(CartRule::$only_one_gift[$this->id . '-' . $this->gift_product])
|| CartRule::$only_one_gift[$this->id . '-' . $this->gift_product] == $id_address
|| CartRule::$only_one_gift[$this->id . '-' . $this->gift_product] == 0
|| $id_address == 0
|| !$use_cache) {
$reduction_value += Tools::ps_round($use_tax ? $product['price_wt'] : $product['price'], Context::getContext()->getComputingPrecision());
if ($use_cache && (!isset(CartRule::$only_one_gift[$this->id . '-' . $this->gift_product]) || CartRule::$only_one_gift[$this->id . '-' . $this->gift_product] == 0)) {
CartRule::$only_one_gift[$this->id . '-' . $this->gift_product] = $id_address;
}
break;
}
}
}
}
Cache::store($cache_id, $reduction_value);
// update virtual total values, for percentage reductions that might be applied later
// but remove the carrier as free shipping is not a real reduction
if ($use_tax && !empty($context->virtualTotalTaxIncluded)) {
$context->virtualTotalTaxIncluded -= $reduction_value;
if ($this->free_shipping) {
$context->virtualTotalTaxIncluded += $reduction_carrier;
}
} elseif (!$use_tax && !empty($context->virtualTotalTaxExcluded)) {
$context->virtualTotalTaxExcluded -= $reduction_value;
if ($this->free_shipping) {
$context->virtualTotalTaxExcluded += $reduction_carrier;
}
}
return $reduction_value;
}
protected function getCheapestProduct(array $allProducts, array $packageProducts, bool $useTax): ?array
{
$minPrice = false;
$cheapestProduct = null;
foreach ($allProducts as $product) {
$price = $useTax ? $product['price_with_reduction'] : $product['price_with_reduction_without_tax'];
if ($price > 0 && ($minPrice === false || $minPrice > $price) && (($this->reduction_exclude_special && !$product['reduction_applies']) || !$this->reduction_exclude_special)) {
$minPrice = $price;
$cheapestProduct = $product;
}
}
if (!$cheapestProduct) {
return null;
}
// Check if the cheapest product is in the package
$cheapestProductId = $cheapestProduct['id_product'] . '-' . (string) ($cheapestProduct['id_product_attribute'] ?? 0);
$inPackage = false;
foreach ($packageProducts as $product) {
if ($product['id_product'] . '-' . $product['id_product_attribute'] == $cheapestProductId || $product['id_product'] . '-0' == $cheapestProductId) {
$inPackage = true;
}
}
return $inPackage ? $cheapestProduct : null;
}
/**
* Make sure caches are empty
* Must be called before calling multiple time getContextualValue().
*/
public static function cleanCache()
{
self::$only_one_gift = [];
}
/**
* Get CartRule combinations.
*
* @param int $offset Offset
* @param int $limit Limit
* @param string $search Search query
*
* @return array CartRule search results
*/
protected function getCartRuleCombinations($offset = null, $limit = null, $search = '')
{
$array = [];
if ($offset !== null && $limit !== null) {
$sql_limit = ' LIMIT ' . (int) $offset . ', ' . (int) ($limit + 1);
} else {
$sql_limit = '';
}
$array['selected'] = Db::getInstance()->executeS('
SELECT cr.*, crl.*, 1 as selected
FROM ' . _DB_PREFIX_ . 'cart_rule cr
LEFT JOIN ' . _DB_PREFIX_ . 'cart_rule_lang crl ON (cr.id_cart_rule = crl.id_cart_rule AND crl.id_lang = ' . (int) Context::getContext()->language->id . ')
WHERE cr.id_cart_rule != ' . (int) $this->id . ($search ? ' AND crl.name LIKE "%' . pSQL($search) . '%"' : '') . '
AND (
cr.cart_rule_restriction = 0
OR EXISTS (
SELECT 1
FROM ' . _DB_PREFIX_ . 'cart_rule_combination
WHERE cr.id_cart_rule = ' . _DB_PREFIX_ . 'cart_rule_combination.id_cart_rule_1 AND ' . (int) $this->id . ' = id_cart_rule_2
)
OR EXISTS (
SELECT 1
FROM ' . _DB_PREFIX_ . 'cart_rule_combination
WHERE cr.id_cart_rule = ' . _DB_PREFIX_ . 'cart_rule_combination.id_cart_rule_2 AND ' . (int) $this->id . ' = id_cart_rule_1
)
) ORDER BY cr.id_cart_rule' . $sql_limit);
$array['unselected'] = Db::getInstance()->executeS('
SELECT cr.*, crl.*, 1 as selected
FROM ' . _DB_PREFIX_ . 'cart_rule cr
INNER JOIN ' . _DB_PREFIX_ . 'cart_rule_lang crl ON (cr.id_cart_rule = crl.id_cart_rule AND crl.id_lang = ' . (int) Context::getContext()->language->id . ')
LEFT JOIN ' . _DB_PREFIX_ . 'cart_rule_combination crc1 ON (cr.id_cart_rule = crc1.id_cart_rule_1 AND crc1.id_cart_rule_2 = ' . (int) $this->id . ')
LEFT JOIN ' . _DB_PREFIX_ . 'cart_rule_combination crc2 ON (cr.id_cart_rule = crc2.id_cart_rule_2 AND crc2.id_cart_rule_1 = ' . (int) $this->id . ')
WHERE cr.cart_rule_restriction = 1
AND cr.id_cart_rule != ' . (int) $this->id . ($search ? ' AND crl.name LIKE "%' . pSQL($search) . '%"' : '') . '
AND crc1.id_cart_rule_1 IS NULL
AND crc2.id_cart_rule_1 IS NULL ORDER BY cr.id_cart_rule' . $sql_limit);
return $array;
}
/**
* Get associated restrictions.
*
* @param string $type Restriction type
* Can be one of the following:
* - country
* - carrier
* - group
* - cart_rule
* - shop
* @param bool $active_only Only return active restrictions
* @param bool $i18n Join with associated language table
* @param int $offset Search offset
* @param int $limit Search results limit
* @param string $search_cart_rule_name CartRule name to search for
*
* @return array|bool Array with DB rows of requested type
*
* @throws PrestaShopDatabaseException
*/
public function getAssociatedRestrictions(
$type,
$active_only,
$i18n,
$offset = null,
$limit = null,
$search_cart_rule_name = ''
) {
$array = ['selected' => [], 'unselected' => []];
if (!in_array($type, ['country', 'carrier', 'group', 'cart_rule', 'shop'])) {
return false;
}
$shop_list = '';
if ($type == 'shop') {
$shops = Context::getContext()->employee->getAssociatedShops();
if (count($shops)) {
$shop_list = ' AND t.id_shop IN (' . implode(',', array_map('intval', $shops)) . ') ';
}
}
if ($offset !== null && $limit !== null) {
$sql_limit = ' LIMIT ' . (int) $offset . ', ' . (int) ($limit + 1);
} else {
$sql_limit = '';
}
if (!Validate::isLoadedObject($this) || $this->{$type . '_restriction'} == 0) {
$array['selected'] = Db::getInstance()->executeS('
SELECT t.*' . ($i18n ? ', tl.*' : '') . ', 1 as selected
FROM `' . _DB_PREFIX_ . $type . '` t
' . ($i18n ? 'LEFT JOIN `' . _DB_PREFIX_ . $type . '_lang` tl ON (t.id_' . $type . ' = tl.id_' . $type . ' AND tl.id_lang = ' . (int) Context::getContext()->language->id . ')' : '') . '
WHERE 1
' . ($active_only ? 'AND t.active = 1' : '') . '
' . (in_array($type, ['carrier', 'shop']) ? ' AND t.deleted = 0' : '') . '
' . ($type == 'cart_rule' ? 'AND t.id_cart_rule != ' . (int) $this->id : '') .
$shop_list .
(in_array($type, ['carrier', 'shop']) ? ' ORDER BY t.name ASC ' : '') .
(in_array($type, ['country', 'group', 'cart_rule']) && $i18n ? ' ORDER BY tl.name ASC ' : '') .
$sql_limit);
} else {
if ($type == 'cart_rule') {
$array = $this->getCartRuleCombinations($offset, $limit, $search_cart_rule_name);
} else {
$resource = Db::getInstance()->executeS(
'
SELECT t.*' . ($i18n ? ', tl.*' : '') . ', IF(crt.id_' . $type . ' IS NULL, 0, 1) as selected
FROM `' . _DB_PREFIX_ . $type . '` t
' . ($i18n ? 'LEFT JOIN `' . _DB_PREFIX_ . $type . '_lang` tl ON (t.id_' . $type . ' = tl.id_' . $type . ' AND tl.id_lang = ' . (int) Context::getContext()->language->id . ')' : '') . '
LEFT JOIN (SELECT id_' . $type . ' FROM `' . _DB_PREFIX_ . 'cart_rule_' . $type . '` WHERE id_cart_rule = ' . (int) $this->id . ') crt ON t.id_' . ($type == 'carrier' ? 'reference' : $type) . ' = crt.id_' . $type . '
WHERE 1 ' . ($active_only ? ' AND t.active = 1' : '') .
$shop_list
. (in_array($type, ['carrier', 'shop']) ? ' AND t.deleted = 0' : '') .
(in_array($type, ['carrier', 'shop']) ? ' ORDER BY t.name ASC ' : '') .
(in_array($type, ['country', 'group']) && $i18n ? ' ORDER BY tl.name ASC ' : '') .
$sql_limit,
false
);
while ($row = Db::getInstance()->nextRow($resource)) {
$array[($row['selected'] || $this->{$type . '_restriction'} == 0) ? 'selected' : 'unselected'][] = $row;
}
}
}
return $array;
}
/**
* Automatically add this CartRule to the Cart.
*
* @param Context|null $context Context instance
* @param bool $useOrderPrices
*/
public static function autoAddToCart(?Context $context = null, bool $useOrderPrices = false)
{
if ($context === null) {
$context = Context::getContext();
}
if (!CartRule::isFeatureActive() || !Validate::isLoadedObject($context->cart)) {
return;
}
$sql = '
SELECT SQL_NO_CACHE cr.*
FROM ' . _DB_PREFIX_ . 'cart_rule cr
LEFT JOIN ' . _DB_PREFIX_ . 'cart_rule_shop crs ON cr.id_cart_rule = crs.id_cart_rule
' . (!Validate::isLoadedObject($context->customer) && Group::isFeatureActive() ? ' LEFT JOIN ' . _DB_PREFIX_ . 'cart_rule_group crg ON cr.id_cart_rule = crg.id_cart_rule' : '') . '
LEFT JOIN ' . _DB_PREFIX_ . 'cart_rule_carrier crca ON cr.id_cart_rule = crca.id_cart_rule
' . ($context->cart->id_carrier ? 'LEFT JOIN ' . _DB_PREFIX_ . 'carrier c ON (c.id_reference = crca.id_carrier AND c.deleted = 0)' : '') . '
LEFT JOIN ' . _DB_PREFIX_ . 'cart_rule_country crco ON cr.id_cart_rule = crco.id_cart_rule
WHERE cr.active = 1
AND cr.code = ""
AND (cr.quantity > 0 OR cr.quantity is null)
AND NOW() BETWEEN cr.date_from AND cr.date_to
AND (
cr.id_customer = 0
' . (Validate::isLoadedObject($context->customer) ? 'OR cr.id_customer = ' . (int) $context->cart->id_customer : '') . '
)
AND (
cr.`carrier_restriction` = 0
' . ($context->cart->id_carrier ? 'OR c.id_carrier = ' . (int) $context->cart->id_carrier : '') . '
)
AND (
cr.`shop_restriction` = 0
' . ((Shop::isFeatureActive() && $context->shop->id) ? 'OR crs.id_shop = ' . (int) $context->shop->id : '') . '
)
AND (
cr.`group_restriction` = 0
' . (Validate::isLoadedObject($context->customer) ? 'OR EXISTS (
SELECT 1
FROM `' . _DB_PREFIX_ . 'customer_group` cg
INNER JOIN `' . _DB_PREFIX_ . 'cart_rule_group` crg ON cg.id_group = crg.id_group
WHERE cr.`id_cart_rule` = crg.`id_cart_rule`
AND cg.`id_customer` = ' . (int) $context->customer->id . '
LIMIT 1
)' : (Group::isFeatureActive() ? 'OR crg.`id_group` = ' . (int) Configuration::get('PS_UNIDENTIFIED_GROUP') : '')) . '
)
AND (
cr.`reduction_product` <= 0
OR EXISTS (
SELECT 1
FROM `' . _DB_PREFIX_ . 'cart_product`
WHERE `' . _DB_PREFIX_ . 'cart_product`.`id_product` = cr.`reduction_product` AND `id_cart` = ' . (int) $context->cart->id . '
)
)
AND NOT EXISTS (SELECT 1 FROM ' . _DB_PREFIX_ . 'cart_cart_rule WHERE cr.id_cart_rule = ' . _DB_PREFIX_ . 'cart_cart_rule.id_cart_rule
AND id_cart = ' . (int) $context->cart->id . ')
ORDER BY priority';
$result = Db::getInstance()->executeS($sql, true, false);
if ($result) {
$cart_rules = ObjectModel::hydrateCollection('CartRule', $result);
if ($cart_rules) {
foreach ($cart_rules as $cart_rule) {
/** @var CartRule $cart_rule */
if ($cart_rule->checkValidity($context, false, false, true, $useOrderPrices)) {
$context->cart->addCartRule($cart_rule->id, $useOrderPrices);
}
}
}
}
}
/**
* Automatically remove this CartRule from the Cart.
*
* @param Context|null $context Context instance
* @param bool $useOrderPrice
*
* @return array Error messages
*/
public static function autoRemoveFromCart(?Context $context = null, bool $useOrderPrice = false)
{
if (!$context) {
$context = Context::getContext();
}
if (!CartRule::isFeatureActive() || !Validate::isLoadedObject($context->cart)) {
return [];
}
static $errors = [];
foreach ($context->cart->getCartRules(CartRule::FILTER_ACTION_ALL, true, $useOrderPrice) as $cart_rule) {
if ($error = $cart_rule['obj']->checkValidity($context, true, true, true, $useOrderPrice)) {
$context->cart->removeCartRule($cart_rule['obj']->id, $useOrderPrice);
$context->cart->update();
$errors[] = $error;
}
}
return $errors;
}
/**
* Check if the CartRule feature is active
* It becomes active after adding the first CartRule to the store.
*
* @return bool Indicates whether the CartRule feature is active
*/
public static function isFeatureActive()
{
$is_feature_active = (bool) Configuration::get('PS_CART_RULE_FEATURE_ACTIVE');
return $is_feature_active;
}
/**
* CartRule cleanup
* When an entity associated to a product rule
* (product, category, attribute, supplier, manufacturer...)
* is deleted, the product rules must be updated.
*
* @param string $type Entity type
* Can be one of the following:
* - products
* - categories
* - attributes
* - manufacturers
* - suppliers
* @param array|int $list Entities
*
* @return bool Indicates whether the cleanup was successful
*/
public static function cleanProductRuleIntegrity($type, $list)
{
// Type must be available in the 'type' enum of the table cart_rule_product_rule
if (!in_array($type, ['products', 'categories', 'attributes', 'manufacturers', 'suppliers'])) {
return false;
}
// This check must not be removed because this var is used a few lines below
$list = (is_array($list) ? implode(',', array_map('intval', $list)) : (int) $list);
if (!preg_match('/^[0-9,]+$/', $list)) {
return false;
}
// Delete associated restrictions on cart rules
Db::getInstance()->execute('
DELETE crprv
FROM `' . _DB_PREFIX_ . 'cart_rule_product_rule` crpr
LEFT JOIN `' . _DB_PREFIX_ . 'cart_rule_product_rule_value` crprv ON crpr.`id_product_rule` = crprv.`id_product_rule`
WHERE crpr.`type` = "' . pSQL($type) . '"
AND crprv.`id_item` IN (' . $list . ')');
// $list is checked a few lines above
// Delete the product rules that does not have any values
if (Db::getInstance()->Affected_Rows() > 0) {
Db::getInstance()->delete('cart_rule_product_rule', 'NOT EXISTS (SELECT 1 FROM `' . _DB_PREFIX_ . 'cart_rule_product_rule_value`
WHERE `' . _DB_PREFIX_ . 'cart_rule_product_rule`.`id_product_rule` = `' . _DB_PREFIX_ . 'cart_rule_product_rule_value`.`id_product_rule`)');
}
// If the product rules were the only conditions of a product rule group, delete the product rule group
if (Db::getInstance()->Affected_Rows() > 0) {
Db::getInstance()->delete('cart_rule_product_rule_group', 'NOT EXISTS (SELECT 1 FROM `' . _DB_PREFIX_ . 'cart_rule_product_rule`
WHERE `' . _DB_PREFIX_ . 'cart_rule_product_rule`.`id_product_rule_group` = `' . _DB_PREFIX_ . 'cart_rule_product_rule_group`.`id_product_rule_group`)');
}
// If the product rule group were the only restrictions of a cart rule, update de cart rule restriction cache
if (Db::getInstance()->Affected_Rows() > 0) {
Db::getInstance()->execute('
UPDATE `' . _DB_PREFIX_ . 'cart_rule` cr
LEFT JOIN `' . _DB_PREFIX_ . 'cart_rule_product_rule_group` crprg ON cr.id_cart_rule = crprg.id_cart_rule
SET product_restriction = IF(crprg.id_product_rule_group IS NULL, 0, 1)');
}
return true;
}
/**
* Get CartRules by voucher code.
*
* @param string $name Name of voucher code
* @param int $id_lang Language ID
* @param bool $extended Also search by voucher name
*
* @return array Result from database
*/
public static function getCartsRuleByCode($name, $id_lang, $extended = false)
{
$sql_base = 'SELECT cr.*, crl.*
FROM ' . _DB_PREFIX_ . 'cart_rule cr
LEFT JOIN ' . _DB_PREFIX_ . 'cart_rule_lang crl ON (cr.id_cart_rule = crl.id_cart_rule AND crl.id_lang = ' . (int) $id_lang . ')';
if ($extended) {
return Db::getInstance()->executeS('(' . $sql_base . ' WHERE code LIKE \'%' . pSQL($name) . '%\') UNION (' . $sql_base . ' WHERE name LIKE \'%' . pSQL($name) . '%\')');
} else {
return Db::getInstance()->executeS($sql_base . ' WHERE code LIKE \'%' . pSQL($name) . '%\'');
}
}
/**
* CartRules compare function to use the Product and the rules.
*
* @param array $products List of Products from the cart,
* @param array $eligibleProducts List of Product eligible for rules,
* @param string $ruleType name of the rule,
*
* @return array Product selected who are eligible
*/
protected function filterProducts($products, $eligibleProducts, $ruleType)
{
// If the two same array, no verification todo.
if ($products === $eligibleProducts) {
return $products;
}
$return = [];
// Attribute id is not important for this filter in the global list
// so the ids are replaced by 0
if (in_array($ruleType, ['products', 'categories', 'manufacturers', 'suppliers'])) {
$productsList = explode(':', preg_replace("#\-[0-9]+#", '-0', implode(':', $products)));
} else {
$productsList = $products;
}
foreach ($productsList as $k => $product) {
if (in_array($product, $eligibleProducts)) {
$return[] = $products[$k];
}
}
return $return;
}
protected function isDiscountFeatureFlagEnabled(): bool
{
return $this->getFeatureFlagManager() !== null && $this->getFeatureFlagManager()->isEnabled(FeatureFlagSettings::FEATURE_FLAG_DISCOUNT);
}
protected function getFeatureFlagManager(): ?FeatureFlagStateCheckerInterface
{
if (!$this->featureFlagManager) {
try {
$containerFinder = new ContainerFinder(Context::getContext());
$container = $containerFinder->getContainer();
$this->featureFlagManager = $container->get(FeatureFlagStateCheckerInterface::class);
} catch (Throwable) {
// Do nothing, just here for resilience
}
}
return $this->featureFlagManager;
}
}