spree_promo 0.30.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. data/app/controllers/admin/promotion_rules_controller.rb +27 -0
  2. data/app/controllers/admin/promotions_controller.rb +18 -0
  3. data/app/models/calculator/free_shipping.rb +24 -0
  4. data/app/models/promotion/rules/first_order.rb +7 -0
  5. data/app/models/promotion/rules/item_total.rb +14 -0
  6. data/app/models/promotion/rules/product.rb +41 -0
  7. data/app/models/promotion/rules/user.rb +18 -0
  8. data/app/models/promotion.rb +57 -0
  9. data/app/models/promotion_credit.rb +30 -0
  10. data/app/models/promotion_rule.rb +26 -0
  11. data/app/views/admin/promotion_rules/create.js.erb +6 -0
  12. data/app/views/admin/promotion_rules/destroy.js.erb +1 -0
  13. data/app/views/admin/promotions/_form.html.erb +46 -0
  14. data/app/views/admin/promotions/_promotion_rule.html.erb +8 -0
  15. data/app/views/admin/promotions/_tab.html.erb +1 -0
  16. data/app/views/admin/promotions/edit.html.erb +45 -0
  17. data/app/views/admin/promotions/index.html.erb +40 -0
  18. data/app/views/admin/promotions/new.html.erb +7 -0
  19. data/app/views/admin/promotions/rules/_first_order.html.erb +0 -0
  20. data/app/views/admin/promotions/rules/_item_total.html.erb +7 -0
  21. data/app/views/admin/promotions/rules/_product.html.erb +18 -0
  22. data/app/views/admin/promotions/rules/_user.html.erb +7 -0
  23. data/app/views/products/_promotions.html.erb +23 -0
  24. data/config/locales/en.yml +42 -0
  25. data/config/routes.rb +7 -0
  26. data/db/migrate/20100419190933_rename_coupons_to_promotions.rb +10 -0
  27. data/db/migrate/20100419194457_fix_existing_coupon_credits.rb +9 -0
  28. data/db/migrate/20100426100726_create_promotion_rules.rb +24 -0
  29. data/db/migrate/20100501084722_match_policy_for_promotions.rb +9 -0
  30. data/db/migrate/20100501095202_create_promotion_rules_users.rb +14 -0
  31. data/db/migrate/20100502163939_name_for_promotions.rb +9 -0
  32. data/db/migrate/20100923095305_update_calculable_type_for_promotions.rb +9 -0
  33. data/lib/spree_promo.rb +104 -0
  34. data/lib/spree_promo_hooks.rb +9 -0
  35. data/lib/tasks/install.rake +27 -0
  36. data/lib/tasks/promotions.rake +11 -0
  37. data/lib/tasks/promotions_extension_tasks.rake +17 -0
  38. data/public/javascripts/admin/promotions.js +26 -0
  39. data/public/stylesheets/admin/promotions.css +54 -0
  40. metadata +122 -0
@@ -0,0 +1,27 @@
1
+ class Admin::PromotionRulesController < Admin::BaseController
2
+ resource_controller
3
+ belongs_to :promotion
4
+
5
+ create.response do |wants|
6
+ wants.html { redirect_to edit_admin_promotion_path(parent_object) }
7
+ wants.js { render :action => 'create', :layout => false}
8
+ end
9
+ destroy.response do |wants|
10
+ wants.html { redirect_to edit_admin_promotion_path(parent_object) }
11
+ wants.js { render :action => 'destroy', :layout => false}
12
+ end
13
+
14
+ private
15
+
16
+ def build_object
17
+ return @object if @object.present?
18
+ if params[:promotion_rule] && params[:promotion_rule][:type]
19
+ @object = params[:promotion_rule][:type].constantize.new(object_params)
20
+ @object.promotion = parent_object
21
+ else
22
+ @object = end_of_association_chain.build(object_params)
23
+ end
24
+ @object
25
+ end
26
+
27
+ end
@@ -0,0 +1,18 @@
1
+ class Admin::PromotionsController < Admin::BaseController
2
+ resource_controller
3
+ before_filter :load_data
4
+
5
+ update.wants.html { redirect_to edit_object_url }
6
+ create.wants.html { redirect_to edit_object_url }
7
+ destroy.success.wants.js { render_js_for_destroy }
8
+
9
+ private
10
+ def build_object
11
+ @object ||= end_of_association_chain.send parent? ? :build : :new, object_params
12
+ @object.calculator = params[:promotion][:calculator_type].constantize.new if params[:promotion]
13
+ end
14
+
15
+ def load_data
16
+ @calculators = Promotion.calculators
17
+ end
18
+ end
@@ -0,0 +1,24 @@
1
+ class Calculator::FreeShipping < ::Calculator
2
+
3
+ def self.description
4
+ I18n.t("free_shipping")
5
+ end
6
+
7
+ def self.register
8
+ super
9
+ Promotion.register_calculator(self)
10
+ end
11
+
12
+ def compute(object)
13
+ if object.is_a?(Array)
14
+ return if object.empty?
15
+ order = object.first.order
16
+ else
17
+ order = object
18
+ end
19
+
20
+ order.ship_total
21
+ end
22
+
23
+ end
24
+
@@ -0,0 +1,7 @@
1
+ class Promotion::Rules::FirstOrder < PromotionRule
2
+
3
+ def eligible?(order)
4
+ order.user && order.user.orders.checkout_complete.count == 0
5
+ end
6
+
7
+ end
@@ -0,0 +1,14 @@
1
+ # A rule to limit a promotion to a specific user.
2
+ class Promotion::Rules::ItemTotal < PromotionRule
3
+
4
+ preference :amount, :decimal, :default => 100.00
5
+ preference :operator, :string, :default => '>'
6
+
7
+ OPERATORS = ['gt', 'gte']
8
+
9
+
10
+ def eligible?(order)
11
+ order.item_total.send(preferred_operator == 'gte' ? :>= : :>, preferred_amount)
12
+ end
13
+
14
+ end
@@ -0,0 +1,41 @@
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
+ class Promotion::Rules::Product < PromotionRule
5
+
6
+ belongs_to :product_group
7
+ has_and_belongs_to_many :products, :class_name => '::Product', :join_table => 'products_promotion_rules', :foreign_key => 'promotion_rule_id'
8
+
9
+ MATCH_POLICIES = %w(any all)
10
+ preference :match_policy, :string, :default => MATCH_POLICIES.first
11
+
12
+ # scope/association that is used to test eligibility
13
+ def eligible_products
14
+ product_group ? product_group.products : products
15
+ end
16
+
17
+ def eligible?(order)
18
+ return true if eligible_products.empty?
19
+ order_products = order.line_items.map{|li| li.variant.product}
20
+ if preferred_match_policy == 'all'
21
+ eligible_products.all? {|p| order_products.include?(p) }
22
+ else
23
+ order_products.any? {|p| eligible_products.include?(p) }
24
+ end
25
+ end
26
+
27
+
28
+ def products_source=(source)
29
+ if source.to_s == 'manual'
30
+ self.product_group_id = nil
31
+ end
32
+ end
33
+
34
+ def product_ids_string
35
+ product_ids.join(',')
36
+ end
37
+ def product_ids_string=(s)
38
+ self.product_ids = s.to_s.split(',').map(&:strip)
39
+ end
40
+
41
+ end
@@ -0,0 +1,18 @@
1
+ class Promotion::Rules::User < PromotionRule
2
+ belongs_to :user
3
+ has_and_belongs_to_many :users, :class_name => '::User', :join_table => 'promotion_rules_users', :foreign_key => 'promotion_rule_id'
4
+
5
+ def eligible?(order)
6
+ users.none? or users.include?(order.user)
7
+ end
8
+
9
+
10
+ def user_ids_string
11
+ user_ids.join(',')
12
+ end
13
+
14
+ def user_ids_string=(s)
15
+ self.user_ids = s.to_s.split(',').map(&:strip)
16
+ end
17
+
18
+ end
@@ -0,0 +1,57 @@
1
+ class Promotion < ActiveRecord::Base
2
+ has_many :promotion_credits, :as => :source
3
+ calculated_adjustments
4
+ alias credits promotion_credits
5
+
6
+ has_many :promotion_rules
7
+ accepts_nested_attributes_for :promotion_rules
8
+ alias_method :rules, :promotion_rules
9
+
10
+ validates :name, :code, :presence => true
11
+
12
+ MATCH_POLICIES = %w(all any)
13
+
14
+ scope :automatic, where("code IS NULL OR code = ''")
15
+
16
+
17
+ def eligible?(order)
18
+ !expired? && rules_are_eligible?(order)
19
+ end
20
+
21
+ def expired?
22
+ starts_at && Time.now < starts_at ||
23
+ expires_at && Time.now > expires_at ||
24
+ usage_limit && promotion_credits.with_order.count >= usage_limit
25
+ end
26
+
27
+ def rules_are_eligible?(order)
28
+ return true if rules.none?
29
+ if match_policy == 'all'
30
+ rules.all?{|r| r.eligible?(order)}
31
+ else
32
+ rules.any?{|r| r.eligible?(order)}
33
+ end
34
+ end
35
+
36
+ def create_discount(order)
37
+ return if order.promotion_credits.reload.detect { |credit| credit.source_id == self.id }
38
+ if eligible?(order) and amount = calculator.compute(order)
39
+ amount = order.item_total if amount > order.item_total
40
+ order.promotion_credits.reload.clear unless combine? and order.promotion_credits.all? { |credit| credit.source.combine? }
41
+ order.promotion_credits.create({
42
+ :label => "#{I18n.t(:coupon)} (#{code})",
43
+ :source => self,
44
+ :amount => -amount.abs
45
+ })
46
+ order.update!
47
+ end
48
+ end
49
+
50
+
51
+
52
+ # Products assigned to all product rules
53
+ def products
54
+ @products ||= rules.of_type("Promotion::Rules::Product").map(&:products).flatten.uniq
55
+ end
56
+
57
+ end
@@ -0,0 +1,30 @@
1
+ class PromotionCredit < ::Adjustment
2
+ belongs_to :order
3
+ scope :with_order, :conditions => "order_id IS NOT NULL"
4
+
5
+ def calculate_adjustment
6
+ adjustment_source && calculate_coupon_credit
7
+ end
8
+
9
+ # Checks if credit is still applicable to order
10
+ # If source of adjustment is credit, it checks if it's still valid
11
+ def applicable?
12
+ adjustment_source && adjustment_source.eligible?(order) && super
13
+ end
14
+
15
+ # Calculates credit for the coupon.
16
+ #
17
+ # If coupon amount exceeds the order item_total, credit is adjusted.
18
+ #
19
+ # Always returns negative non positive.
20
+ def calculate_coupon_credit
21
+ return 0 if order.line_items.empty?
22
+ amount = adjustment_source.calculator.compute(order.line_items).abs
23
+ amount = order.item_total if amount > order.item_total
24
+ -1 * amount
25
+ end
26
+
27
+ def total
28
+ map(&:amount).sum
29
+ end
30
+ end
@@ -0,0 +1,26 @@
1
+ # Base class for all promotion rules
2
+ class PromotionRule < ActiveRecord::Base
3
+
4
+ belongs_to :promotion
5
+
6
+ scope :of_type, lambda {|t| {:conditions => {:type => t}}}
7
+
8
+ def eligible?(order)
9
+ raise 'eligible? should be implemented in a sub-class of Promotion::PromotionRule'
10
+ end
11
+
12
+ @rule_classes = nil
13
+ @@rule_classes = Set.new
14
+ def self.register
15
+ @@rule_classes.add(self)
16
+ end
17
+
18
+ def self.rule_classes
19
+ @@rule_classes.to_a
20
+ end
21
+
22
+ def self.rule_class_names
23
+ PromotionRule.rule_classes.map(&:name)
24
+ end
25
+
26
+ end
@@ -0,0 +1,6 @@
1
+ jQuery('#rules').append('<%= escape_javascript( render(:partial => 'admin/promotions/promotion_rule', :object => object) ) %>');
2
+ jQuery('#<%= dom_id object %>').hide();
3
+ jQuery('#<%= dom_id object %>').fadeIn();
4
+ initProductRuleSourceField();
5
+ jQuery('#<%= dom_id object %> .tokeninput.products').productPicker();
6
+ jQuery('#<%= dom_id object %> .tokeninput.users').userPicker();
@@ -0,0 +1 @@
1
+ jQuery('#<%= dom_id object %>').fadeOut();
@@ -0,0 +1,46 @@
1
+ <%= render "shared/error_messages", :target => @promotion %>
2
+ <fieldset id="general_fields">
3
+ <legend>General</legend>
4
+ <%= f.field_container :name do %>
5
+ <%= f.label :name %><br />
6
+ <%= f.text_field :name %>
7
+ <% end %>
8
+
9
+ <%= f.field_container :description do %>
10
+ <%= f.label :description %><br />
11
+ <%= f.text_area :description, :style => "height:50px" %>
12
+ <% end %>
13
+
14
+ <%= f.field_container :code do %>
15
+ <%= f.label :code %><br />
16
+ <%= f.text_field :code %>
17
+ <% end %>
18
+
19
+ <%= f.field_container :combine do %>
20
+ <label>
21
+ <%= f.check_box :combine %>
22
+ May be combined with other promotions
23
+ </label>
24
+ <% end %>
25
+
26
+ </fieldset>
27
+
28
+ <fieldset id="expiry_fields">
29
+ <legend>Expiry</legend>
30
+ <p>
31
+ <%= f.label :usage_limit %><br />
32
+ <%= f.text_field :usage_limit %>
33
+ </p>
34
+
35
+ <p id="starts_at_field">
36
+ <%= f.label :starts_at %>
37
+ <%= date_select :promotion, :starts_at, :prompt => true, :use_month_numbers => true %>
38
+ </p>
39
+ <p id="expires_at_field">
40
+ <%= f.label :expires_at %>
41
+ <%= date_select :promotion, :expires_at, :prompt => true, :use_month_numbers => true %>
42
+ </p>
43
+ </fieldset>
44
+
45
+ <hr />
46
+ <%= render "admin/shared/calculator_fields", :f => f %>
@@ -0,0 +1,8 @@
1
+ <div class="promotion-rule" id="<%= dom_id promotion_rule %>">
2
+ <span class="delete"><%= link_to_with_icon 'cross', '', admin_promotion_promotion_rule_path(@promotion, promotion_rule), 'data-remote' => true, 'data-method' => 'delete' %></span>
3
+ <% type_name = promotion_rule.class.name.demodulize.underscore %>
4
+ <% param_prefix = "promotion[promotion_rules_attributes][#{promotion_rule.id}]" %>
5
+ <%= hidden_field_tag "#{param_prefix}[id]", promotion_rule.id %>
6
+ <p><%= t "promotion_rule_types.#{type_name}.description" %></p>
7
+ <%= render "admin/promotions/rules/#{type_name}", :promotion_rule => promotion_rule, :param_prefix => param_prefix %>
8
+ </div>
@@ -0,0 +1 @@
1
+ <%= tab(:promotions) %>
@@ -0,0 +1,45 @@
1
+ <h1><%= t("editing_promotion") %></h1>
2
+
3
+ <%= form_for(@promotion, :url => object_url, :html => { :method => :put }) do |f| %>
4
+ <%= render :partial => "form", :locals => { :f => f } %>
5
+ <%= render "admin/shared/edit_resource_links" %>
6
+ <% end %>
7
+
8
+ <fieldset id="rule_fields">
9
+ <legend>Rules</legend>
10
+
11
+ <%= form_for(@promotion, :url => object_url, :html => { :method => :put }) do |f| %>
12
+ <p>
13
+ <% for policy in Promotion::MATCH_POLICIES %>
14
+ <label><%= f.radio_button :match_policy, policy %> <%= t "promotion_form.match_policies.#{policy}" %></label> &nbsp;
15
+ <% end %>
16
+ </p>
17
+ <div id="rules" class="filter_list">
18
+ <% if @promotion.rules.any? %>
19
+ <%= render :partial => 'promotion_rule', :collection => @promotion.rules %>
20
+ <% else %>
21
+ <!-- <p><%= t('no_rules_added') %></p> -->
22
+ <% end %>
23
+ </div>
24
+ <p class="form-buttons">
25
+ <%= button t("update") %>
26
+ </p>
27
+ <% end %>
28
+
29
+ <%= form_tag(admin_promotion_promotion_rules_path(@promotion), 'data-remote' => true, :id => 'new_product_rule_form') do |f| %>
30
+ <% options = options_for_select( PromotionRule.rule_class_names.map {|name| [ t("promotion_rule_types.#{name.demodulize.underscore}.name"), name] } ) %>
31
+ <p>
32
+ <label for="promotion_rule_type"><%= t('add_rule_of_type') %></label>
33
+ <%= select_tag("promotion_rule[type]", options) %>
34
+ <input type="submit" value="<%= t('add') %>" />
35
+ </p>
36
+ <% end %>
37
+
38
+ </fieldset>
39
+
40
+
41
+
42
+ <% content_for :head do %>
43
+ <%= javascript_include_tag 'admin/promotions.js' %>
44
+ <%= stylesheet_link_tag 'admin/promotions.css' %>
45
+ <% end %>
@@ -0,0 +1,40 @@
1
+ <div class='toolbar'>
2
+ <ul class='actions'>
3
+ <li>
4
+ <%= button_link_to t("new_promotion"), new_object_url, :icon => 'add' %>
5
+ </li>
6
+ </ul>
7
+ <br class='clear' />
8
+ </div>
9
+
10
+ <h1><%= t("promotions") %></h1>
11
+
12
+ <table class="index">
13
+ <thead>
14
+ <tr>
15
+ <th><%= t("code") %></th>
16
+ <th><%= t("description") %></th>
17
+ <th><%= t("combine") %></th>
18
+ <th><%= t("usage_limit") %></th>
19
+ <th><%= t("expiration") %></th>
20
+ <th><%= t("calculator") %></th>
21
+ <th width="150"><%= t("action") %></th>
22
+ </tr>
23
+ </thead>
24
+ <tbody>
25
+ <% for promotion in @promotions %>
26
+ <tr id="<%= dom_id promotion %>">
27
+ <td><%= promotion.code %></td>
28
+ <td><%= promotion.description %></td>
29
+ <td><%= promotion.combine ? t(:yes) : t(:no) %></td>
30
+ <td><%= promotion.usage_limit %></td>
31
+ <td><%= promotion.expires_at.to_date.to_s(:short_date) if promotion.expires_at %></td>
32
+ <td><%= promotion.calculator.description if promotion.calculator %></td>
33
+ <td>
34
+ <%= link_to_edit promotion %> &nbsp;
35
+ <%= link_to_delete promotion %>
36
+ </td>
37
+ </tr>
38
+ <% end %>
39
+ </tbody>
40
+ </table>
@@ -0,0 +1,7 @@
1
+ <h1><%= t("new_promotion") %></h1>
2
+ <%= render "shared/error_messages", :target => @promotion %>
3
+
4
+ <%= form_for(:promotion, :url => collection_url) do |f| %>
5
+ <%= render :partial => "form", :locals => { :f => f } %>
6
+ <%= render :partial => "admin/shared/new_resource_links" %>
7
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <p class="field">
2
+ <label>
3
+ Item total must be
4
+ <%= select_tag "#{param_prefix}[preferred_operator]", options_for_select(Promotion::Rules::ItemTotal::OPERATORS.map{|o| [t("item_total_rule.operators.#{o}"),o]}, promotion_rule.preferred_operator) %>
5
+ <%= text_field_tag "#{param_prefix}[preferred_amount]", promotion_rule.preferred_amount, :size => 10 %>
6
+ </label>
7
+ </p>
@@ -0,0 +1,18 @@
1
+ <p class="field products_rule_products_source_field">
2
+ <label><%= radio_button_tag "#{param_prefix}[products_source]", :manual, promotion_rule.product_group.nil? %> <%= t "product_rule.product_source.manual" %></label> &nbsp;
3
+ <label><%= radio_button_tag "#{param_prefix}[products_source]", :group, promotion_rule.product_group.present? %> <%= t "product_rule.product_source.group" %></label>
4
+ </p>
5
+ <p class="field products_rule_product_group">
6
+ <label><%= t('product_group') %><br />
7
+ <%= select_tag "#{param_prefix}[product_group_id]", '<option></option>' + options_from_collection_for_select(ProductGroup.all, :id, :name, promotion_rule.product_group_id) %></label>
8
+ </p>
9
+ <p class="field products_rule_products">
10
+ <label>
11
+ <%= t('product_rule.choose_products') %><br />
12
+ <%= product_picker_field "#{param_prefix}[product_ids_string]", promotion_rule.product_ids_string %>
13
+ </p>
14
+ <p>
15
+ <label>
16
+ <%= t("product_rule.label", :select => select_tag("#{param_prefix}[preferred_match_policy]", options_for_select(Promotion::Rules::Product::MATCH_POLICIES.map{|s| [t("product_rule.match_#{s}"),s] }, promotion_rule.preferred_match_policy))).html_safe %>
17
+ </label>
18
+ </p>
@@ -0,0 +1,7 @@
1
+ <p class="field">
2
+ <label>
3
+ <%= t('user_rule.choose_users') %><br />
4
+ <% user_names_hash = promotion_rule.users.inject({}){|memo,item| memo[item.id] = item.email; memo} %>
5
+ <input type="text" name="<%= param_prefix %>[user_ids_string]" value="<%= promotion_rule.user_ids_string %>" class="tokeninput users" data-names='<%= user_names_hash.to_json %>' />
6
+ </label>
7
+ </p>
@@ -0,0 +1,23 @@
1
+ <% promotions = @product.possible_promotions %>
2
+ <% if promotions.any? %>
3
+ <div id="promotions">
4
+ <h3><%= t 'promotions' %></h3>
5
+
6
+ <% for promotion in promotions %>
7
+ <div>
8
+ <h4><%= promotion.name %></h4>
9
+ <p><%= promotion.description %></p>
10
+ <% if promotion.products.any? %>
11
+ <ul>
12
+ <% for product in promotion.products %>
13
+ <li><%= link_to product.name, product_path(product) %></li>
14
+ <% end %>
15
+ </ul>
16
+ <% end %>
17
+ </div>
18
+ <% end %>
19
+
20
+ </div>
21
+ <% end %>
22
+
23
+
@@ -0,0 +1,42 @@
1
+ ---
2
+ en:
3
+ add_rule_of_type: Add rule of type
4
+ coupon: Coupon
5
+ coupon_code: Coupon code
6
+ editing_promotion: Editing Promotion
7
+ free_shipping: Free Shipping
8
+ new_promotion: New Promotion
9
+ no_rules_added: No rules added
10
+ promotions: Promotions
11
+ promotion_form:
12
+ match_policies:
13
+ all: Match any of these rules
14
+ any: Match all of these rules
15
+ promotions_description: Manage offers and coupons with promotions
16
+ promotion_rule_types:
17
+ user:
18
+ name: User
19
+ description: Available only to the specified users
20
+ product:
21
+ name: Product(s)
22
+ description: Order includes specified product(s)
23
+ item_total:
24
+ name: Item total
25
+ description: Order total meets these criteria
26
+ first_order:
27
+ name: First order
28
+ description: Must be the customer's first order
29
+ product_rule:
30
+ choose_products: Choose products
31
+ label: "Order must contain {{select}} of these products"
32
+ match_any: at least one
33
+ match_all: all
34
+ product_source:
35
+ group: From product group
36
+ manual: Manually choose
37
+ user_rule:
38
+ choose_users: Choose users
39
+ item_total_rule:
40
+ operators:
41
+ gt: greater than
42
+ gte: greater than or equal to
data/config/routes.rb ADDED
@@ -0,0 +1,7 @@
1
+ Rails.application.routes.draw do
2
+ namespace :admin do
3
+ resources :promotions do
4
+ resources :promotion_rules
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ class RenameCouponsToPromotions < ActiveRecord::Migration
2
+ def self.up
3
+ execute "DROP TABLE IF EXISTS promotions"
4
+ rename_table :coupons, :promotions
5
+ end
6
+
7
+ def self.down
8
+ rename_table :promotions, :coupons
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ class FixExistingCouponCredits < ActiveRecord::Migration
2
+ def self.up
3
+ execute("UPDATE adjustments SET type='PromotionCredit' WHERE type='CouponCredit'")
4
+ execute("UPDATE adjustments SET adjustment_source_type='Promotion' WHERE adjustment_source_type='Coupon'")
5
+ end
6
+
7
+ def self.down
8
+ end
9
+ end
@@ -0,0 +1,24 @@
1
+ class CreatePromotionRules < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :promotion_rules do |t|
4
+ t.references :promotion, :user, :product_group
5
+ t.timestamps
6
+ t.string :type
7
+ end
8
+ add_index :promotion_rules, :product_group_id
9
+ add_index :promotion_rules, :user_id
10
+
11
+ create_table :products_promotion_rules do |t|
12
+ t.integer :product_id, :promotion_rule_id
13
+ end
14
+ remove_column :products_promotion_rules, :id
15
+ add_index :products_promotion_rules, :product_id
16
+ add_index :products_promotion_rules, :promotion_rule_id
17
+
18
+ end
19
+
20
+ def self.down
21
+ drop_table :promotion_rules
22
+ drop_table :products_promotion_rules
23
+ end
24
+ end
@@ -0,0 +1,9 @@
1
+ class MatchPolicyForPromotions < ActiveRecord::Migration
2
+ def self.up
3
+ add_column "promotions", "match_policy", :string, :default => 'all'
4
+ end
5
+
6
+ def self.down
7
+ remove_column "promotions", "match_policy"
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ class CreatePromotionRulesUsers < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :promotion_rules_users do |t|
4
+ t.integer :user_id, :promotion_rule_id
5
+ end
6
+ remove_column :promotion_rules_users, :id
7
+ add_index :promotion_rules_users, :user_id
8
+ add_index :promotion_rules_users, :promotion_rule_id
9
+ end
10
+
11
+ def self.down
12
+ drop_table :promotion_rules_users
13
+ end
14
+ end
@@ -0,0 +1,9 @@
1
+ class NameForPromotions < ActiveRecord::Migration
2
+ def self.up
3
+ add_column "promotions", "name", :string
4
+ end
5
+
6
+ def self.down
7
+ remove_column "promotions", "name"
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ class UpdateCalculableTypeForPromotions < ActiveRecord::Migration
2
+ def self.up
3
+ Calculator.update_all("calculable_type = 'Promotion'", "calculable_type = 'Coupon'")
4
+ end
5
+
6
+ def self.down
7
+ Calculator.update_all("calculable_type = 'Coupon'", "calculable_type = 'Promotion'")
8
+ end
9
+ end
@@ -0,0 +1,104 @@
1
+ require 'spree_core'
2
+ require 'spree_promo_hooks'
3
+
4
+ module SpreePromo
5
+ class Engine < Rails::Engine
6
+ def self.activate
7
+ # put class_eval and other logic that depends on classes outside of the engine inside this block
8
+ Product.class_eval do
9
+ has_and_belongs_to_many :promotion_rules
10
+
11
+ def possible_promotions
12
+ rules_with_matching_product_groups = product_groups.map(&:promotion_rules).flatten
13
+ all_rules = promotion_rules + rules_with_matching_product_groups
14
+ promotion_ids = all_rules.map(&:promotion_id).uniq
15
+ Promotion.automatic.scoped(:conditions => {:id => promotion_ids})
16
+ end
17
+ end
18
+
19
+ ProductGroup.class_eval do
20
+ has_many :promotion_rules
21
+ end
22
+
23
+ Order.class_eval do
24
+
25
+ has_many :promotion_credits, :conditions => "source_type='Promotion'"
26
+
27
+ attr_accessible :coupon_code
28
+ attr_accessor :coupon_code
29
+ before_save :process_coupon_code, :if => "@coupon_code"
30
+
31
+ def process_coupon_code
32
+ coupon = Promotion.find(:first, :conditions => ["UPPER(code) = ?", @coupon_code.upcase])
33
+ if coupon
34
+ coupon.create_discount(self)
35
+ end
36
+ end
37
+
38
+ def products
39
+ line_items.map {|li| li.variant.product}
40
+ end
41
+
42
+ def update_totals(force_adjustment_recalculation=false)
43
+ self.payment_total = payments.completed.map(&:amount).sum
44
+ self.item_total = line_items.map(&:amount).sum
45
+
46
+ process_automatic_promotions
47
+
48
+ if force_adjustment_recalculation
49
+ applicable_adjustments, adjustments_to_destroy = adjustments.partition{|a| a.applicable?}
50
+ self.adjustments = applicable_adjustments
51
+ adjustments_to_destroy.each(&:destroy)
52
+ end
53
+
54
+ self.adjustment_total = self.adjustments.map(&:amount).sum
55
+ self.total = self.item_total + self.adjustment_total
56
+ end
57
+
58
+
59
+ def process_automatic_promotions
60
+ #promotion_credits.reload.clear
61
+ eligible_automatic_promotions.each do |coupon|
62
+ # can't use coupon.create_discount as it re-saves the order causing an infinite loop
63
+ if amount = coupon.calculator.compute(line_items)
64
+ amount = item_total if amount > item_total
65
+ promotion_credits.reload.clear unless coupon.combine? and promotion_credits.all? { |credit| credit.adjustment_source.combine? }
66
+ promotion_credits.create!({
67
+ :source => coupon,
68
+ :amount => -amount.abs,
69
+ :label => coupon.description
70
+ })
71
+ end
72
+ end.compact
73
+ end
74
+
75
+ def eligible_automatic_promotions
76
+ @eligible_automatic_coupons ||= Promotion.automatic.select{|c| c.eligible?(self)}
77
+ end
78
+ end
79
+
80
+ if File.basename( $0 ) != "rake"
81
+ # register promotion rules
82
+ [Promotion::Rules::ItemTotal, Promotion::Rules::Product, Promotion::Rules::User, Promotion::Rules::FirstOrder].each &:register
83
+
84
+ # register default promotion calculators
85
+ [
86
+ Calculator::FlatPercentItemTotal,
87
+ Calculator::FlatRate,
88
+ Calculator::FlexiRate,
89
+ Calculator::PerItem,
90
+ Calculator::FreeShipping
91
+ ].each{|c_model|
92
+ begin
93
+ Promotion.register_calculator(c_model) if c_model.table_exists?
94
+ rescue Exception => e
95
+ $stderr.puts "Error registering promotion calculator #{c_model}"
96
+ end
97
+ }
98
+ end
99
+ end
100
+
101
+ config.autoload_paths += %W(#{config.root}/lib)
102
+ config.to_prepare &method(:activate).to_proc
103
+ end
104
+ end
@@ -0,0 +1,9 @@
1
+ class PromoHooks < Spree::ThemeSupport::HookListener
2
+
3
+ insert_after :admin_tabs do
4
+ %(<%= tab(:promotions) %>)
5
+ end
6
+
7
+ insert_after :product_properties, 'products/promotions'
8
+
9
+ end
@@ -0,0 +1,27 @@
1
+ namespace :spree_promo do
2
+ desc "Copies all migrations and assets (NOTE: This will be obsolete with Rails 3.1)"
3
+ task :install do
4
+ Rake::Task['spree_promo:install:migrations'].invoke
5
+ Rake::Task['spree_promo:install:assets'].invoke
6
+ end
7
+
8
+ namespace :install do
9
+
10
+ desc "Copies all migrations (NOTE: This will be obsolete with Rails 3.1)"
11
+ task :migrations do
12
+ source = File.join(File.dirname(__FILE__), '..', '..', 'db')
13
+ destination = File.join(Rails.root, 'db')
14
+ puts "INFO: Mirroring assets from #{source} to #{destination}"
15
+ Spree::FileUtilz.mirror_files(source, destination)
16
+ end
17
+
18
+ desc "Copies all assets (NOTE: This will be obsolete with Rails 3.1)"
19
+ task :assets do
20
+ source = File.join(File.dirname(__FILE__), '..', '..', 'public')
21
+ destination = File.join(Rails.root, 'public')
22
+ puts "INFO: Mirroring assets from #{source} to #{destination}"
23
+ Spree::FileUtilz.mirror_files(source, destination)
24
+ end
25
+
26
+ end
27
+ end
@@ -0,0 +1,11 @@
1
+ namespace :spree do
2
+ desc "Synchronize public assets, migrations, seed and sample data from the Spree gems"
3
+ task :sync do
4
+ public_dir = File.join(File.dirname(__FILE__), '..', '..', 'public')
5
+ migration_dir = File.join(File.dirname(__FILE__), '..', '..', 'db', 'migrate')
6
+ puts "Mirror: #{public_dir}"
7
+ Spree::FileUtilz.mirror_with_backup(public_dir, File.join(Rails.root, 'public'))
8
+ puts "Mirror: #{migration_dir}"
9
+ Spree::FileUtilz.mirror_with_backup(migration_dir, File.join(Rails.root, 'db', 'migrate'))
10
+ end
11
+ end
@@ -0,0 +1,17 @@
1
+ namespace :spree do
2
+ namespace :extensions do
3
+ namespace :promotions do
4
+ desc "Copies public assets of the Promotions to the instance public/ directory."
5
+ task :update => :environment do
6
+ is_svn_git_or_dir = proc {|path| path =~ /\.svn/ || path =~ /\.git/ || File.directory?(path) }
7
+ Dir[PromotionsExtension.root + "/public/**/*"].reject(&is_svn_git_or_dir).each do |file|
8
+ path = file.sub(PromotionsExtension.root, '')
9
+ directory = File.dirname(path)
10
+ puts "Copying #{path}..."
11
+ mkdir_p Rails.root + directory
12
+ cp file, Rails.root + path
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,26 @@
1
+ var initProductRuleSourceField = function(){
2
+
3
+ $products_source_field = jQuery('.products_rule_products_source_field input');
4
+ $products_source_field.click(function() {
5
+ $rule_container = jQuery(this).parents('.promotion-rule');
6
+ if(this.checked){
7
+ if(this.value == 'manual'){
8
+ $rule_container.find('.products_rule_products').show();
9
+ $rule_container.find('.products_rule_product_group').hide();
10
+ } else {
11
+ $rule_container.find('.products_rule_products').hide();
12
+ $rule_container.find('.products_rule_product_group').show();
13
+ }
14
+ }
15
+ });
16
+ $products_source_field.each(function() {
17
+ $(this).triggerHandler('click');
18
+ });
19
+
20
+ };
21
+
22
+
23
+ jQuery(document).ready(function() {
24
+ initProductRuleSourceField();
25
+ });
26
+
@@ -0,0 +1,54 @@
1
+ .promotion-rule {
2
+ -moz-border-radius: 5px;
3
+ -webkit-border-radius: 5px;
4
+ padding: 8px 40px 8px 10px;
5
+ background-color: #eee;
6
+ border-bottom: 1px solid #bbb;
7
+ margin-bottom: 4px;
8
+ position: relative;
9
+ }
10
+ .promotion-rule .delete {
11
+ display: block;
12
+ position: absolute;
13
+ right: 10px;
14
+ top: 10px;
15
+ }
16
+
17
+
18
+ .products_rule_products_source_field label {
19
+ display: inline;
20
+ }
21
+
22
+
23
+
24
+ .edit_promotion #general_fields, .edit_promotion #expiry_fields {
25
+ float: left;
26
+ width: 46%;
27
+ }
28
+
29
+ .edit_promotion #general_fields {
30
+ margin-right: 10px;
31
+ }
32
+
33
+ .edit_promotion #expiry_fields {
34
+
35
+ }
36
+
37
+ .edit_promotion #starts_at_field, .edit_promotion #expires_at_field {
38
+ width: 200px;
39
+ float: left;
40
+ clear: none;
41
+ }
42
+ .edit_promotion #starts_at_field label, .edit_promotion #expires_at_field label{
43
+ display: block;
44
+ }
45
+ #rule_fields {
46
+ position: relative;
47
+ }
48
+ #new_product_rule_form {
49
+ position: absolute;
50
+ text-align:right;
51
+ width: 300px;
52
+ right: 30px;
53
+ bottom: 20px;
54
+ }
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: spree_promo
3
+ version: !ruby/object:Gem::Version
4
+ hash: 103
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 30
9
+ - 0
10
+ version: 0.30.0
11
+ platform: ruby
12
+ authors:
13
+ - David North
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2010-11-09 00:00:00 -05:00
19
+ default_executable:
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ name: spree_core
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - "="
28
+ - !ruby/object:Gem::Version
29
+ hash: 103
30
+ segments:
31
+ - 0
32
+ - 30
33
+ - 0
34
+ version: 0.30.0
35
+ type: :runtime
36
+ version_requirements: *id001
37
+ description: Required dependancy for Spree
38
+ email: david@railsdog.com
39
+ executables: []
40
+
41
+ extensions: []
42
+
43
+ extra_rdoc_files: []
44
+
45
+ files:
46
+ - app/controllers/admin/promotion_rules_controller.rb
47
+ - app/controllers/admin/promotions_controller.rb
48
+ - app/models/calculator/free_shipping.rb
49
+ - app/models/promotion/rules/first_order.rb
50
+ - app/models/promotion/rules/item_total.rb
51
+ - app/models/promotion/rules/product.rb
52
+ - app/models/promotion/rules/user.rb
53
+ - app/models/promotion.rb
54
+ - app/models/promotion_credit.rb
55
+ - app/models/promotion_rule.rb
56
+ - app/views/admin/promotion_rules/create.js.erb
57
+ - app/views/admin/promotion_rules/destroy.js.erb
58
+ - app/views/admin/promotions/_form.html.erb
59
+ - app/views/admin/promotions/_promotion_rule.html.erb
60
+ - app/views/admin/promotions/_tab.html.erb
61
+ - app/views/admin/promotions/edit.html.erb
62
+ - app/views/admin/promotions/index.html.erb
63
+ - app/views/admin/promotions/new.html.erb
64
+ - app/views/admin/promotions/rules/_first_order.html.erb
65
+ - app/views/admin/promotions/rules/_item_total.html.erb
66
+ - app/views/admin/promotions/rules/_product.html.erb
67
+ - app/views/admin/promotions/rules/_user.html.erb
68
+ - app/views/products/_promotions.html.erb
69
+ - config/locales/en.yml
70
+ - config/routes.rb
71
+ - lib/spree_promo.rb
72
+ - lib/spree_promo_hooks.rb
73
+ - lib/tasks/install.rake
74
+ - lib/tasks/promotions.rake
75
+ - lib/tasks/promotions_extension_tasks.rake
76
+ - db/migrate/20100419190933_rename_coupons_to_promotions.rb
77
+ - db/migrate/20100419194457_fix_existing_coupon_credits.rb
78
+ - db/migrate/20100426100726_create_promotion_rules.rb
79
+ - db/migrate/20100501084722_match_policy_for_promotions.rb
80
+ - db/migrate/20100501095202_create_promotion_rules_users.rb
81
+ - db/migrate/20100502163939_name_for_promotions.rb
82
+ - db/migrate/20100923095305_update_calculable_type_for_promotions.rb
83
+ - public/javascripts/admin/promotions.js
84
+ - public/stylesheets/admin/promotions.css
85
+ has_rdoc: true
86
+ homepage: http://spreecommerce.com
87
+ licenses: []
88
+
89
+ post_install_message:
90
+ rdoc_options: []
91
+
92
+ require_paths:
93
+ - lib
94
+ required_ruby_version: !ruby/object:Gem::Requirement
95
+ none: false
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ hash: 57
100
+ segments:
101
+ - 1
102
+ - 8
103
+ - 7
104
+ version: 1.8.7
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ none: false
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ hash: 3
111
+ segments:
112
+ - 0
113
+ version: "0"
114
+ requirements:
115
+ - none
116
+ rubyforge_project: spree_promo
117
+ rubygems_version: 1.3.7
118
+ signing_key:
119
+ specification_version: 3
120
+ summary: Promotion functionality for use with Spree.
121
+ test_files: []
122
+