solidus_volume_pricing 0.1.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 (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>