solidus_legacy_promotions 4.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (179) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +345 -0
  3. data/Rakefile +43 -0
  4. data/app/assets/config/solidus_legacy_promotions/manifest.js +2 -0
  5. data/app/assets/javascripts/spree/backend/edit_promotion.js +7 -0
  6. data/app/assets/javascripts/spree/backend/promotions/activation.js +26 -0
  7. data/app/assets/javascripts/spree/backend/promotions.js +35 -0
  8. data/app/assets/javascripts/spree/backend/templates/promotions/calculators/fields/tiered_flat_rate.hbs +23 -0
  9. data/app/assets/javascripts/spree/backend/templates/promotions/calculators/fields/tiered_percent.hbs +23 -0
  10. data/app/assets/javascripts/spree/backend/templates/promotions/rules/option_values.hbs +12 -0
  11. data/app/assets/javascripts/spree/backend/views/calculators/tiered.js +54 -0
  12. data/app/assets/javascripts/spree/backend/views/order/details_adjustments.js +43 -0
  13. data/app/assets/javascripts/spree/backend/views/promotions/option_values_rule.js +79 -0
  14. data/app/assets/javascripts/spree/backend/views/promotions.js +3 -0
  15. data/app/assets/stylesheets/solidus_legacy_promotions/promotions/_edit.scss +138 -0
  16. data/app/assets/stylesheets/solidus_legacy_promotions/promotions.scss +3 -0
  17. data/app/assets/stylesheets/spree/backend/sections/_adjustments.scss +3 -0
  18. data/app/decorators/solidus_legacy_promotions/controllers/solidus_admin/solidus_admin_adjustments_controller_decorator.rb +20 -0
  19. data/app/decorators/solidus_legacy_promotions/lib/spree_order_state_machine_decorator.rb +18 -0
  20. data/app/decorators/solidus_legacy_promotions/models/spree_adjustment_decorator.rb +76 -0
  21. data/app/decorators/solidus_legacy_promotions/models/spree_calculator_returns_default_refund_amount_decorator.rb +13 -0
  22. data/app/decorators/solidus_legacy_promotions/models/spree_line_item_decorator.rb +11 -0
  23. data/app/decorators/solidus_legacy_promotions/models/spree_order_decorator.rb +28 -0
  24. data/app/decorators/solidus_legacy_promotions/models/spree_order_updater_decorator.rb +40 -0
  25. data/app/decorators/solidus_legacy_promotions/models/spree_product_decorator.rb +16 -0
  26. data/app/decorators/solidus_legacy_promotions/models/spree_promotion_code_batch_decorator.rb +16 -0
  27. data/app/decorators/solidus_legacy_promotions/models/spree_shipment_decorator.rb +13 -0
  28. data/app/helpers/spree/admin/promotions_helper.rb +15 -0
  29. data/app/helpers/spree/promotion_rules_helper.rb +12 -0
  30. data/app/jobs/spree/promotion_code_batch_job.rb +26 -0
  31. data/app/mailers/spree/promotion_code_batch_mailer.rb +15 -0
  32. data/app/models/spree/calculator/distributed_amount.rb +33 -0
  33. data/app/models/spree/calculator/flat_percent_item_total.rb +23 -0
  34. data/app/models/spree/calculator/flexi_rate.rb +22 -0
  35. data/app/models/spree/calculator/percent_on_line_item.rb +13 -0
  36. data/app/models/spree/calculator/tiered_flat_rate.rb +52 -0
  37. data/app/models/spree/calculator/tiered_percent.rb +62 -0
  38. data/app/models/spree/order_contents.rb +36 -0
  39. data/app/models/spree/order_promotion.rb +27 -0
  40. data/app/models/spree/permission_sets/promotion_display.rb +25 -0
  41. data/app/models/spree/permission_sets/promotion_management.rb +25 -0
  42. data/app/models/spree/promotion/actions/create_adjustment.rb +81 -0
  43. data/app/models/spree/promotion/actions/create_item_adjustments.rb +98 -0
  44. data/app/models/spree/promotion/actions/create_quantity_adjustments.rb +139 -0
  45. data/app/models/spree/promotion/actions/free_shipping.rb +59 -0
  46. data/app/models/spree/promotion/order_adjustments_recalculator.rb +92 -0
  47. data/app/models/spree/promotion/rules/first_order.rb +36 -0
  48. data/app/models/spree/promotion/rules/first_repeat_purchase_since.rb +36 -0
  49. data/app/models/spree/promotion/rules/item_total.rb +86 -0
  50. data/app/models/spree/promotion/rules/minimum_quantity.rb +59 -0
  51. data/app/models/spree/promotion/rules/nth_order.rb +45 -0
  52. data/app/models/spree/promotion/rules/one_use_per_user.rb +25 -0
  53. data/app/models/spree/promotion/rules/option_value.rb +50 -0
  54. data/app/models/spree/promotion/rules/product.rb +86 -0
  55. data/app/models/spree/promotion/rules/store.rb +26 -0
  56. data/app/models/spree/promotion/rules/taxon.rb +91 -0
  57. data/app/models/spree/promotion/rules/user.rb +34 -0
  58. data/app/models/spree/promotion/rules/user_logged_in.rb +20 -0
  59. data/app/models/spree/promotion/rules/user_role.rb +45 -0
  60. data/app/models/spree/promotion.rb +271 -0
  61. data/app/models/spree/promotion_action.rb +51 -0
  62. data/app/models/spree/promotion_advertiser.rb +10 -0
  63. data/app/models/spree/promotion_category.rb +8 -0
  64. data/app/models/spree/promotion_chooser.rb +34 -0
  65. data/app/models/spree/promotion_code/batch_builder.rb +68 -0
  66. data/app/models/spree/promotion_code.rb +54 -0
  67. data/app/models/spree/promotion_code_batch.rb +18 -0
  68. data/app/models/spree/promotion_finder.rb +9 -0
  69. data/app/models/spree/promotion_handler/cart.rb +75 -0
  70. data/app/models/spree/promotion_handler/coupon.rb +125 -0
  71. data/app/models/spree/promotion_handler/page.rb +26 -0
  72. data/app/models/spree/promotion_handler/shipping.rb +61 -0
  73. data/app/models/spree/promotion_rule.rb +55 -0
  74. data/app/models/spree/promotion_rule_store.rb +10 -0
  75. data/app/models/spree/promotion_rule_taxon.rb +8 -0
  76. data/app/models/spree/promotion_rule_user.rb +10 -0
  77. data/app/subscribers/spree/order_promotion_subscriber.rb +20 -0
  78. data/app/views/spree/order_mailer/cancel_email.html.erb +45 -0
  79. data/app/views/spree/order_mailer/cancel_email.text.erb +16 -0
  80. data/app/views/spree/order_mailer/confirm_email.html.erb +84 -0
  81. data/app/views/spree/order_mailer/confirm_email.text.erb +38 -0
  82. data/app/views/spree/promotion_code_batch_mailer/promotion_code_batch_errored.text.erb +2 -0
  83. data/app/views/spree/promotion_code_batch_mailer/promotion_code_batch_finished.text.erb +2 -0
  84. data/bin/rails +13 -0
  85. data/config/locales/en.yml +101 -0
  86. data/config/locales/promotion_categories.en.yml +6 -0
  87. data/config/locales/promotions.en.yml +6 -0
  88. data/config/routes.rb +28 -0
  89. data/db/migrate/20160101010001_solidus_one_four_promotions.rb +126 -0
  90. data/db/migrate/20161017102621_create_spree_promotion_code_batch.rb +48 -0
  91. data/db/migrate/20180202190713_create_promotion_rule_stores.rb +14 -0
  92. data/db/migrate/20180328172631_add_join_characters_to_promotion_code_batch.rb +15 -0
  93. data/db/migrate/20190106184413_remove_code_from_spree_promotions.rb +46 -0
  94. data/db/migrate/20220317165036_set_promotions_with_any_policy_to_all_if_possible.rb +20 -0
  95. data/db/migrate/20230322085416_remove_match_policy_from_spree_promotion.rb +7 -0
  96. data/db/migrate/20230325132905_remove_unused_columns_from_promotion_rules.rb +10 -0
  97. data/db/migrate/20231027084517_add_order_promotions_foreign_key.rb +14 -0
  98. data/db/migrate/20240621100123_add_promotion_code_id_to_spree_adjustments.rb +10 -0
  99. data/db/migrate/20240622113334_move_adjustment_eligible_to_legacy_promotions.rb +11 -0
  100. data/lib/components/admin/solidus_admin/orders/show/adjustments/index/source/spree_promotion_action/component.rb +17 -0
  101. data/lib/components/admin/solidus_admin/promotion_categories/index/component.rb +56 -0
  102. data/lib/components/admin/solidus_admin/promotions/index/component.rb +104 -0
  103. data/lib/components/admin/solidus_admin/promotions/index/component.yml +10 -0
  104. data/lib/components/admin/solidus_legacy_promotions/orders/index/component.rb +15 -0
  105. data/lib/components/admin/solidus_legacy_promotions/orders/index/component.yml +20 -0
  106. data/lib/controllers/admin/solidus_admin/promotion_categories_controller.rb +29 -0
  107. data/lib/controllers/admin/solidus_admin/promotions_controller.rb +46 -0
  108. data/lib/controllers/backend/spree/admin/promotion_actions_controller.rb +51 -0
  109. data/lib/controllers/backend/spree/admin/promotion_categories_controller.rb +8 -0
  110. data/lib/controllers/backend/spree/admin/promotion_code_batches_controller.rb +30 -0
  111. data/lib/controllers/backend/spree/admin/promotion_codes_controller.rb +48 -0
  112. data/lib/controllers/backend/spree/admin/promotion_rules_controller.rb +60 -0
  113. data/lib/controllers/backend/spree/admin/promotions_controller.rb +66 -0
  114. data/lib/solidus_legacy_promotions/configuration.rb +115 -0
  115. data/lib/solidus_legacy_promotions/engine.rb +97 -0
  116. data/lib/solidus_legacy_promotions/migrations/promotions_with_code_handlers.rb +68 -0
  117. data/lib/solidus_legacy_promotions/testing_support/factories/calculator_factory.rb +7 -0
  118. data/lib/solidus_legacy_promotions/testing_support/factories/order_factory.rb +22 -0
  119. data/lib/solidus_legacy_promotions/testing_support/factories/order_promotion_factory.rb +8 -0
  120. data/lib/solidus_legacy_promotions/testing_support/factories/promotion_category_factory.rb +7 -0
  121. data/lib/solidus_legacy_promotions/testing_support/factories/promotion_code_factory.rb +8 -0
  122. data/lib/solidus_legacy_promotions/testing_support/factories/promotion_factory.rb +98 -0
  123. data/lib/solidus_legacy_promotions/testing_support/factory_bot.rb +28 -0
  124. data/lib/solidus_legacy_promotions.rb +28 -0
  125. data/lib/tasks/solidus_legacy_promotions/delete_ineligible_adjustments.rake +8 -0
  126. data/lib/views/backend/spree/admin/adjustments/_adjustment.html.erb +24 -0
  127. data/lib/views/backend/spree/admin/orders/_adjustments.html.erb +19 -0
  128. data/lib/views/backend/spree/admin/orders/_order_details.html.erb +32 -0
  129. data/lib/views/backend/spree/admin/orders/confirm.html.erb +59 -0
  130. data/lib/views/backend/spree/admin/promotion_actions/create.js.erb +10 -0
  131. data/lib/views/backend/spree/admin/promotion_actions/destroy.js.erb +1 -0
  132. data/lib/views/backend/spree/admin/promotion_categories/_form.html.erb +14 -0
  133. data/lib/views/backend/spree/admin/promotion_categories/edit.html.erb +10 -0
  134. data/lib/views/backend/spree/admin/promotion_categories/index.html.erb +47 -0
  135. data/lib/views/backend/spree/admin/promotion_categories/new.html.erb +10 -0
  136. data/lib/views/backend/spree/admin/promotion_code_batches/_form_fields.html.erb +22 -0
  137. data/lib/views/backend/spree/admin/promotion_code_batches/download.csv.ruby +8 -0
  138. data/lib/views/backend/spree/admin/promotion_code_batches/index.html.erb +65 -0
  139. data/lib/views/backend/spree/admin/promotion_code_batches/new.html.erb +8 -0
  140. data/lib/views/backend/spree/admin/promotion_codes/index.csv.ruby +8 -0
  141. data/lib/views/backend/spree/admin/promotion_codes/index.html.erb +32 -0
  142. data/lib/views/backend/spree/admin/promotion_codes/new.html.erb +31 -0
  143. data/lib/views/backend/spree/admin/promotion_rules/create.js.erb +8 -0
  144. data/lib/views/backend/spree/admin/promotion_rules/destroy.js.erb +3 -0
  145. data/lib/views/backend/spree/admin/promotions/_actions.html.erb +37 -0
  146. data/lib/views/backend/spree/admin/promotions/_activations_edit.html.erb +22 -0
  147. data/lib/views/backend/spree/admin/promotions/_activations_new.html.erb +43 -0
  148. data/lib/views/backend/spree/admin/promotions/_form.html.erb +67 -0
  149. data/lib/views/backend/spree/admin/promotions/_promotion_action.html.erb +13 -0
  150. data/lib/views/backend/spree/admin/promotions/_promotion_rule.html.erb +12 -0
  151. data/lib/views/backend/spree/admin/promotions/_rules.html.erb +42 -0
  152. data/lib/views/backend/spree/admin/promotions/actions/_create_adjustment.html.erb +2 -0
  153. data/lib/views/backend/spree/admin/promotions/actions/_create_item_adjustments.html.erb +6 -0
  154. data/lib/views/backend/spree/admin/promotions/actions/_create_quantity_adjustments.html.erb +10 -0
  155. data/lib/views/backend/spree/admin/promotions/actions/_free_shipping.html.erb +0 -0
  156. data/lib/views/backend/spree/admin/promotions/actions/_promotion_calculators_with_custom_fields.html.erb +29 -0
  157. data/lib/views/backend/spree/admin/promotions/calculators/_default_fields.html.erb +6 -0
  158. data/lib/views/backend/spree/admin/promotions/calculators/distributed_amount/_fields.html.erb +56 -0
  159. data/lib/views/backend/spree/admin/promotions/calculators/flat_rate/_fields.html.erb +6 -0
  160. data/lib/views/backend/spree/admin/promotions/calculators/tiered_flat_rate/_fields.html.erb +30 -0
  161. data/lib/views/backend/spree/admin/promotions/calculators/tiered_percent/_fields.html.erb +30 -0
  162. data/lib/views/backend/spree/admin/promotions/edit.html.erb +40 -0
  163. data/lib/views/backend/spree/admin/promotions/index.html.erb +124 -0
  164. data/lib/views/backend/spree/admin/promotions/new.html.erb +14 -0
  165. data/lib/views/backend/spree/admin/promotions/rules/_first_order.html.erb +0 -0
  166. data/lib/views/backend/spree/admin/promotions/rules/_first_repeat_purchase_since.html.erb +13 -0
  167. data/lib/views/backend/spree/admin/promotions/rules/_item_total.html.erb +14 -0
  168. data/lib/views/backend/spree/admin/promotions/rules/_minimum_quantity.html.erb +5 -0
  169. data/lib/views/backend/spree/admin/promotions/rules/_nth_order.html.erb +12 -0
  170. data/lib/views/backend/spree/admin/promotions/rules/_one_use_per_user.html.erb +0 -0
  171. data/lib/views/backend/spree/admin/promotions/rules/_option_value.html.erb +13 -0
  172. data/lib/views/backend/spree/admin/promotions/rules/_product.html.erb +15 -0
  173. data/lib/views/backend/spree/admin/promotions/rules/_store.html.erb +6 -0
  174. data/lib/views/backend/spree/admin/promotions/rules/_taxon.html.erb +9 -0
  175. data/lib/views/backend/spree/admin/promotions/rules/_user.html.erb +4 -0
  176. data/lib/views/backend/spree/admin/promotions/rules/_user_logged_in.html.erb +0 -0
  177. data/lib/views/backend/spree/admin/promotions/rules/_user_role.html.erb +12 -0
  178. data/solidus_legacy_promotions.gemspec +29 -0
  179. metadata +262 -0
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_dependency 'spree/calculator'
4
+
5
+ module Spree
6
+ class Calculator::TieredFlatRate < Calculator
7
+ preference :base_amount, :decimal, default: 0
8
+ preference :tiers, :hash, default: {}
9
+ preference :currency, :string, default: -> { Spree::Config[:currency] }
10
+
11
+ before_validation do
12
+ # Convert tier values to decimals. Strings don't do us much good.
13
+ if preferred_tiers.is_a?(Hash)
14
+ self.preferred_tiers = preferred_tiers.map do |key, value|
15
+ [cast_to_d(key.to_s), cast_to_d(value.to_s)]
16
+ end.to_h
17
+ end
18
+ end
19
+
20
+ validate :preferred_tiers_content
21
+
22
+ def compute(object)
23
+ _base, amount = preferred_tiers.sort.reverse.detect do |value, _|
24
+ object.amount >= value
25
+ end
26
+
27
+ if preferred_currency.casecmp(object.currency).zero?
28
+ amount || preferred_base_amount
29
+ else
30
+ 0
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def cast_to_d(value)
37
+ value.to_s.to_d
38
+ rescue ArgumentError
39
+ BigDecimal(0)
40
+ end
41
+
42
+ def preferred_tiers_content
43
+ if preferred_tiers.is_a? Hash
44
+ unless preferred_tiers.keys.all?{ |key| key.is_a?(Numeric) && key > 0 }
45
+ errors.add(:base, :keys_should_be_positive_number)
46
+ end
47
+ else
48
+ errors.add(:preferred_tiers, :should_be_hash)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_dependency 'spree/calculator'
4
+
5
+ module Spree
6
+ class Calculator::TieredPercent < Calculator
7
+ preference :base_percent, :decimal, default: 0
8
+ preference :tiers, :hash, default: {}
9
+ preference :currency, :string, default: -> { Spree::Config[:currency] }
10
+
11
+ before_validation do
12
+ # Convert tier values to decimals. Strings don't do us much good.
13
+ if preferred_tiers.is_a?(Hash)
14
+ self.preferred_tiers = preferred_tiers.map do |key, value|
15
+ [cast_to_d(key.to_s), cast_to_d(value.to_s)]
16
+ end.to_h
17
+ end
18
+ end
19
+
20
+ validates :preferred_base_percent, numericality: {
21
+ greater_than_or_equal_to: 0,
22
+ less_than_or_equal_to: 100
23
+ }
24
+ validate :preferred_tiers_content
25
+
26
+ def compute(object)
27
+ order = object.is_a?(Order) ? object : object.order
28
+
29
+ _base, percent = preferred_tiers.sort.reverse.detect do |value, _|
30
+ order.item_total >= value
31
+ end
32
+
33
+ if preferred_currency.casecmp(order.currency).zero?
34
+ currency_exponent = ::Money::Currency.find(preferred_currency).exponent
35
+ (object.amount * (percent || preferred_base_percent) / 100).round(currency_exponent)
36
+ else
37
+ 0
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def cast_to_d(value)
44
+ value.to_s.to_d
45
+ rescue ArgumentError
46
+ BigDecimal(0)
47
+ end
48
+
49
+ def preferred_tiers_content
50
+ if preferred_tiers.is_a? Hash
51
+ unless preferred_tiers.keys.all?{ |key| key.is_a?(Numeric) && key > 0 }
52
+ errors.add(:base, :keys_should_be_positive_number)
53
+ end
54
+ unless preferred_tiers.values.all?{ |key| key.is_a?(Numeric) && key >= 0 && key <= 100 }
55
+ errors.add(:base, :values_should_be_percent)
56
+ end
57
+ else
58
+ errors.add(:preferred_tiers, :should_be_hash)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class OrderContents < Spree::SimpleOrderContents
5
+ # Updates the order's line items with the params passed in.
6
+ # Also runs the PromotionHandler::Cart.
7
+ def update_cart(params)
8
+ if order.update(params)
9
+ unless order.completed?
10
+ order.line_items = order.line_items.select { |li| li.quantity > 0 }
11
+ # Update totals, then check if the order is eligible for any cart promotions.
12
+ # If we do not update first, then the item total will be wrong and ItemTotal
13
+ # promotion rules would not be triggered.
14
+ reload_totals
15
+ order.check_shipments_and_restart_checkout
16
+ ::Spree::PromotionHandler::Cart.new(order).activate
17
+ end
18
+ reload_totals
19
+ true
20
+ else
21
+ false
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def after_add_or_remove(line_item, options = {})
28
+ reload_totals
29
+ shipment = options[:shipment]
30
+ shipment.present? ? shipment.update_amounts : order.check_shipments_and_restart_checkout
31
+ ::Spree::PromotionHandler::Cart.new(order, line_item).activate
32
+ reload_totals
33
+ line_item
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ # Spree::OrderPromotion represents the relationship between:
5
+ #
6
+ # 1. A promotion that a user attempted to apply to their order
7
+ # 2. The specific code that they used
8
+ class OrderPromotion < Spree::Base
9
+ self.table_name = 'spree_orders_promotions'
10
+
11
+ belongs_to :order, class_name: 'Spree::Order', optional: true
12
+ belongs_to :promotion, class_name: 'Spree::Promotion', optional: true
13
+ belongs_to :promotion_code, class_name: 'Spree::PromotionCode', optional: true
14
+
15
+ validates :order, presence: true
16
+ validates :promotion, presence: true
17
+ validates :promotion_code, presence: true, if: :require_promotion_code?
18
+
19
+ self.allowed_ransackable_associations = %w[promotion_code]
20
+
21
+ private
22
+
23
+ def require_promotion_code?
24
+ promotion && !promotion.apply_automatically && promotion.codes.any?
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module PermissionSets
5
+ # Read-only permissions for promotions.
6
+ #
7
+ # This permission set allows users to view all related information about
8
+ # promotions, also from the admin panel, including:
9
+ #
10
+ # - Promotions
11
+ # - Promotion rules
12
+ # - Promotion actions
13
+ # - Promotion categories
14
+ # - Promotion codes
15
+ class PromotionDisplay < PermissionSets::Base
16
+ def activate!
17
+ can [:read, :admin, :edit], Spree::Promotion
18
+ can [:read, :admin], Spree::PromotionRule
19
+ can [:read, :admin], Spree::PromotionAction
20
+ can [:read, :admin], Spree::PromotionCategory
21
+ can [:read, :admin], Spree::PromotionCode
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module PermissionSets
5
+ # Full permissions for promotion management.
6
+ #
7
+ # This permission set grants full control over all promotion and related resources,
8
+ # including:
9
+ #
10
+ # - Promotions
11
+ # - Promotion rules
12
+ # - Promotion actions
13
+ # - Promotion categories
14
+ # - Promotion codes
15
+ class PromotionManagement < PermissionSets::Base
16
+ def activate!
17
+ can :manage, Spree::Promotion
18
+ can :manage, Spree::PromotionRule
19
+ can :manage, Spree::PromotionAction
20
+ can :manage, Spree::PromotionCategory
21
+ can :manage, Spree::PromotionCode
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class Promotion < Spree::Base
5
+ module Actions
6
+ class CreateAdjustment < PromotionAction
7
+ include Spree::CalculatedAdjustments
8
+ include Spree::AdjustmentSource
9
+
10
+ has_many :adjustments, as: :source
11
+
12
+ delegate :eligible?, to: :promotion
13
+
14
+ before_validation :ensure_action_has_calculator
15
+ before_destroy :remove_adjustments_from_incomplete_orders
16
+ before_discard :remove_adjustments_from_incomplete_orders
17
+
18
+ def preload_relations
19
+ [:calculator]
20
+ end
21
+
22
+ # Creates the adjustment related to a promotion for the order passed
23
+ # through options hash
24
+ #
25
+ # Returns `true` if an adjustment is applied to an order,
26
+ # `false` if the promotion has already been applied.
27
+ def perform(options = {})
28
+ order = options[:order]
29
+ return if promotion_credit_exists?(order)
30
+
31
+ amount = compute_amount(order)
32
+ order.adjustments.create!(
33
+ amount:,
34
+ order:,
35
+ source: self,
36
+ promotion_code: options[:promotion_code],
37
+ label: I18n.t('spree.adjustment_labels.order', promotion: Spree::Promotion.model_name.human, promotion_name: promotion.name)
38
+ )
39
+ true
40
+ end
41
+
42
+ # Ensure a negative amount which does not exceed the sum of the order's
43
+ # item_total and ship_total
44
+ def compute_amount(calculable)
45
+ amount = calculator.compute(calculable)
46
+ amount ||= BigDecimal(0)
47
+ amount = amount.abs
48
+ [(calculable.item_total + calculable.ship_total), amount].min * -1
49
+ end
50
+
51
+ # Removes any adjustments generated by this action from the order.
52
+ # @param order [Spree::Order] the order to remove the action from.
53
+ # @return [void]
54
+ def remove_from(order)
55
+ order.adjustments.each do |adjustment|
56
+ if adjustment.source == self
57
+ order.adjustments.destroy(adjustment)
58
+ end
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ # Tells us if there if the specified promotion is already associated with the line item
65
+ # regardless of whether or not its currently eligible. Useful because generally
66
+ # you would only want a promotion action to apply to order no more than once.
67
+ #
68
+ # Receives an adjustment +source+ (here a PromotionAction object) and tells
69
+ # if the order has adjustments from that already
70
+ def promotion_credit_exists?(adjustable)
71
+ adjustments.where(adjustable_id: adjustable.id).exists?
72
+ end
73
+
74
+ def ensure_action_has_calculator
75
+ return if calculator
76
+ self.calculator = Calculator::FlatPercentItemTotal.new
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class Promotion < Spree::Base
5
+ module Actions
6
+ class CreateItemAdjustments < PromotionAction
7
+ include Spree::CalculatedAdjustments
8
+ include Spree::AdjustmentSource
9
+
10
+ has_many :adjustments, as: :source
11
+
12
+ delegate :eligible?, to: :promotion
13
+
14
+ before_validation :ensure_action_has_calculator
15
+ before_destroy :remove_adjustments_from_incomplete_orders
16
+ before_discard :remove_adjustments_from_incomplete_orders
17
+
18
+ def preload_relations
19
+ [:calculator]
20
+ end
21
+
22
+ def perform(payload = {})
23
+ order = payload[:order]
24
+ promotion = payload[:promotion]
25
+ promotion_code = payload[:promotion_code]
26
+
27
+ results = line_items_to_adjust(promotion, order).map do |line_item|
28
+ create_adjustment(line_item, order, promotion_code)
29
+ end
30
+
31
+ results.any?
32
+ end
33
+
34
+ # Ensure a negative amount which does not exceed the sum of the order's
35
+ # item_total and ship_total
36
+ def compute_amount(adjustable)
37
+ order = adjustable.is_a?(Order) ? adjustable : adjustable.order
38
+ return 0 unless promotion.line_item_actionable?(order, adjustable)
39
+ promotion_amount = calculator.compute(adjustable)
40
+ promotion_amount ||= BigDecimal(0)
41
+ promotion_amount = promotion_amount.abs
42
+ [adjustable.amount, promotion_amount].min * -1
43
+ end
44
+
45
+ # Removes any adjustments generated by this action from the order's
46
+ # line items.
47
+ # @param order [Spree::Order] the order to remove the action from.
48
+ # @return [void]
49
+ def remove_from(order)
50
+ order.line_items.each do |line_item|
51
+ line_item.adjustments.each do |adjustment|
52
+ if adjustment.source == self
53
+ line_item.adjustments.destroy(adjustment)
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def create_adjustment(adjustable, order, promotion_code)
62
+ amount = compute_amount(adjustable)
63
+ return if amount == 0
64
+ adjustable.adjustments.create!(
65
+ source: self,
66
+ amount:,
67
+ order:,
68
+ promotion_code:,
69
+ label: I18n.t('spree.adjustment_labels.line_item', promotion: Spree::Promotion.model_name.human, promotion_name: promotion.name)
70
+ )
71
+ true
72
+ end
73
+
74
+ # Tells us if there if the specified promotion is already associated with the line item
75
+ # regardless of whether or not its currently eligible. Useful because generally
76
+ # you would only want a promotion action to apply to line item no more than once.
77
+ #
78
+ # Receives an adjustment +source+ (here a PromotionAction object) and tells
79
+ # if the order has adjustments from that already
80
+ def promotion_credit_exists?(adjustable)
81
+ adjustments.where(adjustable_id: adjustable.id).exists?
82
+ end
83
+
84
+ def ensure_action_has_calculator
85
+ return if calculator
86
+ self.calculator = Calculator::PercentOnLineItem.new
87
+ end
88
+
89
+ def line_items_to_adjust(promotion, order)
90
+ order.line_items.select do |line_item|
91
+ line_item.adjustments.none? { |adjustment| adjustment.source == self } &&
92
+ promotion.line_item_actionable?(order, line_item)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class Promotion < Spree::Base
5
+ module Actions
6
+ class CreateQuantityAdjustments < CreateItemAdjustments
7
+ preference :group_size, :integer, default: 1
8
+
9
+ has_many :line_item_actions, foreign_key: :action_id, dependent: :destroy
10
+ has_many :line_items, through: :line_item_actions
11
+
12
+ ##
13
+ # Computes the amount for the adjustment based on the line item and any
14
+ # other applicable items in the order. The rules for this specific
15
+ # adjustment are as follows:
16
+ #
17
+ # = Setup
18
+ #
19
+ # We have a quantity group promotion on t-shirts. If a user orders 3
20
+ # t-shirts, they get $5 off of each. The shirts come in one size and three
21
+ # colours: red, blue, and white.
22
+ #
23
+ # == Scenario 1
24
+ #
25
+ # User has 2 red shirts, 1 white shirt, and 1 blue shirt in their
26
+ # order. We want to compute the adjustment amount for the white shirt.
27
+ #
28
+ # *Result:* -$5
29
+ #
30
+ # *Reasoning:* There are a total of 4 items that are eligible for the
31
+ # promotion. Since that is greater than 3, we can discount the items. The
32
+ # white shirt has a quantity of 1, therefore it will get discounted by
33
+ # +adjustment_amount * 1+ or $5.
34
+ #
35
+ # === Scenario 1-1
36
+ #
37
+ # What about the blue shirt? How much does it get discounted?
38
+ #
39
+ # *Result:* $0
40
+ #
41
+ # *Reasoning:* We have a total quantity of 4. However, we only apply the
42
+ # adjustment to groups of 3. Assuming the white and red shirts have already
43
+ # had their adjustment calculated, that means 3 units have been discounted.
44
+ # Leaving us with a lonely blue shirt that isn't part of a group of 3.
45
+ # Therefore, it does not receive the discount.
46
+ #
47
+ # == Scenario 2
48
+ #
49
+ # User has 4 red shirts in their order. What is the amount?
50
+ #
51
+ # *Result:* -$15
52
+ #
53
+ # *Reasoning:* The total quantity of eligible items is 4, so we the
54
+ # adjustment will be non-zero. However, we only apply it to groups of 3,
55
+ # therefore there is one extra item that is not eligible for the
56
+ # adjustment. +adjustment_amount * 3+ or $15.
57
+ #
58
+ def compute_amount(line_item)
59
+ adjustment_amount = calculator.compute(PartialLineItem.new(line_item))
60
+ adjustment_amount ||= BigDecimal(0)
61
+ adjustment_amount = adjustment_amount.abs
62
+
63
+ order = line_item.order
64
+ line_items = actionable_line_items(order)
65
+
66
+ actioned_line_items = order.line_item_adjustments.reload.
67
+ select { |adjustment| adjustment.source == self && adjustment.amount < 0 }.
68
+ map(&:adjustable)
69
+ other_line_items = actioned_line_items - [line_item]
70
+
71
+ applicable_quantity = total_applicable_quantity(line_items)
72
+ used_quantity = total_used_quantity(other_line_items)
73
+ usable_quantity = [
74
+ applicable_quantity - used_quantity,
75
+ line_item.quantity
76
+ ].min
77
+
78
+ persist_quantity(usable_quantity, line_item)
79
+
80
+ amount = adjustment_amount * usable_quantity
81
+ [line_item.amount, amount].min * -1
82
+ end
83
+
84
+ private
85
+
86
+ def actionable_line_items(order)
87
+ order.line_items.select do |item|
88
+ promotion.line_item_actionable? order, item
89
+ end
90
+ end
91
+
92
+ def total_applicable_quantity(line_items)
93
+ total_quantity = line_items.sum(&:quantity)
94
+ extra_quantity = total_quantity % preferred_group_size
95
+
96
+ total_quantity - extra_quantity
97
+ end
98
+
99
+ def total_used_quantity(line_items)
100
+ line_item_actions.where(
101
+ line_item_id: line_items.map(&:id)
102
+ ).sum(:quantity)
103
+ end
104
+
105
+ def persist_quantity(quantity, line_item)
106
+ line_item_action = line_item_actions.where(
107
+ line_item_id: line_item.id
108
+ ).first_or_initialize
109
+ line_item_action.quantity = quantity
110
+ line_item_action.save!
111
+ end
112
+
113
+ ##
114
+ # Used specifically for PercentOnLineItem calculator. That calculator uses
115
+ # `line_item.amount`, however we might not necessarily want to discount the
116
+ # entire amount. This class allows us to determine the discount per
117
+ # quantity and then calculate the adjustment amount the way we normally do
118
+ # for flat rate adjustments.
119
+ class PartialLineItem
120
+ def initialize(line_item)
121
+ @line_item = line_item
122
+ end
123
+
124
+ def amount
125
+ @line_item.price
126
+ end
127
+
128
+ def order
129
+ @line_item.order
130
+ end
131
+
132
+ def currency
133
+ @line_item.currency
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class Promotion < Spree::Base
5
+ module Actions
6
+ class FreeShipping < Spree::PromotionAction
7
+ def perform(payload = {})
8
+ order = payload[:order]
9
+ promotion_code = payload[:promotion_code]
10
+ return false unless promotion.eligible? order
11
+
12
+ created_adjustments = order.shipments.map do |shipment|
13
+ next if promotion_credit_exists?(shipment)
14
+
15
+ shipment.adjustments.create!(
16
+ order: shipment.order,
17
+ amount: compute_amount(shipment),
18
+ source: self,
19
+ promotion_code:,
20
+ label:
21
+ )
22
+ end
23
+
24
+ # Did we actually end up creating any adjustments?
25
+ # If so, then this action should be classed as 'successful'
26
+ created_adjustments.any?
27
+ end
28
+
29
+ def label
30
+ "#{I18n.t('spree.promotion')} (#{promotion.name})"
31
+ end
32
+
33
+ def compute_amount(shipment)
34
+ shipment.cost * -1
35
+ end
36
+
37
+ # Removes any adjustments generated by this action from the order's
38
+ # shipments.
39
+ # @param order [Spree::Order] the order to remove the action from.
40
+ # @return [void]
41
+ def remove_from(order)
42
+ order.shipments.each do |shipment|
43
+ shipment.adjustments.each do |adjustment|
44
+ if adjustment.source == self
45
+ shipment.adjustments.destroy(adjustment)
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def promotion_credit_exists?(shipment)
54
+ shipment.adjustments.where(source: self).exists?
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end