Magento Catalog Price Rules

You may be familiar with setting up Magento Catalog Price Rules in the admin area. But how does Magento save this information behind the scenes? And how does it check to see if a rule is applied and therefore calculate the discount?

There a few tables Magento uses to save rule information.

Magento Catalog Price Rules

  • catalogrule – Saves the general rule information as well as serialised conditions and actions data
  • catalogrule_affected_product
  • catalogrule_customer_group – The customer group IDs saved against the rule
  • catalogrule_group_website – The website ID that the customer groups are saved against
  • catalogrule_product – The products that are associated with a catalog rule
  • catalogrule_product_price – The discounted price of the products that are associated with a catalog rule
  • catalogrule_website – The website IDs that are saved against the rule.

The catalogrule_product and catalogrule_product_price tables only get populated when the catalog rule is applied and not just saved.

When creating or editing a catalog price rule, we come across a Conditions tab that allows us to include certain attributes we can use.

The attributes are loaded in the loadAttributeOptions() method of the Mage_Rule_Model_Condition_Product_Abstract class.

public function loadAttributeOptions()
{
    $productAttributes = Mage::getResourceSingleton('catalog/product')
        ->loadAllAttributes()
        ->getAttributesByCode();

    $attributes = array();
    foreach ($productAttributes as $attribute) {
        /* @var $attribute Mage_Catalog_Model_Resource_Eav_Attribute */
        if (!$attribute->isAllowedForRuleCondition()
            || !$attribute->getDataUsingMethod($this->_isUsedForRuleProperty)
        ) {
            continue;
        }
        $attributes[$attribute->getAttributeCode()] = $attribute->getFrontendLabel();
    }

    $this->_addSpecialAttributes($attributes);

    asort($attributes);
    $this->setAttributeOption($attributes);

    return $this;
}

We check to see if the attribute is allowed to be used for rule conditions.

public function isAllowedForRuleCondition()
{
    $allowedInputTypes = array('text', 'multiselect', 'textarea', 'date', 'datetime', 'select', 'boolean', 'price');
    return $this->getIsVisible() && in_array($this->getFrontendInput(), $allowedInputTypes);
}

So to summarise we’re checking a few things.

  • If the attribute’s input type is of type text, multiselect, textarea, date, datetime or select
  • Whether the attribute is set to visible
  • Whether the attribute’s is_used_for_promo_rules property is set to true

The actions of the rule is actually what is calculated in the calcPriceRule() method which is what Magento uses to return the promotion price.

This is found within the Mage_CatalogRule_Helper_Data class.

public function calcPriceRule($actionOperator, $ruleAmount, $price)
{
    $priceRule = 0;
    switch ($actionOperator) {
        case 'to_fixed':
            $priceRule = min($ruleAmount, $price);
            break;
        case 'to_percent':
            $priceRule = $price * $ruleAmount / 100;
            break;
        case 'by_fixed':
            $priceRule = max(0, $price - $ruleAmount);
            break;
        case 'by_percent':
            $priceRule = $price * (1 - $ruleAmount / 100);
            break;
    }
    return $priceRule;
}

As you’ll note, the $actionOperator variable is populated with what action was selected when creating the catalog price rule.

Magento keeps the price rules up to date via catalogrule_apply_all cron job. This is configured by default by Magento.

By default, the cron job is set to run once per day at 1am as seen below.

<?xml version="1.0"?>
<config>
    ....
    <crontab>
        <jobs>
            <catalogrule_apply_all>
                <schedule>
                    <cron_expr>0 1 * * *</cron_expr>
                </schedule>
                <run>
                    <model>catalogrule/observer::dailyCatalogUpdate</model>
                </run>
            </catalogrule_apply_all>
        </jobs>
        ....
    </crontab>
</config>

The applyDailyCatalog() method is then executed at this time.

public function dailyCatalogUpdate($observer)
{
    /** @var $model Mage_CatalogRule_Model_Rule */
    $model = Mage::getSingleton('catalogrule/rule');
    $model->applyAll();

    return $this;
}
public function applyAll()
{
    $this->getResourceCollection()->walk(array($this->_getResource(), 'updateRuleProductData'));
    $this->_getResource()->applyAllRules();
    $this->_invalidateCache();
    $indexProcess = Mage::getSingleton('index/indexer')->getProcessByCode('catalog_product_price');
    if ($indexProcess) {
        $indexProcess->reindexAll();
    }
}

When product prices are calculated on the frontend of the website, there are three events that are dispatched that intercept and alter the prices should a catalog rule apply to the product.

1. The catalog_product_get_final_price event is dispatched within the getFinalPrice() method.

public function getFinalPrice($qty = null, $product)
{
    if (is_null($qty) && !is_null($product->getCalculatedFinalPrice())) {
        return $product->getCalculatedFinalPrice();
    }

    $finalPrice = $this->getBasePrice($product, $qty);
    $product->setFinalPrice($finalPrice);

    Mage::dispatchEvent('catalog_product_get_final_price', array('product' => $product, 'qty' => $qty));

    $finalPrice = $product->getData('final_price');
    $finalPrice = $this->_applyOptionsPrice($product, $qty, $finalPrice);
    $finalPrice = max(0, $finalPrice);
    $product->setFinalPrice($finalPrice);

     return $finalPrice;
}

There are actually two observers listening to this event. The observers execute the processFrontFinalPrice() and processAdminFinalPrice() methods respectively.

public function processFrontFinalPrice($observer)
{
    $product    = $observer->getEvent()->getProduct();
    $pId        = $product->getId();
    $storeId    = $product->getStoreId();

    if ($observer->hasDate()) {
        $date = $observer->getEvent()->getDate();
    } else {
        $date = Mage::app()->getLocale()->storeTimeStamp($storeId);
    }

    if ($observer->hasWebsiteId()) {
        $wId = $observer->getEvent()->getWebsiteId();
    } else {
        $wId = Mage::app()->getStore($storeId)->getWebsiteId();
    }

    if ($observer->hasCustomerGroupId()) {
        $gId = $observer->getEvent()->getCustomerGroupId();
    } elseif ($product->hasCustomerGroupId()) {
        $gId = $product->getCustomerGroupId();
    } else {
        $gId = Mage::getSingleton('customer/session')->getCustomerGroupId();
    }

    $key = $this->_getRulePricesKey(array($date, $wId, $gId, $pId));
    if (!isset($this->_rulePrices[$key])) {
        $rulePrice = Mage::getResourceModel('catalogrule/rule')
            ->getRulePrice($date, $wId, $gId, $pId);
        $this->_rulePrices[$key] = $rulePrice;
    }
    if ($this->_rulePrices[$key]!==false) {
        $finalPrice = min($product->getData('final_price'), $this->_rulePrices[$key]);
        $product->setFinalPrice($finalPrice);
    }
    return $this;
}
public function processAdminFinalPrice($observer)
{
    $product = $observer->getEvent()->getProduct();
    $storeId = $product->getStoreId();
    $date = Mage::app()->getLocale()->storeDate($storeId);
    $key = false;

    if ($ruleData = Mage::registry('rule_data')) {
        $wId = $ruleData->getWebsiteId();
        $gId = $ruleData->getCustomerGroupId();
        $pId = $product->getId();

        $key = $this->_getRulePricesKey(array($date, $wId, $gId, $pId));
    }
    elseif (!is_null($storeId) && !is_null($product->getCustomerGroupId())) {
        $wId = Mage::app()->getStore($storeId)->getWebsiteId();
        $gId = $product->getCustomerGroupId();
        $pId = $product->getId();
        $key = $this->_getRulePricesKey(array($date, $wId, $gId, $pId));
    }

    if ($key) {
        if (!isset($this->_rulePrices[$key])) {
            $rulePrice = Mage::getResourceModel('catalogrule/rule')
                ->getRulePrice($date, $wId, $gId, $pId);
            $this->_rulePrices[$key] = $rulePrice;
        }
        if ($this->_rulePrices[$key]!==false) {
            $finalPrice = min($product->getData('final_price'), $this->_rulePrices[$key]);
            $product->setFinalPrice($finalPrice);
        }
    }

    return $this;
}

2. The catalog_product_type_configurable_price event gets dispatched when the configurable product’s price is calculated. This happens in a couple of places: the getTotalConfigurableItemsPrice() method and the getJsonConfig() method.

public function getTotalConfigurableItemsPrice($product, $finalPrice)
{
    $price = 0.0;

    $product->getTypeInstance(true)
        ->setStoreFilter($product->getStore(), $product);
    $attributes = $product->getTypeInstance(true)
        ->getConfigurableAttributes($product);

    $selectedAttributes = array();
    if ($product->getCustomOption('attributes')) {
        $selectedAttributes = unserialize($product->getCustomOption('attributes')->getValue());
    }

    foreach ($attributes as $attribute) {
        $attributeId = $attribute->getProductAttribute()->getId();
        $value = $this->_getValueByIndex(
            $attribute->getPrices() ? $attribute->getPrices() : array(),
            isset($selectedAttributes[$attributeId]) ? $selectedAttributes[$attributeId] : null
        );
        $product->setParentId(true);
        if ($value) {
            if ($value['pricing_value'] != 0) {
                $product->setConfigurablePrice($this->_calcSelectionPrice($value, $finalPrice));
                Mage::dispatchEvent(
                    'catalog_product_type_configurable_price',
                    array('product' => $product)
                );
                $price += $product->getConfigurablePrice();
            }
        }
    }
    return $price;
}
public function getJsonConfig() 
{
    ....
    if(!$this->_validateAttributeValue($attributeId, $value, $options)) {
        continue;
    }
    $currentProduct->setConfigurablePrice(
        $this->_preparePrice($value['pricing_value'], $value['is_percent'])
    );
    $currentProduct->setParentId(true);
    Mage::dispatchEvent(
       'catalog_product_type_configurable_price',
        array('product' => $currentProduct)
    );
    ....
}

The observer method that gets executed is the catalogProductTypeConfigurablePrice() method.

public function catalogProductTypeConfigurablePrice(Varien_Event_Observer $observer)
{
    $product = $observer->getEvent()->getProduct();
    if ($product instanceof Mage_Catalog_Model_Product
        && $product->getConfigurablePrice() !== null
    ) {
        $configurablePrice = $product->getConfigurablePrice();
        $productPriceRule = Mage::getModel('catalogrule/rule')->calcProductPriceRule($product, $configurablePrice);
        if ($productPriceRule !== null) {
            $product->setConfigurablePrice($productPriceRule);
        }
    }

    return $this;
}

3. The prepare_catalog_product_collection_prices event gets dispatched when obtaining the items collection from the quote model. It is also used in bundle products.

protected function _afterLoad()
{
    parent::_afterLoad();

    /**
     * Assign parent items
     */
    foreach ($this as $item) {
        if ($item->getParentItemId()) {
            $item->setParentItem($this->getItemById($item->getParentItemId()));
        }
        if ($this->_quote) {
            $item->setQuote($this->_quote);
        }
    }

    /**
     * Assign options and products
     */
    $this->_assignOptions();
    $this->_assignProducts();
    $this->resetItemsDataChanged();

    return $this;
}
protected function _assignProducts() {
    ....
    $productCollection = Mage::getModel('catalog/product')->getCollection()
        ->setStoreId($this->getStoreId())
        ->addIdFilter($this->_productIds)
        ->addAttributeToSelect(Mage::getSingleton('sales/quote_config')->getProductAttributes())
        ->addOptionsToResult()
        ->addStoreFilter()
        ->addUrlRewrite()
        ->addTierPriceData();

    Mage::dispatchEvent('prepare_catalog_product_collection_prices', array(
        'collection'            => $productCollection,
        'store_id'              => $this->getStoreId(),
    ));
    ....
}
public function getTotalBundleItemsPrice($product, $qty = null)
{
    $price = 0.0;
    if ($product->hasCustomOptions()) {
        $customOption = $product->getCustomOption('bundle_selection_ids');
        if ($customOption) {
            $selectionIds = unserialize($customOption->getValue());
            $selections = $product->getTypeInstance(true)->getSelectionsByIds($selectionIds, $product);
            $selections->addTierPriceData();
            Mage::dispatchEvent('prepare_catalog_product_collection_prices', array(
                'collection' => $selections,
                'store_id' => $product->getStoreId(),
            ));
            foreach ($selections->getItems() as $selection) {
                if ($selection->isSalable()) {
                    $selectionQty = $product->getCustomOption('selection_qty_' . $selection->getSelectionId());
                    if ($selectionQty) {
                        $price += $this->getSelectionFinalTotalPrice($product, $selection, $qty,
                            $selectionQty->getValue());
                    }
                }
            }
        }
    }
    return $price;
}

The observer method that gets executed is the prepareCatalogProductCollectionPrices() method.

public function prepareCatalogProductCollectionPrices(Varien_Event_Observer $observer)
{
    /* @var $collection Mage_Catalog_Model_Resource_Eav_Mysql4_Product_Collection */
    $collection = $observer->getEvent()->getCollection();
    $store      = Mage::app()->getStore($observer->getEvent()->getStoreId());
    $websiteId  = $store->getWebsiteId();
    if ($observer->getEvent()->hasCustomerGroupId()) {
        $groupId = $observer->getEvent()->getCustomerGroupId();
    } else {
        /* @var $session Mage_Customer_Model_Session */
        $session = Mage::getSingleton('customer/session');
        if ($session->isLoggedIn()) {
            $groupId = Mage::getSingleton('customer/session')->getCustomerGroupId();
        } else {
            $groupId = Mage_Customer_Model_Group::NOT_LOGGED_IN_ID;
        }
    }
    if ($observer->getEvent()->hasDate()) {
        $date = $observer->getEvent()->getDate();
    } else {
        $date = Mage::app()->getLocale()->storeTimeStamp($store);
    }

    $productIds = array();
    /* @var $product Mage_Core_Model_Product */
    foreach ($collection as $product) {
        $key = $this->_getRulePricesKey(array($date, $websiteId, $groupId, $product->getId()));
        if (!isset($this->_rulePrices[$key])) {
            $productIds[] = $product->getId();
        }
    }

    if ($productIds) {
        $rulePrices = Mage::getResourceModel('catalogrule/rule')
            ->getRulePrices($date, $websiteId, $groupId, $productIds);
        foreach ($productIds as $productId) {
            $key = $this->_getRulePricesKey(array($date, $websiteId, $groupId, $productId));
            $this->_rulePrices[$key] = isset($rulePrices[$productId]) ? $rulePrices[$productId] : false;
        }
    }

    return $this;
}

Note: This article is based on Magento Community/Open Source version 1.9.