apispree_promo 0.0.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 (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.rb +73 -0
  6. data/app/models/promotion/rules/first_order.rb +7 -0
  7. data/app/models/promotion/rules/item_total.rb +12 -0
  8. data/app/models/promotion/rules/product.rb +40 -0
  9. data/app/models/promotion/rules/user.rb +17 -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/apispree_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 +98 -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,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,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,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 'apispree_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 :apispree_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['apispree_promo:install:migrations'].invoke
5
+ Rake::Task['apispree_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 :apispree 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 :apispree 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,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: apispree_promo
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Santosh Kumar Mohanty
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-03-26 00:00:00.000000000Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: apispree_core
16
+ requirement: &76370210 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - =
20
+ - !ruby/object:Gem::Version
21
+ version: 0.0.0
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *76370210
25
+ description: Required dependancy for API-Spree
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/apispree_promo.rb
60
+ - lib/tasks/promotions.rake
61
+ - lib/tasks/install.rake
62
+ - lib/tasks/promotions_extension_tasks.rake
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
+ requirements:
92
+ - none
93
+ rubyforge_project: apispree_promo
94
+ rubygems_version: 1.8.17
95
+ signing_key:
96
+ specification_version: 3
97
+ summary: Promotion functionality for use with API-Spree.
98
+ test_files: []