re-work coupons to allow multiple coupon types
authorTony Cook <tony@develop-help.com>
Sun, 1 May 2016 00:45:54 +0000 (10:45 +1000)
committerTony Cook <tony@develop-help.com>
Fri, 27 May 2016 09:13:16 +0000 (19:13 +1000)
34 files changed:
MANIFEST
Makefile
schema/bse.sql
site/cgi-bin/bse.cfg
site/cgi-bin/modules/BSE/Cart.pm
site/cgi-bin/modules/BSE/Coupon/Base.pm [new file with mode: 0644]
site/cgi-bin/modules/BSE/Coupon/Dollar.pm [new file with mode: 0644]
site/cgi-bin/modules/BSE/Coupon/Percent.pm [new file with mode: 0644]
site/cgi-bin/modules/BSE/Coupon/ProductPercent.pm [new file with mode: 0644]
site/cgi-bin/modules/BSE/Request/Base.pm
site/cgi-bin/modules/BSE/TB/Coupon.pm
site/cgi-bin/modules/BSE/TB/Coupons.pm
site/cgi-bin/modules/BSE/TB/Order.pm
site/cgi-bin/modules/BSE/TB/OrderItem.pm
site/cgi-bin/modules/BSE/UI/AdminShop.pm
site/cgi-bin/modules/BSE/UI/Shop.pm
site/cgi-bin/modules/Squirrel/Template/Expr/WrapScalar.pm
site/htdocs/css/admin.css
site/htdocs/css/style-main.css
site/htdocs/js/admin.js
site/htdocs/js/admin_coupons.js [new file with mode: 0644]
site/templates/admin/base.tmpl
site/templates/admin/coupons/add.tmpl
site/templates/admin/coupons/edit.tmpl
site/templates/admin/coupons/list.tmpl
site/templates/admin/order_detail.tmpl
site/templates/base.tmpl
site/templates/cart_base.tmpl
site/templates/checkoutfinal_base.tmpl
site/templates/checkoutnew_base.tmpl
site/templates/checkoutpay_base.tmpl
site/templates/preload.tmpl
site/util/mysql.str
t/t000load.t

index 025bd51..4ba338b 100644 (file)
--- a/MANIFEST
+++ b/MANIFEST
@@ -78,6 +78,10 @@ site/cgi-bin/modules/BSE/ChangePW.pm
 site/cgi-bin/modules/BSE/ComposeMail.pm
 site/cgi-bin/modules/BSE/Console.pm
 site/cgi-bin/modules/BSE/Countries.pm
+site/cgi-bin/modules/BSE/Coupon/Base.pm
+site/cgi-bin/modules/BSE/Coupon/Dollar.pm
+site/cgi-bin/modules/BSE/Coupon/Percent.pm
+site/cgi-bin/modules/BSE/Coupon/ProductPercent.pm
 site/cgi-bin/modules/BSE/Custom.pm
 site/cgi-bin/modules/BSE/CustomBase.pm
 site/cgi-bin/modules/BSE/DB.pm
@@ -529,6 +533,7 @@ site/htdocs/images/videoclose.png
 site/htdocs/js/admin-ui/debug.js
 site/htdocs/js/admin-ui/menu.js
 site/htdocs/js/admin.js
+site/htdocs/js/admin_coupons.js
 site/htdocs/js/admin_edit.js
 site/htdocs/js/admin_editprodopt.js
 site/htdocs/js/admin_jedit.js
index 7c0286b..22d1f27 100755 (executable)
--- a/Makefile
+++ b/Makefile
@@ -111,7 +111,15 @@ testinst: distdir
        $(PERL) -MExtUtils::Command -e rm_rf $(DISTBUILD)
        cd $(UTILDIR) ; $(PERL) loaddata.pl $(DATADIR)/db
 
-testup: checkver distdir
+testupx: checkver distdir
+       $(PERL) localinst.perl $(DISTBUILD) leavedb
+       $(PERL) -MExtUtils::Command -e rm_rf $(DISTBUILD)
+       cd $(UTILDIR) ; $(PERL) upgrade_mysql.pl -b ; $(PERL) loaddata.pl $(DATADIR)/db
+
+testload :
+       $(PERL) '-MTest::Harness=runtests,$$verbose' -Isite/cgi-bin/modules -It -e '$$verbose=$(TEST_VERBOSE); runtests @ARGV' t/t000load.t
+
+testup : testload checkver distdir
        $(PERL) localinst.perl $(DISTBUILD) leavedb
        $(PERL) -MExtUtils::Command -e rm_rf $(DISTBUILD)
        cd $(UTILDIR) ; $(PERL) upgrade_mysql.pl -b ; $(PERL) loaddata.pl $(DATADIR)/db
@@ -122,7 +130,7 @@ checkver:
 TEST_FILES=t/*.t t/*/*.t
 TEST_VERBOSE=0
 
-test: testup
+test: testupx
        $(PERL) '-MTest::Harness=runtests,$$verbose' -Isite/cgi-bin/modules -It -e '$$verbose=$(TEST_VERBOSE); runtests @ARGV' $(TEST_FILES)
 
 test_load: testup
index c0e6ea3..8bac349 100644 (file)
@@ -350,10 +350,17 @@ create table orders (
   paid_manually integer not null default 0,
 
   coupon_id integer null,
-  coupon_code_discount_pc real not null default 0,
+  -- obsolete
+  coupon_code_discount_pc real null default 0,
 
   delivery_in integer null,
 
+  product_cost_discount integer not null default 0,
+
+  coupon_cart_wide integer not null default 1,
+
+  coupon_description varchar(255) not null default '',
+
   primary key (id),
   index order_cchash(ccNumberHash),
   index order_userId(userId, orderDate),
@@ -404,6 +411,9 @@ create table order_item (
 
   tier_id integer null default null,
 
+  product_discount integer not null default 0,
+  product_discount_units integer not null default 0,
+
   primary key (id),
   index order_item_order(orderId, id)
 ) engine=InnoDB;
@@ -1363,7 +1373,7 @@ create table bse_coupons (
 
   expiry date not null,
 
-  discount_percent real not null,
+  discount_percent real null,
 
   campaign varchar(20) not null,
 
@@ -1371,6 +1381,10 @@ create table bse_coupons (
 
   untiered integer not null default 0,
 
+  classid varchar(20) not null default 'bse_simple',
+
+  config blob not null,
+
   unique codes(code)
 ) engine=InnoDB;
 
index 3480cd9..4750ea7 100644 (file)
@@ -508,3 +508,8 @@ bse_audit_log_clean=1
 [number money]
 divisor=100
 places=2
+
+[coupon classes]
+bse_simple=BSE::Coupon::Percent
+bse_dollar=BSE::Coupon::Dollar
+bse_prodpercent=BSE::Coupon::ProductPercent
index ba53aed..a2c69b3 100644 (file)
@@ -2,7 +2,7 @@ package BSE::Cart;
 use strict;
 use Scalar::Util;
 
-our $VERSION = "1.011";
+our $VERSION = "1.015";
 
 =head1 NAME
 
@@ -215,13 +215,7 @@ Note: this rounds the total B<down>.
 sub discounted_product_cost {
   my ($self) = @_;
 
-  my $cost = $self->total_cost;
-
-  if ($self->coupon_active) {
-    $cost -= $cost * $self->coupon_code_discount_pc / 100;
-  }
-
-  return int($cost);
+  return $self->total_cost - $self->product_cost_discount;
 }
 
 =item product_cost_discount
@@ -233,7 +227,10 @@ Return any amount taken off the product cost.
 sub product_cost_discount {
   my ($self) = @_;
 
-  return $self->total_cost - $self->discounted_product_cost;
+  $self->coupon_active
+    or return 0;
+
+  return $self->{coupon_check}{coupon}->discount($self);
 }
 
 =item cfg_shipping
@@ -418,6 +415,8 @@ sub set_coupon_code {
 The percentage discount for the current coupon code, if that code is
 valid and the contents of the cart are valid for that coupon code.
 
+This method is historical and no longer useful.
+
 =cut
 
 sub coupon_code_discount_pc {
@@ -446,31 +445,17 @@ sub coupon_valid {
        (
         coupon => $coupon,
         valid => 0,
+        active => 0,
+        msg => "",
        );
       #print STDERR " coupon $coupon\n";
       #print STDERR "released ", 0+ $coupon->is_released, " expired ",
       #        0+$coupon->is_expired, " valid ", 0+$coupon->is_valid, "\n" if $coupon;
       if ($coupon && $coupon->is_valid) {
        $check{valid} = 1;
-       $check{active} = 1;
-       my %tiers = map { $_ => 1 } $coupon->tiers;
-      ITEM:
-       for my $item ($self->items) {
-         my $applies = 1;
-         if ($item->tier_id) {
-           #print STDERR "tier ", $item->tier_id, " tiers ", join(",", keys %tiers), "\n";
-           if (!$tiers{$item->tier_id}) {
-             $applies = 0;
-           }
-         }
-         else {
-           if (!$coupon->untiered) {
-             $applies = 0;
-           }
-         }
-         $item->{coupon_applies} = $applies;
-         $applies or $check{active} = 0;
-       }
+       my ($active, $msg) = $coupon->is_active($self);
+       $check{active} = $active;
+       $check{msg} = $msg || "";
       }
       $self->{coupon_check} = \%check;
     }
@@ -479,6 +464,7 @@ sub coupon_valid {
        {
         valid => 0,
         active => 0,
+        msg => "",
        };
     }
   }
@@ -502,6 +488,21 @@ sub coupon_active {
   return $self->{coupon_check}{active};
 }
 
+=item coupon_inactive_message
+
+Returns why the coupon is inactive.
+
+=cut
+
+sub coupon_inactive_message {
+  my ($self) = @_;
+
+  $self->coupon_valid
+    or return "";
+
+  return $self->{coupon_check}{msg};
+}
+
 =item coupon
 
 The current coupon object, if and only if the coupon code is valid.
@@ -517,6 +518,42 @@ sub coupon {
   $self->{coupon_check}{coupon};
 }
 
+=item coupon_cart_wide
+
+Returns true if the coupon discount applies to the cart as a whole.
+
+Always returns false if the coupon is not active.
+
+If this is true the item discount methods are useful.
+
+=cut
+
+sub coupon_cart_wide {
+  my ($self) = @_;
+
+  $self->coupon_active
+    or return;
+
+  return $self->coupon->cart_wide($self);
+}
+
+=item coupon_description
+
+Describe the coupon.
+
+Compatible with order objects.
+
+=cut
+
+sub coupon_description {
+  my ($self) = @_;
+
+  $self->coupon_valid
+    or return;
+
+  return $self->coupon->describe;
+}
+
 =item custom_cost
 
 Return any custom cost specified by a custom class.
@@ -627,7 +664,7 @@ sub affiliate_code {
   return $code;
 }
 
-=item any_phyiscal_products
+=item any_physical_products
 
 Returns true if the cart contains any physical products, ie. needs
 shipping.
@@ -788,6 +825,8 @@ sub cleanup {
 
 Empty the cart.
 
+For BSE use.
+
 =cut
 
 sub empty {
@@ -836,6 +875,16 @@ sub product {
   return $self->{cart}->_product($self->{productId});
 }
 
+=item product_id
+
+Id of the product in this row.
+
+=cut
+
+sub product_id {
+  $_[0]{productId};
+}
+
 =item price
 
 =cut
@@ -974,19 +1023,52 @@ sub option_text {
 
 =item coupon_applies
 
-Returns true if the current coupon code applies to the item.
+Returns true for a cart-wide coupon if this item allows the coupon to
+apply.
 
 =cut
 
 sub coupon_applies {
   my ($self) = @_;
 
-  $self->{cart}->coupon_valid
+  $self->{cart}->coupon_active
     or return 0;
 
-  return $self->{coupon_applies};
+  return $self->{cart}{coupon_check}{coupon}->product_valid($self->{cart}, $self->{index});
 }
 
+=item product_discount
+
+Returns the number of cents of discount this product receives per unit
+
+=cut
+
+sub product_discount {
+  my ($self) = @_;
+
+  $self->{cart}->coupon_active
+    or return 0;
+
+  return $self->{cart}{coupon_check}{coupon}->product_discount($self->{cart}, $self->{index});
+}
+
+=item product_discount_units
+
+Returns the number of units in the current row that the product
+discount applies to.
+
+=cut
+
+sub product_discount_units {
+  my ($self) = @_;
+
+  $self->{cart}->coupon_active
+    or return 0;
+
+  return $self->{cart}{coupon_check}{coupon}->product_discount_units($self->{cart}, $self->{index});
+}
+
+
 =item session
 
 The session object of the seminar session
diff --git a/site/cgi-bin/modules/BSE/Coupon/Base.pm b/site/cgi-bin/modules/BSE/Coupon/Base.pm
new file mode 100644 (file)
index 0000000..e10d6b7
--- /dev/null
@@ -0,0 +1,247 @@
+package BSE::Coupon::Base;
+use strict;
+
+our $VERSION = "1.001";
+
+sub new {
+  my ($class, $config) = @_;
+
+  return bless { config => $config }, $class;
+}
+
+sub config_rules {
+  return {};
+}
+
+sub used_in {
+  # do nothing by default
+}
+
+sub cart_wide {
+  1;
+}
+
+sub product_valid {
+  0;
+}
+
+sub product_discount {
+  0;
+}
+
+sub product_discount_units {
+  0;
+}
+
+sub test_all_tiers_match {
+  my ($self, $coupon, $cart) = @_;
+
+  my %tiers = map { $_ => 1 } $coupon->tiers;
+
+  my $bad_tier_count = 0;
+  for my $item ($cart->items) {
+    if ($item->tier_id) {
+      if (!$tiers{$item->tier_id}) {
+       return 0;
+      }
+    }
+    else {
+      if (!$coupon->untiered) {
+       return 0;
+      }
+    }
+  }
+
+  return 1;
+}
+
+sub test_tier_matches {
+  my ($self, $coupon, $cart, $index) = @_;
+
+  my @items = $cart->items;
+  $index >= 0 && $index < @items
+    or return 0;
+
+  my $item = $items[$index];
+
+  if ($item->tier_id) {
+    my %tiers = map { $_ => 1 } $coupon->tiers;
+
+    if (!$tiers{$item->tier_id}) {
+      return 0;
+    }
+  }
+  else {
+    if (!$coupon->untiered) {
+      return 0;
+    }
+  }
+
+  return 1;
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+BSE::Coupon::Base - base class for coupon behaviour classes.
+
+=head1 SYNOPSIS
+
+  package BSE::Coupon::YourCoupon;
+  use parent 'BSE::Coupon::Base';
+
+  sub new {
+    my ($class, $config) = @_;
+    ...
+  }
+
+  # ... and more
+
+=head1 DESCRIPTION
+
+This class provides base behaviour and documentation on the
+requirements of BSE coupon behaviour classes.
+
+A coupon behaviour class must use BSE::Coupon::Base as a base class,
+to provide default behaviour that might be added later to this
+specification.
+
+A coupon behaviour class may override the default constructor:
+
+=over
+
+=item *
+
+new($config) - create a new coupon behaviour object.  This is passed a
+single parameter of the config hash for that behaviour, as specified
+by config_fields().
+
+=back
+
+The class must implement the following class methods:
+
+=over
+
+=item *
+
+config_fields() - returns a hash reference of customization
+fields for this coupon class.  For example it might return a field
+definition for the dollar amount to be discounted.
+
+If a field called C<discount_percent> is included it will be stored in
+the C<discount_percent> field of the coupon entry.
+
+  my $fields = $class->config_fields();
+
+=item *
+
+config_rules() - any extra rule definitions required for the
+validation rules in config_fields().
+
+  my $rules = $class->config_rules();
+
+A default implementation is provided that returns an empty hash.
+
+=item *
+
+config_valid() - validate whether a supplied configuration is valid.
+
+  my $valid = $class->config_valid($config, \%errors);
+
+This occurs in addition to any validation rules in the fields returned
+by config_fields().
+
+=item *
+
+class_description() - a brief description of this coupon behaviour,
+for use in drop-down lists when creating a coupon.
+
+=back
+
+The class must implement the following object methods:
+
+=over
+
+=item *
+
+is_active() - return true if the coupon is usable for the cart.  If
+the coupon is not usable, returns $msg as the reson why:
+
+  my ($active, $msg) = $cb->is_active($coupon, $cart);
+
+This is in addition to the date and tier checks already done for the coupon.
+
+=item *
+
+discount() - return the discount in cents provided by the coupon on
+the supplied cart.
+
+  my ($cents) = $cb->discount($coupon, $cart);
+
+Must only be called if is_active() returns true for the cart.
+
+=item *
+
+product_valid($coupon, $cart, $index) - returns true if the given line
+item in the cart is valid for the coupon.
+
+Only meaningful for cart-wide coupons.
+
+=item *
+
+product_discount($coupon, $cart, $index) - returns the per-unit
+discount in cents provided by the coupon on the specified entry in the
+given cart.
+
+  my ($cents) = $cb->product_discount($coupon, $cart, $index);
+
+Must only be called if is_active() returns true for the cart.
+
+Returns 0 if the discount is against the entire cart.
+
+A default implementation returns 0.
+
+=item *
+
+product_discount_units($coupon, $cart, $index) - returns the number of
+units the product specific discount applies to on specified entry in
+the given cart.
+
+  my ($cents) = $cb->product_discount_units($coupon, $cart, $index);
+
+Must only be called if is_active() returns true for the cart.
+
+Returns 0 if the discount is against the entire cart.
+
+A default implementation returns 0.
+
+=item *
+
+cart_wide($coupon, $cart) - return true if the coupon behaviour
+provides a cart-wide discount.  Generally this is class-wide, but must
+be called as an instance method in-case that's untrue for some
+particular behaviour class.
+
+The default implementation returns false.
+
+=item *
+
+used_in() - called when a new order if finalized with the given coupon
+code.
+
+  $cb->used_in($coupon_code, $order);
+
+A default implementation is provided that does nothing.
+
+=item *
+
+describe() - return a text description of the coupon based on its
+configuration data.
+
+  my ($text) = $cb->describe();
+
+=back
+
diff --git a/site/cgi-bin/modules/BSE/Coupon/Dollar.pm b/site/cgi-bin/modules/BSE/Coupon/Dollar.pm
new file mode 100644 (file)
index 0000000..6a24965
--- /dev/null
@@ -0,0 +1,82 @@
+package BSE::Coupon::Dollar;
+use parent 'BSE::Coupon::Base';
+use strict;
+
+our $VERSION = "1.003";
+
+sub config_fields {
+  my ($class) = @_;
+
+  return
+    {
+     min_cart =>
+     {
+      description => "Min Cart Value",
+      required => 1,
+      width => 5,
+      htmltype => "text",
+      rules => "money",
+      type => "money",
+      order => 1,
+     },
+     discount_dollars =>
+     {
+      description => "Discount \$",
+      required => 1,
+      width => 5,
+      htmltype => "text",
+      rules => "money",
+      type => "money",
+      order => 2,
+     },
+    };
+}
+
+sub config_valid {
+  1;
+}
+
+sub class_description {
+  "Simple dollar cart discount";
+}
+
+sub is_active {
+  my ($self, $coupon, $cart) = @_;
+
+  $self->test_all_tiers_match($coupon, $cart)
+    or return ( 0, "One or more products are already discounted" );
+  unless ($cart->total_cost >= $self->{config}{min_cart}) {
+    require BSE::Util::Format;
+    return ( 0, sprintf("You need \$%s of items in the cart for the discount",
+                       BSE::Util::Format::bse_number("money", $self->{config}{min_cart})) );
+  }
+
+  1;
+}
+
+sub product_valid {
+  my ($self, $coupon, $cart, $index) = @_;
+
+  return $self->test_tier_matches($coupon, $cart, $index);
+}
+
+sub discount {
+  my ($self, $coupon, $cart) = @_;
+
+  return 0
+    if $cart->total_cost < $self->{config}{min_cart};
+
+  return $self->{config}{discount_dollars};
+}
+
+sub describe {
+  my ($self) = @_;
+
+  require BSE::Util::Format;
+
+  sprintf("\$%s discount on cart over \$%s",
+         BSE::Util::Format::bse_number("money", $self->{config}{discount_dollars}),
+         BSE::Util::Format::bse_number("money", $self->{config}{min_cart}));
+}
+
+1;
diff --git a/site/cgi-bin/modules/BSE/Coupon/Percent.pm b/site/cgi-bin/modules/BSE/Coupon/Percent.pm
new file mode 100644 (file)
index 0000000..9ebf6e7
--- /dev/null
@@ -0,0 +1,70 @@
+package BSE::Coupon::Percent;
+use parent 'BSE::Coupon::Base';
+use strict;
+
+our $VERSION = "1.002";
+
+sub config_fields {
+  my ($class) = @_;
+
+  return
+    {
+     discount_percent =>
+     {
+      description => "Discount",
+      required => 1,
+      width => 5,
+      htmltype => "text",
+      rules => "coupon_percent",
+      units => "%",
+      order => 1,
+     },
+    };
+}
+
+sub config_rules {
+  return
+    (
+     coupon_percent =>
+     {
+      real => '0 - 100',
+     },
+    );
+}
+
+sub config_valid {
+  1;
+}
+
+sub class_description {
+  "Simple percentage cart discount";
+}
+
+sub is_active {
+  my ($self, $coupon, $cart) = @_;
+
+  $self->test_all_tiers_match($coupon, $cart)
+    or return ( 0, "One or more products are already discounted" );
+
+  1;
+}
+
+sub product_valid {
+  my ($self, $coupon, $cart, $index) = @_;
+
+  return $self->test_tier_matches($coupon, $cart, $index);
+}
+
+sub discount {
+  my ($self, $coupon, $cart) = @_;
+
+  return int($cart->total_cost * $self->{config}{discount_percent} / 100);
+}
+
+sub describe {
+  my ($self) = @_;
+
+  sprintf("%.1f%% cart discount", $self->{config}{discount_percent});
+}
+
+1;
diff --git a/site/cgi-bin/modules/BSE/Coupon/ProductPercent.pm b/site/cgi-bin/modules/BSE/Coupon/ProductPercent.pm
new file mode 100644 (file)
index 0000000..82dca21
--- /dev/null
@@ -0,0 +1,181 @@
+package BSE::Coupon::ProductPercent;
+use parent 'BSE::Coupon::Base';
+use strict;
+
+our $VERSION = "1.000";
+
+sub config_fields {
+  my ($class) = @_;
+
+  require BSE::TB::Products;
+
+  return
+    {
+     discount_percent =>
+     {
+      description => "Discount",
+      required => 1,
+      width => 5,
+      htmltype => "text",
+      rules => "coupon_percent",
+      units => "%",
+      order => 1,
+     },
+     product_id =>
+     {
+      description => "Product",
+      required => 1,
+      htmltype => "select",
+      order => 2,
+      select =>
+      {
+       id => "id",
+       label => "label",
+       values =>
+       [
+       sort { lc $a->{label} cmp lc $b->{label} }
+       map
+       +{
+         id => $_->id,
+         label => $_->title,
+        }, BSE::TB::Products->all
+       ],
+      },
+     },
+     max_units =>
+     {
+      description => "Max Units",
+      required => 1,
+      width => 5,
+      htmltype => "text",
+      rules => "natural",
+      units => "units",
+      order => 3,
+     },
+    };
+}
+
+sub config_rules {
+  return
+    (
+    );
+}
+
+sub config_valid {
+  my ($self, $config, $errors) = @_;
+
+  require BSE::TB::Products;
+  my ($prod) = BSE::TB::Products->getByPkey($config->{product_id});
+  unless ($prod) {
+    $errors->{product_id} = "Unknown product id";
+  }
+  !keys %$errors;
+}
+
+sub class_description {
+  "Simple product specific discount";
+}
+
+sub _product {
+  my ($self) = @_;
+
+  unless ($self->{_product}) {
+    require BSE::TB::Products;
+    $self->{_product} = BSE::TB::Products->getByPkey($self->{config}{product_id});
+  }
+
+  $self->{_product};
+}
+
+sub is_active {
+  my ($self, $coupon, $cart) = @_;
+
+  for my $item ($cart->items) {
+    return 1
+      if $item->product->id == $self->{config}{product_id};
+  }
+
+  require BSE::TB::Products;
+  my ($prod) = $self->_product;
+
+  return ( 0, "This coupon only applies to ".$prod->title );
+}
+
+sub cart_wide {
+  0;
+}
+
+sub _row_discounts {
+  my ($self, $coupon, $cart) = @_;
+
+  my $max_units = $self->{config}{max_units};
+  my $product_id = $self->{config}{product_id};
+  my $discount = $self->{config}{discount_percent};
+  my $units_seen = 0;
+  my @row_discounts;
+  for my $item ($cart->items) {
+    my $discount_units = 0;
+    my $row_discount = 0;
+    if ($item->product_id == $product_id) {
+      my $prod_discount = int($item->price * $discount / 100);
+      if ($max_units) {
+       if ($units_seen < $max_units) {
+         $row_discount = $prod_discount;
+         my $units_left = $max_units - $units_seen;
+         $discount_units = $units_left > $item->units ? $item->units : $units_left;
+       }
+      }
+      else {
+       $row_discount = $prod_discount;
+       $discount_units = $item->units;
+      }
+      $units_seen += $item->units;
+    }
+    push @row_discounts, [ $row_discount, $discount_units ];
+  }
+
+  @row_discounts;
+}
+
+sub discount {
+  my ($self, $coupon, $cart) = @_;
+
+  my @discounts = $self->_row_discounts($coupon, $cart);
+
+  my $total_discount = 0;
+  for my $row (@discounts) {
+    $total_discount += $row->[0] * $row->[1];
+  }
+
+  return $total_discount;
+}
+
+sub product_discount {
+  my ($self, $coupon, $cart, $index) = @_;
+
+  my @discounts = $self->_row_discounts($coupon, $cart);
+
+  return $discounts[$index][0];
+}
+
+sub product_discount_units {
+  my ($self, $coupon, $cart, $index) = @_;
+
+  my @discounts = $self->_row_discounts($coupon, $cart);
+
+  return $discounts[$index][1];
+}
+
+sub describe {
+  my ($self) = @_;
+
+  my $desc = sprintf("%.1f%% discount on ", $self->{config}{discount_percent});
+  if ($self->{config}{max_units}) {
+    $desc .= "the first $self->{config}{max_units} units of ";
+  }
+  $desc .= $self->_product->title;
+
+  $desc;
+}
+
+1;
index 7fb1413..f03c63f 100644 (file)
@@ -5,7 +5,7 @@ use BSE::Cfg;
 use BSE::Util::HTML;
 use Carp qw(cluck confess);
 
-our $VERSION = "1.033";
+our $VERSION = "1.034";
 
 =head1 NAME
 
@@ -1813,6 +1813,10 @@ sub cgi_fields {
       my ($hour, $minute, $sec) = DevHelp::Date::dh_parse_time($value, \$msg);
       $value = sprintf("%02d:%02d:%02d", $hour, $minute, $sec);
     }
+    elsif ($field->{type} && $field->{type} eq "money") {
+      ($value) = $cgi->param($name);
+      $value *= 100;
+    }
     else {
       ($value) = $cgi->param($name);
       defined $name or $value = "";
index 4d36e00..232fec8 100644 (file)
@@ -4,7 +4,7 @@ use Squirrel::Row;
 our @ISA = qw/Squirrel::Row/;
 use BSE::TB::CouponTiers;
 
-our $VERSION = "1.003";
+our $VERSION = "1.007";
 
 =head1 NAME
 
@@ -27,7 +27,8 @@ Represents shop coupons.
 =cut
 
 sub columns {
-  return qw/id code description release expiry discount_percent campaign last_modified untiered/;
+  return qw/id code description release expiry discount_percent campaign last_modified untiered
+            classid config/;
 }
 
 sub table {
@@ -40,6 +41,7 @@ sub defaults {
     (
      last_modified => BSE::Util::SQL::now_sqldatetime(),
      untiered => 1,
+     discount_percent => undef,
     );
 }
 
@@ -139,6 +141,8 @@ sub json_data {
 
   my $data = $self->data_only;
   $data->{tiers} = [ $self->tiers ];
+  $data->{config_obj} = $self->config_obj;
+  delete @$data{qw/config discount_percent/};
 
   return $data;
 }
@@ -208,6 +212,121 @@ sub is_renamable {
   return $self->is_removable;
 }
 
+=item is_active
+
+Returns a list of (is active, message) for the given cart.
+
+Wrapper around is_active() for the behaviour.
+
+  my ($active, $msg) = $coupon->is_active($cart);
+
+=cut
+
+sub is_active {
+  my ($self, $cart) = @_;
+
+  return $self->behaviour->is_active($self, $cart);
+}
+
+=item discount
+
+Return the discount in cents for the given cart.
+
+Must only be called if is_active() returned the coupon as active.
+
+Wrapper around discount() for the behaviour.
+
+  my ($cents) = $coupon->discount($cart);
+
+=cut
+
+sub discount {
+  my ($self, $cart) = @_;
+
+  return $self->behaviour->discount($self, $cart);
+}
+
+=item product_valid
+
+Return true if the given cart item is valid for the coupon.
+
+Only relevant for cart-wide coupons.
+
+=cut
+
+sub product_valid {
+  my ($self, $cart, $index) = @_;
+
+  return $self->behaviour->product_valid($self, $cart, $index);
+}
+
+=item product_discount
+
+Return the product specific discount per unit for the given row
+(counting from zero) in the cart.
+
+Must only be called if is_active() returned the coupon as active.
+
+Returns zero if the coupon discount is for the cart as a whole.
+
+Wrapper around product_discount() for the behaviour.
+
+  my $cents = $coupon->product_discount($cart, $index);
+
+=cut
+
+sub product_discount {
+  my ($self, $cart, $index) = @_;
+
+  return $self->behaviour->product_discount($self, $cart, $index);
+}
+
+=item product_discount_units
+
+Return the number of units a product specific discount applies to the given row
+(counting from zero) in the cart.
+
+Must only be called if is_active() returned the coupon as active.
+
+Returns zero if the coupon discount is for the cart as a whole.
+
+Wrapper around product_discount_units() for the behaviour.
+
+  my $cents = $coupon->product_discount_units($cart, $index);
+
+=cut
+
+sub product_discount_units {
+  my ($self, $cart, $index) = @_;
+
+  return $self->behaviour->product_discount_units($self, $cart, $index);
+}
+
+=item describe
+
+Describe the behaviour of the coupon briefly.
+
+=cut
+
+sub describe {
+  my ($self) = @_;
+
+  $self->behaviour->describe;
+}
+
+=item cart_wide($cart)
+
+Returns true if the discount provided by the behaviour applies to the
+cart as a whole.
+
+=cut
+
+sub cart_wide {
+  my ($self, $cart) = @_;
+
+  return $self->behaviour->cart_wide($cart);
+}
+
 =item set_code($code)
 
 Set the coupon code.  Requires that is_renamable() be true.
@@ -226,6 +345,8 @@ sub set_code {
 sub fields {
   my ($self) = @_;
 
+  my $bclasses = BSE::TB::Coupons->behaviour_classes;
+
   my %fields =
     (
      code =>
@@ -263,15 +384,6 @@ sub fields {
       type => "date",
       rules => "date",
      },
-     discount_percent =>
-     {
-      description => "Discount %",
-      required => 1,
-      width => 5,
-      htmltype => "text",
-      rules => "coupon_percent",
-      units => "%",
-     },
      campaign =>
      {
       description => "Campaign",
@@ -303,6 +415,27 @@ sub fields {
        label => "description",
       },
      },
+     classid =>
+     {
+      description => "Coupon Class",
+      htmltype => "select",
+      select =>
+      {
+       id => "id",
+       label => "label",
+       values =>
+       [
+       sort { lc $a->{label} cmp lc $b->{label} }
+       map
+       +{
+         id => $_,
+         label => $bclasses->{$_}->class_description,
+        },
+       sort { lc $bclasses->{$a}->class_description cmp lc $bclasses->{$b}->class_description}
+       keys %$bclasses
+       ],
+      },
+     },
     );
 
   if (ref $self && !$self->is_renamable) {
@@ -321,13 +454,46 @@ sub rules {
       match => qr/\A[a-zA-Z0-9]+\z/,
       error => '$n can only contain letters and digits',
      },
-     coupon_percent =>
-     {
-      real => '0 - 100',
-     },
     };
 }
 
+sub config_obj {
+  my ($self) = @_;
+
+  my $config = $self->config;
+  $config = "{}" if $config eq "";
+
+  require JSON;
+  my $obj = JSON->new->decode($config);
+  $obj->{discount_percent} = $self->discount_percent;
+
+  return $obj;
+}
+
+sub set_config_obj {
+  my ($self, $obj) = @_;
+
+  $self->set_discount_percent(delete $obj->{discount_percent});
+
+  require JSON;
+  $self->set_config(JSON->new->encode($obj));
+}
+
+sub behaviour {
+  my ($self) = @_;
+
+  delete $self->{_behaviour}
+    if $self->{_classid} ne $self->classid
+    || $self->{_config} ne $self->config;
+
+  $self->{_behaviour} ||=
+    BSE::TB::Coupons->behaviour_class($self->classid)->new($self->config_obj);
+  $self->{_classid} = $self->classid;
+  $self->{_config} = $self->config;
+
+  $self->{_behaviour};
+}
+
 1;
 
 =back
index 24894f5..6db06eb 100644 (file)
@@ -4,7 +4,7 @@ use Squirrel::Table;
 our @ISA = qw(Squirrel::Table);
 use BSE::TB::Coupon;
 
-our $VERSION = "1.001";
+our $VERSION = "1.002";
 
 sub rowClass {
   return 'BSE::TB::Coupon';
@@ -15,6 +15,13 @@ sub make {
 
   my $tiers = delete $opts{tiers};
 
+  my $config_obj = delete $opts{config_obj};
+  if ($config_obj) {
+    $opts{discount_percent} = delete $config_obj->{discount_percent};
+    require JSON;
+    $opts{config} = JSON->new->encode($config_obj);
+  }
+
   my $coupon = $self->SUPER::make(%opts);
 
   if ($tiers) {
@@ -25,4 +32,45 @@ sub make {
   return $coupon;
 }
 
+=item behaviour_class
+
+Return the class name of a behaviour class given a class id.
+
+Loads the class.
+
+Throws an exception if no class if configured for the class id or if
+the class cannot be loaded.
+
+=cut
+
+sub behaviour_class {
+  my ($class, $classid) = @_;
+
+  my $bclass = BSE::Cfg->single->entryErr("coupon classes", $classid);
+  (my $bfile = $bclass . ".pm") =~ s(::)(/)g;
+
+  require $bfile;
+
+  return $bclass;
+}
+
+=item behaviour_classes
+
+Returns a hash of all behaviour classes with the keys being the
+classid and the value the class name.
+
+=cut
+
+sub behaviour_classes {
+  my ($class) = @_;
+
+  my %entries = BSE::Cfg->single->entries("coupon classes");
+  my %bclasses;
+  for my $classid (keys %entries) {
+    $bclasses{$classid} = $class->behaviour_class($classid);
+  }
+
+  \%bclasses;
+}
+
 1;
index 6f9a4ac..d73ac07 100644 (file)
@@ -7,7 +7,7 @@ use vars qw/@ISA/;
 use Carp 'confess';
 use BSE::Shop::PaymentTypes;
 
-our $VERSION = "1.026";
+our $VERSION = "1.029";
 
 sub columns {
   return qw/id
@@ -30,7 +30,8 @@ sub columns {
            delivStreet2 billStreet2 purchase_order shipping_method
            shipping_name shipping_trace
           paypal_token paypal_tran_id freight_tracking stage ccPAN
-          paid_manually coupon_id coupon_code_discount_pc delivery_in/;
+          paid_manually coupon_id coupon_code_discount_pc delivery_in
+           product_cost_discount coupon_cart_wide coupon_description/;
 }
 
 sub table {
@@ -771,21 +772,13 @@ sub discounted_product_cost {
 
   my $cost = $self->total_cost;
 
-  $cost -= $cost * $self->coupon_code_discount_pc / 100;
-
-  return int($cost);
-}
-
-=item product_cost_discount
-
-Return any amount taken off the product cost.
-
-=cut
+  if ($self->product_cost_discount) {
+    return $cost - $self->product_cost_discount;
+  }
 
-sub product_cost_discount {
-  my ($self) = @_;
+  $cost -= int($cost * $self->coupon_code_discount_pc / 100);
 
-  return $self->total_cost - $self->discounted_product_cost;
+  return $cost;
 }
 
 =item coupon
index eaf968b..6364fb0 100644 (file)
@@ -5,7 +5,7 @@ use Squirrel::Row;
 use vars qw/@ISA/;
 @ISA = qw/Squirrel::Row/;
 
-our $VERSION = "1.005";
+our $VERSION = "1.006";
 
 sub table { "order_item" }
 
@@ -13,7 +13,7 @@ sub columns {
   return qw/id productId orderId units price wholesalePrice gst options
             customInt1 customInt2 customInt3 customStr1 customStr2 customStr3
             title description subscription_id subscription_period max_lapsed
-            session_id product_code tier_id/;
+            session_id product_code tier_id product_discount product_discount_units/;
 }
 
 sub db_columns {
index 98c9b62..9003b17 100644 (file)
@@ -21,7 +21,7 @@ use BSE::CfgInfo qw(cfg_dist_image_uri);
 use BSE::Util::SQL qw/now_sqldate sql_to_date date_to_sql sql_date sql_datetime/;
 use BSE::Util::Valid qw/valid_date/;
 
-our $VERSION = "1.028";
+our $VERSION = "1.030";
 
 my %actions =
   (
@@ -1272,6 +1272,25 @@ sub req_coupon_list {
   return $req->dyn_response('admin/coupons/list', \%acts);
 }
 
+# coupon behaviour classes wrapped for use in templates
+
+sub _coupon_behaviours {
+  my ($self) = @_;
+
+  require BSE::TB::Coupons;
+  my $bclasses = BSE::TB::Coupons->behaviour_classes();
+  return
+    [
+     map
+     +{
+       id => $_,
+       behaviour => Squirrel::Template::Expr::WrapClass->new($bclasses->{$_})
+      },
+     sort { lc $bclasses->{$a}->class_description cmp lc $bclasses->{$b}->class_description}
+     keys %$bclasses
+    ]
+}
+
 =item coupon_addform
 
 Display a form for adding new coupons.
@@ -1315,6 +1334,7 @@ sub req_coupon_addform {
   $req->set_variable(errors => $errors || {});
   require BSE::TB::PriceTiers;
   $req->set_variable(tiers => [ BSE::TB::PriceTiers->all ]);
+  $req->set_variable(behaviours => $self->_coupon_behaviours);
 
   return $req->dyn_response("admin/coupons/add", \%acts);
 }
@@ -1339,9 +1359,20 @@ sub req_coupon_add {
   my %errors;
   $req->validate(fields => $fields, errors => \%errors,
                 rules => BSE::TB::Coupon->rules);
-
   my $values = $req->cgi_fields(fields => $fields);
 
+  unless ($errors{classid}) {
+    my $bh = BSE::TB::Coupons->behaviour_class($values->{classid});
+    my $bfields = $bh->config_fields();
+    my $brules = $bh->config_rules();
+    $req->validate(fields => $bfields, rules => $brules,
+                  errors => \%errors);
+    unless (keys %errors) {
+      $values->{config_obj} = $req->cgi_fields(fields => $bfields);
+      $bh->config_valid($values->{config_obj}, \%errors);
+    }
+  }
+
   unless ($errors{code}) {
     my ($other) = BSE::TB::Coupons->getBy(code => $values->{code});
     $other
@@ -1470,6 +1501,7 @@ sub req_coupon_edit {
   $req->set_variable(errors => $errors || {});
   require BSE::TB::PriceTiers;
   $req->set_variable(tiers => [ BSE::TB::PriceTiers->all ]);
+  $req->set_variable(behaviours => $self->_coupon_behaviours);
 
   return $req->dyn_response("admin/coupons/edit", \%acts);
 }
@@ -1509,6 +1541,18 @@ sub req_coupon_save {
 
   my $values = $req->cgi_fields(fields => $fields);
 
+  unless ($errors{classid}) {
+    my $bh = BSE::TB::Coupons->behaviour_class($values->{classid});
+    my $bfields = $bh->config_fields();
+    my $brules = $bh->config_rules();
+    $req->validate(fields => $bfields, rules => $brules,
+                  errors => \%errors);
+    unless (keys %errors) {
+      $values->{config_obj} = $req->cgi_fields(fields => $bfields);
+      $bh->config_valid($values->{config_obj}, \%errors);
+    }
+  }
+
   unless ($errors{code}) {
     my ($other) = BSE::TB::Coupons->getBy(code => $values->{code});
     $other && $other->id != $coupon->id
@@ -1524,10 +1568,12 @@ sub req_coupon_save {
   my $old = $coupon->json_data;
 
   my $tiers = delete $values->{tiers};
+  my $config_obj = delete $values->{config_obj};
   for my $key (keys %$values) {
     $coupon->set($key => $values->{$key});
   }
   $coupon->set_tiers($tiers);
+  $coupon->set_config_obj($config_obj);
   $coupon->save;
 
   $req->audit
index d67639e..0a5bdcc 100644 (file)
@@ -18,7 +18,7 @@ use BSE::Countries qw(bse_country_code);
 use BSE::Util::Secure qw(make_secret);
 use BSE::Template;
 
-our $VERSION = "1.049";
+our $VERSION = "1.051";
 
 =head1 NAME
 
@@ -1807,6 +1807,14 @@ sub _build_items {
       $work{extended_retailPrice} = $work{units} * $work{price};
       $work{extended_gst} = $work{units} * $work{gst};
       $work{extended_wholesale} = $work{units} * $work{wholesalePrice};
+      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;
+      }
       
       push @newcart, \%work;
     }
@@ -1892,9 +1900,13 @@ sub _fillout_order {
   }
   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});
@@ -1902,6 +1914,7 @@ sub _fillout_order {
   $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);
index 2a20dcc..a24cb90 100644 (file)
@@ -2,7 +2,7 @@ package Squirrel::Template::Expr::WrapScalar;
 use strict;
 use base qw(Squirrel::Template::Expr::WrapBase);
 
-our $VERSION = "1.010";
+our $VERSION = "1.011";
 
 sub _do_length  {
   my ($self, $args) = @_;
@@ -244,7 +244,7 @@ sub _do_match {
   my ($self, $args) = @_;
 
   @$args == 1
-    or die [ error => "scalar.escape requires one parameter" ];
+    or die [ error => "scalar.match requires one parameter" ];
 
   $self->[0] =~ $args->[0]
     or return undef;
index 653a83a..c27ecd5 100644 (file)
@@ -797,4 +797,8 @@ fieldset > ul {
   font-weight: bold;
   text-align: center;
   padding: 0.5em;
+}
+
+.price_tier, .productdiscount {
+  font-size: 90%;
 }
\ No newline at end of file
index a91d9fe..d96bb59 100644 (file)
@@ -189,4 +189,10 @@ div#admin_messages {
   background-color: #FCC;
   border: 1px dashed #F00;
   padding: 2px 5px;
+}
+
+.itemdiscount {
+  color: green;
+  font-size: 80%;
+  padding-left: 1em;
 }
\ No newline at end of file
index 4f52a63..0645439 100644 (file)
@@ -1,5 +1,6 @@
 /* stuff for every admin page */
 /* mark accesskeys */
+jQuery.noConflict();
 document.observe("dom:loaded", function() {
   $$("label").each(function(label) {
     if (!label.htmlFor)
@@ -67,4 +68,4 @@ document.observe("dom:loaded", function() {
       }
     }.bindAsEventListener(ele));
   });
-});
\ No newline at end of file
+});
diff --git a/site/htdocs/js/admin_coupons.js b/site/htdocs/js/admin_coupons.js
new file mode 100644 (file)
index 0000000..1334176
--- /dev/null
@@ -0,0 +1,13 @@
+(function($) {
+    var $form = $("#coupon_form");
+    var $classid = $("select[name=classid]", $form);
+    $classid.on("change", function(ev) {
+       var val = ev.target.value;
+       var val_sel = "[data-behaviour=" + val + "]";
+       $("[data-behaviour]", $form).hide();
+       $("[data-behaviour] input, [data-behaviour] select", $form).prop("disabled", true);
+       $(val_sel + " input, " + val_sel + " select", $form).prop("disabled", false);
+       $(val_sel, $form).show();
+       
+    });
+})(jQuery);
index 886d20f..a660dd6 100644 (file)
@@ -5,6 +5,7 @@
     <link rel="stylesheet" href="/css/admin.css" type="text/css" />
 <:ifParam css:><link rel="stylesheet" href="/css/<:param css:>" type="text/css" /><:or:><:eif:>
 <:ajax includes:>
+<:ajax jquery:>
 <script type="text/javascript" src="/js/bse.js"></script>
 <script type="text/javascript" src="/js/admin.js"></script>
 <script type="text/javascript" src="/js/swfobject.js"></script>
index caa4d2a..457d809 100644 (file)
@@ -7,7 +7,7 @@
 </p>
 <:.call "messages"-:>
 <:.set object = coupon -:>
-<form action="<:= cfg.admin_url("shopadmin") :>" method="post">
+<form action="<:= cfg.admin_url("shopadmin") :>" method="post" id="coupon_form">
   <:csrfp admin_bse_coupon_add hidden:>
   <fieldset>
     <legend>Coupon Details</legend>
     <:.call "field", "name":"description" :>
     <:.call "field", "name":"release" :>
     <:.call "field", "name":"expiry" :>
-    <:.call "field", "name":"discount_percent" :>
+    <:.call "field", "name":"classid" :>
+    <:.set classid = cgi.param("classid") ? [ cgi.param("classid") ][0] : behaviours[0].id -:>
+    <:.for bh in behaviours -:>
+      <:.set fs = bh.behaviour.config_fields -:>
+      <:.set ordered_f = fs.keys.sort(@{a,b: fs[a].order <=> fs[b].order }) -:>
+      <:.set attr = { "data-behaviour": bh.id } -:>
+      <:.set inputattr = { } -:>
+      <:.if classid ne bh.id -:>
+        <:% attr.set("style", "display: none") -:>
+        <:% inputattr.set("disabled", "disabled") -:>
+      <:.end if -:>
+      <:.for f in ordered_f -:>
+        <:.call "field", name:f, fields: fs, options: { htmlattr: attr, inputattr: inputattr } -:>
+      <:.end for -:>
+    <:.end for -:>
     <:.call "field", "name":"campaign" :>
   </fieldset>
   <:.call "fieldset", "name":"tiers" :>
@@ -23,3 +37,5 @@
     <input type="submit" name="a_coupon_add" value="Add Coupon">
   </p>
 </form>
+<script type="text/javascript" src="/js/admin_coupons.js"></script>
+
index 9eab3ab..cf5fb2f 100644 (file)
@@ -7,7 +7,7 @@
 </p>
 <:.call "messages"-:>
 <:.set object = coupon -:>
-<form action="<:= cfg.admin_url("shopadmin") :>" method="post">
+<form action="<:= cfg.admin_url("shopadmin") :>" method="post" id="coupon_form">
   <:csrfp admin_bse_coupon_edit hidden:>
   <input type="hidden" name="id" value="<:= coupon.id :>">
   <fieldset>
     <:.call "field", "name":"description" :>
     <:.call "field", "name":"release" :>
     <:.call "field", "name":"expiry" :>
-    <:.call "field", "name":"discount_percent" :>
+    <:.call "field", "name":"classid" :>
+    <:.set classid = cgi.param("classid") ? [ cgi.param("classid") ][0] : coupon.classid -:>
+    <:.set config = coupon.config_obj -:>
+    <:.for bh in behaviours -:>
+      <:.set fs = bh.behaviour.config_fields -:>
+      <:.set ordered_f = fs.keys.sort(@{a,b: fs[a].order <=> fs[b].order }) -:>
+      <:.set attr = { "data-behaviour": bh.id } -:>
+      <:.set inputattr = { } -:>
+      <:.if classid ne bh.id -:>
+        <:% attr.set("style", "display: none") -:>
+        <:% inputattr.set("disabled", "disabled") -:>
+      <:.end if -:>
+      <:.for f in ordered_f -:>
+        <:.call "field", name:f, fields: fs, options: { htmlattr: attr, inputattr: inputattr }, object: config -:>
+      <:.end for -:>
+    <:.end for -:>
     <:.call "field", "name":"campaign" :>
   </fieldset>
   <:.call "fieldset", "name":"tiers" :>
@@ -24,3 +39,4 @@
     <input type="submit" name="a_coupon_save" value="Save Coupon">
   </p>
 </form>
+<script type="text/javascript" src="/js/admin_coupons.js"></script>
index 677e519..4292036 100644 (file)
@@ -71,6 +71,9 @@
        <:% classes.push("released") -:>
     <:.end if -:>
     <:.set tier_names = [] -:>
+    <:.if coupon.untiered -:>
+      <:% tier_names.push("(untiered)") -:>
+    <:.end if :>
     <:.for tier in [ coupon.tier_objects ] -:>
       <:% tier_names.push(tier.description) -:>
     <:.end for -:>
@@ -80,7 +83,7 @@
     <td class="col_description"><:= coupon.description :></td>
     <td class="col_release"><:= bse.date("%d/%m/%Y", coupon.release) :></td>
     <td class="col_expiry"><:= bse.date("%d/%m/%Y", coupon.expiry) :></td>
-    <td class="col_discount"><:= coupon.discount_percent :>%</td>
+    <td class="col_discount"><:= coupon.describe :></td>
     <td class="col_tiers"><:= tier_names.size ? tier_names.join(", ") : "(none)" :></td>
     <td class="col_campaign"><:= coupon.campaign :></td>
     <td class="col_actions">
index 363a27a..31e94f4 100644 (file)
   <:.end if -:>
         <tr> 
           <td class="col_description"><:.if product :><a href="<:= product.admin :>"><:= product.title :></a><:.else:><:= item.title :> (product deleted)<:.end if:> <:= item.nice_options:>
-         <:.if item.tier_id:><br><span class="price_tier"><:= item.tier.description :></span><:.end if:></td>
+         <:.if item.tier_id:><br><span class="price_tier"><:= item.tier.description :></span><:.end if:>
+         <:.if item.product_discount_units -:>
+         <br><span class="productdiscount">
+           <:.if item.product_discount_units < item.units -:>
+         (Saved $<:= bse.number("money", item.product_discount) :> on the first <:= item.product_discount_units :> units)
+           <:-.else -:>
+Saved $<:= bse.number("money", item.product_discount) :> on each unit
+           <:.end if -:>
+&nbsp;(total $<:= bse.number("money", item.product_discount * item.product_discount_units) :>)
+          </span>
+         <:-.end if -:>
+         </td>
           <td class="col_units"><:= item.units:></td>
           <td class="col_unit_wsale"><:= bse.number("money", item.wholesalePrice) :></td>
           <td class="col_ext_wsale"><:= bse.number("money", item.extended("wholesalePrice")) :></td>
 </tr>
 <:.if order.coupon -:>
 <tr>
-   <td>Coupon code <b><:= order.coupon_code -:></b></td>
+   <td>Coupon code <b><:= order.coupon_code -:></b> (<:= order.coupon_description :>)</td>
    <td colspan="6" class="col_label_right">Discount:</td>
    <td class="col_extension">(<:= bse.number("money", order.product_cost_discount) -:>)</td>
 </tr>
index 911e0cf..9a7614d 100644 (file)
@@ -23,8 +23,8 @@
 <:- .if article.metaDescription :>
   <meta name="description" content="<:= article.metaDescription:>" />
 <:- .end if:>
-<:- .if article.linkAlias or article.id == 1:>
   <link rel="stylesheet" type="text/css" href="/css/style-main.css">
+<:- .if article.linkAlias or article.id == 1:>
   <link rel="canonical" href="<:= url(article, 1) :>" />
 <:- .end if:>
 <:ajax includes:>
index 9c29728..b4a1553 100644 (file)
 <:= item.coupon_applies ? "Y" : "N" -:>
            </td>
 <:.end if -:>
-            <td width="100%" align="left"> &nbsp;<font face="Verdana, Arial, Helvetica, sans-serif" size="-2"><a href="<:= item.link | html:>"><:= item.product.description | html :> <:.if options.size:>(<:.for option in options:><:= loop.index ? ", " : "" :><:= option.desc | html:>: 
-              <:= option.display |html :><:.end for:>)<:.end if -:></a><:.if item.session_id:>(session at <:= session.location.description | html:> <:= bse.date("%H:%M %d/%m/%Y", session.when_at) -:>)<:.end if:></font></td>
+            <td width="100%" align="left"> &nbsp;<span class="cartproducttitle"><a href="<:= item.link | html:>"><:= item.product.description | html :></a></span> <:.if options.size:>(<:.for option in options:><:= loop.index ? ", " : "" :><:= option.desc | html:>: 
+              <:= option.display |html :><:.end for:>)<:.end if -:><:.if item.session_id:>(session at <:= session.location.description | html:> <:= bse.date("%H:%M %d/%m/%Y", session.when_at) -:>)<:.end if:>
+<:-.if cart.coupon_active and !cart.coupon_cart_wide and item.product_discount_units > 0 :>
+<br><span class="itemdiscount">
+  <:-.if item.product_discount_units < item.units -:>
+Saved $<:= bse.number("money", item.product_discount) :> on the first <:= item.product_discount_units :> units
+  <:-.else -:>
+Saved $<:= bse.number("money", item.product_discount) :> on each unit
+  <:-.end if -:>
+&nbsp;(total $<:= bse.number("money", item.product_discount * item.product_discount_units) :>)
+</span>
+<:-.end if -:>
+             </td>
             <td nowrap align="center"> 
               <input type="text" name="quantity_<:= loop.index :>" size="2" value="<:= item.units :>">
             </td>
 <:.if cart.coupon_active -:>
 Coupon active
 <:.elsif cart.coupon_valid -:>
-<:.if request.siteuser -:>
-Your cart contains items the code isn't valid for
-<:.else -:>
-You need to logon, or your cart contains items the code isn't valid for.
-<:.end if -:>
+<:= cart.coupon_inactive_message :>
 <:.elsif cart.coupon_code ne "" -:>
 Unknown coupon code
 <:.end if -:>
index 26bffe5..43f191e 100644 (file)
        <:.set items = [ order.items ] -:>
        <:.for item in items -:>
         <tr valign="middle" align="center" bgcolor="#FFFFFF"> 
-          <td width="100%" align="left"> &nbsp;<font face="Verdana, Arial, Helvetica, sans-serif" size="-2"><a href="<:= item.product.link | html :>"><:= item.product.description | html:></a> <:= item.nice_options | html :><:.if item.session_id:>(session at <:= item.session.location.description | html:> <:= bse.date("%H:%M %d/%m/%Y", item.session.when_at) | html:>)<:.end if:></font></td>
+          <td width="100%" align="left"> &nbsp;<span class="cartproducttitle"><a href="<:= item.product.link | html :>"><:= item.product.description | html:></a></span> <:= item.nice_options | html :><:.if item.session_id:>(session at <:= item.session.location.description | html:> <:= bse.date("%H:%M %d/%m/%Y", item.session.when_at) | html:>)<:.end if:>
+<:-.if order.coupon_active and !order.coupon_cart_wide and item.product_discount_units > 0 :>
+<br><span class="itemdiscount">
+  <:-.if item.product_discount_units < item.units -:>
+Saved $<:= bse.number("money", item.product_discount) :> on the first <:= item.product_discount_units :> units
+  <:-.else -:>
+Saved $<:= bse.number("money", item.product_discount) :> on each unit
+  <:-.end if -:>
+&nbsp;(total $<:= bse.number("money", item.product_discount * item.product_discount_units) :>)
+</span>
+<:-.end if -:>
+</td>
           <td nowrap align="center"><font face="Verdana, Arial, Helvetica, sans-serif" size="-2"><:= item.units | html :></font></td>
           <td align="right"> <font face="Verdana, Arial, Helvetica, sans-serif" size="-2"><b>$<:= bse.number("money", item.price) | html :></b></font></td>
         </tr>
index a305026..c328fee 100644 (file)
@@ -99,8 +99,19 @@ function BSE_validateForm {
 <:= item.coupon_applies ? "Y" : "N" -:>
            </td>
 <:.end if -:>
-            <td width="100%" align="left"> &nbsp;<font face="Verdana, Arial, Helvetica, sans-serif" size="-2"><a href="<:= item.link | html:>"><:= item.product.description | html :> <:.if options.size:>(<:.for option in options:><:= loop.index ? ", " : "" :><:= option.desc | html:>: 
-              <:= option.display |html :><:.end for:>)<:.end if -:></a><:.if item.session_id:>(session at <:= session.location.description | html:> <:= bse.date("%H:%M %d/%m/%Y", session.when_at) -:>)<:.end if:></font></td>
+            <td width="100%" align="left"> &nbsp;<span class="cartproducttitle"><a href="<:= item.link | html:>"><:= item.product.description | html :></a></span> <:.if options.size:>(<:.for option in options:><:= loop.index ? ", " : "" :><:= option.desc | html:>: 
+              <:= option.display |html :><:.end for:>)<:.end if -:><:.if item.session_id:>(session at <:= session.location.description | html:> <:= bse.date("%H:%M %d/%m/%Y", session.when_at) -:>)<:.end if:>
+<:-.if cart.coupon_active and !cart.coupon_cart_wide and item.product_discount_units > 0 :>
+<br><span class="itemdiscount">
+  <:-.if item.product_discount_units < item.units -:>
+Saved $<:= bse.number("money", item.product_discount) :> on the first <:= item.product_discount_units :> units
+  <:-.else -:>
+Saved $<:= bse.number("money", item.product_discount) :> on each unit
+  <:-.end if -:>
+&nbsp;(total $<:= bse.number("money", item.product_discount * item.product_discount_units) :>)
+</span>
+<:-.end if -:>
+</td>
             <td nowrap align="center"> 
               <input type="text" name="quantity_<:= loop.index :>" size="2" value="<:= item.units :>">
             </td>
@@ -134,11 +145,7 @@ function BSE_validateForm {
 <:.if cart.coupon_active -:>
 Coupon active
 <:.elsif cart.coupon_valid -:>
-<:.if request.siteuser -:>
-Your cart contains items the code isn't valid for
-<:.else -:>
-You need to logon
-<:.end if -:>
+<:= cart.coupon_inactive_message :>
 <:.elsif cart.coupon_code ne "" -:>
 Unknown coupon code
 <:.end if -:>
@@ -294,7 +301,7 @@ Unknown coupon code
       <td> <font face="Verdana, Arial, Helvetica, sans-serif" size="2"> 
         <textarea name="instructions" rows="5" cols="40" wrap="virtual"><:old instructions:></textarea></font><:error_img instructions:></td>
     </tr>
-<:.if cart.cfg_shipping and cfg.any_physical_products:>
+<:.if cart.cfg_shipping and cart.any_physical_products:>
     <tr>
       <td valign="top"><font face="Verdana, Arial, Helvetica, sans-serif" size="2">Shipping<br /> method:</font></td>
       <td><font face="Verdana, Arial, Helvetica, sans-serif" size="2"><:shipping_select:></font><:error_img shipping_name:> *
index 2d4b762..a26797e 100644 (file)
              <:.set options = [ item.option_list ] -:>
              <:.set session = item.session -:>
           <tr valign="middle" align="center" bgcolor="#FFFFFF"> 
-            <td width="100%" align="left"> &nbsp;<font face="Verdana, Arial, Helvetica, sans-serif" size="-2"><a href="<:= item.link | html:>"><:= item.product.description | html :> <:.if options.size:>(<:.for option in options:><:= loop.index ? ", " : "" :><:= option.desc | html:>: 
-              <:= option.display |html :><:.end for:>)<:.end if -:></a><:.if item.session_id:>(session at <:= session.location.description | html:> <:= bse.date("%H:%M %d/%m/%Y", session.when_at) -:>)<:.end if:></font></td>
+            <td width="100%" align="left"> &nbsp;<span class="cartproducttitle"><a href="<:= item.link | html:>"><:= item.product.description | html :></a></span> <:.if options.size:>(<:.for option in options:><:= loop.index ? ", " : "" :><:= option.desc | html:>: 
+              <:= option.display |html :><:.end for:>)<:.end if -:><:.if item.session_id:>(session at <:= session.location.description | html:> <:= bse.date("%H:%M %d/%m/%Y", session.when_at) -:>)<:.end if:>
+<:-.if ordercart.coupon_active and !ordercart.coupon_cart_wide and item.product_discount_units > 0 :>
+<br><span class="itemdiscount">
+  <:-.if item.product_discount_units < item.units -:>
+Saved $<:= bse.number("money", item.product_discount) :> on the first <:= item.product_discount_units :> units
+  <:-.else -:>
+Saved $<:= bse.number("money", item.product_discount) :> on each unit
+  <:-.end if -:>
+&nbsp;(total $<:= bse.number("money", item.product_discount * item.product_discount_units) :>)
+</span>
+<:-.end if -:>
+</td>
+</td>
             <td nowrap align="center"> 
               <input type="text" name="quantity_<:= loop.index :>" size="2" value="<:= item.units :>">
             </td>
index 925d8d7..f7b1811 100644 (file)
@@ -2,11 +2,11 @@
 <:.set dist_image_uri = cfg.entryIfVar("uri", "dist_images", "/images") -:>
 <:# utility definitions :>
 <:-.define make_select; groups: 0, grouplabel: "label", groupid: "id",
-          itemgroupid: "groupid" -:>
+          itemgroupid: "groupid", attr: {} -:>
   <:-.if !default.defined -:>
     <:-.set default = "" -:>
   <:.end if:>
-  <select name="<:= name | html :>">
+  <select name="<:= name | html :>"<:.call "elementextra", extra:attr:>>
   <:- .if groups -:>
     <:-.for i in list -:>
       <:.if i.$itemgroupid eq "" -:>
@@ -46,8 +46,9 @@ make_multicheck expects:
   desc - the name of the description field.
   name - the name of the input elements
   readonly - true to make it readonly
+  attr - extra attributes to set on the generated inputs
 -:>
-<:-.define make_multicheck; readonly: 0-:>
+<:-.define make_multicheck; readonly: 0, attr: {}-:>
   <:.if !readonly -:>
   <input type="hidden" name="_save_<:= name -:>" value="1">
   <:.end if -:>
@@ -66,6 +67,7 @@ make_multicheck expects:
       id="<:= element_id -:>" value="<:= i.$id :>"
 <:-# readonly attribute isn't valid for checkboxes -:>
 <:-= readonly ? " disabled" : "" -:>
+<:-.call "elementextra", extra: attr -:>
     >
     <label for="<:= element_id :>"><:= i.$desc -:></label>
     </li>
@@ -138,6 +140,16 @@ Page <:= pages.page :> of <:= pages.pagecount :>
 <:.call "error_img_n", index:0 -:>
 <:.end define -:>
 
+<:.define elementextra -:>
+<:  .set extratext = "" -:>
+<:  .if extra.defined -:>
+<:    .for n in extra.keys -:>
+<:      .set extratext = extratext _ " " _ n _ '="' _ extra[n].escape("html") _ '"' -:>
+<:    .end for -:>
+<:  .end if -:>
+<:= extratext |raw -:>
+<:.end define -:>
+
 <:.define input; options: {} -:>
 <:# parameters:
   name - field name
@@ -158,18 +170,20 @@ Page <:= pages.page :> of <:= pages.pagecount :>
 <:    .set default = default.replace(/(\d+)\D+(\d+)\D+(\d+)/, "$3/$2/$1") -:>
 <:  .elsif field.type and field.type eq "time" and default ne "" -:>
 <:    .set default = bse.date(default =~ /:00$/ ? "%I:%M%p" : "%I:%M:%S%p", default).replace(/^0/, "").lower() -:>
+<:  .elsif field.type and field.type eq "money" and default ne "" -:>
+<:    .set default = bse.number("money", default) -:>
 <:  .end if -:>
 <:  .if cgi.param(name).defined -:>
 <:     .set default = cgi.param(name) -:>
 <:  .end if -:>
 <:  .if field.htmltype eq "textarea" -:>
-<textarea id="<:= name | html :>" name="<:= name | html :>" rows="<:= field.height ? field.height : cfg.entry("forms", "textarea_rows", 10) :>" cols=<:= field.width ? field.width : cfg.entry("forms", "textarea_cols", 60) | html :>>
+<textarea id="<:= name | html :>" name="<:= name | html :>" rows="<:= field.height ? field.height : cfg.entry("forms", "textarea_rows", 10) :>" cols=<:= field.width ? field.width : cfg.entry("forms", "textarea_cols", 60) | html :><:.call "elementextra", extra: options.inputattr :>>
 <:-= default | html -:>
 </textarea>
 <:  .elsif field.htmltype eq "checkbox" -:>
 <:.set is_checked = cgi.param("_save_" _ name) ? cgi.param(name).defined : default -:>
 <input type="hidden" name="_save_<:= name -:>" value="1">
-<input id="<:= name | html :>" type="checkbox" name="<:= name | html :>"<:= is_checked ? ' checked="checked"' : '' :> value="<:= field.value ? field.value : 1 | html :>" />
+<input id="<:= name | html :>" type="checkbox" name="<:= name | html :>"<:= is_checked ? ' checked="checked"' : '' :> value="<:= field.value ? field.value : 1 | html :>"<:.call "elementextra", extra: options.inputattr :> />
 <:  .elsif field.htmltype eq "multicheck" -:>
 <:# we expect default to be a list of selected checks -:>
 <:.set values = field.select["values"] -:>
@@ -177,7 +191,8 @@ Page <:= pages.page :> of <:= pages.pagecount :>
 <:.set default = cgi.param("_save_" _ name) ? [ cgi.param(name) ] : default -:>
 <:.call "make_multicheck",
   id:field.select.id,
-  desc:field.select.label -:>
+  desc:field.select.label,
+  attr: options.inputattr -:>
 <:  .elsif field.htmltype eq "select" -:>
 <:.set values = field.select["values"] -:>
 <:.set values = values.is_code ? values() : values -:>
@@ -190,17 +205,18 @@ Page <:= pages.page :> of <:= pages.pagecount :>
     groupid : (field.select.groupid or "id"),
     itemgroupid: (field.select.itemgroupid or "groupid"),
     groups: field.select.groups ? (field.select.groups.is_code ? (field.select.groups)() : field.select.groups ) : 0,
-    grouplabel: (field.select.grouplabel or "label")
+    grouplabel: (field.select.grouplabel or "label"),
+    attr: options.inputattr
 -:>
 <:  .elsif field.htmltype eq 'file' -:>
 <:   .if default.length -:>
 <span class="filename"><:= default :></span>
 <:   .end if -:>
-<input id="<:= name :>" type="file" name="<:= name :>" />
+<input id="<:= name :>" type="file" name="<:= name :>"<:.call "elementextra", extra: options.inputattr :> />
 <:- .else -:>
 <input id="<:= name | html :>" type="text" name="<:= name | html :>" value="<:=  default | html :>" 
 <:-= field.maxlength ? ' maxlength="' _ field.maxlength _ '"' : '' |raw:>
-<:-= field.width ? ' size="' _ field.width _ '"' : '' | raw :> />
+<:-= field.width ? ' size="' _ field.width _ '"' : '' | raw :><:.call "elementextra", extra: options.inputattr :> />
 <:  .end if -:>
 <:.end define -:>
 
@@ -231,9 +247,17 @@ Page <:= pages.page :> of <:= pages.pagecount :>
      note - display this text as a note below the field
      delete - add a delete checkbox
      default - a custom default value, overrides object
+     htmlattr - attributes for the wrapper div
+     inputattr - attributes for the input/select generated
 -:>
   <:.if field.is_hash -:>
-<div>
+  <:.set divextra = "" -:>
+  <:.if options.htmlattr :>
+    <:.for n in options.htmlattr.keys -:>
+      <:.set divextra = divextra _ " " _ n _ '="' _ options.htmlattr[n].escape("html") _ '"' -:>
+    <:.end for -:>
+  <:.end if -:>
+<div<:= divextra |raw:>>
   <label for="<:= name :>"><:= field.nolabel ? "" : field.description | html :>:</label>
   <span>
     <:-.if field.readonly -:>
index 457e3ac..b1274a5 100644 (file)
@@ -183,10 +183,12 @@ Column code;varchar(40);NO;NULL;
 Column description;text;NO;NULL;
 Column release;date;NO;NULL;
 Column expiry;date;NO;NULL;
-Column discount_percent;double;NO;NULL;
+Column discount_percent;double;YES;NULL;
 Column campaign;varchar(20);NO;NULL;
 Column last_modified;datetime;NO;NULL;
 Column untiered;int(11);NO;0;
+Column classid;varchar(20);NO;bse_simple;
+Column config;blob;NO;NULL;
 Index PRIMARY;1;[id]
 Index codes;1;[code]
 Table bse_file_access_log
@@ -598,6 +600,8 @@ Column max_lapsed;int(11);NO;0;
 Column session_id;int(11);NO;-1;
 Column product_code;varchar(80);NO;;
 Column tier_id;int(11);YES;NULL;
+Column product_discount;int(11);NO;0;
+Column product_discount_units;int(11);NO;0;
 Index PRIMARY;1;[id]
 Index order_item_order;0;[orderId;id]
 Index tier_id;0;[tier_id]
@@ -680,8 +684,11 @@ Column stage;varchar(20);NO;;
 Column ccPAN;varchar(4);NO;;
 Column paid_manually;int(11);NO;0;
 Column coupon_id;int(11);YES;NULL;
-Column coupon_code_discount_pc;double;NO;0;
+Column coupon_code_discount_pc;double;YES;0;
 Column delivery_in;int(11);YES;NULL;
+Column product_cost_discount;int(11);NO;0;
+Column coupon_cart_wide;int(11);NO;1;
+Column coupon_description;varchar(255);NO;;
 Index PRIMARY;1;[id]
 Index order_cchash;0;[ccNumberHash]
 Index order_coupon;0;[coupon_id]
index a450386..f888adb 100644 (file)
@@ -1,6 +1,6 @@
 #!perl -w
 use strict;
-use Test::More tests => 38;
+use Test::More tests => 45;
 use_ok("BSE::Cfg");
 use_ok("Squirrel::Template");
 use_ok("BSE::Template");
@@ -19,6 +19,8 @@ use_ok("BSE::TB::TagOwner");
 use_ok("BSE::TB::TagOwners");
 use_ok("BSE::TB::Article");
 use_ok("BSE::TB::Articles");
+use_ok("BSE::TB::Coupons");
+use_ok("BSE::TB::Coupon");
 use_ok('BSE::Generate');
 use_ok('BSE::Generate::Article');
 use_ok('BSE::Generate::Product');
@@ -39,6 +41,11 @@ use_ok("BSE::UI::Interest");
 use_ok("BSE::Request::Base");
 use_ok("BSE::Request");
 use_ok("BSE::Request::Test");
+use_ok("BSE::Coupon::Base");
+use_ok("BSE::Coupon::Percent");
+use_ok("BSE::Coupon::Dollar");
+use_ok("BSE::Coupon::ProductPercent");
+use_ok("BSE::UI::AdminShop");
 
 my $builder = Test::Builder->new;
 $builder->is_passing or $builder->BAIL_OUT;