I've recently created a new module for Drupal's Ubercart e-commerce solution that required me to add a new line item to the checkout process. It was a huge headache, as the documentation is incomplete, and there are no clear explanations/tutorials on how to do this process from start to finish. So I have decided to post the process here for three reasons:

  1. To save others from having to go through the same process of having to figure it all out like I did
  2. To save the process in a central area so that I can refer back to it myself!
  3. To get feedback on parts of the process I've done incorrectly, or to add parts that I may have missed

Disclaimer: I make no claims that this tutorial is exhaustive. As of the time of writing, it is the process that I used in order to implement new line items for the purpose I needed. There may be a few parts that I didn't need for my own purposes that can be implemented in line items. Please feel free to add them in the comments. Please include where in the process the step should occur, and as much detail as you can.

So on to the tutorial.

Step 1: Create a new pane for the checkout page

The first thing we need to do is create a new pane on the checkout page where we can select our new line item. This step may not be required if your line item is determined somewhere before the checkout process. However, if your line item is determined by clicking a checkbox or selecting from a list, this step will be required. In my module, I was adding a gift wrapping option to the checkout process. As such, I needed to add a checkbox to the checkout page, where the user could choose to have the order gift wrapped (wrapping came with a 3 pound surcharge).

  1. Implement hook_checkout_pane(). hook_checkout_pane() is used to define the new pane that will appear on the checkout page.

    The key to this hook is the callback function that is defined. Here is an example of the hook definition:

    function my_module_checkout_pane()
    {
      $panes[] = array
      (
        'id' => 'gift-wrap', 
        'callback' => 'my_module_checkout_pane_wrapping_options', // This is the title of the callback function
        'title' => t('Wrapping Options'), // This will be the title of the pane as displayed on the checkout page
        'desc' => t('Wrapping options added during checkout'), // The description of the pane. Will appear in admin pages
        'weight' => -1, // Determines the default location of the pane on the page
        'default' => TRUE, 
        'process' => TRUE,
        'collapsible' => TRUE,
      );
      return $panes;
    }

  2. Next we will look at the callback function for the hook defined above. The callback function will is passed three variables, $op, &$arg1, and $arg2. Inside the function, we need to deal with two values for $op: 'view' and 'process'. First, the callback function definition:

    function my_module_checkout_pane_wrapping_options($op, &$arg1, $arg2)
    {
      switch($op)
      {
        case 'view':
          // Here we create the form elements that will be added into the checkout pane
          break;
        case 'process':
          // Here we tell Ubercart what to do with the submitted values for the form elements added when $op == 'view'
          break;
      }
    }

    Let's look at an example of what to add when $op == 'view':

    // First define the form fields:
    $form['gift_wrap'] = array
    (
      '#type' => 'checkbox',
      '#title' => t('Gift Wrap this Order? ($3.00 surcharge)'),
    );
    // Next we return the form fields according to the following pattern
    return array
    (
      'contents' => $form,
      'next-button' => TRUE,
    );

    Now let's look at an example of how we will handle the above form fields upon checkout form submission. $arg2 holds the values from the submitted form items, and $arg1 holds the order details. So we have to examine the submitted values in $arg2, and add whatever data we want to $arg1, which will then be passed through the rest of the payment process.

    // $arg 2 holds the submitted data. In this example, the submitted value will either be zero (don't wrap) or one (wrap). Regardless of the submitted value, I will add it to the $order, and will use that to determine whether or not the surcharge should be added to the total later on in the module
    $arg1->gift_wrap = $arg2['gift_wrap'];

    So now we have finished defining the new pane in the checkout process. Our code will look like this:

    function my_module_checkout_pane()
    {
      $panes[] = array
      (
        'id' => 'special-options', 
        'callback' => 'my_module_checkout_pane_wrapping_options', // This is the title of the callback function
        'title' => t('Wrapping Options'), // This will be the title of the pane as displayed on the checkout page
        'desc' => t('Wrapping options added during checkout'), // The description of the pane. Will appear in admin pages
        'weight' => -1, // Determines the default location of the pane on the page
        'default' => TRUE, 
        'process' => TRUE,
        'collapsible' => TRUE,
      );
      return $panes;
    }
    function my_module_checkout_pane_wrapping_options($op, &$arg1, &$arg2)
    {
      switch($op)
      {
        case 'view':
          // First define the form fields:
          $form['gift_wrap'] = array
          (
            '#type' => 'checkbox',
            '#title' => t('Gift Wrap this Order? ($3.00 surcharge)'),
          );
          // Next we return the form fields according to the following pattern
          return array
          (
            'contents' => $form,
            'next-button' => TRUE,
          );
        break;
        case 'process':
          // Here we tell Ubercart what to do with the submitted values for the form elements added when $op == 'view'
          $arg1->gift_wrap = $arg2['gift_wrap'];
          break;
      }
    }

Step 2: Create the line item

We need to add some javascript so that if the user selects the line item from the pane define above, the total is updated in real time on the checkout page. This will involve some custom javascript.

  1. First we define the line item by implementing hook_line_item().

    function my_module_line_item()
    {
      $items[] = array
      (
        'id' => 'gift-wrap', // You will use this ID in the javascript 
        'title' => t('Gift Wrapping'), // This is the text that will be displayed on the line item in the subtotal
        'callback' => 'my_module_line_item_callback', // This is the callback function
        'weight' => 2, // This is the position of the line item in the total process
        'default' => TRUE,
        'stored' => TRUE, // This tells Ubercart to store the submitted data
        'calculated' => TRUE, // This is for line items that need to be calculated and not just displayed. For this tutorial this value is required to be true
        'add_list' => TRUE,
        'display_only' => FALSE,
      );
      return $items;
    }

  2. Next we will look at the callback function for the hook defined above. The callback function in this tutorial is used for adding the javascript to the page. The callback function will is passed two variables, $op and &$arg1. $arg1 contains the products in the order and their total. Inside the function, we need to deal with one value for $op: 'cart-preview'. There are other values of $op for the callback function, but I did not require them for my module, and as of the time of writing, I'm not sure what they are used for. First, the callback function definition:

    function my_module_line_item_callback($op, &$arg1)
    {
      if($op == 'cart-preview')
      {
        // First we need to determine the value of the discount. In this tutorial, the discount is a fixed price, but your module may require the discount value to be calculated. In my real module, the value of the discount was a percentage discount. The percentag wasset on the settings page. So I used variable_get() to get the percentage, then used the values contained in $arg1 to calculate the order total, then multiplied this by the percentage to get the amount of the discount. This tutorial is much simpler however and just uses a hard-coded value of 3 (3.00 pounds). 
        $vars = array('giftWrappingPrice' => 3)); // This is the amount of the discount
        drupal_add_js(array('myModule' => $vars), 'setting'); This passes the discount to the javascript, so that it can be recovered in the javascript script
        $path = drupal_get_path('module', 'my_module') . '/'; We get the path to the javascript script
        drupal_add_js($path . 'scripts/gift_wrap.js');  // I keep my scripts in a folder inside the module called 'scripts'. In this case the path to the script is [MODULES_FOLDER]/my_module/scripts/gift_wrap.js
      }
    }

    So now our line item hook definition will look like this:

    function my_module_line_item()
    {
      $items[] = array
      (
        'id' => 'gift-wrap', // You will use this ID in the javascript 
        'title' => t('Gift Wrapping'), // This is the text that will be displayed on the line item in the subtotal
        'callback' => 'my_module_line_item_callback', // This is the callback function
        'weight' => 2, // This is the position of the line item in the total process
        'default' => TRUE,
        'stored' => TRUE, // This tells Ubercart to store the submitted data
        'calculated' => TRUE, // This is for line items that need to be calculated and not just displayed. For this tutorial this value is required to be true
        'add_list' => TRUE,
        'display_only' => FALSE,
      );
      return $items;
    }
    function my_module_line_item_callback($op, &$arg1)
    {
      if($op == 'cart-preview')
      {
        // First we need to determine the value of the line item. In this tutorial, the line item is a fixed price, but your module may require the line item value to be calculated. In my real module, the value of the line item was a percentage discount. The percentage was set on the settings page. So I used variable_get() to get the percentage, then used the values contained in $arg1 to calculate the order total, then multiplied this by the percentage to get the amount of the discount. This tutorial is much simpler however and just uses a hard-coded value of 3 (3.00 pounds). 
        $vars = array('giftWrappingPrice' => 3)); // This is the amount of the discount
        drupal_add_js(array('myModule' => $vars), 'setting'); This passes the discount to the javascript, so that it can be recovered in the javascript script
        $path = drupal_get_path('module', 'my_module') . '/'; We get the path to the javascript script
        drupal_add_js($path . 'scripts/gift_wrap.js');  // I keep my scripts in a folder inside the module called 'scripts'. In this case the path to the script is MODULES_FOLDER/my_module/scripts/gift_wrap.js
      }
    }

  3. Finally we create the javascript script. This example in this tutorial is quite simple, as there is only one checkbox. Your own function will almost definitely be different and probably more complex than this, but regardless, the primary goal of the javascript file is to use two functions. The first function, set_line_item() is used to add the line item to the total when the checkbox is selected, and the second function, remove_line_item() is used to remove the line item if the checkbox is deselected. This example shows how those two functions would be used with this example of a single checkbox. Let's look at the script:

    // first I create an empty object as the namespace for my script. All functions will be added to this namespace to prevent conflicts with other modules.
    var myModule = {}; 
     
    // Next, I create the onload function for the script. Drupal.behaviors is the Drupal version of an onload function, and I add myModule in order to create an onload namespace and prevent onload conflicts with other modules. All code executed inside this function will be executed onload.
    Drupal.behaviors.myModule = function()  
    {
      myModule.checkboxListener(); // I will create this function below. 
    };
     
    myModule.checkboxListener = function() // This is the function called by my onload definition, and this function is part of the namespace defined at the top of the script
    {
      $("#edit-gift-wrap").click(function()
      {
        switch($(this).attr("checked"))
        {
          case false: // This indicates that the checkbox has been unchecked, so we remove the line item
            remove_line_item("gift-wrap"); // this value has to be the same as the ID defined in hook_line_item() or it will not update the total
            break;
          case true: // This indicates that the checkbox has been checked, so we add the line item
            // The definition for add_line_item() is: set_line_item(key, title, value, weight)
            set_line_item
            (
              "gift-wrap", // this value has to be the same as the ID defined in hook_line_item() or it will not update the total
              Drupal.t("Gift Wrapping"), // This is the text that will show up in the order total. I run it through Drupal.t() which is the javascript version of the t() translation function
              Drupal.settings.myModule.giftWrappingPrice // Drupal.settings.myModule.giftWrappingPrice is the value of the discount that I passed from the PHP function
            );
            break;
        }
      });
    };

Step 3: Handle the line item in the order process

While we have created the line item, and caused it to be able to be added to the display on the checkout process by checking the checkbox, this value will not yet carry over to the order review page. We will take care of this step in the process here.

  1. Implement hook_order(). Hook order is passed three arguments: $op, $arg1, and $arg2. We will deal with three values of $op: 'save', 'total' and 'submit'. Let's look at the hook_definition:

    function my_module_order($op, &$arg1, $arg2)
    {
      switch($op)
      {
        case 'save':
          // This is called when the user clicks 'review order' on the checkout page. We add our line item to the order in this step.
          break;
        case 'total':
          // Here we return a value that will be added to the order total. For discounts we would return a negative value, for surcharges we return a positive value. We do NOT return the new total, only the change in the total.
          break;
        case 'submit':
          // Here we perform any actions that our module requires for the module itself - i.e. database updates. In this tutorial there isn't actually anything to be saved to the database, but in my real module I had to save text for a card that would be attached to the order if the user chose gift wrapping. I did all my database manipulation, saving the message to the database, in this section.
          break;
      }
    }

  2. First we will look at the section when $op equals 'save'. In this section, if the line item needs to be added, we will use the function :

    // The first thing we do is remove the line item if it exists. Since users can go back and forth from the order review page to the checkout page, they may make different selections each time. By defaulting to having the line item removed, we prevent accidentally adding it multiple times, as well as preventing adding it if the user hasn't selected it when reviewing the page. So we loop through the existing line items searching for our specific line item, and removing it if it exists. We will add it afterwards if it needs to be added.
    foreach($arg1->line_items as $key => $line_item)
    {
      if($line_item['type'] == 'gift_wrap') // 'gift_wrap' is the line item ID that will be defined after this loop.
      {
        unset($arg1->line_items[$key]);
        db_query('DELETE FROM {uc_order_line_items} WHERE order_id = %d AND type = "%s"', $arg1->order_id, 'gift_wrap');
        break;
      }
    }
    // We added the value of the checkbox to the order in our hook_checkout_pane callback function when [geshifilter-code]$op == 'process'[/geshifilter-code]. If the checkbox was selected, the value is 1 (true). If the checkbox was not selected, the value is 0 (false). So we check the value, and if the checkbox was selected, we add the line item to the order.
    if($arg1->gift_wrap) 
    {
      // We will enter this conditional when the checkbox was selected. 
      // In this tutorial, the amount of the line item is a fixed amount, 3 pounds, but in my real tutorial the value was a percentage of the total, as I explained above in the hook_line_order() explanation. You will most likely need to re-calculate the value of the line item again here, however in this tutorial it is a fixed amount.
      $line_item_value = 3;
      // Next we add the line item
      uc_order_line_item_add
      (
        $arg1->order_id, // This is the order ID. 
        'gift_wrap', // This is the line item ID. We used this ID to remove the line item in the loop above.
        t('Gift Wrapping'), // This is the text that will be shown on the order review page to describe what the line item is
        3, // This is the value of the line item. Discounts will be a negative number, surcharges a positive number
        NULL, // This is the weight of the line item
        array() // Here you can pass any data that your module will use for itself when the order is submitted. In the case of my real module, I passed the value of the message that should be attached to the order when the user purchases gift wrapping. In this module there is no custom value so I actually wouldn't even pass the empty array normally. I just put it here to explain how it can be used.
      );
    }

  3. Next we add the code when $op equals 'total'. In this section we pass the amount that the order should be changed by. In the case of a discount, it will be a negative amount, in the case of a surcharge it will be a positive amount.
    // We loop through each of the line items to see if ours has been added. If it has, we adjust the order total accordingly.
    foreach($arg1->line_items as $line_item)
    {
      if($line_item['type'] == 'gift_wrap') // 'gift_wrap' is the ID of the line item as defined when $op equals 'save'
      {
        // If we enter this, the line item exists, so we need to return the value of the line item. This will adjust the order total.
        return $line_item['amount'];
      }
    }

  4. As I explained in the original hook_order() definition, if our module needs to interact with the database, we would add some code when $op equals 'submit'. However this tutorial does not save any custom information to the database, so I will not be adding that step. So lets look at our final hook_order() definition (as it applies to this tutorial):

    function my_module_order($op, &$arg1, $arg2)
    {
      switch($op)
      {
        case 'save':
          // This is called when the user clicks 'review order' on the checkout page. We add our line item to the order in this step.
          // The first thing we do is remove the line item if it exists. Since users can go back and forth from the order review page to the checkout page, they may make different selections each time. By defaulting to having the line item removed, we prevent accidentally adding it multiple times, as well as preventing adding it if the user hasn't selected it when reviewing the page. So we loop through the existing line items searching for our specific line item, and removing it if it exists. We will add it afterwards if it needs to be added.
          foreach($arg1->line_items as $key => $line_item)
          {
            if($line_item['type'] == 'gift_wrap') // 'gift_wrap' is the line item ID that will be defined after this loop.
            {
              unset($arg1->line_items[$key]);
              db_query('DELETE FROM {uc_order_line_items} WHERE order_id = %d AND type = "%s"', $arg1->order_id, 'gift_wrap');
              break;
            }
          }
          // We added the value of the checkbox to the order in our hook_checkout_pane callback function when [geshifilter-code]$op == 'process'[/geshifilter-code]. If the checkbox was selected, the value is 1 (true). If the checkbox was not selected, the value is 0 (false). So we check the value, and if the checkbox was selected, we add the line item to the order.
          if($arg1->gift_wrap) 
          {
            // We will enter this conditional when the checkbox was selected. 
            // In this tutorial, the amount of the line item is a fixed amount, 3 pounds, but in my real tutorial the value was a percentage of the total, as I explained above in the hook_line_order() explanation. You will most likely need to re-calculate the value of the line item again here, however in this tutorial it is a fixed amount.
            $line_item_value = 3;
            // Next we add the line item
            uc_order_line_item_add
            (
              $arg1->order_id, // This is the order ID. 
              'gift_wrap', // This is the line item ID. We used this ID to remove the line item in the loop above.
              t('Gift Wrapping'), // This is the text that will be shown on the order review page to describe what the line item is
              3, // This is the value of the line item. Discounts will be a negative number, surcharges a positive number
              NULL, // This is the weight of the line item
              array() // Here you can pass any data that your module will use for itself when the order is submitted. In the case of my real module, I passed the value of the message that should be attached to the order when the user purchases gift wrapping. In this module there is no custom value so I actually wouldn't even pass the empty array normally. I just put it here to explain how it can be used.
            );
          }
          break;
        case 'total':
          // Here we return a value that will be added to the order total. For discounts we would return a negative value, for surcharges we return a positive value. We do NOT return the new total, only the change in the total.
          // We loop through each of the line items to see if ours has been added. If it has, we adjust the order total accordingly.
          foreach($arg1->line_items as $line_item)
          {
            if($line_item['type'] == 'gift_wrap') // 'gift_wrap' is the ID of the line item as defined when $op equals 'save'
            {
              // If we enter this, the line item exists, so we need to return the value of the line item. This will adjust the order total.
              return $line_item['amount'];
            }
          }
          break;
        case 'submit':
          // Here we perform any actions that our module requires for the module itself - i.e. database updates. In this tutorial there isn't actually anything to be saved to the database, but in my real module I had to save text for a card that would be attached to the order if the user chose gift wrapping. I did all my database manipulation, saving the message to the database, in this section.
          break;
      }
    }

Step 4: Add any custom data to the order status page

This tutorial doesn't actually use this step. However, if your module needs to have some data that will be viewable on the order status page, you would add it using hook_order_pane() with an $op of 'customer'. In my real module this was the text that would be applied to the message card that when the user gets gift wrapping. This is a little out of the scope of my tutorial though, so I will leave this out for now unless someone specifically requests it.

Summary

So there you have it. In this tutorial we performed three steps:

  1. We defined a pane to be displayed on the checkout page
  2. We added javascript to update the total in real time if the user selects the line item
  3. We dealt with the line item throughout the order process, altering the total on the order review page

Hopefully this will save you some of the many troubles and headaches I had trying to figure out how this all worked. Good luck, and please feel free to leave any comments below!