solidus_volume_pricing 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (67) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +13 -0
  3. data/.hound.yml +40 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +8 -0
  6. data/.travis.yml +14 -0
  7. data/CONTRIBUTING.md +81 -0
  8. data/Gemfile +13 -0
  9. data/Guardfile +9 -0
  10. data/LICENSE.md +26 -0
  11. data/README.md +112 -0
  12. data/Rakefile +21 -0
  13. data/app/assets/javascripts/spree/backend/solidus_volume_pricing.js +16 -0
  14. data/app/controllers/spree/admin/variants_controller_decorator.rb +32 -0
  15. data/app/controllers/spree/admin/volume_price_models_controller.rb +27 -0
  16. data/app/controllers/spree/admin/volume_prices_controller.rb +11 -0
  17. data/app/helpers/spree/base_helper_decorator.rb +19 -0
  18. data/app/models/spree/line_item_decorator.rb +27 -0
  19. data/app/models/spree/user_decorator.rb +10 -0
  20. data/app/models/spree/variant_decorator.rb +104 -0
  21. data/app/models/spree/volume_price.rb +35 -0
  22. data/app/models/spree/volume_price_model.rb +8 -0
  23. data/app/overrides/spree/admin/shared/sub_menu/_configuration/add_volume_price_model_admin_menu_links.html.erb.deface +3 -0
  24. data/app/overrides/views_decorator.rb +13 -0
  25. data/app/views/spree/admin/shared/_vp_product_tab.html.erb +3 -0
  26. data/app/views/spree/admin/variants/_edit_fields.html.erb +33 -0
  27. data/app/views/spree/admin/variants/volume_prices.html.erb +39 -0
  28. data/app/views/spree/admin/volume_price_models/_form.html.erb +9 -0
  29. data/app/views/spree/admin/volume_price_models/_list.html.erb +23 -0
  30. data/app/views/spree/admin/volume_price_models/edit.html.erb +12 -0
  31. data/app/views/spree/admin/volume_price_models/index.html.erb +18 -0
  32. data/app/views/spree/admin/volume_price_models/new.html.erb +12 -0
  33. data/app/views/spree/admin/volume_prices/_edit_fields.html.erb +31 -0
  34. data/app/views/spree/admin/volume_prices/_volume_price_fields.html.erb +33 -0
  35. data/app/views/spree/products/_volume_pricing.html.erb +15 -0
  36. data/bin/rails +7 -0
  37. data/config/locales/de.yml +18 -0
  38. data/config/locales/en.yml +18 -0
  39. data/config/locales/pt.yml +18 -0
  40. data/config/locales/ru.yml +18 -0
  41. data/config/locales/sv.yml +18 -0
  42. data/config/locales/tr.yml +18 -0
  43. data/config/routes.rb +12 -0
  44. data/db/migrate/20081119145604_create_volume_prices.rb +16 -0
  45. data/db/migrate/20110203174010_change_display_name_for_volume_prices.rb +9 -0
  46. data/db/migrate/20111206173307_prefix_volume_pricing_table_names.rb +5 -0
  47. data/db/migrate/20121115043422_add_discount_type_column.rb +5 -0
  48. data/db/migrate/20150513200904_add_role_to_volume_price.rb +5 -0
  49. data/db/migrate/20150603143015_create_spree_volume_price_models.rb +18 -0
  50. data/lib/generators/solidus_volume_pricing/install/install_generator.rb +24 -0
  51. data/lib/solidus_volume_pricing.rb +6 -0
  52. data/lib/solidus_volume_pricing/engine.rb +42 -0
  53. data/lib/solidus_volume_pricing/version.rb +18 -0
  54. data/solidus_volume_pricing.gemspec +40 -0
  55. data/spec/controllers/spree/admin/variants_controller_spec.rb +27 -0
  56. data/spec/factories/volume_price_factory.rb +12 -0
  57. data/spec/helpers/base_helper_spec.rb +22 -0
  58. data/spec/models/spree/line_item_spec.rb +34 -0
  59. data/spec/models/spree/order_spec.rb +49 -0
  60. data/spec/models/spree/variant_spec.rb +306 -0
  61. data/spec/models/spree/volume_price_spec.rb +121 -0
  62. data/spec/spec_helper.rb +41 -0
  63. data/spec/support/capybara.rb +12 -0
  64. data/spec/support/database_cleaner.rb +24 -0
  65. data/spec/support/factory_girl.rb +7 -0
  66. data/spec/support/spree.rb +10 -0
  67. metadata +337 -0
@@ -0,0 +1,19 @@
1
+ Spree::BaseHelper.class_eval do
2
+ def display_volume_price(variant, quantity = 1, user = nil)
3
+ Spree::Money.new(
4
+ variant.volume_price(quantity, user),
5
+ currency: Spree::Config[:currency]
6
+ ).to_html
7
+ end
8
+
9
+ def display_volume_price_earning_percent(variant, quantity = 1, user = nil)
10
+ variant.volume_price_earning_percent(quantity, user).round.to_s
11
+ end
12
+
13
+ def display_volume_price_earning_amount(variant, quantity = 1, user = nil)
14
+ Spree::Money.new(
15
+ variant.volume_price_earning_amount(quantity, user),
16
+ currency: Spree::Config[:currency]
17
+ ).to_html
18
+ end
19
+ end
@@ -0,0 +1,27 @@
1
+ Spree::LineItem.class_eval do
2
+ # pattern grabbed from: http://stackoverflow.com/questions/4470108/
3
+
4
+ # the idea here is compatibility with spree_sale_products
5
+ # trying to create a 'calculation stack' wherein the best valid price is
6
+ # chosen for the product. This is mainly for compatibility with spree_sale_products
7
+ #
8
+ # Assumption here is that the volume price currency is the same as the product currency
9
+ old_copy_price = instance_method(:copy_price)
10
+ define_method(:copy_price) do
11
+ old_copy_price.bind(self).call
12
+ return unless variant
13
+
14
+ if variant
15
+ if changed? && changes.keys.include?('quantity')
16
+ vprice = variant.volume_price(quantity, order.user)
17
+ if price.present? && vprice <= variant.price
18
+ self.price = vprice and return
19
+ end
20
+ end
21
+
22
+ self.price = variant.price if price.nil?
23
+ end
24
+
25
+ self.price = variant.price if price.nil?
26
+ end
27
+ end
@@ -0,0 +1,10 @@
1
+ Spree.user_class.class_eval do
2
+
3
+ def resolve_role
4
+ if self.has_spree_role? Spree::Config.volume_pricing_role.to_sym
5
+ return Spree::Role.find_by name: Spree::Config.volume_pricing_role
6
+ else
7
+ return Spree::Role.find_by name: 'user'
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,104 @@
1
+ Spree::Variant.class_eval do
2
+ has_and_belongs_to_many :volume_price_models
3
+ has_many :volume_prices, -> { order(position: :asc) }, dependent: :destroy
4
+ has_many :model_volume_prices, -> { order(position: :asc) }, class_name: 'Spree::VolumePrice', through: :volume_price_models, source: :volume_prices
5
+ accepts_nested_attributes_for :volume_prices, allow_destroy: true,
6
+ reject_if: proc { |volume_price|
7
+ volume_price[:amount].blank? && volume_price[:range].blank?
8
+ }
9
+
10
+ def join_volume_prices(user = nil)
11
+ table = Spree::VolumePrice.arel_table
12
+
13
+ if user
14
+ Spree::VolumePrice.where(
15
+ (table[:variant_id].eq(id)
16
+ .or(table[:volume_price_model_id].in(volume_price_models.ids)))
17
+ .and(table[:role_id].eq(user.resolve_role))
18
+ )
19
+ .order(position: :asc)
20
+ else
21
+ Spree::VolumePrice.where(
22
+ (table[:variant_id]
23
+ .eq(id)
24
+ .or(table[:volume_price_model_id].in(volume_price_models.ids)))
25
+ .and(table[:role_id].eq(nil))
26
+ ).order(position: :asc)
27
+ end
28
+ end
29
+
30
+ # calculates the price based on quantity
31
+ def volume_price(quantity, user = nil)
32
+ compute_volume_price_quantities :volume_price, price, quantity, user
33
+ end
34
+
35
+ # return percent of earning
36
+ def volume_price_earning_percent(quantity, user = nil)
37
+ compute_volume_price_quantities :volume_price_earning_percent, 0, quantity, user
38
+ end
39
+
40
+ # return amount of earning
41
+ def volume_price_earning_amount(quantity, user = nil)
42
+ compute_volume_price_quantities :volume_price_earning_amount, 0, quantity, user
43
+ end
44
+
45
+ protected
46
+
47
+ def use_master_variant_volume_pricing?
48
+ Spree::Config.use_master_variant_volume_pricing && !(product.master.join_volume_prices.count == 0)
49
+ end
50
+
51
+ def compute_volume_price_quantities(type, default_price, quantity, user)
52
+ volume_prices = join_volume_prices user
53
+ if volume_prices.count == 0
54
+ if use_master_variant_volume_pricing?
55
+ product.master.send(type, quantity, user)
56
+ else
57
+ return default_price
58
+ end
59
+ else
60
+ volume_prices.each do |volume_price|
61
+ if volume_price.include?(quantity)
62
+ return send "compute_#{type}".to_sym, volume_price
63
+ end
64
+ end
65
+
66
+ # No price ranges matched.
67
+ default_price
68
+ end
69
+ end
70
+
71
+ def compute_volume_price(volume_price)
72
+ case volume_price.discount_type
73
+ when 'price'
74
+ return volume_price.amount
75
+ when 'dollar'
76
+ return price - volume_price.amount
77
+ when 'percent'
78
+ return price * (1 - volume_price.amount)
79
+ end
80
+ end
81
+
82
+ def compute_volume_price_earning_percent(volume_price)
83
+ case volume_price.discount_type
84
+ when 'price'
85
+ diff = price - volume_price.amount
86
+ return (diff * 100 / price).round
87
+ when 'dollar'
88
+ return (volume_price.amount * 100 / price).round
89
+ when 'percent'
90
+ return (volume_price.amount * 100).round
91
+ end
92
+ end
93
+
94
+ def compute_volume_price_earning_amount(volume_price)
95
+ case volume_price.discount_type
96
+ when 'price'
97
+ return price - volume_price.amount
98
+ when 'dollar'
99
+ return volume_price.amount
100
+ when 'percent'
101
+ return price - (price * (1 - volume_price.amount))
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,35 @@
1
+ class Spree::VolumePrice < ActiveRecord::Base
2
+ belongs_to :variant, touch: true
3
+ belongs_to :volume_price_model, touch: true
4
+ belongs_to :spree_role, class_name: 'Spree::Role', foreign_key: 'role_id'
5
+ acts_as_list scope: [:variant_id, :volume_price_model_id]
6
+
7
+ validates :amount, presence: true
8
+ validates :discount_type,
9
+ presence: true,
10
+ inclusion: {
11
+ in: %w(price dollar percent),
12
+ message: I18n.t(:'activerecord.errors.messages.is_not_a_valid_volume_price_type', value: self)
13
+ }
14
+ validates :range,
15
+ format: {
16
+ with: /\(?[0-9]+(?:\.{2,3}[0-9]+|\+\)?)/,
17
+ message: I18n.t(:'activerecord.errors.messages.must_be_in_format')
18
+ }
19
+
20
+ OPEN_ENDED = /\(?[0-9]+\+\)?/
21
+
22
+ def include?(quantity)
23
+ if open_ended?
24
+ bound = /\d+/.match(range)[0].to_i
25
+ return quantity >= bound
26
+ else
27
+ range.to_range === quantity
28
+ end
29
+ end
30
+
31
+ # indicates whether or not the range is a true Ruby range or an open ended range with no upper bound
32
+ def open_ended?
33
+ OPEN_ENDED =~ range
34
+ end
35
+ end
@@ -0,0 +1,8 @@
1
+ class Spree::VolumePriceModel < ActiveRecord::Base
2
+ has_many :variants
3
+ has_many :volume_prices, -> { order(position: :asc) }, dependent: :destroy
4
+ accepts_nested_attributes_for :volume_prices, allow_destroy: true,
5
+ reject_if: proc { |volume_price|
6
+ volume_price[:amount].blank? && volume_price[:range].blank?
7
+ }
8
+ end
@@ -0,0 +1,3 @@
1
+ <!-- insert_bottom "[data-hook='admin_configurations_sidebar_menu'], #admin_configurations_sidebar_menu[data-hook]" -->
2
+
3
+ <%= configurations_sidebar_menu_item Spree.t('volume_price_models'), admin_volume_price_models_path if can? :admin, Spree::VolumePriceModel %>
@@ -0,0 +1,13 @@
1
+ Deface::Override.new(
2
+ virtual_path: 'spree/admin/shared/_product_tabs',
3
+ name: 'add_volume_pricing_admin_tab',
4
+ insert_bottom: '[data-hook="admin_product_tabs"]',
5
+ partial: 'spree/admin/shared/vp_product_tab'
6
+ )
7
+
8
+ Deface::Override.new(
9
+ virtual_path: 'spree/admin/variants/edit',
10
+ name: 'add_volume_pricing_field_to_variant',
11
+ insert_after: '[data-hook="admin_variant_edit_form"]',
12
+ partial: 'spree/admin/variants/edit_fields'
13
+ )
@@ -0,0 +1,3 @@
1
+ <%= content_tag :li, class: ('active' if current == Spree.t(:volume_pricing)) do %>
2
+ <%= link_to_with_icon 'tasks', Spree.t(:volume_pricing), volume_prices_admin_product_variant_url(@product, @product.master) %>
3
+ <% end %>
@@ -0,0 +1,33 @@
1
+ <% content_for :page_actions do %>
2
+ <span id="new_add_volume_price" data-hook>
3
+ <%= button_link_to Spree.t(:add_volume_price), 'javascript:;', {
4
+ icon: 'add', :'data-target' => 'tbody#volume_prices', class: 'btn-success spree_add_fields', id: 'add_volume_price' } %>
5
+ </span>
6
+ <% end %>
7
+
8
+ <h3><%= Spree.t(:volume_prices) %></h3>
9
+ <table class="table">
10
+ <thead>
11
+ <tr>
12
+ <th><%= Spree.t(:name) %></th>
13
+ <th><%= Spree.t(:discount_type) %></th>
14
+ <th><%= Spree.t(:range) %></th>
15
+ <th><%= Spree.t(:amount) %></th>
16
+ <th><%= Spree.t(:position) %></th>
17
+ <th><%= Spree.t(:role) %></th>
18
+ <th><%= Spree.t(:action) %></th>
19
+ </tr>
20
+ </thead>
21
+ <tbody id="volume_prices">
22
+ <%= f.fields_for :volume_prices do |vp_form| %>
23
+ <%= render partial: 'spree/admin/volume_prices/volume_price_fields', locals: { f: vp_form } %>
24
+ <% end %>
25
+ </tbody>
26
+ <tbody id="volume_price_models">
27
+ <%= f.field_container :volume_price_model, class: ['form-group'] do %>
28
+ <%= f.label :volume_price_model_id, Spree.t(:volume_price_model) %>
29
+ <%= f.collection_select(:volume_price_model_ids, Spree::VolumePriceModel.all, :id, :name, { include_blank: Spree.t('match_choices.none') }, { class: 'select2', disabled: (cannot? :edit, Spree::VolumePriceModel) }) %>
30
+ <%= f.error_message_on :volume_price_model %>
31
+ <% end %>
32
+ </tbody>
33
+ </table>
@@ -0,0 +1,39 @@
1
+ <%= render 'spree/admin/shared/product_tabs', current: Spree.t(:volume_pricing) %>
2
+ <%= render 'spree/shared/error_messages', target: @variant %>
3
+
4
+ <% content_for :page_actions do %>
5
+ <span id="new_add_volume_price" data-hook>
6
+ <%= button_link_to Spree.t(:add_volume_price), 'javascript:;', {
7
+ icon: 'add', :'data-target' => 'tbody#volume_prices', class: 'btn-success spree_add_fields', id: 'add_volume_price'} %>
8
+ </span>
9
+ <% end %>
10
+
11
+ <%= form_for(@variant, url: admin_product_variant_path(@product, @variant), html: { method: :put }) do |f| %>
12
+ <h3><%= Spree.t(:volume_prices) %></h3>
13
+ <table class="table">
14
+ <thead>
15
+ <tr>
16
+ <th><%= Spree.t(:name) %></th>
17
+ <th><%= Spree.t(:discount_type) %></th>
18
+ <th><%= Spree.t(:range) %></th>
19
+ <th><%= Spree.t(:amount) %></th>
20
+ <th><%= Spree.t(:position) %></th>
21
+ <th><%= Spree.t(:role) %></th>
22
+ <th class="actions"></th>
23
+ </tr>
24
+ </thead>
25
+ <tbody id="volume_prices">
26
+ <%= f.fields_for :volume_prices do |vp_form| %>
27
+ <%= render partial: 'spree/admin/volume_prices/volume_price_fields', locals: { f: vp_form } %>
28
+ <% end %>
29
+ </tbody>
30
+ <tbody id="volume_price_models">
31
+ <%= f.field_container :volume_price_model, class: ['form-group'] do %>
32
+ <%= f.label :volume_price_model_id, Spree.t(:volume_price_model) %>
33
+ <%= f.collection_select(:volume_price_model_ids, Spree::VolumePriceModel.all, :id, :name, { include_blank: Spree.t('match_choices.none') }, { class: 'select2', disabled: (cannot? :edit, Spree::VolumePriceModel) }) %>
34
+ <%= f.error_message_on :volume_price_model %>
35
+ <% end %>
36
+ </tbody>
37
+ </table>
38
+ <%= render 'spree/admin/shared/edit_resource_links' %>
39
+ <% end %>
@@ -0,0 +1,9 @@
1
+ <div data-hook="admin_inside_volume_price_model_form" class="form-group">
2
+ <%= f.field_container :name, class: ['form-group'] do %>
3
+ <%= f.label :name, Spree.t(:name) %> <span class="required">*</span>
4
+ <%= error_message_on :volume_price_model, :name, :class => 'error-message' %>
5
+ <%= text_field :volume_price_model, :name, :class => 'form-control' %>
6
+ <% end %>
7
+
8
+ <%= render 'spree/admin/volume_prices/edit_fields', f: f %>
9
+ </div>
@@ -0,0 +1,23 @@
1
+ <table class="table sortable" id='listing_volume_price_models' data-hook data-sortable-link="<%#= update_positions_admin_volume_price_models_url %>">
2
+ <thead>
3
+ <tr data-hook="volume_price_models_header">
4
+ <th class="no-border"></th>
5
+ <th><%= Spree.t(:name) %></th>
6
+ <th class="actions"></th>
7
+ </tr>
8
+ </thead>
9
+ <tbody>
10
+ <% @volume_price_models.each do |price_model| %>
11
+ <tr id="<%= spree_dom_id price_model %>" data-hook="volume_price_models_row">
12
+ <td class="move-handle">
13
+ <span class="icon icon-sort handle"></span>
14
+ </td>
15
+ <td><%= price_model.name %></td>
16
+ <td class="actions actions-2 text-right">
17
+ <%= link_to_edit price_model.id, no_text: true %>
18
+ <%= link_to_delete price_model, no_text: true %>
19
+ </td>
20
+ </tr>
21
+ <% end %>
22
+ </tbody>
23
+ </table>
@@ -0,0 +1,12 @@
1
+ <% content_for :page_title do %>
2
+ <%= Spree.t(:volume_price_model_edit) %>
3
+ <% end %>
4
+
5
+ <%= form_for [:admin, @volume_price_model] do |f| %>
6
+ <fieldset>
7
+ <%= render partial: 'form', locals: { f: f } %>
8
+ </fieldset>
9
+
10
+ <%= render 'spree/admin/shared/edit_resource_links' %>
11
+
12
+ <% end %>
@@ -0,0 +1,18 @@
1
+ <% content_for :page_title do %>
2
+ <%= "Volume Price Models" %>
3
+ <% end %>
4
+
5
+ <% content_for :page_actions do %>
6
+ <%= button_link_to Spree.t(:new_volume_price_model), new_object_url, class: "btn-success", icon: 'add', id: 'admin_new_volume_price_model_link' %>
7
+ <% end %>
8
+
9
+ <% if @volume_price_models.any? %>
10
+ <div id="list-volume-price-models" data-hook>
11
+ <%= render 'list' %>
12
+ </div>
13
+ <% else %>
14
+ <div class="alert alert-info no-objects-found">
15
+ <%= Spree.t(:no_resource_found, resource: "Volume Price Models") %>,
16
+ <%= link_to Spree.t(:add_one), new_object_url %>!
17
+ </div>
18
+ <% end %>
@@ -0,0 +1,12 @@
1
+ <% content_for :page_title do %>
2
+ <%= Spree.t(:new_volume_price_model) %>
3
+ <% end %>
4
+
5
+ <%= form_for [:admin, @volume_price_model] do |f| %>
6
+ <fieldset>
7
+ <%= render partial: 'form', locals: { f: f } %>
8
+ <div class="form-actions" data-hook="buttons">
9
+ <%= button Spree.t(:create), 'save' %>
10
+ </div>
11
+ </fieldset>
12
+ <% end %>
@@ -0,0 +1,31 @@
1
+ <% content_for :page_actions do %>
2
+ <span id="new_add_volume_price" data-hook>
3
+ <%= button_link_to Spree.t(:add_volume_price), 'javascript:;', {
4
+ icon: 'add',
5
+ :'data-target' => 'tbody#volume_prices',
6
+ class: 'btn-success spree_add_fields',
7
+ id: 'add_volume_price'
8
+ } %>
9
+ </span>
10
+ <% end %>
11
+
12
+ <h3><%= Spree.t(:volume_prices) %></h3>
13
+ <table class="table">
14
+ <thead>
15
+ <tr>
16
+ <th><%= Spree.t(:name) %></th>
17
+ <th><%= Spree.t(:discount_type) %></th>
18
+ <th><%= Spree.t(:range) %></th>
19
+ <th><%= Spree.t(:amount) %></th>
20
+ <th><%= Spree.t(:position) %></th>
21
+ <th><%= Spree.t(:role) %></th>
22
+ <th><%= Spree.t(:action) %></th>
23
+ </tr>
24
+ </thead>
25
+ <tbody id="volume_prices">
26
+ <%= f.fields_for :volume_prices do |vp_form| %>
27
+ <%= render partial: 'spree/admin/volume_prices/volume_price_fields', locals: { f: vp_form } %>
28
+ <% end %>
29
+ </tbody>
30
+ </table>
31
+ <br/><br/>
@@ -0,0 +1,33 @@
1
+ <tr class="volume_price fields">
2
+ <td>
3
+ <%= error_message_on(f.object, :name) %>
4
+ <%= f.text_field :name, size: 10, class: 'form-control' %>
5
+ </td>
6
+ <td>
7
+ <%= error_message_on(f.object, :discount_type) %>
8
+ <%= f.select :discount_type, [
9
+ ["#{Spree.t(:total_price)}", 'price'],
10
+ ["#{Spree.t(:percent_discount)}", 'percent'],
11
+ ["#{Spree.t(:price_discount)}", 'dollar']
12
+ ], { inlcude_blank: true }, class: 'select2' %>
13
+ </td>
14
+ <td>
15
+ <%= error_message_on(f.object, :range) %>
16
+ <%= f.text_field :range, size: 10, class: 'form-control' %>
17
+ </td>
18
+ <td>
19
+ <%= error_message_on(f.object, :amount) %>
20
+ <%= f.text_field :amount, size: 10, class: 'form-control' %>
21
+ </td>
22
+ <td>
23
+ <%= error_message_on(f.object, :position) %>
24
+ <%= f.text_field :position, size: 3, class: 'form-control' %>
25
+ </td>
26
+ <td>
27
+ <%= error_message_on(f.object, :role_id) %>
28
+ <%= f.collection_select(:role_id, Spree::Role.all, :id, :name, { include_blank: Spree.t('match_choices.none') }, { class: 'select2' }) %>
29
+ </td>
30
+ <td class="actions">
31
+ <%= link_to_icon_remove_fields f %>
32
+ </td>
33
+ </tr>