solidus_friendly_promotions 1.0.0.pre

Sign up to get free protection for your applications and to get access to all the features.
Files changed (214) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +61 -0
  3. data/.gem_release.yml +5 -0
  4. data/.github/stale.yml +1 -0
  5. data/.github_changelog_generator +2 -0
  6. data/.gitignore +21 -0
  7. data/.rspec +2 -0
  8. data/.rubocop.yml +29 -0
  9. data/.rubocop_todo.yml +157 -0
  10. data/.standard.yml +4 -0
  11. data/CHANGELOG.md +1 -0
  12. data/Gemfile +42 -0
  13. data/LICENSE +26 -0
  14. data/MIGRATING.md +134 -0
  15. data/README.md +144 -0
  16. data/Rakefile +7 -0
  17. data/app/assets/config/solidus_friendly_promotions/manifest.js +7 -0
  18. data/app/controllers/solidus_friendly_promotions/admin/base_controller.rb +54 -0
  19. data/app/controllers/solidus_friendly_promotions/admin/promotion_actions_controller.rb +103 -0
  20. data/app/controllers/solidus_friendly_promotions/admin/promotion_categories_controller.rb +13 -0
  21. data/app/controllers/solidus_friendly_promotions/admin/promotion_code_batches_controller.rb +30 -0
  22. data/app/controllers/solidus_friendly_promotions/admin/promotion_codes_controller.rb +58 -0
  23. data/app/controllers/solidus_friendly_promotions/admin/promotion_rules_controller.rb +105 -0
  24. data/app/controllers/solidus_friendly_promotions/admin/promotions_controller.rb +64 -0
  25. data/app/decorators/models/solidus_friendly_promotions/adjustment_decorator.rb +26 -0
  26. data/app/decorators/models/solidus_friendly_promotions/line_item_decorator.rb +7 -0
  27. data/app/decorators/models/solidus_friendly_promotions/order_decorator.rb +42 -0
  28. data/app/decorators/models/solidus_friendly_promotions/order_recalculator_decorator.rb +13 -0
  29. data/app/decorators/models/solidus_friendly_promotions/shipment_decorator.rb +14 -0
  30. data/app/decorators/models/solidus_friendly_promotions/shipping_rate_decorator.rb +29 -0
  31. data/app/helpers/solidus_friendly_promotions/admin/promotion_actions_helper.rb +23 -0
  32. data/app/helpers/solidus_friendly_promotions/admin/promotion_rules_helper.rb +25 -0
  33. data/app/javascript/solidus_friendly_promotions/controllers/application.js +9 -0
  34. data/app/javascript/solidus_friendly_promotions/controllers/calculator_tiers_controller.js +37 -0
  35. data/app/javascript/solidus_friendly_promotions/controllers/flash_controller.js +10 -0
  36. data/app/javascript/solidus_friendly_promotions/controllers/index.js +8 -0
  37. data/app/javascript/solidus_friendly_promotions/controllers/product_option_values_controller.js +62 -0
  38. data/app/javascript/solidus_friendly_promotions/jquery/option_value_picker.js +44 -0
  39. data/app/javascript/solidus_friendly_promotions.js +12 -0
  40. data/app/jobs/solidus_friendly_promotions/promotion_code_batch_job.rb +26 -0
  41. data/app/mailers/solidus_friendly_promotions/promotion_code_batch_mailer.rb +15 -0
  42. data/app/models/concerns/solidus_friendly_promotions/discountable_amount.rb +21 -0
  43. data/app/models/concerns/solidus_friendly_promotions/rules/line_item_applicable_order_rule.rb +23 -0
  44. data/app/models/concerns/solidus_friendly_promotions/rules/line_item_level_rule.rb +15 -0
  45. data/app/models/concerns/solidus_friendly_promotions/rules/order_level_rule.rb +15 -0
  46. data/app/models/concerns/solidus_friendly_promotions/rules/shipment_level_rule.rb +15 -0
  47. data/app/models/solidus_friendly_promotions/actions/adjust_line_item.rb +15 -0
  48. data/app/models/solidus_friendly_promotions/actions/adjust_line_item_quantity_groups.rb +116 -0
  49. data/app/models/solidus_friendly_promotions/actions/adjust_shipment.rb +15 -0
  50. data/app/models/solidus_friendly_promotions/calculators/distributed_amount.rb +37 -0
  51. data/app/models/solidus_friendly_promotions/calculators/flat_rate.rb +21 -0
  52. data/app/models/solidus_friendly_promotions/calculators/flexi_rate.rb +24 -0
  53. data/app/models/solidus_friendly_promotions/calculators/percent.rb +17 -0
  54. data/app/models/solidus_friendly_promotions/calculators/tiered_flat_rate.rb +56 -0
  55. data/app/models/solidus_friendly_promotions/calculators/tiered_percent.rb +66 -0
  56. data/app/models/solidus_friendly_promotions/distributed_amounts_handler.rb +43 -0
  57. data/app/models/solidus_friendly_promotions/eligibility_result.rb +5 -0
  58. data/app/models/solidus_friendly_promotions/eligibility_results.rb +48 -0
  59. data/app/models/solidus_friendly_promotions/friendly_promotion_adjuster/choose_discounts.rb +21 -0
  60. data/app/models/solidus_friendly_promotions/friendly_promotion_adjuster/discount_order.rb +76 -0
  61. data/app/models/solidus_friendly_promotions/friendly_promotion_adjuster/load_promotions.rb +54 -0
  62. data/app/models/solidus_friendly_promotions/friendly_promotion_adjuster/persist_discounted_order.rb +81 -0
  63. data/app/models/solidus_friendly_promotions/friendly_promotion_adjuster.rb +25 -0
  64. data/app/models/solidus_friendly_promotions/item_discount.rb +21 -0
  65. data/app/models/solidus_friendly_promotions/migration_support/order_promotion_syncer.rb +54 -0
  66. data/app/models/solidus_friendly_promotions/order_promotion.rb +23 -0
  67. data/app/models/solidus_friendly_promotions/permission_sets/friendly_promotion_management.rb +15 -0
  68. data/app/models/solidus_friendly_promotions/products_promotion_rule.rb +8 -0
  69. data/app/models/solidus_friendly_promotions/promotion.rb +166 -0
  70. data/app/models/solidus_friendly_promotions/promotion_action.rb +73 -0
  71. data/app/models/solidus_friendly_promotions/promotion_category.rb +9 -0
  72. data/app/models/solidus_friendly_promotions/promotion_code/batch_builder.rb +72 -0
  73. data/app/models/solidus_friendly_promotions/promotion_code.rb +55 -0
  74. data/app/models/solidus_friendly_promotions/promotion_code_batch.rb +25 -0
  75. data/app/models/solidus_friendly_promotions/promotion_handler/coupon.rb +113 -0
  76. data/app/models/solidus_friendly_promotions/promotion_handler/page.rb +30 -0
  77. data/app/models/solidus_friendly_promotions/promotion_rule.rb +55 -0
  78. data/app/models/solidus_friendly_promotions/promotion_rules_store.rb +8 -0
  79. data/app/models/solidus_friendly_promotions/promotion_rules_taxon.rb +8 -0
  80. data/app/models/solidus_friendly_promotions/promotion_rules_user.rb +8 -0
  81. data/app/models/solidus_friendly_promotions/rules/discounted_item_total.rb +22 -0
  82. data/app/models/solidus_friendly_promotions/rules/first_order.rb +31 -0
  83. data/app/models/solidus_friendly_promotions/rules/first_repeat_purchase_since.rb +31 -0
  84. data/app/models/solidus_friendly_promotions/rules/item_total.rb +86 -0
  85. data/app/models/solidus_friendly_promotions/rules/line_item_option_value.rb +37 -0
  86. data/app/models/solidus_friendly_promotions/rules/line_item_product.rb +52 -0
  87. data/app/models/solidus_friendly_promotions/rules/line_item_taxon.rb +55 -0
  88. data/app/models/solidus_friendly_promotions/rules/minimum_quantity.rb +48 -0
  89. data/app/models/solidus_friendly_promotions/rules/nth_order.rb +40 -0
  90. data/app/models/solidus_friendly_promotions/rules/one_use_per_user.rb +25 -0
  91. data/app/models/solidus_friendly_promotions/rules/option_value.rb +28 -0
  92. data/app/models/solidus_friendly_promotions/rules/product.rb +85 -0
  93. data/app/models/solidus_friendly_promotions/rules/shipping_method.rb +19 -0
  94. data/app/models/solidus_friendly_promotions/rules/store.rb +26 -0
  95. data/app/models/solidus_friendly_promotions/rules/taxon.rb +98 -0
  96. data/app/models/solidus_friendly_promotions/rules/user.rb +35 -0
  97. data/app/models/solidus_friendly_promotions/rules/user_logged_in.rb +16 -0
  98. data/app/models/solidus_friendly_promotions/rules/user_role.rb +42 -0
  99. data/app/models/solidus_friendly_promotions/shipping_rate_discount.rb +11 -0
  100. data/app/models/solidus_friendly_promotions/simple_order_contents.rb +27 -0
  101. data/app/models/solidus_friendly_promotions.rb +7 -0
  102. data/app/views/solidus_friendly_promotions/admin/promotion_actions/_calculator_select.html.erb +16 -0
  103. data/app/views/solidus_friendly_promotions/admin/promotion_actions/_form.html.erb +3 -0
  104. data/app/views/solidus_friendly_promotions/admin/promotion_actions/_promotion_action.html.erb +29 -0
  105. data/app/views/solidus_friendly_promotions/admin/promotion_actions/_type_select.html.erb +14 -0
  106. data/app/views/solidus_friendly_promotions/admin/promotion_actions/actions/_adjust_line_item.html.erb +6 -0
  107. data/app/views/solidus_friendly_promotions/admin/promotion_actions/actions/_adjust_line_item_quantity_groups.html.erb +13 -0
  108. data/app/views/solidus_friendly_promotions/admin/promotion_actions/actions/_adjust_shipment.html.erb +6 -0
  109. data/app/views/solidus_friendly_promotions/admin/promotion_actions/actions/_calculator_fields.erb +8 -0
  110. data/app/views/solidus_friendly_promotions/admin/promotion_actions/calculators/_default_fields.html.erb +6 -0
  111. data/app/views/solidus_friendly_promotions/admin/promotion_actions/calculators/distributed_amount/_fields.html.erb +56 -0
  112. data/app/views/solidus_friendly_promotions/admin/promotion_actions/calculators/flat_rate/_fields.html.erb +6 -0
  113. data/app/views/solidus_friendly_promotions/admin/promotion_actions/calculators/tiered_flat_rate/_fields.html.erb +34 -0
  114. data/app/views/solidus_friendly_promotions/admin/promotion_actions/calculators/tiered_flat_rate/_tier_fields.html.erb +32 -0
  115. data/app/views/solidus_friendly_promotions/admin/promotion_actions/calculators/tiered_percent/_fields.html.erb +34 -0
  116. data/app/views/solidus_friendly_promotions/admin/promotion_actions/calculators/tiered_percent/_tier_fields.html.erb +32 -0
  117. data/app/views/solidus_friendly_promotions/admin/promotion_actions/edit.html.erb +23 -0
  118. data/app/views/solidus_friendly_promotions/admin/promotion_actions/new.html.erb +26 -0
  119. data/app/views/solidus_friendly_promotions/admin/promotion_categories/_form.html.erb +14 -0
  120. data/app/views/solidus_friendly_promotions/admin/promotion_categories/edit.html.erb +10 -0
  121. data/app/views/solidus_friendly_promotions/admin/promotion_categories/index.html.erb +47 -0
  122. data/app/views/solidus_friendly_promotions/admin/promotion_categories/new.html.erb +10 -0
  123. data/app/views/solidus_friendly_promotions/admin/promotion_codes/index.csv.ruby +8 -0
  124. data/app/views/solidus_friendly_promotions/admin/promotion_codes/index.html.erb +32 -0
  125. data/app/views/solidus_friendly_promotions/admin/promotion_codes/new.html.erb +31 -0
  126. data/app/views/solidus_friendly_promotions/admin/promotion_rules/_promotion_rule.html.erb +22 -0
  127. data/app/views/solidus_friendly_promotions/admin/promotion_rules/_type_select.html.erb +20 -0
  128. data/app/views/solidus_friendly_promotions/admin/promotion_rules/new.html.erb +24 -0
  129. data/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_first_order.html.erb +3 -0
  130. data/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_first_repeat_purchase_since.html.erb +6 -0
  131. data/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_item_total.html.erb +17 -0
  132. data/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_line_item_option_value.html.erb +25 -0
  133. data/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_line_item_product.html.erb +21 -0
  134. data/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_line_item_taxon.html.erb +17 -0
  135. data/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_minimum_quantity.html.erb +5 -0
  136. data/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_nth_order.html.erb +15 -0
  137. data/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_one_use_per_user.html.erb +3 -0
  138. data/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_option_value.html.erb +63 -0
  139. data/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_product.html.erb +44 -0
  140. data/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_shipping_method.html.erb +10 -0
  141. data/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_store.html.erb +9 -0
  142. data/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_taxon.html.erb +44 -0
  143. data/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_user.html.erb +7 -0
  144. data/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_user_logged_in.html.erb +3 -0
  145. data/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/_user_role.html.erb +15 -0
  146. data/app/views/solidus_friendly_promotions/admin/promotion_rules/rules/line_item_option_value/_option_value_fields.html.erb +17 -0
  147. data/app/views/solidus_friendly_promotions/admin/promotions/_activations_edit.html.erb +22 -0
  148. data/app/views/solidus_friendly_promotions/admin/promotions/_activations_new.html.erb +43 -0
  149. data/app/views/solidus_friendly_promotions/admin/promotions/_form.html.erb +96 -0
  150. data/app/views/solidus_friendly_promotions/admin/promotions/edit.html.erb +73 -0
  151. data/app/views/solidus_friendly_promotions/admin/promotions/index.html.erb +124 -0
  152. data/app/views/solidus_friendly_promotions/admin/promotions/new.html.erb +9 -0
  153. data/app/views/solidus_friendly_promotions/admin/shared/_promotion_sub_menu.html.erb +14 -0
  154. data/app/views/solidus_friendly_promotions/promotion_code_batch_mailer/promotion_code_batch_errored.text.erb +2 -0
  155. data/app/views/solidus_friendly_promotions/promotion_code_batch_mailer/promotion_code_batch_finished.text.erb +2 -0
  156. data/bin/console +17 -0
  157. data/bin/importmap +4 -0
  158. data/bin/rails +7 -0
  159. data/bin/rails-engine +13 -0
  160. data/bin/rails-sandbox +16 -0
  161. data/bin/rake +7 -0
  162. data/bin/sandbox +75 -0
  163. data/bin/setup +8 -0
  164. data/config/importmap.rb +12 -0
  165. data/config/initializers/solidus_friendly_promotions.rb +3 -0
  166. data/config/locales/en.yml +255 -0
  167. data/config/routes.rb +18 -0
  168. data/db/migrate/20230703101637_create_promotions.rb +16 -0
  169. data/db/migrate/20230703141116_create_promotion_categories.rb +14 -0
  170. data/db/migrate/20230703143943_create_promotion_rules.rb +12 -0
  171. data/db/migrate/20230704083830_add_rule_tables.rb +31 -0
  172. data/db/migrate/20230704093625_create_promotion_actions.rb +14 -0
  173. data/db/migrate/20230704102444_create_promotion_codes.rb +11 -0
  174. data/db/migrate/20230704102656_create_promotion_code_batches.rb +33 -0
  175. data/db/migrate/20230705171556_create_friendly_order_promotions.rb +11 -0
  176. data/db/migrate/20230725074235_create_shipping_rate_discounts.rb +12 -0
  177. data/db/migrate/20230928093138_add_lane_to_solidus_friendly_promotions_promotions.rb +5 -0
  178. data/db/migrate/20231006134042_add_customer_label_to_promotions.rb +7 -0
  179. data/db/migrate/20231011100059_add_db_comments_to_friendly_order_promotions.rb +61 -0
  180. data/db/migrate/20231011120928_add_db_comments_to_friendly_promotion_rules_taxons.rb +54 -0
  181. data/db/migrate/20231011131324_add_db_comments_to_friendly_promotion_rules.rb +60 -0
  182. data/db/migrate/20231011142040_add_db_comments_to_friendly_promotion_rules_users.rb +53 -0
  183. data/db/migrate/20231011154553_allow_null_promotion_ids.rb +9 -0
  184. data/db/migrate/20231011155822_add_db_comments_to_friendly_promotions.rb +123 -0
  185. data/db/migrate/20231011163030_add_db_comments_to_friendly_promotion_codes.rb +60 -0
  186. data/db/migrate/20231011173312_add_db_comments_to_friendly_promotion_code_batches.rb +91 -0
  187. data/db/migrate/20231011184205_add_db_comments_to_friendly_promotion_rules_stores.rb +53 -0
  188. data/db/migrate/20231011190222_add_db_comments_to_friendly_promotion_actions.rb +68 -0
  189. data/db/migrate/20231012020928_add_db_comments_to_friendly_products_promotion_rules.rb +52 -0
  190. data/db/migrate/20231012120928_add_db_comments_to_friendly_promotion_categories.rb +52 -0
  191. data/db/migrate/20231013181921_add_original_promotion_ids.rb +6 -0
  192. data/lib/generators/solidus_friendly_promotions/install/install_generator.rb +38 -0
  193. data/lib/generators/solidus_friendly_promotions/install/templates/initializer.rb +118 -0
  194. data/lib/solidus_friendly_promotions/configuration.rb +52 -0
  195. data/lib/solidus_friendly_promotions/engine.rb +37 -0
  196. data/lib/solidus_friendly_promotions/migrate_adjustments.rb +62 -0
  197. data/lib/solidus_friendly_promotions/migrate_order_promotions.rb +73 -0
  198. data/lib/solidus_friendly_promotions/nested_class_set.rb +24 -0
  199. data/lib/solidus_friendly_promotions/promotion_map.rb +96 -0
  200. data/lib/solidus_friendly_promotions/promotion_migrator.rb +103 -0
  201. data/lib/solidus_friendly_promotions/testing_support/factories/friendly_order_factory.rb +20 -0
  202. data/lib/solidus_friendly_promotions/testing_support/factories/friendly_order_promotion_factory.rb +8 -0
  203. data/lib/solidus_friendly_promotions/testing_support/factories/friendly_promotion_category_factory.rb +7 -0
  204. data/lib/solidus_friendly_promotions/testing_support/factories/friendly_promotion_code_factory.rb +8 -0
  205. data/lib/solidus_friendly_promotions/testing_support/factories/friendly_promotion_factory.rb +91 -0
  206. data/lib/solidus_friendly_promotions/testing_support/factories/friendly_shipping_rate_discount_factory.rb +14 -0
  207. data/lib/solidus_friendly_promotions/testing_support.rb +9 -0
  208. data/lib/solidus_friendly_promotions/version.rb +5 -0
  209. data/lib/solidus_friendly_promotions.rb +18 -0
  210. data/lib/tasks/solidus_friendly_promotions/migrate_adjustments.rake +17 -0
  211. data/lib/tasks/solidus_friendly_promotions/migrate_existing_promotions.rake +12 -0
  212. data/lib/tasks/solidus_friendly_promotions/migrate_order_promotions.rake +17 -0
  213. data/solidus_friendly_promotions.gemspec +40 -0
  214. metadata +392 -0
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusFriendlyPromotions
4
+ class DistributedAmountsHandler
5
+ attr_reader :line_items, :total_amount
6
+
7
+ def initialize(line_items, total_amount)
8
+ @line_items = line_items
9
+ @total_amount = total_amount
10
+ end
11
+
12
+ # @param line_item [LineItem] one of the line_items distributed over
13
+ # @return [BigDecimal] the weighted adjustment for this line_item
14
+ def amount(line_item)
15
+ distributed_amounts[line_item.id].to_d
16
+ end
17
+
18
+ private
19
+
20
+ # @private
21
+ # @return [Hash<Integer, BigDecimal>] a hash of line item IDs and their
22
+ # corresponding weighted adjustments
23
+ def distributed_amounts
24
+ line_item_ids.zip(allocated_amounts).to_h
25
+ end
26
+
27
+ def line_item_ids
28
+ line_items.map(&:id)
29
+ end
30
+
31
+ def elligible_amounts
32
+ line_items.map(&:discountable_amount)
33
+ end
34
+
35
+ def subtotal
36
+ elligible_amounts.sum
37
+ end
38
+
39
+ def allocated_amounts
40
+ total_amount.to_money.allocate(elligible_amounts).map(&:to_money)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusFriendlyPromotions
4
+ EligibilityResult = Struct.new(:item, :rule, :success, :code, :message, keyword_init: true)
5
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusFriendlyPromotions
4
+ class EligibilityResults
5
+ include Enumerable
6
+ attr_reader :results, :promotion
7
+ def initialize(promotion)
8
+ @promotion = promotion
9
+ @results = []
10
+ end
11
+
12
+ def add(item:, rule:, success:, code:, message:)
13
+ results << EligibilityResult.new(
14
+ item: item,
15
+ rule: rule,
16
+ success: success,
17
+ code: code,
18
+ message: message
19
+ )
20
+ end
21
+
22
+ def success?
23
+ return true if results.empty?
24
+ promotion.actions.any? do |action|
25
+ action.relevant_rules.all? do |rule|
26
+ results_for_rule = results.select { |result| result.rule == rule }
27
+ results_for_rule.any?(&:success)
28
+ end
29
+ end
30
+ end
31
+
32
+ def error_messages
33
+ return [] if results.empty?
34
+ results.group_by(&:rule).map do |rule, results|
35
+ next if results.any?(&:success)
36
+ results.detect { |r| !r.success }&.message
37
+ end.compact
38
+ end
39
+
40
+ def each(&block)
41
+ results.each(&block)
42
+ end
43
+
44
+ def last
45
+ results.last
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusFriendlyPromotions
4
+ class FriendlyPromotionAdjuster
5
+ class ChooseDiscounts
6
+ attr_reader :discounts
7
+
8
+ def initialize(discounts)
9
+ @discounts = discounts
10
+ end
11
+
12
+ def call
13
+ Array.wrap(
14
+ discounts.min_by do |discount|
15
+ [discount.amount, -discount.source&.id.to_i]
16
+ end
17
+ )
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusFriendlyPromotions
4
+ class FriendlyPromotionAdjuster
5
+ class DiscountOrder
6
+ attr_reader :order, :promotions, :dry_run
7
+
8
+ def initialize(order, promotions, dry_run: false)
9
+ @order = order
10
+ @promotions = promotions
11
+ @dry_run = dry_run
12
+ end
13
+
14
+ def call
15
+ return order if order.shipped?
16
+
17
+ SolidusFriendlyPromotions::Promotion.ordered_lanes.each do |lane, _index|
18
+ lane_promotions = eligible_promotions_for_promotable(promotions.select { |promotion| promotion.lane == lane }, order)
19
+ line_item_discounts = adjust_line_items(lane_promotions)
20
+ shipment_discounts = adjust_shipments(lane_promotions)
21
+ shipping_rate_discounts = adjust_shipping_rates(lane_promotions)
22
+ (line_item_discounts + shipment_discounts + shipping_rate_discounts).each do |item, chosen_discounts|
23
+ item.current_discounts.concat(chosen_discounts)
24
+ end
25
+ end
26
+
27
+ order
28
+ end
29
+
30
+ private
31
+
32
+ def adjust_line_items(promotions)
33
+ order.line_items.select do |line_item|
34
+ line_item.variant.product.promotionable?
35
+ end.map do |line_item|
36
+ discounts = generate_discounts(promotions, line_item)
37
+ chosen_item_discounts = SolidusFriendlyPromotions.config.discount_chooser_class.new(discounts).call
38
+ [line_item, chosen_item_discounts]
39
+ end
40
+ end
41
+
42
+ def adjust_shipments(promotions)
43
+ order.shipments.map do |shipment|
44
+ discounts = generate_discounts(promotions, shipment)
45
+ chosen_item_discounts = SolidusFriendlyPromotions.config.discount_chooser_class.new(discounts).call
46
+ [shipment, chosen_item_discounts]
47
+ end
48
+ end
49
+
50
+ def adjust_shipping_rates(promotions)
51
+ order.shipments.flat_map(&:shipping_rates).select(&:cost).map do |rate|
52
+ discounts = generate_discounts(promotions, rate)
53
+ chosen_item_discounts = SolidusFriendlyPromotions.config.discount_chooser_class.new(discounts).call
54
+ [rate, chosen_item_discounts]
55
+ end
56
+ end
57
+
58
+ def eligible_promotions_for_promotable(possible_promotions, promotable)
59
+ possible_promotions.select do |candidate|
60
+ candidate.eligible_by_applicable_rules?(promotable, dry_run: dry_run)
61
+ end
62
+ end
63
+
64
+ def generate_discounts(possible_promotions, item)
65
+ eligible_promotions = eligible_promotions_for_promotable(possible_promotions, item)
66
+ eligible_promotions.flat_map do |promotion|
67
+ promotion.actions.select do |action|
68
+ action.can_discount?(item)
69
+ end.map do |action|
70
+ action.discount(item)
71
+ end.compact
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusFriendlyPromotions
4
+ class FriendlyPromotionAdjuster
5
+ class LoadPromotions
6
+ def initialize(order:, dry_run_promotion: nil)
7
+ @order = order
8
+ @dry_run_promotion = dry_run_promotion
9
+ end
10
+
11
+ def call
12
+ promos = connected_order_promotions | sale_promotions
13
+ promos << dry_run_promotion if dry_run_promotion
14
+ promos.flat_map(&:actions).group_by(&:preload_relations).each do |preload_relations, actions|
15
+ preload(records: actions, associations: preload_relations)
16
+ end
17
+ promos.flat_map(&:rules).group_by(&:preload_relations).each do |preload_relations, rules|
18
+ preload(records: rules, associations: preload_relations)
19
+ end
20
+ promos.reject { |promotion| promotion.usage_limit_exceeded?(excluded_orders: [order]) }
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :order, :dry_run_promotion
26
+
27
+ def preload(records:, associations:)
28
+ ActiveRecord::Associations::Preloader.new(records: records, associations: associations).call
29
+ end
30
+
31
+ def connected_order_promotions
32
+ eligible_connected_promotion_ids = order.friendly_order_promotions.select do |order_promotion|
33
+ order_promotion.promotion_code.nil? || !order_promotion.promotion_code.usage_limit_exceeded?(excluded_orders: [order])
34
+ end.map(&:promotion_id)
35
+ order.friendly_promotions.active(reference_time).where(id: eligible_connected_promotion_ids).includes(promotion_includes)
36
+ end
37
+
38
+ def sale_promotions
39
+ SolidusFriendlyPromotions::Promotion.where(apply_automatically: true).active(reference_time).includes(promotion_includes)
40
+ end
41
+
42
+ def reference_time
43
+ order.completed_at || Time.current
44
+ end
45
+
46
+ def promotion_includes
47
+ [
48
+ :rules,
49
+ :actions
50
+ ]
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusFriendlyPromotions
4
+ class FriendlyPromotionAdjuster
5
+ class PersistDiscountedOrder
6
+ def initialize(order)
7
+ @order = order
8
+ end
9
+
10
+ def call
11
+ order.line_items.each do |line_item|
12
+ update_adjustments(line_item, line_item.current_discounts)
13
+ end
14
+
15
+ order.shipments.each do |shipment|
16
+ update_adjustments(shipment, shipment.current_discounts)
17
+ end
18
+
19
+ order.shipments.flat_map(&:shipping_rates).each do |shipping_rate|
20
+ shipping_rate.discounts = shipping_rate.current_discounts.map do |discount|
21
+ SolidusFriendlyPromotions::ShippingRateDiscount.create!(
22
+ shipping_rate: shipping_rate,
23
+ amount: discount.amount,
24
+ label: discount.label,
25
+ promotion_action: discount.source
26
+ )
27
+ end
28
+ end
29
+ order.reset_current_discounts
30
+ order.promo_total = (order.line_items + order.shipments).sum(&:promo_total)
31
+ order
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :order
37
+
38
+ # Walk through the discounts for an item and update adjustments for it. Once
39
+ # all of the discounts have been added as adjustments, remove any old tax
40
+ # adjustments that weren't touched.
41
+ #
42
+ # @private
43
+ # @param [#adjustments] item a {Spree::LineItem} or {Spree::Shipment}
44
+ # @param [Array<SolidusFriendlyPromotions::ItemDiscount>] item_discounts a list of calculated discounts for an item
45
+ # @return [void]
46
+ def update_adjustments(item, item_discounts)
47
+ promotion_adjustments = item.adjustments.select(&:promotion?)
48
+
49
+ active_adjustments = item_discounts.map do |item_discount|
50
+ update_adjustment(item, item_discount)
51
+ end
52
+ item.update(promo_total: active_adjustments.sum(&:amount))
53
+ # Remove any tax adjustments tied to promotion actions which no longer match.
54
+ unmatched_adjustments = promotion_adjustments - active_adjustments
55
+
56
+ item.adjustments.destroy(unmatched_adjustments)
57
+ end
58
+
59
+ # Update or create a new tax adjustment on an item.
60
+ #
61
+ # @private
62
+ # @param [#adjustments] item a {Spree::LineItem} or {Spree::Shipment}
63
+ # @param [SolidusFriendlyPromotions::ItemDiscount] tax_item calculated discounts for an item
64
+ # @return [Spree::Adjustment] the created or updated tax adjustment
65
+ def update_adjustment(item, discount_item)
66
+ adjustment = item.adjustments.detect do |item_adjustment|
67
+ item_adjustment.source == discount_item.source
68
+ end
69
+
70
+ adjustment ||= item.adjustments.new(
71
+ source: discount_item.source,
72
+ order_id: item.is_a?(Spree::Order) ? item.id : item.order_id,
73
+ label: discount_item.label,
74
+ eligible: true
75
+ )
76
+ adjustment.update!(amount: discount_item.amount)
77
+ adjustment
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusFriendlyPromotions
4
+ class FriendlyPromotionAdjuster
5
+ attr_reader :order, :promotions, :dry_run
6
+
7
+ def initialize(order, dry_run_promotion: nil)
8
+ @order = order
9
+ @dry_run = !!dry_run_promotion
10
+ @promotions = LoadPromotions.new(order: order, dry_run_promotion: dry_run_promotion).call
11
+ end
12
+
13
+ def call
14
+ order.reset_current_discounts
15
+
16
+ return order if order.shipped?
17
+ discounted_order = DiscountOrder.new(order, promotions, dry_run: dry_run).call
18
+
19
+ PersistDiscountedOrder.new(discounted_order).call unless dry_run
20
+
21
+ order.reset_current_discounts
22
+ order
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusFriendlyPromotions
4
+ # Simple object used to hold discount data for an item.
5
+ #
6
+ # This generic object will hold the amount of discount that should be applied to
7
+ # an item.
8
+ #
9
+ # @attr_reader [Spree::LineItem,Spree::Shipment] the item to be discounted.
10
+ # @attr_reader [String] label information about the discount
11
+ # @attr_reader [ApplicationRecord] source will be used as the source for adjustments
12
+ # @attr_reader [BigDecimal] amount the amount of discount applied to the item
13
+ class ItemDiscount
14
+ include ActiveModel::Model
15
+ attr_accessor :item, :label, :source, :amount
16
+
17
+ def ==(other)
18
+ item == other.item && label == other.label && source == other.source && amount == other.amount
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusFriendlyPromotions
4
+ module MigrationSupport
5
+ class OrderPromotionSyncer
6
+ attr_reader :order
7
+
8
+ def initialize(order:)
9
+ @order = order
10
+ end
11
+
12
+ def call
13
+ sync_spree_order_promotions_to_friendly_order_promotions
14
+ sync_friendly_order_promotions_to_spree_order_promotions
15
+ end
16
+
17
+ private
18
+
19
+ def sync_spree_order_promotions_to_friendly_order_promotions
20
+ order.order_promotions.each do |spree_order_promotion|
21
+ friendly_promotion = SolidusFriendlyPromotions::Promotion.find_by(
22
+ original_promotion_id: spree_order_promotion.promotion.id
23
+ )
24
+ next unless friendly_promotion
25
+ if spree_order_promotion.promotion_code
26
+ friendly_promotion_code = friendly_promotion.codes.find_by(
27
+ value: spree_order_promotion.promotion_code.value
28
+ )
29
+ end
30
+ order.friendly_order_promotions.find_or_create_by!(
31
+ promotion: friendly_promotion,
32
+ promotion_code: friendly_promotion_code
33
+ )
34
+ end
35
+ end
36
+
37
+ def sync_friendly_order_promotions_to_spree_order_promotions
38
+ order.friendly_order_promotions.each do |friendly_order_promotion|
39
+ spree_promotion = friendly_order_promotion.promotion.original_promotion
40
+ next unless spree_promotion
41
+ if friendly_order_promotion.promotion_code
42
+ spree_promotion_code = spree_promotion.promotion_codes.find_by(
43
+ value: friendly_order_promotion.promotion_code.value
44
+ )
45
+ end
46
+ order.order_promotions.find_or_create_by!(
47
+ promotion: spree_promotion,
48
+ promotion_code: spree_promotion_code
49
+ )
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusFriendlyPromotions
4
+ # SolidusFriendlyPromotions::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
+ belongs_to :order, class_name: "Spree::Order"
10
+ belongs_to :promotion, class_name: "SolidusFriendlyPromotions::Promotion"
11
+ belongs_to :promotion_code, class_name: "SolidusFriendlyPromotions::PromotionCode", optional: true
12
+
13
+ validates :promotion_code, presence: true, if: :require_promotion_code?
14
+
15
+ self.allowed_ransackable_associations = %w[promotion_code]
16
+
17
+ private
18
+
19
+ def require_promotion_code?
20
+ promotion && !promotion.apply_automatically && promotion.codes.any?
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusFriendlyPromotions
4
+ module PermissionSets
5
+ class FriendlyPromotionManagement < Spree::PermissionSets::Base
6
+ def activate!
7
+ can :manage, SolidusFriendlyPromotions::Promotion
8
+ can :manage, SolidusFriendlyPromotions::PromotionRule
9
+ can :manage, SolidusFriendlyPromotions::PromotionAction
10
+ can :manage, SolidusFriendlyPromotions::PromotionCategory
11
+ can :manage, SolidusFriendlyPromotions::PromotionCode
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusFriendlyPromotions
4
+ class ProductsPromotionRule < Spree::Base
5
+ belongs_to :product, class_name: "Spree::Product", optional: true
6
+ belongs_to :promotion_rule, class_name: "SolidusFriendlyPromotions::PromotionRule", optional: true
7
+ end
8
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusFriendlyPromotions
4
+ class Promotion < Spree::Base
5
+ belongs_to :category, class_name: "SolidusFriendlyPromotions::PromotionCategory",
6
+ foreign_key: :promotion_category_id, optional: true
7
+ belongs_to :original_promotion, class_name: "Spree::Promotion", optional: true
8
+ has_many :rules, class_name: "SolidusFriendlyPromotions::PromotionRule", dependent: :destroy
9
+ has_many :actions, class_name: "SolidusFriendlyPromotions::PromotionAction", dependent: :nullify
10
+ has_many :codes, class_name: "SolidusFriendlyPromotions::PromotionCode", dependent: :destroy
11
+ has_many :code_batches, class_name: "SolidusFriendlyPromotions::PromotionCodeBatch", dependent: :destroy
12
+ has_many :order_promotions, class_name: "SolidusFriendlyPromotions::OrderPromotion", dependent: :destroy
13
+
14
+ validates :name, :customer_label, presence: true
15
+ validates :path, uniqueness: {allow_blank: true, case_sensitive: true}
16
+ validates :usage_limit, numericality: {greater_than: 0, allow_nil: true}
17
+ validates :per_code_usage_limit, numericality: {greater_than_or_equal_to: 0, allow_nil: true}
18
+ validates :description, length: {maximum: 255}
19
+ validate :apply_automatically_disallowed_with_paths
20
+
21
+ scope :active, ->(time = Time.current) { has_actions.started_and_unexpired(time) }
22
+ scope :advertised, -> { where(advertise: true) }
23
+ scope :coupons, -> { joins(:codes).distinct }
24
+ scope :started_and_unexpired, ->(time = Time.current) do
25
+ table = arel_table
26
+
27
+ where(table[:starts_at].eq(nil).or(table[:starts_at].lt(time)))
28
+ .where(table[:expires_at].eq(nil).or(table[:expires_at].gt(time)))
29
+ end
30
+ scope :has_actions, -> do
31
+ joins(:actions).distinct
32
+ end
33
+
34
+ enum lane: SolidusFriendlyPromotions.config.preferred_lanes
35
+
36
+ def self.human_enum_name(enum_name, enum_value)
37
+ I18n.t("activerecord.attributes.#{model_name.i18n_key}.#{enum_name.to_s.pluralize}.#{enum_value}")
38
+ end
39
+
40
+ def self.lane_options
41
+ ordered_lanes.map do |lane_name, _index|
42
+ [human_enum_name(:lane, lane_name), lane_name]
43
+ end
44
+ end
45
+
46
+ def self.ordered_lanes
47
+ lanes.sort_by(&:last).to_h
48
+ end
49
+
50
+ self.allowed_ransackable_associations = ["codes"]
51
+ self.allowed_ransackable_attributes = %w[name path promotion_category_id]
52
+ self.allowed_ransackable_scopes = %i[active]
53
+
54
+ # All orders that have been discounted using this promotion
55
+ def discounted_orders
56
+ Spree::Order
57
+ .joins(:all_adjustments)
58
+ .where(
59
+ spree_adjustments: {
60
+ source_type: "SolidusFriendlyPromotions::PromotionAction",
61
+ source_id: actions.map(&:id),
62
+ eligible: true
63
+ }
64
+ ).distinct
65
+ end
66
+
67
+ # Number of times the code has been used overall
68
+ #
69
+ # @param excluded_orders [Array<Spree::Order>] Orders to exclude from usage count
70
+ # @return [Integer] usage count
71
+ def usage_count(excluded_orders: [])
72
+ discounted_orders
73
+ .complete
74
+ .where.not(id: [excluded_orders.map(&:id)])
75
+ .where.not(spree_orders: {state: :canceled})
76
+ .count
77
+ end
78
+
79
+ def used_by?(user, excluded_orders = [])
80
+ discounted_orders
81
+ .complete
82
+ .where.not(id: excluded_orders.map(&:id))
83
+ .where(user: user)
84
+ .where.not(spree_orders: {state: :canceled})
85
+ .exists?
86
+ end
87
+
88
+ # Whether the promotion has exceeded its usage restrictions.
89
+ #
90
+ # @param excluded_orders [Array<Spree::Order>] Orders to exclude from usage limit
91
+ # @return true or false
92
+ def usage_limit_exceeded?(excluded_orders: [])
93
+ return unless usage_limit
94
+
95
+ usage_count(excluded_orders: excluded_orders) >= usage_limit
96
+ end
97
+
98
+ def not_expired?(time = Time.current)
99
+ !expired?(time)
100
+ end
101
+
102
+ def not_started?(time = Time.current)
103
+ !started?(time)
104
+ end
105
+
106
+ def started?(time = Time.current)
107
+ starts_at.nil? || starts_at < time
108
+ end
109
+
110
+ def active?(time = Time.current)
111
+ started?(time) && not_expired?(time) && actions.present?
112
+ end
113
+
114
+ def inactive?(time = Time.current)
115
+ !active?(time)
116
+ end
117
+
118
+ def expired?(time = Time.current)
119
+ expires_at.present? && expires_at < time
120
+ end
121
+
122
+ def products
123
+ rules.where(type: "SolidusFriendlyPromotions::Rules::Product").flat_map(&:products).uniq
124
+ end
125
+
126
+ def eligibility_results
127
+ @eligibility_results ||= SolidusFriendlyPromotions::EligibilityResults.new(self)
128
+ end
129
+
130
+ def eligible_by_applicable_rules?(promotable, dry_run: false)
131
+ applicable_rules = rules.select do |rule|
132
+ rule.applicable?(promotable)
133
+ end
134
+
135
+ applicable_rules.map do |applicable_rule|
136
+ eligible = applicable_rule.eligible?(promotable)
137
+
138
+ break [false] if !eligible && !dry_run
139
+
140
+ if dry_run
141
+ if applicable_rule.eligibility_errors.details[:base].first
142
+ code = applicable_rule.eligibility_errors.details[:base].first[:error_code]
143
+ message = applicable_rule.eligibility_errors.full_messages.first
144
+ end
145
+ eligibility_results.add(
146
+ item: promotable,
147
+ rule: applicable_rule,
148
+ success: eligible,
149
+ code: eligible ? nil : (code || :coupon_code_unknown_error),
150
+ message: eligible ? nil : (message || I18n.t(:coupon_code_unknown_error, scope: [:solidus_friendly_promotions, :eligibility_errors]))
151
+ )
152
+ end
153
+
154
+ eligible
155
+ end.all?
156
+ end
157
+
158
+ private
159
+
160
+ def apply_automatically_disallowed_with_paths
161
+ return unless apply_automatically
162
+
163
+ errors.add(:apply_automatically, :disallowed_with_path) if path.present?
164
+ end
165
+ end
166
+ end