dup_spree_promo 1.3.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (68) hide show
  1. data/LICENSE +26 -0
  2. data/app/assets/javascripts/admin/promotions.js +98 -0
  3. data/app/assets/javascripts/admin/spree_promo.js +32 -0
  4. data/app/assets/javascripts/store/spree_promo.js +1 -0
  5. data/app/assets/stylesheets/admin/spree_promo.css +3 -0
  6. data/app/assets/stylesheets/store/spree_promo.css +17 -0
  7. data/app/controllers/spree/admin/promotion_actions_controller.rb +27 -0
  8. data/app/controllers/spree/admin/promotion_rules_controller.rb +32 -0
  9. data/app/controllers/spree/admin/promotions_controller.rb +30 -0
  10. data/app/controllers/spree/checkout_controller_decorator.rb +30 -0
  11. data/app/controllers/spree/content_controller_decorator.rb +13 -0
  12. data/app/controllers/spree/orders_controller_decorator.rb +24 -0
  13. data/app/controllers/spree/store_controller_decorator.rb +55 -0
  14. data/app/helpers/spree/promotion_rules_helper.rb +13 -0
  15. data/app/models/spree/adjustment_decorator.rb +7 -0
  16. data/app/models/spree/calculator/free_shipping.rb +18 -0
  17. data/app/models/spree/calculator/percent_per_item.rb +48 -0
  18. data/app/models/spree/order_decorator.rb +19 -0
  19. data/app/models/spree/order_updater_decorator.rb +14 -0
  20. data/app/models/spree/payment_decorator.rb +5 -0
  21. data/app/models/spree/product_decorator.rb +8 -0
  22. data/app/models/spree/promotion.rb +105 -0
  23. data/app/models/spree/promotion/actions/create_adjustment.rb +48 -0
  24. data/app/models/spree/promotion/actions/create_line_items.rb +23 -0
  25. data/app/models/spree/promotion/rules/first_order.rb +12 -0
  26. data/app/models/spree/promotion/rules/item_total.rb +21 -0
  27. data/app/models/spree/promotion/rules/product.rb +46 -0
  28. data/app/models/spree/promotion/rules/user.rb +24 -0
  29. data/app/models/spree/promotion/rules/user_logged_in.rb +20 -0
  30. data/app/models/spree/promotion_action.rb +19 -0
  31. data/app/models/spree/promotion_action_line_item.rb +8 -0
  32. data/app/models/spree/promotion_rule.rb +25 -0
  33. data/app/overrides/promo_admin_tabs.rb +6 -0
  34. data/app/overrides/promo_cart_coupon_code_field.rb +6 -0
  35. data/app/overrides/promo_coupon_code_field.rb +6 -0
  36. data/app/overrides/promo_product_properties.rb +6 -0
  37. data/app/views/spree/admin/promotion_actions/create.js.erb +12 -0
  38. data/app/views/spree/admin/promotion_actions/destroy.js.erb +1 -0
  39. data/app/views/spree/admin/promotion_rules/create.js.erb +13 -0
  40. data/app/views/spree/admin/promotion_rules/destroy.js.erb +3 -0
  41. data/app/views/spree/admin/promotions/_actions.html.erb +32 -0
  42. data/app/views/spree/admin/promotions/_form.html.erb +56 -0
  43. data/app/views/spree/admin/promotions/_promotion_action.html.erb +11 -0
  44. data/app/views/spree/admin/promotions/_promotion_rule.html.erb +9 -0
  45. data/app/views/spree/admin/promotions/_rules.html.erb +45 -0
  46. data/app/views/spree/admin/promotions/_tab.html.erb +1 -0
  47. data/app/views/spree/admin/promotions/actions/_create_adjustment.html.erb +26 -0
  48. data/app/views/spree/admin/promotions/actions/_create_line_items.html.erb +22 -0
  49. data/app/views/spree/admin/promotions/edit.html.erb +28 -0
  50. data/app/views/spree/admin/promotions/index.html.erb +52 -0
  51. data/app/views/spree/admin/promotions/new.html.erb +16 -0
  52. data/app/views/spree/admin/promotions/rules/_first_order.html.erb +0 -0
  53. data/app/views/spree/admin/promotions/rules/_item_total.html.erb +6 -0
  54. data/app/views/spree/admin/promotions/rules/_landing_page.html.erb +5 -0
  55. data/app/views/spree/admin/promotions/rules/_product.html.erb +9 -0
  56. data/app/views/spree/admin/promotions/rules/_user.html.erb +4 -0
  57. data/app/views/spree/admin/promotions/rules/_user_logged_in.html.erb +0 -0
  58. data/app/views/spree/checkout/_coupon_code_field.html.erb +6 -0
  59. data/app/views/spree/orders/_coupon_code_field.html.erb +7 -0
  60. data/app/views/spree/products/_promotions.html.erb +23 -0
  61. data/config/locales/en.yml +98 -0
  62. data/config/routes.rb +8 -0
  63. data/db/migrate/20120831092359_spree_promo_one_two.rb +45 -0
  64. data/lib/spree/promo.rb +9 -0
  65. data/lib/spree/promo/engine.rb +56 -0
  66. data/lib/spree/promo/environment.rb +9 -0
  67. data/lib/spree_promo.rb +1 -0
  68. metadata +129 -0
@@ -0,0 +1,48 @@
1
+ module Spree
2
+
3
+ # A calculator for promotions that calculates a percent-off discount
4
+ # for all matching products in an order. This should not be used as a
5
+ # shipping calculator since it would be the same thing as a flat percent
6
+ # off the entire order.
7
+
8
+ class Calculator::PercentPerItem < Calculator
9
+ preference :percent, :decimal, :default => 0
10
+
11
+ attr_accessible :preferred_percent
12
+
13
+ def self.description
14
+ I18n.t(:percent_per_item)
15
+ end
16
+
17
+ def compute(object=nil)
18
+ return 0 if object.nil?
19
+ object.line_items.reduce(0) do |sum, line_item|
20
+ sum += value_for_line_item(line_item)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ # Returns all products that match the promotion's rule.
27
+ def matching_products
28
+ @matching_products ||= if compute_on_promotion?
29
+ self.calculable.promotion.rules.map(&:products).flatten
30
+ end
31
+ end
32
+
33
+ # Calculates the discount value of each line item. Returns zero
34
+ # unless the product is included in the promotion rules.
35
+ def value_for_line_item(line_item)
36
+ if compute_on_promotion?
37
+ return 0 unless matching_products.include?(line_item.product)
38
+ end
39
+ line_item.price * line_item.quantity * preferred_percent
40
+ end
41
+
42
+ # Determines wether or not the calculable object is a promotion
43
+ def compute_on_promotion?
44
+ @compute_on_promotion ||= self.calculable.respond_to?(:promotion)
45
+ end
46
+
47
+ end
48
+ end
@@ -0,0 +1,19 @@
1
+ Spree::Order.class_eval do
2
+ attr_accessible :coupon_code
3
+ attr_reader :coupon_code
4
+
5
+ def coupon_code=(code)
6
+ @coupon_code = code.strip.downcase rescue nil
7
+ end
8
+
9
+ # Tells us if there if the specified promotion is already associated with the order
10
+ # regardless of whether or not its currently eligible. Useful because generally
11
+ # you would only want a promotion to apply to order no more than once.
12
+ def promotion_credit_exists?(promotion)
13
+ !! adjustments.promotion.reload.detect { |credit| credit.originator.promotion.id == promotion.id }
14
+ end
15
+
16
+ def promo_total
17
+ adjustments.eligible.promotion.map(&:amount).sum
18
+ end
19
+ end
@@ -0,0 +1,14 @@
1
+ Spree::OrderUpdater.class_eval do
2
+ unless self.method_defined?('update_adjustments_with_promotion_limiting')
3
+ def update_adjustments_with_promotion_limiting
4
+ update_adjustments_without_promotion_limiting
5
+ return if adjustments.promotion.eligible.none?
6
+ most_valuable_adjustment = adjustments.promotion.eligible.max{|a,b| a.amount.abs <=> b.amount.abs}
7
+ current_adjustments = (adjustments.promotion.eligible - [most_valuable_adjustment])
8
+ current_adjustments.each do |adjustment|
9
+ adjustment.update_attribute_without_callbacks(:eligible, false)
10
+ end
11
+ end
12
+ alias_method_chain :update_adjustments, :promotion_limiting
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ Spree::Payment.class_eval do
2
+ def promo_total
3
+ order.promo_total * 100
4
+ end
5
+ end
@@ -0,0 +1,8 @@
1
+ Spree::Product.class_eval do
2
+ has_and_belongs_to_many :promotion_rules, :join_table => :spree_products_promotion_rules
3
+
4
+ def possible_promotions
5
+ promotion_ids = promotion_rules.map(&:activator_id).uniq
6
+ Spree::Promotion.advertised.where(:id => promotion_ids).reject(&:expired?)
7
+ end
8
+ end
@@ -0,0 +1,105 @@
1
+ module Spree
2
+ class Promotion < Spree::Activator
3
+ MATCH_POLICIES = %w(all any)
4
+ UNACTIVATABLE_ORDER_STATES = ["complete", "awaiting_return", "returned"]
5
+
6
+ Activator.event_names << 'spree.checkout.coupon_code_added'
7
+ Activator.event_names << 'spree.content.visited'
8
+
9
+ has_many :promotion_rules, :foreign_key => :activator_id, :autosave => true, :dependent => :destroy
10
+ alias_method :rules, :promotion_rules
11
+ accepts_nested_attributes_for :promotion_rules
12
+
13
+ has_many :promotion_actions, :foreign_key => :activator_id, :autosave => true, :dependent => :destroy
14
+ alias_method :actions, :promotion_actions
15
+ accepts_nested_attributes_for :promotion_actions
16
+
17
+ validates_associated :rules
18
+
19
+ attr_accessible :name, :event_name, :code, :match_policy,
20
+ :path, :advertise, :description, :usage_limit,
21
+ :starts_at, :expires_at, :promotion_rules_attributes,
22
+ :promotion_actions_attributes
23
+
24
+ # TODO: This shouldn't be necessary with :autosave option but nested attribute updating of actions is broken without it
25
+ after_save :save_rules_and_actions
26
+ def save_rules_and_actions
27
+ (rules + actions).each &:save
28
+ end
29
+
30
+ validates :name, :presence => true
31
+ validates :code, :presence => true, :if => lambda{|r| r.event_name == 'spree.checkout.coupon_code_added' }
32
+ validates :path, :presence => true, :if => lambda{|r| r.event_name == 'spree.content.visited' }
33
+ validates :usage_limit, :numericality => { :greater_than => 0, :allow_nil => true }
34
+
35
+ def self.advertised
36
+ where(:advertise => true)
37
+ end
38
+
39
+ def activate(payload)
40
+ return unless order_activatable? payload[:order]
41
+
42
+ if code.present?
43
+ event_code = payload[:coupon_code]
44
+ return unless event_code == self.code
45
+ end
46
+
47
+ if path.present?
48
+ return unless path == payload[:path]
49
+ end
50
+
51
+ actions.each do |action|
52
+ action.perform(payload)
53
+ end
54
+ end
55
+
56
+ # called anytime order.update! happens
57
+ def eligible?(order)
58
+ return false if expired? || usage_limit_exceeded?(order)
59
+ rules_are_eligible?(order, {})
60
+ end
61
+
62
+ def rules_are_eligible?(order, options = {})
63
+ return true if rules.none?
64
+ eligible = lambda { |r| r.eligible?(order, options) }
65
+ if match_policy == 'all'
66
+ rules.all?(&eligible)
67
+ else
68
+ rules.any?(&eligible)
69
+ end
70
+ end
71
+
72
+ def order_activatable?(order)
73
+ order &&
74
+ created_at.to_i < order.created_at.to_i &&
75
+ !UNACTIVATABLE_ORDER_STATES.include?(order.state)
76
+ end
77
+
78
+ # Products assigned to all product rules
79
+ def products
80
+ @products ||= rules.of_type('Spree::Promotion::Rules::Product').map(&:products).flatten.uniq
81
+ end
82
+
83
+ def usage_limit_exceeded?(order = nil)
84
+ usage_limit.present? && usage_limit > 0 && adjusted_credits_count(order) >= usage_limit
85
+ end
86
+
87
+ def adjusted_credits_count(order)
88
+ return credits_count if order.nil?
89
+ credits_count - (order.promotion_credit_exists?(self) ? 1 : 0)
90
+ end
91
+
92
+ def credits
93
+ Adjustment.promotion.where(:originator_id => actions.map(&:id))
94
+ end
95
+
96
+ def credits_count
97
+ credits.count
98
+ end
99
+
100
+ def code=(coupon_code)
101
+ write_attribute(:code, (coupon_code.downcase.strip rescue nil))
102
+ end
103
+
104
+ end
105
+ end
@@ -0,0 +1,48 @@
1
+ module Spree
2
+ class Promotion
3
+ module Actions
4
+ class CreateAdjustment < PromotionAction
5
+ calculated_adjustments
6
+
7
+ delegate :eligible?, :to => :promotion
8
+
9
+ before_validation :ensure_action_has_calculator
10
+
11
+ def perform(options = {})
12
+ return unless order = options[:order]
13
+ # Nothing to do if the promotion is already associated with the order
14
+ return if order.promotion_credit_exists?(promotion)
15
+
16
+ order.adjustments.promotion.reload.clear
17
+ order.update!
18
+ create_adjustment("#{I18n.t(:promotion)} (#{promotion.name})", order, order)
19
+ order.update!
20
+ end
21
+
22
+ # override of CalculatedAdjustments#create_adjustment so promotional
23
+ # adjustments are added all the time. They will get their eligability
24
+ # set to false if the amount is 0
25
+ def create_adjustment(label, target, calculable, mandatory=false)
26
+ amount = compute_amount(calculable)
27
+ params = { :amount => amount,
28
+ :source => calculable,
29
+ :originator => self,
30
+ :label => label,
31
+ :mandatory => mandatory }
32
+ target.adjustments.create(params, :without_protection => true)
33
+ end
34
+
35
+ # Ensure a negative amount which does not exceed the sum of the order's item_total and ship_total
36
+ def compute_amount(calculable)
37
+ [(calculable.item_total + calculable.ship_total), super.to_f.abs].min * -1
38
+ end
39
+
40
+ private
41
+ def ensure_action_has_calculator
42
+ return if self.calculator
43
+ self.calculator = Calculator::FlatPercentItemTotal.new
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,23 @@
1
+ module Spree
2
+ class Promotion
3
+ module Actions
4
+ class CreateLineItems < PromotionAction
5
+ has_many :promotion_action_line_items, :foreign_key => :promotion_action_id
6
+ accepts_nested_attributes_for :promotion_action_line_items
7
+ attr_accessible :promotion_action_line_items_attributes
8
+
9
+
10
+ def perform(options = {})
11
+ return unless order = options[:order]
12
+ promotion_action_line_items.each do |item|
13
+ current_quantity = order.quantity_of(item.variant)
14
+ if current_quantity < item.quantity
15
+ order.add_variant(item.variant, item.quantity - current_quantity)
16
+ order.update!
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,12 @@
1
+ module Spree
2
+ class Promotion
3
+ module Rules
4
+ class FirstOrder < PromotionRule
5
+ def eligible?(order, options = {})
6
+ user = order.try(:user) || options[:user]
7
+ !!(user && user.orders.complete.count == 0)
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,21 @@
1
+ # A rule to apply to an order greater than (or greater than or equal to)
2
+ # a specific amount
3
+ module Spree
4
+ class Promotion
5
+ module Rules
6
+ class ItemTotal < PromotionRule
7
+ preference :amount, :decimal, :default => 100.00
8
+ preference :operator, :string, :default => '>'
9
+
10
+ attr_accessible :preferred_amount, :preferred_operator
11
+
12
+ OPERATORS = ['gt', 'gte']
13
+
14
+ def eligible?(order, options = {})
15
+ item_total = order.line_items.map(&:amount).sum
16
+ item_total.send(preferred_operator == 'gte' ? :>= : :>, BigDecimal.new(preferred_amount.to_s))
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,46 @@
1
+ # A rule to limit a promotion based on products in the order.
2
+ # Can require all or any of the products to be present.
3
+ # Valid products either come from assigned product group or are assingned directly to the rule.
4
+ module Spree
5
+ class Promotion
6
+ module Rules
7
+ class Product < PromotionRule
8
+ has_and_belongs_to_many :products, :class_name => '::Spree::Product', :join_table => 'spree_products_promotion_rules', :foreign_key => 'promotion_rule_id'
9
+ validate :only_one_promotion_per_product
10
+
11
+ MATCH_POLICIES = %w(any all)
12
+ preference :match_policy, :string, :default => MATCH_POLICIES.first
13
+
14
+ # scope/association that is used to test eligibility
15
+ def eligible_products
16
+ products
17
+ end
18
+
19
+ def eligible?(order, options = {})
20
+ return true if eligible_products.empty?
21
+ if preferred_match_policy == 'all'
22
+ eligible_products.all? {|p| order.products.include?(p) }
23
+ else
24
+ order.products.any? {|p| eligible_products.include?(p) }
25
+ end
26
+ end
27
+
28
+ def product_ids_string
29
+ product_ids.join(',')
30
+ end
31
+
32
+ def product_ids_string=(s)
33
+ self.product_ids = s.to_s.split(',').map(&:strip)
34
+ end
35
+
36
+ private
37
+
38
+ def only_one_promotion_per_product
39
+ if Spree::Promotion::Rules::Product.all.map(&:products).flatten.uniq!
40
+ errors[:base] << "You can't create two promotions for the same product"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,24 @@
1
+ module Spree
2
+ class Promotion
3
+ module Rules
4
+ class User < PromotionRule
5
+ attr_accessible :user_ids_string
6
+
7
+ belongs_to :user, :class_name => Spree.user_class.to_s
8
+ has_and_belongs_to_many :users, :class_name => Spree.user_class.to_s, :join_table => 'spree_promotion_rules_users', :foreign_key => 'promotion_rule_id'
9
+
10
+ def eligible?(order, options = {})
11
+ users.none? or users.include?(order.user)
12
+ end
13
+
14
+ def user_ids_string
15
+ user_ids.join(',')
16
+ end
17
+
18
+ def user_ids_string=(s)
19
+ self.user_ids = s.to_s.split(',').map(&:strip)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,20 @@
1
+ module Spree
2
+ class Promotion
3
+ module Rules
4
+ class UserLoggedIn < PromotionRule
5
+
6
+ def eligible?(order, options = {})
7
+ # this is tricky. We couldn't use any of the devise methods since we aren't in the controller.
8
+ # we need to rely on the controller already having done this for us.
9
+
10
+ # The thinking is that the controller should have some sense of what state
11
+ # we should be in before firing events,
12
+ # so the controller will have to set this field.
13
+
14
+ return options && options[:user_signed_in]
15
+ end
16
+
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # Base class for all types of promotion action.
2
+ # PromotionActions perform the necessary tasks when a promotion is activated by an event and determined to be eligible.
3
+ module Spree
4
+ class PromotionAction < ActiveRecord::Base
5
+ belongs_to :promotion, :foreign_key => 'activator_id', :class_name => "Spree::Promotion"
6
+
7
+ scope :of_type, lambda {|t| {:conditions => {:type => t}}}
8
+
9
+ attr_accessible :line_items_string
10
+
11
+ # This method should be overriden in subclass
12
+ # Updates the state of the order or performs some other action depending on the subclass
13
+ # options will contain the payload from the event that activated the promotion. This will include
14
+ # the key :user which allows user based actions to be performed in addition to actions on the order
15
+ def perform(options = {})
16
+ raise 'perform should be implemented in a sub-class of PromotionAction'
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,8 @@
1
+ module Spree
2
+ class PromotionActionLineItem < ActiveRecord::Base
3
+ belongs_to :promotion_action, :class_name => 'Spree::Promotion::Actions::CreateLineItems'
4
+ belongs_to :variant, :class_name => "Spree::Variant"
5
+
6
+ attr_accessible :quantity, :variant_id
7
+ end
8
+ end
@@ -0,0 +1,25 @@
1
+ # Base class for all promotion rules
2
+ module Spree
3
+ class PromotionRule < ActiveRecord::Base
4
+ belongs_to :promotion, :foreign_key => 'activator_id', :class_name => "Spree::Promotion"
5
+
6
+ scope :of_type, lambda {|t| {:conditions => {:type => t}}}
7
+
8
+ validate :promotion, :presence => true
9
+ validate :unique_per_activator, :on => :create
10
+
11
+ attr_accessible :preferred_operator, :preferred_amount, :product, :product_ids_string, :preferred_match_policy
12
+
13
+ def eligible?(order, options = {})
14
+ raise 'eligible? should be implemented in a sub-class of Promotion::PromotionRule'
15
+ end
16
+
17
+ private
18
+ def unique_per_activator
19
+ if Spree::PromotionRule.exists?(:activator_id => activator_id, :type => self.class.name)
20
+ errors[:base] << "Promotion already contains this rule type"
21
+ end
22
+ end
23
+
24
+ end
25
+ end