]> git.imager.perl.org - bse.git/blobdiff - site/cgi-bin/modules/BSE/UI/Shop.pm
allow purchase of products with missing options
[bse.git] / site / cgi-bin / modules / BSE / UI / Shop.pm
index d4f1a03d146c10c66c8ba42a7f9debcb87b2ace8..df6aeb92a083a3d915825ed36b6b6d1fa7bf35ac 100644 (file)
@@ -3,21 +3,36 @@ use strict;
 use base 'BSE::UI::Dispatch';
 use BSE::Util::HTML qw(:default popup_menu);
 use BSE::Util::SQL qw(now_sqldate now_sqldatetime);
-use BSE::Shop::Util qw(:payment need_logon shop_cart_tags payment_types nice_options 
+use BSE::Shop::Util qw(:payment shop_cart_tags payment_types nice_options 
                        cart_item_opts basic_tags order_item_opts);
 use BSE::CfgInfo qw(custom_class credit_card_class bse_default_country);
 use BSE::TB::Orders;
 use BSE::TB::OrderItems;
 use BSE::Util::Tags qw(tag_error_img tag_hash tag_article);
-use Products;
+use BSE::TB::Products;
 use BSE::TB::Seminars;
 use DevHelp::Validate qw(dh_validate dh_validate_hash);
 use Digest::MD5 'md5_hex';
 use BSE::Shipping;
 use BSE::Countries qw(bse_country_code);
 use BSE::Util::Secure qw(make_secret);
+use BSE::Template;
 
-our $VERSION = "1.029";
+our $VERSION = "1.053";
+
+=head1 NAME
+
+BSE::UI::Shop - implements the shop for BSE
+
+=head1 DESCRIPTION
+
+BSE::UI::Shop implements the shop for BSE.
+
+=head1 TARGETS
+
+=over
+
+=cut
 
 use constant MSG_SHOP_CART_FULL => 'Your shopping cart is full, please remove an item and try adding an item again';
 
@@ -87,33 +102,28 @@ sub other_action {
 sub req_cart {
   my ($class, $req, $msg) = @_;
 
-  my @cart = @{$req->session->{cart} || []};
-  my @cart_prods;
-  my @items = $class->_build_items($req, \@cart_prods);
-  my $item_index = -1;
-  my @options;
-  my $option_index;
-  
-  $req->session->{custom} ||= {};
-  my %custom_state = %{$req->session->{custom}};
+  $class->_refresh_cart($req);
+
+  my $cart = $req->cart("cart");
 
   my $cust_class = custom_class($req->cfg);
-  $cust_class->enter_cart(\@cart, \@cart_prods, \%custom_state, $req->cfg); 
-  $msg = '' unless defined $msg;
-  $msg = escape_html($msg);
+  # $req->session->{custom} ||= {};
+  # my %custom_state = %{$req->session->{custom}};
 
-  $msg ||= $req->message;
+  # $cust_class->enter_cart(\@cart, \@cart_prods, \%custom_state, $req->cfg);
+  $msg = $req->message($msg);
   
   my %acts;
   %acts =
     (
-     $cust_class->cart_actions(\%acts, \@cart, \@cart_prods, \%custom_state, 
-                              $req->cfg),
-     shop_cart_tags(\%acts, \@items, \@cart_prods, $req, 'cart'),
+     $cust_class->cart_actions(\%acts, scalar $cart->items, scalar $cart->products,
+                              $cart->custom_state, $req->cfg),
+     shop_cart_tags(\%acts, $cart, $req, 'cart'),
      basic_tags(\%acts),
      msg => $msg,
     );
-  $req->session->{custom} = \%custom_state;
+
+  $req->session->{custom} = { %{$cart->custom_state} };
   $req->session->{order_info_confirmed} = 0;
 
   # intended to ajax enable the shop cart with partial templates
@@ -138,16 +148,17 @@ Flashes msg:bse/shop/cart/empty unless C<r> is supplied.
 sub req_emptycart {
   my ($self, $req) = @_;
 
-  my $old = $req->session->{cart};;
-  $req->session->{cart} = [];
+  my $cart = $req->cart;
+  my $item_count = @{$cart->items};
+  $cart->empty;
 
   my $refresh = $req->cgi->param('r');
   unless ($refresh) {
     $refresh = $req->user_url(shop => 'cart');
-    $req->flash("msg:bse/shop/cart/empty");
+    $req->flash_notice("msg:bse/shop/cart/empty");
   }
 
-  return _add_refresh($refresh, $req, !$old);
+  return _add_refresh($refresh, $req, !$item_count);
 }
 
 sub req_add {
@@ -198,6 +209,7 @@ sub req_add {
 
     ++$found;
     $item->{units} += $quantity;
+    $req->flash_notice("msg:bse/shop/cart/addquant", [ $product, $quantity ]);
     last;
   }
   unless ($found) {
@@ -205,14 +217,19 @@ sub req_add {
     if (defined $cart_limit && @cart >= $cart_limit) {
       return $class->req_cart($req, $req->text('shop/cartfull', MSG_SHOP_CART_FULL));
     }
+    my $user = $req->siteuser;
+    my ($price, $tier) = $product->price(user => $user);
     push @cart, 
       { 
        productId => $product->{id}, 
        units => $quantity, 
-       price=> scalar $product->price(user => scalar $req->siteuser),
+       price=> $price,
        options=>$options,
+       tier => $tier ? $tier->id : "",
+       user => $user ? $user->id : 0,
        %$extras,
       };
+    $req->flash_notice("msg:bse/shop/cart/add", [ $product, $quantity ]);
   }
 
   $req->session->{cart} = \@cart;
@@ -267,6 +284,7 @@ sub req_addsingle {
 
     ++$found;
     $item->{units} += $quantity;
+    $req->flash_notice("msg:bse/shop/cart/addquant", [ $product, $quantity ]);
     last;
   }
   unless ($found) {
@@ -274,14 +292,19 @@ sub req_addsingle {
     if (defined $cart_limit && @cart >= $cart_limit) {
       return $class->req_cart($req, $req->text('shop/cartfull', MSG_SHOP_CART_FULL));
     }
+    my $user = $req->siteuser;
+    my ($price, $tier) = $product->price(user => $user);
     push @cart, 
       { 
        productId => $addid, 
        units => $quantity, 
-       price=> scalar $product->price(user => scalar $req->siteuser),
+       price=> $price,
        options=>$options,
+       tier => $tier ? $tier->id : "",
+       user => $user ? $user->id : 0,
        %$extras,
       };
+    $req->flash_notice("msg:bse/shop/cart/add", [ $product, $quantity ]);
   }
 
   $req->session->{cart} = \@cart;
@@ -387,12 +410,15 @@ sub req_addmultiple {
        or next;
 
       $item->{units} += $addition->{quantity};
+      $req->flash_notice("msg:bse/shop/cart/addquant",
+                        [ $addition->{product}, $addition->{quantity} ]);
     }
 
     my $cart_limit = $req->cfg->entry('shop', 'cart_entry_limit');
 
     my @additions = grep $_->{quantity} > 0, values %additions;
 
+    my $user = $req->siteuser;
     my $error;
     for my $addition (@additions) {
       my $product = $addition->{product};
@@ -402,14 +428,19 @@ sub req_addmultiple {
        last;
       }
 
+      my ($price, $tier) = $product->price(user => $user);
       push @cart, 
        { 
         productId => $product->{id},
         units => $addition->{quantity}, 
-        price=> scalar $product->price(user => scalar $req->siteuser),
+        price=> $price,
+        tier => $tier ? $tier->id : "",
+        user => $user ? $user->id : 0,
         options=>[],
         %{$addition->{extras}},
        };
+      $req->flash_notice("msg:bse/shop/cart/add",
+                        [ $addition->{product}, $addition->{quantity} ]);
     }
     
     $req->session->{cart} = \@cart;
@@ -423,12 +454,7 @@ sub req_addmultiple {
     $refresh = $req->user_url(shop => 'cart');
   }
   if (@messages) {
-    my $sep = $refresh =~ /\?/ ? '&' : '?';
-    
-    for my $message (@messages) {
-      $refresh .= $sep . "m=" . escape_uri($message);
-      $sep = '&';
-    }
+    $req->flash_error($_) for @messages;
   }
 
   # speed for ajax
@@ -455,9 +481,51 @@ sub tag_ifUser {
   }
 }
 
+sub _any_physical_products {
+  my $prods = shift;
+
+  for my $prod (@$prods) {
+    if ($prod->weight) {
+      return 1;
+      last;
+    }
+  }
+
+  return 0;
+}
+
+=item checkout
+
+Display the checkout form.
+
+Variables:
+
+=over
+
+=item *
+
+old - a function returning the old value for most fields.
+
+=item *
+
+errors - any errors from attempting to progress to payment
+
+=item *
+
+need_delivery - true if the user indicates they want separate delivery
+and billing details.
+
+=back
+
+Template C<checkoutnew>
+
+=cut
+
 sub req_checkout {
   my ($class, $req, $message, $olddata) = @_;
 
+  $class->_refresh_cart($req);
+
   my $errors = {};
   if (defined $message) {
     if (ref $message) {
@@ -474,29 +542,21 @@ sub req_checkout {
   my $need_delivery = ( $olddata ? $cgi->param("need_delivery") : $req->session->{order_need_delivery} ) || 0;
 
   $class->update_quantities($req);
-  my @cart = @{$req->session->{cart}};
+  my $cart = $req->cart("checkout");
+  my @cart = @{$cart->items};
 
   @cart or return $class->req_cart($req);
 
-  my @cart_prods;
-  my @items = $class->_build_items($req, \@cart_prods);
+  my @cart_prods = @{$cart->products};
+  my @items = @{$cart->items};
 
-  if (my ($msg, $id) = $class->_need_logon($req, \@cart, \@cart_prods)) {
+  if ($cart->need_logon) {
+    my ($msg, $id) = $cart->need_logon_message;
     return $class->_refresh_logon($req, $msg, $id);
-    return;
   }
 
   my $user = $req->siteuser;
 
-  $req->session->{custom} ||= {};
-  my %custom_state = %{$req->session->{custom}};
-
-  my $cust_class = custom_class($cfg);
-  $cust_class->enter_cart(\@cart, \@cart_prods, \%custom_state, $cfg);
-
-  my $affiliate_code = $req->session->{affiliate_code};
-  defined $affiliate_code or $affiliate_code = '';
-
   my $order_info = $req->session->{order_info};
 
   my $billing_map = BSE::TB::Order->billing_to_delivery_map;
@@ -532,84 +592,110 @@ sub req_checkout {
   my ($delivery_in, $shipping_cost, $shipping_method);
   my $shipping_error = '';
   my $shipping_name = '';
-  my $prompt_ship = $cfg->entry("shop", "shipping", 0);
-  if ($prompt_ship) {
-    my $work_order;
-    $work_order = $order_info unless $olddata;
-    unless ($work_order) {
-      my %fake_order;
-      my %fields = $class->_order_fields($req);
-      $class->_order_hash($req, \%fake_order, \%fields, user => 1);
-      $work_order = \%fake_order;
-    }
+  my $prompt_ship = $cart->cfg_shipping;
 
-    # Get a list of couriers
-    my $sel_cn = $old->("shipping_name") || "";
-    my $country_field = $need_delivery ? "delivCountry" : "billCountry";
-    my $country = $old->($country_field)
-      || bse_default_country($cfg);
-    my $country_code = bse_country_code($country);
-    my $suburb = $old->($need_delivery ? "delivSuburb" : "billSuburb");
-    my $postcode = $old->($need_delivery ? "delivPostCode" : "billPostCode");
-
-    $country_code
-      or $errors->{$country_field} = "Unknown country name $country";
+  my $physical = $cart->any_physical_products;
 
-    my @couriers = BSE::Shipping->get_couriers($cfg);
-
-    if ($country_code and $postcode) {
-      @couriers = grep $_->can_deliver(country => $country_code,
-                                      suburb => $suburb,
-                                      postcode => $postcode), @couriers;
-    }
+  if ($prompt_ship) {
+    my $sel_cn = $old->("shipping_name") || "";
+    if ($physical) {
+      my $work_order;
+      $work_order = $order_info unless $olddata;
+      unless ($work_order) {
+       my %fake_order;
+       my %fields = $class->_order_fields($req);
+       $class->_order_hash($req, \%fake_order, \%fields, user => 1);
+       $work_order = \%fake_order;
+      }
+      
+      # Get a list of couriers
+      my $country_field = $need_delivery ? "delivCountry" : "billCountry";
+      my $country = $old->($country_field)
+       || bse_default_country($cfg);
+      my $country_code = bse_country_code($country);
+      my $suburb = $old->($need_delivery ? "delivSuburb" : "billSuburb");
+      my $postcode = $old->($need_delivery ? "delivPostCode" : "billPostCode");
+      
+      $country_code
+       or $errors->{$country_field} = "Unknown country name $country";
+      
+      my @couriers = BSE::Shipping->get_couriers($cfg);
+      
+      if ($country_code and $postcode) {
+       @couriers = grep $_->can_deliver(country => $country_code,
+                                        suburb => $suburb,
+                                        postcode => $postcode), @couriers;
+      }
+      
+      my ($sel_cour) = grep $_->name eq $sel_cn, @couriers;
+      # if we don't match against the list (perhaps because of a country
+      # change) the first item in the list will be selected by the
+      # browser anyway, so select it ourselves and display an
+      # appropriate shipping cost for the item
+      unless ($sel_cour) {
+       $sel_cour = $couriers[0];
+       $sel_cn = $sel_cour->name;
+      }
+      if ($sel_cour and $postcode and $suburb and $country_code) {
+       my @parcels = BSE::Shipping->package_order($cfg, $order_info, \@items);
+       $shipping_cost = $sel_cour->calculate_shipping
+         (
+          parcels => \@parcels,
+          suburb => $suburb,
+          postcode => $postcode,
+          country => $country_code,
+          products => \@cart_prods,
+          items => \@items,
+         );
+       $delivery_in = $sel_cour->delivery_in();
+       $shipping_method = $sel_cour->description();
+       $shipping_name = $sel_cour->name;
+       unless (defined $shipping_cost) {
+         $shipping_error = "$shipping_method: " . $sel_cour->error_message;
+         $errors->{shipping_name} = $shipping_error;
+         
+         # use the last one, which should be the Null shipper
+         $sel_cour = $couriers[-1];
+         $sel_cn = $sel_cour->name;
+         $shipping_method = $sel_cour->description;
+       }
+      }
     
-    my ($sel_cour) = grep $_->name eq $sel_cn, @couriers;
-    # if we don't match against the list (perhaps because of a country
-    # change) the first item in the list will be selected by the
-    # browser anyway, so select it ourselves and display an
-    # appropriate shipping cost for the item
-    unless ($sel_cour) {
-      $sel_cour = $couriers[0];
-      $sel_cn = $sel_cour->name;
+      $shipping_select = popup_menu
+       (
+        -name => "shipping_name",
+        -id => "shipping_name",
+        -values => [ map $_->name, @couriers ],
+        -labels => { map { $_->name => $_->description } @couriers },
+        -default => $sel_cn,
+       );
     }
-    if ($sel_cour and $postcode and $suburb and $country_code) {
-      my @parcels = BSE::Shipping->package_order($cfg, $order_info, \@items);
-      $shipping_cost = $sel_cour->calculate_shipping
+    else {
+      $sel_cn = $shipping_name = "none";
+      $shipping_method = "Nothing to ship!";
+      $shipping_select = popup_menu
        (
-        parcels => \@parcels,
-        suburb => $suburb,
-        postcode => $postcode,
-        country => $country_code,
-        products => \@cart_prods,
-        items => \@items,
+        -name => "shipping_name",
+        -id => "shipping_name",
+        -values => [ "none" ],
+        -labels => { none => $shipping_method },
+        -default => $sel_cn,
        );
-      $delivery_in = $sel_cour->delivery_in();
-      $shipping_method = $sel_cour->description();
-      $shipping_name = $sel_cour->name;
-      unless (defined $shipping_cost) {
-       $shipping_error = "$shipping_method: " . $sel_cour->error_message;
-       $errors->{shipping_name} = $shipping_error;
-
-       # use the last one, which should be the Null shipper
-       $sel_cour = $couriers[-1];
-       $sel_cn = $sel_cour->name;
-       $shipping_method = $sel_cour->description;
-      }
     }
-    
-    $shipping_select = popup_menu
-      (
-       -name => "shipping_name",
-       -id => "shipping_name",
-       -values => [ map $_->name, @couriers ],
-       -labels => { map { $_->name => $_->description } @couriers },
-       -default => $sel_cn,
-      );
   }
 
+  my $cust_class = custom_class($cfg);
+
   if (!$message && keys %$errors) {
     $message = $req->message($errors);
   }
+  $cart->set_shipping_cost($shipping_cost);
+  $cart->set_shipping_method($shipping_method);
+  $cart->set_shipping_name($shipping_name);
+  $cart->set_delivery_in($delivery_in);
+  $req->set_variable(old => $old);
+  $req->set_variable(errors => $errors);
+  $req->set_variable(need_delivery => $need_delivery);
 
   my $item_index = -1;
   my @options;
@@ -617,52 +703,49 @@ sub req_checkout {
   my %acts;
   %acts =
     (
-     shop_cart_tags(\%acts, \@items, \@cart_prods, $req, 'checkout'),
+     shop_cart_tags(\%acts, $cart, $req, 'checkout'),
      basic_tags(\%acts),
      message => $message,
      msg => $message,
      old => sub { escape_html($old->($_[0])); },
      $cust_class->checkout_actions(\%acts, \@cart, \@cart_prods, 
-                                  \%custom_state, $req->cgi, $cfg),
+                                  $cart->custom_state, $req->cgi, $cfg),
      ifUser => [ \&tag_ifUser, $user ],
      user => $user ? [ \&tag_hash, $user ] : '',
-     affiliate_code => escape_html($affiliate_code),
+     affiliate_code => escape_html($cart->affiliate_code),
      error_img => [ \&tag_error_img, $cfg, $errors ],
      ifShipping => $prompt_ship,
      shipping_select => $shipping_select,
-     delivery_in => escape_html($delivery_in),
+     delivery_in => escape_html(defined $delivery_in ? $delivery_in : ""),
      shipping_cost => $shipping_cost,
      shipping_method => escape_html($shipping_method),
      shipping_error => escape_html($shipping_error),
      shipping_name => $shipping_name,
+     ifPhysical => $physical,
      ifNeedDelivery => $need_delivery,
     );
-  $req->session->{custom} = \%custom_state;
-  my $tmp = $acts{total};
-  $acts{total} =
-    sub {
-        my $total = &$tmp();
-        $total += $shipping_cost if $total and $shipping_cost;
-        return $total;
-    };
+  $req->session->{custom} = $cart->custom_state;
 
   return $req->response('checkoutnew', \%acts);
 }
 
 sub req_checkupdate {
-  my ($class, $req) = @_;
+  my ($self, $req) = @_;
 
-  $req->session->{cart} ||= [];
-  my @cart = @{$req->session->{cart}};
-  my @cart_prods = map { Products->getByPkey($_->{productId}) } @cart;
-  $req->session->{custom} ||= {};
-  my %custom_state = %{$req->session->{custom}};
-  custom_class($req->cfg)
-      ->checkout_update($req->cgi, \@cart, \@cart_prods, \%custom_state, $req->cfg);
-  $req->session->{custom} = \%custom_state;
+  my $cart = $req->cart("checkupdate");
+
+  $self->update_quantities($req);
+
+  $req->session->{custom} = $cart->custom_state;
   $req->session->{order_info_confirmed} = 0;
-  
-  return $class->req_checkout($req, "", 1);
+
+  my %fields = $self->_order_fields($req);
+  my %values;
+  $self->_order_hash($req, \%values, \%fields);
+  $req->session->{order_info} = \%values;
+  $req->session->{order_need_delivery} = $req->cgi->param("need_delivery");
+
+  return $req->get_refresh($req->user_url(shop => "checkout"));
 }
 
 sub req_remove_item {
@@ -671,7 +754,9 @@ sub req_remove_item {
   $req->session->{cart} ||= [];
   my @cart = @{$req->session->{cart}};
   if ($index >= 0 && $index < @cart) {
-    splice(@cart, $index, 1);
+    my ($item) = splice(@cart, $index, 1);
+    my $product = BSE::TB::Products->getByPkey($item->{productId});
+    $req->flash_notice("msg:bse/shop/cart/remove", [ $product ]);
   }
   $req->session->{cart} = \@cart;
   $req->session->{order_info_confirmed} = 0;
@@ -718,6 +803,11 @@ sub _order_hash {
       $values->{$delivery} = $values->{$billing};
     }
   }
+  my $cart = $req->cart;
+  if ($cart->cfg_shipping && $cart->any_physical_products) {
+    my $shipping_name = $cgi->param("shipping_name");
+    defined $shipping_name and $values->{shipping_name} = $shipping_name;
+  }
 }
 
 # saves order and refresh to payment page
@@ -734,11 +824,14 @@ sub req_order {
   $class->_validate_cfg($req, \$msg)
     or return $class->req_cart($req, $msg);
 
-  my @products;
-  my @items = $class->_build_items($req, \@products);
+  my $cart = $req->cart("order");
+
+  my @products = @{$cart->products};
+  my @items = @{$cart->items};
 
   my $id;
-  if (($msg, $id) = $class->_need_logon($req, \@items, \@products)) {
+  if ($cart->need_logon) {
+    my ($msg, $id) = $cart->need_logon_message;
     return $class->_refresh_logon($req, $msg, $id);
   }
 
@@ -751,7 +844,7 @@ sub req_order {
 
   dh_validate_hash(\%values, \%errors, { rules=>\%rules, fields=>\%fields },
                   $cfg, 'Shop Order Validation');
-  my $prompt_ship = $cfg->entry("shop", "shipping", 0);
+  my $prompt_ship = $cart->cfg_shipping;
   if ($prompt_ship) {
     my $country = $values{delivCountry} || bse_default_country($cfg);
     my $country_code = bse_country_code($country);
@@ -761,7 +854,7 @@ sub req_order {
   keys %errors
     and return $class->req_checkout($req, \%errors, 1);
 
-  $class->_fillout_order($req, \%values, \@items, \@products, \$msg, 'payment')
+  $class->_fillout_order($req, \%values, \$msg, 'payment')
     or return $class->req_checkout($req, $msg, 1);
 
   $req->session->{order_info} = \%values;
@@ -809,39 +902,39 @@ sub req_show_payment {
 
   # ideally supply order_id to be consistent with a_payment.
   my $order_id = $cgi->param('orderid') || $cgi->param("order_id");
+  my $cart;
   if ($order_id) {
     $order_id =~ /^\d+$/
       or return $class->req_cart($req, "No or invalid order id supplied");
-    
+
     my $user = $req->siteuser
       or return $class->_refresh_logon
        ($req, "Please logon before paying your existing order", "logonpayorder",
         undef, { a_show_payment => 1, orderid => $order_id });
-    
+
     require BSE::TB::Orders;
     $order = BSE::TB::Orders->getByPkey($order_id)
       or return $class->req_cart($req, "Unknown order id");
-    
+
     $order->siteuser_id == $user->id
       or return $class->req_cart($req, "You can only pay for your own orders");
-    
+
     $order->paidFor
       and return $class->req_cart($req, "Order $order->{id} has been paid");
-    
-    @items = $order->items;
-    @products = $order->products;
+
+    $cart = $order;
   }
   else {
     $req->session->{order_info_confirmed}
       or return $class->req_checkout($req, 'Please proceed via the checkout page');
-    
+
     $req->session->{cart} && @{$req->session->{cart}}
       or return $class->req_cart($req, "Your cart is empty");
-    
+
     $order = $req->session->{order_info}
       or return $class->req_checkout($req, "You need to enter order information first");
 
-    @items = $class->_build_items($req, \@products);
+    $cart = $req->cart("payment");
   }
 
   $errors ||= {};
@@ -857,6 +950,11 @@ sub req_show_payment {
   $errors and $payment = $cgi->param('paymentType');
   defined $payment or $payment = $payment_types[0];
 
+  $cart->set_shipping_cost($order->{shipping_cost});
+  $cart->set_shipping_method($order->{shipping_method});
+  $cart->set_shipping_name($order->{shipping_name});
+  $req->set_variable(errors => $errors);
+
   my %acts;
   %acts =
     (
@@ -864,13 +962,13 @@ sub req_show_payment {
      message => $msg,
      msg => $msg,
      order => [ \&tag_hash, $order ],
-     shop_cart_tags(\%acts, \@items, \@products, $req, 'payment'),
+     shop_cart_tags(\%acts, $cart, $req, 'payment'),
      ifMultPaymentTypes => @payment_types > 1,
      checkedPayment => [ \&tag_checkedPayment, $payment, \%types_by_name ],
      ifPayments => [ \&tag_ifPayments, \@payment_types, \%types_by_name ],
      paymentTypeId => [ \&tag_paymentTypeId, \%types_by_name ],
      error_img => [ \&tag_error_img, $cfg, $errors ],
-     total => $order->{total},
+     total => $cart->total,
      delivery_in => $order->{delivery_in},
      shipping_cost => $order->{shipping_cost},
      shipping_method => $order->{shipping_method},
@@ -883,6 +981,9 @@ sub req_show_payment {
     $acts{"checkedIfFirst$name"} = $payment_types[0] == $id ? "checked " : "";
     $acts{"checkedPayment$name"} = $payment == $id ? 'checked="checked" ' : "";
   }
+  $req->set_variable(ordercart => $cart);
+  $req->set_variable(order => $order);
+  $req->set_variable(is_order => !!$order_id);
 
   return $req->response('checkoutpay', \%acts);
 }
@@ -992,7 +1093,9 @@ sub req_payment {
 
     for my $field (keys %fields) {
       unless ($nostore{$field}) {
-       ($order_values->{$field}) = $cgi->param($field);
+       if (my ($value) = $cgi->param($field)) {
+         $order_values->{$field} = $value;
+       }
       }
     }
 
@@ -1016,10 +1119,14 @@ sub req_payment {
     }
   }
   else {
+    my $cart = $req->cart("payment");
+
     $order_values->{filled} = 0;
     $order_values->{paidFor} = 0;
     
-    my @items = $class->_build_items($req, \@products);
+    my @items = $class->_build_items($req);
+    my @cartitems = $cart->items;
+    @products = $cart->products;
     
     if ($session->{order_work}) {
       $order = BSE::TB::Orders->getByPkey($session->{order_work});
@@ -1042,7 +1149,7 @@ sub req_payment {
       my @allbutid = @columns;
       shift @allbutid;
       @{$order}{@allbutid} = @data;
-      
+
       $order->clear_items;
       delete $session->{order_work};
       eval {
@@ -1068,7 +1175,7 @@ sub req_payment {
       defined $item{session_id} or $item{session_id} = 0;
       $item{options} = ""; # not used for new orders
       my @data = @item{@item_cols};
-    shift @data;
+      shift @data;
       my $dbitem = BSE::TB::OrderItems->add(@data);
       push @dbitems, $dbitem;
       
@@ -1076,8 +1183,8 @@ sub req_payment {
        require BSE::TB::OrderItemOptions;
        my @option_descs = $product->option_descs($cfg, $item->{options});
        my $display_order = 1;
-       for my $option (@option_descs) {
-         BSE::TB::OrderItemOptions->make
+       for my $option (grep $_->{value} ne '', @option_descs) {
+         my $optionitem = BSE::TB::OrderItemOptions->make
              (
               order_item_id => $dbitem->{id},
               original_id => $option->{id},
@@ -1086,9 +1193,18 @@ sub req_payment {
               display => $option->{display},
               display_order => $display_order++,
              );
+         BSE::PubSub->customize(
+           order_item_option =>
+             {
+               cartitem => $cartitems[$row_num],
+               cartoption => $option->{valueobj},
+               cart => $cart,
+               orderitem => $dbitem,
+               orderitemoption => $optionitem,
+             });
        }
       }
-      
+
       my $sub = $product->subscription;
       if ($sub) {
        $subscribing_to{$sub->{text_id}} = $sub;
@@ -1217,10 +1333,26 @@ sub _finish_order {
 
   $self->_send_order($req, $order);
 
-  # empty the cart ready for the next order
-  delete @{$req->session}{qw/order_info order_info_confirmed order_need_delivery cart order_work/};
+  my $cart = $req->cart;
+  $cart->empty;
 }
 
+=item orderdone
+
+Display the order after the order is complete.
+
+Sets variables:
+
+=over
+
+=item *
+
+C<order> - the new L<BSE::TB::Order> object.
+
+=back
+
+=cut
+
 sub req_orderdone {
   my ($class, $req) = @_;
 
@@ -1235,10 +1367,10 @@ sub req_orderdone {
   my $order = BSE::TB::Orders->getByPkey($id)
     or return $class->req_cart($req);
   my @items = $order->items;
-  my @products = map { Products->getByPkey($_->{productId}) } @items;
+  my @products = map { BSE::TB::Products->getByPkey($_->{productId}) } @items;
 
   my @item_cols = BSE::TB::OrderItem->columns;
-  my %copy_cols = map { $_ => 1 } Product->columns;
+  my %copy_cols = map { $_ => 1 } BSE::TB::Product->columns;
   delete @copy_cols{@item_cols};
   my @copy_cols = keys %copy_cols;
   my @showitems;
@@ -1305,16 +1437,6 @@ sub req_orderdone {
        $items[$item_index]{units} * $items[$item_index]{$what};
      },
      order => sub { escape_html($order->{$_[0]}) },
-     _format =>
-     sub {
-       my ($value, $fmt) = @_;
-       if ($fmt =~ /^m(\d+)/) {
-        return sprintf("%$1s", sprintf("%.2f", $value/100));
-       }
-       elsif ($fmt =~ /%/) {
-        return sprintf($fmt, $value);
-       }
-     },
      iterate_options_reset => sub { $option_index = -1 },
      iterate_options => sub { ++$option_index < @options },
      option => sub { escape_html($options[$option_index]{$_[0]}) },
@@ -1341,6 +1463,9 @@ sub req_orderdone {
     $acts{"if${name}Payment"} = $order->{paymentType} == $id;
   }
 
+  $req->set_variable(order => $order);
+  $req->set_variable(payment_types => \@pay_types);
+
   return $req->response('checkoutfinal', \%acts);
 }
 
@@ -1428,7 +1553,13 @@ sub req_recalc {
 
   $class->update_quantities($req);
   $req->session->{order_info_confirmed} = 0;
-  return $class->req_cart($req);
+
+  my $refresh = $req->cgi->param('r');
+  unless ($refresh) {
+    $refresh = $req->user_url(shop => 'cart');
+  }
+
+  return $req->get_refresh($refresh);
 }
 
 sub req_recalculate {
@@ -1491,6 +1622,11 @@ sub _send_order {
      ifSubscribingTo => [ \&tag_ifSubscribingTo, \%subscribing_to ],
     );
 
+  my %vars =
+    (
+     order => $order,
+    );
+
   my $email_order = $cfg->entryBool('shop', 'email_order', $Constants::SHOP_EMAIL_ORDER);
   require BSE::ComposeMail;
   if ($email_order) {
@@ -1498,6 +1634,8 @@ sub _send_order {
       $acts{cardNumber} = $cgi->param('cardNumber');
       $acts{cardExpiry} = $cgi->param('cardExpiry');
       $acts{cardVerify} = $cgi->param('cardVerify');
+      @vars{qw(cardNumber cardExpiry cardVerify)} =
+       @acts{qw(cardNumber cardExpiry cardVerify)};
     }
 
     my $mailer = BSE::ComposeMail->new(cfg => $cfg);
@@ -1510,7 +1648,8 @@ sub _send_order {
        template => "mailorder",
        log_component => "shop:sendorder:mailowner",
        log_object => $order,
-       log_msg => "Order $order->{id} sent to site owner",
+       log_msg => "Send Order No. $order->{id} to admin",
+       vars => \%vars,
       );
 
     unless ($noencrypt) {
@@ -1527,6 +1666,7 @@ sub _send_order {
     }
 
     delete @acts{qw/cardNumber cardExpiry cardVerify/};
+    delete @vars{qw/cardNumber cardExpiry cardVerify/};
   }
   my $to_email = $order->billEmail;
   my $user = $req->siteuser;
@@ -1544,7 +1684,8 @@ sub _send_order {
      acts => \%acts,
      log_component => "shop:sendorder:mailbuyer",
      log_object => $order,
-     log_msg => "Order $order->{id} sent to purchaser $to_email",
+     log_msg => "Send Order No. $order->{id} to customer ($to_email)",
+     vars => \%vars,
     );
   my $bcc_order = $cfg->entry("shop", "bcc_email");
   if ($bcc_order) {
@@ -1575,19 +1716,13 @@ sub _refresh_logon {
   if ($msgid) {
     $msg = $req->cfg->entry('messages', $msgid, $msg);
   }
-  $parms{message} = $msg if $msg;
+  $parms{m} = $msg if $msg;
   $parms{mid} = $msgid if $msgid;
   $url .= "?" . join("&", map "$_=".escape_uri($parms{$_}), keys %parms);
   
   return BSE::Template->get_refresh($url, $req->cfg);
 }
 
-sub _need_logon {
-  my ($class, $req, $cart, $cart_prods) = @_;
-
-  return need_logon($req, $cart, $cart_prods);
-}
-
 sub tag_checkedPayment {
   my ($payment, $types_by_name, $args) = @_;
 
@@ -1617,6 +1752,7 @@ sub tag_ifPayments {
 sub update_quantities {
   my ($class, $req) = @_;
 
+  # FIXME: should use the cart class to update quantities
   my $session = $req->session;
   my $cgi = $req->cgi;
   my $cfg = $req->cfg;
@@ -1638,45 +1774,65 @@ sub update_quantities {
   my %custom_state = %{$session->{custom}};
   custom_class($cfg)->recalc($cgi, \@cart, [], \%custom_state, $cfg);
   $session->{custom} = \%custom_state;
+
+  my ($coupon) = $cgi->param("coupon");
+  if (defined $coupon) {
+    my $cart = $req->cart;
+    $cart->set_coupon_code($coupon);
+  }
 }
 
 sub _build_items {
-  my ($class, $req, $products) = @_;
+  my ($class, $req) = @_;
 
   my $session = $req->session;
+  my $cart = $req->cart;
   $session->{cart}
     or return;
   my @msgs;
   my @cart = @{$req->session->{cart}}
     or return;
   my @items;
-  my @prodcols = Product->columns;
+  my @prodcols = BSE::TB::Product->columns;
   my @newcart;
   my $today = now_sqldate();
-  for my $item (@cart) {
+  for my $item ($cart->items) {
     my %work = %$item;
-    my $product = Products->getByPkey($item->{productId});
+    my $product = $item->product;
     if ($product) {
-      (my $comp_release = $product->{release}) =~ s/ .*//;
-      (my $comp_expire = $product->{expire}) =~ s/ .*//;
-      $comp_release le $today
+      $product->is_released
        or do { push @msgs, "'$product->{title}' has not been released yet";
                next; };
-      $today le $comp_expire
-       or do { push @msgs, "'$product->{title}' has expired"; next; };
-      $product->{listed} 
+      $product->is_expired
+        and do { push @msgs, "'$product->{title}' has expired"; next; };
+      $product->listed
        or do { push @msgs, "'$product->{title}' not available"; next; };
 
       for my $col (@prodcols) {
        $work{$col} = $product->$col() unless exists $work{$col};
       }
-      $work{price} = $product->price(user => scalar $req->siteuser);
-      $work{extended_retailPrice} = $work{units} * $work{price};
-      $work{extended_gst} = $work{units} * $work{gst};
-      $work{extended_wholesale} = $work{units} * $work{wholesalePrice};
-      
+      my ($price, $tier) = $product->price(user => scalar $req->siteuser);
+      $work{price} = $item->price;
+      $work{tier_id} = $item->tier_id;
+      $work{extended_retailPrice} = $item->extended_retailPrice;
+      $work{extended_gst} = $item->extended_gst;
+      $work{extended_wholesale} = $item->extended_wholesale;
+      if ($cart->coupon_active) {
+       $work{product_discount} = $item->product_discount;
+       $work{product_discount_units} = $item->product_discount_units;
+      }
+      else {
+       $work{product_discount} = 0;
+       $work{product_discount_units} = 0;
+      }
+      BSE::PubSub->customize(
+       order_build_item => {
+         cartitem => $item,
+         cart => $cart,
+         orderitem => \%work,
+        });
+
       push @newcart, \%work;
-      push @$products, $product;
     }
   }
 
@@ -1689,72 +1845,99 @@ sub _build_items {
 }
 
 sub _fillout_order {
-  my ($class, $req, $values, $items, $products, $rmsg, $how) = @_;
+  my ($class, $req, $values, $rmsg, $how) = @_;
 
   my $session = $req->session;
   my $cfg = $req->cfg;
   my $cgi = $req->cgi;
 
-  my $total = 0;
-  my $total_gst = 0;
-  my $total_wholesale = 0;
-  for my $item (@$items) {
-    $total += $item->{extended_retailPrice};
-    $total_gst += $item->{extended_gst};
-    $total_wholesale += $item->{extended_wholesale};
+  my $cart = $req->cart($how);
+
+  if ($cart->is_empty) {
+    $$rmsg = "Your cart is empty";
+    return;
   }
-  $values->{total} = $total;
-  $values->{gst} = $total_gst;
-  $values->{wholesaleTotal} = $total_wholesale;
 
-  my $prompt_ship = $cfg->entry("shop", "shipping", 0);
+  # FIXME? this doesn't take discounting into effect
+  $values->{gst} = $cart->gst;
+  $values->{wholesaleTotal} = $cart->wholesaleTotal;
+
+  my $items = $cart->items;
+  my $products = $cart->products;
+  my $prompt_ship = $cart->cfg_shipping;
   if ($prompt_ship) {
-    my ($courier) = BSE::Shipping->get_couriers($cfg, $cgi->param("shipping_name"));
-    my $country_code = bse_country_code($values->{delivCountry});
-    if ($courier) {
-      unless ($courier->can_deliver(country => $country_code,
-                                   suburb => $values->{delivSuburb},
-                                   postcode => $values->{delivPostCode})) {
-       $cgi->param("courier", undef);
-       $$rmsg =
-         "Can't use the selected courier ".
-            "(". $courier->description(). ") for this order.";
-       return;
+    if (_any_physical_products($products)) {
+      my ($courier) = BSE::Shipping->get_couriers($cfg, $cgi->param("shipping_name"));
+      my $country_code = bse_country_code($values->{delivCountry});
+      if ($courier) {
+       unless ($courier->can_deliver(country => $country_code,
+                                     suburb => $values->{delivSuburb},
+                                     postcode => $values->{delivPostCode})) {
+         $cgi->param("courier", undef);
+         $$rmsg =
+           "Can't use the selected courier ".
+             "(". $courier->description(). ") for this order.";
+         return;
+       }
+       my @parcels = BSE::Shipping->package_order($cfg, $values, $items);
+       my $cost = $courier->calculate_shipping
+         (
+          parcels => \@parcels,
+          country => $country_code,
+          suburb => $values->{delivSuburb},
+          postcode => $values->{delivPostCode},
+          products => $products,
+          items => $items,
+         );
+       if (!defined $cost and $courier->name() ne 'contact') {
+         my $err = $courier->error_message();
+         $$rmsg = "Error calculating shipping cost";
+         $$rmsg .= ": $err" if $err;
+         return;
+       }
+       $values->{shipping_method} = $courier->description();
+       $values->{shipping_name} = $courier->name;
+       $values->{shipping_cost} = $cost;
+       $values->{shipping_trace} = $courier->trace;
+       $values->{delivery_in} = $courier->delivery_in();
       }
-      my @parcels = BSE::Shipping->package_order($cfg, $values, $items);
-      my $cost = $courier->calculate_shipping
-       (
-        parcels => \@parcels,
-        country => $country_code,
-        suburb => $values->{delivSuburb},
-        postcode => $values->{delivPostCode},
-        products => $products,
-        items => $items,
-       );
-      if (!$cost and $courier->name() ne 'contact') {
-       my $err = $courier->error_message();
-       $$rmsg = "Error calculating shipping cost";
-       $$rmsg .= ": $err" if $err;
+      else {
+       # XXX: What to do?
+       $$rmsg = "Error: no usable courier found.";
        return;
       }
-      $values->{shipping_method} = $courier->description();
-      $values->{shipping_name} = $courier->name;
-      $values->{shipping_cost} = $cost;
-      $values->{shipping_trace} = $courier->trace;
-      #$values->{delivery_in} = $courier->delivery_in();
-      $values->{total} += $values->{shipping_cost};
     }
     else {
-      # XXX: What to do?
-      $$rmsg = "Error: no usable courier found.";
-      return;
+      $values->{shipping_method} = "Nothing to ship!";
+      $values->{shipping_name} = "none";
+      $values->{shipping_cost} = 0;
+      $values->{shipping_trace} = "All products have zero weight.";
     }
   }
+  if ($cart->coupon_active) {
+    $values->{coupon_id} = $cart->coupon->id;
+    $values->{coupon_description} = $cart->coupon_description;
+    $values->{coupon_cart_wide} = $cart->coupon_cart_wide;
+  }
+  else {
+    $values->{coupon_id} = undef;
+    $values->{coupon_description} = "";
+    $values->{coupon_cart_wide} = 0;
+  }
+  $cart->set_shipping_cost($values->{shipping_cost});
+  $cart->set_shipping_method($values->{shipping_method});
+  $cart->set_shipping_name($values->{shipping_name});
+  $cart->set_delivery_in($values->{delivery_in});
+
+  $values->{coupon_code_discount_pc} = $cart->coupon_code_discount_pc;
+  $values->{product_cost_discount} = $cart->product_cost_discount;
+  $values->{total} = $cart->total;
 
   my $cust_class = custom_class($cfg);
 
   eval {
     local $SIG{__DIE__};
+    $session->{custom} = $cart->custom_state || {};
     my %custom = %{$session->{custom}};
     $cust_class->order_save($cgi, $values, $items, $items, 
                            \%custom, $cfg);
@@ -1828,7 +2011,7 @@ sub _validate_add_by_id {
   my $product;
   if ($addid) {
     $product = BSE::TB::Seminars->getByPkey($addid);
-    $product ||= Products->getByPkey($addid);
+    $product ||= BSE::TB::Products->getByPkey($addid);
   }
   unless ($product) {
     $$error = "Cannot find product $addid";
@@ -1844,7 +2027,7 @@ sub _validate_add_by_code {
   my $product;
   if (defined $code) {
     $product = BSE::TB::Seminars->getBy(product_code => $code);
-    $product ||= Products->getBy(product_code => $code);
+    $product ||= BSE::TB::Products->getBy(product_code => $code);
   }
   unless ($product) {
     $$error = "Cannot find product code $code";
@@ -1861,14 +2044,20 @@ sub _validate_add {
   my @options;
   my @option_descs =  $product->option_descs($req->cfg);
   my @option_names = map $_->{name}, @option_descs;
+  my $allow_missing_options = $req->cfg->entry("shop", "allow_missing_options", 0);
   my @not_def;
   my $cgi = $req->cgi;
   for my $name (@option_names) {
     my $value = $cgi->param($name);
-    push @options, $value;
     unless (defined $value) {
-      push @not_def, $name;
+      if ($allow_missing_options) {
+       $value = "";
+      }
+      else {
+       push @not_def, $name;
+      }
     }
+    push @options, $value;
   }
   if (@not_def) {
     $$error = "Some product options (@not_def) not supplied";
@@ -2028,17 +2217,23 @@ sub _add_refresh {
        print STDERR "not on base host ('$ENV{SERVER_NAME}' cmp '$basehost' '$protocol cmp '$baseprot'  $baseport cmp $port\n" if $debug;
        $onbase = 0;
       }
-      my $url = $onbase ? $secure_url : $base_url;
+      my $base = $onbase ? $secure_url : $base_url;
       my $finalbase = $onbase ? $base_url : $secure_url;
       $refresh = $finalbase . $refresh unless $refresh =~ /^\w+:/;
+      my $sessionid = $req->session->{_session_id};
+      require BSE::SessionSign;
+      my $sig = BSE::SessionSign->make($sessionid);
+      my $url = $cfg->user_url("user", undef,
+                              -base => $base,
+                              setcookie => $sessionid,
+                              s => $sig,
+                              r => $refresh);
       print STDERR "Heading to $url to setcookie\n" if $debug;
-      $url .= "/cgi-bin/user.pl?setcookie=".$req->session->{_session_id};
-      $url .= "&r=".CGI::escape($refresh);
-      return BSE::Template->get_refresh($url, $cfg);
+      return $req->get_refresh($url);
     }
   }
 
-  return BSE::Template->get_refresh($refresh, $cfg);
+  return $req->get_refresh($refresh);
 }
 
 sub _same_options {
@@ -2134,10 +2329,394 @@ sub req_paypalcan {
   my $order = $self->_paypal_order($req, \$msg)
     or return $self->req_show_payment($req, { _ => $msg });
 
-  $req->flash("msg:bse/shop/paypal/cancelled");
+  $req->flash_notice("msg:bse/shop/paypal/cancelled");
 
   my $url = $req->user_url(shop => "show_payment");
   return $req->get_refresh($url);
 }
 
+sub _refresh_cart {
+  my ($self, $req) = @_;
+
+  my $user = $req->siteuser
+    or return;
+
+  my $cart = $req->session->{cart}
+    or return;
+
+  for my $item (@$cart) {
+    if (!$item->{user} || $item->{user} != $user->id) {
+      my $product = BSE::TB::Products->getByPkey($item->{productId})
+       or next;
+      my ($price, $tier) = $product->price(user => $user);
+      $item->{price} = $price;
+      $item->{tier} = $tier ? $tier->id : "";
+    }
+  }
+
+  $req->session->{cart} = $cart;
+}
+
 1;
+
+=back
+
+=head1 TAGS
+
+=head2 Cart page
+
+=over 4
+
+=item iterator ... items
+
+Iterates over the items in the shopping cart, setting the C<item> tag
+for each one.
+
+=item item I<field>
+
+Retreives the given field from the item.  This can include product
+fields for this item.
+
+=item index
+
+The numeric index of the current item.
+
+=item extended [<field>]
+
+The "extended price", the product of the unit cost and the number of
+units for the current item in the cart.  I<field> defaults to the
+price of the product.
+
+=item money I<which> <field>
+
+Formats the given field as a money value (without a currency symbol.)
+
+=item count
+
+The number of items in the cart.
+
+=item ifUser
+
+Conditional tag, true if a registered user is logged in.
+
+=item user I<field>
+
+Retrieved the given field from the currently logged in user, if any.
+
+=back
+
+=head2 Checkout tags
+
+This has the same tags as the L<Cart page>, and some extras:
+
+=over 4
+
+=item total
+
+The total cost of all items in the cart.
+
+This will need to be formatted as a money value with the C<money> tag.
+
+=item message
+
+An error message, if a validation error occurred.
+
+=item old I<field>
+
+The previously entered value for I<field>.  This should be used as the
+value for the various checkout fields, so that if a validation error
+occurs the user won't need to re-enter values.
+
+=back
+
+=head2 Completed order
+
+These tags are used in the F<checkoutfinal_base.tmpl>.
+
+=over 4
+
+=item item I<field>
+
+=item product I<field>
+
+This is split out for these forms.
+
+=item order I<field>
+
+Order fields.
+
+=item ifSubscribingTo I<subid>
+
+Can be used to check if this order is intended to be subscribing to a
+subscription.
+
+=back
+
+=head2 Mailed order tags
+
+These tags are used in the emails sent to the user to confirm an order
+and in the encrypted copy sent to the site administrator:
+
+=over 4
+
+=item *
+
+C<iterate> ... C<items>
+
+Iterates over the items in the order.
+
+=item *
+
+C<item> I<field>
+
+Access to the given field in the order item.
+
+=item *
+
+C<product> I<field>
+
+Access to the product field for the current order item.
+
+=item *
+
+C<order> I<field>
+
+Access to fields of the order.
+
+=item *
+
+C<extended> I<field>
+
+The product of the I<field> in the current item and it's quantity.
+
+=item *
+
+C<money> I<tag> I<parameters>
+
+Formats the given field as a money value.
+
+=back
+
+The mail generation template can use extra formatting specified with
+'|format':
+
+=over 4
+
+=item *
+
+m<number>
+
+Format the value as a I<number> wide money value.
+
+=item *
+
+%<format>
+
+Performs sprintf formatting on the value.
+
+=item *
+
+<number>
+
+Left justifies the value in a I<number> wide field.
+
+=back
+
+The order email sent to the site administrator has a couple of extra
+fields:
+
+=over
+
+=item *
+
+cardNumber
+
+The credit card number of the user's credit card.
+
+=item *
+
+cardExpiry
+
+The entered expiry date for the user's credit card.
+
+=back
+
+=head2 Order fields
+
+These names can be used with the <: order ... :> tag.
+
+Monetary values should typically be used with <:money order ...:>
+
+=over
+
+=item *
+
+id
+
+The order id or order number.
+
+=item *
+
+delivFirstName, delivLastName, delivStreet, delivSuburb, delivState,
+delivPostCode, delivCountry - Delivery information for the order.
+
+=item *
+
+billFirstName, billLastName, billStreet, billSuburb, billState,
+billPostCode, billCountry - Billing information for the order.
+
+=item *
+
+telephone, facsimile, emailAddress - Contact information for the
+order.
+
+=item *
+
+total - Total price of the order.
+
+=item *
+
+wholesaleTotal - Wholesale cost of the total.  Your costs, if you
+entered wholesale prices for the products.
+
+=item *
+
+gst - GST (in Australia) payable on the order, if you entered GST for
+the products.
+
+=item *
+
+orderDate - When the order was made.
+
+=item *
+
+filled - Whether or not the order has been filled.  This can be used
+with the order_filled target in shopadmin.pl for tracking filled
+orders.
+
+=item *
+
+whenFilled - The time and date when the order was filled.
+
+=item *
+
+whoFilled - The user who marked the order as filled.
+
+=item *
+
+paidFor - Whether or not the order has been paid for.  This can be
+used with a custom purchasing handler to mark the product as paid for.
+You can then filter the order list to only display paid for orders.
+
+=item *
+
+paymentReceipt - A custom payment handler can fill this with receipt
+information.
+
+=item *
+
+randomId - Generated by the prePurchase target, this can be used as a
+difficult to guess identifier for orders, when working with custom
+payment handlers.
+
+=item *
+
+cancelled - This can be used by a custom payment handler to mark an
+order as cancelled if the user starts processing an order without
+completing payment.
+
+=back
+
+=head2 Order item fields
+
+=over
+
+=item *
+
+productId - The product id of this item.
+
+=item *
+
+orderId - The order Id.
+
+=item *
+
+units - The number of units for this item.
+
+=item *
+
+price - The price paid for the product.
+
+=item *
+
+wholesalePrice - The wholesale price for the product.
+
+=item *
+
+gst - The gst for the product.
+
+=item *
+
+options - A comma separated list of options specified for this item.
+These correspond to the option names in the product.
+
+=back
+
+=head2 Options
+
+New with 0.10_04 is the facility to set options for each product.
+
+The cart, checkout and checkoutfinal pages now include the following
+tags:
+
+=over
+
+=item *
+
+C<iterator> ... <options>
+
+within an item, iterates over the options for this item in the cart.
+Sets the item tag.
+
+=item *
+
+C<option> I<field>
+
+Retrieves the given field from the option, possible field names are:
+
+=over
+
+=item *
+
+id - The type/identifier for this option.  eg. msize for a male
+clothing size field.
+
+=item *
+
+value - The underlying value of the option, eg. XL.
+
+=item *
+
+desc - The description of the field from the product options hash.  If
+the description isn't defined this is the same as the id. eg. Size.
+
+=item *
+
+label - The description of the value from the product options hash.
+eg. "Extra large".
+
+=back
+
+=item *
+
+ifOptions - A conditional tag, true if the current cart item has any
+options.
+
+=item *
+
+options - A simple rendering of the options as a parenthesized
+comma-separated list.
+
+=back
+
+=cut