Getting an Attribute’s Default Value if a Product Doesn’t Have a Value Saved

<?php
public function getValue($product, $attributeCode){
	if(($val = $product->getData($attributeCode)) === null){
		return $product->getResource()->getAttribute($attributeCode)->getDefaultValue();
	}

	return $val;
}
  • If the attribute has never been saved on a product, it’ll return the default for the attribute.
  • If the attribute has been saved for a product, it’ll return that value.
  • If the attribute was saved as an empty string, it’ll get deleted from the product (instead of being saved as an empty string. If the value is required to be blank, it could be saved as a single space, and then a trim check could be added when outputting to the block.

Prices

  • To get the price actually set on a product, use $product->getPrice(), this returns the price without any discounts applied.
    -To get the price with discounts applied, use $product->getFinalPrice(). This takes the lowest of the product’s price, special price and and customer group price.

    • To calculate the product’s price with tax, use the tax helper:
      <?php
      Mage::helper('tax')->getPrice($product, $product->getFinalPrice()));
      
    • This takes all tax settings into account in the config.
    • Product prices can be set to include tax with their price, or not. This is set in Config > sales tax > product prices
    • Make sure to set the Shipping Country’s origin (Config > Sales > Shipping Settings) to the correct country. If left on the United States, for some reason Magento will ignore whether product prices are saved including tax, and will apply it again.
    • To format the output, use
       <?php Mage::helper('core')->currency($value, $format = true, $includeContainer = true);

Overriding the product’s final price

  • Use
     <?php $product->setFinalPrice($price)

    to override the getFinalPriceCalculation:

    <?php
    public function getFinalPrice($qty=null)
        {
            $price = $this->_getData('final_price');
            if ($price !== null) {
                return $price;
            }
            return $this->getPriceModel()->getFinalPrice($qty, $this);
        }
    
    • So, to modify a product’s price before adding it to the cart, an observer could be set up to modify the product’s final price beforehand.

Getting an item’s tiered price

  • Use the product’s price model to get the price with the tiered price applied.
  • A quantity must be supplied in order to get a product’s tiered price.
<?php
$product->getPriceModel()->getFinalPrice($qty, $product);
  • This executes the following method, which takes the minimum of the tiered price, grouped price, and special price:
<?php
 public function getBasePrice($product, $qty = null)
    {
        $price = (float)$product->getPrice();
        return min($this->_applyGroupPrice($product, $price), $this->_applyTierPrice($product, $qty, $price),
            $this->_applySpecialPrice($product, $price)
        );
    }

The Quote

  • When orders are created, the base price for the product is stored on the cart object (base_grand_total). This is the order’s grand price in the base currency for the country the store is set in.
  • The grand_total on the order is the price in the currency of the store the user is in.
  • The base currency code (E.g. GBP) is stored alongside the order, the store’s currency code and the quote’s currency code. The quote’s currency code is used in determining the grand total from the base grand total.
  • Order items also have a base_price and price set on them.

Javascript prices

On the product view page, product prices are pushed into the an instance of the Product.Config object.

{
  {
	"attributes": {
		"92": { // The ID of the attribute
			"id": "92",
			"code": "color",
			"label": "Color",
			"options": [{
				"id": "20",
				"label": "Black",
				"price": "10", // How much to modify the base price by
				"oldPrice": "0",
				"products": ["253", "254", "483", "484", "488"] // Simple product IDs
			}, {
				"id": "22",
				"label": "White",
				"price": "20",
				"oldPrice": "0",
				"products": ["251", "252"]
			}]
		},
		"180": {
			"id": "180",
			"code": "size",
			"label": "Size",
			"options": [{
				"id": "80",
				"label": "S",
				"price": "0",
				"oldPrice": "0",
				"products": ["253", "249"]
			}, {
				"id": "78",
				"label": "L",
				"price": "0",
				"oldPrice": "0",
				"products": ["254", "252"]
			}, {
				"id": "77",
				"label": "XL",
				"price": "85", // How much the base price is modified by
				"oldPrice": "85",
				"products": ["488"]
			}]
		}
	},
	"template": "£#{price}",
	"basePrice": "75", // The base price with any customer discounts. When recalculating the actual product price, it's not this value that's used - it's the productPrice value on the optionsPrice object.
	"oldPrice": "95", // The old price without discounts
	"productId": "410", 
	"chooseText": "Choose an Option...", // For select boxes in configurables
	"taxConfig": {
		"includeTax": true,
		"showIncludeTax": true,
		"showBothPrices": false,
		"defaultTax": 0,
		"currentTax": 0,
		"inclTaxTitle": "Incl. Tax"
	}
}

An instance of Product.OptionPrices is also instantiated with its own config. This is the object which will actually perform price changes on the page. Here’s an example of the config object:

var optionsPrice = new Product.OptionsPrice({
	"productId": "410",
	"priceFormat": {
		"pattern": "£%s",
		"precision": 2,
		"requiredPrecision": 2,
		"decimalSymbol": ".",
		"groupSymbol": ",",
		"groupLength": 3,
		"integerRequired": 1
	},
	"includeTax": "true",
	"showIncludeTax": true,
	"showBothPrices": false,
	"productPrice": 65, // The base price of the product with any customer discounts or special prices applied. This price can be modified to change the output on the page
	"productOldPrice": 75, // The old price on the product if we've got customer discounts or a special price applied
	"priceInclTax": 78
	"priceExclTax": 65,
	"skipCalculate": 1,
	"defaultTax": 0,
	"currentTax": 0,
	"idSuffix": "_clone",
	"oldPlusDisposition": 0,
	"plusDisposition": 0,
	"plusDispositionTax": 0,
	"oldMinusDisposition": 0,
	"minusDisposition": 0,
	"tierPrices": [],
	"tierPricesInclTax": []
})

When a configurable option is changed, the Product.Config calls configueElement, which refills the select boxes for all other attributes. This calls fillSelect() which updates the next select with available options. Finally, reloadPrice() is called:

 reloadPrice: function(){
        if (this.config.disablePriceReload) {
            return;
        }
        var price    = 0;
        var oldPrice = 0;

		
        for(var i=this.settings.length-1;i>=0;i--){
            var selected = this.settings[i].options[this.settings[i].selectedIndex];
            if(selected.config){
                price    += parseFloat(selected.config.price);
                oldPrice += parseFloat(selected.config.oldPrice);
            }
        }

        optionsPrice.changePrice('config', {'price': price, 'oldPrice': oldPrice});
        optionsPrice.reload();

        return price;
  • This loops over all selected attributes, and using the config object which is set upon the option element (in fillSelect()), adds up each configurable prices, and sends them to an instance of the Product.OptionsPrice model’s changePrice method. key tends to be config for configurables (guess), and price is an object containing the oldPrice of the product option and the price modifier.
 changePrice: function(key, price) {
        this.optionPrices[key] = price;
    },

– Change price is an important function. By default, the key that’s passed into it is config. E.g

optionsPrice.changePrice('config', {'oldPrice':0, 'price':300});

– Calling the above will cause the configurable modifier to be 300, which will be added on to the base price when optionsPrice.reload() is called. This value would be replaced the next time a product option is changed (as it would replace the values set on the ‘config’ key.
– **Adding more keys into the optionsPrice.changePrice method will also cause the price to be changed by each. Negative numbers can also be passed into this method. E.g.

optionsPrice.changePrice('alternative-options', {'oldPrice':0, 'price':10});
  • Calling the reload method then also applies customPrices which have been set on the product. These are stored in the Product.OptionsPrice’s customPrice array. All of the custom options are stored in an instance of the Product.Options object (opConfig), and looks like this:
{
	"3": {
		"price": 20,
		"oldPrice": 20,
		"priceValue": "20.0000",
		"type": "fixed", // type of discount, fixed or percent
		"excludeTax": 20,
		"includeTax": 24
	},
	"2": {
		"1": {
			"price": 32.5, // Even though this is a percent modifier, the price is already calculated in the base currency.
			"oldPrice": 50,
			"priceValue": "50.0000",
			"type": "percent",
			"excludeTax": 32.5,
			"includeTax": 39
		},
		"2": {
			"price": 60,
			"oldPrice": 60,
			"priceValue": "60.0000",
			"type": "fixed",
			"excludeTax": 60,
			"includeTax": 72
		}
	}
}

Modifying the prices

  • We can set
     window.optionsPrice.productPrice

    to a value, and then call

     optionsPrice.reload()

    Calculations will then be applied to this new base price – custom options and configurable options will be applied on top of this price

  • We can call
     window.optionsPrice.addCustomPrices('customcode', priceData)

    , and pass an object such as:

{
			"price": 60,
			"oldPrice": 60,
			"priceValue": "60.0000",
			"type": "fixed",
			"excludeTax": 60,
			"includeTax": 72
		}

However, this may prove awkward as we have to calculate our own tax values for our object.

  • We can call window.optionsPrice.changePrice, and pass our own key and code with the object, so if we wanted to take 50% off the base price, we could do something like the following:
var halfPrice  = window.optionsPrice.productPrice / 2;

window.optionsPrice.changePrice('custom-price-change', {
  price: -halfPrice, // This needs to be negative, prices are added to the base price.
  oldPrice: -halfPrice
});

window.optionsPrice.reload();

– Bear in mind that this will affect the base price — which may or may not already include tax depending on the config settings.
Note: If there are no custom options or configurable options on the product then the javascript will not refresh the price with the above method. This is because the productOptions object is not populated with any data. TODO: Investigate this to see how possible it is – might involve making our own data array like magento’s to initiate things.
– The logic will need duplicating when adding the product to the cart in PHP 🙂 Fuuuun.

TODO: Applying tax to shipping

Allowing prices to be set per website

By default, Magento prices are set on a global level. To allow them to be set on a website level, the System > Configuration > Catalog > Price > Catalog Price Scope must be changed.

Order Status vs State

States are what Magento uses internally to understand the position of the order. States are defined in XML in the global/sales/states node:

<states>	
    <new translate="label">
        <label>New</label>
        <statuses>
            <pending default="1"/>
        </statuses>
        <visible_on_front>1</visible_on_front>
    </new>
    <pending_payment translate="label">
        <label>Pending Payment</label>
        <statuses>
            <pending_payment default="1"/>
        </statuses>
    </pending_payment>
    <processing translate="label">
        <label>Processing</label>
        <statuses>
            <processing default="1"/>
        </statuses>
        <visible_on_front>1</visible_on_front>
    </processing>
    <complete translate="label">
        <label>Complete</label>
        <statuses>
            <complete default="1"/>
        </statuses>
        <visible_on_front>1</visible_on_front>
    </complete>
    <closed translate="label">
        <label>Closed</label>
        <statuses>
            <closed default="1"/>
        </statuses>
        <visible_on_front>1</visible_on_front>
    </closed>
    <canceled translate="label">
        <label>Canceled</label>
        <statuses>
            <canceled default="1"/>
        </statuses>
        <visible_on_front>1</visible_on_front>
    </canceled>
    <holded translate="label">
        <label>On Hold</label>
        <statuses>
            <holded default="1"/>
        </statuses>
        <visible_on_front>1</visible_on_front>
    </holded>
    <payment_review translate="label">
        <label>Payment Review</label>
        <statuses>
            <payment_review default="1"/>
            <fraud/>
        </statuses>
        <visible_on_front>1</visible_on_front>
    </payment_review>
</states>
 

Status

A status is used to understand in more granularity for the store owner (or custom application) what position the order is in.

Statuses are created in the admin area (System > Order Statuses). Each created status can be assigned a state; ** state is a child of status **

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

Changing & Fixing Required Customer Fields

Sometimes, a Magento owner may decide that certain customer fields which were optional should now be compulsory. This can break the OPC for customers who registered before this time who are trying to check out. Magento will validate their customer object during the billing section of the OPC, and throw an error (that in our case “Prefix is a required value”).

To fix this, we can copy the information from the customer’s default billing address.

The validation takes place in the Mage_Checkout_Model_Type_Onepage class, in the _validateCustomerData method:

<?php
$customerErrors = $customerForm->validateData($customerData);
        if ($customerErrors !== true) {
            return array(
                'error'     => -1,
                'message'   => implode(', ', $customerErrors)
            );
        }

config.xml

<checkout>
	<rewrite>
		<onepage_billing>Brandoutlet_Config_Block_Checkout_Onepage_Billing</onepage_billing>
	</rewrite>
</checkout>

Overriding the Onepage Model

<?php
class Brandoutlet_Config_Model_Checkout_Type_Onepage extends Mage_Checkout_Model_Type_Onepage{
	protected function  _validateCustomerData(array $data){
		// Copy over the prefix from the default billing address.
		// It looks like prefix was made compulsory at some point, so this makes
		// Accounts saved before this was done throw an error at the OPC.
		
		$customer = $this->getQuote()->getCustomer();
		
		if(!$customer->getPrefix()){
			if($billingDefault = $customer->getDefaultBillingAddress()){
				$customer->setPrefix($billingDefault->getPrefix());
			}
		}
		
		return parent::_validateCustomerData($data);
	}
}

Email (& Static Block) Directives

Var

Used to pull out any variables which are passed in to the template via the ‘bind’ parameter. Methods can also be called on passed in variables on the email objects:

{{var subscriber.getConfirmationLink()}}
{{var passedInVariable.horse}}
<?php
$bind = array(
	'passedInVariable'  => array(
		'horse' => 'Moose’
	}
);

Block

Creates the block, and sets the parameters on them (or calls the methods). If type is not set as a parameter

{{block type='cms/block’ customVar='1’ methodToCall='test’}} 

Protocol

{{protocol}} - current protocol http or https
{{protocol url="www.domain.com/"}} domain URL with current protocol
{{protocol http="http://url" https="https://url"}

Config

Puts a system config setting into the email

{{config path="path/to/config"}}

CustomVar

{{customvar code="custom_var_code"}}

InlineCss

This is used in Magento’s new email styling method. Styles from the CSS file specified are “Emogrified” and placed into style tags on the corresponding elements.

{{inlinecss file="path/to/file.css"}}

Layout

Loads the layout handle specified and sets all variables on each of the blocks in the layout handle. Methods can also be called on the blocks in the layout handle using this method, if they exist. “area” can also be set as a parameter but will default to the current area if not. Most likely this will be ‘frontend’

{{layout handle="my_custom_layout_handle_to_be_used" variable="test" method="moose"}}

Skin

Embeds the url to a skin asset, _secure can be used if required.

{{skin url="path/to/asset.ext" _secure="true"}}

Media

Embeds a media URL, _secure can be used if required

{{media url="path/to/media/asset.ext" _secure="true"}}

Store

Get the URL for the store. If the url doesn’t end in a suffix, then ‘URL’ can be used, otherwise, direct_url should be used instead. Parameters can also be passed into the url using the syntax _query_paramName=”paramValue”

{{store url="contacts"}} // http://dave.magento.store/contacts/
{{store direct_url="html_file.html"}} // http://dave.magento.store/html_file.html
{{store direct_url="goosey.html" _query_param="horseparam"}} // http://dave.magento-test-2.iweb/goosey.html?param=horseparam

Depends

Allows the contents of the depends to be omitted if the clause doesn’t hold true.

{{depend order.getIsNotVirtual()}}
<!-- Statemement -->
{{/depend}}

If

Allows the contents of the if to be omitted if the clause doesn’t hold true. Like depends, but allows the use of an else clause

{{if order.getIsNotVirtual()}}
<!-- Statemement One -->
{{else}}
<!-- Statemement Two -->
{{/if}}

Template

Allows templates to be inserted via a config path. The config paths in the example are from System > Config > Design > Transactional Emails

{{template config_path="design/email/header"}}
{{template config_path="design/email/footer"}}

Creating a Custom Payment Method

This post describes the way a basic payment method can be added to Magento. It does not include any kind of payment gateway.

The code used from this post exists as a Magento module here:
https://github.com/llapgoch/magento-custom-payment-method

config.xml

Adding the following to your config.xml’s default node will, in effect create the payment type.

<default>
	<payment>
		<llapgoch_pay>
			<!-- used in determining whether to display the payment type -->
			<active>1</active>
			<title>LLAP-Goch Pay</title>
			<order_status>processing</order_status>
			<!-- the model which gets instantiated for this payment type -->
			<model>llapgoch_basicpayment/payment</model>
			<!-- This requires the billing address to be in the specified country -->
			<allowspecific>1</allowspecific>
			<specificcountry>GB,US</specificcountry>
			<!-- Only show this order type for orders with the values between the following -->
			<min_order_total>1</min_order_total>
			<max_order_total>6000</max_order_total>	   
			<message>Thank you for using LLAP-Goch Pay!</message>
    			<!-- The debug node allows us to debug the payment method -->
 			<debug>0</debug>   
		</llapgoch_pay>
	</payment>
</default>
  • all of the nodes with the exception of “message” are used automatically by Magento in determining whether the payment type should be displayed to the user.
  • order_status is the status the order will have when the order is placed.
  • allow_specific means Magento will look for the specificcountry node and validate the billing address’ country against the contents of that field.

Payment method model

<?php
class Llapgoch_BasicPayment_Model_Payment extends Mage_Payment_Model_Method_Abstract{
	// Code to match up with the groups node in default.xml
	protected $_code = "llapgoch_pay";
	// This is the block that's displayed on the checkout
	protected $_formBlockType = 'llapgoch_basicpayment/form_pay';
	// This is the block that's used to add information to the payment info in the admin and previous
	// order screens
	protected $_infoBlockType = 'llapgoch_basicpayment/info_pay';
	
	
	// Use this to set whether the payment method should be available in only certain circumstances
	// This should only allow our payment method for over two items.
	public function isAvailable($quote = null){
		if(!$quote){
			return false;
		}
		
		if($quote->getAllVisibleItems() <= 2){
			return false;
		}
		
		return true;
	}
	
	// Errors are handled as a javascript alert on the client side
	// This method gets run twice - once on the quote payment object, once on the order payment object
	// To make sure the values come across from quote payment to order payment, use the config node sales_convert_quote_payment
    public function validate(){
       parent::validate();
	   
	   // This returns Mage_Sales_Model_Quote_Payment, or the Mage_Sales_Model_Order_Payment
       $info = $this->getInfoInstance();

       $no = $info->getCheckNo();
       $date = $info->getCheckDate();
	   
       if(empty($no) || empty($date)){
           Mage::throwException($this->_getHelper()->__('Check No and Date are required fields'));
       }
	   
	   if(strlen($no) < 5){
		   Mage::throwException($this->_getHelper()->__('Number must be five or more characters'));
	   }
       return $this;
   }
	   
}
  • This class is automatically instantiated by the model node defined the config.xml above
  • php $_code = "llapgoch_pay" tells the class where to find its config node in the xml, this is also used in our template file to append to the container’s ID which is what Magento will use to hide and display the form.
  • Config values set for the payment type can be retrieved using the method getConfigData($node)
  • php $_formBlockType = 'llapgoch_basicpayment/form_pay'; tells the class which block to use for the checkout form. When in the instance of the block, we can get the payment method object using $this->getMethod() which is automatically set on the block. If we need to get config data in the block or template, we can use $this->getMethod()->getConfigData($node)
  • php $_infoBlockType = 'llapgoch_basicpayment/info_pay'; This defines a block class to be used when displaying the payment information either in the admin area or on the front end in customer accounts. It doesn’t behave like a normal block class per se, it just sets data to be output.
  • The method isAvailable is used by Magento in determining whether this payment method should be used
  • The validate method is used to validate whether all requirements have been met and the user can proceed. Commonly, any form fields will be validated here. To do this, we get an instance of a subclass of Mage_Payment_Model_Info, which will typically be Mage_Sales_Model_Quote_Payment or Mage_Sales_Model_Order_Payment, as the validate method is run once for each. The quote payment object will have the values set on it from the form automatically, however for that data to persist to the order payment, we have to create new columns in the sales_flat_order_payment table and set the fields to be copied over via XML (More on this later).
  • As the onepage form loads its content in via AJAX, any exceptions which are thrown within this context are output as JSON and alerted to the user as a Javascript alert.

Copying the new fields to the sales payment object

As the new values will automatically be set on the quote payment object, and hence pass the first validation routine, we need to tell Magento to copy these values to the order payment object. This stage only provides the mechanism to copy the values specified, just doing this won’t make them persist to the sales_flat_order_payment table. The following needs to be added to the module’s config.xml:

<config>
	<global>
		<fieldsets>
			<sales_convert_quote_payment>
				<check_no>
					<to_order_payment>*</to_order_payment>
				</check_no>
				<check_date>
					<to_order_payment>*</to_order_payment>
				</check_date>
			</sales_convert_quote_payment>
		</fieldsets>
    </global>
</config>
  • sales_convert_quote_payment is a node that’s looked for in the Mage_Sales_Model_Convert_Quote object in the paymentToOrderPayment method.
  • It uses the copyFieldset method of the Mage::helper(‘core’) helper.
  • Each of the child nodes of sales_convert_quote_payment are converted onto the order payment object, providing they have the node to_order_payment.
  • The value will be converted to a value with the same name if the node value of to_order_payment is an asterisk, otherwise it will adopt the value of the node.

The Payment Block Class

<?php 
class Llapgoch_BasicPayment_Block_Form_Pay extends Mage_Core_Block_Template{
	protected function _construct(){
		parent::_construct();
		
		$this->setTemplate('llapgoch/basicpayment/form/pay.phtml');
	}
}
  • A basic block class which just sets its own template

The Payment Block Template

<?php
// The ID using the code on the UL is important - this is what Magento will use to hide and display the 
// Form with javascript - it should be set to display:none by default
?>
<?php $_code = $this->getMethod()->getCode() ?>
<ul class="form-list" id="payment_form_<?php echo $_code ?>" style="display:none;">
    <li>
        <label for="<?php echo $_code ?>_check_no" class="required"><em>*</em><?php echo $this->__('Check No#') ?></label>
        <span class="input-box">
            <input type="text" title="<?php echo $this->__('Check No#') ?>" class="input-text required-entry" id="<?php echo $_code ?>_check_no" name="payment[check_no]" value="<?php echo $this->htmlEscape($this->getInfoData('check_no')) ?>" />
        </span>
    </li>
    <li>
        <label for="<?php echo $_code ?>_check_date" class="required"><em>*</em><?php echo $this->__('Check Date:') ?></label>
        <span class="input-box">
            <input type="text" title="<?php echo $this->__('Check Date:') ?>" class="input-text required-entry" id="<?php echo $_code ?>_check_date" name="payment[check_date]" value="<?php echo $this->htmlEscape($this->getInfoData('check_date')) ?>" />
        </span>
    </li>
	<li>
		<div>
		    <?php echo $this->getMethod()->getConfigData('message');?>
		</div>
	</li>
</ul>

  • Magento uses the code set in the payment class to show and hide the form with javascript
  • The form should be set to display:none initially.
  • The value of all submitted form elements will be placed automatically on the Quote Payment object which can then be used in the validate method of the Payment class. All form data should be placed in the ‘payment’ array, E.g. payment[check_date].
  • “Message” is an arbitrary node set in the config.xml. Any nodes can be retrieved using the getConfigData method.

The Info Block

This is the block that’s used to add data to the payment info blocks which exist in the admin area and the customer’s previous order page.

<?php
// This block allows data along with the payment method to be presented on the admin screen and user order screen.
class Llapgoch_BasicPayment_Block_Info_Pay extends Mage_Payment_Block_Info{
    protected function _prepareSpecificInformation($transport = null)
       {
           if (null !== $this->_paymentSpecificInformation) {
               return $this->_paymentSpecificInformation;
           }
           $info = $this->getInfo();
           $transport = new Varien_Object();
           
           $transport->addData(array(
               Mage::helper('payment')->__('Check No#') => $info->getCheckNo(),
               Mage::helper('payment')->__('Check Date') => $info->getCheckDate()
           ));
		   
           $transport = parent::_prepareSpecificInformation($transport);
           return $transport;
       }
}
  • First we check if the value of paymentSpecificInformation has been set. This is set by the Mage_Payment_Block_Info superclass’s _prepareSpecificInformation method.
  • The data we require to be displayed gets added to the transport object.
  • We translate the labels of the labels to be displayed at this point.

The Setup Class

This is required to add our required information to the sales_flat_order_payment table. If these fields don’t exist then the validation will pass in the Payment class above, as they’ll get copied to the sales payment object from the quote payment object, but they won’t persist into the database.

<?php
$this->startSetup();

// We have to create these columns so that our new fields show up in the sales_flat_order_payment table
// We don't need to create these columns for the quote payment table as the data is copied from the form
// To the quote object in code - it's then converted to an order payment object, however it may be required to add these columns to the quote so that the quote reloads with the data. To be investigated!
$this->getConnection()->addColumn(
	// getTable returns the name of the table as a string
	$this->getTable('sales/order_payment'),
	'check_no',
	array(
		// Use TYPE_TEXT instead of TYPE_VARCHAR, as it's deprecated and will throw an error
		// Adding a length will make it as varchar
		'type' => Varien_Db_Ddl_Table::TYPE_TEXT,
		'nullable' => true,
		'default' => null,
		// Comment must be provided
		'comment' => 'Check Number',
		'length' => 100			
	)
);

$this->getConnection()->addColumn(
	$this->getTable('sales/order_payment'),
	'check_date',
	array(
		'type' => Varien_Db_Ddl_Table::TYPE_TEXT,
		'nullable' => true,
		'default' => null,
		'comment' => 'Check Date',
		'length' => 255
	)
);

$this->endSetup();
  • Remember to use TYPE_TEXT with a length set instead of TYPE_VARCHAR, as this has been deprecated. Using varchar will throw an exception.
  • A comment must be provided for each new column. An exception will be thrown if not.

Alternate Setup Class

There is a utility function inside of the **Mage_Sales_Model_Resource_Setup ** class to aid with adding columns to the tables above:

<?php
   $this->startSetup();
    $installer = new Mage_Sales_Model_Resource_Setup('core_setup');

    $options = array(
        'type'     => Varien_Db_Ddl_Table::TYPE_INTEGER,
        'visible'  => true,
        'required' => false
    );

    $installer->addAttribute('order_payment', 'check_date', $options);
    $installer->addAttribute('order_payment', 'check_no', $options);
}

$this->endSetup();

– This essentially runs the same code as the previous example, but with a more succinct installer.

system.xml

Just for completeness, this allows the values in default to be overridden from the admin area.

<?xml version="1.0"?>
<config>
	<sections>
		<payment>
			<groups>
				<llapgoch_pay translate="label" module="llapgoch_basicpayment">
					<label>LLAP-Goch Basic Payment</label>
					<sort_order>300</sort_order>
					<show_in_default>1</show_in_default>
					<show_in_website>1</show_in_website>
					<show_in_store>1</show_in_store>
					<fields>
						<active translate="label" module="llapgoch_basicpayment">
							<label>Enabled</label>
							<sort_order>1</sort_order>
							<show_in_default>1</show_in_default>
							<show_in_website>1</show_in_website>
							<show_in_store>1</show_in_store>
							<frontend_type>select</frontend_type>
							<source_model>adminhtml/system_config_source_yesno</source_model>
						</active>
						
						<order_status translate="label" module="llapgoch_basicpayment">
							<label>Order Status</label>
							<sort_order>10</sort_order>
							<show_in_default>1</show_in_default>
							<show_in_website>1</show_in_website>
							<show_in_store>1</show_in_store>
							<frontend_type>select</frontend_type>
							<source_model>adminhtml/system_config_source_order_status_newprocessing</source_model>
						</order_status>
						
						<title translate="label" module="llapgoch_basicpayment">
							<label>Title</label>
							<sort_order>20</sort_order>
							<show_in_default>1</show_in_default>
							<show_in_website>1</show_in_website>
							<show_in_store>1</show_in_store>
							<frontend_type>text</frontend_type>
						</title>
					</fields>
				</llapgoch_pay>
			</groups>
		</payment>
	</sections>
</config>

The code used from this post exists as a Magento module here:
https://github.com/llapgoch/magento-custom-payment-method

Email Translations

When working with email translations, they’re taken from the following place:

app/locale/<<lang_COUNTRY>>/template/email/

So, depending on the set locale for the store (set in System>Configuration>General>Locale) depends on where it gets its translation files from. Inspect the dropdown to see the list of language / country combinations.

Copying Product Attributes to Quote & Order Items

Add this to your XML;

<global>
    <sales>
        <quote>
            <item>
                <product_attributes>
                    <location/>
                </product_attributes>
            </item>
        </quote>
    </sales>
</global>

  • This makes the product attributes accessible to the Mage_Sales_Model_Quote_Config class’ getProductAttributes() method. This reads in the sales/quote/item/product_attributes node.

  • This is called by the _assignProducts method of the Mage_Sales_Model_Resource_Quote_Item_Collection‘s _assignProducts() method, where it adds all of the attributes to the product collection of the quote item collection.

Add the columns to the quote and order item tables

  • Create an installer with the following:

    $installer = new Mage_Sales_Model_Resource_Setup('core_setup');
    
    $entities = array(
        'quote_item',
        'order_item'
    );
    
    $options = array(
        // For some reason, VARCHAR works here, whereas elsewhere it must be TYPE_TEXT with a length of 255.
        'type'     => Varien_Db_Ddl_Table::TYPE_VARCHAR,
        'visible'  => true,
        'required' => false
    );
    
    foreach ($entities as $entity) {
        $installer->addAttribute($entity, 'location', $options);
    }
    

  • The addAttribute() method of the Mage_Sales_Model_Resource_Setup creates the columns on the quote_item and order_item tables.

Copy the attributes to the quote item

Unlike copying from quote items to order items, there isn’t an XML method to do this – it has to be accomplished through an observer. Add the following to the config (with the observer / method substituted):

<frontend>
    <events>
        <sales_quote_item_set_product>
            <observers>
                <observer_name>
                    <class>model/observer</class>
                    <method>addAttributesToQuoteItem</method>
                </observer_name>
            </observers>
        </sales_quote_item_set_product>
    </events>
</frontend>

Then create the following method for the observer above:

    public function addAttributesToQuoteItem($observer){
        $quoteItem = $observer->getQuoteItem();
        $product = $observer->getProduct();
        $quoteItem->setLocation($product->getLocation());
    }

Note: Values Don’t have to come from the product item

Any arbitrary information could be set on the quote or quote item; it does not have to come from the product itself. E.g. A different type of ship note could be set by a different observer. The associated columns just need to exist on the quote or quote item tables.

Copying quote item attributes to the order item

  • This can be achieved with a small amount of XML:
  <global>
    <fieldsets>
        <sales_convert_quote_item>
            <location>
                <to_order_item>*</to_order_item>
            </location>
        </sales_convert_quote_item>
    </fieldsets>
</global>

Note: This will not copy the data to the sales_flat_shipment_item table.

Converting from an order item to other types of item

The following will copy the object’s attributes when converting the quote item back to a quote item, an invoice item, and a credit memo item

<global>
    <fieldsets>
        <sales_convert_order_item>
            <location>
                <to_quote_item>*</to_quote_item>
                <to_invoice_item>*</to_invoice_item>
                <to_cm_item>*</to_cm_item>
            </location>
        </sales_convert_order_item>
    </fieldsets>
</global>

These are used in the Mage_Sales_Model_Convert_Order class’ itemToQuoteItem, itemToInvoiceItem, and itemToCreditmemoItem methods. Note: In default Magento, it does not appear that the itemToQuoteItem is used, so this should be included for third party extensions which may rely on this method.

Collections, Models and Queries

Models

Loading an item:

<?php Mage::getModel('catalog/category')->load($id);

Loading by attribute:

<?php Mage::getModel('catalog/category')->load(‘url_key’, $key);

Collections

Adding an item collection

<?php $collection->addItem($item);

Eav’s AddAttributeToFilter

The addAttributeToFilter for EAV is interchangeable with addFieldToFilter (which non-eav collections use)

Less Than

<?php $products->addAttributeToFilter('price', array('lt' => 100));

Greated Than

<?php $products->addAttributeToFilter('price', array('gt' => 100));

In

<?php $products->addAttributeToFilter('sku', array('in' => array('1222333', 'ABC 456')));

String Equal

<?php $products->addAttributeToFilter('sku', array('in' => array('1222333', 'ABC 456')));

List of methods from Varien_Db_Adapter_PDO

<?php
$conditionKeyMap = array(
            'eq'            => "{{fieldName}} = ?",
            'neq'           => "{{fieldName}} != ?",
            'like'          => "{{fieldName}} LIKE ?",
            'nlike'         => "{{fieldName}} NOT LIKE ?",
            'in'            => "{{fieldName}} IN(?)",
            'nin'           => "{{fieldName}} NOT IN(?)",
            'is'            => "{{fieldName}} IS ?",
            'notnull'       => "{{fieldName}} IS NOT NULL",
            'null'          => "{{fieldName}} IS NULL",
            'gt'            => "{{fieldName}} > ?",
            'lt'            => "{{fieldName}} < ?",
            'gteq'          => "{{fieldName}} >= ?",
            'lteq'          => "{{fieldName}} <= ?",
            'finset'        => "FIND_IN_SET(?, {{fieldName}})",
            'regexp'        => "{{fieldName}} REGEXP ?",
            'from'          => "{{fieldName}} >= ?",
            'to'            => "{{fieldName}} <= ?",
            'seq'           => null,
            'sneq'          => null
        );
  

Performing OR Queries

ORs with the same attributes

<?php $products->addAttributeToFilter('sku', array(array('eq' => '1222333'), array('eq' => 'LLAPGOCH-SIMPLE' ));

ORs with different attributes

<?php
$collection->addAttributeToFilter(
    array(
        array('attribute'=> 'sku','like' => 'value'),
        array('attribute'=> 'otherattribute','nin' => array(1, 2, 3)),
        array('attribute'=> 'anotherattribute','like' => 'value'),
    )
);

Ordering

<?php
$collection->setOrder('attribute', Varien_Data_Collection::SORT_ORDER_DESC);
$collection->setOrder('attribute', Varien_Data_Collection::SORT_ORDER_ASC);

Useful Methods

<?php
// Gets a list of all Ids within a collection
$collection->getAllIds();

// Gets the last page number of a collection (when using the page functions)
$collection->getLastPageNumber();

// Get the pagesize of a collection
$collection->getPageSize();

// Get the size of the collection (similar to count, but the result is cached)
$collection->getSize(0);

// Getting a specific item(s)
$collection->getFirstItem();
$collection->getLastItem();
$collection->getItems();

// Clearing a collection (allows reloading)
$collection->clear();

// Converts the collection to an XML String
$collection->toXML();

Iterating Collections

Sometimes, large collections will cause memory issues in PHP. One solution, is to use Magento’s core/resource_iterator. This is the only purpose of this class:

<?php
public function output(){
        $products = Mage::getResourceModel('catalog/product_collection')

                // When adding attributes to the collection, make sure to use the the second parameter 'inner', otherwise the resource iterator won't pull them out.
        $products->addFieldToFilter('price', array('gt' => 300))
            ->addAttributeToSelect(array('name', 'image', 'url_key', 'price', 'visibility'), 'inner');

        Mage::getSingleton('core/resource_iterator')->walk(
            $products->getSelect(),
            array(array($this, 'walkCallback'))
        );

    }

    public function walkCallback($args) {
        $product = Mage::getModel('catalog/product');
        $product->setData($args['row']);

        var_dump($args['row']);
    }

  • The second join parameter must be ‘inner’ to pull the required data out using this method.

Joining Tables in a Collection

<?php
$collection->join(
	array('banner_item' => 'llapgoch_banners/banner_item'),
	'banner_item.banner_id=main_table.banner_id',
        array('banner_item_title' => 'title')
);

– The first parameter of the join, is the table to join onto. It’s an array, the key of the array being the alias that we’ll use for the table.
– The second parameter is the ON part of the query, I.e. the query to join the two tables together. Use the alias name given in the first parameter to reference the table that we’re joining on, and Magento uses the alias main_table for the table to which this collection refers.
– The third parameter is the fields which should be selected from the table we’re joining on. Leaving this parameter as null will cause Magento to select everything from the table; **This could overwrite variables with the same name from the main table if they clash **. As in the example, this parameter can be used as key value pairs, the key becoming the field’s alias in the query.

Left Joining

  • The collection object doesn’t have a left join method itself, so the collection’s select object needs to be used:
    <?php
    $collection->getSelect()->joinLeft(
    	array('banner_item' => $collection->getTable('llapgoch_banners/banner_item')),
    	'banner_item.banner_id=main_table.banner_id',
    	array('banner_item_title' => 'title')			
    );
    

  • The select object is an instance of Varien_Db_Select, this extends Zend_Db_Select, which is where this method is defined.

  • The first parameter is the table name (which needs to be resolved by the getTable method, either on the collection or connection object), this needs to be done here and not in the collection object’s join method because the collection object performs this operation for us.
  • In the example, we use an array as the first parameter instead of just the table name. The key of this array becomes the alias for the table.
  • The second parameter is the ON query. Use the alias (if defined) from the first parameter. Main table is the table for which the collection refers to.
  • The third parameter is the columns is the columns we would like to select from the joined table. Leaving this as null (default) will cause all columns to be selected. If no columns are required (E.g. we’re just joining to use the data for an aggregate query), then pass an empty array in as this parameter.

Inner Join

  • The select object also includes an inner join method, if the collection’s join method can’t be used:
    <?php
    $collection->getSelect()->joinInner(
    	array('banner_item' => $collection->getTable('llapgoch_banners/banner_item')),
    	'banner_item.banner_id=main_table.banner_id',
    	array('banner_item_title' => 'title')			
    );
    

Grouping

<?php
$collection->getSelect()->group('main_table.banner_id');

– The collection’s select object needs to be employed to group by a column. main_table references the table to which the collection refers to.

Adding aggregate queries

Method One

<?php
$collection->addExpressionFieldToSelect('num_banner_items', 'COUNT(*)', null);

– The first parameter is the fieldset name the value will be assigned to
– The second parameter is the query, aggregate or otherwise.
– The third parameter can be null, or an array of key values used in replacing values specified in the query between double curly brackets: {{value_to_be_replaced}}

Method Two 

<?php
$collection->getSelect()->columns('COUNT(*) as num_banner_items');

– This will add the parameter ‘num_banner_items’ to the collection object’s items.

Randomly Ordering a Collection

Method One

<?php
$collection->getSelect()->orderRand();

Method Two

<?php
$collection->getSelect()->order(new Zend_Db_Expr('RAND()'));

– See the note later on as to what the Zend_Db_Expr object actually is.

Adding a Straight Where Query

<?php
$collection->getSelect()->where('main_table.banner_id <= 1');

Limiting using the Select Object

  • Instead of setting the and page size on the collection, a limit can be called directly on the select object:
    <?php
    $collection->getSelect()->where('main_table.banner_id <= 1');
    

### Getting a collection’s full query ###

<?php
$collection->getSelect()->assemble()

Having

  • Use having to filter on aliases that have been set up either in the $collection->getSelect()->columns method or the $collection->addExpressionFieldToSelect method.
    <?php
    $collection->getSelect()->having('COUNT(*) = ?', 2);
    
    • Note: When using havings or aggregate queries in an admin grid, be careful as Magento will strip off all column names when it performs its pagination queries. You won’t be able to use aggregate aliases in the having, so use the aggregate function again.

The Zend_Db_Expr Object

This is an object which accepts an expression as its parameter. All the object does is cast the value passed in to a string, and then give it back in its _toString() method:

<?php
class Zend_Db_Expr
{
    protected $_expression;
    
    public function __construct($expression)
    {
        $this->_expression = (string) $expression;
    }
    public function __toString()
    {
        return $this->_expression;
    }
}

Flat Catalog

More complex queries may fail id flat catalog is toggled. These can be adapted and checked for using the following check:

  public function isProductFlatCatalogOn()
    {
        $flatHelper = Mage::helper('catalog/product_flat');
        return $flatHelper->isAvailable() && !Mage::app()->getStore()->isAdmin() && $flatHelper->isBuilt(true);
    }