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,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