Catalog price rules not applied when creating order from Magento admin

Magento BugEven though I'm in love with the platform, as it turns out, I tend to blog about Magento bugs quite often. I like to believe this is a good thing, because besides helping other Magento developers, finding and fixing bugs makes our favorite ecommerce platform better. It has nothing to do with frustration couple of days debugging session can cause, I ensure you. In this article I'm about to explain and provide workaround for bug affecting all Magento versions (including latest Magento CE 1.9.0.1 at the time of writing), one that's causing catalog price rules not being applied to final price and totals not getting calculated correctly when creating orders from Magento admin, all this under specific and hard to reproduce circumstances. Honestly, this is probably the ugliest bug I've ever seen in my coding career, largely due to fact that client's bug report is usually that catalog price rules prices revert when creating orders from Magento admin, but only sometimes and without any pattern.

Unlucky amongst you probably know how easy it is to get caught inside infinite loop caused by 'trigger_recollect' functionality of Magento quote, while coding quote address total models. It's simple as loading Mage_Sales_Model_Quote inside quote address total model, something that's problematic due to Mage_Sales_Model_Quote::collectTotals() being fired on Mage_Sales_Model_Quote::_afterLoad() when trigger_recollect is set, causing infinite loop. Unfortunately for my team, one of Magento core team developers fell into similar trap while coding Magento admin create order functionality. The root cause of difficulties described here is that collecting quote totals in Magento admin requires Varien_Object containing store ID, website ID and customer group ID of customer for whom we are creating order, placed in registry and under rule_data key before calling Mage_Sales_Model_Quote::collectTotals(). This is due to way catalog price rules are applied to final price when creating orders trough Magento admin, more precisely this work is done by Mage_CatalogRule_Model_Observer::processAdminFinalPrice() triggered on catalog_product_get_final_price event, to extract data from catalogrule/rule model using data under rule_data registry key. A little code to help visualize things:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<?php
 
/**
 * Catalog Price rules observer model
 */
class Mage_CatalogRule_Model_Observer
{
    /* Other methods */
 
    /**
     * Apply catalog price rules to product in admin
     *
     * @param   Varien_Event_Observer $observer
     *
     * @return  Mage_CatalogRule_Model_Observer
     */
    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 = "$date|$wId|$gId|$pId";
        }
        elseif (!is_null($product->getWebsiteId()) && !is_null($product->getCustomerGroupId())) {
            $wId = $product->getWebsiteId();
            $gId = $product->getCustomerGroupId();
            $pId = $product->getId();
            $key = "$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;
    }
 
    /* Other methods */
}

It's visible from following code listing that Mage_Adminhtml_Sales_Order_CreateController controller is aware of the fact that data required for applying catalog price rules must be placed in registry, before attempting to save quote (action that includes collecting totals).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?php
/**
 * Adminhtml sales orders creation process controller
 *
 */
class Mage_Adminhtml_Sales_Order_CreateController extends Mage_Adminhtml_Controller_Action
{
    /* Other methods */
 
    /**
     * Process request data with additional logic for saving quote and creating order
     *
     * @param string $action
     * @return Mage_Adminhtml_Sales_Order_CreateController
     */
    protected function _processActionData($action = null)
    {
 
        /* Other code */
 
        /**
         * Initialize catalog rule data
         */
        $this->_getOrderCreateModel()->initRuleData();
 
        /* Other code */
 
        $this->_getOrderCreateModel()
            ->saveQuote();
 
        /* Other code */
 
        return $this;
    }
 
    /* Other methods */
}

For the sake of completeness, here's an excerpt of relevant code from Mage_Adminhtml_Model_Sales_Order_Create model used by Mage_Adminhtml_Sales_Order_CreateController.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<?php
/**
 * Order create model
 *
 */
class Mage_Adminhtml_Model_Sales_Order_Create extends Varien_Object implements Mage_Checkout_Model_Cart_Interface
{
    /* Other methods */
 
    /**
     * Initialize data for price rules
     *
     * @return Mage_Adminhtml_Model_Sales_Order_Create
     */
    public function initRuleData()
    {
        Mage::register('rule_data', new Varien_Object(array(
            'store_id'  => $this->_session->getStore()->getId(),
            'website_id'  => $this->_session->getStore()->getWebsiteId(),
            'customer_group_id' => $this->getCustomerGroupId(),
        )));
        return $this;
    }
 
    /**
     * Quote saving
     *
     * @return Mage_Adminhtml_Model_Sales_Order_Create
     */
    public function saveQuote()
    {
        if (!$this->getQuote()->getId()) {
            return $this;
        }
 
        if ($this->_needCollect) {
            $this->getQuote()->collectTotals();
        }
 
        $this->getQuote()->save();
        return $this;
    }
 
    /* Other methods */
}

One thing Magento admin create order code did not take into consideration is trigger recollect functionality of Mage_Sales_Model_Quote, something that's briefly described at the beginning of this article, but more precisely:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
/**
 * Quote model
 *
 */
class Mage_Sales_Model_Quote extends Mage_Core_Model_Abstract
{
    /* Other methods */
 
    /**
     * Trigger collect totals after loading, if required
     *
     * @return Mage_Sales_Model_Quote
     */
    protected function _afterLoad()
    {
        // collect totals and save me, if required
        if (1 == $this->getData('trigger_recollect')) {
            $this->collectTotals()->save();
        }
        return parent::_afterLoad();
    }
 
    /* Other methods */
}

Put into words, Magento includes functionality for recollecting quote totals on specific price calculation changing events like new catalog rules getting applied or catalog product being updated. This is to prevent situations where customer bought something at full price just because he added product to cart before new catalog price rules have been applied, where natural behaviour is to use final prices at the time when checkout is completed. Using this mechanism, when it is required to recollect totals for all quotes containing product that had it's price calculation factors changed, Magento marks these quotes with trigger_recollect (DB field in sales_flat_quote table). So the next call to Mage_Sales_Model_Quote::load() on these quotes will trigger Mage_Sales_Model_Quote::collectTotals() on affected quotes.

Unfortunately, trigger recollect as it is by default breaks creating order from admin in situations where product being added to cart is under catalog price rules, and trigger_recollect has been set by one of the Mage_CatalogRule module observers on quote being assembled in Magento admin. Reason for broken functionality is that when trigger_recollect is set, Magento tries to recollect totals first time order is loaded from database to Mage_Adminhtml_Model_Session_Quote, something that happens on $this->getCustomerGroupId() while preparing data for setting rule_data to registry by Mage_Adminhtml_Model_Sales_Order_Create::initRuleData():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php
/**
 * Order create model
 *
 */
class Mage_Adminhtml_Model_Sales_Order_Create extends Varien_Object implements Mage_Checkout_Model_Cart_Interface
{
    /* Other methods */
 
    /**
     * Initialize data for price rules
     *
     * @return Mage_Adminhtml_Model_Sales_Order_Create
     */
    public function initRuleData()
    {
        Mage::register('rule_data', new Varien_Object(array(
            'store_id'  => $this->_session->getStore()->getId(),
            'website_id'  => $this->_session->getStore()->getWebsiteId(),
            'customer_group_id' => $this->getCustomerGroupId(),
        )));
        return $this;
    }
 
    /* Other methods */
}

But guess what, initialized rule_data is required to collect quote totals in the first place, leaving us with the chicken or the egg dilemma causing incorrect final price being used for calculating quote totals (one without catalog price rules applied).

You might ask how to handle this issue? Solution is to prevent recollect totals when in admin and when rule_data isn't set in registry, because without it it's not possible to correctly collect totals in Magento admin. To implement this fix cleanly we need something my colleague named pre-after load event, and this is something Magento models do not dispatch. Next best thing is to rewrite the Mage_Sales_Model_Quote and override it's _afterLoad() method with something like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
class  Whatever_Whatever_Model_Sales_Quote extends Mage_Sales_Model_Quote
{
    protected function _afterLoad()
    {
        $storeId = Mage::app()->getStore()->getId();
        $ruleData = Mage::registry('rule_data');
 
        if($storeId == Mage_Core_Model_App::ADMIN_STORE_ID && $ruleData == null) {
            $this->setData('trigger_recollect', 0)
                ->save();
        }
        parent::_afterLoad();
    }
}

With this code even when trigger_recollect is set, collect totals will not be started automatically when loading quote in admin, at least not without required data being set in registry.

Basically that's it. If it happens that store you're in charge for is affected with this issue, I hope this article will cut a few hours from your search. Also, I hope this issue will get fixed in Magento core soon so we could remove this ugly rewrite from Mage_Sales_Model_Quote model.

DevGenii

E-commerce is a breeze with Magento Certified Developer Plus & Zend Certified PHP Engineer nearby. Get in touch!

One thought on “Catalog price rules not applied when creating order from Magento admin

Leave a Reply

Your email address will not be published. Required fields are marked *