solidus_legacy_promotions 4.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (179) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +345 -0
  3. data/Rakefile +43 -0
  4. data/app/assets/config/solidus_legacy_promotions/manifest.js +2 -0
  5. data/app/assets/javascripts/spree/backend/edit_promotion.js +7 -0
  6. data/app/assets/javascripts/spree/backend/promotions/activation.js +26 -0
  7. data/app/assets/javascripts/spree/backend/promotions.js +35 -0
  8. data/app/assets/javascripts/spree/backend/templates/promotions/calculators/fields/tiered_flat_rate.hbs +23 -0
  9. data/app/assets/javascripts/spree/backend/templates/promotions/calculators/fields/tiered_percent.hbs +23 -0
  10. data/app/assets/javascripts/spree/backend/templates/promotions/rules/option_values.hbs +12 -0
  11. data/app/assets/javascripts/spree/backend/views/calculators/tiered.js +54 -0
  12. data/app/assets/javascripts/spree/backend/views/order/details_adjustments.js +43 -0
  13. data/app/assets/javascripts/spree/backend/views/promotions/option_values_rule.js +79 -0
  14. data/app/assets/javascripts/spree/backend/views/promotions.js +3 -0
  15. data/app/assets/stylesheets/solidus_legacy_promotions/promotions/_edit.scss +138 -0
  16. data/app/assets/stylesheets/solidus_legacy_promotions/promotions.scss +3 -0
  17. data/app/assets/stylesheets/spree/backend/sections/_adjustments.scss +3 -0
  18. data/app/decorators/solidus_legacy_promotions/controllers/solidus_admin/solidus_admin_adjustments_controller_decorator.rb +20 -0
  19. data/app/decorators/solidus_legacy_promotions/lib/spree_order_state_machine_decorator.rb +18 -0
  20. data/app/decorators/solidus_legacy_promotions/models/spree_adjustment_decorator.rb +76 -0
  21. data/app/decorators/solidus_legacy_promotions/models/spree_calculator_returns_default_refund_amount_decorator.rb +13 -0
  22. data/app/decorators/solidus_legacy_promotions/models/spree_line_item_decorator.rb +11 -0
  23. data/app/decorators/solidus_legacy_promotions/models/spree_order_decorator.rb +28 -0
  24. data/app/decorators/solidus_legacy_promotions/models/spree_order_updater_decorator.rb +40 -0
  25. data/app/decorators/solidus_legacy_promotions/models/spree_product_decorator.rb +16 -0
  26. data/app/decorators/solidus_legacy_promotions/models/spree_promotion_code_batch_decorator.rb +16 -0
  27. data/app/decorators/solidus_legacy_promotions/models/spree_shipment_decorator.rb +13 -0
  28. data/app/helpers/spree/admin/promotions_helper.rb +15 -0
  29. data/app/helpers/spree/promotion_rules_helper.rb +12 -0
  30. data/app/jobs/spree/promotion_code_batch_job.rb +26 -0
  31. data/app/mailers/spree/promotion_code_batch_mailer.rb +15 -0
  32. data/app/models/spree/calculator/distributed_amount.rb +33 -0
  33. data/app/models/spree/calculator/flat_percent_item_total.rb +23 -0
  34. data/app/models/spree/calculator/flexi_rate.rb +22 -0
  35. data/app/models/spree/calculator/percent_on_line_item.rb +13 -0
  36. data/app/models/spree/calculator/tiered_flat_rate.rb +52 -0
  37. data/app/models/spree/calculator/tiered_percent.rb +62 -0
  38. data/app/models/spree/order_contents.rb +36 -0
  39. data/app/models/spree/order_promotion.rb +27 -0
  40. data/app/models/spree/permission_sets/promotion_display.rb +25 -0
  41. data/app/models/spree/permission_sets/promotion_management.rb +25 -0
  42. data/app/models/spree/promotion/actions/create_adjustment.rb +81 -0
  43. data/app/models/spree/promotion/actions/create_item_adjustments.rb +98 -0
  44. data/app/models/spree/promotion/actions/create_quantity_adjustments.rb +139 -0
  45. data/app/models/spree/promotion/actions/free_shipping.rb +59 -0
  46. data/app/models/spree/promotion/order_adjustments_recalculator.rb +92 -0
  47. data/app/models/spree/promotion/rules/first_order.rb +36 -0
  48. data/app/models/spree/promotion/rules/first_repeat_purchase_since.rb +36 -0
  49. data/app/models/spree/promotion/rules/item_total.rb +86 -0
  50. data/app/models/spree/promotion/rules/minimum_quantity.rb +59 -0
  51. data/app/models/spree/promotion/rules/nth_order.rb +45 -0
  52. data/app/models/spree/promotion/rules/one_use_per_user.rb +25 -0
  53. data/app/models/spree/promotion/rules/option_value.rb +50 -0
  54. data/app/models/spree/promotion/rules/product.rb +86 -0
  55. data/app/models/spree/promotion/rules/store.rb +26 -0
  56. data/app/models/spree/promotion/rules/taxon.rb +91 -0
  57. data/app/models/spree/promotion/rules/user.rb +34 -0
  58. data/app/models/spree/promotion/rules/user_logged_in.rb +20 -0
  59. data/app/models/spree/promotion/rules/user_role.rb +45 -0
  60. data/app/models/spree/promotion.rb +271 -0
  61. data/app/models/spree/promotion_action.rb +51 -0
  62. data/app/models/spree/promotion_advertiser.rb +10 -0
  63. data/app/models/spree/promotion_category.rb +8 -0
  64. data/app/models/spree/promotion_chooser.rb +34 -0
  65. data/app/models/spree/promotion_code/batch_builder.rb +68 -0
  66. data/app/models/spree/promotion_code.rb +54 -0
  67. data/app/models/spree/promotion_code_batch.rb +18 -0
  68. data/app/models/spree/promotion_finder.rb +9 -0
  69. data/app/models/spree/promotion_handler/cart.rb +75 -0
  70. data/app/models/spree/promotion_handler/coupon.rb +125 -0
  71. data/app/models/spree/promotion_handler/page.rb +26 -0
  72. data/app/models/spree/promotion_handler/shipping.rb +61 -0
  73. data/app/models/spree/promotion_rule.rb +55 -0
  74. data/app/models/spree/promotion_rule_store.rb +10 -0
  75. data/app/models/spree/promotion_rule_taxon.rb +8 -0
  76. data/app/models/spree/promotion_rule_user.rb +10 -0
  77. data/app/subscribers/spree/order_promotion_subscriber.rb +20 -0
  78. data/app/views/spree/order_mailer/cancel_email.html.erb +45 -0
  79. data/app/views/spree/order_mailer/cancel_email.text.erb +16 -0
  80. data/app/views/spree/order_mailer/confirm_email.html.erb +84 -0
  81. data/app/views/spree/order_mailer/confirm_email.text.erb +38 -0
  82. data/app/views/spree/promotion_code_batch_mailer/promotion_code_batch_errored.text.erb +2 -0
  83. data/app/views/spree/promotion_code_batch_mailer/promotion_code_batch_finished.text.erb +2 -0
  84. data/bin/rails +13 -0
  85. data/config/locales/en.yml +101 -0
  86. data/config/locales/promotion_categories.en.yml +6 -0
  87. data/config/locales/promotions.en.yml +6 -0
  88. data/config/routes.rb +28 -0
  89. data/db/migrate/20160101010001_solidus_one_four_promotions.rb +126 -0
  90. data/db/migrate/20161017102621_create_spree_promotion_code_batch.rb +48 -0
  91. data/db/migrate/20180202190713_create_promotion_rule_stores.rb +14 -0
  92. data/db/migrate/20180328172631_add_join_characters_to_promotion_code_batch.rb +15 -0
  93. data/db/migrate/20190106184413_remove_code_from_spree_promotions.rb +46 -0
  94. data/db/migrate/20220317165036_set_promotions_with_any_policy_to_all_if_possible.rb +20 -0
  95. data/db/migrate/20230322085416_remove_match_policy_from_spree_promotion.rb +7 -0
  96. data/db/migrate/20230325132905_remove_unused_columns_from_promotion_rules.rb +10 -0
  97. data/db/migrate/20231027084517_add_order_promotions_foreign_key.rb +14 -0
  98. data/db/migrate/20240621100123_add_promotion_code_id_to_spree_adjustments.rb +10 -0
  99. data/db/migrate/20240622113334_move_adjustment_eligible_to_legacy_promotions.rb +11 -0
  100. data/lib/components/admin/solidus_admin/orders/show/adjustments/index/source/spree_promotion_action/component.rb +17 -0
  101. data/lib/components/admin/solidus_admin/promotion_categories/index/component.rb +56 -0
  102. data/lib/components/admin/solidus_admin/promotions/index/component.rb +104 -0
  103. data/lib/components/admin/solidus_admin/promotions/index/component.yml +10 -0
  104. data/lib/components/admin/solidus_legacy_promotions/orders/index/component.rb +15 -0
  105. data/lib/components/admin/solidus_legacy_promotions/orders/index/component.yml +20 -0
  106. data/lib/controllers/admin/solidus_admin/promotion_categories_controller.rb +29 -0
  107. data/lib/controllers/admin/solidus_admin/promotions_controller.rb +46 -0
  108. data/lib/controllers/backend/spree/admin/promotion_actions_controller.rb +51 -0
  109. data/lib/controllers/backend/spree/admin/promotion_categories_controller.rb +8 -0
  110. data/lib/controllers/backend/spree/admin/promotion_code_batches_controller.rb +30 -0
  111. data/lib/controllers/backend/spree/admin/promotion_codes_controller.rb +48 -0
  112. data/lib/controllers/backend/spree/admin/promotion_rules_controller.rb +60 -0
  113. data/lib/controllers/backend/spree/admin/promotions_controller.rb +66 -0
  114. data/lib/solidus_legacy_promotions/configuration.rb +115 -0
  115. data/lib/solidus_legacy_promotions/engine.rb +97 -0
  116. data/lib/solidus_legacy_promotions/migrations/promotions_with_code_handlers.rb +68 -0
  117. data/lib/solidus_legacy_promotions/testing_support/factories/calculator_factory.rb +7 -0
  118. data/lib/solidus_legacy_promotions/testing_support/factories/order_factory.rb +22 -0
  119. data/lib/solidus_legacy_promotions/testing_support/factories/order_promotion_factory.rb +8 -0
  120. data/lib/solidus_legacy_promotions/testing_support/factories/promotion_category_factory.rb +7 -0
  121. data/lib/solidus_legacy_promotions/testing_support/factories/promotion_code_factory.rb +8 -0
  122. data/lib/solidus_legacy_promotions/testing_support/factories/promotion_factory.rb +98 -0
  123. data/lib/solidus_legacy_promotions/testing_support/factory_bot.rb +28 -0
  124. data/lib/solidus_legacy_promotions.rb +28 -0
  125. data/lib/tasks/solidus_legacy_promotions/delete_ineligible_adjustments.rake +8 -0
  126. data/lib/views/backend/spree/admin/adjustments/_adjustment.html.erb +24 -0
  127. data/lib/views/backend/spree/admin/orders/_adjustments.html.erb +19 -0
  128. data/lib/views/backend/spree/admin/orders/_order_details.html.erb +32 -0
  129. data/lib/views/backend/spree/admin/orders/confirm.html.erb +59 -0
  130. data/lib/views/backend/spree/admin/promotion_actions/create.js.erb +10 -0
  131. data/lib/views/backend/spree/admin/promotion_actions/destroy.js.erb +1 -0
  132. data/lib/views/backend/spree/admin/promotion_categories/_form.html.erb +14 -0
  133. data/lib/views/backend/spree/admin/promotion_categories/edit.html.erb +10 -0
  134. data/lib/views/backend/spree/admin/promotion_categories/index.html.erb +47 -0
  135. data/lib/views/backend/spree/admin/promotion_categories/new.html.erb +10 -0
  136. data/lib/views/backend/spree/admin/promotion_code_batches/_form_fields.html.erb +22 -0
  137. data/lib/views/backend/spree/admin/promotion_code_batches/download.csv.ruby +8 -0
  138. data/lib/views/backend/spree/admin/promotion_code_batches/index.html.erb +65 -0
  139. data/lib/views/backend/spree/admin/promotion_code_batches/new.html.erb +8 -0
  140. data/lib/views/backend/spree/admin/promotion_codes/index.csv.ruby +8 -0
  141. data/lib/views/backend/spree/admin/promotion_codes/index.html.erb +32 -0
  142. data/lib/views/backend/spree/admin/promotion_codes/new.html.erb +31 -0
  143. data/lib/views/backend/spree/admin/promotion_rules/create.js.erb +8 -0
  144. data/lib/views/backend/spree/admin/promotion_rules/destroy.js.erb +3 -0
  145. data/lib/views/backend/spree/admin/promotions/_actions.html.erb +37 -0
  146. data/lib/views/backend/spree/admin/promotions/_activations_edit.html.erb +22 -0
  147. data/lib/views/backend/spree/admin/promotions/_activations_new.html.erb +43 -0
  148. data/lib/views/backend/spree/admin/promotions/_form.html.erb +67 -0
  149. data/lib/views/backend/spree/admin/promotions/_promotion_action.html.erb +13 -0
  150. data/lib/views/backend/spree/admin/promotions/_promotion_rule.html.erb +12 -0
  151. data/lib/views/backend/spree/admin/promotions/_rules.html.erb +42 -0
  152. data/lib/views/backend/spree/admin/promotions/actions/_create_adjustment.html.erb +2 -0
  153. data/lib/views/backend/spree/admin/promotions/actions/_create_item_adjustments.html.erb +6 -0
  154. data/lib/views/backend/spree/admin/promotions/actions/_create_quantity_adjustments.html.erb +10 -0
  155. data/lib/views/backend/spree/admin/promotions/actions/_free_shipping.html.erb +0 -0
  156. data/lib/views/backend/spree/admin/promotions/actions/_promotion_calculators_with_custom_fields.html.erb +29 -0
  157. data/lib/views/backend/spree/admin/promotions/calculators/_default_fields.html.erb +6 -0
  158. data/lib/views/backend/spree/admin/promotions/calculators/distributed_amount/_fields.html.erb +56 -0
  159. data/lib/views/backend/spree/admin/promotions/calculators/flat_rate/_fields.html.erb +6 -0
  160. data/lib/views/backend/spree/admin/promotions/calculators/tiered_flat_rate/_fields.html.erb +30 -0
  161. data/lib/views/backend/spree/admin/promotions/calculators/tiered_percent/_fields.html.erb +30 -0
  162. data/lib/views/backend/spree/admin/promotions/edit.html.erb +40 -0
  163. data/lib/views/backend/spree/admin/promotions/index.html.erb +124 -0
  164. data/lib/views/backend/spree/admin/promotions/new.html.erb +14 -0
  165. data/lib/views/backend/spree/admin/promotions/rules/_first_order.html.erb +0 -0
  166. data/lib/views/backend/spree/admin/promotions/rules/_first_repeat_purchase_since.html.erb +13 -0
  167. data/lib/views/backend/spree/admin/promotions/rules/_item_total.html.erb +14 -0
  168. data/lib/views/backend/spree/admin/promotions/rules/_minimum_quantity.html.erb +5 -0
  169. data/lib/views/backend/spree/admin/promotions/rules/_nth_order.html.erb +12 -0
  170. data/lib/views/backend/spree/admin/promotions/rules/_one_use_per_user.html.erb +0 -0
  171. data/lib/views/backend/spree/admin/promotions/rules/_option_value.html.erb +13 -0
  172. data/lib/views/backend/spree/admin/promotions/rules/_product.html.erb +15 -0
  173. data/lib/views/backend/spree/admin/promotions/rules/_store.html.erb +6 -0
  174. data/lib/views/backend/spree/admin/promotions/rules/_taxon.html.erb +9 -0
  175. data/lib/views/backend/spree/admin/promotions/rules/_user.html.erb +4 -0
  176. data/lib/views/backend/spree/admin/promotions/rules/_user_logged_in.html.erb +0 -0
  177. data/lib/views/backend/spree/admin/promotions/rules/_user_role.html.erb +12 -0
  178. data/solidus_legacy_promotions.gemspec +29 -0
  179. metadata +262 -0
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class Promotion < Spree::Base
5
+ module Rules
6
+ class UserRole < PromotionRule
7
+ preference :role_ids, :array, default: []
8
+
9
+ MATCH_POLICIES = %w(any all)
10
+ preference :match_policy, default: MATCH_POLICIES.first
11
+
12
+ def applicable?(promotable)
13
+ promotable.is_a?(Spree::Order)
14
+ end
15
+
16
+ def eligible?(order, _options = {})
17
+ return false unless order.user
18
+ if all_match_policy?
19
+ match_all_roles?(order)
20
+ else
21
+ match_any_roles?(order)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def all_match_policy?
28
+ preferred_match_policy == 'all' && preferred_role_ids.present?
29
+ end
30
+
31
+ def user_roles(order)
32
+ order.user.spree_roles.where(id: preferred_role_ids)
33
+ end
34
+
35
+ def match_all_roles?(order)
36
+ user_roles(order).count == preferred_role_ids.count
37
+ end
38
+
39
+ def match_any_roles?(order)
40
+ user_roles(order).exists?
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class Promotion < Spree::Base
5
+ UNACTIVATABLE_ORDER_STATES = ["complete", "awaiting_return", "returned"]
6
+
7
+ attr_reader :eligibility_errors
8
+
9
+ belongs_to :promotion_category, optional: true
10
+
11
+ has_many :promotion_rules, autosave: true, dependent: :destroy, inverse_of: :promotion
12
+ alias_method :rules, :promotion_rules
13
+
14
+ has_many :promotion_actions, autosave: true, dependent: :destroy, inverse_of: :promotion
15
+ alias_method :actions, :promotion_actions
16
+
17
+ has_many :order_promotions, class_name: "Spree::OrderPromotion", inverse_of: :promotion, dependent: :destroy
18
+ has_many :orders, through: :order_promotions
19
+
20
+ has_many :codes, class_name: "Spree::PromotionCode", inverse_of: :promotion, dependent: :destroy
21
+ alias_method :promotion_codes, :codes
22
+
23
+ has_many :promotion_code_batches, class_name: "Spree::PromotionCodeBatch", dependent: :destroy
24
+
25
+ accepts_nested_attributes_for :promotion_actions, :promotion_rules
26
+
27
+ validates_associated :rules
28
+
29
+ validates :name, presence: true
30
+ validates :path, uniqueness: { allow_blank: true, case_sensitive: true }
31
+ validates :usage_limit, numericality: { greater_than: 0, allow_nil: true }
32
+ validates :per_code_usage_limit, numericality: { greater_than_or_equal_to: 0, allow_nil: true }
33
+ validates :description, length: { maximum: 255 }
34
+ validate :apply_automatically_disallowed_with_paths
35
+
36
+ before_save :normalize_blank_values
37
+
38
+ scope :coupons, -> { joins(:codes).distinct }
39
+ scope :advertised, -> { where(advertise: true) }
40
+ scope :active, -> { has_actions.started_and_unexpired }
41
+ scope :started_and_unexpired, -> do
42
+ table = arel_table
43
+ time = Time.current
44
+
45
+ where(table[:starts_at].eq(nil).or(table[:starts_at].lt(time))).
46
+ where(table[:expires_at].eq(nil).or(table[:expires_at].gt(time)))
47
+ end
48
+ scope :has_actions, -> do
49
+ joins(:promotion_actions).distinct
50
+ end
51
+ scope :applied, -> { joins(:order_promotions).distinct }
52
+
53
+ self.allowed_ransackable_associations = ['codes']
54
+ self.allowed_ransackable_attributes = %w[name path promotion_category_id]
55
+ self.allowed_ransackable_scopes = %i[active]
56
+
57
+ def self.order_activatable?(order)
58
+ order && !UNACTIVATABLE_ORDER_STATES.include?(order.state)
59
+ end
60
+
61
+ def self.with_coupon_code(val)
62
+ joins(:codes).where(
63
+ PromotionCode.arel_table[:value].eq(val.downcase)
64
+ ).first
65
+ end
66
+
67
+ # All orders that have been discounted using this promotion
68
+ def discounted_orders
69
+ Spree::Order.
70
+ joins(:all_adjustments).
71
+ where(
72
+ spree_adjustments: {
73
+ source_type: "Spree::PromotionAction",
74
+ source_id: actions.map(&:id),
75
+ eligible: true
76
+ }
77
+ ).distinct
78
+ end
79
+
80
+ def as_json(options = {})
81
+ options[:except] ||= :code
82
+ super
83
+ end
84
+
85
+ def not_started?
86
+ !started?
87
+ end
88
+
89
+ def started?
90
+ starts_at.nil? || starts_at < Time.current
91
+ end
92
+
93
+ def expired?
94
+ expires_at.present? && expires_at < Time.current
95
+ end
96
+
97
+ def not_expired?
98
+ !expired?
99
+ end
100
+
101
+ def active?
102
+ started? && not_expired? && actions.present?
103
+ end
104
+
105
+ def inactive?
106
+ !active?
107
+ end
108
+
109
+ def activate(order:, line_item: nil, user: nil, path: nil, promotion_code: nil)
110
+ return unless self.class.order_activatable?(order)
111
+
112
+ payload = {
113
+ order:,
114
+ promotion: self,
115
+ line_item:,
116
+ user:,
117
+ path:,
118
+ promotion_code:
119
+ }
120
+
121
+ # Track results from actions to see if any action has been taken.
122
+ # Actions should return nil/false if no action has been taken.
123
+ # If an action returns true, then an action has been taken.
124
+ results = actions.map do |action|
125
+ action.perform(payload)
126
+ end
127
+ # If an action has been taken, report back to whatever activated this promotion.
128
+ action_taken = results.include?(true)
129
+
130
+ if action_taken
131
+ # connect to the order
132
+ order.order_promotions.find_or_create_by!(
133
+ promotion: self,
134
+ promotion_code:,
135
+ )
136
+ order.promotions.reset
137
+ order_promotions.reset
138
+ orders.reset
139
+ end
140
+
141
+ action_taken
142
+ end
143
+
144
+ # called anytime order.recalculate happens
145
+ def eligible?(promotable, promotion_code: nil)
146
+ return false if inactive?
147
+ return false if blacklisted?(promotable)
148
+
149
+ excluded_orders = eligibility_excluded_orders(promotable)
150
+ return false if usage_limit_exceeded?(excluded_orders:)
151
+ return false if promotion_code&.usage_limit_exceeded?(excluded_orders:)
152
+
153
+ !!eligible_rules(promotable, {})
154
+ end
155
+
156
+ # eligible_rules returns an array of promotion rules where eligible? is true for the promotable
157
+ # if there are no such rules, an empty array is returned
158
+ # if the rules make this promotable ineligible, then nil is returned (i.e. this promotable is not eligible)
159
+ def eligible_rules(promotable, options = {})
160
+ # Promotions without rules are eligible by default.
161
+ return [] if rules.none?
162
+
163
+ eligible = lambda { |rule| rule.eligible?(promotable, options) }
164
+ specific_rules = rules.select { |rule| rule.applicable?(promotable) }
165
+ return [] if specific_rules.none?
166
+
167
+ # If there are rules for this promotion, but no rules for this
168
+ # particular promotable, then the promotion is ineligible by default.
169
+ unless specific_rules.all?(&eligible)
170
+ @eligibility_errors = specific_rules.map(&:eligibility_errors).detect(&:present?)
171
+ return nil
172
+ end
173
+ specific_rules
174
+ end
175
+
176
+ def products
177
+ rules.where(type: "Spree::Promotion::Rules::Product").flat_map(&:products).uniq
178
+ end
179
+
180
+ # Whether the promotion has exceeded its usage restrictions.
181
+ #
182
+ # @param excluded_orders [Array<Spree::Order>] Orders to exclude from usage limit
183
+ # @return true or false
184
+ def usage_limit_exceeded?(excluded_orders: [])
185
+ if usage_limit
186
+ usage_count(excluded_orders:) >= usage_limit
187
+ end
188
+ end
189
+
190
+ # Number of times the code has been used overall
191
+ #
192
+ # @param excluded_orders [Array<Spree::Order>] Orders to exclude from usage count
193
+ # @return [Integer] usage count
194
+ def usage_count(excluded_orders: [])
195
+ discounted_orders.
196
+ complete.
197
+ where.not(id: [excluded_orders.map(&:id)]).
198
+ where.not(spree_orders: { state: :canceled }).
199
+ count
200
+ end
201
+
202
+ def line_item_actionable?(order, line_item, promotion_code: nil)
203
+ return false if blacklisted?(line_item)
204
+
205
+ if eligible?(order, promotion_code:)
206
+ rules = eligible_rules(order)
207
+ if rules.blank?
208
+ true
209
+ else
210
+ rules.all? { |rule| rule.actionable? line_item }
211
+ end
212
+ else
213
+ false
214
+ end
215
+ end
216
+
217
+ def used_by?(user, excluded_orders = [])
218
+ discounted_orders.
219
+ complete.
220
+ where.not(id: excluded_orders.map(&:id)).
221
+ where(user:).
222
+ where.not(spree_orders: { state: :canceled }).
223
+ exists?
224
+ end
225
+
226
+ # Removes a promotion and any adjustments or other side effects from an
227
+ # order.
228
+ # @param order [Spree::Order] the order to remove the promotion from.
229
+ # @return [void]
230
+ def remove_from(order)
231
+ actions.each do |action|
232
+ action.remove_from(order)
233
+ end
234
+ # NOTE: this destroys the join table entry, not the promotion itself
235
+ order.promotions.destroy(self)
236
+ order.order_promotions.reset
237
+ order_promotions.reset
238
+ end
239
+
240
+ private
241
+
242
+ def blacklisted?(promotable)
243
+ case promotable
244
+ when Spree::LineItem
245
+ !promotable.variant.product.promotionable?
246
+ when Spree::Order
247
+ promotable.line_items.any? { |line_item| !line_item.variant.product.promotionable? }
248
+ end
249
+ end
250
+
251
+ def normalize_blank_values
252
+ self[:path] = nil if self[:path].blank?
253
+ end
254
+
255
+ def apply_automatically_disallowed_with_paths
256
+ return unless apply_automatically
257
+
258
+ errors.add(:apply_automatically, :disallowed_with_path) if path.present?
259
+ end
260
+
261
+ def eligibility_excluded_orders(promotable)
262
+ if promotable.is_a?(Spree::Order)
263
+ [promotable]
264
+ elsif promotable.respond_to?(:order)
265
+ [promotable.order]
266
+ else
267
+ []
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spree/preferences/persistable'
4
+
5
+ module Spree
6
+ # Base class for all types of promotion action.
7
+ #
8
+ # PromotionActions perform the necessary tasks when a promotion is activated
9
+ # by an event and determined to be eligible.
10
+ class PromotionAction < Spree::Base
11
+ include Spree::Preferences::Persistable
12
+ include Spree::SoftDeletable
13
+
14
+ belongs_to :promotion, class_name: 'Spree::Promotion', inverse_of: :promotion_actions, optional: true
15
+
16
+ scope :of_type, ->(type) { where(type: Array.wrap(type).map(&:to_s)) }
17
+ scope :shipping, -> { of_type(Spree::Config.promotions.shipping_actions.to_a) }
18
+
19
+ def preload_relations
20
+ []
21
+ end
22
+
23
+ # Updates the state of the order or performs some other action depending on
24
+ # the subclass options will contain the payload from the event that
25
+ # activated the promotion. This will include the key :user which allows
26
+ # user based actions to be performed in addition to actions on the order
27
+ #
28
+ # @note This method should be overriden in subclassses.
29
+ def perform(_options = {})
30
+ raise 'perform should be implemented in a sub-class of PromotionAction'
31
+ end
32
+
33
+ # Removes the action from an order
34
+ #
35
+ # @note This method should be overriden in subclassses.
36
+ #
37
+ # @param order [Spree::Order] the order to remove the action from
38
+ # @return [void]
39
+ def remove_from(_order)
40
+ raise 'remove_from should be implemented in a sub-class of PromotionAction'
41
+ end
42
+
43
+ def to_partial_path
44
+ "spree/admin/promotions/actions/#{model_name.element}"
45
+ end
46
+
47
+ def available_calculators
48
+ Spree::Config.promotions.calculators[self.class]
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class PromotionAdvertiser
5
+ def self.for_product(product)
6
+ promotion_ids = product.promotion_rules.map(&:promotion_id).uniq
7
+ Spree::Promotion.advertised.where(id: promotion_ids).reject(&:inactive?)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class PromotionCategory < Spree::Base
5
+ validates_presence_of :name
6
+ has_many :promotions
7
+ end
8
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class PromotionChooser
5
+ def initialize(adjustments)
6
+ @adjustments = adjustments
7
+ end
8
+
9
+ # Picks the best promotion from this set of adjustments, all others are
10
+ # marked as ineligible.
11
+ #
12
+ # @return [BigDecimal] The amount of the best adjustment
13
+ def update
14
+ if best_promotion_adjustment
15
+ @adjustments.select(&:eligible?).each do |adjustment|
16
+ next if adjustment == best_promotion_adjustment
17
+ adjustment.update_columns(eligible: false, updated_at: Time.current)
18
+ end
19
+ best_promotion_adjustment.amount
20
+ else
21
+ BigDecimal('0')
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ # @return The best promotion from this set of adjustments.
28
+ def best_promotion_adjustment
29
+ @best_promotion_adjustment ||= @adjustments.select(&:eligible?).min_by do |adjustment|
30
+ [adjustment.amount, -adjustment.id]
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ::Spree::PromotionCode::BatchBuilder
4
+ attr_reader :promotion_code_batch, :options
5
+
6
+ delegate :promotion, :number_of_codes, :base_code, to: :promotion_code_batch
7
+
8
+ DEFAULT_OPTIONS = {
9
+ random_code_length: 6,
10
+ batch_size: 1000,
11
+ sample_characters: ('a'..'z').to_a + (2..9).to_a.map(&:to_s)
12
+ }
13
+
14
+ def initialize(promotion_code_batch, options = {})
15
+ @promotion_code_batch = promotion_code_batch
16
+ options.assert_valid_keys(*DEFAULT_OPTIONS.keys)
17
+ @options = DEFAULT_OPTIONS.merge(options)
18
+ end
19
+
20
+ def build_promotion_codes
21
+ generate_random_codes
22
+ promotion_code_batch.update!(state: "completed")
23
+ rescue StandardError => error
24
+ promotion_code_batch.update!(
25
+ error: error.inspect,
26
+ state: "failed"
27
+ )
28
+ raise error
29
+ end
30
+
31
+ private
32
+
33
+ def generate_random_codes
34
+ created_codes = promotion_code_batch.promotion_codes.count
35
+
36
+ batch_size = @options[:batch_size]
37
+
38
+ while created_codes < number_of_codes
39
+ max_codes_to_generate = [batch_size, number_of_codes - created_codes].min
40
+
41
+ new_codes = Array.new(max_codes_to_generate) { generate_random_code }.uniq
42
+ codes_for_current_batch = get_unique_codes(new_codes)
43
+
44
+ codes_for_current_batch.filter! do |value|
45
+ Spree::PromotionCode.create!(
46
+ value:,
47
+ promotion:,
48
+ promotion_code_batch:
49
+ )
50
+ rescue ActiveRecord::RecordInvalid
51
+ false
52
+ end
53
+ created_codes += codes_for_current_batch.size
54
+ end
55
+ end
56
+
57
+ def generate_random_code
58
+ suffix = Array.new(@options[:random_code_length]) do
59
+ @options[:sample_characters].sample
60
+ end.join
61
+
62
+ "#{base_code}#{@promotion_code_batch.join_characters}#{suffix}"
63
+ end
64
+
65
+ def get_unique_codes(code_set)
66
+ code_set - Spree::PromotionCode.where(value: code_set.to_a).pluck(:value)
67
+ end
68
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Spree::PromotionCode < Spree::Base
4
+ belongs_to :promotion, inverse_of: :codes, optional: true
5
+ belongs_to :promotion_code_batch, class_name: "Spree::PromotionCodeBatch", optional: true
6
+ has_many :adjustments
7
+
8
+ before_validation :normalize_code
9
+
10
+ validates :value, presence: true, uniqueness: { allow_blank: true, case_sensitive: true }
11
+ validates :promotion, presence: true
12
+ validate :promotion_not_apply_automatically, on: :create
13
+
14
+ self.allowed_ransackable_attributes = ['value']
15
+
16
+ # Whether the promotion code has exceeded its usage restrictions
17
+ #
18
+ # @param excluded_orders [Array<Spree::Order>] Orders to exclude from usage limit
19
+ # @return true or false
20
+ def usage_limit_exceeded?(excluded_orders: [])
21
+ if usage_limit
22
+ usage_count(excluded_orders:) >= usage_limit
23
+ end
24
+ end
25
+
26
+ # Number of times the code has been used overall
27
+ #
28
+ # @param excluded_orders [Array<Spree::Order>] Orders to exclude from usage count
29
+ # @return [Integer] usage count
30
+ def usage_count(excluded_orders: [])
31
+ promotion.
32
+ discounted_orders.
33
+ complete.
34
+ where.not(spree_orders: { state: :canceled }).
35
+ joins(:order_promotions).
36
+ where(spree_orders_promotions: { promotion_code_id: id }).
37
+ where.not(id: excluded_orders.map(&:id)).
38
+ count
39
+ end
40
+
41
+ def usage_limit
42
+ promotion.per_code_usage_limit
43
+ end
44
+
45
+ def promotion_not_apply_automatically
46
+ errors.add(:base, :disallowed_with_apply_automatically) if promotion.apply_automatically
47
+ end
48
+
49
+ private
50
+
51
+ def normalize_code
52
+ self.value = value.downcase.strip
53
+ end
54
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class PromotionCodeBatch < Spree::Base
5
+ class CantProcessStartedBatch < StandardError
6
+ end
7
+
8
+ belongs_to :promotion, class_name: "Spree::Promotion", optional: true
9
+ has_many :promotion_codes, class_name: "Spree::PromotionCode", dependent: :destroy
10
+
11
+ validates :number_of_codes, numericality: { greater_than: 0 }
12
+ validates_presence_of :base_code, :number_of_codes
13
+
14
+ def finished?
15
+ state == "completed"
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class PromotionFinder
5
+ def self.by_code_or_id(coupon_code)
6
+ Spree::Promotion.with_coupon_code(coupon_code.to_s) || Spree::Promotion.find(coupon_code)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ module PromotionHandler
5
+ # Decides which promotion should be activated given the current order context
6
+ #
7
+ # By activated it doesn't necessarily mean that the order will have a
8
+ # discount for every activated promotion. It means that the discount will be
9
+ # created and might eventually become eligible. The intention here is to
10
+ # reduce overhead. e.g. a promotion that requires item A to be eligible
11
+ # shouldn't be eligible unless item A is added to the order.
12
+ #
13
+ # It can be used as a wrapper for custom handlers as well. Different
14
+ # applications might have completely different requirements to make
15
+ # the promotions system accurate and performant. Here they can plug custom
16
+ # handler to activate promos as they wish once an item is added to cart
17
+ class Cart
18
+ attr_reader :line_item, :order
19
+ attr_accessor :error, :success
20
+
21
+ def initialize(order, line_item = nil)
22
+ @order, @line_item = order, line_item
23
+ end
24
+
25
+ def activate
26
+ promotions.each do |promotion|
27
+ if (line_item && promotion.eligible?(line_item, promotion_code: promotion_code(promotion))) || promotion.eligible?(order, promotion_code: promotion_code(promotion))
28
+ promotion.activate(line_item:, order:, promotion_code: promotion_code(promotion))
29
+ end
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def promotions
36
+ promos = connected_order_promotions | sale_promotions
37
+ promos.flat_map(&:promotion_actions).group_by(&:preload_relations).each do |preload_relations, actions|
38
+ preload(records: actions, associations: preload_relations)
39
+ end
40
+ promos.flat_map(&:promotion_rules).group_by(&:preload_relations).each do |preload_relations, rules|
41
+ preload(records: rules, associations: preload_relations)
42
+ end
43
+ promos
44
+ end
45
+
46
+ def preload(records:, associations:)
47
+ if Rails::VERSION::MAJOR >= 7
48
+ ActiveRecord::Associations::Preloader.new(records:, associations:).call
49
+ else
50
+ ActiveRecord::Associations::Preloader.new.preload(records, associations)
51
+ end
52
+ end
53
+
54
+ def connected_order_promotions
55
+ order.promotions.active.includes(promotion_includes)
56
+ end
57
+
58
+ def sale_promotions
59
+ Spree::Promotion.where(apply_automatically: true).active.includes(promotion_includes)
60
+ end
61
+
62
+ def promotion_code(promotion)
63
+ order_promotion = order.order_promotions.detect { |op| op.promotion_id == promotion.id }
64
+ order_promotion.present? ? order_promotion.promotion_code : nil
65
+ end
66
+
67
+ def promotion_includes
68
+ [
69
+ :promotion_rules,
70
+ :promotion_actions,
71
+ ]
72
+ end
73
+ end
74
+ end
75
+ end