spree_promo 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
+