Why not combine shopping carts on user login?

When I first wrote Ubercart's Cart module, we knew we were going to support both anonymous and authenticated shopping carts and checkout. The decision came at a time when there wasn't consensus around the impact of forced login on conversions, but we knew we wanted it to be optional if at all possible. Additionally, for authenticated users, we wanted to preserve items in their shopping carts so they would see the same items when logging in from multiple devices or across multiple sessions.

This resulted in a small conflict that we had to figure out how to deal with: users could have items in their authenticated shopping carts but browse the site anonymously, create a new shopping cart, and then log in. What should happen to the items in their authenticated carts vs. the items in their anonymous carts?

There are three basic resolutions: combine the shopping carts together so the user still has a single shopping cart, remove the items from the previous session and leave it up to the customer to find them again if desired, or retain the old shopping cart but ignore it until the customer has completed checkout for the current cart. In Ubercart, I chose to combine the items, but in Drupal Commerce I changed course to retain the old cart but, from the customer's point of view, treat that anonymously created cart as the current cart after login.

We got some push back for this decision, but ultimately I didn't change the default functionality of Drupal Commerce. We just made sure there was an appropriate hook (hook_commerce_cart_order_convert()) so developers could alter this behavior on a site-by-site basis as need be.

From the merchant's standpoint, the thinking behind combining carts goes that you don't want customers to forget they intended to purchase those products in the past. However, from the customer's standpoint, suddenly having additional items in the cart after logging in during the checkout process is quite jarring.

In fact, I've been bitten by this behavior when shopping online at Barnes & Noble. Weeks prior to placing an order, I had put a Wheel of Time novel in my shopping cart but eventually bought the book in store. When I came back to the site to purchase a gift for my wife, I used a login button on the checkout form to quickly reuse my previous addresses and payment details. Unbeknownst to me, the website combined my old shopping cart with my current one such that my "quick checkout" experience made me accidentally order a book I already owned! I then had to spend 30 minutes with customer service canceling the order and placing it afresh just for the book I actually wanted.

That experience confirmed in my mind we made the correct decision not to combine carts automatically. As eCommerce framework developers, we have no clue where a developer might like to integrate login during the checkout process. Best to let them decide if it's safe to do something with those previous cart items instead of silently making the decision for them.

That said, I believe we can improve the experience even further. Right now, Drupal Commerce retains the old shopping cart order, and after the customer completes checkout they'll see the previous shopping cart as their current cart. This can be confusing as well!

My ideal situation would likely be a user interface component on the shopping cart page where customers can see items they had added to their carts in previous sessions, giving them the option to add those products to their current carts. If they decide not to, I don't see any harm in then just deleting those historical carts and moving on.

There's always room for improvement. Smile

Photo credit: alphageek

Getting to work on a stronger Drupal Commerce 2.x

I don't like being sandy but I love building sand castles. The potential is high but the stakes are low. You have unlimited raw material to build whatever you can imagine, and if you screw up you can just knock it over (to the delight of your children) and start again.

When your constant is impermanence, you adjust your expectations accordingly. If the sands shift or the tide rises, you shrug it off and start again or go back to bocce. However, if you were somehow bound by the fruit of your labor, you'd think twice about the when, where, and how of your building.

Lyle and I started developing Ubercart when Drupal 5 was still in beta, and we put up with the impermanence because we were just two dudes learning a lot and working from a clean slate.

Moving that forward to Drupal 6 took even longer because our codebase grew unwieldy, so I decided at Commerce Guys to trim the fat and start over. We didn't learn every lesson moving to Drupal 7, though, as it was still unstable when we began developing Drupal Commerce. With an unstable Views module. And an unstable Rules module. And an incomplete Entity API. Tongue

I don't think we would've done any differently, as the flip side of the instability is the opportunity to positively impact the development of the projects you depend on. For Drupal 8 we ended up contributing to core in other ways while tackling a full Drupal Commerce rewrite more slowly than we hoped. Even the code that we did develop against Drupal 8 is now outdated, as we had to juggle managing our existing ecosystem, writing new code, and rewriting that code to track changes in Drupal 8 itself.

However, we've recently been given the opportunity to host a variety of developers / documenters in Paris from June 30 - July 4 to re-evaluate our Commerce 2.x roadmap with the direct help and guidance of key members of the Symfony project. Specifically, we'll be looking to both move our code "upstream" from our Drupal modules into generalized libraries and take advantage of existing Symfony projects where possible.

Our target is an even leaner codebase with connections to the broader world of PHP based eCommerce. If you have any insights in this direction, please comment or contact me directly and consider joining us in Paris to learn and contribute.

Photo credit: j.s. clark

Creating a custom Add to Cart form with Drupal Commerce

I'm currently helping some friends rebuild the theological education website, BiblicalTraining.org, in Drupal 7 with Drupal Commerce. I built the Drupal 6 version some years ago to move the website from a bespoke PHP application into Drupal, using modules like Ubercart, Quiz, and Organic Groups to solve most of the requirements. However, the donation code was more or less a straight Drupal port of the PHP script handling donations via PayTrace at the time.

With the rebuild onto Drupal 7, we have the opportunity to unify the donation form with the course payment checkout form to begin using Commerce Reports and multiple payment methods, including the newly released Commerce PayPal 2.0 for Express Checkout payments. However, we still have to deal with actually creating an order to represent the donation payment on the checkout form.

On this site, we don't use product display nodes. I decided instead to directly instantiate the Add to Cart form at a custom URL and avoid the need to create a product display node type just for the one form.

I started by defining a donation product type so the site administrators could create a product to represent the various campaigns donors could give toward. Since I am building the form in a small page callback function, I can easily support as many donation products as get created without introducing the human process of making sure administrators both add the product and update a product display node to reference it.

I created a single menu item at /donate whose page callback is the following:

<?php
/**
 * Page callback: builds a donation Add to Cart form.
 */
function bt_donation_form_page() {
 
// Create the donation line item defaulted to the General fund.
 
$line_item = commerce_product_line_item_new(commerce_product_load(5), 1, 0, array('context' => array('display_path' => 'donate')), 'donation');
 
$wrapper = entity_metadata_wrapper('commerce_line_item', $line_item);

 
// Set the line item context to reference all of the donation products.
 
$query = new EntityFieldQuery();
 
$query
   
->entityCondition('entity_type', 'commerce_product')
    ->
entityCondition('bundle', 'donation')
    ->
propertyCondition('status', TRUE);

 
$result = $query->execute();

  if (!empty(
$result['commerce_product'])) {
   
$line_item->data['context']['product_ids'] = array_keys($result['commerce_product']);
  }

 
// Do not allow the Add to Cart form to combine line items.
 
$line_item->data['context']['add_to_cart_combine'] = FALSE;

  return
drupal_get_form('commerce_cart_add_to_cart_form', $line_item);
}
?>

To build an Add to Cart form, you have to pass in a line item object that contains all the information required to build the form. Our page callback starts by building a donation product line item, a custom line item type that allows for custom donation amounts as demonstrated in this tutorial video from Randy Fay. The product the line item references, "General fund", will be the default selection on the Add to Cart form, and the context array I pass to the line item creation function populates the line item's display_path field to link this line item to the donation page.

Next I use a simple EntityFieldQuery to find all the enabled donation products on the site. These product IDs are added to the line item's build context array, which the Add to Cart form builder function uses to know which products the form should represent. In a product display scenario, these product IDs would come from the value of the product reference field. Here I pass them in directly and get a simple Add to Cart form that is now ready to be themed to perfection:


Cameo: Select or Other powers the "Other" option here.

Additional improvements on the site involve changing the "Add to Cart" button to read "Donate Now" and using a custom message upon submission with a redirect to /checkout. In case the donor cancels checkout and ends up at /cart, I do two things to ensure they can't manipulate the quantity of the donation line items: I removed the quantity textfield field from the View, but in case we need to add it back in later for other line item types, I also use a form alter to convert any donation line item quantity textfield to the plain text quantity value:

<?php
/**
 * Implements hook_form_FORM_ID_alter().
 */
function bt_donation_form_views_form_commerce_cart_form_default_alter(&$form, &$form_state) {
 
// Loop over the quantity textfields on the form.
 
foreach (element_children($form['edit_quantity']) as $key) {
   
$line_item_id = $form['edit_quantity'][$key]['#line_item_id'];
   
$line_item = commerce_line_item_load($line_item_id);
   
   
// If it's for a donation line item...
   
if ($line_item->type == 'donation') { 
     
// Turn it into a simple text representation of the quantity.
     
$form['edit_quantity'][$key]['#type'] = 'value';
     
$form['edit_quantity'][$key]['#suffix'] = check_plain($form['edit_quantity'][$key]['#default_value']);
    }
  }
}
?>

This might actually make a handy contrib module...

If the donor leaves a donation line item in the cart and goes back to the donation form, you'll notice toward the end of my page callback that I also indicate in the context array that the form should not attempt to combine like items during the Add to Cart submission process. I actually realized the form builder function was missing some documentation for context keys, so I added those in straightaway.

All told, I spent a couple hours building a custom donation form and workflow that now perfectly integrates with the checkout process used by the rest of the site. This will make it easier to customize and maintain long term, and it allows us to use existing Drupal Commerce payment method modules to manage donations instead of having to write and maintain a custom payment module for the task.

Bypassing Drupal Commerce customer profile duplication

In Drupal Commerce, we deal in entities, fields, and field-based relationships between entities (i.e. references). The extent to which we implemented our data model on these systems from the earliest days of Drupal 7 is what grants Drupal Commerce developers a level of flexibility previously unavailable to eCommerce developers in general. Cool, right? Wink

Unfortunately, the Drupal entity trade can be dangerous when dealing with historical data that isn't supposed to change - when a reference should do more than merely "refer" but also express constraints.

Such is the case with customer profile references on orders.

Once customer information (e.g. a billing or shipping address) is entered for an order, we don't want the order to lose that information at a later date. This means we have to prevent not just the deletion of the referenced customer profiles but also changes to them - the latter because our references are to entitiies, not revisions of entities.

Side note: honestly, I'm fine with that - I can't imagine a reasonable UI or update strategy for entity revision references. It's hard enough to keep straight as is. Tongue

The most common place users encounter customer profile duplication is when they go to update a customer profile through the UI. If any field values are changed, the Order module will prevent the update if it detects the customer profile is referenced by a non-shopping cart order. It empties the profile IDs in a presave hook, forcing the save to create a new customer profile instead of updating the existing one.

This same process affects not just customer profiles being edited through the UI but also customer profiles being updated through code. However, if you combine the fact that customer profiles have revisions with the absence of the duplication related messages you see when performing the operation in the UI, it's easy to see how this functionality might appear to developers as a case of revisions gone awry.

There are a couple of functions developers can refer to to read comments describing the process and see the implementation itself:

  • commerce_order_commerce_customer_profile_presave()
  • commerce_order_commerce_customer_profile_can_delete()

It's worth noting that we've coded this functionality to be paranoid - if there's a slight chance something substantive may have changed in the field data, we force duplication instead of updating the original. Better to duplicate than to lose vital data.

Still, the keen observer will note that we actually do permit customer profiles to be updated through the UI on one condition. If an administrator edits a customer profile through the edit form of the sole order referencing the profile, we permit the update.

You may have a need in custom code to perform an update to a customer profile field that you know does not affect the historical record in a substantive way. Maybe it's simply a matter of changing some internal field that has no bearing on the fulfillment of orders. In such cases, to bypass customer profile duplication, you can imitate the process the order edit form uses to identify an order as safe to delete.

To do this, add a temporary "entity_context" property to the customer profile object. This is the property the Order module looks for in the presave hook to determine if the customer profile is being edited in the context of its sole referencing order. If you properly identify this order in the entity_context, the Order module will permit the update to occur without duplication:

<?php
$profile
= commerce_customer_profile_load(1);
$wrapper = entity_metadata_wrapper('commerce_customer_profile', $profile);
$wrapper->field_referral_source = 'MySpace';
$profile->entity_context = array(
 
'entity_type' => 'commerce_order',
 
'entity_id' => 1,
);
commerce_customer_profile_save($profile);
?>

Obviously, this is obtuse.

In Commerce 2.x, we'll do well to improve the developer experience here. It gets even worse if you want to make such an update on a site using Commerce Addressbook where you do want to update a customer profile referenced by multiple orders without enacting duplication. In fact, until I wrote this post, there would have been no way to achieve this short of a miraculous hook_query_alter().

This whole post came out of a quick e-mail exchange with Forest Mars, whom I'm looking forward to meeting at next week's Florida DrupalCamp (you going?). Since I've had a chance to think about the issue in a longer form post, I'm going to go ahead and add a query tag so it's at least possible to target the "can delete" query without committing developer sins.

And that is how e-mail and blogging improve open source projects, my friends! Cool

Pages