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,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Spree
|
4
|
+
class Promotion < Spree::Base
|
5
|
+
# This class encapsulates all the things the promotion system does to
|
6
|
+
# an order. It is called from the `Spree::OrderUpdater` before taxes are
|
7
|
+
# calculated, such that taxes always respect promotions.
|
8
|
+
|
9
|
+
# This class iterates over all existing promotion adjustments and recalculates
|
10
|
+
# their amount and eligibility using their adjustment source.
|
11
|
+
class OrderAdjustmentsRecalculator
|
12
|
+
def initialize(order)
|
13
|
+
@order = order
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
all_items = line_items + shipments
|
18
|
+
all_items.each do |item|
|
19
|
+
promotion_adjustments = item.adjustments.select(&:promotion?)
|
20
|
+
|
21
|
+
promotion_adjustments.each { |adjustment| recalculate(adjustment) }
|
22
|
+
Spree::Config.promotions.promotion_chooser_class.new(promotion_adjustments).update
|
23
|
+
|
24
|
+
item.promo_total = promotion_adjustments.select(&:eligible?).sum(&:amount)
|
25
|
+
end
|
26
|
+
# Update and select the best promotion adjustment for the order.
|
27
|
+
# We don't update the order.promo_total yet. Order totals are updated later
|
28
|
+
# in #update_adjustment_total since they include the totals from the order's
|
29
|
+
# line items and/or shipments.
|
30
|
+
order_promotion_adjustments = order.adjustments.select(&:promotion?)
|
31
|
+
order_promotion_adjustments.each { |adjustment| recalculate(adjustment) }
|
32
|
+
Spree::Config.promotions.promotion_chooser_class.new(order_promotion_adjustments).update
|
33
|
+
|
34
|
+
order.promo_total = all_items.sum(&:promo_total) +
|
35
|
+
order_promotion_adjustments.
|
36
|
+
select(&:eligible?).
|
37
|
+
select(&:promotion?).
|
38
|
+
sum(&:amount)
|
39
|
+
order
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
attr_reader :order
|
45
|
+
|
46
|
+
delegate :line_items, :shipments, to: :order
|
47
|
+
|
48
|
+
# Recalculate and persist the amount from this adjustment's source based on
|
49
|
+
# the adjustable ({Order}, {Shipment}, or {LineItem})
|
50
|
+
#
|
51
|
+
# If the adjustment has no source (such as when created manually from the
|
52
|
+
# admin) or is closed, this is a noop.
|
53
|
+
#
|
54
|
+
# @return [BigDecimal] New amount of this adjustment
|
55
|
+
def recalculate(adjustment)
|
56
|
+
if adjustment.finalized?
|
57
|
+
return adjustment.amount
|
58
|
+
end
|
59
|
+
|
60
|
+
# If the adjustment has no source, do not attempt to re-calculate the
|
61
|
+
# amount.
|
62
|
+
# Some scenarios where this happens:
|
63
|
+
# - Adjustments that are manually created via the admin backend
|
64
|
+
# - PromotionAction adjustments where the PromotionAction was deleted
|
65
|
+
# after the order was completed.
|
66
|
+
if adjustment.source.present?
|
67
|
+
adjustment.amount = adjustment.source.compute_amount(adjustment.adjustable)
|
68
|
+
|
69
|
+
adjustment.eligible = calculate_eligibility(adjustment)
|
70
|
+
|
71
|
+
# Persist only if changed
|
72
|
+
# This is only not a save! to avoid the extra queries to load the order
|
73
|
+
# (for validations) and to touch the adjustment.
|
74
|
+
adjustment.update_columns(eligible: adjustment.eligible, amount: adjustment.amount, updated_at: Time.current) if adjustment.changed?
|
75
|
+
end
|
76
|
+
adjustment.amount
|
77
|
+
end
|
78
|
+
|
79
|
+
# Calculates based on attached promotion (if this is a promotion
|
80
|
+
# adjustment) whether this promotion is still eligible.
|
81
|
+
# @api private
|
82
|
+
# @return [true,false] Whether this adjustment is eligible
|
83
|
+
def calculate_eligibility(adjustment)
|
84
|
+
if !adjustment.finalized? && adjustment.source
|
85
|
+
adjustment.source.promotion.eligible?(adjustment.adjustable, promotion_code: adjustment.promotion_code)
|
86
|
+
else
|
87
|
+
adjustment.eligible?
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Spree
|
4
|
+
class Promotion < Spree::Base
|
5
|
+
module Rules
|
6
|
+
class FirstOrder < PromotionRule
|
7
|
+
attr_reader :user, :email
|
8
|
+
|
9
|
+
def applicable?(promotable)
|
10
|
+
promotable.is_a?(Spree::Order)
|
11
|
+
end
|
12
|
+
|
13
|
+
def eligible?(order, options = {})
|
14
|
+
@user = order.try(:user) || options[:user]
|
15
|
+
@email = order.email
|
16
|
+
|
17
|
+
if (user || email) && (completed_orders.present? && completed_orders.first != order)
|
18
|
+
eligibility_errors.add(:base, eligibility_error_message(:not_first_order), error_code: :not_first_order)
|
19
|
+
end
|
20
|
+
|
21
|
+
eligibility_errors.empty?
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def completed_orders
|
27
|
+
user ? user.orders.complete : orders_by_email
|
28
|
+
end
|
29
|
+
|
30
|
+
def orders_by_email
|
31
|
+
Spree::Order.where(email:).complete
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Spree
|
4
|
+
class Promotion < Spree::Base
|
5
|
+
module Rules
|
6
|
+
class FirstRepeatPurchaseSince < PromotionRule
|
7
|
+
preference :days_ago, :integer, default: 365
|
8
|
+
validates :preferred_days_ago, numericality: { only_integer: true, greater_than: 0 }
|
9
|
+
|
10
|
+
# This promotion is applicable to orders only.
|
11
|
+
def applicable?(promotable)
|
12
|
+
promotable.is_a?(Spree::Order)
|
13
|
+
end
|
14
|
+
|
15
|
+
# This is never eligible if the order does not have a user, and that user does not have any previous completed orders.
|
16
|
+
#
|
17
|
+
# This is eligible if the user's most recently completed order is more than the preferred days ago
|
18
|
+
# @param order [Spree::Order]
|
19
|
+
def eligible?(order, _options = {})
|
20
|
+
return false unless order.user
|
21
|
+
|
22
|
+
last_order = last_completed_order(order.user)
|
23
|
+
return false unless last_order
|
24
|
+
|
25
|
+
last_order.completed_at < preferred_days_ago.days.ago
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def last_completed_order(user)
|
31
|
+
user.orders.complete.order(:completed_at).last
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Spree
|
4
|
+
class Promotion < Spree::Base
|
5
|
+
module Rules
|
6
|
+
# A rule to apply to an order greater than (or greater than or equal to)
|
7
|
+
# a specific amount
|
8
|
+
#
|
9
|
+
# To add extra operators please override `self.operators_map` or any other helper method.
|
10
|
+
# To customize the error message you can also override `ineligible_message`.
|
11
|
+
class ItemTotal < PromotionRule
|
12
|
+
preference :amount, :decimal, default: 100.00
|
13
|
+
preference :currency, :string, default: ->{ Spree::Config[:currency] }
|
14
|
+
preference :operator, :string, default: 'gt'
|
15
|
+
|
16
|
+
# The list of allowed operators names mapped to their symbols.
|
17
|
+
def self.operators_map
|
18
|
+
{
|
19
|
+
gte: :>=,
|
20
|
+
gt: :>,
|
21
|
+
}
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.operator_options
|
25
|
+
operators_map.map do |name, _method|
|
26
|
+
[I18n.t(name, scope: 'spree.item_total_rule.operators'), name]
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def applicable?(promotable)
|
31
|
+
promotable.is_a?(Spree::Order)
|
32
|
+
end
|
33
|
+
|
34
|
+
def eligible?(order, _options = {})
|
35
|
+
return false unless order.currency == preferred_currency
|
36
|
+
|
37
|
+
unless total_for_order(order).send(operator, threshold)
|
38
|
+
eligibility_errors.add(:base, ineligible_message, error_code: ineligible_error_code)
|
39
|
+
end
|
40
|
+
|
41
|
+
eligibility_errors.empty?
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def operator
|
47
|
+
self.class.operators_map.fetch(
|
48
|
+
preferred_operator.to_sym,
|
49
|
+
preferred_operator_default,
|
50
|
+
)
|
51
|
+
end
|
52
|
+
|
53
|
+
def total_for_order(order)
|
54
|
+
order.item_total
|
55
|
+
end
|
56
|
+
|
57
|
+
def threshold
|
58
|
+
BigDecimal(preferred_amount.to_s)
|
59
|
+
end
|
60
|
+
|
61
|
+
def formatted_amount
|
62
|
+
Spree::Money.new(preferred_amount, currency: preferred_currency).to_s
|
63
|
+
end
|
64
|
+
|
65
|
+
def ineligible_message
|
66
|
+
case preferred_operator.to_s
|
67
|
+
when 'gte'
|
68
|
+
eligibility_error_message(:item_total_less_than, amount: formatted_amount)
|
69
|
+
when 'gt'
|
70
|
+
eligibility_error_message(:item_total_less_than_or_equal, amount: formatted_amount)
|
71
|
+
else
|
72
|
+
eligibility_error_message(:item_total_doesnt_match_with_operator, amount: formatted_amount, operator: preferred_operator)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def ineligible_error_code
|
77
|
+
if preferred_operator == 'gte'
|
78
|
+
:item_total_less_than
|
79
|
+
else
|
80
|
+
:item_total_less_than_or_equal
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Spree
|
4
|
+
class Promotion
|
5
|
+
module Rules
|
6
|
+
# Promotion rule for ensuring an order contains a minimum quantity of
|
7
|
+
# actionable items.
|
8
|
+
#
|
9
|
+
# This promotion rule is only compatible with the "all" match policy. It
|
10
|
+
# doesn't make a lot of sense to use it without that policy as it reduces
|
11
|
+
# it to a simple quantity check across the entire order which would be
|
12
|
+
# better served by an item total rule.
|
13
|
+
class MinimumQuantity < PromotionRule
|
14
|
+
validates :preferred_minimum_quantity, numericality: { only_integer: true, greater_than: 0 }
|
15
|
+
|
16
|
+
preference :minimum_quantity, :integer, default: 1
|
17
|
+
|
18
|
+
# What type of objects we should run our eligiblity checks against. In
|
19
|
+
# this case, our rule only applies to an entire order.
|
20
|
+
#
|
21
|
+
# @param promotable [Spree::Order,Spree::LineItem]
|
22
|
+
# @return [Boolean] true if promotable is a Spree::Order, false
|
23
|
+
# otherwise
|
24
|
+
def applicable?(promotable)
|
25
|
+
promotable.is_a?(Spree::Order)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Will look at all of the "actionable" line items in the order and
|
29
|
+
# determine if the sum of their quantity is greater than the minimum.
|
30
|
+
#
|
31
|
+
# "Actionable" items are ones where they pass the "actionable?" check of
|
32
|
+
# all rules on the promotion. (e.g.: Match product/taxon when one of
|
33
|
+
# those rules is present.)
|
34
|
+
#
|
35
|
+
# When false is returned, the reason will be included in the
|
36
|
+
# `eligibility_errors` object.
|
37
|
+
#
|
38
|
+
# @param order [Spree::Order] the order we want to check eligibility on
|
39
|
+
# @param _options [Hash] ignored
|
40
|
+
# @return [Boolean] true if promotion is eligible, false otherwise
|
41
|
+
def eligible?(order, _options = {})
|
42
|
+
actionable_line_items = order.line_items.select do |line_item|
|
43
|
+
promotion.rules.all? { _1.actionable?(line_item) }
|
44
|
+
end
|
45
|
+
|
46
|
+
if actionable_line_items.sum(&:quantity) < preferred_minimum_quantity
|
47
|
+
eligibility_errors.add(
|
48
|
+
:base,
|
49
|
+
eligibility_error_message(:quantity_less_than_minimum, count: preferred_minimum_quantity),
|
50
|
+
error_code: :quantity_less_than_minimum
|
51
|
+
)
|
52
|
+
end
|
53
|
+
|
54
|
+
eligibility_errors.empty?
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Spree
|
4
|
+
class Promotion
|
5
|
+
module Rules
|
6
|
+
class NthOrder < PromotionRule
|
7
|
+
preference :nth_order, :integer, default: 2
|
8
|
+
# It does not make sense to have this apply to the first order using preferred_nth_order == 1
|
9
|
+
# Instead we could use the first_order rule
|
10
|
+
validates :preferred_nth_order, numericality: { only_integer: true, greater_than: 1 }
|
11
|
+
|
12
|
+
# This promotion is applicable to orders only.
|
13
|
+
def applicable?(promotable)
|
14
|
+
promotable.is_a?(Spree::Order)
|
15
|
+
end
|
16
|
+
|
17
|
+
# This is never eligible if the order does not have a user, and that user does not have any previous completed orders.
|
18
|
+
#
|
19
|
+
# Use the first order rule if you want a promotion to be applied to the first order for a user.
|
20
|
+
# @param order [Spree::Order]
|
21
|
+
def eligible?(order, _options = {})
|
22
|
+
return false unless order.user
|
23
|
+
|
24
|
+
nth_order?(order)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def completed_order_count(order)
|
30
|
+
order.
|
31
|
+
user.
|
32
|
+
orders.
|
33
|
+
complete.
|
34
|
+
where(Spree::Order.arel_table[:completed_at].lt(order.completed_at || Time.current)).
|
35
|
+
count
|
36
|
+
end
|
37
|
+
|
38
|
+
def nth_order?(order)
|
39
|
+
count = completed_order_count(order) + 1
|
40
|
+
count == preferred_nth_order
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Spree
|
4
|
+
class Promotion < Spree::Base
|
5
|
+
module Rules
|
6
|
+
class OneUsePerUser < PromotionRule
|
7
|
+
def applicable?(promotable)
|
8
|
+
promotable.is_a?(Spree::Order)
|
9
|
+
end
|
10
|
+
|
11
|
+
def eligible?(order, _options = {})
|
12
|
+
if order.user.present?
|
13
|
+
if promotion.used_by?(order.user, [order])
|
14
|
+
eligibility_errors.add(:base, eligibility_error_message(:limit_once_per_user), error_code: :limit_once_per_user)
|
15
|
+
end
|
16
|
+
else
|
17
|
+
eligibility_errors.add(:base, eligibility_error_message(:no_user_specified), error_code: :no_user_specified)
|
18
|
+
end
|
19
|
+
|
20
|
+
eligibility_errors.empty?
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Spree
|
4
|
+
class Promotion < Spree::Base
|
5
|
+
module Rules
|
6
|
+
class OptionValue < PromotionRule
|
7
|
+
MATCH_POLICIES = %w(any)
|
8
|
+
preference :match_policy, :string, default: MATCH_POLICIES.first
|
9
|
+
preference :eligible_values, :hash
|
10
|
+
|
11
|
+
def applicable?(promotable)
|
12
|
+
promotable.is_a?(Spree::Order)
|
13
|
+
end
|
14
|
+
|
15
|
+
def eligible?(promotable, _options = {})
|
16
|
+
case preferred_match_policy
|
17
|
+
when 'any'
|
18
|
+
promotable.line_items.any? { |item| actionable?(item) }
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def actionable?(line_item)
|
23
|
+
pid = line_item.product.id
|
24
|
+
ovids = line_item.variant.option_values.pluck(:id)
|
25
|
+
|
26
|
+
product_ids.include?(pid) && (value_ids(pid) & ovids).present?
|
27
|
+
end
|
28
|
+
|
29
|
+
def preferred_eligible_values
|
30
|
+
values = preferences[:eligible_values] || {}
|
31
|
+
Hash[values.keys.map(&:to_i).zip(
|
32
|
+
values.values.map do |value|
|
33
|
+
(value.is_a?(Array) ? value : value.split(",")).map(&:to_i)
|
34
|
+
end
|
35
|
+
)]
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def product_ids
|
41
|
+
preferred_eligible_values.keys
|
42
|
+
end
|
43
|
+
|
44
|
+
def value_ids(product_id)
|
45
|
+
preferred_eligible_values[product_id]
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Spree
|
4
|
+
class Promotion < Spree::Base
|
5
|
+
module Rules
|
6
|
+
# A rule to limit a promotion based on products in the order. Can
|
7
|
+
# require all or any of the products to be present. Valid products
|
8
|
+
# either come from assigned product group or are assingned directly to
|
9
|
+
# the rule.
|
10
|
+
class Product < PromotionRule
|
11
|
+
has_many :product_promotion_rules, dependent: :destroy, foreign_key: :promotion_rule_id,
|
12
|
+
class_name: 'Spree::ProductPromotionRule'
|
13
|
+
has_many :products, class_name: 'Spree::Product', through: :product_promotion_rules
|
14
|
+
|
15
|
+
def preload_relations
|
16
|
+
[:products]
|
17
|
+
end
|
18
|
+
|
19
|
+
MATCH_POLICIES = %w(any all none)
|
20
|
+
|
21
|
+
validates_inclusion_of :preferred_match_policy, in: MATCH_POLICIES
|
22
|
+
|
23
|
+
preference :match_policy, :string, default: MATCH_POLICIES.first
|
24
|
+
|
25
|
+
# scope/association that is used to test eligibility
|
26
|
+
def eligible_products
|
27
|
+
products
|
28
|
+
end
|
29
|
+
|
30
|
+
def applicable?(promotable)
|
31
|
+
promotable.is_a?(Spree::Order)
|
32
|
+
end
|
33
|
+
|
34
|
+
def eligible?(order, _options = {})
|
35
|
+
return true if eligible_products.empty?
|
36
|
+
|
37
|
+
case preferred_match_policy
|
38
|
+
when "all"
|
39
|
+
unless eligible_products.all? { |product| order_products(order).include?(product) }
|
40
|
+
eligibility_errors.add(:base, eligibility_error_message(:missing_product), error_code: :missing_product)
|
41
|
+
end
|
42
|
+
when "any"
|
43
|
+
unless order_products(order).any? { |product| eligible_products.include?(product) }
|
44
|
+
eligibility_errors.add(:base, eligibility_error_message(:no_applicable_products),
|
45
|
+
error_code: :no_applicable_products)
|
46
|
+
end
|
47
|
+
when "none"
|
48
|
+
unless order_products(order).none? { |product| eligible_products.include?(product) }
|
49
|
+
eligibility_errors.add(:base, eligibility_error_message(:has_excluded_product),
|
50
|
+
error_code: :has_excluded_product)
|
51
|
+
end
|
52
|
+
else
|
53
|
+
raise "unexpected match policy: #{preferred_match_policy.inspect}"
|
54
|
+
end
|
55
|
+
|
56
|
+
eligibility_errors.empty?
|
57
|
+
end
|
58
|
+
|
59
|
+
def actionable?(line_item)
|
60
|
+
case preferred_match_policy
|
61
|
+
when 'any', 'all'
|
62
|
+
product_ids.include? line_item.variant.product_id
|
63
|
+
when 'none'
|
64
|
+
product_ids.exclude? line_item.variant.product_id
|
65
|
+
else
|
66
|
+
raise "unexpected match policy: #{preferred_match_policy.inspect}"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def product_ids_string
|
71
|
+
product_ids.join(',')
|
72
|
+
end
|
73
|
+
|
74
|
+
def product_ids_string=(product_ids)
|
75
|
+
self.product_ids = product_ids.to_s.split(',').map(&:strip)
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def order_products(order)
|
81
|
+
order.line_items.map(&:variant).map(&:product)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Spree
|
4
|
+
class Promotion
|
5
|
+
module Rules
|
6
|
+
class Store < PromotionRule
|
7
|
+
has_many :promotion_rule_stores, class_name: "Spree::PromotionRuleStore",
|
8
|
+
foreign_key: :promotion_rule_id,
|
9
|
+
dependent: :destroy
|
10
|
+
has_many :stores, through: :promotion_rule_stores, class_name: "Spree::Store"
|
11
|
+
|
12
|
+
def preload_relations
|
13
|
+
[:stores]
|
14
|
+
end
|
15
|
+
|
16
|
+
def applicable?(promotable)
|
17
|
+
promotable.is_a?(Spree::Order)
|
18
|
+
end
|
19
|
+
|
20
|
+
def eligible?(order, _options = {})
|
21
|
+
stores.none? || stores.include?(order.store)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Spree
|
4
|
+
class Promotion < Spree::Base
|
5
|
+
module Rules
|
6
|
+
class Taxon < PromotionRule
|
7
|
+
has_many :promotion_rule_taxons, class_name: 'Spree::PromotionRuleTaxon', foreign_key: :promotion_rule_id,
|
8
|
+
dependent: :destroy
|
9
|
+
has_many :taxons, through: :promotion_rule_taxons, class_name: 'Spree::Taxon'
|
10
|
+
|
11
|
+
def preload_relations
|
12
|
+
[:taxons]
|
13
|
+
end
|
14
|
+
|
15
|
+
MATCH_POLICIES = %w(any all none)
|
16
|
+
|
17
|
+
validates_inclusion_of :preferred_match_policy, in: MATCH_POLICIES
|
18
|
+
|
19
|
+
preference :match_policy, :string, default: MATCH_POLICIES.first
|
20
|
+
def applicable?(promotable)
|
21
|
+
promotable.is_a?(Spree::Order)
|
22
|
+
end
|
23
|
+
|
24
|
+
def eligible?(order, _options = {})
|
25
|
+
order_taxons = taxons_in_order(order)
|
26
|
+
|
27
|
+
case preferred_match_policy
|
28
|
+
when 'all'
|
29
|
+
matches_all = taxons.all? do |rule_taxon|
|
30
|
+
order_taxons.where(id: rule_taxon.self_and_descendants.ids).exists?
|
31
|
+
end
|
32
|
+
|
33
|
+
unless matches_all
|
34
|
+
eligibility_errors.add(:base, eligibility_error_message(:missing_taxon), error_code: :missing_taxon)
|
35
|
+
end
|
36
|
+
when 'any'
|
37
|
+
unless order_taxons.where(id: rule_taxon_ids_with_children).exists?
|
38
|
+
eligibility_errors.add(:base, eligibility_error_message(:no_matching_taxons), error_code: :no_matching_taxons)
|
39
|
+
end
|
40
|
+
when 'none'
|
41
|
+
if order_taxons.where(id: rule_taxon_ids_with_children).exists?
|
42
|
+
eligibility_errors.add(:base, eligibility_error_message(:has_excluded_taxon), error_code: :has_excluded_taxon)
|
43
|
+
end
|
44
|
+
else
|
45
|
+
raise "unexpected match policy: #{preferred_match_policy.inspect}"
|
46
|
+
end
|
47
|
+
|
48
|
+
eligibility_errors.empty?
|
49
|
+
end
|
50
|
+
|
51
|
+
def actionable?(line_item)
|
52
|
+
found = Spree::Classification.where(
|
53
|
+
product_id: line_item.variant.product_id,
|
54
|
+
taxon_id: rule_taxon_ids_with_children
|
55
|
+
).exists?
|
56
|
+
|
57
|
+
case preferred_match_policy
|
58
|
+
when 'any', 'all'
|
59
|
+
found
|
60
|
+
when 'none'
|
61
|
+
!found
|
62
|
+
else
|
63
|
+
raise "unexpected match policy: #{preferred_match_policy.inspect}"
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def taxon_ids_string
|
68
|
+
taxons.pluck(:id).join(',')
|
69
|
+
end
|
70
|
+
|
71
|
+
def taxon_ids_string=(taxon_ids)
|
72
|
+
taxon_ids = taxon_ids.to_s.split(',').map(&:strip)
|
73
|
+
self.taxons = Spree::Taxon.find(taxon_ids)
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
# All taxons in an order
|
79
|
+
def taxons_in_order(order)
|
80
|
+
Spree::Taxon.joins(products: { variants_including_master: :line_items })
|
81
|
+
.where(spree_line_items: { order_id: order.id }).distinct
|
82
|
+
end
|
83
|
+
|
84
|
+
# ids of taxons rules and taxons rules children
|
85
|
+
def rule_taxon_ids_with_children
|
86
|
+
taxons.flat_map { |taxon| taxon.self_and_descendants.ids }.uniq
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Spree
|
4
|
+
class Promotion < Spree::Base
|
5
|
+
module Rules
|
6
|
+
class User < PromotionRule
|
7
|
+
has_many :promotion_rule_users, class_name: 'Spree::PromotionRuleUser',
|
8
|
+
foreign_key: :promotion_rule_id,
|
9
|
+
dependent: :destroy
|
10
|
+
has_many :users, through: :promotion_rule_users, class_name: Spree::UserClassHandle.new
|
11
|
+
|
12
|
+
def preload_relations
|
13
|
+
[:users]
|
14
|
+
end
|
15
|
+
|
16
|
+
def applicable?(promotable)
|
17
|
+
promotable.is_a?(Spree::Order)
|
18
|
+
end
|
19
|
+
|
20
|
+
def eligible?(order, _options = {})
|
21
|
+
users.include?(order.user)
|
22
|
+
end
|
23
|
+
|
24
|
+
def user_ids_string
|
25
|
+
user_ids.join(',')
|
26
|
+
end
|
27
|
+
|
28
|
+
def user_ids_string=(user_ids)
|
29
|
+
self.user_ids = user_ids.to_s.split(',').map(&:strip)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Spree
|
4
|
+
class Promotion < Spree::Base
|
5
|
+
module Rules
|
6
|
+
class UserLoggedIn < PromotionRule
|
7
|
+
def applicable?(promotable)
|
8
|
+
promotable.is_a?(Spree::Order)
|
9
|
+
end
|
10
|
+
|
11
|
+
def eligible?(order, _options = {})
|
12
|
+
if order.user.blank?
|
13
|
+
eligibility_errors.add(:base, eligibility_error_message(:no_user_specified), error_code: :no_user_specified)
|
14
|
+
end
|
15
|
+
eligibility_errors.empty?
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|