diff --git a/payment/uc_payment/uc_payment.module b/payment/uc_payment/uc_payment.module
index 2527560..6e210f6 100644
--- a/payment/uc_payment/uc_payment.module
+++ b/payment/uc_payment/uc_payment.module
@@ -248,7 +248,9 @@ function uc_payment_uc_order_state() {
  *   The formatted HTML of the order total preview if $return is set to TRUE.
  */
 function uc_payment_get_totals($form, $form_state) {
-  return $form['panes']['payment']['line_items'];
+  $commands[] = ajax_command_replace('#line-items-div', trim(drupal_render($form['panes']['payment']['line_items'])));
+
+  return array('#type' => 'ajax', '#commands' => $commands);
 }
 
 /**
diff --git a/payment/uc_payment/uc_payment_checkout_pane.inc b/payment/uc_payment/uc_payment_checkout_pane.inc
index 35a280d..4da6a32 100644
--- a/payment/uc_payment/uc_payment_checkout_pane.inc
+++ b/payment/uc_payment/uc_payment_checkout_pane.inc
@@ -69,6 +69,8 @@ function uc_checkout_pane_payment($op, &$order, $form = NULL, &$form_state = NUL
             'type' => 'throbber',
           ),
         ),
+        '#prefix' => '<div id="payment-method">',
+        '#suffix' => '</div>',
       );
 
       $contents['details'] = array(
diff --git a/payment/uc_payment/uc_payment_order_pane.inc b/payment/uc_payment/uc_payment_order_pane.inc
index 7681c11..bdfa60b 100644
--- a/payment/uc_payment/uc_payment_order_pane.inc
+++ b/payment/uc_payment/uc_payment_order_pane.inc
@@ -122,7 +122,7 @@ function uc_order_pane_payment($op, $order, &$form = NULL, &$form_state = NULL)
  * AJAX callback to render the payment method pane.
  */
 function uc_payment_order_pane_ajax_callback($form, &$form_state) {
-  $commands[] = ajax_command_replace('#payment-details', drupal_render($form['payment']['payment_details']));
+  $commands[] = ajax_command_replace('#payment-details', trim(drupal_render($form['payment']['payment_details'])));
   $commands[] = ajax_command_prepend('#payment-details', theme('status_messages'));
   return array('#type' => 'ajax', '#commands' => $commands);
 }
diff --git a/shipping/uc_quote/uc_quote.module b/shipping/uc_quote/uc_quote.module
index c331601..c1af46f 100644
--- a/shipping/uc_quote/uc_quote.module
+++ b/shipping/uc_quote/uc_quote.module
@@ -241,75 +241,6 @@ function uc_quote_form_alter(&$form, &$form_state, $form_id) {
 }
 
 /**
- * Implements hook_form_FORM_ID_alter() for uc_cart_checkout_form().
- *
- * Adds Ajax shipping quote functionality to the checkout form.
- */
-function uc_quote_form_uc_cart_checkout_form_alter(&$form, &$form_state) {
-  if (isset($form['panes']['delivery']['address'])) {
-    $form['panes']['delivery']['address']['#process'] = array('uc_store_process_address_field', 'uc_quote_process_checkout_address');
-  }
-  if (isset($form['panes']['delivery']['select_address'])) {
-    $form['panes']['delivery']['select_address']['#ajax']['callback'] = 'uc_quote_select_address';
-  }
-  // @todo: Figure out what needs to be done when copy-address box is checked.
-
-  if (isset($form['panes']['payment']['line_items'])) {
-    $form['panes']['quotes']['quotes']['quote_option']['#ajax'] = array(
-      'callback' => 'uc_payment_get_totals',
-      'wrapper' => 'line-items-div',
-    );
-  }
-}
-
-/**
- * Ajax callback: Updates quotes when a saved address is selected.
- */
-function uc_quote_select_address($form, $form_state) {
-  $return = uc_quote_checkout_returned_rates($form, $form_state);
-  $address = uc_checkout_pane_address_render($form, $form_state);
-
-  $return['#commands'][] = ajax_command_replace('#' . $form_state['triggering_element']['#ajax']['wrapper'], drupal_render($address));
-
-  return $return;
-}
-
-/**
- * Ajax callback: Updates shipping quotes when the delivery country changes.
- */
-function uc_quote_country_change_wrapper(&$form, &$form_state) {
-  $return = uc_quote_checkout_returned_rates($form, $form_state);
-  $return['#commands'][] = ajax_command_replace('#uc-store-address-delivery-zone-wrapper', drupal_render(uc_store_update_address_field_zones($form, $form_state)));
-  return $return;
-}
-
-/**
- * Process callback: Adds Ajax functionality to delivery address fields.
- */
-function uc_quote_process_checkout_address($element, $form_state) {
-  $ajax = array(
-    'callback' => 'uc_quote_checkout_returned_rates',
-    'effect' => 'slide',
-    'progress' => array(
-      'type' => 'throbber',
-      'message' => t('Receiving shipping quotes...'),
-    ),
-  );
-
-  if (isset($element['delivery_postal_code'])) {
-    $element['delivery_postal_code']['#ajax'] = $ajax;
-  }
-  // The following replaces "uc_store_process_address_field" from uc_store
-  // with a wrapper that will update the available quotes when the country
-  // is changed.
-  if (isset($element['delivery_country'])) {
-    $element['delivery_country']['#ajax']['callback'] = 'uc_quote_country_change_wrapper';
-  }
-
-  return $element;
-}
-
-/**
  * Implements hook_uc_cart_pane().
  */
 function uc_quote_uc_cart_pane($items) {
@@ -617,7 +548,9 @@ function uc_checkout_pane_quotes($op, &$order, $form = NULL, &$form_state = NULL
         '#submit' => array('uc_quote_checkout_pane_quotes_submit'),
         '#weight' => 0,
         '#ajax' => array(
-          'callback' => 'uc_quote_checkout_returned_rates',
+          // The callbacks are set below in $form_state['uc_ajax_attach']
+          //'callback' => 'uc_ajax_replace_checkout_pane',
+          //'wrapper' => 'quotes-pane',
           'effect' => 'slide',
           'progress' => array(
             'type' => 'bar',
@@ -636,6 +569,13 @@ function uc_checkout_pane_quotes($op, &$order, $form = NULL, &$form_state = NULL
 
       $contents['quotes'] += $order->quote_form;
 
+      $ajax_attach = array(
+        'payment-pane' => 'uc_ajax_replace_checkout_pane',
+        'quotes-pane' => 'uc_ajax_replace_checkout_pane'
+      );
+      $form_state['uc_ajax']['uc_quote']['panes][quotes][quote_button'] = $ajax_attach;
+      $form_state['uc_ajax']['uc_quote']['panes][quotes][quotes][quote_option'] = array('payment-pane' => 'uc_ajax_replace_checkout_pane');
+
       return array('description' => $description, 'contents' => $contents);
 
     case 'prepare':
@@ -736,7 +676,8 @@ function uc_order_pane_quotes($op, $order, &$form = NULL, &$form_state = NULL) {
         '#value' => t('Get shipping quotes'),
         '#submit' => array('uc_quote_order_pane_quotes_submit'),
         '#ajax' => array(
-          'callback' => 'uc_quote_order_returned_rates',
+          'callback' => 'uc_quote_replace_order_quotes',
+          'wrapper' => 'quote',
           'effect' => 'slide',
           'progress' => array(
             'type' => 'bar',
@@ -760,6 +701,8 @@ function uc_order_pane_quotes($op, $order, &$form = NULL, &$form_state = NULL) {
         );
       }
 
+      $form_state['uc_ajax']['uc_quote']['ship_to][delivery_country'] = array('quote' => 'uc_quote_replace_order_quotes');
+
       return $form;
 
     case 'edit-theme':
@@ -900,8 +843,8 @@ function uc_quote_build_quote_form($order, $show_errors = TRUE) {
  * Ajax callback: Shows estimated shipping quotes on the cart page.
  */
 function uc_quote_cart_returned_rates($form, $form_state) {
-  $commands[] = ajax_command_replace('#quote', drupal_render($form['quote']));
-  $commands[] = ajax_command_prepend('#quote', theme('status_messages'));
+  $commands[] = ajax_command_replace('#quote', trim(drupal_render($form['quote'])));
+  $commands[] = ajax_command_prepend('#quote', trim(theme('status_messages')));
 
   return array('#type' => 'ajax', '#commands' => $commands);
 }
@@ -929,29 +872,10 @@ function _uc_quote_extract_default_option($quote_form) {
 }
 
 /**
- * AJAX callback for calculated shipping rates.
+ * Ajax callback to update the quotes on the order edit form.
  */
-function uc_quote_checkout_returned_rates($form, $form_state) {
-  $commands[] = ajax_command_replace('#quote', drupal_render($form['panes']['quotes']['quotes']));
-  $commands[] = ajax_command_prepend('#quote', theme('status_messages'));
-
-  // Show default shipping rate as a line item.
-  if (isset($form['panes']['payment']['line_items'])) {
-    $commands[] = ajax_command_replace('#line-items-div', drupal_render($form['panes']['payment']['line_items']));
-    $commands[] = ajax_command_prepend('#line-items-div', theme('status_messages'));
-  }
-
-  return array('#type' => 'ajax', '#commands' => $commands);
-}
-
-/**
- * AJAX callback for calculated shipping rates.
- */
-function uc_quote_order_returned_rates($form, $form_state) {
-  $commands[] = ajax_command_replace('#quote', drupal_render($form['quotes']['quotes']));
-  $commands[] = ajax_command_prepend('#quote', theme('status_messages'));
-
-  return array('#type' => 'ajax', '#commands' => $commands);
+function uc_quote_replace_order_quotes($form, $form_state) {
+  return $form['quotes']['quotes'];
 }
 
 /**
diff --git a/uc_ajax_admin/uc_ajax_admin.info b/uc_ajax_admin/uc_ajax_admin.info
new file mode 100644
index 0000000..edba546
--- /dev/null
+++ b/uc_ajax_admin/uc_ajax_admin.info
@@ -0,0 +1,5 @@
+name = Ubercart Ajax Administration
+description = Administrative interface for ajax updates to Ubercart forms.
+dependencies[] = uc_cart
+package = Ubercart - extra
+core = 7.x
diff --git a/uc_ajax_admin/uc_ajax_admin.module b/uc_ajax_admin/uc_ajax_admin.module
new file mode 100644
index 0000000..1ae9557
--- /dev/null
+++ b/uc_ajax_admin/uc_ajax_admin.module
@@ -0,0 +1,243 @@
+<?php
+
+/**
+ * Implements hook_menu().
+ */
+function uc_ajax_admin_menu() {
+  $items = array();
+  $items['admin/store/settings/checkout/ajax'] = array(
+    'title' => 'Ajax',
+    'description' => 'Administer ajax updates on checkout form.',
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('uc_ajax_admin_form', 'checkout'),
+    'access arguments' => array('administer store'),
+    'type' => MENU_LOCAL_TASK,
+    'weight' => 3,
+  );
+  return $items;
+}
+
+/**
+ * Administration form for uc_ajax.
+ *
+ * @param $target_form
+ *   The form for which ajax behaviors are to be administered. Currently only
+ *   'checkout' is supported.
+ */
+function uc_ajax_admin_form($form, &$form_state, $target_form = 'checkout') {
+  module_load_include('inc', 'uc_store', 'includes/uc_ajax_attach');
+  switch ($target_form) {
+    case 'checkout':
+      $triggers = _uc_ajax_admin_checkout_trigger_options(_uc_ajax_admin_build_checkout_form());
+      $panes = _uc_checkout_pane_list();
+      $wrappers = array();
+      foreach ($panes as $id => $pane) {
+        $wrappers["$id-pane"] = _uc_checkout_pane_data($id, 'title');
+      }
+      break;
+
+    default:
+      drupal_not_found();
+  }
+  $form['#uc_ajax_target'] = $target_form;
+  $form['#uc_ajax_config'] = variable_get('uc_ajax_' . $target_form, _uc_ajax_defaults($target_form));
+
+  $instructions =  '<p>' . t('Use this page to configure "Ajax" behaviors on the @target_form form. The table below
+    associates triggering form input elements with @target_form panes. The contents of each associated pane
+    will be refreshed whenever the customer clicks on or changes the triggering form element. For example, you
+    can cause the available payment methods to be refreshed when the customer changes their billing zone.',
+    array('@target_form' => $target_form))
+    . '</p>';
+  $instructions .= '<p>' . t("Note that the triggering elements you can choose are listed based on the
+    @target_form form as it would be displayed to you right now. For example, if none of your shipping methods
+    apply to the current cart contents, you won't see the shipping quote selection element. If you don't see
+    the form element you wish to use as a trigger, try adding some products to the shopping cart or otherwise
+    simulating the customer experience, and verify that those elements are present on the form itself.",
+    array('@target_form' => $target_form))
+    . '</p>';
+
+  $form['instructions'] = array(
+    '#type' => 'item',
+    '#title' => t('Checkout form Ajax Behaviors'),
+    '#markup' => $instructions,
+  );
+  $form['table'] = tapir_get_table('uc_ajax_admin_table', $triggers, $wrappers, $form['#uc_ajax_config']);
+  $form['actions'] = array(
+    '#type' => 'actions',
+    'submit' => array(
+      '#type' => 'submit',
+      '#value' => t('Submit'),
+    ),
+  );
+  return $form;
+}
+
+/**
+ * Submit handler for the uc_ajax_admin form.
+ */
+function uc_ajax_admin_form_submit($form, &$form_state) {
+  $config = $form['#uc_ajax_config'];
+  foreach ($form_state['values']['table'] as $index => $entry) {
+    $key = $entry['key'];
+    if ($index === '_new') {
+      if (!empty($key) && !empty($entry['panes'])) {
+        $config[$key] = $entry['panes'];
+      }
+    }
+    elseif ($entry['remove'] || empty($entry['panes'])) {
+      unset($config[$key]);
+    }
+    else {
+      $config[$key] = $entry['panes'];
+    }
+  }
+  variable_set('uc_ajax_' . $form['#uc_ajax_target'], $config);
+  drupal_set_message(t('Your changes have been saved.'));
+}
+
+/**
+ * TAPIr table callback for the uc_ajax administrative form.
+ *
+ * @param $trigger_options
+ *   The select options for triggering elements.
+ * @param $wrapper_options
+ *   The select options for wrappers.
+ * @param $config
+ *   The existing configuration.
+ */
+function uc_ajax_admin_table($trigger_options, $wrapper_options, $config) {
+  $rows = array();
+  foreach ($config as $key => $panes) {
+    list(, $pane) = explode('][', $key);
+    $rows[] = array(
+      'key' => array(
+        '#type' => 'hidden',
+        '#value' => $key,
+        '#suffix' => empty($trigger_options[ucfirst($pane)][$key]) ? $key : ucfirst($pane) . ': ' . $trigger_options[ucfirst($pane)][$key],
+      ),
+      'panes' => array(
+        '#type' => 'select',
+        '#options' => $wrapper_options,
+        '#default_value' => $panes,
+        '#multiple' => TRUE,
+      ),
+      'remove' => array(
+        '#type' => 'checkbox',
+        '#default_value' => FALSE,
+      ),
+    );
+  }
+  $rows['_new'] = array(
+    'key' => array(
+      '#type' => 'select',
+      '#options' => array(0 => t('--Add a new element--')) + $trigger_options,
+    ),
+    'panes' => array(
+      '#type' => 'select',
+      '#options' => $wrapper_options,
+      '#multiple' => TRUE,
+    ),
+    'remove' => array(
+      '#type' => 'hidden',
+      '#value' => 0,
+    ),
+  );
+
+  $table = array(
+    '#type' => 'tapir_table',
+    '#tree' => TRUE,
+    '#columns' => array(
+      'remove' => array(
+        'cell' => t('Remove'),
+        'weight' => 3,
+      ),
+      'key' => array(
+        'cell' => t('Triggering form element'),
+        'weight' => 1,
+      ),
+      'panes' => array(
+        'cell' => t('Panes to update'),
+        'weight' => 2,
+      ),
+    ),
+  ) + $rows;
+
+  return $table;
+}
+
+/**
+ * Recursively builds a list of all form elements which are suitable triggers
+ * for ajax updates.
+ *
+ * @param $element
+ *   The element to check.
+ * @param $list
+ *   The list being built.  When complete will be an array of the form
+ *     'element_name' => 'Element title'
+ *   where 'element_name' is the name of the element as would be specified for
+ *   form_set_error(), and 'Element title' is a best guess at the human readable
+ *   name of the element.
+ */
+function _uc_ajax_admin_list_triggers($element, &$list) {
+  if (!empty($element['#input']) && !in_array($element['#type'], array('hidden', 'uc_address'))) {
+    $key = implode('][', $element['#array_parents']);
+    switch ($element['#type']) {
+      case 'button': case 'submit':
+        $title = empty($element['#value']) ? $key : $element['#value'];
+        break;
+      default:
+        $title = empty($element['#title']) ? $key : $element['#title'];
+    }
+    $list[$key] = $title;
+  }
+  if (empty($element['#type']) || !in_array($element['#type'], array('radios', 'checkboxes'))) {
+    foreach (element_children($element) as $child) {
+      _uc_ajax_admin_list_triggers($element[$child], $list);
+    }
+  }
+}
+
+/**
+ * Builds a hierarchical list of possible ajax triggers for the checkout form.
+ *
+ * @param $form
+ *   The fully processed checkout form to search for triggers.
+ *
+ * @return
+ *   An hierarchical array of select options, categorized by pane.
+ */
+function _uc_ajax_admin_checkout_trigger_options($form) {
+  $list = array();
+  foreach (element_children($form['panes']) as $name) {
+    $group = ucfirst($name);
+    $list[$group] = array();
+    _uc_ajax_admin_list_triggers($form['panes'][$name], $list[$group]);
+    if (empty($list[$group])) {
+      unset($list[$group]);
+    }
+  }
+  return $list;
+}
+
+/**
+ * Builds the checkout form, using the cart order if it exists, or a default
+ * shippable order if not.
+ */
+function _uc_ajax_admin_build_checkout_form() {
+  module_load_include('inc', 'uc_cart', 'uc_cart.pages');
+  $order = empty($_SESSION['cart_order']) ? FALSE : uc_order_load($_SESSION['cart_order']);
+  if (!$order) {
+    $order = new UcOrder();
+    $order->products = array((object) array(
+      'cart_item_id' => 0,
+      'title' => 'fake',
+      'nid' => 0,
+      'qty' => 1,
+      'price' => 1,
+      'data' => array('shippable' => TRUE),
+      'model' => 0,
+      'weight' => 0
+    ));
+  }
+  return drupal_get_form('uc_cart_checkout_form', $order);
+}
diff --git a/uc_cart/uc_cart.pages.inc b/uc_cart/uc_cart.pages.inc
index 5d7968b..ad4a256 100644
--- a/uc_cart/uc_cart.pages.inc
+++ b/uc_cart/uc_cart.pages.inc
@@ -158,8 +158,9 @@ function uc_cart_checkout() {
  * @param $order
  *   The order that is being checked out.
  *
+ * @see uc_cart_checkout_form_process()
  * @see uc_cart_checkout_form_validate()
- * @see uc_cart_checkout_form_review()
+ * @see uc_cart_checkout_form_submit()
  * @see uc_cart_checkout_review()
  * @see theme_uc_cart_checkout_form()
  * @ingroup forms
@@ -279,6 +280,9 @@ function uc_cart_checkout_form($form, &$form_state, $order) {
     '#value' => t('Review order'),
   );
 
+  form_load_include($form_state, 'inc', 'uc_store', 'includes/uc_ajax_attach');
+  $form['#process'][] = 'uc_ajax_process_form';
+
   unset($_SESSION['uc_checkout'][$order->order_id]);
 
   return $form;
diff --git a/uc_order/uc_order.admin.inc b/uc_order/uc_order.admin.inc
index ec06c97..62df9d7 100644
--- a/uc_order/uc_order.admin.inc
+++ b/uc_order/uc_order.admin.inc
@@ -1058,6 +1058,9 @@ function uc_order_edit_form($form, &$form_state, $order) {
 
   field_attach_form('uc_order', $order, $form, $form_state);
 
+  form_load_include($form_state, 'inc', 'uc_store', 'includes/uc_ajax_attach');
+  $form['#process'][] = 'uc_ajax_process_form';
+
   return $form;
 }
 
diff --git a/uc_order/uc_order.order_pane.inc b/uc_order/uc_order.order_pane.inc
index 04f7d3e..d2929c6 100644
--- a/uc_order/uc_order.order_pane.inc
+++ b/uc_order/uc_order.order_pane.inc
@@ -591,13 +591,13 @@ function uc_order_edit_products_remove($form, &$form_state) {
  * AJAX callback to render the order product controls.
  */
 function uc_order_pane_products_ajax_callback($form, &$form_state) {
-  $commands[] = ajax_command_replace('#product-controls', drupal_render($form['product_controls']));
-  $commands[] = ajax_command_prepend('#product-controls', theme('status_messages'));
+  $commands[] = ajax_command_replace('#product-controls', trim(drupal_render($form['product_controls'])));
+  $commands[] = ajax_command_prepend('#product-controls', trim(theme('status_messages')));
 
   if (isset($form_state['refresh_products']) && $form_state['refresh_products']) {
-    $commands[] = ajax_command_replace('#order-edit-products', drupal_render($form['products']));
-    $commands[] = ajax_command_replace('#order-line-items', drupal_render($form['line_items']));
-    $commands[] = ajax_command_prepend('#order-edit-products', theme('status_messages'));
+    $commands[] = ajax_command_replace('#order-edit-products', trim(drupal_render($form['products'])));
+    $commands[] = ajax_command_replace('#order-line-items', trim(drupal_render($form['line_items'])));
+    $commands[] = ajax_command_prepend('#order-edit-products', trim(theme('status_messages')));
   }
 
   return array('#type' => 'ajax', '#commands' => $commands);
@@ -824,7 +824,7 @@ function uc_order_pane_line_items_remove($form, &$form_state) {
  * AJAX callback to render the line items.
  */
 function uc_order_pane_line_items_update($form, &$form_state) {
-  $commands[] = ajax_command_replace('#order-line-items', drupal_render($form['line_items']));
+  $commands[] = ajax_command_replace('#order-line-items', trim(drupal_render($form['line_items'])));
   $commands[] = ajax_command_prepend('#order-line-items', theme('status_messages'));
 
   return array('#type' => 'ajax', '#commands' => $commands);
diff --git a/uc_store/includes/uc_ajax_attach.inc b/uc_store/includes/uc_ajax_attach.inc
new file mode 100644
index 0000000..973721a
--- /dev/null
+++ b/uc_store/includes/uc_ajax_attach.inc
@@ -0,0 +1,231 @@
+<?php
+/**
+ * @file
+ * Contains logic to aid in attaching multiple ajax behaviors to form
+ * elements on the checkout and order-edit forms.
+ *
+ * Both the checkout and the order edit forms are made up of multiple panes,
+ * many supplied by contrib modules. Any pane may wish to update its own
+ * display or that of another pane based on user input from input elements
+ * anywhere on the form. The mechanism here described enables modules
+ * to add ajax behaviors to the form in an orderly and efficient manner.
+ *
+ * Generally, an implementing pane should not add #ajax keys to existing form
+ * elements directly. Rather, it should attach ajax behavior by adding
+ * to the $form_state['uc_ajax'] array.
+ *
+ * $form_state['uc_ajax'] is an associative array keyed by the name of the
+ * implementing module. Each implementing module should provide an array
+ * of ajax callbacks, keyed by the name of the triggering element as it would
+ * be specified when invoking form_set_error(). The entry for each element
+ * may be either the name of a single ajax callback to be attached to that
+ * element, or an array of ajax callbacks, optionally keyed by wrapper.
+ * For example:
+ *
+ * @code
+ *   $form_state['uc_ajax']['mymodule']['panes][quotes][quote_button'] = array(
+ *      'quotes-pane' => 'uc_ajax_replace_checkout_pane',
+ *   );
+ * @endcode
+ *
+ * This will cause the contents of 'quotes-pane' to be replaced by the return
+ * value of uc_ajax_replace_checkout_pane(). Note that if more than one module
+ * assign a callback to the same wrapper key, the heavier module or pane will
+ * take precedence.
+ *
+ * Implementors need not provide a wrapper key for each callback, in which case
+ * the callback must return an array of ajax commands rather than a renderable
+ * form element. For example:
+ *
+ * @code
+ *   $form_state['uc_ajax']['mymodule']['panes][quotes][quote_button'] = array('my_ajax_callback');
+ *   ...
+ *   function my_ajax_callback($form, $form_state) {
+ *     $commands[] = ajax_command_invoke('#my-input-element', 'val', 0);
+ *     return array('#type' => 'ajax', '#commands' => $commands);
+ *   }
+ * @endcode
+ *
+ * However, using a wrapper key where appropriate will reduce redundant
+ * replacements of the same element.
+ *
+ * NOTE: 'uc_ajax_replace_checkout_pane' is a convenience callback which will
+ * replace the contents of an entire checkout pane. It is generally preferable
+ * to use this when updating data on the checkout form, as this will
+ * further reduce the likelihood of redundant replacements. You should use
+ * your own callback only when behaviours other than replacement are
+ * desired, or when replacing data that lie outside a checkout pane. Note
+ * also that you may combine both formulations by mixing numeric and string keys.
+ * For example:
+ *
+ * @code
+ *   $form_state['uc_ajax']['mymodule']['panes][quotes][quote_button'] = array(
+ *      'my_ajax_callback',
+ *      'quotes-pane' => 'uc_ajax_replace_checkout_pane',
+ *   );
+ * @endcode
+ */
+
+/**
+ * Form process callback to allow multiple Ajax callbacks on form elements.
+ */
+function uc_ajax_process_form($form, &$form_state) {
+  // When processing the top level form, add any variable-defined pane wrappers.
+  if (isset($form['#form_id'])) {
+    switch ($form['#form_id']) {
+      case 'uc_cart_checkout_form':
+        $config = variable_get('uc_ajax_checkout', _uc_ajax_defaults('checkout'));
+        foreach ($config as $key => $panes) {
+          foreach (array_keys($panes) as $pane) {
+            $config[$key][$pane] = 'uc_ajax_replace_checkout_pane';
+          }
+        }
+        $form_state['uc_ajax']['uc_ajax'] = $config;
+        break;
+    }
+  }
+
+  if (!isset($form_state['uc_ajax'])) {
+    return $form;
+  }
+
+  // We have to operate on the children rather than on the element itself, as
+  // #process functions are called *after* form_handle_input_elements(),
+  // which is where the triggering element is determined. If we haven't added
+  // an '#ajax' key by that time, Drupal won't be able to determine which
+  // callback to invoke.
+  foreach (element_children($form) as $child) {
+    $element =& $form[$child];
+
+    // Add this process function recursively to the children.
+    if (empty($element['#process']) && !empty($element['#type'])) {
+      // We want to be sure the default process functions for the element type are called.
+      $info = element_info($element['#type']);
+      if (!empty($info['#process'])) {
+        $element['#process'] = $info['#process'];
+      }
+    }
+    $element['#process'][] = 'uc_ajax_process_form';
+
+    // Multiplex any Ajax calls for this element.
+    $parents = $form['#array_parents'];
+    array_push($parents, $child);
+    $key = implode('][', $parents);
+
+    $callbacks = array();
+    foreach ($form_state['uc_ajax'] as $module => $fields) {
+      if (!empty($fields[$key])) {
+        if (is_array($fields[$key])) {
+          $callbacks = array_merge($callbacks, $fields[$key]);
+        }
+        else {
+          $callbacks[] = $fields[$key];
+        }
+      }
+    }
+
+    if (!empty($callbacks)) {
+      if (empty($element['#ajax'])) {
+        $element['#ajax'] = array();
+      }
+      elseif (!empty($element['#ajax']['callback'])) {
+        if (!empty($element['#ajax']['wrapper'])) {
+          $callbacks[$element['#ajax']['wrapper']] = $element['#ajax']['callback'];
+        }
+        else {
+          array_unshift($callbacks, $element['#ajax']['callback']);
+        }
+      }
+
+      $element['#ajax'] = array_merge($element['#ajax'], array(
+        'callback' => 'uc_ajax_multiplex',
+        'list' => $callbacks,
+      ));
+    }
+  }
+
+  return $form;
+}
+
+/**
+ * Ajax callback multiplexer.
+ *
+ * Processes a set of Ajax commands attached to the triggering element.
+ */
+function uc_ajax_multiplex($form, $form_state) {
+  $element = $form_state['triggering_element'];
+  if (!empty($element['#ajax']['list'])) {
+    $commands = array();
+    foreach ($element['#ajax']['list'] as $wrapper => $callback) {
+      if (!empty($callback) && function_exists($callback) && $result = $callback($form, $form_state, $wrapper)) {
+        if (is_array($result) && !empty($result['#type']) && $result['#type'] == 'ajax') {
+          // If the callback returned an array of commands, simply add these to the list.
+          $commands = array_merge($commands, $result['#commands']);
+        }
+        elseif (is_string($wrapper)) {
+          // Otherwise, assume the callback returned a string or render-array, and insert it into the wrapper.
+          $html = is_string($result) ? $result : drupal_render($result);
+          $commands[] = ajax_command_replace('#' . $wrapper, trim($html));
+          $commands[] = ajax_command_prepend('#' . $wrapper, theme('status_messages'));
+        }
+      }
+    }
+  }
+  if (!empty($commands)) {
+    return array('#type' => 'ajax', '#commands' => $commands);
+  }
+}
+
+/**
+ * Ajax callback to replace a whole checkout pane.
+ *
+ * @param $form
+ *   The checkout form.
+ * @param $form_state
+ *   The current form state.
+ * @param $wrapper
+ *   Special third parameter passed for uc_ajax callbacks containing the ajax
+ *   wrapper for this callback.  Here used to determine which pane to replace.
+ *
+ * @return
+ *   The form element representing the pane, suitable for ajax rendering. If
+ *   the pane does not exist, or if the wrapper does not refer to a checkout
+ *   pane, returns nothing.
+ */
+function uc_ajax_replace_checkout_pane($form, $form_state, $wrapper = NULL) {
+  if (empty($wrapper) && !empty($form_state['triggering_element']['#ajax']['wrapper'])) {
+    // If $wrapper is absent, then we were not invoked by uc_ajax_multiplex,
+    // so try to use the wrapper of the triggering element's #ajax array.
+    $wrapper = $form_state['triggering_element']['#ajax']['wrapper'];
+  }
+  if (!empty($wrapper)) {
+    list($pane, $verify) = explode('-', $wrapper);
+    if ($verify === 'pane' && !empty($form['panes'][$pane])) {
+      return $form['panes'][$pane];
+    }
+  }
+}
+
+/**
+ * Retrieve the default ajax behaviors for a target form.
+ *
+ * @param $target_form
+ *   The form whose default behaviors are to be retrieved.
+ *
+ * @return
+ *   The array of default behaviors for the form.
+ */
+function _uc_ajax_defaults($target_form) {
+  switch ($target_form) {
+    case 'checkout':
+      $quotes_defaults = drupal_map_assoc(array('payment-pane', 'quotes-pane'));
+      return array(
+        'panes][delivery][address][delivery_country' => $quotes_defaults,
+        'panes][delivery][address][delivery_postal_code' => $quotes_defaults,
+        'panes][delivery][select_address' => $quotes_defaults,
+        'panes][billing][address][billing_country' => array('payment-pane' => 'payment-pane'),
+      );
+    default:
+      return array();
+  }
+}
diff --git a/uc_store/tests/uc_ajax.test b/uc_store/tests/uc_ajax.test
new file mode 100644
index 0000000..dbbf8a3
--- /dev/null
+++ b/uc_store/tests/uc_ajax.test
@@ -0,0 +1,103 @@
+<?php
+
+/**
+ * @file
+ * Tests for the UcAddress class.
+ */
+/**
+ * Tests for the Ubercart Ajax Attach.
+ */
+class UbercartAjaxTestCase extends UbercartTestHelper {
+  public static function getInfo() {
+    return array(
+      'name' => 'Ajax functionality',
+      'description' => 'Ajax update of checkout and order pages.',
+      'group' => 'Ubercart',
+    );
+  }
+
+  /**
+   * Overrides DrupalWebTestCase::setUp().
+   */
+  public function setUp() {
+    module_load_include('inc', 'uc_store', 'includes/uc_ajax_attach');
+    $modules = array('rules_admin', 'uc_payment', 'uc_payment_pack');
+    $permissions = array('administer rules', 'bypass rules access');
+    parent::setUp($modules, $permissions);
+    $this->drupalLogin($this->adminUser);
+  }
+
+  /**
+   * Set a zone-based condition for a particular payment method.
+   *
+   * @param $method
+   *   The method to set (e.g. 'check')
+   * @param $zone
+   *   The zone id (numeric) to check for.
+   * @param $negate
+   *   TRUE to negate the condition.
+   */
+  function addPaymentZoneCondition($method, $zone, $negate = FALSE) {
+    $not = $negate ? 'NOT ' : '';
+    $name = 'uc_payment_method_' . $method;
+    $label = ucfirst($method) . ' conditions';
+    $condition = array(
+      'LABEL' => $label,
+      'PLUGIN' => 'and',
+      'REQUIRES' => array('rules'),
+      'USES VARIABLES' => array(
+      	'order' => array(
+      		'label' => 'Order',
+          'type' => 'uc_order',
+        ),
+      ),
+      'AND' => array(
+        array(
+          $not . 'data_is' => array(
+            'data' => array('order:billing-address:zone'),
+            'value' => $zone,
+          ),
+        ),
+      ),
+    );
+    $newconfig = rules_import(array($name => $condition));
+    $oldconfig = rules_config_load($name);
+    if ($oldconfig) {
+      $newconfig->id = $oldconfig->id;
+      unset($newconfig->is_new);
+      $newconfig->status = ENTITY_CUSTOM;
+    }
+    $newconfig->save();
+    entity_flush_caches();
+    //$this->drupalGet('admin/config/workflow/rules/components/manage/' . $newconfig->id);
+  }
+
+  function testCheckoutAjax() {
+    // Enable two payment methods and set a condition on one.
+    variable_set('uc_payment_method_check_checkout', TRUE);
+    variable_set('uc_payment_method_other_checkout', TRUE);
+    $this->addPaymentZoneCondition('other', '26');
+
+    // Speciy that the billing zone should update the payment pane.
+    $config = variable_get('uc_ajax_checkout', _uc_ajax_defaults('checkout'));
+    $config['panes][billing][address][billing_zone'] = array('payment-pane' => 'payment-pane');
+    variable_set('uc_ajax_checkout', $config);
+
+    // Go to the checkout page, veriy that the conditional payment method is
+    // not available.
+    $product = $this->createProduct(array('shippable' => FALSE));
+    $this->drupalPost('node/' . $product->nid, array(), t('Add to cart'));
+    $this->drupalPost('cart', array('items[0][qty]' => 1), t('Checkout'));
+    $this->assertNoText('Other');
+
+    // Change the billing zone and veriy that payment pane updates.
+    $edit = array();
+    $edit['panes[billing][billing_zone]'] = '26';
+    $result = $this->ucPostAjax(NULL, $edit, 'panes[billing][billing_zone]');
+    $this->assertText("Other");
+    $edit['panes[billing][billing_zone]'] = '1';
+    $result = $this->ucPostAjax(NULL, $edit, 'panes[billing][billing_zone]');
+    // Not in Kansas any more...
+    $this->assertNoText("Other");
+  }
+}
diff --git a/uc_store/uc_store.info b/uc_store/uc_store.info
index a1b7824..28c5c90 100644
--- a/uc_store/uc_store.info
+++ b/uc_store/uc_store.info
@@ -11,6 +11,7 @@ files[] = classes/encrypt.inc
 ; Test cases
 files[] = tests/uc_store.test
 files[] = tests/uc_address.test
+files[] = tests/uc_ajax.test
 
 configure = admin/store/settings/store
 stylesheets[all][] = uc_store.css
