collectTotals in Magento 2

If we were to have a custom quote object which we were adding items to, we can re-calculate the totals in the cart using the following method.

$shippingAddress = $preorderQuote->getShippingAddress();
$shippingAddress->unsetData('cached_items_all');            
$quote->setTotalsCollectedFlag(false)->collectTotals();
$this->quoteRepository->save($quote);

We unset the cached item on the quote address object to force Magento to re-load the items for the address. This is necessary as Magento will sometimes not update this automatically, leading to an incorrect zero value quote and quote items.

Magento Tax Snippets

Getting the Customer’s Address Country For Tax Calculations

The following will perform the following fallbacks:

-The quote’s shipping address
-The customer’s shipping address
-The customer’s billing address
-The default system configuration country

class Namespace_Module_Helper_Data extends Mage_Core_Helper_Abstract {
    const COUNTRY_CODE_GB = "GB";

    public function isDeliveryAddressUk(){
        /** @var Mage_Sales_Model_Quote $quote */
        $quote = Mage::getSingleton('checkout/session')->getQuote();
        $address = $quote->getShippingAddress();

        if($address && $address->getCountryId()){
            return $address->getCountryId() == self::COUNTRY_CODE_GB;
        }

        $cSession = Mage::getSingleton('customer/session');

        if($cSession->isLoggedIn()){
            $customer = $cSession->getCustomer();
            $address = $customer->getDefaultShippingAddress();

            if($address && $address->getCountryId()){
                return $address->getCountryId() == self::COUNTRY_CODE_GB;
            }

            $customer->getDefaultBillingAddress();

            if($address && $address->getCountryId()){
                return $address->getCountryId() == self::COUNTRY_CODE_GB;
            }
        }
        
        return Mage::getStoreConfig(Mage_Tax_Model_Config::CONFIG_XML_PATH_DEFAULT_COUNTRY) == self::COUNTRY_CODE_GB;
    }
}

Checking the Tax Display Type

// Including Tax
if(Mage::helper('tax')->getPriceDisplayType() == Mage_Tax_Model_Config::DISPLAY_TYPE_INCLUDING_TAX){
    ...
}

// Excluding Tax
if(Mage::helper('tax')->getPriceDisplayType() == Mage_Tax_Model_Config::DISPLAY_TYPE_EXCLUDING_TAX){
    ...
}

Basket Sales Row Totals

Sales Row Totals are defined in config.xml in the nodes:

<sales>
    <quote>
        <totals>
            <shipping>
                <class>sales/quote_address_total_shipping</class>
                <after>subtotal,freeshipping,tax_subtotal</after>
                <before>grand_total</before>
            </shipping>
        </totals>
    </quote>
</sales>

The class node is instantiated by each total type, and then collect is called on each. The fetch method actually adds the total type to the address — up until this point the config.xml definition only declares the total types which should be queried for addition. Logic could exist in the fetch method to check whether the total type should be added at all. Additionally, the fetch method could add more than one type of total, as the Mage_Tax_Model_Sales_Total_Quote_Tax does.

<?php
  public function fetch(Mage_Sales_Model_Quote_Address $address)
    {
        $amount = $address->getShippingAmount();
       if ($amount != 0 || $address->getShippingDescription()) {
            $title = Mage::helper('sales')->__('Shipping & Handling');
            if ($address->getShippingDescription()) {
                $title .= ' (' . $address->getShippingDescription() . ')';
            }
            $address->addTotal(array(
                'code' => $this->getCode(),
                'title' => $title,
                'value' => $address->getShippingAmount()
            ));
        }
        return $this;
    }

These are rendered by the Mage_Checkout_Block_Cart_Totals‘ renderTotal method. A total can also have a block total renderer assigned to it instead of having to rely on the default output in the cart rows. The block is instantiated using the node names of the total config definition. The total node name is used (in the node) with _total_renderer appended to it. This is done in the Cart Totals’ _getTotalRenderer() method. In the example above, the renderer would be called *shipping_total_renderer*.

Fishpigs Total Renderer Example

Fish pigs add a cart total renderer to the cart. To achieve this, they add a shipping_total_renderer in their layout XML:

<layout>
	
	<checkout_cart_index>
		<reference name="checkout.cart.totals">
			<block type="basketshipping/total_shipping" name="shipping_total_renderer" />
		</reference>
	</checkout_cart_index>
	
</layout>

– This block will now be rendered for the shipping_total. Due to the way the fetch method works however, the row will only be added to the cart totals is not equal to zero or the address does not has a shipping description. This can be changed by overwriting the Mage_Sales_Model_Quote_Address_Total_Shipping model.

Notes on Magento’s Tax Module

Magento’s tax modules adds renderers to many of the sales/quote/total rows. This may often be overlooked when looking at the config file from Magento’s Sales module. This changes the renderers of the subtotal, shipping, discount, and grand total to renderer blocks within the tax module:

 <subtotal>
    <renderer>tax/checkout_subtotal</renderer>
    <admin_renderer>adminhtml/sales_order_create_totals_subtotal</admin_renderer>
</subtotal>
<shipping>
    <renderer>tax/checkout_shipping</renderer>
    <admin_renderer>adminhtml/sales_order_create_totals_shipping</admin_renderer>
</shipping>
<discount>
    <renderer>tax/checkout_discount</renderer>
    <admin_renderer>adminhtml/sales_order_create_totals_discount</admin_renderer>
</discount>
<grand_total>
    <renderer>tax/checkout_grandtotal</renderer>
    <admin_renderer>adminhtml/sales_order_create_totals_grandtotal</admin_renderer>
</grand_total>

Basket Row Total Areas

There are three main areas in the shipping row totals: footer, taxes, and null. Null is the default area any is output by default, other renderers require explicit output in a template, such as the taxes area. This area area is only created if tax/cart_display/grandtotal is set to Yes.

The position of taxes in Magento will render based on the tax/cart_display/grandtotal setting. If this setting is set to Yes, then the tax will not be output as part of the regular totals, and assigned to the taxes area, which by default is output as part of the grand total template (tax/checkout/grandtotal.phtml):

<?php echo $this->renderTotals('taxes', $this->getColspan()); ?>

The area is set in the fetch method of Mage_Tax_Model_Sales_Total_Quote_Tax:

$area = null;
if ($this->_config->displayCartTaxWithGrandTotal($store) && $address->getGrandTotal()) {
    $area = 'taxes';
}

if (($amount != 0) || ($this->_config->displayCartZeroTax($store))) {
    $address->addTotal(array(
        'code' => $this->getCode(),
        'title' => Mage::helper('tax')->__('Tax'),
        'full_info' => $applied ? $applied : array(),
        'value' => $amount,
        'area' => $area
    ));
}

If the tax/cart_display/grandtotal is set to “No”, then area doesn’t get assigned, and defaults to null, so the total will be output alongside all of the other totals.

Multiple Shipping Addresses

On the multiple shipping addresses page, Magento presents a list of addresses to use for each order. These are passed to the MultishippingController‘s addressPostAction method using the post parameter name “ship”. Here’s an example of the array that gets posted:

<?php
array (size=3)
  0 => 
    array (size=1)
      2540 => 
        array (size=2)
          'qty' => string '1' (length=1)
          'address' => string '93' (length=2)
  1 => 
    array (size=1)
      2542 => 
        array (size=2)
          'qty' => string '1' (length=1)
          'address' => string '93' (length=2)
  2 => 
    array (size=1)
      2544 => 
        array (size=2)
          'qty' => string '1' (length=1)
          'address' => string '94' (length=2)
  • The key of each of the array is the order item id.
  • qty is the quantity that is required
  • address contains an id of a customer/address entity (from the EAV table customer_address_entity)

The Multishipping Controller passes the array to the method setShippingItemsInformation of a singleton of checkout/type_multishipping model. It’s here that the model firstly validates that the customer hasn’t ordered too many of the items (set in config shipping/option/checkout_multiple_maximum_qty – and set in the same place in the admin area).

  • All shipping addresses which may have previously set on the quote are now removed, and the new ones are assigned using the method ** $this->_addShippingMethod($quoteItemId, $data)** where $data is the individual elements of the array above:
<?php
protected function _addShippingItem($quoteItemId, $data)
    {
        $qty       = isset($data['qty']) ? (int) $data['qty'] : 1;
        //$qty       = $qty > 0 ? $qty : 1;
        $addressId = isset($data['address']) ? $data['address'] : false;
        $quoteItem = $this->getQuote()->getItemById($quoteItemId);

        if ($addressId && $quoteItem) {
            /**
             * Skip item processing if qty 0
             */
            if ($qty === 0) {
                return $this;
            }
            $quoteItem->setMultishippingQty((int)$quoteItem->getMultishippingQty()+$qty);
            $quoteItem->setQty($quoteItem->getMultishippingQty());
            $address = $this->getCustomer()->getAddressById($addressId);
            if ($address->getId()) {
                if (!$quoteAddress = $this->getQuote()->getShippingAddressByCustomerAddressId($address->getId())) {
                    $quoteAddress = Mage::getModel('sales/quote_address')->importCustomerAddress($address);
                    $this->getQuote()->addShippingAddress($quoteAddress);
                }

                $quoteAddress = $this->getQuote()->getShippingAddressByCustomerAddressId($address->getId());
                if ($quoteAddressItem = $quoteAddress->getItemByQuoteItemId($quoteItemId)) {
                    $quoteAddressItem->setQty((int)($quoteAddressItem->getQty()+$qty));
                } else {
                    $quoteAddress->addItem($quoteItem, $qty);
                }
                /**
                 * Require shiping rate recollect
                 */
                $quoteAddress->setCollectShippingRates((boolean) $this->getCollectRatesFlag());
            }
        }
        return $this;
    }
  •  $quote->getShippingAddressByCustomerAddressId($addressId)

    attempts to find whether this particular customer’s address has been used for any items in this specific quote before (this uses the sales_flat_quote_address table, using the Quote’s id, the type of shipping (Mage_Sales_Model_Quote_Address::TYPE_SHIPPING) and the customer’s address id (customer_address_entity table).

  • If a quote address is not found, then one is created, and the customer’s address is imported into it using the sales/quote_address method importCustomerAddress
  • The shipping address is added to the quote object.
  • The quote address is then queried using getItemByQuoteItemId($quoteItemId) to see if this address already contains an item with the same quote id. If it does, the quantities are added together, otherwise the item is added to the quote address model.

Back in the checkout/type_multishipping model’s setShippingItemsInformation method

<?php
      if ($billingAddress = $quote->getBillingAddress()) {
                $quote->removeAddress($billingAddress->getId());
            }

            if ($customerDefaultBilling = $this->getCustomerDefaultBillingAddress()) {
                $quote->getBillingAddress()->importCustomerAddress($customerDefaultBilling);
            }

            foreach ($quote->getAllItems() as $_item) {
                if (!$_item->getProduct()->getIsVirtual()) {
                    continue;
                }

                if (isset($itemsInfo[$_item->getId()]['qty'])) {
                    if ($qty = (int)$itemsInfo[$_item->getId()]['qty']) {
                        $_item->setQty($qty);
                        $quote->getBillingAddress()->addItem($_item);
                    } else {
                        $_item->setQty(0);
                        $quote->removeItem($_item->getId());
                    }
                 }

            }

  • The default billing address is obtained, and all items with a set quantity in the data array get added to the address. If there’s no quantity then the item is removed from the quote. The customer can change the billing address for the quote in a later stage of the multishipping process.

Converting the order to a sales order

This takes place at the last stage of the multishipping process, in the overviewPostAction. After a host of validation,

 $this->_getCheckout()->createOrders(); 

is called. The checkout object is an instance of the Mage_Checkout_Model_Type_Multishipping

<?php
        try {
            foreach ($shippingAddresses as $address) {
                $order = $this->_prepareOrder($address);

                $orders[] = $order;
                Mage::dispatchEvent(
                    'checkout_type_multishipping_create_orders_single',
                    array('order'=>$order, 'address'=>$address)
                );
            }

– Each of the addresses are looped over and an order is created for each one. Into the _prepareOrder method, amongst validation, the parts of the quote are converted to their order counterparts.
– The Mage_Sales_Quote_Convert class is responsible for this:

 $order = $convertQuote->addressToOrder($address);

– Takes values from the address object and puts them onto the order object. These values are found in the

** global/fieldsets/sales_convert_quote_address **, any children with the to_order child will be copied over.

 $convertQuote->addressToOrderAddress($quote->getBillingAddress()) 

– Takes values from global/fieldsets/ sales_convert_quote_address and uses the child to_order_address to copy values from the billing address to the order address.

<?php
if ($address->getAddressType() == 'billing') {
            $order->setIsVirtual(1);
        } else {
            $order->setShippingAddress($convertQuote->addressToOrderAddress($address));
        }
 

If the address type is set to billing, then set the order to virtual, otherwise convert address to an order address using the same method as above. The payment is then converted to an order payment::

 $convertQuote->paymentToOrderPayment($quote->getPayment()) 

– This uses the global/fieldsets node sales_convert_quote_payment and to_order_payment

<?php
if (Mage::app()->getStore()->roundPrice($address->getGrandTotal()) == 0) {
            $order->getPayment()->setMethod('free');
        }
[/php]
 - If the price of the items in the address object is zero, the payment method is set as free.


<?php
 foreach ($address->getAllItems() as $item) {
            $_quoteItem = $item->getQuoteItem();
            if (!$_quoteItem) {
                throw new Mage_Checkout_Exception(Mage::helper('checkout')->__('Item not found or already ordered'));
            }
            $item->setProductType($_quoteItem->getProductType())
                ->setProductOptions(
                    $_quoteItem->getProduct()->getTypeInstance(true)->getOrderOptions($_quoteItem->getProduct())
                );
            $orderItem = $convertQuote->itemToOrderItem($item);
            if ($item->getParentItem()) {
                $orderItem->setParentItem($order->getItemByQuoteItemId($item->getParentItem()->getId()));
            }
            $order->addItem($orderItem);
        }
 

- Each order item in the address is looped over and an order item is created from each quote item. This sets a few items itself, such as the quote item id, product type, quantity backordered, base original price, quote parent id, and store id. It also sets all of the product options on the quote item, then copies all entries in the global/fieldset/sales_convert_quote_item children with the child to_order_item

If there's a discount on the order item, this is copied over to the order item discount here:

<?php
if (!$item->getNoDiscount()) {
            Mage::helper('core')->copyFieldset('sales_convert_quote_item', 'to_order_item_discount', $item, $orderItem);
        }
  • Events are fired for each of the conversion routines in the sales/convert_quote model.

Back in the checkout/type_multishipping model

  • After each order has been created, a checkout_type_multishipping_create_orders_single event is dispatched.
  • For each of the orders, an email is scheduled to be sent as confirmation to the customer.
  • Session variables are set for use elsewhere:
Mage::getSingleton('core/session')->setOrderIds($orderIds);
Mage::getSingleton('checkout/session')->setLastQuoteId($this->getQuote()->getId());
  • Another event is dispatched: checkout_submit_all_after

Back in the multishipping controller

<?php
$this->_getState()->setActiveStep(
                Mage_Checkout_Model_Type_Multishipping_State::STEP_SUCCESS
            );
            $this->_getState()->setCompleteStep(
                Mage_Checkout_Model_Type_Multishipping_State::STEP_OVERVIEW
            );
            $this->_getCheckout()->getCheckoutSession()->clear();
            $this->_getCheckout()->getCheckoutSession()->setDisplaySuccess(true);
            $this->_redirect('*/*/success');

- The active step is set to success, the checkout session is cleared, a message to the user is primed and they're redirected to the success page.

Summary Notes

  • Each quote item can have its own address. The sales_flat_quote_address and sales_flat_quote_item are linked using the sales_flat_quote_address_item table.
  • When the quote is converted to an order, each shipping address creates a new order. It's the addresses (shipping and billing) which have a list of order items. The order id is set confusingly on the order addresses as parent_id, this is done in the order's addAddress method:
<?php
public function addAddress(Mage_Sales_Model_Order_Address $address)
    {
        $address->setOrder($this)->setParentId($this->getId());
        if (!$address->getId()) {
            $this->getAddressesCollection()->addItem($address);
        }
        return $this;
    }