solidus_promotions 4.6.2 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/MIGRATING.md +10 -4
- data/README.md +7 -0
- data/app/javascript/backend/solidus_promotions/controllers/product_option_values_controller.js +3 -26
- data/app/javascript/backend/solidus_promotions/web_components/option_value_picker.js +36 -19
- data/app/javascript/backend/solidus_promotions/web_components/product_picker.js +6 -1
- data/app/jobs/solidus_promotions/promotion_code_batch_job.rb +1 -1
- data/app/models/concerns/solidus_promotions/adjustment_discounts.rb +20 -0
- data/app/models/concerns/solidus_promotions/benefits/line_item_benefit.rb +5 -0
- data/app/models/concerns/solidus_promotions/benefits/order_benefit.rb +5 -0
- data/app/models/concerns/solidus_promotions/benefits/shipment_benefit.rb +5 -0
- data/app/models/concerns/solidus_promotions/calculators/promotion_calculator.rb +7 -0
- data/app/models/concerns/solidus_promotions/conditions/line_item_applicable_order_level_condition.rb +17 -5
- data/app/models/concerns/solidus_promotions/conditions/line_item_level_condition.rb +15 -2
- data/app/models/concerns/solidus_promotions/conditions/option_value_condition.rb +21 -0
- data/app/models/concerns/solidus_promotions/conditions/order_level_condition.rb +15 -2
- data/app/models/concerns/solidus_promotions/conditions/product_condition.rb +28 -0
- data/app/models/concerns/solidus_promotions/conditions/shipment_level_condition.rb +15 -2
- data/app/models/concerns/solidus_promotions/conditions/taxon_condition.rb +77 -0
- data/app/models/concerns/solidus_promotions/coupon_code_normalizer.rb +37 -0
- data/app/models/concerns/solidus_promotions/discountable_amount.rb +3 -4
- data/app/models/concerns/solidus_promotions/discounted_amount.rb +54 -0
- data/app/models/solidus_promotions/benefit.rb +257 -36
- data/app/models/solidus_promotions/benefits/adjust_line_item.rb +28 -3
- data/app/models/solidus_promotions/benefits/adjust_line_item_quantity_groups.rb +1 -0
- data/app/models/solidus_promotions/benefits/adjust_shipment.rb +45 -3
- data/app/models/solidus_promotions/benefits/advertise_price.rb +28 -0
- data/app/models/solidus_promotions/benefits/create_discounted_item.rb +30 -7
- data/app/models/solidus_promotions/calculators/distributed_amount.rb +34 -8
- data/app/models/solidus_promotions/calculators/flat_rate.rb +52 -6
- data/app/models/solidus_promotions/calculators/flexi_rate.rb +69 -6
- data/app/models/solidus_promotions/calculators/percent.rb +40 -4
- data/app/models/solidus_promotions/calculators/percent_with_cap.rb +44 -3
- data/app/models/solidus_promotions/calculators/tiered_flat_rate.rb +81 -19
- data/app/models/solidus_promotions/calculators/tiered_percent.rb +96 -25
- data/app/models/solidus_promotions/calculators/tiered_percent_on_eligible_item_quantity.rb +101 -16
- data/app/models/solidus_promotions/condition.rb +186 -7
- data/app/models/solidus_promotions/conditions/first_order.rb +3 -1
- data/app/models/solidus_promotions/conditions/first_repeat_purchase_since.rb +3 -2
- data/app/models/solidus_promotions/conditions/item_total.rb +2 -1
- data/app/models/solidus_promotions/conditions/line_item_option_value.rb +4 -12
- data/app/models/solidus_promotions/conditions/line_item_product.rb +4 -22
- data/app/models/solidus_promotions/conditions/line_item_taxon.rb +7 -38
- data/app/models/solidus_promotions/conditions/minimum_quantity.rb +3 -2
- data/app/models/solidus_promotions/conditions/nth_order.rb +3 -2
- data/app/models/solidus_promotions/conditions/one_use_per_user.rb +2 -1
- data/app/models/solidus_promotions/conditions/option_value.rb +6 -11
- data/app/models/solidus_promotions/conditions/order_option_value.rb +19 -0
- data/app/models/solidus_promotions/conditions/order_product.rb +62 -0
- data/app/models/solidus_promotions/conditions/order_taxon.rb +60 -0
- data/app/models/solidus_promotions/conditions/price_option_value.rb +26 -0
- data/app/models/solidus_promotions/conditions/price_product.rb +36 -0
- data/app/models/solidus_promotions/conditions/price_taxon.rb +28 -0
- data/app/models/solidus_promotions/conditions/product.rb +17 -59
- data/app/models/solidus_promotions/conditions/shipping_method.rb +3 -5
- data/app/models/solidus_promotions/conditions/store.rb +2 -1
- data/app/models/solidus_promotions/conditions/taxon.rb +24 -73
- data/app/models/solidus_promotions/conditions/user.rb +2 -1
- data/app/models/solidus_promotions/conditions/user_logged_in.rb +1 -3
- data/app/models/solidus_promotions/conditions/user_role.rb +1 -3
- data/app/models/solidus_promotions/distributed_amounts_handler.rb +2 -6
- data/app/models/solidus_promotions/eligibility_results.rb +1 -0
- data/app/models/solidus_promotions/item_discount.rb +1 -0
- data/app/models/solidus_promotions/order_adjuster/discount_order.rb +29 -35
- data/app/models/solidus_promotions/order_adjuster/recalculate_promo_totals.rb +45 -0
- data/app/models/solidus_promotions/order_adjuster/set_discounts_to_zero.rb +33 -0
- data/app/models/solidus_promotions/order_adjuster.rb +4 -14
- data/app/models/solidus_promotions/order_promotion.rb +1 -0
- data/app/models/solidus_promotions/product_advertiser.rb +57 -0
- data/app/models/solidus_promotions/promotion.rb +12 -10
- data/app/models/solidus_promotions/promotion_code/batch_builder.rb +1 -1
- data/app/models/solidus_promotions/promotion_code.rb +4 -4
- data/app/models/solidus_promotions/promotion_code_batch.rb +1 -1
- data/app/models/solidus_promotions/promotion_handler/coupon.rb +1 -1
- data/app/models/solidus_promotions/promotion_handler/page.rb +1 -1
- data/app/models/solidus_promotions/promotion_lane.rb +48 -0
- data/app/models/solidus_promotions/shipping_rate_discount.rb +3 -0
- data/app/patches/models/solidus_promotions/line_item_patch.rb +2 -0
- data/app/patches/models/solidus_promotions/order_patch.rb +8 -0
- data/app/patches/models/solidus_promotions/order_recalculator_patch.rb +3 -1
- data/app/patches/models/solidus_promotions/price_patch.rb +31 -0
- data/app/patches/models/solidus_promotions/shipment_patch.rb +2 -0
- data/app/patches/models/solidus_promotions/shipping_rate_patch.rb +15 -0
- data/config/locales/en.yml +47 -11
- data/config/routes.rb +1 -1
- data/db/migrate/20230703101637_create_promotions.rb +2 -2
- data/db/migrate/20230703113625_create_promotion_benefits.rb +3 -3
- data/db/migrate/20230703141116_create_promotion_categories.rb +1 -1
- data/db/migrate/20230703143943_create_promotion_conditions.rb +1 -1
- data/db/migrate/20230704083830_add_condition_join_tables.rb +8 -8
- data/db/migrate/20230704102444_create_promotion_codes.rb +1 -1
- data/db/migrate/20230704102656_create_promotion_code_batches.rb +1 -1
- data/db/migrate/20230705171556_create_order_promotions.rb +3 -3
- data/db/migrate/20230725074235_create_shipping_rate_discounts.rb +2 -2
- data/db/migrate/20231104135812_add_managed_by_order_benefit_to_line_items.rb +1 -1
- data/db/migrate/20251104170913_update_promotion_code_value_collation.rb +38 -0
- data/db/migrate/20251104214304_separate_out_order_only_conditions.rb +41 -0
- data/eslint.config.mjs +29 -0
- data/lib/components/admin/solidus_promotions/promotion_categories/index/component.rb +6 -6
- data/lib/components/admin/solidus_promotions/promotions/index/component.rb +5 -5
- data/lib/solidus_promotions/configuration.rb +57 -12
- data/lib/solidus_promotions/promotion_map.rb +14 -14
- data/lib/solidus_promotions/testing_support/shared_examples/option_value_condition.rb +18 -0
- data/lib/solidus_promotions/testing_support/shared_examples/product_condition.rb +37 -0
- data/lib/solidus_promotions/testing_support/shared_examples/promotion_calculator.rb +11 -0
- data/lib/solidus_promotions/testing_support/shared_examples/taxon_condition.rb +37 -0
- data/lib/solidus_promotions/testing_support/shared_examples.rb +6 -0
- data/lib/views/backend/solidus_promotions/admin/benefit_fields/_advertise_price.html.erb +7 -0
- data/lib/views/backend/solidus_promotions/admin/calculator_fields/_default_fields.html.erb +1 -1
- data/lib/views/backend/solidus_promotions/admin/calculator_fields/percent/_fields.html.erb +1 -1
- data/lib/views/backend/solidus_promotions/admin/condition_fields/_line_item_option_value.html.erb +6 -5
- data/lib/views/backend/solidus_promotions/admin/condition_fields/_option_value.html.erb +6 -12
- data/lib/views/backend/solidus_promotions/admin/condition_fields/_price_option_value.html.erb +26 -0
- data/lib/views/backend/solidus_promotions/admin/condition_fields/_price_product.html.erb +21 -0
- data/lib/views/backend/solidus_promotions/admin/condition_fields/_price_taxon.html.erb +17 -0
- data/lib/views/backend/solidus_promotions/admin/condition_fields/_product.html.erb +0 -7
- data/lib/views/backend/solidus_promotions/admin/condition_fields/_taxon.html.erb +0 -7
- data/lib/views/backend/solidus_promotions/admin/condition_fields/line_item_option_value/_option_value_fields.html.erb +10 -4
- data/solidus_promotions.gemspec +1 -1
- metadata +37 -6
- data/.eslintrc.json +0 -10
- data/app/models/solidus_promotions/order_adjuster/persist_discounted_order.rb +0 -79
|
@@ -4,34 +4,119 @@ require_dependency "spree/calculator"
|
|
|
4
4
|
|
|
5
5
|
module SolidusPromotions
|
|
6
6
|
module Calculators
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
7
|
+
# A calculator that applies tiered percentage discounts based on the total quantity of eligible items.
|
|
8
|
+
#
|
|
9
|
+
# This calculator defines discount tiers based on the combined quantity of all eligible line items
|
|
10
|
+
# in an order (not their monetary value). Each tier specifies a minimum quantity threshold and the
|
|
11
|
+
# corresponding percentage discount to apply. The calculator selects the highest tier that the
|
|
12
|
+
# order's eligible item quantity meets or exceeds.
|
|
13
|
+
#
|
|
14
|
+
# The tier thresholds are evaluated against the total quantity of eligible line items, but the
|
|
15
|
+
# percentage discount is applied to each individual item's discountable amount. This makes it
|
|
16
|
+
# ideal for "buy more, save more" promotions based on item count rather than order value.
|
|
17
|
+
#
|
|
18
|
+
# If the total eligible quantity doesn't meet any tier threshold, the base percentage is used.
|
|
19
|
+
# The discount is only applied if the currency matches the preferred currency.
|
|
20
|
+
#
|
|
21
|
+
# @example Use case: Bulk quantity discounts
|
|
22
|
+
# # Buy 10+ items get 5% off, 25+ get 10% off, 50+ get 15% off
|
|
23
|
+
# calculator = TieredPercentOnEligibleItemQuantity.new(
|
|
24
|
+
# preferred_base_percent: 0,
|
|
25
|
+
# preferred_tiers: {
|
|
26
|
+
# 10 => 5, # 5% off when total eligible quantity >= 10
|
|
27
|
+
# 25 => 10, # 10% off when total eligible quantity >= 25
|
|
28
|
+
# 50 => 15 # 15% off when total eligible quantity >= 50
|
|
29
|
+
# },
|
|
30
|
+
# preferred_currency: 'USD'
|
|
31
|
+
# )
|
|
32
|
+
#
|
|
33
|
+
# @example Use case: Multi-item bundle promotions
|
|
34
|
+
# # Encourage buying multiple items from a category
|
|
35
|
+
# calculator = TieredPercentOnEligibleItemQuantity.new(
|
|
36
|
+
# preferred_base_percent: 0,
|
|
37
|
+
# preferred_tiers: {
|
|
38
|
+
# 3 => 10, # 10% off when buying 3+ eligible items
|
|
39
|
+
# 5 => 15, # 15% off when buying 5+ eligible items
|
|
40
|
+
# 10 => 20 # 20% off when buying 10+ eligible items
|
|
41
|
+
# },
|
|
42
|
+
# preferred_currency: 'USD'
|
|
43
|
+
# )
|
|
44
|
+
class TieredPercentOnEligibleItemQuantity < Spree::Calculator
|
|
45
|
+
include PromotionCalculator
|
|
46
|
+
|
|
47
|
+
preference :base_percent, :decimal, default: Spree::ZERO
|
|
48
|
+
preference :tiers, :hash, default: {10 => 5}
|
|
49
|
+
preference :currency, :string, default: -> { Spree::Config[:currency] }
|
|
18
50
|
|
|
19
|
-
|
|
20
|
-
|
|
51
|
+
before_validation :transform_preferred_tiers
|
|
52
|
+
|
|
53
|
+
# Computes the tiered percentage discount for an item based on total eligible item quantity.
|
|
54
|
+
#
|
|
55
|
+
# Evaluates the total quantity of all eligible line items in the order against all defined
|
|
56
|
+
# tiers and selects the highest tier threshold that is met or exceeded. Returns a percentage
|
|
57
|
+
# of the item's discountable amount based on the matching tier, or the base percentage if no
|
|
58
|
+
# tier threshold is met. Returns 0 if the currency doesn't match.
|
|
59
|
+
#
|
|
60
|
+
# @param item [Object] The object to calculate the discount for (e.g., LineItem, Shipment, ShippingRate)
|
|
61
|
+
#
|
|
62
|
+
# @return [BigDecimal] The percentage-based discount amount, rounded to currency precision
|
|
63
|
+
#
|
|
64
|
+
# @example Computing discount with tier matching
|
|
65
|
+
# calculator = TieredPercentOnEligibleItemQuantity.new(
|
|
66
|
+
# preferred_base_percent: 0,
|
|
67
|
+
# preferred_tiers: { 10 => 10, 25 => 15 }
|
|
68
|
+
# )
|
|
69
|
+
# # Order has 3 eligible line items with quantities: 5, 6, 4 (total: 15)
|
|
70
|
+
# line_item.discountable_amount # => 50.00
|
|
71
|
+
# calculator.compute_item(line_item) # => 5.00 (10% of $50, matches quantity tier of 10)
|
|
72
|
+
#
|
|
73
|
+
# @example Computing discount below all tiers
|
|
74
|
+
# calculator = TieredPercentOnEligibleItemQuantity.new(
|
|
75
|
+
# preferred_base_percent: 5,
|
|
76
|
+
# preferred_tiers: { 10 => 10, 25 => 15 }
|
|
77
|
+
# )
|
|
78
|
+
# # Order has 2 eligible line items with quantities: 3, 4 (total: 7)
|
|
79
|
+
# line_item.discountable_amount # => 30.00
|
|
80
|
+
# calculator.compute_item(line_item) # => 1.50 (5% base percent of $30)
|
|
81
|
+
#
|
|
82
|
+
# @example Computing discount with currency mismatch
|
|
83
|
+
# calculator = TieredPercentOnEligibleItemQuantity.new(
|
|
84
|
+
# preferred_currency: 'USD',
|
|
85
|
+
# preferred_tiers: { 10 => 10 }
|
|
86
|
+
# )
|
|
87
|
+
# order.currency # => 'EUR'
|
|
88
|
+
# calculator.compute_item(line_item) # => 0
|
|
89
|
+
def compute_item(item)
|
|
90
|
+
order = item.order
|
|
21
91
|
|
|
22
92
|
_base, percent = preferred_tiers.sort.reverse.detect do |value, _|
|
|
23
93
|
eligible_line_items_quantity_total(order) >= value
|
|
24
94
|
end
|
|
25
95
|
if preferred_currency.casecmp(order.currency).zero?
|
|
26
|
-
|
|
27
|
-
(line_item.discountable_amount * (percent || preferred_base_percent) / 100).round(currency_exponent)
|
|
96
|
+
round_to_currency(item.discountable_amount * (percent || preferred_base_percent) / 100, preferred_currency)
|
|
28
97
|
else
|
|
29
|
-
|
|
98
|
+
Spree::ZERO
|
|
30
99
|
end
|
|
31
100
|
end
|
|
101
|
+
alias_method :compute_shipment, :compute_item
|
|
102
|
+
alias_method :compute_shipping_rate, :compute_item
|
|
103
|
+
alias_method :compute_line_item, :compute_item
|
|
32
104
|
|
|
33
105
|
private
|
|
34
106
|
|
|
107
|
+
# Transforms preferred_tiers keys to integers and values to BigDecimal.
|
|
108
|
+
#
|
|
109
|
+
# Converts tier threshold keys (item quantities) to integers and percentage values
|
|
110
|
+
# to BigDecimal for consistent calculations.
|
|
111
|
+
def transform_preferred_tiers
|
|
112
|
+
preferred_tiers.transform_keys!(&:to_i)
|
|
113
|
+
preferred_tiers.transform_values! { |value| value.to_s.to_d }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Calculates the total quantity of all eligible line items in the order.
|
|
117
|
+
#
|
|
118
|
+
# @param order [Spree::Order] The order to calculate eligible quantity for
|
|
119
|
+
# @return [Integer] The sum of quantities for all applicable line items
|
|
35
120
|
def eligible_line_items_quantity_total(order)
|
|
36
121
|
calculable.applicable_line_items(order).sum(&:quantity)
|
|
37
122
|
end
|
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SolidusPromotions
|
|
4
|
+
# Base class for all promotion conditions.
|
|
5
|
+
#
|
|
6
|
+
# Conditions determine whether a promotion is eligible to be applied to a specific
|
|
7
|
+
# promotable object (such as an order or line item). Each condition subclass implements
|
|
8
|
+
# the eligibility logic and specifies what type of objects it can be applied to.
|
|
9
|
+
#
|
|
10
|
+
# Conditions work at different levels:
|
|
11
|
+
# - Order-level conditions (include OrderLevelCondition): Check entire orders
|
|
12
|
+
# - Line item-level conditions (include LineItemLevelCondition): Check individual line items
|
|
13
|
+
# - Hybrid conditions (include LineItemApplicableOrderLevelCondition): Check orders but can also
|
|
14
|
+
# filter which line items are eligible
|
|
15
|
+
#
|
|
16
|
+
# @abstract Subclass and override {#applicable?} and {#eligible?} to implement
|
|
17
|
+
# a custom condition.
|
|
18
|
+
#
|
|
19
|
+
# @example Creating an order-level condition
|
|
20
|
+
# class MinimumPurchaseCondition < Condition
|
|
21
|
+
# include OrderLevelCondition
|
|
22
|
+
#
|
|
23
|
+
# preference :minimum_amount, :decimal, default: 50.00
|
|
24
|
+
#
|
|
25
|
+
# def eligible?(order, _options = {})
|
|
26
|
+
# if order.item_total < preferred_minimum_amount
|
|
27
|
+
# eligibility_errors.add(:base, "Order total too low")
|
|
28
|
+
# end
|
|
29
|
+
# eligibility_errors.empty?
|
|
30
|
+
# end
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# @example Creating a line item-level condition
|
|
34
|
+
# class SpecificProductCondition < Condition
|
|
35
|
+
# include LineItemLevelCondition
|
|
36
|
+
#
|
|
37
|
+
# preference :product_id, :integer
|
|
38
|
+
#
|
|
39
|
+
# def eligible?(line_item, _options = {})
|
|
40
|
+
# if line_item.product_id != preferred_product_id
|
|
41
|
+
# eligibility_errors.add(:base, "Wrong product")
|
|
42
|
+
# end
|
|
43
|
+
# eligibility_errors.empty?
|
|
44
|
+
# end
|
|
45
|
+
# end
|
|
4
46
|
class Condition < Spree::Base
|
|
5
47
|
include Spree::Preferences::Persistable
|
|
6
48
|
|
|
@@ -12,46 +54,183 @@ module SolidusPromotions
|
|
|
12
54
|
validate :unique_per_benefit, on: :create
|
|
13
55
|
validate :possible_condition_for_benefit, if: -> { benefit.present? }
|
|
14
56
|
|
|
57
|
+
class << self
|
|
58
|
+
# Returns the subset of conditions that can compute eligibility instances of the provided array of classes.
|
|
59
|
+
#
|
|
60
|
+
# @example SolidusPromotions::Condition.applicable_to([Spree::Order, Spree::LineItem])
|
|
61
|
+
#
|
|
62
|
+
# @param [Array<Class>] An array of classes to compute eligibility for
|
|
63
|
+
# @return [Array<SolidusPromotions::Condition>] Conditions that can compute eligibility for the provided classes.
|
|
64
|
+
def applicable_to(classes)
|
|
65
|
+
SolidusPromotions.config.conditions.select do |condition_class|
|
|
66
|
+
(condition_class.instance_methods & classes.map { |k| eligible_method_for(k) }).any?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Generates the eligibility method name for a promotable
|
|
71
|
+
#
|
|
72
|
+
# @return [Symbol] the method name
|
|
73
|
+
def eligible_method_for(promotable_class)
|
|
74
|
+
:"#{promotable_class.name.demodulize.underscore}_eligible?"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns relations that should be preloaded for this condition.
|
|
79
|
+
#
|
|
80
|
+
# Override this method in subclasses to specify associations that should be eager loaded
|
|
81
|
+
# to avoid N+1 queries when evaluating conditions.
|
|
82
|
+
#
|
|
83
|
+
# @return [Array<Symbol>] An array of association names to preload
|
|
84
|
+
#
|
|
85
|
+
# @example Preloading products association
|
|
86
|
+
# def preload_relations
|
|
87
|
+
# [:products]
|
|
88
|
+
# end
|
|
15
89
|
def preload_relations
|
|
16
90
|
[]
|
|
17
91
|
end
|
|
18
92
|
|
|
19
|
-
|
|
20
|
-
|
|
93
|
+
# Determines if this condition can be applied to a given promotable object.
|
|
94
|
+
#
|
|
95
|
+
# @param _promotable [Object] The object to check (e.g., Spree::Order, Spree::LineItem)
|
|
96
|
+
#
|
|
97
|
+
# @return [Boolean] true if this condition applies to the promotable type
|
|
98
|
+
#
|
|
99
|
+
# @example Condition applicability
|
|
100
|
+
# condition.applicable?(order) # => true
|
|
101
|
+
# condition.applicable?(line_item) # => false
|
|
102
|
+
def applicable?(promotable)
|
|
103
|
+
respond_to?(eligible_method_for(promotable))
|
|
21
104
|
end
|
|
22
105
|
|
|
23
|
-
|
|
24
|
-
|
|
106
|
+
# Determines if the promotable object meets this condition's eligibility requirements.
|
|
107
|
+
#
|
|
108
|
+
# This typically dispatches to a specific eligibility method defined on a subclass, such as
|
|
109
|
+
# `#order_eligible?` or `line_item_eligible?`.
|
|
110
|
+
#
|
|
111
|
+
# @param _promotable [Object] The object to evaluate (e.g., Spree::Order, Spree::LineItem)
|
|
112
|
+
# @param _options [Hash] Additional options for eligibility checking
|
|
113
|
+
#
|
|
114
|
+
# @return [Boolean] true if the promotable meets the condition, false otherwise
|
|
115
|
+
#
|
|
116
|
+
# @see #eligibility_errors
|
|
117
|
+
def eligible?(promotable, ...)
|
|
118
|
+
if applicable?(promotable)
|
|
119
|
+
send(eligible_method_for(promotable), promotable, ...)
|
|
120
|
+
else
|
|
121
|
+
raise NotImplementedError, "Please implement #{eligible_method_for(promotable)} in your condition"
|
|
122
|
+
end
|
|
25
123
|
end
|
|
26
124
|
|
|
27
|
-
def
|
|
28
|
-
|
|
125
|
+
def self.inherited(klass)
|
|
126
|
+
def klass.method_added(method_added)
|
|
127
|
+
if method_added == :eligible?
|
|
128
|
+
Spree.deprecator.warn <<~MSG
|
|
129
|
+
Please refactor `#{name}`. You're defining `eligible?`. Instead, define method for each type of promotable
|
|
130
|
+
that your condition can be applied to. For example:
|
|
131
|
+
```
|
|
132
|
+
class MyCondition < SolidusPromotions::Condition
|
|
133
|
+
def applicable?(promotable)
|
|
134
|
+
promotable.is_a?(Spree::Order)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def eligible?(order)
|
|
138
|
+
order.total > 20
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
can now become
|
|
142
|
+
```
|
|
143
|
+
class MyCondition < SolidusPromotions::Condition
|
|
144
|
+
def order_eligible?(order)
|
|
145
|
+
order.total > 20
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
MSG
|
|
150
|
+
end
|
|
151
|
+
super
|
|
152
|
+
end
|
|
153
|
+
super
|
|
29
154
|
end
|
|
30
155
|
|
|
156
|
+
def level
|
|
157
|
+
raise NotImplementedError, "level should be implemented in a sub-class of SolidusPromotions::Condition"
|
|
158
|
+
end
|
|
159
|
+
deprecate :level, deprecator: Spree.deprecator
|
|
160
|
+
|
|
161
|
+
# Returns an errors object for tracking eligibility failures.
|
|
162
|
+
#
|
|
163
|
+
# When {#eligible?} determines that a promotable doesn't meet the condition,
|
|
164
|
+
# it should add descriptive errors to this object. These errors are used to
|
|
165
|
+
# provide feedback about why a promotion isn't being applied.
|
|
166
|
+
#
|
|
167
|
+
# @return [ActiveModel::Errors] An errors collection for this condition
|
|
168
|
+
#
|
|
169
|
+
# @example Adding an eligibility error
|
|
170
|
+
# def eligible?(order, _options = {})
|
|
171
|
+
# if order.item_total < 50
|
|
172
|
+
# eligibility_errors.add(:base, "Minimum order is $50", error_code: :item_total_too_low)
|
|
173
|
+
# end
|
|
174
|
+
# eligibility_errors.empty?
|
|
175
|
+
# end
|
|
31
176
|
def eligibility_errors
|
|
32
177
|
@eligibility_errors ||= ActiveModel::Errors.new(self)
|
|
33
178
|
end
|
|
34
179
|
|
|
180
|
+
# Returns the partial path for rendering this condition in the admin interface.
|
|
181
|
+
#
|
|
182
|
+
# @return [String] The path to the admin form partial for this condition
|
|
183
|
+
#
|
|
184
|
+
# @example
|
|
185
|
+
# # For SolidusPromotions::Conditions::ItemTotal
|
|
186
|
+
# # => "solidus_promotions/admin/condition_fields/item_total"
|
|
35
187
|
def to_partial_path
|
|
36
188
|
"solidus_promotions/admin/condition_fields/#{model_name.element}"
|
|
37
189
|
end
|
|
38
190
|
|
|
191
|
+
# Determines if this condition can be updated in the admin interface.
|
|
192
|
+
#
|
|
193
|
+
# A condition is considered updateable if it has any preferences that can be configured.
|
|
194
|
+
#
|
|
195
|
+
# @return [Boolean] true if the condition has configurable preferences
|
|
39
196
|
def updateable?
|
|
40
197
|
preferences.any?
|
|
41
198
|
end
|
|
42
199
|
|
|
43
200
|
private
|
|
44
201
|
|
|
202
|
+
def eligible_method_for(promotable)
|
|
203
|
+
self.class.eligible_method_for(promotable.class)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Validates that only one instance of this condition type exists per benefit.
|
|
207
|
+
#
|
|
208
|
+
# Prevents duplicate conditions of the same type from being added to a single benefit.
|
|
45
209
|
def unique_per_benefit
|
|
46
210
|
return unless self.class.exists?(benefit_id: benefit_id, type: self.class.name)
|
|
47
211
|
|
|
48
212
|
errors.add(:benefit, :already_contains_condition_type)
|
|
49
213
|
end
|
|
50
214
|
|
|
215
|
+
# Validates that this condition type is allowed for the associated benefit.
|
|
216
|
+
#
|
|
217
|
+
# Checks the benefit's {Benefit#applicable_conditions} to ensure this condition
|
|
218
|
+
# type is compatible.
|
|
51
219
|
def possible_condition_for_benefit
|
|
52
|
-
benefit.
|
|
220
|
+
benefit.class.applicable_conditions.include?(self.class) || errors.add(:type, :invalid_condition_type)
|
|
53
221
|
end
|
|
54
222
|
|
|
223
|
+
# Generates a translated eligibility error message.
|
|
224
|
+
#
|
|
225
|
+
# Looks up the error message in the I18n translations under the condition's scope.
|
|
226
|
+
#
|
|
227
|
+
# @param key [Symbol] The I18n key for the error message
|
|
228
|
+
# @param options [Hash] Interpolation options for the message
|
|
229
|
+
#
|
|
230
|
+
# @return [String] The translated error message
|
|
231
|
+
#
|
|
232
|
+
# @example
|
|
233
|
+
# eligibility_error_message(:item_total_too_low, minimum: "$50")
|
|
55
234
|
def eligibility_error_message(key, options = {})
|
|
56
235
|
I18n.t(key, scope: [:solidus_promotions, :eligibility_errors, self.class.name.underscore], **options)
|
|
57
236
|
end
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
module SolidusPromotions
|
|
4
4
|
module Conditions
|
|
5
5
|
class FirstOrder < Condition
|
|
6
|
+
# TODO: Remove in Solidus 5
|
|
6
7
|
include OrderLevelCondition
|
|
8
|
+
|
|
7
9
|
attr_reader :user, :email
|
|
8
10
|
|
|
9
|
-
def
|
|
11
|
+
def order_eligible?(order, options = {})
|
|
10
12
|
@user = order.try(:user) || options[:user]
|
|
11
13
|
@email = order.email
|
|
12
14
|
|
|
@@ -3,16 +3,17 @@
|
|
|
3
3
|
module SolidusPromotions
|
|
4
4
|
module Conditions
|
|
5
5
|
class FirstRepeatPurchaseSince < Condition
|
|
6
|
+
# TODO: Remove in Solidus 5
|
|
6
7
|
include OrderLevelCondition
|
|
7
8
|
|
|
8
9
|
preference :days_ago, :integer, default: 365
|
|
9
|
-
validates :preferred_days_ago, numericality: {
|
|
10
|
+
validates :preferred_days_ago, numericality: {only_integer: true, greater_than: 0}
|
|
10
11
|
|
|
11
12
|
# This is never eligible if the order does not have a user, and that user does not have any previous completed orders.
|
|
12
13
|
#
|
|
13
14
|
# This is eligible if the user's most recently completed order is more than the preferred days ago
|
|
14
15
|
# @param order [Spree::Order]
|
|
15
|
-
def
|
|
16
|
+
def order_eligible?(order, _options = {})
|
|
16
17
|
return false unless order.user
|
|
17
18
|
|
|
18
19
|
last_order = last_completed_order(order.user)
|
|
@@ -8,6 +8,7 @@ module SolidusPromotions
|
|
|
8
8
|
# To add extra operators please override `self.operators_map` or any other helper method.
|
|
9
9
|
# To customize the error message you can also override `ineligible_message`.
|
|
10
10
|
class ItemTotal < Condition
|
|
11
|
+
# TODO: Remove in Solidus 5
|
|
11
12
|
include OrderLevelCondition
|
|
12
13
|
|
|
13
14
|
preference :amount, :decimal, default: 100.00
|
|
@@ -28,7 +29,7 @@ module SolidusPromotions
|
|
|
28
29
|
end
|
|
29
30
|
end
|
|
30
31
|
|
|
31
|
-
def
|
|
32
|
+
def order_eligible?(order, _options = {})
|
|
32
33
|
return false unless order.currency == preferred_currency
|
|
33
34
|
|
|
34
35
|
unless total_for_order(order).send(operator, threshold)
|
|
@@ -3,26 +3,18 @@
|
|
|
3
3
|
module SolidusPromotions
|
|
4
4
|
module Conditions
|
|
5
5
|
class LineItemOptionValue < Condition
|
|
6
|
+
# TODO: Remove in Solidus 5
|
|
6
7
|
include LineItemLevelCondition
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
include OptionValueCondition
|
|
9
10
|
|
|
10
|
-
def
|
|
11
|
+
def line_item_eligible?(line_item, _options = {})
|
|
11
12
|
pid = line_item.product.id
|
|
12
|
-
ovids = line_item.variant.
|
|
13
|
+
ovids = line_item.variant.option_value_ids
|
|
13
14
|
|
|
14
15
|
product_ids.include?(pid) && (value_ids(pid) & ovids).present?
|
|
15
16
|
end
|
|
16
17
|
|
|
17
|
-
def preferred_eligible_values
|
|
18
|
-
values = preferences[:eligible_values] || {}
|
|
19
|
-
values.keys.map(&:to_i).zip(
|
|
20
|
-
values.values.map do |value|
|
|
21
|
-
(value.is_a?(Array) ? value : value.split(",")).map(&:to_i)
|
|
22
|
-
end
|
|
23
|
-
).to_h
|
|
24
|
-
end
|
|
25
|
-
|
|
26
18
|
private
|
|
27
19
|
|
|
28
20
|
def product_ids
|
|
@@ -4,26 +4,16 @@ module SolidusPromotions
|
|
|
4
4
|
module Conditions
|
|
5
5
|
# A condition to apply a promotion only to line items with or without a chosen product
|
|
6
6
|
class LineItemProduct < Condition
|
|
7
|
+
# TODO: Remove in Solidus 5
|
|
7
8
|
include LineItemLevelCondition
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
include ProductCondition
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
dependent: :destroy,
|
|
13
|
-
foreign_key: :condition_id,
|
|
14
|
-
class_name: "SolidusPromotions::ConditionProduct",
|
|
15
|
-
inverse_of: :condition
|
|
16
|
-
has_many :products,
|
|
17
|
-
class_name: "Spree::Product",
|
|
18
|
-
through: :condition_products
|
|
12
|
+
MATCH_POLICIES = %w[include exclude].freeze
|
|
19
13
|
|
|
20
14
|
preference :match_policy, :string, default: MATCH_POLICIES.first
|
|
21
15
|
|
|
22
|
-
def
|
|
23
|
-
[:products]
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def eligible?(line_item, _options = {})
|
|
16
|
+
def line_item_eligible?(line_item, _options = {})
|
|
27
17
|
order_includes_product = product_ids.include?(line_item.variant.product_id)
|
|
28
18
|
success = inverse? ? !order_includes_product : order_includes_product
|
|
29
19
|
|
|
@@ -39,14 +29,6 @@ module SolidusPromotions
|
|
|
39
29
|
success
|
|
40
30
|
end
|
|
41
31
|
|
|
42
|
-
def product_ids_string
|
|
43
|
-
product_ids.join(",")
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def product_ids_string=(product_ids)
|
|
47
|
-
self.product_ids = product_ids.to_s.split(",").map(&:strip)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
32
|
private
|
|
51
33
|
|
|
52
34
|
def inverse?
|
|
@@ -3,60 +3,29 @@
|
|
|
3
3
|
module SolidusPromotions
|
|
4
4
|
module Conditions
|
|
5
5
|
class LineItemTaxon < Condition
|
|
6
|
+
# TODO: Remove in Solidus 5
|
|
6
7
|
include LineItemLevelCondition
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
class_name: "SolidusPromotions::ConditionTaxon",
|
|
10
|
-
foreign_key: :condition_id,
|
|
11
|
-
dependent: :destroy,
|
|
12
|
-
inverse_of: :condition
|
|
13
|
-
has_many :taxons, through: :condition_taxons, class_name: "Spree::Taxon"
|
|
9
|
+
include TaxonCondition
|
|
14
10
|
|
|
15
11
|
MATCH_POLICIES = %w[include exclude].freeze
|
|
16
12
|
|
|
17
|
-
validates :preferred_match_policy, inclusion: {
|
|
13
|
+
validates :preferred_match_policy, inclusion: {in: MATCH_POLICIES}
|
|
18
14
|
|
|
19
15
|
preference :match_policy, :string, default: MATCH_POLICIES.first
|
|
20
16
|
|
|
21
|
-
def
|
|
22
|
-
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def eligible?(line_item, _options = {})
|
|
26
|
-
found = Spree::Classification.where(
|
|
27
|
-
product_id: line_item.variant.product_id,
|
|
28
|
-
taxon_id: condition_taxon_ids_with_children
|
|
29
|
-
).exists?
|
|
17
|
+
def line_item_eligible?(line_item, _options = {})
|
|
18
|
+
line_item_taxon_ids = line_item.variant.product.classifications.map(&:taxon_id)
|
|
30
19
|
|
|
31
20
|
case preferred_match_policy
|
|
32
21
|
when "include"
|
|
33
|
-
|
|
22
|
+
taxon_ids_with_children.any? { |taxon_and_descendant_ids| (line_item_taxon_ids & taxon_and_descendant_ids).any? }
|
|
34
23
|
when "exclude"
|
|
35
|
-
|
|
24
|
+
taxon_ids_with_children.none? { |taxon_and_descendant_ids| (line_item_taxon_ids & taxon_and_descendant_ids).any? }
|
|
36
25
|
else
|
|
37
26
|
raise "unexpected match policy: #{preferred_match_policy.inspect}"
|
|
38
27
|
end
|
|
39
28
|
end
|
|
40
|
-
|
|
41
|
-
def taxon_ids_string
|
|
42
|
-
taxons.pluck(:id).join(",")
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def taxon_ids_string=(taxon_ids)
|
|
46
|
-
taxon_ids = taxon_ids.to_s.split(",").map(&:strip)
|
|
47
|
-
self.taxons = Spree::Taxon.find(taxon_ids)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def updateable?
|
|
51
|
-
true
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
private
|
|
55
|
-
|
|
56
|
-
# ids of taxons conditions and taxons conditions children
|
|
57
|
-
def condition_taxon_ids_with_children
|
|
58
|
-
taxons.flat_map { |taxon| taxon.self_and_descendants.ids }.uniq
|
|
59
|
-
end
|
|
60
29
|
end
|
|
61
30
|
end
|
|
62
31
|
end
|
|
@@ -10,9 +10,10 @@ module SolidusPromotions
|
|
|
10
10
|
# it to a simple quantity check across the entire order which would be
|
|
11
11
|
# better served by an item total condition.
|
|
12
12
|
class MinimumQuantity < Condition
|
|
13
|
+
# TODO: Remove in Solidus 5
|
|
13
14
|
include OrderLevelCondition
|
|
14
15
|
|
|
15
|
-
validates :preferred_minimum_quantity, numericality: {
|
|
16
|
+
validates :preferred_minimum_quantity, numericality: {only_integer: true, greater_than: 0}
|
|
16
17
|
|
|
17
18
|
preference :minimum_quantity, :integer, default: 1
|
|
18
19
|
|
|
@@ -26,7 +27,7 @@ module SolidusPromotions
|
|
|
26
27
|
#
|
|
27
28
|
# @param order [Spree::Order] the order we want to check eligibility on
|
|
28
29
|
# @return [Boolean] true if promotion is eligible, false otherwise
|
|
29
|
-
def
|
|
30
|
+
def order_eligible?(order, _options = {})
|
|
30
31
|
if benefit.applicable_line_items(order).sum(&:quantity) < preferred_minimum_quantity
|
|
31
32
|
eligibility_errors.add(
|
|
32
33
|
:base,
|
|
@@ -3,18 +3,19 @@
|
|
|
3
3
|
module SolidusPromotions
|
|
4
4
|
module Conditions
|
|
5
5
|
class NthOrder < Condition
|
|
6
|
+
# TODO: Remove in Solidus 5
|
|
6
7
|
include OrderLevelCondition
|
|
7
8
|
|
|
8
9
|
preference :nth_order, :integer, default: 2
|
|
9
10
|
# It does not make sense to have this apply to the first order using preferred_nth_order == 1
|
|
10
11
|
# Instead we could use the first_order condition
|
|
11
|
-
validates :preferred_nth_order, numericality: {
|
|
12
|
+
validates :preferred_nth_order, numericality: {only_integer: true, greater_than: 1}
|
|
12
13
|
|
|
13
14
|
# This is never eligible if the order does not have a user, and that user does not have any previous completed orders.
|
|
14
15
|
#
|
|
15
16
|
# Use the first order condition if you want a promotion to be applied to the first order for a user.
|
|
16
17
|
# @param order [Spree::Order]
|
|
17
|
-
def
|
|
18
|
+
def order_eligible?(order, _options = {})
|
|
18
19
|
return false unless order.user
|
|
19
20
|
|
|
20
21
|
nth_order?(order)
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
module SolidusPromotions
|
|
4
4
|
module Conditions
|
|
5
5
|
class OneUsePerUser < Condition
|
|
6
|
+
# TODO: Remove in Solidus 5
|
|
6
7
|
include OrderLevelCondition
|
|
7
8
|
|
|
8
|
-
def
|
|
9
|
+
def order_eligible?(order, _options = {})
|
|
9
10
|
if order.user.present?
|
|
10
11
|
if promotion.used_by?(order.user, [order])
|
|
11
12
|
eligibility_errors.add(
|
|
@@ -5,23 +5,18 @@ module SolidusPromotions
|
|
|
5
5
|
class OptionValue < Condition
|
|
6
6
|
include LineItemApplicableOrderLevelCondition
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
include OptionValueCondition
|
|
9
9
|
|
|
10
|
-
def order_eligible?(order)
|
|
11
|
-
|
|
10
|
+
def order_eligible?(order, _options = {})
|
|
11
|
+
OrderOptionValue.new(preferred_eligible_values: preferred_eligible_values).eligible?(order)
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def line_item_eligible?(line_item)
|
|
14
|
+
def line_item_eligible?(line_item, _options = {})
|
|
15
15
|
LineItemOptionValue.new(preferred_eligible_values: preferred_eligible_values).eligible?(line_item)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
values.keys.map(&:to_i).zip(
|
|
21
|
-
values.values.map do |value|
|
|
22
|
-
(value.is_a?(Array) ? value : value.split(",")).map(&:to_i)
|
|
23
|
-
end
|
|
24
|
-
).to_h
|
|
18
|
+
def price_eligible?(price, _options = {})
|
|
19
|
+
PriceOptionValue.new(preferred_eligible_values: preferred_eligible_values).eligible?(price)
|
|
25
20
|
end
|
|
26
21
|
end
|
|
27
22
|
end
|