solidus_legacy_promotions 4.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +345 -0
- data/Rakefile +43 -0
- data/app/assets/config/solidus_legacy_promotions/manifest.js +2 -0
- data/app/assets/javascripts/spree/backend/edit_promotion.js +7 -0
- data/app/assets/javascripts/spree/backend/promotions/activation.js +26 -0
- data/app/assets/javascripts/spree/backend/promotions.js +35 -0
- data/app/assets/javascripts/spree/backend/templates/promotions/calculators/fields/tiered_flat_rate.hbs +23 -0
- data/app/assets/javascripts/spree/backend/templates/promotions/calculators/fields/tiered_percent.hbs +23 -0
- data/app/assets/javascripts/spree/backend/templates/promotions/rules/option_values.hbs +12 -0
- data/app/assets/javascripts/spree/backend/views/calculators/tiered.js +54 -0
- data/app/assets/javascripts/spree/backend/views/order/details_adjustments.js +43 -0
- data/app/assets/javascripts/spree/backend/views/promotions/option_values_rule.js +79 -0
- data/app/assets/javascripts/spree/backend/views/promotions.js +3 -0
- data/app/assets/stylesheets/solidus_legacy_promotions/promotions/_edit.scss +138 -0
- data/app/assets/stylesheets/solidus_legacy_promotions/promotions.scss +3 -0
- data/app/assets/stylesheets/spree/backend/sections/_adjustments.scss +3 -0
- data/app/decorators/solidus_legacy_promotions/controllers/solidus_admin/solidus_admin_adjustments_controller_decorator.rb +20 -0
- data/app/decorators/solidus_legacy_promotions/lib/spree_order_state_machine_decorator.rb +18 -0
- data/app/decorators/solidus_legacy_promotions/models/spree_adjustment_decorator.rb +76 -0
- data/app/decorators/solidus_legacy_promotions/models/spree_calculator_returns_default_refund_amount_decorator.rb +13 -0
- data/app/decorators/solidus_legacy_promotions/models/spree_line_item_decorator.rb +11 -0
- data/app/decorators/solidus_legacy_promotions/models/spree_order_decorator.rb +28 -0
- data/app/decorators/solidus_legacy_promotions/models/spree_order_updater_decorator.rb +40 -0
- data/app/decorators/solidus_legacy_promotions/models/spree_product_decorator.rb +16 -0
- data/app/decorators/solidus_legacy_promotions/models/spree_promotion_code_batch_decorator.rb +16 -0
- data/app/decorators/solidus_legacy_promotions/models/spree_shipment_decorator.rb +13 -0
- data/app/helpers/spree/admin/promotions_helper.rb +15 -0
- data/app/helpers/spree/promotion_rules_helper.rb +12 -0
- data/app/jobs/spree/promotion_code_batch_job.rb +26 -0
- data/app/mailers/spree/promotion_code_batch_mailer.rb +15 -0
- data/app/models/spree/calculator/distributed_amount.rb +33 -0
- data/app/models/spree/calculator/flat_percent_item_total.rb +23 -0
- data/app/models/spree/calculator/flexi_rate.rb +22 -0
- data/app/models/spree/calculator/percent_on_line_item.rb +13 -0
- data/app/models/spree/calculator/tiered_flat_rate.rb +52 -0
- data/app/models/spree/calculator/tiered_percent.rb +62 -0
- data/app/models/spree/order_contents.rb +36 -0
- data/app/models/spree/order_promotion.rb +27 -0
- data/app/models/spree/permission_sets/promotion_display.rb +25 -0
- data/app/models/spree/permission_sets/promotion_management.rb +25 -0
- data/app/models/spree/promotion/actions/create_adjustment.rb +81 -0
- data/app/models/spree/promotion/actions/create_item_adjustments.rb +98 -0
- data/app/models/spree/promotion/actions/create_quantity_adjustments.rb +139 -0
- data/app/models/spree/promotion/actions/free_shipping.rb +59 -0
- data/app/models/spree/promotion/order_adjustments_recalculator.rb +92 -0
- data/app/models/spree/promotion/rules/first_order.rb +36 -0
- data/app/models/spree/promotion/rules/first_repeat_purchase_since.rb +36 -0
- data/app/models/spree/promotion/rules/item_total.rb +86 -0
- data/app/models/spree/promotion/rules/minimum_quantity.rb +59 -0
- data/app/models/spree/promotion/rules/nth_order.rb +45 -0
- data/app/models/spree/promotion/rules/one_use_per_user.rb +25 -0
- data/app/models/spree/promotion/rules/option_value.rb +50 -0
- data/app/models/spree/promotion/rules/product.rb +86 -0
- data/app/models/spree/promotion/rules/store.rb +26 -0
- data/app/models/spree/promotion/rules/taxon.rb +91 -0
- data/app/models/spree/promotion/rules/user.rb +34 -0
- data/app/models/spree/promotion/rules/user_logged_in.rb +20 -0
- data/app/models/spree/promotion/rules/user_role.rb +45 -0
- data/app/models/spree/promotion.rb +271 -0
- data/app/models/spree/promotion_action.rb +51 -0
- data/app/models/spree/promotion_advertiser.rb +10 -0
- data/app/models/spree/promotion_category.rb +8 -0
- data/app/models/spree/promotion_chooser.rb +34 -0
- data/app/models/spree/promotion_code/batch_builder.rb +68 -0
- data/app/models/spree/promotion_code.rb +54 -0
- data/app/models/spree/promotion_code_batch.rb +18 -0
- data/app/models/spree/promotion_finder.rb +9 -0
- data/app/models/spree/promotion_handler/cart.rb +75 -0
- data/app/models/spree/promotion_handler/coupon.rb +125 -0
- data/app/models/spree/promotion_handler/page.rb +26 -0
- data/app/models/spree/promotion_handler/shipping.rb +61 -0
- data/app/models/spree/promotion_rule.rb +55 -0
- data/app/models/spree/promotion_rule_store.rb +10 -0
- data/app/models/spree/promotion_rule_taxon.rb +8 -0
- data/app/models/spree/promotion_rule_user.rb +10 -0
- data/app/subscribers/spree/order_promotion_subscriber.rb +20 -0
- data/app/views/spree/order_mailer/cancel_email.html.erb +45 -0
- data/app/views/spree/order_mailer/cancel_email.text.erb +16 -0
- data/app/views/spree/order_mailer/confirm_email.html.erb +84 -0
- data/app/views/spree/order_mailer/confirm_email.text.erb +38 -0
- data/app/views/spree/promotion_code_batch_mailer/promotion_code_batch_errored.text.erb +2 -0
- data/app/views/spree/promotion_code_batch_mailer/promotion_code_batch_finished.text.erb +2 -0
- data/bin/rails +13 -0
- data/config/locales/en.yml +101 -0
- data/config/locales/promotion_categories.en.yml +6 -0
- data/config/locales/promotions.en.yml +6 -0
- data/config/routes.rb +28 -0
- data/db/migrate/20160101010001_solidus_one_four_promotions.rb +126 -0
- data/db/migrate/20161017102621_create_spree_promotion_code_batch.rb +48 -0
- data/db/migrate/20180202190713_create_promotion_rule_stores.rb +14 -0
- data/db/migrate/20180328172631_add_join_characters_to_promotion_code_batch.rb +15 -0
- data/db/migrate/20190106184413_remove_code_from_spree_promotions.rb +46 -0
- data/db/migrate/20220317165036_set_promotions_with_any_policy_to_all_if_possible.rb +20 -0
- data/db/migrate/20230322085416_remove_match_policy_from_spree_promotion.rb +7 -0
- data/db/migrate/20230325132905_remove_unused_columns_from_promotion_rules.rb +10 -0
- data/db/migrate/20231027084517_add_order_promotions_foreign_key.rb +14 -0
- data/db/migrate/20240621100123_add_promotion_code_id_to_spree_adjustments.rb +10 -0
- data/db/migrate/20240622113334_move_adjustment_eligible_to_legacy_promotions.rb +11 -0
- data/lib/components/admin/solidus_admin/orders/show/adjustments/index/source/spree_promotion_action/component.rb +17 -0
- data/lib/components/admin/solidus_admin/promotion_categories/index/component.rb +56 -0
- data/lib/components/admin/solidus_admin/promotions/index/component.rb +104 -0
- data/lib/components/admin/solidus_admin/promotions/index/component.yml +10 -0
- data/lib/components/admin/solidus_legacy_promotions/orders/index/component.rb +15 -0
- data/lib/components/admin/solidus_legacy_promotions/orders/index/component.yml +20 -0
- data/lib/controllers/admin/solidus_admin/promotion_categories_controller.rb +29 -0
- data/lib/controllers/admin/solidus_admin/promotions_controller.rb +46 -0
- data/lib/controllers/backend/spree/admin/promotion_actions_controller.rb +51 -0
- data/lib/controllers/backend/spree/admin/promotion_categories_controller.rb +8 -0
- data/lib/controllers/backend/spree/admin/promotion_code_batches_controller.rb +30 -0
- data/lib/controllers/backend/spree/admin/promotion_codes_controller.rb +48 -0
- data/lib/controllers/backend/spree/admin/promotion_rules_controller.rb +60 -0
- data/lib/controllers/backend/spree/admin/promotions_controller.rb +66 -0
- data/lib/solidus_legacy_promotions/configuration.rb +115 -0
- data/lib/solidus_legacy_promotions/engine.rb +97 -0
- data/lib/solidus_legacy_promotions/migrations/promotions_with_code_handlers.rb +68 -0
- data/lib/solidus_legacy_promotions/testing_support/factories/calculator_factory.rb +7 -0
- data/lib/solidus_legacy_promotions/testing_support/factories/order_factory.rb +22 -0
- data/lib/solidus_legacy_promotions/testing_support/factories/order_promotion_factory.rb +8 -0
- data/lib/solidus_legacy_promotions/testing_support/factories/promotion_category_factory.rb +7 -0
- data/lib/solidus_legacy_promotions/testing_support/factories/promotion_code_factory.rb +8 -0
- data/lib/solidus_legacy_promotions/testing_support/factories/promotion_factory.rb +98 -0
- data/lib/solidus_legacy_promotions/testing_support/factory_bot.rb +28 -0
- data/lib/solidus_legacy_promotions.rb +28 -0
- data/lib/tasks/solidus_legacy_promotions/delete_ineligible_adjustments.rake +8 -0
- data/lib/views/backend/spree/admin/adjustments/_adjustment.html.erb +24 -0
- data/lib/views/backend/spree/admin/orders/_adjustments.html.erb +19 -0
- data/lib/views/backend/spree/admin/orders/_order_details.html.erb +32 -0
- data/lib/views/backend/spree/admin/orders/confirm.html.erb +59 -0
- data/lib/views/backend/spree/admin/promotion_actions/create.js.erb +10 -0
- data/lib/views/backend/spree/admin/promotion_actions/destroy.js.erb +1 -0
- data/lib/views/backend/spree/admin/promotion_categories/_form.html.erb +14 -0
- data/lib/views/backend/spree/admin/promotion_categories/edit.html.erb +10 -0
- data/lib/views/backend/spree/admin/promotion_categories/index.html.erb +47 -0
- data/lib/views/backend/spree/admin/promotion_categories/new.html.erb +10 -0
- data/lib/views/backend/spree/admin/promotion_code_batches/_form_fields.html.erb +22 -0
- data/lib/views/backend/spree/admin/promotion_code_batches/download.csv.ruby +8 -0
- data/lib/views/backend/spree/admin/promotion_code_batches/index.html.erb +65 -0
- data/lib/views/backend/spree/admin/promotion_code_batches/new.html.erb +8 -0
- data/lib/views/backend/spree/admin/promotion_codes/index.csv.ruby +8 -0
- data/lib/views/backend/spree/admin/promotion_codes/index.html.erb +32 -0
- data/lib/views/backend/spree/admin/promotion_codes/new.html.erb +31 -0
- data/lib/views/backend/spree/admin/promotion_rules/create.js.erb +8 -0
- data/lib/views/backend/spree/admin/promotion_rules/destroy.js.erb +3 -0
- data/lib/views/backend/spree/admin/promotions/_actions.html.erb +37 -0
- data/lib/views/backend/spree/admin/promotions/_activations_edit.html.erb +22 -0
- data/lib/views/backend/spree/admin/promotions/_activations_new.html.erb +43 -0
- data/lib/views/backend/spree/admin/promotions/_form.html.erb +67 -0
- data/lib/views/backend/spree/admin/promotions/_promotion_action.html.erb +13 -0
- data/lib/views/backend/spree/admin/promotions/_promotion_rule.html.erb +12 -0
- data/lib/views/backend/spree/admin/promotions/_rules.html.erb +42 -0
- data/lib/views/backend/spree/admin/promotions/actions/_create_adjustment.html.erb +2 -0
- data/lib/views/backend/spree/admin/promotions/actions/_create_item_adjustments.html.erb +6 -0
- data/lib/views/backend/spree/admin/promotions/actions/_create_quantity_adjustments.html.erb +10 -0
- data/lib/views/backend/spree/admin/promotions/actions/_free_shipping.html.erb +0 -0
- data/lib/views/backend/spree/admin/promotions/actions/_promotion_calculators_with_custom_fields.html.erb +29 -0
- data/lib/views/backend/spree/admin/promotions/calculators/_default_fields.html.erb +6 -0
- data/lib/views/backend/spree/admin/promotions/calculators/distributed_amount/_fields.html.erb +56 -0
- data/lib/views/backend/spree/admin/promotions/calculators/flat_rate/_fields.html.erb +6 -0
- data/lib/views/backend/spree/admin/promotions/calculators/tiered_flat_rate/_fields.html.erb +30 -0
- data/lib/views/backend/spree/admin/promotions/calculators/tiered_percent/_fields.html.erb +30 -0
- data/lib/views/backend/spree/admin/promotions/edit.html.erb +40 -0
- data/lib/views/backend/spree/admin/promotions/index.html.erb +124 -0
- data/lib/views/backend/spree/admin/promotions/new.html.erb +14 -0
- data/lib/views/backend/spree/admin/promotions/rules/_first_order.html.erb +0 -0
- data/lib/views/backend/spree/admin/promotions/rules/_first_repeat_purchase_since.html.erb +13 -0
- data/lib/views/backend/spree/admin/promotions/rules/_item_total.html.erb +14 -0
- data/lib/views/backend/spree/admin/promotions/rules/_minimum_quantity.html.erb +5 -0
- data/lib/views/backend/spree/admin/promotions/rules/_nth_order.html.erb +12 -0
- data/lib/views/backend/spree/admin/promotions/rules/_one_use_per_user.html.erb +0 -0
- data/lib/views/backend/spree/admin/promotions/rules/_option_value.html.erb +13 -0
- data/lib/views/backend/spree/admin/promotions/rules/_product.html.erb +15 -0
- data/lib/views/backend/spree/admin/promotions/rules/_store.html.erb +6 -0
- data/lib/views/backend/spree/admin/promotions/rules/_taxon.html.erb +9 -0
- data/lib/views/backend/spree/admin/promotions/rules/_user.html.erb +4 -0
- data/lib/views/backend/spree/admin/promotions/rules/_user_logged_in.html.erb +0 -0
- data/lib/views/backend/spree/admin/promotions/rules/_user_role.html.erb +12 -0
- data/solidus_legacy_promotions.gemspec +29 -0
- 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,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,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
|