From a0a3ddfefdd2f8c501f29bd99da2c44a3c4b5a5a Mon Sep 17 00:00:00 2001 From: Glenn Murray Date: Mon, 16 Jun 2008 15:37:04 +1000 Subject: [PATCH] Converted to static version --- README | 38 +++++- app/controllers/admin/coupon_controller.rb | 12 ++ .../admin/product_price_controller.rb | 12 ++ app/controllers/cart_controller.rb | 86 ++++++++++-- app/helpers/admin/product_helper.rb | 9 ++ app/models/cart.rb | 85 +++++++----- app/models/cart_item.rb | 30 ++++- app/models/coupon.rb | 23 ++++ app/models/product.rb | 39 +++++- app/models/product_price.rb | 11 ++ app/models/store_page.rb | 39 ++++-- app/views/admin/coupon/edit.rhtml | 38 ++++++ app/views/admin/coupon/remove.rhtml | 21 +++ app/views/admin/product/edit.rhtml | 10 +- app/views/admin/product/index.rhtml | 56 ++++++-- app/views/admin/product/remove.rhtml | 2 +- app/views/admin/product_price/edit.rhtml | 33 +++++ app/views/admin/product_price/remove.rhtml | 21 +++ db/migrate/002_create_product_prices.rb | 19 +++ db/migrate/003_add_product_category.rb | 11 ++ db/migrate/004_create_coupons.rb | 14 ++ shopping_trike_extension.rb | 24 +++- test/functional/cart_controller_test.rb | 72 ++++++++++- test/functional/store_controller_test.rb | 18 --- test/unit/cart_test.rb | 122 ++++++++++++++++++ test/unit/coupon_test.rb | 65 ++++++++++ test/unit/product_price_test.rb | 38 ++++++ test/unit/product_test.rb | 37 +++--- test/unit/store_page_test.rb | 38 +++++- 29 files changed, 890 insertions(+), 133 deletions(-) create mode 100644 app/controllers/admin/coupon_controller.rb create mode 100644 app/controllers/admin/product_price_controller.rb create mode 100644 app/helpers/admin/product_helper.rb create mode 100644 app/models/coupon.rb create mode 100644 app/models/product_price.rb create mode 100644 app/views/admin/coupon/edit.rhtml create mode 100644 app/views/admin/coupon/remove.rhtml create mode 100644 app/views/admin/product_price/edit.rhtml create mode 100644 app/views/admin/product_price/remove.rhtml create mode 100644 db/migrate/002_create_product_prices.rb create mode 100644 db/migrate/003_add_product_category.rb create mode 100644 db/migrate/004_create_coupons.rb delete mode 100644 test/functional/store_controller_test.rb create mode 100644 test/unit/cart_test.rb create mode 100644 test/unit/coupon_test.rb create mode 100644 test/unit/product_price_test.rb diff --git a/README b/README index 47fcd37..1c5457b 100644 --- a/README +++ b/README @@ -41,7 +41,7 @@ Within those tags you may use the following tags to display product information: * - the product's code. * - an add to cart form for the product. * - the product's description. -* - the products price. +* - the products price for the given quantity (default quantity=1). So a simple store page body might contain the following:

Welcome to our store!

@@ -73,6 +73,20 @@ The tags you may use to create this page part are the same as those which may be

+= Express Purchase + +Use + + +within a shopping:product:each loop. + +To produce a button that immediately overrides the existing cart with the specified quantity of this product. If no quantity is specified, then an input field for the quantity is also produced. After processing the controller redirects back to the same page, or next_url if specified. + +You can set next_url to point to the eula page or the payment page. + +To use with a specific product instead of all products in the each loop, use the tags + ... +Where there are multiple products to be selected, list them separated with blanks. Invalid product codes will be ignored. = Displaying the cart contents The above product page provides a form for adding a product to our cart but doesn't allow us to view the cart contents or checkout the cart. @@ -87,9 +101,9 @@ The main cart contents must appear between ... ... tags the following tags are available: * - The code of the item. -* - The price for one of the item. +* - The price for each item at the current quantity. * - The quantity of the item in the cart. -* - The total cost of all of the item in the cart. +* - The total cost for that quantity of that item in the cart. * - A textbox where customers may enter a new quantity for the item. * - A button to remove the item from the cart. @@ -169,10 +183,20 @@ When a user submits a form created using the "admin/#{ model_symbol }/edit" if handle_new_or_edit_post + end +end diff --git a/app/controllers/admin/product_price_controller.rb b/app/controllers/admin/product_price_controller.rb new file mode 100644 index 0000000..a004b7b --- /dev/null +++ b/app/controllers/admin/product_price_controller.rb @@ -0,0 +1,12 @@ +class Admin::ProductPriceController < Admin::AbstractModelController + model_class ProductPrice + def new + product = Product.find(params[:product_id]) + product_price = product.product_prices.build + # this is silly + # product_price.product = product unless product_price.product + + self.model = product_price + render :template => "admin/#{ model_symbol }/edit" if handle_new_or_edit_post + end +end diff --git a/app/controllers/cart_controller.rb b/app/controllers/cart_controller.rb index 42632c6..37c8eb1 100644 --- a/app/controllers/cart_controller.rb +++ b/app/controllers/cart_controller.rb @@ -1,8 +1,44 @@ require 'net/https' require 'uri' require 'digest/md5' +require 'ostruct' class CartController < ActionController::Base + + PRICE_NOT_AVAILABLE = 'N/A' + + def savings_for_product_quantity + result = PRICE_NOT_AVAILABLE + begin + product = Product.find(params[:id]) + rescue ActiveRecord::RecordNotFound + logger.error("Attempt to lookup invalid savings using product id: #{params[:id]}") + else + base_pp = product.first_product_price + if base_pp + base_price = base_pp.price + quantity = params[:quantity].to_i + this_price = product.price_for_quantity(quantity) + result = sprintf("%4.2f", (quantity.to_f * (base_price - this_price))) if this_price + end + end + render :text => result + end + + def price_for_product_quantity + result = PRICE_NOT_AVAILABLE + begin + product = Product.find(params[:id]) + rescue ActiveRecord::RecordNotFound + logger.error("Attempt to lookup invalid price using product id: #{params[:id]}") + else + quantity = params[:quantity].to_i + this_price = product.price_for_quantity(quantity) + result = sprintf("%4.2f", this_price) if this_price + end + render :text => result + end + def add_or_update_in_cart begin product = Product.find(params[:id]) @@ -15,6 +51,19 @@ def add_or_update_in_cart redirect_to :back end + def expresspurchase + begin + product = Product.find(params[:id]) + rescue ActiveRecord::RecordNotFound + logger.error("Attempt to add invalid product to cart using product id: #{params[:id]}") + else + empty_cart + @cart = find_cart + @cart.add_product_or_increase_quantity(product, params[:quantity].to_i) + end + redirect_to params[:next_url] ? params[:next_url] : :back + end + def process_cart_change # determine which input was used to submit the cart and update or remove accordingly if params[:submit_type] == "update" || params[:submit_type] == "notajax" && params[:update_submit] @@ -37,18 +86,18 @@ def submit_to_processor # do nothing if the user did not accept the eula redirect_to :back and return unless params[:agree] - - uri = URI.parse( params[:processor_url] ) - site = Net::HTTP.new( uri.host, uri.port ) - res = site.post( uri.path, contents_xml, { 'Content-Type' => 'text/xml; charset=utf-8' } ) + assign_cart_id + unless params[:processor_url].blank? + uri = URI.parse( params[:processor_url] ) + site = Net::HTTP.new( uri.host, uri.port ) + res = site.post( uri.path, contents_xml, { 'Content-Type' => 'text/xml; charset=utf-8' } ) - raise "Processor did not respond with status 200. Instead gave " + res.code.to_s unless res.code == "200" + raise "Processor did not respond with status 200. Instead gave " + res.code.to_s unless res.code == "200" - logger.debug "Response from order processor contained: " + res.body - + logger.debug "Response from order processor contained: " + res.body + end cart = find_cart - - redirect_to params[:next_url] + "?cart=#{cart.id}" + redirect_to params[:next_url] + (params[:processor_url].blank? ? '' : "?cart=#{cart.id}") end def self.form_to_add_or_update_product_in_cart( product ) @@ -61,6 +110,17 @@ def self.form_to_add_or_update_product_in_cart( product ) ) end + def self.form_to_express_purchase_product( product, next_url, quantity, src ) + quantity_input_type = quantity ? 'hidden' : 'text' + %Q(
+ + + + +
) + end + def self.cart_form_start_fragment %Q(
@@ -109,6 +169,7 @@ def self.cart_ajaxify_script( url_base ) ) end + private def update_in_cart( prod_id, quantity ) cart = find_cart @@ -135,10 +196,14 @@ def empty_cart def contents_xml cart = find_cart - cart.id = create_cart_id cart.xml end + def assign_cart_id + cart = find_cart + cart.id = create_cart_id + end + # This is similar to how session keys are generated. Use this for unique cart ids and # _never_ use the session key as we may open ourselves to fixation attacks. def create_cart_id @@ -151,4 +216,5 @@ def create_cart_id md5.update('foobar') md5.hexdigest end + end diff --git a/app/helpers/admin/product_helper.rb b/app/helpers/admin/product_helper.rb new file mode 100644 index 0000000..d85de89 --- /dev/null +++ b/app/helpers/admin/product_helper.rb @@ -0,0 +1,9 @@ +module Admin::ProductHelper + def currently_used_product_categories + Product.find_by_sql("select product_category from products group by product_category;").collect {|p| "'#{p.product_category}'" }.to_sentence + end + def dom_id(record, prefix = nil) + prefix ||= 'new' unless record.id + [ prefix, record.class.name.singularize, record.id ].compact * '_' + end +end diff --git a/app/models/cart.rb b/app/models/cart.rb index 6beffe6..2640ec9 100644 --- a/app/models/cart.rb +++ b/app/models/cart.rb @@ -1,27 +1,46 @@ class Cart attr_reader :items attr_accessor :id + attr_accessor :gst_charged - def initialize + def initialize(options = {:gst_charged => true}) @items = [] + options.each_pair {|k, v| self.send(:"#{k}=", v) } end def add_product_or_increase_quantity(product, quantity) - current_item = cart_item_for_product( product ) - if current_item - current_item.quantity += quantity - else - current_item = CartItem.new(product, quantity) - @items << current_item + if product.is_a?(Coupon) + matching_product = cart_item_for_product( product.product ) + if coupon || (quantity > 1) + raise(ArgumentError, "You can only add one Coupon per order") + elsif matching_product.nil? + raise(ArgumentError, "Invalid coupon (no matching products in your cart)") + elsif !product.current? + raise(ArgumentError, "Coupon has expired or is otherwise not valid") + else + matching_product.apply_coupon(product) + end + else # product is a Product + current_item = cart_item_for_product( product ) + if current_item + current_item.quantity += quantity + else + current_item = CartItem.new(product, quantity) + @items << current_item + end end - tidy end def set_quantity(product, quantity) current_item = cart_item_for_product( product ) current_item.quantity = quantity + tidy + end + def override_with_product_quantity(product, quantity) + current_item = CartItem.new(product, quantity) + @items = [ current_item ] tidy end @@ -35,45 +54,45 @@ def quantity_of_product( product ) item.quantity if item end + def gst_charged? + @gst_charged + end + + def ex_gst_total + grand_total = 0 + items.each do |item| + grand_total += item.subtotal(false) + end + grand_total + end + def total grand_total = 0 items.each do |item| - grand_total += item.subtotal_price + grand_total += item.subtotal(@gst_charged) end grand_total end - - def xml - xml = Builder::XmlMarkup.new - xml.instruct! :xml, :version=>"1.0", :encoding=>"UTF-8" - xml.cart{ - xml.id(id) - xml.items{ - items.each do |item| - xml.item do - xml.code(item.product.code) - xml.description(item.product.description) - xml.quantity(item.quantity) - xml.unitcost(item.product.price) - xml.subtotal(item.subtotal_price) - end - end - } - xml.total(total) - } - - xml.target! + + def gst_amount + total - ex_gst_total end - + private def cart_item_for_product( product ) - @items.find {|item| item.product == product} + items.find {|item| item.product == product} end def tidy - @items.each do |item| + items.each do |item| # every item must have a quantity of at least one remove_product( item.product ) if item.quantity < 1 end end + + def coupon + item = items.select {|item| item.coupon? }.first + item.product if item + end + end diff --git a/app/models/cart_item.rb b/app/models/cart_item.rb index d7410c1..34e87f7 100644 --- a/app/models/cart_item.rb +++ b/app/models/cart_item.rb @@ -11,7 +11,31 @@ def code @product.code end - def subtotal_price - @product.price * @quantity + def subtotal(gst_charged = false) + subtotal = 0 + if price = @product.price_for_quantity(@quantity) + subtotal = price * @quantity + subtotal -= @coupon.discount_per_order if coupon? + if gst_charged + subtotal = round_to_cents(subtotal * 1.10) + end + end + subtotal end -end \ No newline at end of file + + def coupon? + @coupon + end + + def apply_coupon(coupon) + @coupon = coupon + end + + private + + def round_to_cents(amount) + (amount * 100.0).round.to_f / 100.0 + end + + +end diff --git a/app/models/coupon.rb b/app/models/coupon.rb new file mode 100644 index 0000000..3b8136a --- /dev/null +++ b/app/models/coupon.rb @@ -0,0 +1,23 @@ +class Coupon < ActiveRecord::Base + belongs_to :product + + validates_presence_of :product, :code + validates_uniqueness_of :code, :scope => :product_id + validates_associated :product + validates_numericality_of :discount_per_order + validates_each :discount_per_order do |record, attr, value| + record.errors.add attr, "must be positive." unless value > 0.0 + end + + def price_for_quantity(qty) + current? ? -discount_per_order.to_f / qty.to_f : 0 + end + + def current? + if expiration_date + Date.today <= expiration_date + else + true + end + end +end diff --git a/app/models/product.rb b/app/models/product.rb index 9c88635..b774f98 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -1,8 +1,43 @@ class Product < ActiveRecord::Base validates_exclusion_of :code, :in => %w{checkout cart eula} + # path segment, see http://www.w3.org/Addressing/URL/5_URI_BNF.html + validates_format_of :code, :with => /^[-a-z0-9_\.\$@&!\*"'\(\),]*$/i validates_presence_of :code + validates_uniqueness_of :code + validates_presence_of :product_category + + has_many :product_prices, :dependent => :destroy + has_many :coupons, :dependent => :destroy + + def product_price_for_quantity(quantity) + self.product_prices.find(:first, :conditions => ['min_quantity <= ?', quantity], :order => 'min_quantity desc') + end + + def first_product_price + self.product_prices.find(:first, :order => 'min_quantity') + end + + def price_for_quantity(quantity) + pp = product_price_for_quantity(quantity) + pp && pp.price.to_f + end + + def price_and_total_for_quantity(quantity) + price = price_for_quantity(quantity) + total = quantity*price + return [price,total] + end - def validate - errors.add(:price, "should be a positive value") if price.nil? || price < 0.01 + # savings_for_quantity(quantity) + # returns percent that the unit price is more then price for that quantity + # (ie if there is a 25% discount for the higher quantity, this will return + # 50) + # [ian: a. used price rather then total as quantity was common to both sides of division, b. should this show the discount percentage instead?] + def savings_for_quantity(quantity) + price_for_1 = price_for_quantity(1) + price = price_for_quantity(quantity) + return 0.00 if price_for_1 <= 0.0001 # dont divide by near zero + 100.0*(price_for_1 - price)/price_for_1 end + end diff --git a/app/models/product_price.rb b/app/models/product_price.rb new file mode 100644 index 0000000..d12abc1 --- /dev/null +++ b/app/models/product_price.rb @@ -0,0 +1,11 @@ +class ProductPrice < ActiveRecord::Base + + belongs_to :product + + validates_uniqueness_of :min_quantity, :scope => :product_id + + def validate + errors.add(:price, "should be a positive value") if price.nil? || price < 0.01 + end + +end diff --git a/app/models/store_page.rb b/app/models/store_page.rb index 785a736..4b8208c 100644 --- a/app/models/store_page.rb +++ b/app/models/store_page.rb @@ -4,6 +4,8 @@ class StorePage < Page other tags that will prove useful. } + attr_accessor :form_errors + def process( request, response ) @session = request.session super( request, response ) @@ -42,8 +44,8 @@ def tag_part_name(tag) end end - # The cart page is rendered via AJAX and inserted into complete pages it must not include - # a layout. + # The cart page is rendered via AJAX and inserted into complete pages it + # must not include a layout. def render if @page_type == :cart render_part( :cart ) @@ -61,8 +63,15 @@ def render end tag "shopping:product:each" do |tag| + products = [] + if ! tag.attr['only'].blank? + products = tag.attr['only'].split(' ').collect { |code| Product.find_by_code(code) } + products.compact! + else + products = Product.find(:all) + end result = [] - Product.find(:all).each do |item| + products.each do |item| @product = item result << tag.expand end @@ -73,6 +82,11 @@ def render [CartController.form_to_add_or_update_product_in_cart( @product )] end + tag "shopping:product:expresspurchase" do |tag| + img_src = "http://#{tag.render('img_host')}#{tag.attr['src']}" + [CartController.form_to_express_purchase_product( @product, tag.attr['next_url'], tag.attr['quantity'], img_src )] + end + tag "shopping:product:code" do |tag| @product.code end @@ -82,7 +96,7 @@ def render end tag "shopping:product:price" do |tag| - sprintf('%.2f', @product.price ) + sprintf('%.2f', @product.price_for_quantity(tag.attr['quantity'] || 1)) end tag "shopping:product:link" do |tag| @@ -164,11 +178,11 @@ def render end tag "shopping:cart:item:unitcost" do |tag| - sprintf('%.2f', @cart_item.product.price ) + sprintf('%4.2f', @cart_item.product.price_for_quantity(@cart_item.quantity)) end tag "shopping:cart:item:subtotal" do |tag| - sprintf('%.2f', @cart_item.product.price * @cart_item.quantity ) + sprintf('%4.2f', @cart_item.product.price_for_quantity(@cart_item.quantity) * @cart_item.quantity) end tag "shopping:cart:item:remove" do |tag| @@ -191,7 +205,11 @@ def render [CartController.form_to_payment_processor( tag.attr['processor_url'], tag.attr['next_url'], tag.expand )] end - private + tag "shopping:form_errors" do |tag| + form_errors ? "

#{form_errors}

" : "" + end + + protected def link( url, text ) %Q(#{ text }) end @@ -207,7 +225,6 @@ def assess_page_type_from_url_and_load_models(url) end def request_uri - puts "\n\n\n--\n#{request.inspect}\n--\n\n\n" request.request_uri unless request.nil? end @@ -217,11 +234,11 @@ def is_a_child_page?(url) def page_type_and_required_models(request_uri = self.request_uri) code = $1 if request_uri =~ %r{#{parent.url unless parent.nil?}([^/]+)/?$} - if code =~ %r{^cart} + if code == 'cart' @page_type = :cart - elsif code =~ %r{^checkout} + elsif code == 'checkout' @page_type = :checkout - elsif code =~ %r{^eula} + elsif code == 'eula' @page_type = :eula else @product = Product.find_by_code(code) diff --git a/app/views/admin/coupon/edit.rhtml b/app/views/admin/coupon/edit.rhtml new file mode 100644 index 0000000..ed75043 --- /dev/null +++ b/app/views/admin/coupon/edit.rhtml @@ -0,0 +1,38 @@ +<% @product = @coupon.product -%> +<% if @coupon.new_record? -%> +

New Coupon

+<% else -%> +

Edit Coupon

+<% end -%> + +

<%= h @product.code %>: <%= h @product.description %>

+ + + + + + + + + + + + + + + + + + + + +
<%= text_field :coupon, :code, :size => 100, :class => 'textbox' %>Required. Only numbers and letters allowed
<%= text_field :coupon, :expiration_date, :size => 100, :class => 'textbox' %>Optional, enter as yyyy-mm-dd (4 digit year, 2 digit month, 2 digit day)
<%= text_field :coupon, :discount_per_order, :size => 100, :class => 'textbox' %>Required. Discount per order (regardless of product count in order) in dollars and cents.
+

+ <%= save_model_button(@coupon) %> + <%= save_model_and_continue_editing_button(@coupon) %> + or <%= link_to "Cancel", product_index_url %> +

+ +
+ +<%= focus 'coupon_code' %> diff --git a/app/views/admin/coupon/remove.rhtml b/app/views/admin/coupon/remove.rhtml new file mode 100644 index 0000000..d0da22e --- /dev/null +++ b/app/views/admin/coupon/remove.rhtml @@ -0,0 +1,21 @@ +<% @product = @coupon.product -%> +

Remove Coupon

+

<%= h @product.code %>: <%= h @product.description %>

+ +

Are you sure you want to permanently remove + the following coupon?

+ + + + + + + +
<%= link_to h(@coupon.code), coupon_edit_path(:id => @coupon) %>
+ +
+

<%= submit_tag "Delete Coupon", :class => 'button' %> + or <%= link_to 'Cancel', product_index_path %>

+
diff --git a/app/views/admin/product/edit.rhtml b/app/views/admin/product/edit.rhtml index 1454fca..69d97af 100644 --- a/app/views/admin/product/edit.rhtml +++ b/app/views/admin/product/edit.rhtml @@ -10,7 +10,7 @@ <%= text_field :product, :code, :size => 100, :class => 'textbox' %> - Required. Only numbers an letters allowed + Required. Only numbers and letters allowed @@ -18,9 +18,9 @@ Optional - - <%= text_field :product, :price %> - Required, has to be > 0.01 + + <%= text_field :product, :product_category, :size => 100, :class => 'textbox' %> + Required. One of <%= currently_used_product_categories %> @@ -32,4 +32,4 @@ -<%= focus 'product_code' %> \ No newline at end of file +<%= focus 'product_code' %> diff --git a/app/views/admin/product/index.rhtml b/app/views/admin/product/index.rhtml index 42ad724..8f19dc1 100644 --- a/app/views/admin/product/index.rhtml +++ b/app/views/admin/product/index.rhtml @@ -4,28 +4,66 @@ Code Description - Price + Product Category + Related Modify - <% unless @products.empty? -%> - <% for product in @products %> +<% unless @products.empty? -%> +<% for product in @products -%> <%= link_to h(product.code), product_edit_path(:id => product) %> - <%= link_to product.description, product_edit_path(:id => product) %> - <%=h product.price %> + <%= link_to h(product.description), product_edit_path(:id => product) %> + <%= link_to h(product.product_category), product_edit_path(:id => product) %> + + "> + + + + + + + +<% for product_price in product.product_prices -%> + + + + +<% end -%> + +
Minimum QuantityPrice
<%= link_to h(product_price.min_quantity), product_price_edit_path(:id => product_price) -%><%= link_to h(product_price.price), product_price_edit_path(:id => product_price) -%>
+

<%= link_to "New price break", product_price_new_url(:product_id => product) -%>

+ "> + + + + + + + +<% for coupon in product.coupons -%> + + + + + +<% end -%> + +
CodeExpiration DateDiscount per Order
<%= link_to h(coupon.code), coupon_edit_path(:id => coupon) -%><%= link_to h(coupon.expiration_date), coupon_edit_path(:id => coupon) -%><%= link_to h(coupon.discount_per_order), coupon_edit_path(:id => coupon) -%>
+

<%= link_to "New coupon", coupon_new_url(:product_id => product) -%>

+ <%= link_to image_tag("admin/remove.png", :alt => 'Remove Product'), product_remove_path(:id => product) %> - <% end %> - <% else %> +<% end -%> +<% else -%> No Products - <% end %> +<% end -%>