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,20 +4,66 @@ require_dependency "spree/calculator"
|
|
|
4
4
|
|
|
5
5
|
module SolidusPromotions
|
|
6
6
|
module Calculators
|
|
7
|
+
# A calculator that applies a flat rate discount amount.
|
|
8
|
+
#
|
|
9
|
+
# This calculator returns a fixed discount amount if the item's order currency
|
|
10
|
+
# matches the preferred currency, otherwise it returns zero.
|
|
7
11
|
class FlatRate < Spree::Calculator
|
|
8
12
|
include PromotionCalculator
|
|
9
13
|
|
|
10
|
-
preference :amount, :decimal, default:
|
|
14
|
+
preference :amount, :decimal, default: Spree::ZERO
|
|
11
15
|
preference :currency, :string, default: -> { Spree::Config[:currency] }
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
# Computes the discount amount for an item.
|
|
18
|
+
#
|
|
19
|
+
# Returns the preferred amount if the item's order currency matches the
|
|
20
|
+
# preferred currency, otherwise returns 0.
|
|
21
|
+
#
|
|
22
|
+
# @param item [Object] The item to calculate the discount for (e.g., LineItem, Shipment, ShippingRate)
|
|
23
|
+
#
|
|
24
|
+
# @return [BigDecimal] The discount amount (preferred_amount if currency matches, 0 otherwise)
|
|
25
|
+
#
|
|
26
|
+
# @example Computing discount for a line item with matching currency
|
|
27
|
+
# calculator = FlatRate.new(preferred_amount: 10, preferred_currency: 'USD')
|
|
28
|
+
# line_item.order.currency # => 'USD'
|
|
29
|
+
# calculator.compute_item(line_item) # => 10.0
|
|
30
|
+
#
|
|
31
|
+
# @example Computing discount for a line item with non-matching currency
|
|
32
|
+
# calculator = FlatRate.new(preferred_amount: 10, preferred_currency: 'USD')
|
|
33
|
+
# line_item.order.currency # => 'EUR'
|
|
34
|
+
# calculator.compute_item(line_item) # => 0
|
|
35
|
+
def compute_item(item)
|
|
36
|
+
currency = item.order.currency
|
|
37
|
+
if item && preferred_currency.casecmp(currency).zero?
|
|
38
|
+
compute_for_amount(item.discountable_amount)
|
|
17
39
|
else
|
|
18
|
-
|
|
40
|
+
Spree::ZERO
|
|
19
41
|
end
|
|
20
42
|
end
|
|
43
|
+
alias_method :compute_line_item, :compute_item
|
|
44
|
+
alias_method :compute_shipment, :compute_item
|
|
45
|
+
alias_method :compute_shipping_rate, :compute_item
|
|
46
|
+
|
|
47
|
+
def compute_price(price, options = {})
|
|
48
|
+
order = options[:order]
|
|
49
|
+
quantity = options[:quantity]
|
|
50
|
+
return preferred_amount unless order
|
|
51
|
+
return Spree::ZERO if order.currency != preferred_currency
|
|
52
|
+
line_item_with_variant = order.line_items.detect { _1.variant == price.variant }
|
|
53
|
+
desired_extra_amount = quantity * price.discountable_amount
|
|
54
|
+
current_discounted_amount = line_item_with_variant ? line_item_with_variant.discountable_amount : Spree::ZERO
|
|
55
|
+
round_to_currency(
|
|
56
|
+
(compute_for_amount(current_discounted_amount + desired_extra_amount.to_f) -
|
|
57
|
+
compute_for_amount(current_discounted_amount)) / quantity,
|
|
58
|
+
preferred_currency
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def compute_for_amount(amount)
|
|
65
|
+
[amount, preferred_amount].min
|
|
66
|
+
end
|
|
21
67
|
end
|
|
22
68
|
end
|
|
23
69
|
end
|
|
@@ -4,19 +4,82 @@ require_dependency "spree/calculator"
|
|
|
4
4
|
|
|
5
5
|
module SolidusPromotions
|
|
6
6
|
module Calculators
|
|
7
|
+
# A calculator that applies different discount amounts for the first item and additional items.
|
|
8
|
+
#
|
|
9
|
+
# This calculator allows setting a discount for the first item in a line item and a
|
|
10
|
+
# different discount for each additional item. Optionally, a maximum number of items
|
|
11
|
+
# can be specified to limit the discount calculation.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# # $5 off first item, $2 off each additional item, max 5 items
|
|
15
|
+
# calculator = FlexiRate.new(
|
|
16
|
+
# preferred_first_item: 5,
|
|
17
|
+
# preferred_additional_item: 2,
|
|
18
|
+
# preferred_max_items: 5
|
|
19
|
+
# )
|
|
20
|
+
# # Line item with quantity 3: $5 + ($2 × 2) = $9 discount
|
|
21
|
+
# # Line item with quantity 10: $5 + ($2 × 4) = $13 discount (limited to 5 items)
|
|
7
22
|
class FlexiRate < Spree::Calculator
|
|
8
23
|
include PromotionCalculator
|
|
9
24
|
|
|
10
|
-
preference :first_item, :decimal, default:
|
|
11
|
-
preference :additional_item, :decimal, default:
|
|
25
|
+
preference :first_item, :decimal, default: Spree::ZERO
|
|
26
|
+
preference :additional_item, :decimal, default: Spree::ZERO
|
|
12
27
|
preference :max_items, :integer, default: 0
|
|
13
28
|
preference :currency, :string, default: -> { Spree::Config[:currency] }
|
|
14
29
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
30
|
+
# Computes the discount amount for a line item based on its quantity.
|
|
31
|
+
#
|
|
32
|
+
# Calculates the total discount by applying the first_item rate to the first unit
|
|
33
|
+
# and the additional_item rate to remaining units. If max_items is set (non-zero),
|
|
34
|
+
# the calculation is limited to that number of items.
|
|
35
|
+
#
|
|
36
|
+
# @param line_item [Spree::LineItem] The line item to calculate the discount for
|
|
37
|
+
#
|
|
38
|
+
# @return [BigDecimal] The total discount amount based on quantity and preferences
|
|
39
|
+
#
|
|
40
|
+
# @example Computing discount for a line item with 3 items
|
|
41
|
+
# calculator = FlexiRate.new(preferred_first_item: 10, preferred_additional_item: 5)
|
|
42
|
+
# line_item.quantity # => 3
|
|
43
|
+
# calculator.compute_line_item(line_item) # => 20.0 (10 + 5 + 5)
|
|
44
|
+
#
|
|
45
|
+
# @example Computing discount with max_items limit
|
|
46
|
+
# calculator = FlexiRate.new(
|
|
47
|
+
# preferred_first_item: 10,
|
|
48
|
+
# preferred_additional_item: 5,
|
|
49
|
+
# preferred_max_items: 2
|
|
50
|
+
# )
|
|
51
|
+
# line_item.quantity # => 5
|
|
52
|
+
# calculator.compute_line_item(line_item) # => 15.0 (10 + 5, limited to 2 items)
|
|
53
|
+
def compute_line_item(line_item)
|
|
54
|
+
compute_for_quantity(line_item.quantity)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def compute_price(price, options = {})
|
|
58
|
+
order = options[:order]
|
|
59
|
+
desired_quantity = options[:quantity] || 0
|
|
60
|
+
return Spree::ZERO if desired_quantity.zero?
|
|
61
|
+
|
|
62
|
+
already_ordered_quantity = if order
|
|
63
|
+
order.line_items.detect do |line_item|
|
|
64
|
+
line_item.variant == price.variant
|
|
65
|
+
end&.quantity || 0
|
|
66
|
+
else
|
|
67
|
+
0
|
|
68
|
+
end
|
|
69
|
+
possible_discount = compute_for_quantity(already_ordered_quantity + desired_quantity)
|
|
70
|
+
existing_discount = compute_for_quantity(already_ordered_quantity)
|
|
71
|
+
round_to_currency(
|
|
72
|
+
(possible_discount - existing_discount) / desired_quantity,
|
|
73
|
+
price.currency
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def compute_for_quantity(quantity)
|
|
80
|
+
items_count = preferred_max_items.zero? ? quantity : [quantity, preferred_max_items].min
|
|
18
81
|
|
|
19
|
-
return Spree::ZERO if items_count
|
|
82
|
+
return Spree::ZERO if items_count.zero?
|
|
20
83
|
|
|
21
84
|
additional_items_count = items_count - 1
|
|
22
85
|
preferred_first_item + preferred_additional_item * additional_items_count
|
|
@@ -4,16 +4,52 @@ require_dependency "spree/calculator"
|
|
|
4
4
|
|
|
5
5
|
module SolidusPromotions
|
|
6
6
|
module Calculators
|
|
7
|
+
# A calculator that applies a percentage-based discount.
|
|
8
|
+
#
|
|
9
|
+
# This calculator computes the discount as a percentage of the item's discountable amount,
|
|
10
|
+
# rounded to the appropriate currency precision.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# calculator = Percent.new(preferred_percent: 15)
|
|
14
|
+
# # Line item with discountable_amount of $100
|
|
15
|
+
# calculator.compute_item(line_item) # => 15.00 (15% of $100)
|
|
7
16
|
class Percent < Spree::Calculator
|
|
8
17
|
include PromotionCalculator
|
|
9
18
|
|
|
10
19
|
preference :percent, :decimal, default: 0
|
|
11
20
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
21
|
+
# Computes the percentage-based discount for an item.
|
|
22
|
+
#
|
|
23
|
+
# Calculates the discount by applying the preferred percentage to the item's
|
|
24
|
+
# discountable amount, then rounds the result to the appropriate precision
|
|
25
|
+
# for the order's currency.
|
|
26
|
+
#
|
|
27
|
+
# @param object [Object] The object to calculate the discount for (e.g., LineItem, Shipment, ShippingRate)
|
|
28
|
+
#
|
|
29
|
+
# @return [BigDecimal] The discount amount, rounded to the order's currency precision
|
|
30
|
+
#
|
|
31
|
+
# @example Computing a 20% discount on a $50 line item
|
|
32
|
+
# calculator = Percent.new(preferred_percent: 20)
|
|
33
|
+
# line_item.discountable_amount # => 50.00
|
|
34
|
+
# calculator.compute_item(line_item) # => 10.00
|
|
35
|
+
#
|
|
36
|
+
# @example Computing a 15% discount on a shipment
|
|
37
|
+
# calculator = Percent.new(preferred_percent: 15)
|
|
38
|
+
# shipment.discountable_amount # => 25.00
|
|
39
|
+
# calculator.compute_item(shipment) # => 3.75
|
|
40
|
+
#
|
|
41
|
+
# @example Computing a 15% discount on a price
|
|
42
|
+
# calculator = Percent.new(preferred_percent: 15)
|
|
43
|
+
# price.discountable_amount # => 100.00
|
|
44
|
+
# calculator.compute_item(shipment) # => 15
|
|
45
|
+
def compute_item(object, _options = {})
|
|
46
|
+
currency = object.respond_to?(:currency) ? object.currency : object.order.currency
|
|
47
|
+
round_to_currency(object.discountable_amount * preferred_percent / 100, currency)
|
|
16
48
|
end
|
|
49
|
+
alias_method :compute_line_item, :compute_item
|
|
50
|
+
alias_method :compute_shipment, :compute_item
|
|
51
|
+
alias_method :compute_shipping_rate, :compute_item
|
|
52
|
+
alias_method :compute_price, :compute_item
|
|
17
53
|
end
|
|
18
54
|
end
|
|
19
55
|
end
|
|
@@ -2,11 +2,52 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidusPromotions
|
|
4
4
|
module Calculators
|
|
5
|
-
|
|
5
|
+
# A calculator that applies a percentage-based discount with a maximum cap.
|
|
6
|
+
#
|
|
7
|
+
# This calculator computes a discount as a percentage of the line item's discountable amount,
|
|
8
|
+
# but limits the total discount to a maximum amount distributed across all applicable line items.
|
|
9
|
+
# The actual discount applied is the lesser of the percentage discount and the proportional
|
|
10
|
+
# share of the maximum cap.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# calculator = PercentWithCap.new(preferred_percent: 20, preferred_max_amount: 50)
|
|
14
|
+
# # Line item with $100 discountable amount
|
|
15
|
+
# # Percentage would be $20 (20% of $100)
|
|
16
|
+
# # But if the max cap distributes only $15 to this item, it gets $15
|
|
17
|
+
class PercentWithCap < Spree::Calculator
|
|
18
|
+
include PromotionCalculator
|
|
19
|
+
|
|
20
|
+
preference :percent, :decimal, default: 0
|
|
6
21
|
preference :max_amount, :integer, default: 100
|
|
7
22
|
|
|
8
|
-
|
|
9
|
-
|
|
23
|
+
# Computes the discount for a line item, capped at a maximum amount.
|
|
24
|
+
#
|
|
25
|
+
# Calculates both a percentage-based discount and a distributed maximum discount,
|
|
26
|
+
# then returns whichever is smaller. This ensures the discount never exceeds
|
|
27
|
+
# the line item's proportional share of the maximum cap, even if the percentage
|
|
28
|
+
# would result in a larger discount.
|
|
29
|
+
#
|
|
30
|
+
# @param line_item [Spree::LineItem] The line item to calculate the discount for
|
|
31
|
+
#
|
|
32
|
+
# @return [BigDecimal] The discount amount, limited by both the percentage and the max cap
|
|
33
|
+
#
|
|
34
|
+
# @example Computing discount when percentage is lower than cap
|
|
35
|
+
# calculator = PercentWithCap.new(preferred_percent: 10, preferred_max_amount: 100)
|
|
36
|
+
# line_item.discountable_amount # => 50.00
|
|
37
|
+
# # Percent discount: $5 (10% of $50)
|
|
38
|
+
# # Max distributed: $25 (assuming equal distribution)
|
|
39
|
+
# calculator.compute_line_item(line_item) # => 5.00
|
|
40
|
+
#
|
|
41
|
+
# @example Computing discount when cap is lower than percentage
|
|
42
|
+
# calculator = PercentWithCap.new(preferred_percent: 50, preferred_max_amount: 10)
|
|
43
|
+
# line_item.discountable_amount # => 100.00
|
|
44
|
+
# # Percent discount: $50 (50% of $100)
|
|
45
|
+
# # Max distributed: $10 (assuming single line item)
|
|
46
|
+
# calculator.compute_line_item(line_item) # => 10.00
|
|
47
|
+
#
|
|
48
|
+
# @see DistributedAmount
|
|
49
|
+
def compute_line_item(line_item)
|
|
50
|
+
percent_discount = round_to_currency(line_item.discountable_amount * preferred_percent / 100, line_item.order.currency)
|
|
10
51
|
max_discount = DistributedAmount.new(
|
|
11
52
|
calculable:,
|
|
12
53
|
preferred_amount: preferred_max_amount
|
|
@@ -4,24 +4,82 @@ require_dependency "spree/calculator"
|
|
|
4
4
|
|
|
5
5
|
module SolidusPromotions
|
|
6
6
|
module Calculators
|
|
7
|
+
# A calculator that applies tiered flat-rate discounts based on discountable amount thresholds.
|
|
8
|
+
#
|
|
9
|
+
# This calculator allows defining multiple discount tiers where each tier specifies a minimum
|
|
10
|
+
# discountable amount threshold and the corresponding discount amount to apply. The calculator
|
|
11
|
+
# selects the highest tier that the item qualifies for based on its discountable amount.
|
|
12
|
+
#
|
|
13
|
+
# If the item doesn't meet any tier threshold, the base amount is used. The discount is only
|
|
14
|
+
# applied if the currency matches the preferred currency.
|
|
15
|
+
#
|
|
16
|
+
# @example Use case: Volume-based shipping discounts
|
|
17
|
+
# # Free shipping on orders over $100, $5 off on orders over $50
|
|
18
|
+
# calculator = TieredFlatRate.new(
|
|
19
|
+
# preferred_base_amount: 0,
|
|
20
|
+
# preferred_tiers: {
|
|
21
|
+
# 50 => 5, # $5 discount when amount >= $50
|
|
22
|
+
# 100 => 15 # $15 discount when amount >= $100
|
|
23
|
+
# },
|
|
24
|
+
# preferred_currency: 'USD'
|
|
25
|
+
# )
|
|
26
|
+
#
|
|
27
|
+
# @example Use case: Bulk purchase incentives
|
|
28
|
+
# # Tiered discounts for line items based on total line value
|
|
29
|
+
# calculator = TieredFlatRate.new(
|
|
30
|
+
# preferred_base_amount: 2,
|
|
31
|
+
# preferred_tiers: {
|
|
32
|
+
# 25 => 5, # $5 off when line total >= $25
|
|
33
|
+
# 50 => 12, # $12 off when line total >= $50
|
|
34
|
+
# 100 => 30 # $30 off when line total >= $100
|
|
35
|
+
# },
|
|
36
|
+
# preferred_currency: 'USD'
|
|
37
|
+
# )
|
|
7
38
|
class TieredFlatRate < Spree::Calculator
|
|
8
39
|
include PromotionCalculator
|
|
9
40
|
|
|
10
|
-
preference :base_amount, :decimal, default:
|
|
11
|
-
preference :tiers, :hash, default: {
|
|
41
|
+
preference :base_amount, :decimal, default: Spree::ZERO
|
|
42
|
+
preference :tiers, :hash, default: {10 => 10}
|
|
12
43
|
preference :currency, :string, default: -> { Spree::Config[:currency] }
|
|
13
44
|
|
|
14
|
-
before_validation
|
|
15
|
-
# Convert tier values to decimals. Strings don't do us much good.
|
|
16
|
-
if preferred_tiers.is_a?(Hash)
|
|
17
|
-
self.preferred_tiers = preferred_tiers.map do |key, value|
|
|
18
|
-
[cast_to_d(key.to_s), cast_to_d(value.to_s)]
|
|
19
|
-
end.to_h
|
|
20
|
-
end
|
|
21
|
-
end
|
|
45
|
+
before_validation :transform_preferred_tiers
|
|
22
46
|
|
|
23
47
|
validate :preferred_tiers_content
|
|
24
48
|
|
|
49
|
+
# Computes the tiered flat-rate discount for an item.
|
|
50
|
+
#
|
|
51
|
+
# Evaluates the item's discountable amount against all defined tiers and selects
|
|
52
|
+
# the highest tier threshold that the item meets or exceeds. Returns the discount
|
|
53
|
+
# amount associated with that tier, or the base amount if no tier threshold is met.
|
|
54
|
+
# Returns 0 if the currency doesn't match.
|
|
55
|
+
#
|
|
56
|
+
# @param object [Object] The object to calculate the discount for (e.g., LineItem, Shipment)
|
|
57
|
+
#
|
|
58
|
+
# @return [BigDecimal] The discount amount from the matching tier, base amount, or 0
|
|
59
|
+
#
|
|
60
|
+
# @example Computing discount with tier matching
|
|
61
|
+
# calculator = TieredFlatRate.new(
|
|
62
|
+
# preferred_base_amount: 2,
|
|
63
|
+
# preferred_tiers: { 25 => 5, 50 => 10, 100 => 20 }
|
|
64
|
+
# )
|
|
65
|
+
# line_item.discountable_amount # => 75.00
|
|
66
|
+
# calculator.compute_item(line_item) # => 10.00 (matches $50 tier)
|
|
67
|
+
#
|
|
68
|
+
# @example Computing discount below all tiers
|
|
69
|
+
# calculator = TieredFlatRate.new(
|
|
70
|
+
# preferred_base_amount: 2,
|
|
71
|
+
# preferred_tiers: { 25 => 5, 50 => 10 }
|
|
72
|
+
# )
|
|
73
|
+
# line_item.discountable_amount # => 15.00
|
|
74
|
+
# calculator.compute_item(line_item) # => 2.00 (base amount)
|
|
75
|
+
#
|
|
76
|
+
# @example Computing discount with currency mismatch
|
|
77
|
+
# calculator = TieredFlatRate.new(
|
|
78
|
+
# preferred_currency: 'USD',
|
|
79
|
+
# preferred_tiers: { 50 => 10 }
|
|
80
|
+
# )
|
|
81
|
+
# line_item.currency # => 'EUR'
|
|
82
|
+
# calculator.compute_item(line_item) # => 0
|
|
25
83
|
def compute_item(object)
|
|
26
84
|
_base, amount = preferred_tiers.sort.reverse.detect do |value, _|
|
|
27
85
|
object.discountable_amount >= value
|
|
@@ -30,7 +88,7 @@ module SolidusPromotions
|
|
|
30
88
|
if preferred_currency.casecmp(object.currency).zero?
|
|
31
89
|
amount || preferred_base_amount
|
|
32
90
|
else
|
|
33
|
-
|
|
91
|
+
Spree::ZERO
|
|
34
92
|
end
|
|
35
93
|
end
|
|
36
94
|
alias_method :compute_shipment, :compute_item
|
|
@@ -38,17 +96,21 @@ module SolidusPromotions
|
|
|
38
96
|
|
|
39
97
|
private
|
|
40
98
|
|
|
41
|
-
|
|
42
|
-
|
|
99
|
+
# Transforms preferred_tiers keys and values to BigDecimal for consistent calculations.
|
|
100
|
+
#
|
|
101
|
+
# Converts all tier threshold keys and percentage values from strings or other numeric
|
|
102
|
+
# types to BigDecimal to ensure precision in monetary calculations.
|
|
103
|
+
def transform_preferred_tiers
|
|
104
|
+
preferred_tiers.transform_keys! { |key| key.to_s.to_d }
|
|
105
|
+
preferred_tiers.transform_values! { |value| value.to_s.to_d }
|
|
43
106
|
end
|
|
44
107
|
|
|
108
|
+
# Validates that preferred_tiers is a hash with positive numeric keys.
|
|
109
|
+
#
|
|
110
|
+
# Ensures the tiers preference is properly formatted for tier-based calculations.
|
|
45
111
|
def preferred_tiers_content
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
errors.add(:base, :keys_should_be_positive_number)
|
|
49
|
-
end
|
|
50
|
-
else
|
|
51
|
-
errors.add(:preferred_tiers, :should_be_hash)
|
|
112
|
+
unless preferred_tiers.keys.all? { |key| key.is_a?(Numeric) && key > 0 }
|
|
113
|
+
errors.add(:base, :keys_should_be_positive_number)
|
|
52
114
|
end
|
|
53
115
|
end
|
|
54
116
|
end
|
|
@@ -4,28 +4,93 @@ require_dependency "spree/calculator"
|
|
|
4
4
|
|
|
5
5
|
module SolidusPromotions
|
|
6
6
|
module Calculators
|
|
7
|
+
# A calculator that applies tiered percentage-based discounts based on order item total thresholds.
|
|
8
|
+
#
|
|
9
|
+
# This calculator allows defining multiple discount tiers where each tier specifies a minimum
|
|
10
|
+
# order item total threshold and the corresponding percentage discount to apply to the individual
|
|
11
|
+
# item. The calculator selects the highest tier that the order qualifies for based on its item total.
|
|
12
|
+
#
|
|
13
|
+
# Unlike TieredFlatRate which applies a fixed amount, this calculator applies a percentage of the
|
|
14
|
+
# item's amount. The tier thresholds are evaluated against the entire order's item total, but the
|
|
15
|
+
# percentage discount is applied to the individual item (line item or shipment).
|
|
16
|
+
#
|
|
17
|
+
# If the order doesn't meet any tier threshold, the base percentage is used. The discount is only
|
|
18
|
+
# applied if the currency matches the preferred currency.
|
|
19
|
+
#
|
|
20
|
+
# @example Use case: Volume-based percentage discounts
|
|
21
|
+
# # Higher discounts for larger orders
|
|
22
|
+
# calculator = TieredPercent.new(
|
|
23
|
+
# preferred_base_percent: 5,
|
|
24
|
+
# preferred_tiers: {
|
|
25
|
+
# 100 => 10, # 10% off when order total >= $100
|
|
26
|
+
# 250 => 15, # 15% off when order total >= $250
|
|
27
|
+
# 500 => 20 # 20% off when order total >= $500
|
|
28
|
+
# },
|
|
29
|
+
# preferred_currency: 'USD'
|
|
30
|
+
# )
|
|
31
|
+
#
|
|
32
|
+
# @example Use case: Wholesale tier pricing
|
|
33
|
+
# # Different percentage discounts for different order sizes
|
|
34
|
+
# calculator = TieredPercent.new(
|
|
35
|
+
# preferred_base_percent: 0,
|
|
36
|
+
# preferred_tiers: {
|
|
37
|
+
# 200 => 5, # 5% wholesale discount at $200
|
|
38
|
+
# 500 => 10, # 10% wholesale discount at $500
|
|
39
|
+
# 1000 => 15 # 15% wholesale discount at $1000
|
|
40
|
+
# },
|
|
41
|
+
# preferred_currency: 'USD'
|
|
42
|
+
# )
|
|
7
43
|
class TieredPercent < Spree::Calculator
|
|
8
44
|
include PromotionCalculator
|
|
9
45
|
|
|
10
|
-
preference :base_percent, :decimal, default:
|
|
11
|
-
preference :tiers, :hash, default: {
|
|
46
|
+
preference :base_percent, :decimal, default: Spree::ZERO
|
|
47
|
+
preference :tiers, :hash, default: {50 => 5}
|
|
12
48
|
preference :currency, :string, default: -> { Spree::Config[:currency] }
|
|
13
49
|
|
|
14
|
-
before_validation
|
|
15
|
-
# Convert tier values to decimals. Strings don't do us much good.
|
|
16
|
-
if preferred_tiers.is_a?(Hash)
|
|
17
|
-
self.preferred_tiers = preferred_tiers.map do |key, value|
|
|
18
|
-
[cast_to_d(key.to_s), cast_to_d(value.to_s)]
|
|
19
|
-
end.to_h
|
|
20
|
-
end
|
|
21
|
-
end
|
|
50
|
+
before_validation :transform_preferred_tiers
|
|
22
51
|
|
|
23
52
|
validates :preferred_base_percent, numericality: {
|
|
24
|
-
greater_than_or_equal_to:
|
|
53
|
+
greater_than_or_equal_to: Spree::ZERO,
|
|
25
54
|
less_than_or_equal_to: 100
|
|
26
55
|
}
|
|
27
56
|
validate :preferred_tiers_content
|
|
28
57
|
|
|
58
|
+
# Computes the tiered percentage discount for an item based on the order's item total.
|
|
59
|
+
#
|
|
60
|
+
# Evaluates the order's item total against all defined tiers and selects the highest
|
|
61
|
+
# tier threshold that the order meets or exceeds. Returns a percentage of the item's
|
|
62
|
+
# amount based on the matching tier, or the base percentage if no tier threshold is met.
|
|
63
|
+
# Returns 0 if the currency doesn't match.
|
|
64
|
+
#
|
|
65
|
+
# @param object [Object] The object to calculate the discount for (e.g., LineItem, Shipment)
|
|
66
|
+
#
|
|
67
|
+
# @return [BigDecimal] The percentage-based discount amount, rounded to currency precision
|
|
68
|
+
#
|
|
69
|
+
# @example Computing discount with tier matching
|
|
70
|
+
# calculator = TieredPercent.new(
|
|
71
|
+
# preferred_base_percent: 5,
|
|
72
|
+
# preferred_tiers: { 100 => 10, 250 => 15 }
|
|
73
|
+
# )
|
|
74
|
+
# order.item_total # => 150.00
|
|
75
|
+
# line_item.amount # => 50.00
|
|
76
|
+
# calculator.compute_item(line_item) # => 5.00 (10% of $50, matches $100 tier)
|
|
77
|
+
#
|
|
78
|
+
# @example Computing discount below all tiers
|
|
79
|
+
# calculator = TieredPercent.new(
|
|
80
|
+
# preferred_base_percent: 5,
|
|
81
|
+
# preferred_tiers: { 100 => 10, 250 => 15 }
|
|
82
|
+
# )
|
|
83
|
+
# order.item_total # => 75.00
|
|
84
|
+
# line_item.amount # => 30.00
|
|
85
|
+
# calculator.compute_item(line_item) # => 1.50 (5% base percent of $30)
|
|
86
|
+
#
|
|
87
|
+
# @example Computing discount with currency mismatch
|
|
88
|
+
# calculator = TieredPercent.new(
|
|
89
|
+
# preferred_currency: 'USD',
|
|
90
|
+
# preferred_tiers: { 100 => 10 }
|
|
91
|
+
# )
|
|
92
|
+
# order.currency # => 'EUR'
|
|
93
|
+
# calculator.compute_item(line_item) # => 0
|
|
29
94
|
def compute_item(object)
|
|
30
95
|
order = object.order
|
|
31
96
|
|
|
@@ -34,10 +99,9 @@ module SolidusPromotions
|
|
|
34
99
|
end
|
|
35
100
|
|
|
36
101
|
if preferred_currency.casecmp(order.currency).zero?
|
|
37
|
-
|
|
38
|
-
(object.amount * (percent || preferred_base_percent) / 100).round(currency_exponent)
|
|
102
|
+
round_to_currency(object.amount * (percent || preferred_base_percent) / 100, preferred_currency)
|
|
39
103
|
else
|
|
40
|
-
|
|
104
|
+
Spree::ZERO
|
|
41
105
|
end
|
|
42
106
|
end
|
|
43
107
|
alias_method :compute_shipment, :compute_item
|
|
@@ -45,20 +109,27 @@ module SolidusPromotions
|
|
|
45
109
|
|
|
46
110
|
private
|
|
47
111
|
|
|
48
|
-
|
|
49
|
-
|
|
112
|
+
# Transforms preferred_tiers keys and values to BigDecimal for consistent calculations.
|
|
113
|
+
#
|
|
114
|
+
# Converts all tier threshold keys and percentage values from strings or other numeric
|
|
115
|
+
# types to BigDecimal to ensure precision in monetary calculations.
|
|
116
|
+
def transform_preferred_tiers
|
|
117
|
+
preferred_tiers.transform_keys! { |key| key.to_s.to_d }
|
|
118
|
+
preferred_tiers.transform_values! { |value| value.to_s.to_d }
|
|
50
119
|
end
|
|
51
120
|
|
|
121
|
+
# Validates that preferred_tiers is properly formatted with valid thresholds and percentages.
|
|
122
|
+
#
|
|
123
|
+
# Ensures:
|
|
124
|
+
# - Tiers is a hash
|
|
125
|
+
# - All keys (thresholds) are positive numbers
|
|
126
|
+
# - All values (percentages) are between 0 and 100
|
|
52
127
|
def preferred_tiers_content
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
errors.add(:base, :values_should_be_percent)
|
|
59
|
-
end
|
|
60
|
-
else
|
|
61
|
-
errors.add(:preferred_tiers, :should_be_hash)
|
|
128
|
+
unless preferred_tiers.keys.all? { |key| key.is_a?(Numeric) && key > 0 }
|
|
129
|
+
errors.add(:base, :keys_should_be_positive_number)
|
|
130
|
+
end
|
|
131
|
+
unless preferred_tiers.values.all? { |key| key.is_a?(Numeric) && key >= 0 && key <= 100 }
|
|
132
|
+
errors.add(:base, :values_should_be_percent)
|
|
62
133
|
end
|
|
63
134
|
end
|
|
64
135
|
end
|