Magento Checkout Flow

The Magento checkout flow involves templates, block classes and JavaScript to work together in order for the customer to successfully place an order. This article will explain what files are involved and how the checkout works.

To start with, it is worth looking at the checkout.xml layout file within the RWD theme and specifically looking for the checkout_onepage_index layout handle.

This layout handle defines the blocks and templates that are used on the checkout page.

<?xml version="1.0"?>
<layout version="0.1.0">
    ....
    <checkout_onepage_index translate="label">
        <label>One Page Checkout</label>
        <!-- Mage_Checkout -->
        <remove name="left"/>

        <reference name="root">
            <action method="setTemplate"><template>page/2columns-right.phtml</template></action>
        </reference>
        <reference name="right">
            <action method="unsetChildren"></action>
            <block type="page/html_wrapper" name="checkout.progress.wrapper" translate="label">
                <label>Checkout Progress Wrapper</label>
                <action method="setElementId"><value>checkout-progress-wrapper</value></action>
                <block type="checkout/onepage_progress" name="checkout.progress" before="-" template="checkout/onepage/progress.phtml">
                    <block type="checkout/onepage_progress" name="billing.progress" template="checkout/onepage/progress/billing.phtml"></block>
                    <block type="checkout/onepage_progress" name="shipping.progress" template="checkout/onepage/progress/shipping.phtml"></block>
                    <block type="checkout/onepage_progress" name="shippingmethod.progress" template="checkout/onepage/progress/shipping_method.phtml"></block>
                    <block type="checkout/onepage_progress" name="payment.progress" template="checkout/onepage/progress/payment.phtml"></block>
                </block>
            </block>
        </reference>
        <reference name="content">
            <block type="checkout/onepage" name="checkout.onepage" template="checkout/onepage.phtml">
                <block type="checkout/onepage_login" name="checkout.onepage.login" as="login" template="checkout/onepage/login.phtml">
                    <block type="page/html_wrapper" name="checkout.onepage.login.before" as="login_before" translate="label">
                        <label>Login/Registration Before</label>
                        <action method="setMayBeInvisible"><value>1</value></action>
                    </block>
                </block>
                <block type="checkout/onepage_billing" name="checkout.onepage.billing" as="billing" template="checkout/onepage/billing.phtml"/>
                <block type="checkout/onepage_shipping" name="checkout.onepage.shipping" as="shipping" template="checkout/onepage/shipping.phtml"/>
                <block type="checkout/onepage_shipping_method" name="checkout.onepage.shipping_method" as="shipping_method" template="checkout/onepage/shipping_method.phtml">
                    <block type="checkout/onepage_shipping_method_available" name="checkout.onepage.shipping_method.available" as="available" template="checkout/onepage/shipping_method/available.phtml"/>
                    <block type="checkout/onepage_shipping_method_additional" name="checkout.onepage.shipping_method.additional" as="additional" template="checkout/onepage/shipping_method/additional.phtml"/>
                </block>
                <block type="checkout/onepage_payment" name="checkout.onepage.payment" as="payment" template="checkout/onepage/payment.phtml">
                    <block type="checkout/onepage_payment_methods" name="checkout.payment.methods" as="methods" template="checkout/onepage/payment/info.phtml">
                        <action method="setMethodFormTemplate"><method>purchaseorder</method><template>payment/form/purchaseorder.phtml</template></action>
                    </block>
                    <block type="core/template" name="checkout.onepage.payment.additional" as="additional" />
                    <block type="core/template" name="checkout.onepage.payment.methods_additional" as="methods_additional" />
                </block>
                <block type="checkout/onepage_review" name="checkout.onepage.review" as="review" template="checkout/onepage/review.phtml"/>
            </block>
        </reference>
    </checkout_onepage_index>
</layout>

The main points to take away from here can be seen below.

  • The checkout page by default is set to use the 2columns-right template
  • It has two main areas, a content area that contains the actual steps, and a right area that contains the progress steps.
  • The content area has a onepage.phtml wrapper template file, and the right area has a progress.phtml wrapper template file.

Looking at the layout configuration and pinpointing the onepage.phtml template file, it has a block type of checkout/onepage.

If you’re unfamiliar with Magento blocks, this means that the template file has a corresponding block class located in app/code/core/Mage/Checkout/Block/Onepage.php.

The Onepage.php file consists of the following code.

class Mage_Checkout_Block_Onepage extends Mage_Checkout_Block_Onepage_Abstract
{
    /**
     * Get 'one step checkout' step data
     *
     * @return array
     */
    public function getSteps()
    {
        $steps = array();
        $stepCodes = $this->_getStepCodes();

        if ($this->isCustomerLoggedIn()) {
            $stepCodes = array_diff($stepCodes, array('login'));
        }

        foreach ($stepCodes as $step) {
            $steps[$step] = $this->getCheckout()->getStepData($step);
        }

        return $steps;
    }

    /**
     * Get active step
     *
     * @return string
     */
    public function getActiveStep()
    {
        return $this->isCustomerLoggedIn() ? 'billing' : 'login';
    }
}

Taking a closer look at the _getSteps() method, we can see it makes a call to _getStepCodes() and returns an array of step codes.

<?php
class Mage_Checkout_Block_Onepage extends Mage_Checkout_Block_Onepage_Abstract {
    ....
    protected function _getStepCodes()
    {
        return array('login', 'billing', 'shipping', 'shipping_method', 'payment', 'review');
    }
    ....
}

Each checkout step will also have its own corresponding block class, such as the billing step has a block class of Mage_Checkout_Block_Onepage_Billing.

<block type="checkout/onepage_billing" name="checkout.onepage.billing" as="billing" template="checkout/onepage/billing.phtml"/>

When the checkout page is first loaded, within the indexAction() method, a check to see whether the onepage checkout is enabled and whether the customer’s quote can be obtained are made.

<?php
class Mage_Checkout_OnepageController.php extends Mage_Checkout_Controller_Action {
    ....
    public function indexAction() {
        if (!Mage::helper('checkout')->canOnepageCheckout()) {
            Mage::getSingleton('checkout/session')->addError($this->__('The onepage checkout is disabled.'));
            $this->_redirect('checkout/cart');
            return;
        }
        $quote = $this->getOnepage()->getQuote();
        if (!$quote->hasItems() || $quote->getHasError()) {
            $this->_redirect('checkout/cart');
            return;
        }
    }
    ....
}

When a customer progresses through the checkout steps, the checkout page does not reload. Because of this, we need to have a method that doesn’t just check if the checkout is enabled or disabled, or the quote still exists within the indexAction() method. Luckily, there is a expireAjax() method that is used at the top of each “Action” method when moving through the checkout.

This is present when a customer continues through the checkout steps, and each step calls their own action, e.g. completing the billing step will call the saveBillingAction() method.

public function saveBillingAction()
{
    if ($this->_expireAjax()) {
        return;
}

The _expireAjax() method consists of the following code.

protected function _expireAjax()
{
    if (!$this->getOnepage()->getQuote()->hasItems()
        || $this->getOnepage()->getQuote()->getHasError()
        || $this->getOnepage()->getQuote()->getIsMultiShipping()
    ) {
        $this->_ajaxRedirectResponse();
        return true;
    }
    $action = strtolower($this->getRequest()->getActionName());
    if (Mage::getSingleton('checkout/session')->getCartWasUpdated(true)
        && !in_array($action, array('index', 'progress'))
    ) {
        $this->_ajaxRedirectResponse();
        return true;
    }
    return false;
}

There are a few checks made in the code.

  • If the quote has no items.
  • If the quote has no errors.
  • If the quote is not marked as multishipping (in case the customer switches to Multishipping checkout).
  • If the shopping cart was not updated (e.g., items added or removed).

The JavaScript file responsible for guiding the customer through the checkout steps is the opcheckout.js file.

Within this file, the checkout step codes are also defined here.

var Checkout = Class.create();
Checkout.prototype = {
    initialize: function(accordion, urls){
        this.accordion = accordion;
        this.progressUrl = urls.progress;
        this.reviewUrl = urls.review;
        this.saveMethodUrl = urls.saveMethod;
        this.failureUrl = urls.failure;
        this.billingForm = false;
        this.shippingForm= false;
        this.syncBillingShipping = false;
        this.method = '';
        this.payment = '';
        this.loadWaiting = false;
        this.steps = ['login', 'billing', 'shipping', 'shipping_method', 'payment', 'review'];
        ....
    },
    ....
}

When the customer gets to the review step and finally places the order, a few things happen. Firstly, the save method of the Review class in opcheckout.js gets called.

var Review = Class.create();
Review.prototype = {
    ....
    save: function(){
        if (checkout.loadWaiting!=false) return;
        checkout.setLoadWaiting('review');
        var params = Form.serialize(payment.form);
        if (this.agreementsForm) {
            params += '&'+Form.serialize(this.agreementsForm);
        }
        params.save = true;
        var request = new Ajax.Request(
            this.saveUrl,
            {
                method:'post',
                parameters:params,
                onComplete: this.onComplete,
                onSuccess: this.onSave,
                onFailure: checkout.ajaxFailure.bind(checkout)
            }
        );
    },
    ....
}

A file submits an AJAX request using the url this.saveUrl, which is set at the top of the Review class.

We can see that the URL gets passed in as a parameter in the initialize method. This is seen within some inline JavaScript in the review template file.

<script type="text/javascript">
//<![CDATA[
    review = new Review('<?php echo $this->getUrl('checkout/onepage/saveOrder', array('form_key' => Mage::getSingleton('core/session')->getFormKey())) ?>', '<?php echo $this->getUrl('checkout/onepage/success') ?>', $('checkout-agreements'));
//]]>
</script>

This is the same in ever checkout step. The template files contain script tags that pass in the save URLs to the opcheckout.js file.

So we can see that the saveOrderAction() method gets called when the customer places an order.

public function saveOrderAction()
{
    if (!$this->_validateFormKey()) {
        $this->_redirect('*/*');
        return;
    }

    if ($this->_expireAjax()) {
        return;
    }

    $result = array();
    try {
        $requiredAgreements = Mage::helper('checkout')->getRequiredAgreementIds();
        if ($requiredAgreements) {
            $postedAgreements = array_keys($this->getRequest()->getPost('agreement', array()));
            $diff = array_diff($requiredAgreements, $postedAgreements);
            if ($diff) {
                $result['success'] = false;
                $result['error'] = true;
                $result['error_messages'] = $this->__('Please agree to all the terms and conditions before placing the order.');
                $this->getResponse()->setBody(Mage::helper('core')->jsonEncode($result));
                return;
            }
        }

        $data = $this->getRequest()->getPost('payment', array());
        if ($data) {
             $data['checks'] = Mage_Payment_Model_Method_Abstract::CHECK_USE_CHECKOUT
                | Mage_Payment_Model_Method_Abstract::CHECK_USE_FOR_COUNTRY
                | Mage_Payment_Model_Method_Abstract::CHECK_USE_FOR_CURRENCY
                | Mage_Payment_Model_Method_Abstract::CHECK_ORDER_TOTAL_MIN_MAX
                | Mage_Payment_Model_Method_Abstract::CHECK_ZERO_TOTAL;
            $this->getOnepage()->getQuote()->getPayment()->importData($data);
        }

        $this->getOnepage()->saveOrder();

        $redirectUrl = $this->getOnepage()->getCheckout()->getRedirectUrl();
        $result['success'] = true;
        $result['error']   = false;
    }
    ....
}

This calls the saveOrder() method in the Mage_Checkout_Model_Type_Onepage class.

public function saveOrder()
{
    $this->validate();
    $isNewCustomer = false;
    switch ($this->getCheckoutMethod()) {
        case self::METHOD_GUEST:
            $this->_prepareGuestQuote();
            break;
        case self::METHOD_REGISTER:
            $this->_prepareNewCustomerQuote();
            $isNewCustomer = true;
            break;
        default:
            $this->_prepareCustomerQuote();
            break;
        }

        $service = Mage::getModel('sales/service_quote', $this->getQuote());
        $service->submitAll();
        ....
}

The switch statements checks the checkout method used by the customer when going through the checkout, and then a call is made to the submitAll() method of the Mage_Sales_Model_Service_Quote class.

public function submitAll()
{
    // don't allow submitNominalItems() to inactivate quote
    $shouldInactivateQuoteOld = $this->_shouldInactivateQuote;
    $this->_shouldInactivateQuote = false;
    try {
        $this->submitNominalItems();
        $this->_shouldInactivateQuote = $shouldInactivateQuoteOld;
    } catch (Exception $e) {
        $this->_shouldInactivateQuote = $shouldInactivateQuoteOld;
        throw $e;
    }
    // no need to submit the order if there are no normal items remained
    if (!$this->_quote->getAllVisibleItems()) {
        $this->_inactivateQuote();
        return;
    }
    $this->submitOrder();
}

The submitAll() method delegates to the submitOrder() method. Quite a few thing happen within this method.

First of all, the order is validated by checking the shipping information. The customer and quote objects are added to the transaction model.

public function submitOrder() {
    $this->_deleteNominalItems();
    $this->_validate();
    $quote = $this->_quote;
    $isVirtual = $quote->isVirtual();

    $transaction = Mage::getModel('core/resource_transaction');
    if ($quote->getCustomerId()) {
        $transaction->addObject($quote->getCustomer());
    }
    $transaction->addObject($quote);

    $quote->reserveOrderId();
    ....
}

Note that also the Order ID gets reserved here.

We then have the area where quote data gets converted into order data using the Mage_Sales_Model_Convert_Quote model.

public function submitOrder() {
    ....
    if ($isVirtual) {
        $order = $this->_convertor->addressToOrder($quote->getBillingAddress());
    } else {
        $order = $this->_convertor->addressToOrder($quote->getShippingAddress());
    }
    $order->setBillingAddress($this->_convertor->addressToOrderAddress($quote->getBillingAddress()));
    if ($quote->getBillingAddress()->getCustomerAddress()) {
        $order->getBillingAddress()->setCustomerAddress($quote->getBillingAddress()->getCustomerAddress());
    }
    if (!$isVirtual) {
        $order->setShippingAddress($this->_convertor->addressToOrderAddress($quote->getShippingAddress()));
        if ($quote->getShippingAddress()->getCustomerAddress()) {
            $order->getShippingAddress()->setCustomerAddress($quote->getShippingAddress()->getCustomerAddress());
        }
    }
    $order->setPayment($this->_convertor->paymentToOrderPayment($quote->getPayment()));

    foreach ($this->_orderData as $key => $value) {
        $order->setData($key, $value);
    }

    foreach ($quote->getAllItems() as $item) {
        $orderItem = $this->_convertor->itemToOrderItem($item);
        if ($item->getParentItem()) {
            $orderItem->setParentItem($order->getItemByQuoteItemId($item->getParentItem()->getId()));
        }
        $order->addItem($orderItem);
    }

    $order->setQuote($quote);
    ....
}

Finally, we save the transaction, deactivate the quote by setting the is_active column to 0, and we dispatch a few events.

public function submitOrder() {
    ....
    Mage::dispatchEvent('checkout_type_onepage_save_order', array('order'=>$order, 'quote'=>$quote));
    Mage::dispatchEvent('sales_model_service_quote_submit_before', array('order'=>$order, 'quote'=>$quote));
    try {
        $transaction->save();
        $this->_inactivateQuote();
        Mage::dispatchEvent('sales_model_service_quote_submit_success', array('order'=>$order, 'quote'=>$quote));
    } catch (Exception $e) {
    ....
}

Some of the events are quite interesting to look at. The sales_model_service_quote_submit_before event gets dispatched and the observer found in the Mage_CatalogInventory module has a subtractQuoteInventory() method which gets executed.

This, as the name suggests, decreases the stock of the product(s) ordered.

public function subtractQuoteInventory(Varien_Event_Observer $observer)
{
    $quote = $observer->getEvent()->getQuote();

    // Maybe we've already processed this quote in some event during order placement
    // e.g. call in event 'sales_model_service_quote_submit_before' and later in 'checkout_submit_all_after'
    if ($quote->getInventoryProcessed()) {
        return;
    }
    $items = $this->_getProductsQty($quote->getAllItems());

    /**
     * Remember items
     */
    $this->_itemsForReindex = Mage::getSingleton('cataloginventory/stock')->registerProductsSale($items);

    $quote->setInventoryProcessed(true);
    return $this;
}

The event sales_model_service_quote_submit_success has a different method found in the same observer file as above that reindexes the stock inventory.

public function reindexQuoteInventory($observer) {
    ....
    if (count($productIds)) {
        Mage::getResourceSingleton('cataloginventory/indexer_stock')->reindexProducts($productIds);
    }
    ....
}

To learn how to add a checkout step into Magento, click here to read the Magento Add Checkout Step article.

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