(720) 254-1546
vintagedigital [at] dogstar [dot] org
follow us on twitter @vintagedigital

How to Add Multicurrency Support to Ubercart

Recently I had the privilege of working on the conversion of Novus Biological's website to Drupal 6, selling scientific supplies using Ubercart. I did this as a freelancer for the talented folks at SpireMedia, working with them as well as with fellow Vintage Digital member Ben Jeavons. It was a fantastically intense experience.

Ubercart's a great system out of the box, but there were a number of features on the site which made this implementation difficult.

  1. There were about 70,000 products, which is probably two or three orders of magnitude greater than the usual Ubercart site.
  2. The site needed to work with three currencies, and four prices, depending on the user's location or selection. Ubercart doesn't handle this well.
  3. The site needed to have two kinds of discounts - ones available to most any user, and ones only available to users who had bought the discount with userpoints.

I'll probably write about all of these in time, but at the moment I want to comment on that second item.

A close-up of the novusbio website, showing the region-change text
If you take a look at www.novusbio.com, or the image above, you'll see a bit of text in the upper right hand corner which says something like 'US site', or 'Europe Site', or 'Great Britain site', or 'World Site', with a little arrow next to it letting you change your region. We would start by pre-selecting a region for the user based on which IP they were browsing from, and then users could change it. If you go to a product page on Novus' site, you'll see different prices based on which of these are currently selected, as well as different currency signs. Since doing something like this is what some people would like to be able to do, I wanted to take a little time to go over how we did this.

Note: If you're not a coder, and you need this functionality, then you're going to want to go find a coder to handle this for you. It's not currently possible to do by flipping a switch or installing a module. I'm going over the changes you need to make here, but the full set of changes, along with the needed patches to Ubercart, are contained in uc_example.zip, attached to this post.

To start with, you're going to need to define the regions or currencies you're doing prices for. I use the term regions, because you could well have two different areas of the world using the same symbol but different price amounts. So, I use a define to mark which regions I'm keeping track of.

<?php
define
('UC_EXAMPLE_DOLLAR', 1);
define('UC_EXAMPLE_EURO', 2);
?>

There are basically two things you want to handle when you allow for more than one currency in Ubercart:

  1. You want to change the currency symbol.
  2. You want to change the numeric amount of the price. You probably won't want to have a factor that you apply to the base price - I would rather suggest setting up alternate prices as CCK fields on your product nodes, much the same way that books in the US have set US and Canadian prices printed on them.

(As an aside, although the changes I'm discussing will allow you to have, say, both US and Euro prices on your site, you'll also at some point need to go through and write up some new reports that are multi-currency aware. After all, a report that says you sold 54,382 in the last two months doesn't mean anything if 24,382 was in US Dollars and 30,000 was in Euros. And, if you do up some reports like this, why not share them with the rest of us?)

There's two different places where you need to keep track of the current currency type as well - for when a user is browsing the site, or putting together an order, or reviewing her cart, or whichever, you'll want to store the current currency type in the session. On the other hand, if you're viewing an order - either in the very last step before the order is committed, (where a row has been written to uc_orders) or when viewing an order in the history, you need to have stored the currency type in the order data itself.

Changing the amount/magnitude of the item

Now, the slightly simpler part is changing the amount of the charge - if you're looking at $360, then we're talking about the 360 part of it. This amount needs to change on cart items or on products when they're viewed. We don't need to worry about changing the amount on the items in completed orders, because that amount quite sensibly is fixed when the order is committed.

I'm not going to go over every change you need to make - you might need to do a little work in your views to decide which price to use when displaying a view (indeed, views_customfield may be a good idea for that), but when you're changing how the product is displayed, you're basically doing a little work with hook_nodeapi:

<?php
/**
* Implements hook_nodeapi().
*/
function uc_example_nodeapi(&$node, $op, $a3 = NULL, $a4 = NULL) {
  if (
$op == 'view') {
   
$region_price = uc_example_get_node_price($node, $_SESSION['region']);
   
   
$context['class'][1] = 'sell';
   
$context['field'] = 'sell_price';
   
$node->content['sell_price']['#value'] = theme('uc_product_price', $region_price, $context, array('label' => !$a3));
   
   
$context['class'][1] = 'display';
   
$context['field'] = 'sell_price';
   
$node->content['display_price']['#value'] = theme('uc_product_price', $region_price, $context);

  }
}

/**
* Returns the price from the node, from the region passed in.
*
* @param stdClass $node Product node to get price for.
* @param integer $current_region Region to get the price for.
*/
function uc_example_get_node_price($node, $current_region) {
  switch (
$current_region) {
    case
UC_EXAMPLE_DOLLAR:
      return
$node->field_price_us[0]['value'];
    case
UC_EXAMPLE_EURO:
      return
$node->field_price_euro[0]['value'];
    default:
      return
0;
  }
}
?>

When viewing the price in a person's cart, we'll want to use hook_cart_item($op, $item) to change the price for products.

<?php
/**
* Implements hook_cart_item()
*
* This lets us change the base price based on the user's current region.
*
* See <a href="http://www.ubercart.org/docs/api/hook_cart_item
" title="http://www.ubercart.org/docs/api/hook_cart_item
">http://www.ubercart.org/docs/api/hook_cart_item
</a> * @param String $op     Operation being done
* @param stdClass $item Cart item - usually a node of some sort.
*/
function uc_example_cart_item($op, &$item) {

 
//dpm('hook_cart_item '. $op);
 
if ($op == 'load') {
    global
$user;
   
   
$product_node = node_load($item->nid);

   
//dpm($_SESSION);
   
$region_id = 0;
   
// If we're viewing the cart item on the review page, we want to use the region
    // that's embedded in the order item, not the region that the user is using.
   
if ($_GET['q'] == 'cart/checkout/review') {
     
$order = db_fetch_object(db_query(
       
"SELECT uo.order_id, IFNULL(uom.region, 1) as mc_region,
        IFNULL(uom.currency_sign, '$') AS mc_currency_sign
        FROM {uc_orders} uo
        INNER JOIN {uc_order_products} uop ON (uo.order_id = uop.order_id)
        LEFT OUTER JOIN {uc_order_multicurrency} uom ON (uo.order_id = uom.order_id)
        WHERE uid = %d and order_status = '%s' AND uop.nid = %d ORDER BY uo.order_id DESC"
,
       
$user->uid, 'in_checkout', $item->nid
     
));
      if (
$order) {
       
$current_region = $order->mc_region;
      }
      else {
       
$current_region = $_SESSION['region'];
      }
    }
    else {
     
$current_region = $_SESSION['region'];
    }

    if (
$product_node) {
     
$item->price = uc_example_get_node_price($product_node, $current_region);
    }
  }
}
?>

Changing the Currency Symbol

With that done, lets consider the currency sign. I like storing the user's currently selected symbol in $_SESSION['currency_sign'], and we'll also be storing it in orders as well. The first impulse you might have would be to start mucking about with variable_set('uc_currency_sign') - but changing that changes the symbol for everything at once, and you're no doubt hoping to have more than one customer at a time.

Instead you want to register a price handler with hook_uc_price_handler. Then, whenever a price is being determined by uc_price(), your price alteration function can determine the proper currency sign to use, and provide it.

<?php
/**
* All we're doing with this price alteration function, is telling it to use for the
* currency sign the sign that we've stashed in $_SESSION.
*/
function _uc_example_price_alter(&$price_info, $context, &$options) {
 
// default currency sign.
 
$options['sign'] = $_SESSION['currency_sign'];
  if (isset(
$context['subject']['order']) && $context['subject']['order']->order_id) {
   
$order = $context['subject']['order'];
    if (isset(
$order->mc_currency_sign)) {
     
$options['sign'] = $order->mc_currency_sign; // use sign from order!
   
}
    else {
     
$currency_sign = db_result(db_query("SELECT currency_sign FROM {uc_order_multicurrency} WHERE order_id = %d", $order->order_id));
      if (
$currency_sign) {
       
$options['sign'] = $currency_sign;
      }
    }
  }
}
?>

What I'm doing there is setting $options['sign'] - which is an array passed in by reference - and changing it to the sign we want it to have. First we set it by the $_SESSION, and then we check and see if the context - a collection of information about what this price is and where it's being presented - contains the order which this price is a part of. If it is a part of an order, then we use the sign saved as part of that order.

If you're wondering how this information gets to be part of the order, unsurprisingly the answer is we implement hook_order in our code.

<?php
/**
* Implements hook_order().
*
* This is where we finalize the region_id and currency_sign.
*/
function uc_example_order($op, &$arg1, $arg2) {

  if (
$op == 'new') {
   
// $arg1 is a reference to the order object.

   
$arg1->mc_currency_sign = $_SESSION['currency_sign'];
   
$arg1->mc_region = $_SESSION['region'];
  }
  if (
$op == 'save' && $_GET['q'] == 'cart/checkout') {
   
// Now we need to save our updated region data to the order!
   
if (db_result(db_query("SELECT count(*) FROM {uc_order_multicurrency} WHERE order_id = %d", $arg1->order_id))) {
     
db_query("UPDATE {uc_order_multicurrency} SET region = %d, currency_sign = '%s' WHERE order_id = %d",
              
$_SESSION['region'], $_SESSION['currency_sign'], $arg1->order_id);
    }
    else {
     
db_query("INSERT INTO {uc_order_multicurrency} (order_id, region, currency_sign) VALUES (%d, %d, '%s')",
              
$arg1->order_id, $_SESSION['region'], $_SESSION['currency_sign']);
    }
  }
  if (
$op == 'load') {
   
$multicurrency = db_fetch_array(db_query("SELECT region, currency_sign FROM {uc_order_multicurrency} WHERE order_id = %d",
                                            
$arg1->order_id));
   
$arg1->mc_region = $multicurrency['region'];
   
$arg1->mc_currency_sign = $multicurrency['currency_sign'];
  }
}
?>

And... then this is where we get to the unfortunate part. Remember that price handler above, where we pull the order information out of the context? Unfortunately, Ubercart is really inconsistent about providing us that needed information, even when the call to uc_price is happening in a function where $order is already just sitting there, all loaded up. So, I'd like to introduce these patch files, which are provided to you inside of uc_example.zip:

  • ubercart-621494.patch
  • ubercart-display-symbols.patch
  • ubercart-order-price.patch

These three patch files need to be applied to Ubercart, and force it to provide the $order as part of the $context when uc_price is being called. If you look through these files, you'll see that most of the time this $order object is already there - I'm just adding it to the $context['subject'] array. (Unfamiliar with patches? Copy the three files to the Ubercart directory, change your directory to that Ubercart directory, and then execute patch -p0 < {filename} for each one.)

Enclosed is a module that contains the changes I've mentioned above. It assumes that you've used cck to add a 'price_us' and 'price_euro' field to the product nodes, but otherwise you can use it as is, plug it into a test Ubercart installation, run, and try it out. The patches are only necessary when viewing a finalized order, particularly in the admin area, so you don't even need to use that when just experimenting with the code.

AttachmentSize
uc_example.zip7.1 KB