Previous Entries
- Part 1: Introduction
- Part 2: The Core Process
- Part 3: Orders and Caveats
- Part 4: Invoices and Credit Memos
In the previous entries in this series, we’ve dived into the process Magento uses for calculating and displaying cart totals. With this conclusion, we’ll walk through an example of a new total collector, step by step.
I’ll re-emphasize that collectTotals can be useful to you in more ways than one. Implementing a new total collector can serve various purposes. For a complete example, however, I’ll focus on the most straightforward application: Creating a new, distinct total to be added to and displayed alongside the rest.
Not being an online merchant with a truly legitimate use case for such a customization, I’ll have to settle for dreaming up a half-baked scenario of my own. Let’s say I run the website Cozy Crafts, where I sell my own hand-crafted items. An important offering of my site is the ability for the customer to give instructions for customizing my products, beyond the typical scenarios of monograms or color choices. To keep it simple, I’m just going to present a custom option textarea where customers can enter their instructions, and I will apply a flat surcharge for customization per SKU (not per item), but increasing the flat fee for greater quantities.
Okay, so perhaps it’s not the soundest way to structure my pricing, but it serves well enough as a justification for a “custom surcharge” total. Keeping our example as simple as possible, we’ll assume that for products where customization is available, I’ll simply create a separate version of the product with “-CUSTOM” appended to the SKU. Customers wishing to give customization instructions will place this version of the item in their cart.
I’ll start with the complete config.xml for our module:
<?xml version="1.0"?> <config> <modules> <CozyCrafts_CustomSurcharge> <version>1.0.0</version> </CozyCrafts_CustomSurcharge> </modules> <global> <blocks> <cozycraft_surcharge> <class>CozyCrafts_CustomSurcharge_Block</class> </cozycraft_surcharge> </blocks> <models> <cozycraft_surcharge> <class>CozyCrafts_CustomSurcharge_Model</class> <resourceModel>cozycraft_surcharge_resource</resourceModel> </cozycraft_surcharge> <cozycraft_surcharge_resource> <class>CozyCrafts_CustomSurcharge_Model_Resource</class> </cozycraft_surcharge_resource> </models> <helpers> <cozycraft_surcharge> <class>CozyCrafts_CustomSurcharge_Helper</class> </cozycraft_surcharge> </helpers> <resources> <cozycraft_surcharge_setup> <setup> <module>CozyCrafts_CustomSurcharge</module> </setup> </cozycraft_surcharge_setup> </resources> <fieldsets> <sales_convert_quote_address> <base_custom_surcharge_amount> <to_order>*</to_order> </base_custom_surcharge_amount> <custom_surcharge_amount> <to_order>*</to_order> </custom_surcharge_amount> </sales_convert_quote_address> <sales_convert_quote_item> <base_custom_surcharge_amount> <to_order_item>*</to_order_item> </base_custom_surcharge_amount> <custom_surcharge_amount> <to_order_item>*</to_order_item> </custom_surcharge_amount> </sales_convert_quote_item> </fieldsets> <sales> <quote> <totals> <custom_surcharge> <class> cozycraft_surcharge/sales_quote_address_total_customsurcharge </class> <after>subtotal</after> <renderer> cozycraft_surcharge/checkout_total_customsurcharge </renderer> </custom_surcharge> </totals> </quote> <order_invoice> <totals> <custom_surcharge> <class> cozycraft_surcharge/sales_order_invoice_total_customsurcharge </class> <after>subtotal</after> </custom_surcharge> </totals> </order_invoice> <order_creditmemo> <totals> <custom_surcharge> <class> cozycraft_surcharge/sales_order_creditmemo_total_customsurcharge </class> <after>subtotal</after> </custom_surcharge> </totals> </order_creditmemo> </sales> </global> <frontend> <layout> <updates> <cozycrafts_customsurcharge> <file>cozycrafts_customsurcharge.xml</file> </cozycrafts_customsurcharge> </updates> </layout> </frontend> <adminhtml> <layout> <updates> <cozycrafts_customsurcharge> <file>cozycrafts_customsurcharge.xml</file> </cozycrafts_customsurcharge> </updates> </layout> </adminhtml> </config>
The relevant bits, starting from the top:
- The “fieldsets” node is responsible for making sure values from our new database fields get transferred from quote to order.
- Our surcharge totals, along with their model classes, are defined in the “global/sales/quote/totals”, “global/sales/order_invoice/totals” and “global/sales/order_creditmemo/totals” nodes. Note the use of <after> to position our collector’s calculations directly after the subtotal collector. We also define <renderer> in the quote node, because we will be using a custom block for our total in the cart and checkout.
The next step is to add the database fields for our total on the appropriate tables. We’ll be adding the same two fields – “custom_surcharge_amount” and “base_custom_surcharge_amount” – to the following tables: sales_flat_quote_item, sales_flat_quote_address_item (used for multi-shipping), sales_flat_quote_address, sales_flat_order_item, sales_flat_order, sales_flat_invoice and sales_flat_creditmemo. (It would be common to add these fields to sales_flat_invoice_item and sales_flat_creditmemo_item as well, but we are avoiding this in our example for simplicity.) Here is a snippet from the setup script located in our module at sql/cozycraft_surcharge_setup/install-1.0.0.php, which would be repeated for both fields and for all tables:
$installer->getConnection() ->addColumn($installer->getTable('sales/quote_address'), 'base_custom_surcharge_amount', array( 'type' => Varien_Db_Ddl_Table::TYPE_DECIMAL, 'length' => '12,4', 'nullable' => true, 'comment' => 'Base Custom Surcharge', ) );
Next we’ll look at our surcharge quote total model:
class CozyCrafts_CustomSurcharge_Model_Sales_Quote_Address_Total_Customsurcharge extends Mage_Sales_Model_Quote_Address_Total_Abstract { protected $_surchargeTiers = array( 0 => 10, 5 => 20, 15 => 30, ); public function __construct() { $this->setCode('custom_surcharge'); } }
Our model extends Mage_Sales_Model_Quote_Address_Total_Abstract. Here, we’ve set up the quantity tiers for our surcharge. (For example, if we have between 5 and 14 of a custom product in our cart, a single surcharge of $20 will be applied.) In our constructor, we set the total code on the model, matching the code we used in config.xml.
So far, we’ve let Magento know where to find our total model and when to use it in the sequence of totals collection. Immediately after the “collect” method is run on the subtotal collector model, the same method will be called on ours. Here is the code that will execute:
public function collect(Mage_Sales_Model_Quote_Address $address) { parent::collect($address); foreach ($this->_getAddressItems($address) as $item) { if (preg_match('/-CUSTOM$/', $item->getSku())) { $this->_applyItemSurcharge($item); } } return $this; } protected function _applyItemSurcharge($item) { $baseSurcharge = 0; foreach ($this->_surchargeTiers as $tier => $amt) { if ($item->getQty() < $tier) { break; } $baseSurcharge = $amt; } $surcharge = Mage::app()->getStore()->convertPrice($baseSurcharge); $item->setBaseCustomSurchargeAmount($baseSurcharge); $item->setCustomSurchargeAmount($surcharge); $this->_addBaseAmount($baseSurcharge); $this->_addAmount($surcharge); }
The “collect” method accepts an instance of Mage_Sales_Model_Quote_Address, and our call to the parent method ensures this is set on our model (which is important for the _addAmount methods). From the address, we get the collection of items and apply our surcharge to each that matches the “-CUSTOM” suffix on the SKU. Applying our surcharge involves setting both custom_surcharge and base_custom_surcharge on the item, as well as using the built-in _addBaseAmount and _addAmount, which will take care of setting the appropriate fields on the address and summing them with the rest of its totals.
With this much in place, we’re ready to test adding a custom product to our cart. We should see the surcharge reflected in our grand total when we do (though not itself displayed – that comes next). A look at the sales_flat_quote_address and sales_flat_quote_item tables will show the appropriate values have been saved on the quote records. Next is the total model’s “fetch” method:
public function fetch(Mage_Sales_Model_Quote_Address $address) { $amount = $address->getCustomSurchargeAmount(); if ($amount!=0) { $address->addTotal(array( 'code' => $this->getCode(), 'title' => Mage::helper('cozycraft_surcharge') ->__('Custom Surcharge'), 'value' => $amount )); } return $this; }
Recall that “fetch” is called on each total model during the execution of Mage_Checkout_Block_Cart_Totals::renderTotals. No calculations are being done here; those already took place in “collect.” Rather, we are simply returning an array with our total’s code and a title and value to display.
If we hadn’t defined a custom renderer block in config.xml, this would be all that’s needed to make sure our total shows up along with the others. Since we did, we have a bit more to do. Here’s our block, which actually exists merely to define the separate template used to display our surcharge:
class CozyCrafts_CustomSurcharge_Block_Checkout_Total_Customsurcharge extends Mage_Checkout_Block_Total_Default { protected $_template = 'cozycraft_surcharge/checkout/total/surcharge.phtml'; }
And here is the template, which we’ve customized from the default by adding some styling and including a link that will open a pop-up window with an explanation of our surcharge:
<tr style="color:#f00;" > <th colspan="<?php echo $this->getColspan(); ?>" style="<?php echo $this->getTotal()->getStyle() ?>" class="a-right"> <?php if ($this->getRenderingArea() == $this->getTotal()->getArea()): ?> <strong> <?php endif; ?> <?php echo $this->escapeHtml( $this->getTotal()->getTitle() ); ?><br /> <a style="font-size:10px" href="<?php echo Mage::helper('cozycraft_surcharge')->getInfoUrl() ?>" class="new-window"> <?php echo Mage::helper('cozycraft_surcharge') ->__('Why do we charge this fee?')?> </a> <?php if ($this->getRenderingArea() == $this->getTotal()->getArea()): ?> </strong> <?php endif; ?> </th> <td style="<?php echo $this->getTotal()->getStyle() ?>" class="a-right"> <?php if ($this->getRenderingArea() == $this->getTotal()->getArea()): ?> <strong> <?php endif; ?> <?php echo $this->helper('checkout') ->formatPrice($this->getTotal()->getValue()) ?> <?php if ($this->getRenderingArea() == $this->getTotal()->getArea()): ?> </strong> <?php endif; ?> </td> </tr>
Now we can see our total displayed in the cart. The following is what is shown when we have 2 of one custom product (for a surcharge of $10) and 5 of another (for a surcharge of $20) in the cart:
The “fieldset” nodes we saw in config.xml will take care of transferring the surcharge from the quote items to the order items and from the quote address to the order itself. For the surcharge to actually be displayed in order confirmation emails and in the admin, we’ll have to complete our layout update piece, but before doing so we’ll look at the respective invoice and credit memo total models as well.
For invoicing, we decide to use the following rule: For each order item, we will invoice the full surcharge amount only once the fullquantity has been invoiced. A strategy like this would typically make tracking our surcharge amount on a per item basis a good idea, but we’re going to content ourselves with tracking it only on the entire invoice. Here’s our invoice total class. (Note the use of our own logic for determining if it’s the last invoice rather than relying on “isLast”, as alluded to in the previous article regarding invoices and credit memos.)
class CozyCrafts_CustomSurcharge_Model_Sales_Order_Invoice_Total_Customsurcharge extends Mage_Sales_Model_Order_Invoice_Total_Abstract { public function collect(Mage_Sales_Model_Order_Invoice $invoice) { $invoice->setCustomSurchargeAmount(0); $invoice->setBaseCustomSurchargeAmount(0); // Get any amount we've invoiced already $prevInvoiceCustomSurchargeAmt = 0; $prevInvoiceBaseCustomSurchargeAmt = 0; foreach ($invoice->getOrder()->getInvoiceCollection() as $prevInvoice) { if ($prevInvoice->getCustomSurchargeAmount() && !$prevInvoice->isCanceled()) { $prevInvoiceCustomSurchargeAmt += $prevInvoice->getCustomSurchargeAmount(); $prevInvoiceBaseCustomSurchargeAmt += $prevInvoice->getBaseCustomSurchargeAmount(); } } $customSurchargeAmt = 0; $baseCustomSurchargeAmt = 0; $invoiceIsLast = true; foreach ($invoice->getAllItems() as $item) { $orderItem = $item->getOrderItem(); if ($orderItem->isDummy()) { continue; } if (!$item->isLast()) { $invoiceIsLast = false; } $orderItemQty = $orderItem->getQtyOrdered(); $orderItemPrevInvoiced = $orderItem->getQtyInvoiced(); // If invoicing the last quantity of this item, invoice the surcharge if (($orderItemPrevInvoiced + $item->getQty()) == $orderItemQty) { $customSurchargeAmt += $orderItem->getCustomSurchargeAmount(); $baseCustomSurchargeAmt += $orderItem->getBaseCustomSurchargeAmount(); } } // Just to be safe, if this is the last invoice, override with the full remaining surcharge amount if ($invoiceIsLast) { $customSurchargeAmt = $invoice->getOrder()->getCustomSurchargeAmount() - $prevInvoiceCustomSurchargeAmt; $baseCustomSurchargeAmt = $invoice->getOrder()->getBaseCustomSurchargeAmount() - $prevInvoiceBaseCustomSurchargeAmt; } $invoice->setCustomSurchargeAmount($customSurchargeAmt); $invoice->setBaseCustomSurchargeAmount($baseCustomSurchargeAmt); $invoice->setGrandTotal($invoice->getGrandTotal()+$customSurchargeAmt); $invoice->setBaseGrandTotal($invoice->getBaseGrandTotal()+$baseCustomSurchargeAmt); return $this; } }
Finally, we need to deal with credit memo calculation. Our logic for refunds is even simpler than for invoices: We’ll consider our surcharge non-refundable unless the entire order has been refunded. (What we’d likely want in a similar real life scenario is for the administrator to be able to choose whether to refund this amount or not on the final credit memo, but this would require some customization to the admin form itself. And of course, if we simply want the surcharge to be non-refundable in all cases, there’s no need for a total model at all.) Our credit memo total model:
class CozyCrafts_CustomSurcharge_Model_Sales_Order_Creditmemo_Total_Customsurcharge extends Mage_Sales_Model_Order_Creditmemo_Total_Abstract { public function collect(Mage_Sales_Model_Order_Creditmemo $creditmemo) { $creditmemo->setCustomSurchargeAmount(0); $creditmemo->setBaseCustomSurchargeAmount(0); // Just to be safe, get any amount we've refunded already (shouldn't be possible!) // so we can make sure we don't refund more than was charged $prevCreditmemoCustomSurchargeAmt = 0; $prevCreditmemoBaseCustomSurchargeAmt = 0; foreach ($creditmemo->getOrder()->getCreditmemosCollection() as $prevCreditmemo) { if ($prevCreditmemo->getCustomSurchargeAmount() && ($prevCreditmemo->getState() != Mage_Sales_Model_Order_Creditmemo::STATE_CANCELED)) { $prevCreditmemoCustomSurchargeAmt += $prevCreditmemo->getCustomSurchargeAmount(); $prevCreditmemoBaseCustomSurchargeAmt += $prevCreditmemo->getBaseCustomSurchargeAmount(); } } $allowedAmount = $creditmemo->getOrder()->getCustomSurchargeAmount() - $prevCreditmemoCustomSurchargeAmt; $baseAllowedAmount = $creditmemo->getOrder()->getBaseCustomSurchargeAmount() - $prevCreditmemoBaseCustomSurchargeAmt; // Loop through items to find out if this is the last credit memo $creditmemoIsLast = true; foreach ($creditmemo->getAllItems() as $item) { $orderItem = $item->getOrderItem(); if ($orderItem->isDummy()) { continue; } if (!$item->isLast()) { $creditmemoIsLast = false; break; } } // Only if the entire order has been refunded, refund the surcharge if ($creditmemoIsLast) { $creditmemo->setCustomSurchargeAmount($allowedAmount); $creditmemo->setBaseCustomSurchargeAmount($baseAllowedAmount); $creditmemo->setGrandTotal($creditmemo->getGrandTotal()+$allowedAmount); $creditmemo->setBaseGrandTotal($creditmemo->getBaseGrandTotal()+$baseAllowedAmount); } return $this; } }
All that’s left from here is to define the block class that will handle displaying our total for orders, invoices, and credit memos, and to add it to the appropriate areas in layout XML.
Thankfully, we can use the same class for all three cases. With the block’s “getSource” method, we’ll fetch the source model from the parent block, which may be an order, invoice, or credit memo. Our total amount is fetched from any of the three with the same method, so we won’t care which is being returned.
class CozyCrafts_CustomSurcharge_Block_Sales_Order_Totals_Customsurcharge extends Mage_Core_Block_Abstract { public function getSource() { return $this->getParentBlock()->getSource(); } /** * Add this total to parent */ public function initTotals() { if ((float)$this->getSource()->getCustomSurchargeAmount() == 0) { return $this; } $total = new Varien_Object(array( 'code' => 'custom_surcharge', 'field' => 'custom_surcharge_amount', 'value' => $this->getSource()->getCustomSurchargeAmount(), 'label' => $this->__('Custom Surcharge') )); $this->getParentBlock()->addTotalBefore($total, 'shipping'); return $this; } }
Our layout XML is going to be lengthy. It’s one little block, but it needs to be added in a lot of places. At least we can define each of our layout updates once and then apply them multiple times with a single line. Here’s the front-end version of cozycrafts_customsurcharge.xml:
<?xml version="1.0"?> <layout version="0.1.0"> <cozycrafts_customsurcharge_add_order_total> <reference name="order_totals"> <block type="cozycraft_surcharge/sales_order_totals_customsurcharge" name="total_custom_surcharge" /> </reference> </cozycrafts_customsurcharge_add_order_total> <cozycrafts_customsurcharge_add_invoice_total> <reference name="invoice_totals"> <block type="cozycraft_surcharge/sales_order_totals_customsurcharge" name="total_custom_surcharge" /> </reference> </cozycrafts_customsurcharge_add_invoice_total> <cozycrafts_customsurcharge_add_creditmemo_total> <reference name="creditmemo_totals"> <block type="cozycraft_surcharge/sales_order_totals_customsurcharge" name="total_custom_surcharge" /> </reference> </cozycrafts_customsurcharge_add_creditmemo_total> <sales_order_view> <update handle="cozycrafts_customsurcharge_add_order_total" /> </sales_order_view> <sales_order_invoice> <update handle="cozycrafts_customsurcharge_add_invoice_total" /> </sales_order_invoice> <sales_order_creditmemo> <update handle="cozycrafts_customsurcharge_add_creditmemo_total" /> </sales_order_creditmemo> <sales_order_print> <update handle="cozycrafts_customsurcharge_add_order_total" /> </sales_order_print> <sales_order_printinvoice> <update handle="cozycrafts_customsurcharge_add_invoice_total" /> </sales_order_printinvoice> <sales_order_printcreditmemo> <update handle="cozycrafts_customsurcharge_add_creditmemo_total" /> </sales_order_printcreditmemo> <sales_email_order_items> <update handle="cozycrafts_customsurcharge_add_order_total" /> </sales_email_order_items> <sales_email_order_invoice_items> <update handle="cozycrafts_customsurcharge_add_invoice_total" /> </sales_email_order_invoice_items> <sales_email_order_creditmemo_items> <update handle="cozycrafts_customsurcharge_add_creditmemo_total" /> </sales_email_order_creditmemo_items> <sales_guest_view> <update handle="cozycrafts_customsurcharge_add_order_total" /> </sales_guest_view> <sales_guest_invoice> <update handle="cozycrafts_customsurcharge_add_invoice_total" /> </sales_guest_invoice> <sales_guest_creditmemo> <update handle="cozycrafts_customsurcharge_add_creditmemo_total" /> </sales_guest_creditmemo> <sales_guest_print> <update handle="cozycrafts_customsurcharge_add_order_total" /> </sales_guest_print> <sales_guest_printinvoice> <update handle="cozycrafts_customsurcharge_add_invoice_total" /> </sales_guest_printinvoice> <sales_guest_printcreditmemo> <update handle="cozycrafts_customsurcharge_add_creditmemo_total" /> </sales_guest_printcreditmemo> </layout>
And here’s the admin version:
<?xml version="1.0"?> <layout version="0.1.0"> <cozycrafts_customsurcharge_add_order_total> <reference name="order_totals"> <block type="cozycraft_surcharge/sales_order_totals_customsurcharge" name="total_custom_surcharge" /> </reference> </cozycrafts_customsurcharge_add_order_total> <cozycrafts_customsurcharge_add_invoice_total> <reference name="invoice_totals"> <block type="cozycraft_surcharge/sales_order_totals_customsurcharge" name="total_custom_surcharge" /> </reference> </cozycrafts_customsurcharge_add_invoice_total> <cozycrafts_customsurcharge_add_creditmemo_total> <reference name="creditmemo_totals"> <block type="cozycraft_surcharge/sales_order_totals_customsurcharge" name="total_custom_surcharge" /> </reference> </cozycrafts_customsurcharge_add_creditmemo_total> <adminhtml_sales_order_view> <update handle="cozycrafts_customsurcharge_add_order_total" /> </adminhtml_sales_order_view> <adminhtml_sales_order_invoice_new> <update handle="cozycrafts_customsurcharge_add_invoice_total" /> </adminhtml_sales_order_invoice_new> <adminhtml_sales_order_invoice_updateqty> <update handle="cozycrafts_customsurcharge_add_invoice_total" /> </adminhtml_sales_order_invoice_updateqty> <adminhtml_sales_order_invoice_view> <update handle="cozycrafts_customsurcharge_add_invoice_total" /> </adminhtml_sales_order_invoice_view> <adminhtml_sales_order_creditmemo_new> <update handle="cozycrafts_customsurcharge_add_creditmemo_total" /> </adminhtml_sales_order_creditmemo_new> <adminhtml_sales_order_creditmemo_updateqty> <update handle="cozycrafts_customsurcharge_add_creditmemo_total" /> </adminhtml_sales_order_creditmemo_updateqty> <adminhtml_sales_order_creditmemo_view> <update handle="cozycrafts_customsurcharge_add_creditmemo_total" /> </adminhtml_sales_order_creditmemo_view> </layout>
And with that, our module is complete. We’ve successfully applied our own custom surcharge to the cart and made sure it makes it through to the final cart. After seeing the complexity of the code Magento implements to handle totals, the volume of code we actually had to write to implement our own actually doesn’t seem half bad!
I hope this series has done something to bolster your confidence in dealing with the totals collection process. The more you deal with it, the less you’ll dread those times when a client’s requirements involve customizing totals, and the less readily you’ll resort to a hack when a new total collector might be just the thing to save the day!