My-Commerce_promo 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. data/app/controllers/admin/promotion_rules_controller.rb +26 -0
  2. data/app/controllers/admin/promotions_controller.rb +21 -0
  3. data/app/controllers/orders_controller_decorator.rb +10 -0
  4. data/app/models/calculator/free_shipping.rb +24 -0
  5. data/app/models/promotion/rules/first_order.rb +7 -0
  6. data/app/models/promotion/rules/item_total.rb +12 -0
  7. data/app/models/promotion/rules/product.rb +40 -0
  8. data/app/models/promotion/rules/user.rb +17 -0
  9. data/app/models/promotion.rb +73 -0
  10. data/app/models/promotion_credit.rb +9 -0
  11. data/app/models/promotion_rule.rb +26 -0
  12. data/app/views/admin/promotion_rules/create.js.erb +6 -0
  13. data/app/views/admin/promotion_rules/destroy.js.erb +1 -0
  14. data/app/views/admin/promotions/_form.html.erb +46 -0
  15. data/app/views/admin/promotions/_promotion_rule.html.erb +8 -0
  16. data/app/views/admin/promotions/_tab.html.erb +1 -0
  17. data/app/views/admin/promotions/edit.html.erb +46 -0
  18. data/app/views/admin/promotions/index.html.erb +40 -0
  19. data/app/views/admin/promotions/new.html.erb +6 -0
  20. data/app/views/admin/promotions/rules/_first_order.html.erb +0 -0
  21. data/app/views/admin/promotions/rules/_item_total.html.erb +7 -0
  22. data/app/views/admin/promotions/rules/_product.html.erb +18 -0
  23. data/app/views/admin/promotions/rules/_user.html.erb +7 -0
  24. data/app/views/checkout/_coupon_code_field.html.erb +6 -0
  25. data/app/views/products/_promotions.html.erb +23 -0
  26. data/config/cucumber.yml +10 -0
  27. data/config/locales/en.yml +55 -0
  28. data/config/routes.rb +7 -0
  29. data/db/migrate/20100419190933_rename_coupons_to_promotions.rb +10 -0
  30. data/db/migrate/20100419194457_fix_existing_coupon_credits.rb +9 -0
  31. data/db/migrate/20100426100726_create_promotion_rules.rb +24 -0
  32. data/db/migrate/20100501084722_match_policy_for_promotions.rb +9 -0
  33. data/db/migrate/20100501095202_create_promotion_rules_users.rb +14 -0
  34. data/db/migrate/20100502163939_name_for_promotions.rb +9 -0
  35. data/db/migrate/20100923095305_update_calculable_type_for_promotions.rb +9 -0
  36. data/lib/spree_promo.rb +132 -0
  37. data/lib/spree_promo_hooks.rb +11 -0
  38. data/lib/tasks/install.rake +26 -0
  39. data/lib/tasks/promotions.rake +11 -0
  40. data/lib/tasks/promotions_extension_tasks.rake +17 -0
  41. data/public/javascripts/admin/promotions.js +26 -0
  42. data/public/stylesheets/admin/promotions.css +54 -0
  43. metadata +101 -0
@@ -0,0 +1,26 @@
1
+ class Admin::PromotionRulesController < Admin::BaseController
2
+ def create
3
+ @promotion = Promotion.find(params[:promotion_id])
4
+ @promotion_rule = params[:promotion_rule][:type].constantize.new(params[:promotion_rule])
5
+ @promotion_rule.promotion = @promotion
6
+ if @promotion_rule.save
7
+ flash[:notice] = I18n.t(:successfully_created, :resource => I18n.t(:promotion_rule))
8
+ end
9
+ respond_to do |format|
10
+ format.html { redirect_to edit_admin_promotion_path(@promotion)}
11
+ format.js { render :layout => false }
12
+ end
13
+ end
14
+
15
+ def destroy
16
+ @promotion = Promotion.find(params[:promotion_id])
17
+ @promotion_rule = @promotion.promotion_rules.find(params[:id])
18
+ if @promotion_rule.destroy
19
+ flash[:notice] = I18n.t(:successfully_removed, :resource => I18n.t(:promotion_rule))
20
+ end
21
+ respond_to do |format|
22
+ format.html { redirect_to edit_admin_promotion_path(@promotion)}
23
+ format.js { render :layout => false }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,21 @@
1
+ class Admin::PromotionsController < Admin::ResourceController
2
+ before_filter :load_data
3
+
4
+ protected
5
+
6
+ def build_resource
7
+ @promotion = Promotion.new(params[:promotion])
8
+ if params[:promotion] && params[:promotion][:calculator_type]
9
+ @promotion.calculator = params[:promotion][:calculator_type].constantize.new
10
+ end
11
+ @promotion
12
+ end
13
+
14
+ def location_after_save
15
+ edit_admin_promotion_url(@promotion)
16
+ end
17
+
18
+ def load_data
19
+ @calculators = Promotion.calculators
20
+ end
21
+ end
@@ -0,0 +1,10 @@
1
+ OrdersController.class_eval do
2
+ after_filter :clear_promotions
3
+
4
+ private
5
+ def clear_promotions
6
+ current_order.promotion_credits.destroy_all if current_order
7
+ end
8
+
9
+ end
10
+
@@ -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.complete.count == 0
5
+ end
6
+
7
+ end
@@ -0,0 +1,12 @@
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
+ def eligible?(order)
10
+ order.item_total.send(preferred_operator == 'gte' ? :>= : :>, preferred_amount)
11
+ end
12
+ end
@@ -0,0 +1,40 @@
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
+ if preferred_match_policy == 'all'
20
+ eligible_products.all? {|p| order.products.include?(p) }
21
+ else
22
+ order.products.any? {|p| eligible_products.include?(p) }
23
+ end
24
+ end
25
+
26
+
27
+ def products_source=(source)
28
+ if source.to_s == 'manual'
29
+ self.product_group_id = nil
30
+ end
31
+ end
32
+
33
+ def product_ids_string
34
+ product_ids.join(',')
35
+ end
36
+ def product_ids_string=(s)
37
+ self.product_ids = s.to_s.split(',').map(&:strip)
38
+ end
39
+
40
+ end
@@ -0,0 +1,17 @@
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
+ def user_ids_string
10
+ user_ids.join(',')
11
+ end
12
+
13
+ def user_ids_string=(s)
14
+ self.user_ids = s.to_s.split(',').map(&:strip)
15
+ end
16
+
17
+ end
@@ -0,0 +1,73 @@
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, :autosave => true
7
+ accepts_nested_attributes_for :promotion_rules
8
+ alias_method :rules, :promotion_rules
9
+
10
+ validates :name, :presence => true
11
+
12
+ # TODO: Remove that after fix for https://rails.lighthouseapp.com/projects/8994/tickets/4329-has_many-through-association-does-not-link-models-on-association-save
13
+ # is provided
14
+ def save(*)
15
+ if super
16
+ promotion_rules.each { |p| p.save }
17
+ end
18
+ end
19
+
20
+ MATCH_POLICIES = %w(all any)
21
+
22
+ scope :automatic, where("code IS NULL OR code = ''")
23
+ scope :manual, where("code IS NOT NULL AND code <> ''")
24
+
25
+ def eligible?(order)
26
+ !expired? && rules_are_eligible?(order)
27
+ end
28
+
29
+ def expired?
30
+ starts_at && Time.now < starts_at ||
31
+ expires_at && Time.now > expires_at ||
32
+ usage_limit && credits_count >= usage_limit
33
+ end
34
+
35
+ def credits_count
36
+ credits.with_order.count
37
+ end
38
+
39
+ def rules_are_eligible?(order)
40
+ return true if rules.none?
41
+ if match_policy == 'all'
42
+ rules.all?{|r| r.eligible?(order)}
43
+ else
44
+ rules.any?{|r| r.eligible?(order)}
45
+ end
46
+ end
47
+
48
+ def create_discount(order)
49
+ return if order.promotion_credit_exists?(self)
50
+ if eligible?(order) and amount = compute(order)
51
+ order.promotion_credits.reload.clear unless combine? and order.promotion_credits.all? { |credit| credit.source.combine? }
52
+ order.update!
53
+ PromotionCredit.create!({
54
+ :label => "#{I18n.t(:coupon)} (#{code})",
55
+ :source => self,
56
+ :amount => -amount.abs,
57
+ :order => order
58
+ })
59
+ end
60
+ end
61
+
62
+ def compute(order)
63
+ amount = calculator.compute(order)
64
+ amount = order.item_total if amount > order.item_total
65
+ -amount
66
+ end
67
+
68
+ # Products assigned to all product rules
69
+ def products
70
+ @products ||= rules.of_type("Promotion::Rules::Product").map(&:products).flatten.uniq
71
+ end
72
+
73
+ end
@@ -0,0 +1,9 @@
1
+ class PromotionCredit < ::Adjustment
2
+ scope :with_order, :conditions => "order_id IS NOT NULL"
3
+
4
+ # Checks if credit is still applicable to order
5
+ # If source of adjustment is credit, it checks if it's still valid
6
+ def applicable?
7
+ source && source.eligible?(order) && super
8
+ end
9
+ 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 => @promotion_rule) ) %>');
2
+ jQuery('#<%= dom_id @promotion_rule %>').hide();
3
+ jQuery('#<%= dom_id @promotion_rule %>').fadeIn();
4
+ initProductRuleSourceField();
5
+ jQuery('#<%= dom_id @promotion_rule %> .tokeninput.products').productPicker();
6
+ jQuery('#<%= dom_id @promotion_rule %> .tokeninput.users').userPicker();
@@ -0,0 +1 @@
1
+ jQuery('#<%= dom_id @promotion_rule %>').fadeOut().remove();
@@ -0,0 +1,46 @@
1
+ <%= render "shared/error_messages", :target => @promotion %>
2
+ <fieldset id="general_fields">
3
+ <legend><%= t(: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
+ <%= t(:may_be_combined_with_other_promotions) %>
23
+ </label>
24
+ <% end %>
25
+
26
+ </fieldset>
27
+
28
+ <fieldset id="expiry_fields">
29
+ <legend><%= t(: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
+ <%= f.spree_date_picker :starts_at, :prompt => true, :use_month_numbers => true %>
38
+ </p>
39
+ <p id="expires_at_field">
40
+ <%= f.label :expires_at %>
41
+ <%= f.spree_date_picker :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,46 @@
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><%= t(: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,
30
+ :id => 'new_product_rule_form') do %>
31
+ <% options = options_for_select( PromotionRule.rule_class_names.map {|name| [ t("promotion_rule_types.#{name.demodulize.underscore}.name"), name] } ) %>
32
+ <p>
33
+ <label for="promotion_rule_type"><%= t('add_rule_of_type') %></label>
34
+ <%= select_tag("promotion_rule[type]", options) %>
35
+ <input type="submit" value="<%= t('add') %>" />
36
+ </p>
37
+ <% end %>
38
+
39
+ </fieldset>
40
+
41
+
42
+
43
+ <% content_for :head do %>
44
+ <%= javascript_include_tag 'admin/promotions.js' %>
45
+ <%= stylesheet_link_tag 'admin/promotions.css' %>
46
+ <% 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,6 @@
1
+ <h1><%= t("new_promotion") %></h1>
2
+
3
+ <%= form_for(:promotion, :url => collection_url) do |f| %>
4
+ <%= render :partial => "form", :locals => { :f => f } %>
5
+ <%= render :partial => "admin/shared/new_resource_links" %>
6
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <p class="field">
2
+ <label for="<%= "#{param_prefix}_preferred_amount" %>">
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]", 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,6 @@
1
+ <% if Promotion.manual.count > 0 %>
2
+ <p>
3
+ <%= form.label :coupon_code %><br />
4
+ <%= form.text_field :coupon_code %>
5
+ </p>
6
+ <% end %>
@@ -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,10 @@
1
+ <%
2
+ rerun = File.file?('rerun.txt') ? IO.read('rerun.txt') : ""
3
+ rerun_opts = rerun.to_s.strip.empty? ? "--format #{ENV['CUCUMBER_FORMAT'] || 'progress'} features" : "--format #{ENV['CUCUMBER_FORMAT'] || 'pretty'} #{rerun}"
4
+ std_opts = "--format #{ENV['CUCUMBER_FORMAT'] || 'progress'} --strict --tags ~@wip"
5
+ ci_opts = "--format progress --strict"
6
+ %>
7
+ default: <%= std_opts %> features
8
+ wip: --tags @wip:3 --wip features
9
+ ci: <%= ci_opts %> features CI=true
10
+ rerun: <%= rerun_opts %> --format rerun --out rerun.txt --strict --tags ~@wip
@@ -0,0 +1,55 @@
1
+ ---
2
+ en:
3
+ activerecord:
4
+ attributes:
5
+ promotion:
6
+ name: "Name"
7
+ description: "Description"
8
+ code: "Code"
9
+ usage_limit: "Usage limit"
10
+ starts_at: "Starts at"
11
+ expires_at: "Expires at"
12
+ add_rule_of_type: Add rule of type
13
+ coupon: Coupon
14
+ coupon_code: Coupon code
15
+ editing_promotion: Editing Promotion
16
+ expiry: Expiry
17
+ free_shipping: Free Shipping
18
+ may_be_combined_with_other_promotions: May be combined with other promotions
19
+ new_promotion: New Promotion
20
+ no_rules_added: No rules added
21
+ promotion: Promotion
22
+ promotions: Promotions
23
+ promotion_form:
24
+ match_policies:
25
+ all: Match any of these rules
26
+ any: Match all of these rules
27
+ promotions_description: Manage offers and coupons with promotions
28
+ promotion_rule_types:
29
+ user:
30
+ name: User
31
+ description: Available only to the specified users
32
+ product:
33
+ name: Product(s)
34
+ description: Order includes specified product(s)
35
+ item_total:
36
+ name: Item total
37
+ description: Order total meets these criteria
38
+ first_order:
39
+ name: First order
40
+ description: Must be the customer's first order
41
+ product_rule:
42
+ choose_products: Choose products
43
+ label: "Order must contain %{select} of these products"
44
+ match_any: at least one
45
+ match_all: all
46
+ product_source:
47
+ group: From product group
48
+ manual: Manually choose
49
+ rules: Rules
50
+ user_rule:
51
+ choose_users: Choose users
52
+ item_total_rule:
53
+ operators:
54
+ gt: greater than
55
+ 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,132 @@
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'", :dependent => :destroy
26
+
27
+ attr_accessible :coupon_code
28
+ attr_accessor :coupon_code
29
+ before_save :process_coupon_code, :if => "@coupon_code.present?"
30
+
31
+ def finalized?
32
+ self.class.finalized_states.include?(state)
33
+ end
34
+
35
+ def self.finalized_states
36
+ ["complete", "awaiting_return", "returned"]
37
+ end
38
+
39
+ def promotion_credit_exists?(credit)
40
+ promotion_credits.reload.detect { |c| c.source_id == credit.id }
41
+ end
42
+
43
+ def process_coupon_code
44
+ coupon = Promotion.find(:first, :conditions => ["UPPER(code) = ?", @coupon_code.upcase])
45
+ if coupon
46
+ coupon.create_discount(self)
47
+ end
48
+ end
49
+
50
+ def products
51
+ line_items.map {|li| li.variant.product}
52
+ end
53
+
54
+ def update_totals(force_adjustment_recalculation=false)
55
+ self.payment_total = payments.completed.map(&:amount).sum
56
+ self.item_total = line_items.map(&:amount).sum
57
+
58
+ process_automatic_promotions unless finalized?
59
+
60
+ if force_adjustment_recalculation
61
+ applicable_adjustments, adjustments_to_destroy = adjustments.partition{|a| a.applicable?}
62
+ self.adjustments = applicable_adjustments
63
+ adjustments_to_destroy.each(&:destroy)
64
+ end
65
+
66
+ self.adjustment_total = self.adjustments.map(&:amount).sum
67
+ self.total = self.item_total + self.adjustment_total
68
+ end
69
+
70
+
71
+ def process_automatic_promotions
72
+ # recalculate amount
73
+ self.promotion_credits.each do |credit|
74
+ if credit.source.eligible?(self)
75
+ amount = credit.source.compute(self)
76
+ if credit.amount != amount
77
+ # avoid infinite callbacks
78
+ PromotionCredit.update_all("amount = #{amount}", { :id => credit.id })
79
+ end
80
+ else
81
+ credit.destroy
82
+ end
83
+ end
84
+
85
+ current_promotions = self.promotion_credits.map(&:source)
86
+ # return if current promotions can not be combined
87
+ return if current_promotions.any? { |promotion| !promotion.combine? }
88
+
89
+ new_promotions = eligible_automatic_promotions - current_promotions
90
+ new_promotions.each do |promotion|
91
+ next if current_promotions.present? && !promotion.combine?
92
+ amount = credit.source.compute(self)
93
+ if amount > 0
94
+ self.promotion_credits.create(
95
+ :source => promotion,
96
+ :amount => amount,
97
+ :label => promotion.name
98
+ )
99
+ end
100
+ end
101
+ end
102
+
103
+ def eligible_automatic_promotions
104
+ @eligible_automatic_coupons ||= Promotion.automatic.select{|c| c.eligible?(self)}
105
+ end
106
+ end
107
+
108
+ if File.basename( $0 ) != "rake"
109
+ # register promotion rules
110
+ [Promotion::Rules::ItemTotal, Promotion::Rules::Product, Promotion::Rules::User, Promotion::Rules::FirstOrder].each &:register
111
+
112
+ # register default promotion calculators
113
+ [
114
+ Calculator::FlatPercentItemTotal,
115
+ Calculator::FlatRate,
116
+ Calculator::FlexiRate,
117
+ Calculator::PerItem,
118
+ Calculator::FreeShipping
119
+ ].each{|c_model|
120
+ begin
121
+ Promotion.register_calculator(c_model) if c_model.table_exists?
122
+ rescue Exception => e
123
+ $stderr.puts "Error registering promotion calculator #{c_model}"
124
+ end
125
+ }
126
+ end
127
+ end
128
+
129
+ config.autoload_paths += %W(#{config.root}/lib)
130
+ config.to_prepare &method(:activate).to_proc
131
+ end
132
+ end
@@ -0,0 +1,11 @@
1
+ class SpreePromoHooks < 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
+ replace :coupon_code_field, 'checkout/coupon_code_field'
10
+
11
+ end
@@ -0,0 +1,26 @@
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
+ Spree::FileUtilz.mirror_files(source, destination)
15
+ end
16
+
17
+ desc "Copies all assets (NOTE: This will be obsolete with Rails 3.1)"
18
+ task :assets do
19
+ source = File.join(File.dirname(__FILE__), '..', '..', 'public')
20
+ destination = File.join(Rails.root, 'public')
21
+ puts "INFO: Mirroring assets from #{source} to #{destination}"
22
+ Spree::FileUtilz.mirror_files(source, destination)
23
+ end
24
+
25
+ end
26
+ 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,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: My-Commerce_promo
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Santa
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-03-23 00:00:00.000000000Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: My-Commerce_core
16
+ requirement: &82325120 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - =
20
+ - !ruby/object:Gem::Version
21
+ version: 1.0.0
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *82325120
25
+ description: Required dependancy for My-Commerce
26
+ email: santa.jyp@gmail.com
27
+ executables: []
28
+ extensions: []
29
+ extra_rdoc_files: []
30
+ files:
31
+ - app/models/promotion/rules/product.rb
32
+ - app/models/promotion/rules/item_total.rb
33
+ - app/models/promotion/rules/user.rb
34
+ - app/models/promotion/rules/first_order.rb
35
+ - app/models/promotion_rule.rb
36
+ - app/models/promotion.rb
37
+ - app/models/promotion_credit.rb
38
+ - app/models/calculator/free_shipping.rb
39
+ - app/controllers/admin/promotions_controller.rb
40
+ - app/controllers/admin/promotion_rules_controller.rb
41
+ - app/controllers/orders_controller_decorator.rb
42
+ - app/views/admin/promotions/index.html.erb
43
+ - app/views/admin/promotions/_promotion_rule.html.erb
44
+ - app/views/admin/promotions/rules/_first_order.html.erb
45
+ - app/views/admin/promotions/rules/_item_total.html.erb
46
+ - app/views/admin/promotions/rules/_user.html.erb
47
+ - app/views/admin/promotions/rules/_product.html.erb
48
+ - app/views/admin/promotions/_tab.html.erb
49
+ - app/views/admin/promotions/edit.html.erb
50
+ - app/views/admin/promotions/new.html.erb
51
+ - app/views/admin/promotions/_form.html.erb
52
+ - app/views/admin/promotion_rules/create.js.erb
53
+ - app/views/admin/promotion_rules/destroy.js.erb
54
+ - app/views/checkout/_coupon_code_field.html.erb
55
+ - app/views/products/_promotions.html.erb
56
+ - config/cucumber.yml
57
+ - config/routes.rb
58
+ - config/locales/en.yml
59
+ - lib/tasks/promotions.rake
60
+ - lib/tasks/install.rake
61
+ - lib/tasks/promotions_extension_tasks.rake
62
+ - lib/spree_promo.rb
63
+ - lib/spree_promo_hooks.rb
64
+ - db/migrate/20100501095202_create_promotion_rules_users.rb
65
+ - db/migrate/20100502163939_name_for_promotions.rb
66
+ - db/migrate/20100501084722_match_policy_for_promotions.rb
67
+ - db/migrate/20100923095305_update_calculable_type_for_promotions.rb
68
+ - db/migrate/20100426100726_create_promotion_rules.rb
69
+ - db/migrate/20100419190933_rename_coupons_to_promotions.rb
70
+ - db/migrate/20100419194457_fix_existing_coupon_credits.rb
71
+ - public/javascripts/admin/promotions.js
72
+ - public/stylesheets/admin/promotions.css
73
+ homepage: http://spreecommerce.com
74
+ licenses: []
75
+ post_install_message:
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ none: false
81
+ requirements:
82
+ - - ! '>='
83
+ - !ruby/object:Gem::Version
84
+ version: 1.8.7
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ none: false
87
+ requirements:
88
+ - - ! '>='
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ segments:
92
+ - 0
93
+ hash: -871181983
94
+ requirements:
95
+ - none
96
+ rubyforge_project: My-Commerce_promo
97
+ rubygems_version: 1.8.17
98
+ signing_key:
99
+ specification_version: 3
100
+ summary: Promotion functionality for use with My-Commerce.
101
+ test_files: []