solidus_promotions 4.4.0

Sign up to get free protection for your applications and to get access to all the features.
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,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
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_solidus_order_promotions
14
+ sync_solidus_order_promotions_to_spree_order_promotions
15
+ end
16
+
17
+ private
18
+
19
+ def sync_spree_order_promotions_to_solidus_order_promotions
20
+ order.order_promotions.each do |spree_order_promotion|
21
+ solidus_promotion = SolidusPromotions::Promotion.find_by(
22
+ original_promotion_id: spree_order_promotion.promotion.id
23
+ )
24
+ next unless solidus_promotion
25
+ if spree_order_promotion.promotion_code
26
+ solidus_promotion_code = solidus_promotion.codes.find_by(
27
+ value: spree_order_promotion.promotion_code.value
28
+ )
29
+ end
30
+ order.solidus_order_promotions.find_or_create_by!(
31
+ promotion: solidus_promotion,
32
+ promotion_code: solidus_promotion_code
33
+ )
34
+ end
35
+ end
36
+
37
+ def sync_solidus_order_promotions_to_spree_order_promotions
38
+ order.solidus_order_promotions.each do |solidus_order_promotion|
39
+ spree_promotion = solidus_order_promotion.promotion.original_promotion
40
+ next unless spree_promotion
41
+ if solidus_order_promotion.promotion_code
42
+ spree_promotion_code = spree_promotion.promotion_codes.find_by(
43
+ value: solidus_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,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ class OrderAdjuster
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,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ class OrderAdjuster
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
+ SolidusPromotions::Promotion.ordered_lanes.each_key do |lane|
18
+ lane_promotions = promotions.select { |promotion| promotion.lane == lane }
19
+ lane_benefits = eligible_benefits_for_promotable(lane_promotions.flat_map(&:benefits), order)
20
+ perform_order_benefits(lane_benefits, lane) unless dry_run
21
+ line_item_discounts = adjust_line_items(lane_benefits)
22
+ shipment_discounts = adjust_shipments(lane_benefits)
23
+ shipping_rate_discounts = adjust_shipping_rates(lane_benefits)
24
+ (line_item_discounts + shipment_discounts + shipping_rate_discounts).each do |item, chosen_discounts|
25
+ item.current_discounts.concat(chosen_discounts)
26
+ end
27
+ end
28
+
29
+ order
30
+ end
31
+
32
+ private
33
+
34
+ def perform_order_benefits(lane_benefits, lane)
35
+ lane_benefits.select { |benefit| benefit.level == :order }.each do |benefit|
36
+ benefit.perform(order)
37
+ end
38
+
39
+ automated_line_items = order.line_items.select(&:managed_by_order_benefit)
40
+ return if automated_line_items.empty?
41
+
42
+ ineligible_line_items = automated_line_items.select do |line_item|
43
+ line_item.managed_by_order_benefit.promotion.lane == lane && !line_item.managed_by_order_benefit.in?(lane_benefits)
44
+ end
45
+
46
+ ineligible_line_items.each do |line_item|
47
+ line_item.managed_by_order_benefit.remove_from(order)
48
+ end
49
+ end
50
+
51
+ def adjust_line_items(benefits)
52
+ order.discountable_line_items.select do |line_item|
53
+ line_item.variant.product.promotionable?
54
+ end.map do |line_item|
55
+ discounts = generate_discounts(benefits, line_item)
56
+ chosen_item_discounts = SolidusPromotions.config.discount_chooser_class.new(discounts).call
57
+ [line_item, chosen_item_discounts]
58
+ end
59
+ end
60
+
61
+ def adjust_shipments(benefits)
62
+ order.shipments.map do |shipment|
63
+ discounts = generate_discounts(benefits, shipment)
64
+ chosen_item_discounts = SolidusPromotions.config.discount_chooser_class.new(discounts).call
65
+ [shipment, chosen_item_discounts]
66
+ end
67
+ end
68
+
69
+ def adjust_shipping_rates(benefits)
70
+ order.shipments.flat_map(&:shipping_rates).select(&:cost).map do |rate|
71
+ discounts = generate_discounts(benefits, rate)
72
+ chosen_item_discounts = SolidusPromotions.config.discount_chooser_class.new(discounts).call
73
+ [rate, chosen_item_discounts]
74
+ end
75
+ end
76
+
77
+ def eligible_benefits_for_promotable(possible_benefits, promotable)
78
+ possible_benefits.select do |candidate|
79
+ candidate.eligible_by_applicable_conditions?(promotable, dry_run: dry_run)
80
+ end
81
+ end
82
+
83
+ def generate_discounts(possible_benefits, item)
84
+ eligible_benefits = eligible_benefits_for_promotable(possible_benefits, item)
85
+ eligible_benefits.select do |benefit|
86
+ benefit.can_discount?(item)
87
+ end.map do |benefit|
88
+ benefit.discount(item)
89
+ end.compact
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ class OrderAdjuster
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(&:benefits).group_by(&:preload_relations).each do |benefit_preload_relations, benefits|
15
+ preload(records: benefits, associations: benefit_preload_relations)
16
+ benefits.flat_map(&:conditions).group_by(&:preload_relations).each do |condition_preload_relations, conditions|
17
+ preload(records: conditions, associations: condition_preload_relations)
18
+ end
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.solidus_order_promotions.select do |order_promotion|
33
+ order_promotion.promotion.kept? && (order_promotion.promotion_code.nil? || !order_promotion.promotion_code.usage_limit_exceeded?(excluded_orders: [order]))
34
+ end.map(&:promotion_id)
35
+ order.solidus_promotions.active(reference_time).where(id: eligible_connected_promotion_ids).includes(promotion_includes)
36
+ end
37
+
38
+ def sale_promotions
39
+ SolidusPromotions::Promotion.kept.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
+ benefits: :conditions
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ class OrderAdjuster
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
+ SolidusPromotions::ShippingRateDiscount.create!(
22
+ shipping_rate: shipping_rate,
23
+ amount: discount.amount,
24
+ label: discount.label,
25
+ benefit: discount.source
26
+ )
27
+ end
28
+ end
29
+ order.reset_current_discounts
30
+ order
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :order
36
+
37
+ # Walk through the discounts for an item and update adjustments for it. Once
38
+ # all of the discounts have been added as adjustments, remove any old promotion
39
+ # adjustments that weren't touched.
40
+ #
41
+ # @private
42
+ # @param [#adjustments] item a {Spree::LineItem} or {Spree::Shipment}
43
+ # @param [Array<SolidusPromotions::ItemDiscount>] item_discounts a list of calculated discounts for an item
44
+ # @return [void]
45
+ def update_adjustments(item, item_discounts)
46
+ promotion_adjustments = item.adjustments.select(&:promotion?)
47
+
48
+ active_adjustments = item_discounts.map do |item_discount|
49
+ update_adjustment(item, item_discount)
50
+ end
51
+ item.update(promo_total: active_adjustments.sum(&:amount))
52
+ # Remove any promotion adjustments tied to promotion benefits which no longer match.
53
+ unmatched_adjustments = promotion_adjustments - active_adjustments
54
+
55
+ item.adjustments.destroy(unmatched_adjustments)
56
+ end
57
+
58
+ # Update or create a new promotion adjustment on an item.
59
+ #
60
+ # @private
61
+ # @param [#adjustments] item a {Spree::LineItem} or {Spree::Shipment}
62
+ # @param [SolidusPromotions::ItemDiscount] discount_item calculated discounts for an item
63
+ # @return [Spree::Adjustment] the created or updated promotion adjustment
64
+ def update_adjustment(item, discount_item)
65
+ adjustment = item.adjustments.detect do |item_adjustment|
66
+ item_adjustment.source == discount_item.source
67
+ end
68
+
69
+ adjustment ||= item.adjustments.new(
70
+ source: discount_item.source,
71
+ order_id: item.is_a?(Spree::Order) ? item.id : item.order_id,
72
+ label: discount_item.label
73
+ )
74
+ adjustment.update!(amount: discount_item.amount)
75
+ adjustment
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ class OrderAdjuster
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 (!SolidusPromotions.config.recalculate_complete_orders && order.complete?) || order.shipped?
17
+
18
+ discounted_order = DiscountOrder.new(order, promotions, dry_run: dry_run).call
19
+
20
+ PersistDiscountedOrder.new(discounted_order).call unless dry_run
21
+
22
+ order.reset_current_discounts
23
+
24
+ unless dry_run
25
+ # Since automations might have added a line item, we need to recalculate item total and item count here.
26
+ order.item_total = order.line_items.sum(&:amount)
27
+ order.item_count = order.line_items.sum(&:quantity)
28
+ order.promo_total = (order.line_items + order.shipments).sum(&:promo_total)
29
+ end
30
+ order
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ # SolidusPromotions::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, -> { with_discarded }, class_name: "SolidusPromotions::Promotion"
11
+ belongs_to :promotion_code, class_name: "SolidusPromotions::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 SolidusPromotions
4
+ module PermissionSets
5
+ class SolidusPromotionManagement < Spree::PermissionSets::Base
6
+ def activate!
7
+ can :manage, SolidusPromotions::Promotion
8
+ can :manage, SolidusPromotions::Condition
9
+ can :manage, SolidusPromotions::Benefit
10
+ can :manage, SolidusPromotions::PromotionCategory
11
+ can :manage, SolidusPromotions::PromotionCode
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ class Promotion < Spree::Base
5
+ include Spree::SoftDeletable
6
+
7
+ belongs_to :category, class_name: "SolidusPromotions::PromotionCategory",
8
+ foreign_key: :promotion_category_id, optional: true
9
+ belongs_to :original_promotion, class_name: "Spree::Promotion", optional: true
10
+ has_many :benefits, class_name: "SolidusPromotions::Benefit", dependent: :destroy
11
+ has_many :conditions, through: :benefits
12
+ has_many :codes, class_name: "SolidusPromotions::PromotionCode", dependent: :destroy
13
+ has_many :code_batches, class_name: "SolidusPromotions::PromotionCodeBatch", dependent: :destroy
14
+ has_many :order_promotions, class_name: "SolidusPromotions::OrderPromotion", dependent: :destroy
15
+
16
+ validates :name, :customer_label, presence: true
17
+ validates :path, uniqueness: { allow_blank: true, case_sensitive: true }
18
+ validates :usage_limit, numericality: { greater_than: 0, allow_nil: true }
19
+ validates :per_code_usage_limit, numericality: { greater_than_or_equal_to: 0, allow_nil: true }
20
+ validates :description, length: { maximum: 255 }
21
+ validate :apply_automatically_disallowed_with_paths
22
+ validate :apply_automatically_disallowed_with_promotion_codes
23
+
24
+ before_save :normalize_blank_values
25
+ after_discard :delete_cart_connections
26
+
27
+ scope :active, ->(time = Time.current) { has_benefits.started_and_unexpired(time) }
28
+ scope :advertised, -> { where(advertise: true) }
29
+ scope :coupons, -> { joins(:codes).distinct }
30
+ scope :started_and_unexpired, ->(time = Time.current) do
31
+ table = arel_table
32
+
33
+ where(table[:starts_at].eq(nil).or(table[:starts_at].lt(time)))
34
+ .where(table[:expires_at].eq(nil).or(table[:expires_at].gt(time)))
35
+ end
36
+ scope :has_benefits, -> do
37
+ joins(:benefits).distinct
38
+ end
39
+
40
+ enum :lane, SolidusPromotions.config.preferred_lanes
41
+
42
+ def self.with_coupon_code(val)
43
+ joins(:codes).where(
44
+ SolidusPromotions::PromotionCode.arel_table[:value].eq(val.downcase)
45
+ ).first
46
+ end
47
+
48
+ def self.human_enum_name(enum_name, enum_value)
49
+ I18n.t("activerecord.attributes.#{model_name.i18n_key}.#{enum_name.to_s.pluralize}.#{enum_value}")
50
+ end
51
+
52
+ def self.lane_options
53
+ ordered_lanes.map do |lane_name, _index|
54
+ [human_enum_name(:lane, lane_name), lane_name]
55
+ end
56
+ end
57
+
58
+ def self.ordered_lanes
59
+ lanes.sort_by(&:last).to_h
60
+ end
61
+
62
+ self.allowed_ransackable_associations = ["codes"]
63
+ self.allowed_ransackable_attributes = %w[name customer_label path promotion_category_id lane updated_at]
64
+ self.allowed_ransackable_scopes = %i[active with_discarded]
65
+
66
+ # All orders that have been discounted using this promotion
67
+ def discounted_orders
68
+ Spree::Order
69
+ .joins(:all_adjustments)
70
+ .where(
71
+ spree_adjustments: {
72
+ source_type: "SolidusPromotions::Benefit",
73
+ source_id: benefits.map(&:id)
74
+ }
75
+ ).distinct
76
+ end
77
+
78
+ # Number of times the code has been used overall
79
+ #
80
+ # @param excluded_orders [Array<Spree::Order>] Orders to exclude from usage count
81
+ # @return [Integer] usage count
82
+ def usage_count(excluded_orders: [])
83
+ discounted_orders
84
+ .complete
85
+ .where.not(id: [excluded_orders.map(&:id)])
86
+ .where.not(spree_orders: { state: :canceled })
87
+ .count
88
+ end
89
+
90
+ def used_by?(user, excluded_orders = [])
91
+ discounted_orders
92
+ .complete
93
+ .where.not(id: excluded_orders.map(&:id))
94
+ .where(user: user)
95
+ .where.not(spree_orders: { state: :canceled })
96
+ .exists?
97
+ end
98
+
99
+ # Whether the promotion has exceeded its usage restrictions.
100
+ #
101
+ # @param excluded_orders [Array<Spree::Order>] Orders to exclude from usage limit
102
+ # @return true or false
103
+ def usage_limit_exceeded?(excluded_orders: [])
104
+ return unless usage_limit
105
+
106
+ usage_count(excluded_orders: excluded_orders) >= usage_limit
107
+ end
108
+
109
+ def not_expired?(time = Time.current)
110
+ !expired?(time)
111
+ end
112
+
113
+ def not_started?(time = Time.current)
114
+ !started?(time)
115
+ end
116
+
117
+ def started?(time = Time.current)
118
+ starts_at.nil? || starts_at < time
119
+ end
120
+
121
+ def active?(time = Time.current)
122
+ started?(time) && not_expired?(time) && benefits.present?
123
+ end
124
+
125
+ def inactive?(time = Time.current)
126
+ !active?(time)
127
+ end
128
+
129
+ def expired?(time = Time.current)
130
+ expires_at.present? && expires_at < time
131
+ end
132
+
133
+ def products
134
+ conditions.where(type: "SolidusPromotions::Conditions::Product").flat_map(&:products).uniq
135
+ end
136
+
137
+ def eligibility_results
138
+ @eligibility_results ||= SolidusPromotions::EligibilityResults.new(self)
139
+ end
140
+
141
+ private
142
+
143
+ def normalize_blank_values
144
+ self[:path] = nil if self[:path].blank?
145
+ end
146
+
147
+ def apply_automatically_disallowed_with_paths
148
+ return unless apply_automatically
149
+
150
+ errors.add(:apply_automatically, :disallowed_with_path) if path.present?
151
+ end
152
+
153
+ def apply_automatically_disallowed_with_promotion_codes
154
+ return unless apply_automatically
155
+
156
+ errors.add(:apply_automatically, :disallowed_with_promotion_codes) if codes.present?
157
+ end
158
+
159
+ def delete_cart_connections
160
+ order_promotions.where(order: Spree::Order.incomplete).destroy_all
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ class PromotionAdvertiser
5
+ def self.for_product(product)
6
+ promotion_ids = ConditionProduct.joins(condition: :benefit).where(product: product).select(:promotion_id).distinct
7
+ SolidusPromotions::Promotion.advertised.where(id: promotion_ids).reject(&:inactive?)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ class PromotionCategory < Spree::Base
5
+ has_many :promotions, dependent: :nullify
6
+
7
+ validates :name, presence: true
8
+ end
9
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ class PromotionCode < Spree::Base
5
+ class BatchBuilder
6
+ attr_reader :promotion_code_batch, :options
7
+
8
+ delegate :promotion, :number_of_codes, :base_code, to: :promotion_code_batch
9
+
10
+ DEFAULT_OPTIONS = {
11
+ random_code_length: 6,
12
+ batch_size: 1000,
13
+ sample_characters: ("a".."z").to_a + (2..9).to_a.map(&:to_s)
14
+ }.freeze
15
+
16
+ def initialize(promotion_code_batch, options = {})
17
+ @promotion_code_batch = promotion_code_batch
18
+ options.assert_valid_keys(*DEFAULT_OPTIONS.keys)
19
+ @options = DEFAULT_OPTIONS.merge(options)
20
+ end
21
+
22
+ def build_promotion_codes
23
+ generate_random_codes
24
+ promotion_code_batch.update!(state: "completed")
25
+ rescue StandardError => e
26
+ promotion_code_batch.update!(
27
+ error: e.inspect,
28
+ state: "failed"
29
+ )
30
+ raise e
31
+ end
32
+
33
+ private
34
+
35
+ def generate_random_codes
36
+ created_codes = promotion_code_batch.promotion_codes.count
37
+
38
+ batch_size = @options[:batch_size]
39
+
40
+ while created_codes < number_of_codes
41
+ max_codes_to_generate = [batch_size, number_of_codes - created_codes].min
42
+
43
+ new_codes = Array.new(max_codes_to_generate) { generate_random_code }.uniq
44
+ codes_for_current_batch = get_unique_codes(new_codes)
45
+
46
+ codes_for_current_batch.filter! do |value|
47
+ SolidusPromotions::PromotionCode.create!(
48
+ value: value,
49
+ promotion: promotion,
50
+ promotion_code_batch: promotion_code_batch
51
+ )
52
+ rescue ActiveRecord::RecordInvalid
53
+ false
54
+ end
55
+
56
+ created_codes += codes_for_current_batch.size
57
+ end
58
+ end
59
+
60
+ def generate_random_code
61
+ suffix = Array.new(@options[:random_code_length]) do
62
+ @options[:sample_characters].sample
63
+ end.join
64
+
65
+ "#{base_code}#{@promotion_code_batch.join_characters}#{suffix}"
66
+ end
67
+
68
+ def get_unique_codes(code_set)
69
+ code_set - PromotionCode.where(value: code_set.to_a).pluck(:value)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusPromotions
4
+ class PromotionCode < Spree::Base
5
+ belongs_to :promotion, -> { with_discarded }, inverse_of: :codes
6
+ belongs_to :promotion_code_batch, inverse_of: :promotion_codes, optional: true
7
+
8
+ has_many :order_promotions, class_name: "SolidusPromotions::OrderPromotion", dependent: :destroy
9
+
10
+ before_validation :normalize_code
11
+
12
+ validates :value, presence: true, uniqueness: { allow_blank: true, case_sensitive: true }
13
+ validate :promotion_not_apply_automatically, on: :create
14
+
15
+ self.allowed_ransackable_attributes = ["value"]
16
+
17
+ # Whether the promotion code has exceeded its usage restrictions
18
+ #
19
+ # @param excluded_orders [Array<Spree::Order>] Orders to exclude from usage limit
20
+ # @return true or false
21
+ def usage_limit_exceeded?(excluded_orders: [])
22
+ return unless usage_limit
23
+
24
+ usage_count(excluded_orders: excluded_orders) >= usage_limit
25
+ end
26
+
27
+ # Number of times the code has been used overall
28
+ #
29
+ # @param excluded_orders [Array<Spree::Order>] Orders to exclude from usage count
30
+ # @return [Integer] usage count
31
+ def usage_count(excluded_orders: [])
32
+ promotion
33
+ .discounted_orders
34
+ .complete
35
+ .where.not(spree_orders: { state: :canceled })
36
+ .joins(:solidus_order_promotions)
37
+ .where(SolidusPromotions::OrderPromotion.table_name => { promotion_code_id: id })
38
+ .where.not(id: excluded_orders.map(&:id))
39
+ .count
40
+ end
41
+
42
+ def usage_limit
43
+ promotion.per_code_usage_limit
44
+ end
45
+
46
+ def promotion_not_apply_automatically
47
+ errors.add(:base, :disallowed_with_apply_automatically) if promotion.apply_automatically
48
+ end
49
+
50
+ private
51
+
52
+ def normalize_code
53
+ self.value = value.downcase.strip
54
+ end
55
+ end
56
+ end