solidus_promotions 4.4.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 (234) hide show
  1. checksums.yaml +7 -0
  2. data/.eslintrc.json +10 -0
  3. data/.github/stale.yml +1 -0
  4. data/MIGRATING.md +184 -0
  5. data/README.md +122 -0
  6. data/Rakefile +20 -0
  7. data/app/assets/config/solidus_promotions/manifest.js +13 -0
  8. data/app/decorators/models/solidus_promotions/adjustment_decorator.rb +11 -0
  9. data/app/decorators/models/solidus_promotions/line_item_decorator.rb +27 -0
  10. data/app/decorators/models/solidus_promotions/order_decorator.rb +42 -0
  11. data/app/decorators/models/solidus_promotions/order_recalculator_decorator.rb +15 -0
  12. data/app/decorators/models/solidus_promotions/shipment_decorator.rb +14 -0
  13. data/app/decorators/models/solidus_promotions/shipping_rate_decorator.rb +29 -0
  14. data/app/helpers/solidus_promotions/admin/benefits_helper.rb +19 -0
  15. data/app/helpers/solidus_promotions/admin/conditions_helper.rb +14 -0
  16. data/app/helpers/solidus_promotions/admin/promotions_helper.rb +15 -0
  17. data/app/javascript/backend/solidus_promotions/controllers/application.js +9 -0
  18. data/app/javascript/backend/solidus_promotions/controllers/calculator_tiers_controller.js +37 -0
  19. data/app/javascript/backend/solidus_promotions/controllers/flash_controller.js +10 -0
  20. data/app/javascript/backend/solidus_promotions/controllers/index.js +8 -0
  21. data/app/javascript/backend/solidus_promotions/controllers/product_option_values_controller.js +62 -0
  22. data/app/javascript/backend/solidus_promotions/web_components/number_with_currency.js +35 -0
  23. data/app/javascript/backend/solidus_promotions/web_components/option_value_picker.js +52 -0
  24. data/app/javascript/backend/solidus_promotions/web_components/product_picker.js +7 -0
  25. data/app/javascript/backend/solidus_promotions/web_components/select_two.js +11 -0
  26. data/app/javascript/backend/solidus_promotions/web_components/taxon_picker.js +7 -0
  27. data/app/javascript/backend/solidus_promotions/web_components/user_picker.js +7 -0
  28. data/app/javascript/backend/solidus_promotions/web_components/variant_picker.js +7 -0
  29. data/app/javascript/backend/solidus_promotions.js +11 -0
  30. data/app/jobs/solidus_promotions/promotion_code_batch_job.rb +26 -0
  31. data/app/mailers/solidus_promotions/promotion_code_batch_mailer.rb +15 -0
  32. data/app/models/concerns/solidus_promotions/benefits/line_item_benefit.rb +15 -0
  33. data/app/models/concerns/solidus_promotions/benefits/order_benefit.rb +15 -0
  34. data/app/models/concerns/solidus_promotions/benefits/shipment_benefit.rb +15 -0
  35. data/app/models/concerns/solidus_promotions/calculators/promotion_calculator.rb +11 -0
  36. data/app/models/concerns/solidus_promotions/conditions/line_item_applicable_order_level_condition.rb +23 -0
  37. data/app/models/concerns/solidus_promotions/conditions/line_item_level_condition.rb +15 -0
  38. data/app/models/concerns/solidus_promotions/conditions/order_level_condition.rb +15 -0
  39. data/app/models/concerns/solidus_promotions/conditions/shipment_level_condition.rb +15 -0
  40. data/app/models/concerns/solidus_promotions/discountable_amount.rb +21 -0
  41. data/app/models/solidus_promotions/benefit.rb +123 -0
  42. data/app/models/solidus_promotions/benefits/adjust_line_item.rb +13 -0
  43. data/app/models/solidus_promotions/benefits/adjust_line_item_quantity_groups.rb +107 -0
  44. data/app/models/solidus_promotions/benefits/adjust_shipment.rb +13 -0
  45. data/app/models/solidus_promotions/benefits/create_discounted_item.rb +49 -0
  46. data/app/models/solidus_promotions/calculators/distributed_amount.rb +31 -0
  47. data/app/models/solidus_promotions/calculators/flat_rate.rb +23 -0
  48. data/app/models/solidus_promotions/calculators/flexi_rate.rb +26 -0
  49. data/app/models/solidus_promotions/calculators/percent.rb +19 -0
  50. data/app/models/solidus_promotions/calculators/tiered_flat_rate.rb +58 -0
  51. data/app/models/solidus_promotions/calculators/tiered_percent.rb +68 -0
  52. data/app/models/solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity.rb +40 -0
  53. data/app/models/solidus_promotions/condition.rb +61 -0
  54. data/app/models/solidus_promotions/condition_product.rb +8 -0
  55. data/app/models/solidus_promotions/condition_store.rb +8 -0
  56. data/app/models/solidus_promotions/condition_taxon.rb +8 -0
  57. data/app/models/solidus_promotions/condition_user.rb +8 -0
  58. data/app/models/solidus_promotions/conditions/discounted_item_total.rb +22 -0
  59. data/app/models/solidus_promotions/conditions/first_order.rb +31 -0
  60. data/app/models/solidus_promotions/conditions/first_repeat_purchase_since.rb +31 -0
  61. data/app/models/solidus_promotions/conditions/item_total.rb +75 -0
  62. data/app/models/solidus_promotions/conditions/line_item_option_value.rb +37 -0
  63. data/app/models/solidus_promotions/conditions/line_item_product.rb +56 -0
  64. data/app/models/solidus_promotions/conditions/line_item_taxon.rb +59 -0
  65. data/app/models/solidus_promotions/conditions/minimum_quantity.rb +42 -0
  66. data/app/models/solidus_promotions/conditions/nth_order.rb +40 -0
  67. data/app/models/solidus_promotions/conditions/one_use_per_user.rb +25 -0
  68. data/app/models/solidus_promotions/conditions/option_value.rb +28 -0
  69. data/app/models/solidus_promotions/conditions/product.rb +85 -0
  70. data/app/models/solidus_promotions/conditions/shipping_method.rb +19 -0
  71. data/app/models/solidus_promotions/conditions/store.rb +26 -0
  72. data/app/models/solidus_promotions/conditions/taxon.rb +98 -0
  73. data/app/models/solidus_promotions/conditions/user.rb +35 -0
  74. data/app/models/solidus_promotions/conditions/user_logged_in.rb +16 -0
  75. data/app/models/solidus_promotions/conditions/user_role.rb +42 -0
  76. data/app/models/solidus_promotions/distributed_amounts_handler.rb +39 -0
  77. data/app/models/solidus_promotions/eligibility_result.rb +5 -0
  78. data/app/models/solidus_promotions/eligibility_results.rb +47 -0
  79. data/app/models/solidus_promotions/item_discount.rb +21 -0
  80. data/app/models/solidus_promotions/migration_support/order_promotion_syncer.rb +54 -0
  81. data/app/models/solidus_promotions/order_adjuster/choose_discounts.rb +21 -0
  82. data/app/models/solidus_promotions/order_adjuster/discount_order.rb +93 -0
  83. data/app/models/solidus_promotions/order_adjuster/load_promotions.rb +53 -0
  84. data/app/models/solidus_promotions/order_adjuster/persist_discounted_order.rb +79 -0
  85. data/app/models/solidus_promotions/order_adjuster.rb +33 -0
  86. data/app/models/solidus_promotions/order_promotion.rb +23 -0
  87. data/app/models/solidus_promotions/permission_sets/solidus_promotion_management.rb +15 -0
  88. data/app/models/solidus_promotions/promotion.rb +163 -0
  89. data/app/models/solidus_promotions/promotion_advertiser.rb +10 -0
  90. data/app/models/solidus_promotions/promotion_category.rb +9 -0
  91. data/app/models/solidus_promotions/promotion_code/batch_builder.rb +73 -0
  92. data/app/models/solidus_promotions/promotion_code.rb +56 -0
  93. data/app/models/solidus_promotions/promotion_code_batch.rb +25 -0
  94. data/app/models/solidus_promotions/promotion_finder.rb +10 -0
  95. data/app/models/solidus_promotions/promotion_handler/coupon.rb +113 -0
  96. data/app/models/solidus_promotions/promotion_handler/page.rb +30 -0
  97. data/app/models/solidus_promotions/shipping_rate_discount.rb +11 -0
  98. data/app/subscribers/solidus_promotions/order_promotion_subscriber.rb +20 -0
  99. data/bin/rails +13 -0
  100. data/config/importmap.rb +14 -0
  101. data/config/locales/en.yml +376 -0
  102. data/config/locales/promotion_categories.en.yml +6 -0
  103. data/config/locales/promotions.en.yml +6 -0
  104. data/config/routes.rb +38 -0
  105. data/db/migrate/20230703101637_create_promotions.rb +25 -0
  106. data/db/migrate/20230703113625_create_promotion_benefits.rb +15 -0
  107. data/db/migrate/20230703141116_create_promotion_categories.rb +14 -0
  108. data/db/migrate/20230703143943_create_promotion_conditions.rb +12 -0
  109. data/db/migrate/20230704083830_add_condition_join_tables.rb +31 -0
  110. data/db/migrate/20230704102444_create_promotion_codes.rb +11 -0
  111. data/db/migrate/20230704102656_create_promotion_code_batches.rb +34 -0
  112. data/db/migrate/20230705171556_create_order_promotions.rb +11 -0
  113. data/db/migrate/20230725074235_create_shipping_rate_discounts.rb +12 -0
  114. data/db/migrate/20231011100059_add_db_comments_to_order_promotions.rb +61 -0
  115. data/db/migrate/20231011120928_add_db_comments_to_condition_taxons.rb +54 -0
  116. data/db/migrate/20231011131324_add_db_comments_to_conditions.rb +60 -0
  117. data/db/migrate/20231011142040_add_db_comments_to_condition_users.rb +53 -0
  118. data/db/migrate/20231011155822_add_db_comments_to_promotions.rb +123 -0
  119. data/db/migrate/20231011163030_add_db_comments_to_promotion_codes.rb +60 -0
  120. data/db/migrate/20231011173312_add_db_comments_to_promotion_code_batches.rb +91 -0
  121. data/db/migrate/20231011184205_add_db_comments_to_condition_stores.rb +53 -0
  122. data/db/migrate/20231011190222_add_db_comments_to_benefits.rb +61 -0
  123. data/db/migrate/20231012020928_add_db_comments_to_condition_products.rb +52 -0
  124. data/db/migrate/20231012120928_add_db_comments_to_promotion_categories.rb +52 -0
  125. data/db/migrate/20231104135812_add_managed_by_order_benefit_to_line_items.rb +5 -0
  126. data/lib/components/admin/solidus_admin/orders/show/adjustments/index/source/solidus_promotions_benefit/component.rb +17 -0
  127. data/lib/components/admin/solidus_promotions/orders/index/component.rb +15 -0
  128. data/lib/components/admin/solidus_promotions/orders/index/component.yml +3 -0
  129. data/lib/components/admin/solidus_promotions/promotion_categories/index/component.rb +60 -0
  130. data/lib/components/admin/solidus_promotions/promotions/index/component.rb +108 -0
  131. data/lib/components/admin/solidus_promotions/promotions/index/component.yml +10 -0
  132. data/lib/controllers/admin/solidus_promotions/promotion_categories_controller.rb +29 -0
  133. data/lib/controllers/admin/solidus_promotions/promotions_controller.rb +46 -0
  134. data/lib/controllers/backend/solidus_promotions/admin/base_controller.rb +15 -0
  135. data/lib/controllers/backend/solidus_promotions/admin/benefits_controller.rb +85 -0
  136. data/lib/controllers/backend/solidus_promotions/admin/conditions_controller.rb +69 -0
  137. data/lib/controllers/backend/solidus_promotions/admin/promotion_categories_controller.rb +13 -0
  138. data/lib/controllers/backend/solidus_promotions/admin/promotion_code_batches_controller.rb +42 -0
  139. data/lib/controllers/backend/solidus_promotions/admin/promotion_codes_controller.rb +58 -0
  140. data/lib/controllers/backend/solidus_promotions/admin/promotions_controller.rb +65 -0
  141. data/lib/generators/solidus_promotions/install/install_generator.rb +56 -0
  142. data/lib/generators/solidus_promotions/install/templates/initializer.rb +6 -0
  143. data/lib/solidus_promotions/configuration.rb +125 -0
  144. data/lib/solidus_promotions/engine.rb +115 -0
  145. data/lib/solidus_promotions/migrate_adjustments.rb +62 -0
  146. data/lib/solidus_promotions/migrate_order_promotions.rb +73 -0
  147. data/lib/solidus_promotions/promotion_map.rb +126 -0
  148. data/lib/solidus_promotions/promotion_migrator.rb +103 -0
  149. data/lib/solidus_promotions/testing_support/factories/completed_order_with_solidus_promotion_factory.rb +20 -0
  150. data/lib/solidus_promotions/testing_support/factories/solidus_order_promotion_factory.rb +8 -0
  151. data/lib/solidus_promotions/testing_support/factories/solidus_promotion_category_factory.rb +7 -0
  152. data/lib/solidus_promotions/testing_support/factories/solidus_promotion_code_factory.rb +8 -0
  153. data/lib/solidus_promotions/testing_support/factories/solidus_promotion_factory.rb +65 -0
  154. data/lib/solidus_promotions/testing_support/factories/solidus_shipping_rate_discount_factory.rb +14 -0
  155. data/lib/solidus_promotions/testing_support/factory_bot.rb +30 -0
  156. data/lib/solidus_promotions.rb +37 -0
  157. data/lib/tasks/solidus_promotions/migrate_adjustments.rake +17 -0
  158. data/lib/tasks/solidus_promotions/migrate_existing_promotions.rake +12 -0
  159. data/lib/tasks/solidus_promotions/migrate_order_promotions.rake +17 -0
  160. data/lib/views/backend/solidus_promotions/admin/benefit_fields/_adjust_line_item.html.erb +6 -0
  161. data/lib/views/backend/solidus_promotions/admin/benefit_fields/_adjust_line_item_quantity_groups.html.erb +13 -0
  162. data/lib/views/backend/solidus_promotions/admin/benefit_fields/_adjust_shipment.html.erb +6 -0
  163. data/lib/views/backend/solidus_promotions/admin/benefit_fields/_calculator_fields.erb +11 -0
  164. data/lib/views/backend/solidus_promotions/admin/benefit_fields/_create_discounted_item.html.erb +21 -0
  165. data/lib/views/backend/solidus_promotions/admin/benefits/_benefit.html.erb +50 -0
  166. data/lib/views/backend/solidus_promotions/admin/benefits/_calculator_select.html.erb +16 -0
  167. data/lib/views/backend/solidus_promotions/admin/benefits/_form.html.erb +3 -0
  168. data/lib/views/backend/solidus_promotions/admin/benefits/_new_benefit.html.erb +33 -0
  169. data/lib/views/backend/solidus_promotions/admin/benefits/_type_select.html.erb +16 -0
  170. data/lib/views/backend/solidus_promotions/admin/benefits/edit.html.erb +19 -0
  171. data/lib/views/backend/solidus_promotions/admin/benefits/new.html.erb +1 -0
  172. data/lib/views/backend/solidus_promotions/admin/calculator_fields/_default_fields.html.erb +6 -0
  173. data/lib/views/backend/solidus_promotions/admin/calculator_fields/distributed_amount/_fields.html.erb +18 -0
  174. data/lib/views/backend/solidus_promotions/admin/calculator_fields/flat_rate/_fields.html.erb +6 -0
  175. data/lib/views/backend/solidus_promotions/admin/calculator_fields/flexi_rate/_fields.html.erb +24 -0
  176. data/lib/views/backend/solidus_promotions/admin/calculator_fields/percent/_fields.html.erb +6 -0
  177. data/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_flat_rate/_fields.html.erb +32 -0
  178. data/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_flat_rate/_tier_fields.html.erb +31 -0
  179. data/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_percent/_fields.html.erb +47 -0
  180. data/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_percent/_tier_fields.html.erb +33 -0
  181. data/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_percent_on_eligible_item_quantity/_fields.html.erb +36 -0
  182. data/lib/views/backend/solidus_promotions/admin/calculator_fields/tiered_percent_on_eligible_item_quantity/_tier_fields.html.erb +35 -0
  183. data/lib/views/backend/solidus_promotions/admin/condition_fields/_first_order.html.erb +3 -0
  184. data/lib/views/backend/solidus_promotions/admin/condition_fields/_first_repeat_purchase_since.html.erb +7 -0
  185. data/lib/views/backend/solidus_promotions/admin/condition_fields/_item_total.html.erb +15 -0
  186. data/lib/views/backend/solidus_promotions/admin/condition_fields/_line_item_option_value.html.erb +25 -0
  187. data/lib/views/backend/solidus_promotions/admin/condition_fields/_line_item_product.html.erb +21 -0
  188. data/lib/views/backend/solidus_promotions/admin/condition_fields/_line_item_taxon.html.erb +17 -0
  189. data/lib/views/backend/solidus_promotions/admin/condition_fields/_minimum_quantity.html.erb +5 -0
  190. data/lib/views/backend/solidus_promotions/admin/condition_fields/_nth_order.html.erb +15 -0
  191. data/lib/views/backend/solidus_promotions/admin/condition_fields/_one_use_per_user.html.erb +3 -0
  192. data/lib/views/backend/solidus_promotions/admin/condition_fields/_option_value.html.erb +63 -0
  193. data/lib/views/backend/solidus_promotions/admin/condition_fields/_product.html.erb +27 -0
  194. data/lib/views/backend/solidus_promotions/admin/condition_fields/_shipping_method.html.erb +10 -0
  195. data/lib/views/backend/solidus_promotions/admin/condition_fields/_store.html.erb +9 -0
  196. data/lib/views/backend/solidus_promotions/admin/condition_fields/_taxon.html.erb +27 -0
  197. data/lib/views/backend/solidus_promotions/admin/condition_fields/_user.html.erb +7 -0
  198. data/lib/views/backend/solidus_promotions/admin/condition_fields/_user_logged_in.html.erb +3 -0
  199. data/lib/views/backend/solidus_promotions/admin/condition_fields/_user_role.html.erb +15 -0
  200. data/lib/views/backend/solidus_promotions/admin/condition_fields/line_item_option_value/_option_value_fields.html.erb +21 -0
  201. data/lib/views/backend/solidus_promotions/admin/conditions/_condition.html.erb +26 -0
  202. data/lib/views/backend/solidus_promotions/admin/conditions/_new_condition.html.erb +26 -0
  203. data/lib/views/backend/solidus_promotions/admin/conditions/_type_select.html.erb +20 -0
  204. data/lib/views/backend/solidus_promotions/admin/conditions/new.html.erb +1 -0
  205. data/lib/views/backend/solidus_promotions/admin/promotion_categories/_form.html.erb +14 -0
  206. data/lib/views/backend/solidus_promotions/admin/promotion_categories/edit.html.erb +10 -0
  207. data/lib/views/backend/solidus_promotions/admin/promotion_categories/index.html.erb +47 -0
  208. data/lib/views/backend/solidus_promotions/admin/promotion_categories/new.html.erb +10 -0
  209. data/lib/views/backend/solidus_promotions/admin/promotion_code_batches/_form_fields.html.erb +22 -0
  210. data/lib/views/backend/solidus_promotions/admin/promotion_code_batches/download.csv.ruby +8 -0
  211. data/lib/views/backend/solidus_promotions/admin/promotion_code_batches/index.html.erb +65 -0
  212. data/lib/views/backend/solidus_promotions/admin/promotion_code_batches/new.html.erb +8 -0
  213. data/lib/views/backend/solidus_promotions/admin/promotion_codes/index.csv.ruby +8 -0
  214. data/lib/views/backend/solidus_promotions/admin/promotion_codes/index.html.erb +32 -0
  215. data/lib/views/backend/solidus_promotions/admin/promotion_codes/new.html.erb +31 -0
  216. data/lib/views/backend/solidus_promotions/admin/promotions/_form.html.erb +124 -0
  217. data/lib/views/backend/solidus_promotions/admin/promotions/_table.html.erb +57 -0
  218. data/lib/views/backend/solidus_promotions/admin/promotions/_table_filter.html.erb +78 -0
  219. data/lib/views/backend/solidus_promotions/admin/promotions/edit.html.erb +41 -0
  220. data/lib/views/backend/solidus_promotions/admin/promotions/index.html.erb +23 -0
  221. data/lib/views/backend/solidus_promotions/admin/promotions/new.html.erb +9 -0
  222. data/lib/views/backend/solidus_promotions/admin/shared/_number_with_currency.html.erb +20 -0
  223. data/lib/views/backend/solidus_promotions/admin/shared/_promotion_sub_menu.html.erb +14 -0
  224. data/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_boolean.html.erb +14 -0
  225. data/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_decimal.html.erb +12 -0
  226. data/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_encrypted_string.html.erb +12 -0
  227. data/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_integer.html.erb +12 -0
  228. data/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_password.html.erb +12 -0
  229. data/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_string.html.erb +12 -0
  230. data/lib/views/backend/solidus_promotions/admin/shared/preference_fields/_text.html.erb +12 -0
  231. data/lib/views/backend/solidus_promotions/promotion_code_batch_mailer/promotion_code_batch_errored.text.erb +2 -0
  232. data/lib/views/backend/solidus_promotions/promotion_code_batch_mailer/promotion_code_batch_finished.text.erb +2 -0
  233. data/solidus_promotions.gemspec +33 -0
  234. metadata +366 -0
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ module Conditions
5
+ class LineItemTaxon < Condition
6
+ include LineItemLevelCondition
7
+
8
+ has_many :condition_taxons, class_name: "SolidusPromotions::ConditionTaxon", foreign_key: :condition_id,
9
+ dependent: :destroy
10
+ has_many :taxons, through: :condition_taxons, class_name: "Spree::Taxon"
11
+
12
+ MATCH_POLICIES = %w[include exclude].freeze
13
+
14
+ validates :preferred_match_policy, inclusion: { in: MATCH_POLICIES }
15
+
16
+ preference :match_policy, :string, default: MATCH_POLICIES.first
17
+
18
+ def preload_relations
19
+ [:taxons]
20
+ end
21
+
22
+ def eligible?(line_item, _options = {})
23
+ found = Spree::Classification.where(
24
+ product_id: line_item.variant.product_id,
25
+ taxon_id: condition_taxon_ids_with_children
26
+ ).exists?
27
+
28
+ case preferred_match_policy
29
+ when "include"
30
+ found
31
+ when "exclude"
32
+ !found
33
+ else
34
+ raise "unexpected match policy: #{preferred_match_policy.inspect}"
35
+ end
36
+ end
37
+
38
+ def taxon_ids_string
39
+ taxons.pluck(:id).join(",")
40
+ end
41
+
42
+ def taxon_ids_string=(taxon_ids)
43
+ taxon_ids = taxon_ids.to_s.split(",").map(&:strip)
44
+ self.taxons = Spree::Taxon.find(taxon_ids)
45
+ end
46
+
47
+ def updateable?
48
+ true
49
+ end
50
+
51
+ private
52
+
53
+ # ids of taxons conditions and taxons conditions children
54
+ def condition_taxon_ids_with_children
55
+ taxons.flat_map { |taxon| taxon.self_and_descendants.ids }.uniq
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ module Conditions
5
+ # Promotion condition for ensuring an order contains a minimum quantity of
6
+ # applicable items.
7
+ #
8
+ # This promotion condition is only compatible with the "all" match policy. It
9
+ # doesn't make a lot of sense to use it without that policy as it reduces
10
+ # it to a simple quantity check across the entire order which would be
11
+ # better served by an item total condition.
12
+ class MinimumQuantity < Condition
13
+ include OrderLevelCondition
14
+
15
+ validates :preferred_minimum_quantity, numericality: { only_integer: true, greater_than: 0 }
16
+
17
+ preference :minimum_quantity, :integer, default: 1
18
+
19
+ # Will look at all of the "applicable" line items in the order and
20
+ # determine if the sum of their quantity is greater than the minimum.
21
+ #
22
+ # "Applicable" items are ones that pass all eligibility checks of applicable conditions.
23
+ #
24
+ # When false is returned, the reason will be included in the
25
+ # `eligibility_errors` object.
26
+ #
27
+ # @param order [Spree::Order] the order we want to check eligibility on
28
+ # @return [Boolean] true if promotion is eligible, false otherwise
29
+ def eligible?(order)
30
+ if benefit.applicable_line_items(order).sum(&:quantity) < preferred_minimum_quantity
31
+ eligibility_errors.add(
32
+ :base,
33
+ eligibility_error_message(:quantity_less_than_minimum, count: preferred_minimum_quantity),
34
+ error_code: :quantity_less_than_minimum
35
+ )
36
+ end
37
+
38
+ eligibility_errors.empty?
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ module Conditions
5
+ class NthOrder < Condition
6
+ include OrderLevelCondition
7
+
8
+ preference :nth_order, :integer, default: 2
9
+ # It does not make sense to have this apply to the first order using preferred_nth_order == 1
10
+ # Instead we could use the first_order condition
11
+ validates :preferred_nth_order, numericality: { only_integer: true, greater_than: 1 }
12
+
13
+ # This is never eligible if the order does not have a user, and that user does not have any previous completed orders.
14
+ #
15
+ # Use the first order condition if you want a promotion to be applied to the first order for a user.
16
+ # @param order [Spree::Order]
17
+ def eligible?(order, _options = {})
18
+ return false unless order.user
19
+
20
+ nth_order?(order)
21
+ end
22
+
23
+ private
24
+
25
+ def completed_order_count(order)
26
+ order
27
+ .user
28
+ .orders
29
+ .complete
30
+ .where(Spree::Order.arel_table[:completed_at].lt(order.completed_at || Time.current))
31
+ .count
32
+ end
33
+
34
+ def nth_order?(order)
35
+ count = completed_order_count(order) + 1
36
+ count == preferred_nth_order
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ module Conditions
5
+ class OneUsePerUser < Condition
6
+ include OrderLevelCondition
7
+
8
+ def eligible?(order, _options = {})
9
+ if order.user.present?
10
+ if promotion.used_by?(order.user, [order])
11
+ eligibility_errors.add(
12
+ :base,
13
+ eligibility_error_message(:limit_once_per_user),
14
+ error_code: :limit_once_per_user
15
+ )
16
+ end
17
+ else
18
+ eligibility_errors.add(:base, eligibility_error_message(:no_user_specified), error_code: :no_user_specified)
19
+ end
20
+
21
+ eligibility_errors.empty?
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ module Conditions
5
+ class OptionValue < Condition
6
+ include LineItemApplicableOrderLevelCondition
7
+
8
+ preference :eligible_values, :hash
9
+
10
+ def order_eligible?(order)
11
+ order.line_items.any? { |item| line_item_eligible?(item) }
12
+ end
13
+
14
+ def line_item_eligible?(line_item)
15
+ LineItemOptionValue.new(preferred_eligible_values: preferred_eligible_values).eligible?(line_item)
16
+ end
17
+
18
+ def preferred_eligible_values
19
+ values = preferences[:eligible_values] || {}
20
+ values.keys.map(&:to_i).zip(
21
+ values.values.map do |value|
22
+ (value.is_a?(Array) ? value : value.split(",")).map(&:to_i)
23
+ end
24
+ ).to_h
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ module Conditions
5
+ # A condition to limit a promotion based on products in the order. Can
6
+ # require all or any of the products to be present. Valid products
7
+ # either come from assigned product group or are assingned directly to
8
+ # the condition.
9
+ class Product < Condition
10
+ include LineItemApplicableOrderLevelCondition
11
+
12
+ has_many :condition_products,
13
+ dependent: :destroy,
14
+ foreign_key: :condition_id,
15
+ class_name: "SolidusPromotions::ConditionProduct"
16
+ has_many :products, class_name: "Spree::Product", through: :condition_products
17
+
18
+ def preload_relations
19
+ [:products]
20
+ end
21
+
22
+ MATCH_POLICIES = %w[any all none only].freeze
23
+
24
+ validates :preferred_match_policy, inclusion: { in: MATCH_POLICIES }
25
+
26
+ preference :match_policy, :string, default: MATCH_POLICIES.first
27
+
28
+ # scope/association that is used to test eligibility
29
+ def eligible_products
30
+ products
31
+ end
32
+
33
+ def order_eligible?(order)
34
+ return true if eligible_products.empty?
35
+
36
+ case preferred_match_policy
37
+ when "all"
38
+ unless eligible_products.all? { |product| order_products(order).include?(product) }
39
+ eligibility_errors.add(:base, eligibility_error_message(:missing_product), error_code: :missing_product)
40
+ end
41
+ when "any"
42
+ unless order_products(order).any? { |product| eligible_products.include?(product) }
43
+ eligibility_errors.add(:base, eligibility_error_message(:no_applicable_products),
44
+ error_code: :no_applicable_products)
45
+ end
46
+ when "none"
47
+ unless order_products(order).none? { |product| eligible_products.include?(product) }
48
+ eligibility_errors.add(:base, eligibility_error_message(:has_excluded_product),
49
+ error_code: :has_excluded_product)
50
+ end
51
+ when "only"
52
+ unless order_products(order).all? { |product| eligible_products.include?(product) }
53
+ eligibility_errors.add(:base, eligibility_error_message(:has_excluded_product),
54
+ error_code: :has_excluded_product)
55
+ end
56
+ else
57
+ raise "unexpected match policy: #{preferred_match_policy.inspect}"
58
+ end
59
+
60
+ eligibility_errors.empty?
61
+ end
62
+
63
+ def line_item_eligible?(line_item, _options = {})
64
+ # The order level eligibility check happens first, and if none of the products
65
+ # are in the order, then no line items should be available to check.
66
+ raise "This should not happen" if preferred_match_policy == "none"
67
+ product_ids.include?(line_item.variant.product_id)
68
+ end
69
+
70
+ def product_ids_string
71
+ product_ids.join(",")
72
+ end
73
+
74
+ def product_ids_string=(product_ids)
75
+ self.product_ids = product_ids.to_s.split(",").map(&:strip)
76
+ end
77
+
78
+ private
79
+
80
+ def order_products(order)
81
+ order.line_items.map(&:variant).map(&:product)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ module Conditions
5
+ class ShippingMethod < Condition
6
+ include ShipmentLevelCondition
7
+
8
+ preference :shipping_method_ids, type: :array, default: []
9
+
10
+ def applicable?(promotable)
11
+ promotable.is_a?(Spree::Shipment) || promotable.is_a?(Spree::ShippingRate)
12
+ end
13
+
14
+ def eligible?(promotable)
15
+ promotable.shipping_method&.id&.in?(preferred_shipping_method_ids.map(&:to_i))
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ module Conditions
5
+ class Store < Condition
6
+ include OrderLevelCondition
7
+
8
+ has_many :condition_stores, class_name: "SolidusPromotions::ConditionStore",
9
+ foreign_key: :condition_id,
10
+ dependent: :destroy
11
+ has_many :stores, through: :condition_stores, class_name: "Spree::Store"
12
+
13
+ def preload_relations
14
+ [:stores]
15
+ end
16
+
17
+ def eligible?(order, _options = {})
18
+ stores.none? || stores.include?(order.store)
19
+ end
20
+
21
+ def updateable?
22
+ true
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ module Conditions
5
+ class Taxon < Condition
6
+ include LineItemApplicableOrderLevelCondition
7
+
8
+ has_many :condition_taxons, class_name: "SolidusPromotions::ConditionTaxon", foreign_key: :condition_id,
9
+ dependent: :destroy
10
+ has_many :taxons, through: :condition_taxons, class_name: "Spree::Taxon"
11
+
12
+ def preload_relations
13
+ [:taxons]
14
+ end
15
+
16
+ MATCH_POLICIES = %w[any all none].freeze
17
+
18
+ validates :preferred_match_policy, inclusion: { in: MATCH_POLICIES }
19
+
20
+ preference :match_policy, :string, default: MATCH_POLICIES.first
21
+
22
+ def order_eligible?(order)
23
+ order_taxons = taxons_in_order(order)
24
+
25
+ case preferred_match_policy
26
+ when "all"
27
+ matches_all = taxons.all? do |condition_taxon|
28
+ order_taxons.where(id: condition_taxon.self_and_descendants.ids).exists?
29
+ end
30
+
31
+ unless matches_all
32
+ eligibility_errors.add(:base, eligibility_error_message(:missing_taxon), error_code: :missing_taxon)
33
+ end
34
+ when "any"
35
+ unless order_taxons.where(id: condition_taxon_ids_with_children).exists?
36
+ eligibility_errors.add(
37
+ :base,
38
+ eligibility_error_message(:no_matching_taxons),
39
+ error_code: :no_matching_taxons
40
+ )
41
+ end
42
+ when "none"
43
+ if order_taxons.where(id: condition_taxon_ids_with_children).exists?
44
+ eligibility_errors.add(
45
+ :base,
46
+ eligibility_error_message(:has_excluded_taxon),
47
+ error_code: :has_excluded_taxon
48
+ )
49
+ end
50
+ else
51
+ raise "unexpected match policy: #{preferred_match_policy.inspect}"
52
+ end
53
+
54
+ eligibility_errors.empty?
55
+ end
56
+
57
+ def line_item_eligible?(line_item)
58
+ # The order level eligibility check happens first, and if none of the taxons
59
+ # are in the order, then no line items should be available to check.
60
+ raise "This should not happen" if preferred_match_policy == "none"
61
+
62
+ raise "unexpected match policy: #{preferred_match_policy.inspect}" unless preferred_match_policy.in?(MATCH_POLICIES)
63
+
64
+ Spree::Classification.where(
65
+ product_id: line_item.variant.product_id,
66
+ taxon_id: condition_taxon_ids_with_children
67
+ ).exists?
68
+ end
69
+
70
+ def taxon_ids_string
71
+ taxon_ids.join(",")
72
+ end
73
+
74
+ def taxon_ids_string=(taxon_ids)
75
+ self.taxon_ids = taxon_ids.to_s.split(",").map(&:strip)
76
+ end
77
+
78
+ def updateable?
79
+ true
80
+ end
81
+
82
+ private
83
+
84
+ # All taxons in an order
85
+ def taxons_in_order(order)
86
+ Spree::Taxon
87
+ .joins(products: { variants_including_master: :line_items })
88
+ .where(spree_line_items: { order_id: order.id })
89
+ .distinct
90
+ end
91
+
92
+ # ids of taxons conditions and taxons conditions children
93
+ def condition_taxon_ids_with_children
94
+ taxons.flat_map { |taxon| taxon.self_and_descendants.ids }.uniq
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ module Conditions
5
+ class User < Condition
6
+ include OrderLevelCondition
7
+
8
+ has_many :condition_users,
9
+ class_name: "SolidusPromotions::ConditionUser",
10
+ foreign_key: :condition_id,
11
+ dependent: :destroy
12
+ has_many :users, through: :condition_users, class_name: Spree::UserClassHandle.new
13
+
14
+ def preload_relations
15
+ [:users]
16
+ end
17
+
18
+ def eligible?(order, _options = {})
19
+ users.include?(order.user)
20
+ end
21
+
22
+ def user_ids_string
23
+ user_ids.join(",")
24
+ end
25
+
26
+ def user_ids_string=(user_ids)
27
+ self.user_ids = user_ids.to_s.split(",").map(&:strip)
28
+ end
29
+
30
+ def updateable?
31
+ true
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ module Conditions
5
+ class UserLoggedIn < Condition
6
+ include OrderLevelCondition
7
+
8
+ def eligible?(order, _options = {})
9
+ if order.user.blank?
10
+ eligibility_errors.add(:base, eligibility_error_message(:no_user_specified), error_code: :no_user_specified)
11
+ end
12
+ eligibility_errors.empty?
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ module Conditions
5
+ class UserRole < Condition
6
+ include OrderLevelCondition
7
+
8
+ preference :role_ids, :array, default: []
9
+
10
+ MATCH_POLICIES = %w[any all].freeze
11
+ preference :match_policy, default: MATCH_POLICIES.first
12
+
13
+ def eligible?(order, _options = {})
14
+ return false unless order.user
15
+
16
+ if all_match_policy?
17
+ match_all_roles?(order)
18
+ else
19
+ match_any_roles?(order)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def all_match_policy?
26
+ preferred_match_policy == "all" && preferred_role_ids.present?
27
+ end
28
+
29
+ def user_roles(order)
30
+ order.user.spree_roles.where(id: preferred_role_ids)
31
+ end
32
+
33
+ def match_all_roles?(order)
34
+ user_roles(order).count == preferred_role_ids.count
35
+ end
36
+
37
+ def match_any_roles?(order)
38
+ user_roles(order).exists?
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
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 allocated_amounts
36
+ total_amount.to_money.allocate(elligible_amounts).map(&:to_money)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ EligibilityResult = Struct.new(:item, :condition, :success, :code, :message, keyword_init: true)
5
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ class EligibilityResults
5
+ include Enumerable
6
+ attr_reader :results, :promotion
7
+
8
+ def initialize(promotion)
9
+ @promotion = promotion
10
+ @results = []
11
+ end
12
+
13
+ def add(item:, condition:, success:, code:, message:)
14
+ results << EligibilityResult.new(
15
+ item: item,
16
+ condition: condition,
17
+ success: success,
18
+ code: code,
19
+ message: message
20
+ )
21
+ end
22
+
23
+ def success?
24
+ return true if results.empty?
25
+ promotion.benefits.any? do |benefit|
26
+ benefit.conditions.all? do |condition|
27
+ results_for_condition = results.select { |result| result.condition == condition }
28
+ results_for_condition.any?(&:success)
29
+ end
30
+ end
31
+ end
32
+
33
+ def error_messages
34
+ return [] if results.empty?
35
+ results.group_by(&:condition).map do |_condition, results|
36
+ next if results.any?(&:success)
37
+ results.detect { |r| !r.success }&.message
38
+ end.compact
39
+ end
40
+
41
+ def each(&block)
42
+ results.each(&block)
43
+ end
44
+
45
+ delegate :last, to: :results
46
+ end
47
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
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