Magento Price Generation

When viewing a product page in Magento, its price is rendered by a method in its view.phtml template.

// app/design/frontend/[your_package]/[your_theme]/template/catalog/product/view.phtml
$this->getPriceHtml();

The getPriceHtml() method belongs in the Mage_Catalog_Block_Product_Abstract class and contains the following.

public function getPriceHtml($product, $displayMinimalPrice = false, $idSuffix = '')
{
    $type_id = $product->getTypeId();
    if (Mage::helper('catalog')->canApplyMsrp($product)) {
        $realPriceHtml = $this->_preparePriceRenderer($type_id)
            ->setProduct($product)
            ->setDisplayMinimalPrice($displayMinimalPrice)
            ->setIdSuffix($idSuffix)
            ->toHtml();
        $product->setAddToCartUrl($this->getAddToCartUrl($product));
        $product->setRealPriceHtml($realPriceHtml);
        $type_id = $this->_mapRenderer;
    }

    return $this->_preparePriceRenderer($type_id)
        ->setProduct($product)
        ->setDisplayMinimalPrice($displayMinimalPrice)
        ->setIdSuffix($idSuffix)
        ->toHtml();
}

The _preparePriceRenderer() method returns us the product block type and the block template.

public function _preparePriceRenderer($productType)
{
    return $this->_getPriceBlock($productType)
        ->setTemplate($this->_getPriceBlockTemplate($productType))
        ->setUseLinkForAsLowAs($this->_useLinkForAsLowAs);
}

The _getPriceBlock() method is as follows.

protected function _getPriceBlock($productTypeId)
{
    if (!isset($this->_priceBlock[$productTypeId])) {
        $block = $this->_block;
        if (isset($this->_priceBlockTypes[$productTypeId])) {
            if ($this->_priceBlockTypes[$productTypeId]['block'] != '') {
                $block = $this->_priceBlockTypes[$productTypeId]['block'];
            }
        }
        $this->_priceBlock[$productTypeId] = $this->getLayout()->createBlock($block);
    }
    return $this->_priceBlock[$productTypeId];
}

And the _getPriceBlockTemplate() method can be seen below.

protected function _getPriceBlockTemplate($productTypeId)
{
    if (isset($this->_priceBlockTypes[$productTypeId])) {
        if ($this->_priceBlockTypes[$productTypeId]['template'] != '') {
            return $this->_priceBlockTypes[$productTypeId]['template'];
        }
    }
    return $this->_priceBlockDefaultTemplate;
}

If a specified block and template aren’t set, the default block and template are used that are set as properties at the top of the abstract class.

protected $_block = 'catalog/product_price';
protected $_priceBlockDefaultTemplate = 'catalog/product/price.phtml';

For a simple product, we would use this block and template file. However for some product types like a bundle product, we would use the block and template defined in the bundle.xml layout file.

<?xml version="1.0"?>
<layout>
    ....
    <PRODUCT_TYPE_bundle translate="label" module="bundle">
        ...
        <reference name="product.info">
            <action method="addPriceBlockType"><type>bundle</type><block>bundle/catalog_product_price</block><template>bundle/catalog/product/price.phtml</template></action>
            ....
        </reference>
        ....
    </PRODUCT_TYPE_bundle>
</layout>

So let’s assume that we’re using the default price template. When viewing this .phtml file, there is a lot of code that checks tax rules, group prices and a lot more to finally render the correct price.

An interesting line is the one highlighted below.

$_convertedFinalPrice = $_store->roundPrice($_store->convertPrice($_product->getFinalPrice()));

The getFinalPrice() method will check to see if the final price has been stored in the $this->_data property via the _getData() method. If it hasn’t, the price model is obtained and then we get the final price.

public function getFinalPrice($qty=null)
{
    $price = $this->_getData('final_price');
    if ($price !== null) {
        return $price;
    }
    return $this->getPriceModel()->getFinalPrice($qty, $this);
}
public function getPriceModel()
{
    return Mage::getSingleton('catalog/product_type')->priceFactory($this->getTypeId());
}
public static function priceFactory($productType)
{
    if (isset(self::$_priceModels[$productType])) {
        return self::$_priceModels[$productType];
    }

    $types = self::getTypes();

    if (!empty($types[$productType]['price_model'])) {
        $priceModelName = $types[$productType]['price_model'];
    } else {
        $priceModelName = self::DEFAULT_PRICE_MODEL;
    }

    self::$_priceModels[$productType] = Mage::getModel($priceModelName);
    return self::$_priceModels[$productType];
}

The priceFactory() method will check to see if a price model has been configured for the product type. If it hasn’t, we simply use the default price model set as a constant.

const DEFAULT_PRICE_MODEL   = 'catalog/product_type_price';

The price models for the majority of the product types are defined within the config.xml of the Mage_Catalog module.

<?xml version="1.0"?>
<config>
    <global>
        ....
        <catalog>
            <product>
                <type>
                    <simple translate="label" module="catalog">
                        <label>Simple Product</label>
                        <model>catalog/product_type_simple</model>
                        <composite>0</composite>
                        <index_priority>10</index_priority>
                    </simple>
                    <grouped translate="label" module="catalog">
                        <label>Grouped Product</label>
                        <model>catalog/product_type_grouped</model>
                        <price_model>catalog/product_type_grouped_price</price_model>
                        <composite>1</composite>
                        <allow_product_types>
                            <simple/>
                            <virtual/>
                        </allow_product_types>
                        <index_priority>50</index_priority>
                        <price_indexer>catalog/product_indexer_price_grouped</price_indexer>
                    </grouped>
                    <configurable translate="label" module="catalog">
                        <label>Configurable Product</label>
                        <model>catalog/product_type_configurable</model>
                        <price_model>catalog/product_type_configurable_price</price_model>
                        <composite>1</composite>
                        <allow_product_types>
                            <simple/>
                            <virtual/>
                        </allow_product_types>
                        <index_priority>30</index_priority>
                        <price_indexer>catalog/product_indexer_price_configurable</price_indexer>
                    </configurable>
                    <virtual translate="label" module="catalog">
                        <label>Virtual Product</label>
                        <model>catalog/product_type_virtual</model>
                        <composite>0</composite>
                        <index_priority>20</index_priority>
                    </virtual>
                </type>
            </product>
        </catalog>
        ....
    </global>
</config>

Note that not all of the product types are here. Downloadable and bundle products have their own configuration in their Mage_Downloadable and Mage_Bundle modules respectively.

Moving onto getFinalPrice().

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;
}

The basePrice() method is interesting as it returns the lowest price out of the price, group price, tier price and special price.


The <code>getFinalPrice()</code> method also dispatches a <code>catalog_product_get_final_price</code> event that checks if any catalog price rules are applied to the product.

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

To see more of catalog price rules, it is worth reading the Magento Catalog Price Rules article.

We could actually modify the final price using this event by setting it to something custom, like using the following lines.

$product->setData('final_price', $customPrice);

or

$product->setFinalPrice($customPrice);

Each time we are dealing with product collections and handling several products at a time, we don’t always query the price data each time you get the collection for performance reasons.

Therefore the product price is only read from the index tables.

If we look in the list.phtml file, which is the default template that loads the category page, there is a line that gets us the loaded product collection.

$_productCollection=$this->getLoadedProductCollection();

If we print out the SQL query of the loaded collection by adding the following line underneath.

echo $_productCollection->getSelect();

We get this:

SELECT `e`.*, `cat_index`.`position` AS `cat_index_position`, `price_index`.`price`, `price_index`.`tax_class_id`, `price_index`.`final_price`, IF(price_index.tier_price IS NOT NULL, LEAST(price_index.min_price, price_index.tier_price), price_index.min_price) AS `minimal_price`, `price_index`.`min_price`, `price_index`.`max_price`, `price_index`.`tier_price` FROM `catalog_product_entity` AS `e` INNER JOIN `catalog_category_product_index` AS `cat_index` ON cat_index.product_id=e.entity_id AND cat_index.store_id=1 AND cat_index.visibility IN(2, 4) AND cat_index.category_id = '4' AND cat_index.is_parent=1 INNER JOIN `catalog_product_index_price` AS `price_index` ON price_index.entity_id = e.entity_id AND price_index.website_id = '1' AND price_index.customer_group_id = 0 ORDER BY `cat_index`.`position` ASC LIMIT 12

We can see that the catalog_product_index_price table is being used in the query and not the catalog_product_entity_decimal table where the price attribute values are stored.

This happens deep within the getLoadedProductCollection() method, so let’s follow it carefully.

Firstly, the getLoadedProductCollection() method delegates to the _getProductCollection() method.

public function getLoadedProductCollection()
{
    return $this->_getProductCollection();
}
protected function _getProductCollection()
{
    if (is_null($this->_productCollection)) {
        $layer = $this->getLayer();
        /* @var $layer Mage_Catalog_Model_Layer */
        if ($this->getShowRootCategory()) {
            $this->setCategoryId(Mage::app()->getStore()->getRootCategoryId());
        }

        // if this is a product view page
        if (Mage::registry('product')) {
            // get collection of categories this product is associated with
            $categories = Mage::registry('product')->getCategoryCollection()
                ->setPage(1, 1)
                ->load();
            // if the product is associated with any category
            if ($categories->count()) {
                // show products from this category
                $this->setCategoryId(current($categories->getIterator()));
            }
        }

        $origCategory = null;
        if ($this->getCategoryId()) {
            $category = Mage::getModel('catalog/category')->load($this->getCategoryId());
            if ($category->getId()) {
                $origCategory = $layer->getCurrentCategory();
                $layer->setCurrentCategory($category);
                $this->addModelTags($category);
            }
        }
        $this->_productCollection = $layer->getProductCollection();

        $this->prepareSortableFieldsByCategory($layer->getCurrentCategory());

        if ($origCategory) {
            $layer->setCurrentCategory($origCategory);
        }
     }

    return $this->_productCollection;
}

Eventually, the getProductCollection() method is called within the Mage_Catalog_Model_Layer class.

public function getProductCollection()
{
    if (isset($this->_productCollections[$this->getCurrentCategory()->getId()])) {
        $collection = $this->_productCollections[$this->getCurrentCategory()->getId()];
    } else {
        $collection = $this->getCurrentCategory()->getProductCollection();
        $this->prepareProductCollection($collection);
        $this->_productCollections[$this->getCurrentCategory()->getId()] = $collection;
    }

    return $collection;
}

We get the product collection by filtering by the store and the current category ID, and then head to the prepareProductCollection() method.

public function prepareProductCollection($collection)
{
    $collection
        ->addAttributeToSelect(Mage::getSingleton('catalog/config')->getProductAttributes())
        ->addMinimalPrice()
        ->addFinalPrice()
        ->addTaxPercents()
        ->addUrlRewrite($this->getCurrentCategory()->getId());

    Mage::getSingleton('catalog/product_status')->addVisibleFilterToCollection($collection);
    Mage::getSingleton('catalog/product_visibility')->addVisibleInCatalogFilterToCollection($collection);

    return $this;
}

So here we can there are minimal and final prices added here. Both of these methods delegate to the addPriceData() as seen below.

public function addFinalPrice()
{
    return $this->addPriceData();
}

Interesting at the top of this class we can see this property array that maps price information.

protected $_map = array('fields' => array(
    'price'         => 'price_index.price',
    'final_price'   => 'price_index.final_price',
    'min_price'     => 'price_index.min_price',
    'max_price'     => 'price_index.max_price',
    'tier_price'    => 'price_index.tier_price',
    'special_price' => 'price_index.special_price',
));

Back to the addPriceData() method.

public function addPriceData($customerGroupId = null, $websiteId = null)
{
    $this->_productLimitationFilters['use_price_index'] = true;

    if (!isset($this->_productLimitationFilters['customer_group_id']) && is_null($customerGroupId)) {
        $customerGroupId = Mage::getSingleton('customer/session')->getCustomerGroupId();
    }
    if (!isset($this->_productLimitationFilters['website_id']) && is_null($websiteId)) {
        $websiteId       = Mage::app()->getStore($this->getStoreId())->getWebsiteId();
    }

    if (!is_null($customerGroupId)) {
        $this->_productLimitationFilters['customer_group_id'] = $customerGroupId;
    }
    if (!is_null($websiteId)) {
        $this->_productLimitationFilters['website_id'] = $websiteId;
    }

    $this->_applyProductLimitations();

    return $this;
}

Aha! So we manually set the $this->_productLimitationFilters['use_price_index'] property to true.

protected function _applyProductLimitations()
{
    Mage::dispatchEvent('catalog_product_collection_apply_limitations_before', array(
        'collection'  => $this,
        'category_id' => isset($this->_productLimitationFilters['category_id'])
            ? $this->_productLimitationFilters['category_id']
            : null,
    ));
    $this->_prepareProductLimitationFilters();
    $this->_productLimitationJoinWebsite();
    $this->_productLimitationJoinPrice();
    $filters = $this->_productLimitationFilters;

    if (!isset($filters['category_id']) && !isset($filters['visibility'])) {
        return $this;
    }

    $conditions = array(
        'cat_index.product_id=e.entity_id',
        $this->getConnection()->quoteInto('cat_index.store_id=?', $filters['store_id'])
    );
    if (isset($filters['visibility']) && !isset($filters['store_table'])) {
        $conditions[] = $this->getConnection()
            ->quoteInto('cat_index.visibility IN(?)', $filters['visibility']);
    }

    if (!$this->getFlag('disable_root_category_filter')) {
        $conditions[] = $this->getConnection()->quoteInto('cat_index.category_id = ?', $filters['category_id']);
    }

    if (isset($filters['category_is_anchor'])) {
        $conditions[] = $this->getConnection()
            ->quoteInto('cat_index.is_parent=?', $filters['category_is_anchor']);
    }

    $joinCond = join(' AND ', $conditions);
    $fromPart = $this->getSelect()->getPart(Zend_Db_Select::FROM);
    if (isset($fromPart['cat_index'])) {
        $fromPart['cat_index']['joinCondition'] = $joinCond;
        $this->getSelect()->setPart(Zend_Db_Select::FROM, $fromPart);
    }
    else {
        $this->getSelect()->join(
            array('cat_index' => $this->getTable('catalog/category_product_index')),
            $joinCond,
            array('cat_index_position' => 'position')
        );
    }

    $this->_productLimitationJoinStore();

    Mage::dispatchEvent('catalog_product_collection_apply_limitations_after', array(
        'collection' => $this
    ));

    return $this;
}

At the top of the method, we can see a call to the _productLimitationJoinPrice() method.

protected function _productLimitationJoinPrice()
{
    return $this->_productLimitationPrice();
}
protected function _productLimitationPrice($joinLeft = false)
{
    $filters = $this->_productLimitationFilters;
    if (empty($filters['use_price_index'])) {
        return $this;
    }

    $helper     = Mage::getResourceHelper('core');
    $connection = $this->getConnection();
    $select     = $this->getSelect();
    $joinCond   = join(' AND ', array(
        'price_index.entity_id = e.entity_id',
        $connection->quoteInto('price_index.website_id = ?', $filters['website_id']),
        $connection->quoteInto('price_index.customer_group_id = ?', $filters['customer_group_id'])
    ));

    $fromPart = $select->getPart(Zend_Db_Select::FROM);
    if (!isset($fromPart['price_index'])) {
        $least       = $connection->getLeastSql(array('price_index.min_price', 'price_index.tier_price'));
        $minimalExpr = $connection->getCheckSql('price_index.tier_price IS NOT NULL',
            $least, 'price_index.min_price');
        $colls       = array('price', 'tax_class_id', 'final_price',
            'minimal_price' => $minimalExpr , 'min_price', 'max_price', 'tier_price');
        $tableName = array('price_index' => $this->getTable('catalog/product_index_price'));
        if ($joinLeft) {
            $select->joinLeft($tableName, $joinCond, $colls);
        } else {
            $select->join($tableName, $joinCond, $colls);
        }
        // Set additional field filters
        foreach ($this->_priceDataFieldFilters as $filterData) {
            $select->where(call_user_func_array('sprintf', $filterData));
        }
    } else {
        $fromPart['price_index']['joinCondition'] = $joinCond;
        $select->setPart(Zend_Db_Select::FROM, $fromPart);
    }
    //Clean duplicated fields
    $helper->prepareColumnsList($select);

    return $this;
}

And there we have it. Evidence of the price index data being used for a performance benefit.

The indexer that is responsible for the work is the Mage_Catalog_Model_Product_Indexer_Price class.

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