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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusPromotions
|
|
4
|
+
module DiscountedAmount
|
|
5
|
+
class NotCalculatingPromotions < StandardError
|
|
6
|
+
DEFAULT_MESSAGE = <<~MSG
|
|
7
|
+
You're trying to call `#current_lane_discounts` without a current lane being set on `SolidusPromotions::PromotionLane.
|
|
8
|
+
In order to set a current lane, wrap your call into a `PromotionLane.set` block:
|
|
9
|
+
```
|
|
10
|
+
SolidusPromotions::PromotionLane.set(current_lane: "default") do
|
|
11
|
+
# YOUR CODE HERE
|
|
12
|
+
end
|
|
13
|
+
```
|
|
14
|
+
MSG
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
super(DEFAULT_MESSAGE)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Calculates the total discounted amount including adjustments from previous lanes.
|
|
22
|
+
#
|
|
23
|
+
# @return [BigDecimal] the sum of the current amount and all previous lane discount amounts
|
|
24
|
+
def discounted_amount
|
|
25
|
+
amount + previous_lanes_discounts.sum(&:amount)
|
|
26
|
+
end
|
|
27
|
+
# The discountable amount is always equal to the discounted amount.
|
|
28
|
+
alias_method :discountable_amount, :discounted_amount
|
|
29
|
+
|
|
30
|
+
# Returns discount objects from the current promotion lane.
|
|
31
|
+
#
|
|
32
|
+
# @return [Array<Spree::Adjustment,SolidusPromotions::ShippingRateDiscount>] Discounts from the current lane
|
|
33
|
+
# @raise [NotCalculatingPromotions] if no promotion lane is currently being calculated
|
|
34
|
+
def current_lane_discounts
|
|
35
|
+
raise NotCalculatingPromotions unless PromotionLane.current_lane
|
|
36
|
+
|
|
37
|
+
discounts_by_lanes([PromotionLane.current_lane])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Returns discount objects added by promotion in lanes that come before the current lane.
|
|
43
|
+
#
|
|
44
|
+
# This method retrieves all discounts that were applied by promotion lanes with a priority
|
|
45
|
+
# lower than the current lane, effectively getting discounts from earlier processing stages.
|
|
46
|
+
#
|
|
47
|
+
# @return [Array<Spree::Adjustment,SolidusPromotions::ShippingRateDiscount>] Discounts from previous lanes
|
|
48
|
+
# @see #discounts_by_lanes
|
|
49
|
+
# @see PromotionLane.previous_lanes
|
|
50
|
+
def previous_lanes_discounts
|
|
51
|
+
discounts_by_lanes(PromotionLane.previous_lanes)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -1,59 +1,240 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SolidusPromotions
|
|
4
|
-
# Base class for all
|
|
4
|
+
# Base class for all promotion benefits.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
6
|
+
# A Benefit is the active part of a promotion: once a promotion becomes
|
|
7
|
+
# eligible for a given promotable (order, line item, or shipment), the benefit
|
|
8
|
+
# determines how much discount to apply and produces the corresponding
|
|
9
|
+
# adjustments.
|
|
10
|
+
#
|
|
11
|
+
# Subclasses specialize the discounting target (orders, line items, or
|
|
12
|
+
# shipments) and usually include one of the following mixins to integrate with
|
|
13
|
+
# Solidus' adjustment system:
|
|
14
|
+
# - SolidusPromotions::Benefits::AdjustLineItem
|
|
15
|
+
# - SolidusPromotions::Benefits::AdjustShipment
|
|
16
|
+
# - SolidusPromotions::Benefits::CreateDiscountedItem
|
|
17
|
+
#
|
|
18
|
+
# A benefit can discount any object for which {#can_discount?} returns true.
|
|
19
|
+
# Implementors must provide a calculator via Spree::CalculatedAdjustments and
|
|
20
|
+
# may override methods such as {#adjustment_label}.
|
|
21
|
+
#
|
|
22
|
+
# Usage example
|
|
23
|
+
#
|
|
24
|
+
# benefit = SolidusPromotions::Benefits::AdjustLineItem.new(promotion: promo)
|
|
25
|
+
# if benefit.can_discount?(line_item)
|
|
26
|
+
# discount = benefit.discount(line_item)
|
|
27
|
+
# # => #<SolidusPromotions::ItemDiscount ...>
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# @see SolidusPromotions::Promotion
|
|
31
|
+
# @see Spree::CalculatedAdjustments
|
|
8
32
|
class Benefit < Spree::Base
|
|
9
33
|
include Spree::Preferences::Persistable
|
|
10
34
|
include Spree::CalculatedAdjustments
|
|
11
35
|
include Spree::AdjustmentSource
|
|
36
|
+
|
|
12
37
|
before_destroy :remove_adjustments_from_incomplete_orders
|
|
13
38
|
before_destroy :raise_for_adjustments_for_completed_orders
|
|
14
39
|
|
|
40
|
+
# @!attribute [rw] promotion
|
|
41
|
+
# The owning promotion.
|
|
42
|
+
# @return [SolidusPromotions::Promotion]
|
|
15
43
|
belongs_to :promotion, inverse_of: :benefits
|
|
44
|
+
# @!attribute [rw] original_promotion_action
|
|
45
|
+
# Back-reference to the original Solidus (Spree) promotion action, when migrated.
|
|
46
|
+
# @return [Spree::PromotionAction, nil]
|
|
16
47
|
belongs_to :original_promotion_action, class_name: "Spree::PromotionAction", optional: true
|
|
48
|
+
# @!attribute [r] adjustments
|
|
49
|
+
# Adjustments created by this benefit.
|
|
50
|
+
# @return [ActiveRecord::Associations::CollectionProxy<Spree::Adjustment>]
|
|
17
51
|
has_many :adjustments, class_name: "Spree::Adjustment", as: :source, dependent: :restrict_with_error
|
|
52
|
+
# @!attribute [r] shipping_rate_discounts
|
|
53
|
+
# Shipping-rate-level discounts generated by this benefit.
|
|
54
|
+
# @return [ActiveRecord::Associations::CollectionProxy<SolidusPromotions::ShippingRateDiscount>]
|
|
18
55
|
has_many :shipping_rate_discounts, class_name: "SolidusPromotions::ShippingRateDiscount", inverse_of: :benefit, dependent: :restrict_with_error
|
|
56
|
+
# @!attribute [r] conditions
|
|
57
|
+
# Conditions attached to this benefit.
|
|
58
|
+
# @return [ActiveRecord::Associations::CollectionProxy<SolidusPromotions::Condition>]
|
|
19
59
|
has_many :conditions, class_name: "SolidusPromotions::Condition", inverse_of: :benefit, dependent: :destroy
|
|
20
60
|
|
|
61
|
+
# @!method self.of_type(type)
|
|
62
|
+
# Restricts benefits to the given STI type(s).
|
|
63
|
+
# @param type [String, Symbol, Class, Array<String,Symbol,Class>] a single type or list of types
|
|
64
|
+
# @return [ActiveRecord::Relation<SolidusPromotions::Benefit>]
|
|
21
65
|
scope :of_type, ->(type) { where(type: Array.wrap(type).map(&:to_s)) }
|
|
22
66
|
|
|
67
|
+
# Base set of order-level condition classes available to all benefits.
|
|
68
|
+
#
|
|
69
|
+
# These generic order conditions apply regardless of the concrete benefit
|
|
70
|
+
# type, as every benefit ultimately operates within the context of an order.
|
|
71
|
+
# Concrete benefit subclasses may extend or override this to include
|
|
72
|
+
# additional applicable conditions that are specific to their discount
|
|
73
|
+
# target (e.g., line-item or shipment conditions).
|
|
74
|
+
#
|
|
75
|
+
# @return [Enumerable<Class<SolidusPromotions::Condition>>]
|
|
76
|
+
def self.applicable_conditions
|
|
77
|
+
SolidusPromotions::Condition.applicable_to([Spree::Order])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Returns relations that should be preloaded for this condition.
|
|
81
|
+
#
|
|
82
|
+
# Override this method in subclasses to specify associations that should be eager loaded
|
|
83
|
+
# to avoid N+1 queries when computing discounts or performing automations.
|
|
84
|
+
#
|
|
85
|
+
# @return [Array<Symbol>] An array of association names to preload
|
|
23
86
|
def preload_relations
|
|
24
87
|
[:calculator]
|
|
25
88
|
end
|
|
26
89
|
|
|
90
|
+
# Whether this benefit can discount the given object.
|
|
91
|
+
#
|
|
92
|
+
# Subclasses must implement this according to the kinds
|
|
93
|
+
# of objects they are able to discount.
|
|
94
|
+
#
|
|
95
|
+
# @param object [Object] a potential adjustable (order, line item, or shipment)
|
|
96
|
+
# @return [Boolean]
|
|
97
|
+
# @see SolidusPromotions::Benefits::AdjustLineItem,
|
|
98
|
+
# SolidusPromotions::Benefits::AdjustShipment,
|
|
99
|
+
# SolidusPromotions::Benefits::CreateDiscountedItem
|
|
27
100
|
def can_discount?(object)
|
|
28
|
-
|
|
29
|
-
"`SolidusPromotions::Benefits::LineItemBenefit` or `SolidusPromotions::Benefits::ShipmentBenefit` modules"
|
|
101
|
+
respond_to?(discount_method_for(object))
|
|
30
102
|
end
|
|
31
103
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
104
|
+
# Calculates and returns a discount for the given adjustable object.
|
|
105
|
+
#
|
|
106
|
+
# This method computes the discount amount using the benefit's calculator and returns
|
|
107
|
+
# an ItemDiscount object representing the discount to be applied. If the computed
|
|
108
|
+
# amount is zero, no discount is returned.
|
|
109
|
+
#
|
|
110
|
+
# @param adjustable [Object] The object to calculate the discount for (e.g., LineItem, Shipment, ShippingRate)
|
|
111
|
+
# @param ... [args, kwargs] Additional arguments passed to the calculator's compute method
|
|
112
|
+
#
|
|
113
|
+
# @return [Spree::Adjustment, SolidusPromotions::ShippingRateDiscount, nil] An ItemDiscount object if a discount applies, nil if the amount is zero
|
|
114
|
+
#
|
|
115
|
+
# @example Calculating a discount for a line item
|
|
116
|
+
# benefit.discount(line_item)
|
|
117
|
+
# # => #<Spree::Adjustment, adjustable: line_item, amount: -10.00, ...>
|
|
118
|
+
#
|
|
119
|
+
# @see #compute_amount
|
|
120
|
+
# @see #adjustment_label
|
|
121
|
+
def discount(adjustable, ...)
|
|
122
|
+
if can_discount?(adjustable)
|
|
123
|
+
send(discount_method_for(adjustable), adjustable, ...)
|
|
124
|
+
else
|
|
125
|
+
raise NotImplementedError, "Please implement #{discount_method_for(adjustable)} in your condition"
|
|
126
|
+
end
|
|
41
127
|
end
|
|
42
128
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
129
|
+
def self.inherited(klass)
|
|
130
|
+
def klass.method_added(method_added)
|
|
131
|
+
if method_added == :discount
|
|
132
|
+
Spree.deprecator.warn <<~MSG
|
|
133
|
+
Please refactor `#{name}`. You're defining `#discount`. Instead, define a method for each type of discountable
|
|
134
|
+
that your benefit can discount. For example:
|
|
135
|
+
```
|
|
136
|
+
class MyBenefit < SolidusPromotions::Benefit
|
|
137
|
+
def can_discount?(discountable)
|
|
138
|
+
discountable.is_a?(Spree::LineItem)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def discount(order, _options = {})
|
|
142
|
+
amount = compute_amount(line_item, ...)
|
|
143
|
+
return if amount.zero?
|
|
144
|
+
|
|
145
|
+
ItemDiscount.new(
|
|
146
|
+
item: line_item,
|
|
147
|
+
label: adjustment_label(line_item),
|
|
148
|
+
amount: amount,
|
|
149
|
+
source: self
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
can now become
|
|
154
|
+
```
|
|
155
|
+
class MyBenefit < SolidusPromotions::Benefit
|
|
156
|
+
def discount_line_item(order, ...)
|
|
157
|
+
amount = compute_amount(line_item, ...)
|
|
158
|
+
return if amount.zero?
|
|
159
|
+
|
|
160
|
+
ItemDiscount.new(
|
|
161
|
+
item: line_item,
|
|
162
|
+
label: adjustment_label(line_item),
|
|
163
|
+
amount: amount,
|
|
164
|
+
source: self
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
MSG
|
|
170
|
+
end
|
|
171
|
+
super
|
|
172
|
+
end
|
|
173
|
+
super
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Computes the discount amount for the given adjustable.
|
|
177
|
+
#
|
|
178
|
+
# Ensures the returned amount is negative and does not exceed the
|
|
179
|
+
# adjustable's discountable amount.
|
|
180
|
+
#
|
|
181
|
+
# @param adjustable [#discountable_amount] the adjustable to compute for
|
|
182
|
+
# @param ... [args, kwargs] additional arguments forwarded to the calculator
|
|
183
|
+
# @return [BigDecimal] a negative amount suitable for creating an adjustment
|
|
184
|
+
def compute_amount(adjustable, ...)
|
|
185
|
+
promotion_amount = calculator.compute(adjustable, ...) || Spree::ZERO
|
|
46
186
|
[adjustable.discountable_amount, promotion_amount.abs].min * -1
|
|
47
187
|
end
|
|
48
188
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
189
|
+
# Builds the localized label for adjustments created by this benefit.
|
|
190
|
+
#
|
|
191
|
+
# This method attempts to use a calculator-specific label method if available,
|
|
192
|
+
# falling back to a localized string key based on the adjustable's class name.
|
|
193
|
+
#
|
|
194
|
+
# ## Calculator Override
|
|
195
|
+
#
|
|
196
|
+
# Calculators can provide custom labels by implementing a method named after the
|
|
197
|
+
# adjustable type. For example, a calculator that discounts line items could
|
|
198
|
+
# implement `line_item_adjustment_label`:
|
|
199
|
+
#
|
|
200
|
+
# @example Custom calculator with adjustment label
|
|
201
|
+
# class MyCalculator < Spree::Calculator
|
|
202
|
+
# def compute(adjustable, *args)
|
|
203
|
+
# # calculation logic
|
|
204
|
+
# end
|
|
205
|
+
#
|
|
206
|
+
# def line_item_adjustment_label(line_item, *args)
|
|
207
|
+
# "Custom discount for #{line_item.product.name}"
|
|
208
|
+
# end
|
|
209
|
+
# end
|
|
210
|
+
#
|
|
211
|
+
# The method name follows the pattern: `{adjustable_type}_adjustment_label`
|
|
212
|
+
# where `{adjustable_type}` is the underscored class name of the adjustable
|
|
213
|
+
# (e.g., `line_item`, `shipment`, `shipping_rate`).
|
|
214
|
+
#
|
|
215
|
+
# If the calculator does not respond to the expected method, the benefit will
|
|
216
|
+
# fall back to using an i18n translation key based on the adjustable's class.
|
|
217
|
+
#
|
|
218
|
+
# @param adjustable [Object] the object being discounted (e.g., Spree::LineItem, Spree::Shipment)
|
|
219
|
+
# @param ... [args, kwargs] additional arguments forwarded to the calculator's label method
|
|
220
|
+
# @return [String] a localized label suitable for display in adjustments
|
|
221
|
+
#
|
|
222
|
+
# @see #adjustment_label_method_for
|
|
223
|
+
def adjustment_label(adjustable, ...)
|
|
224
|
+
if calculator.respond_to?(adjustment_label_method_for(adjustable))
|
|
225
|
+
calculator.send(adjustment_label_method_for(adjustable), adjustable, ...)
|
|
226
|
+
else
|
|
227
|
+
I18n.t(
|
|
228
|
+
"solidus_promotions.adjustment_labels.#{adjustable.class.name.demodulize.underscore}",
|
|
229
|
+
promotion: SolidusPromotions::Promotion.model_name.human,
|
|
230
|
+
promotion_customer_label: promotion.customer_label
|
|
231
|
+
)
|
|
232
|
+
end
|
|
55
233
|
end
|
|
56
234
|
|
|
235
|
+
# Partial path used for admin forms for this benefit type.
|
|
236
|
+
#
|
|
237
|
+
# @return [String]
|
|
57
238
|
def to_partial_path
|
|
58
239
|
"solidus_promotions/admin/benefit_fields/#{model_name.element}"
|
|
59
240
|
end
|
|
@@ -62,33 +243,47 @@ module SolidusPromotions
|
|
|
62
243
|
raise NotImplementedError, "Please implement the correct interface, or include one of the `SolidusPromotions::Benefits::OrderBenefit`, " \
|
|
63
244
|
"`SolidusPromotions::Benefits::LineItemBenefit` or `SolidusPromotions::Benefits::ShipmentBenefit` modules"
|
|
64
245
|
end
|
|
246
|
+
deprecate :level, deprecator: Spree.deprecator
|
|
65
247
|
|
|
248
|
+
# Returns the set of condition classes that can still be attached to this benefit.
|
|
249
|
+
# Already-persisted conditions are excluded.
|
|
250
|
+
#
|
|
251
|
+
# @return [Set<Class<SolidusPromotions::Condition>>]
|
|
66
252
|
def available_conditions
|
|
67
|
-
|
|
253
|
+
self.class.applicable_conditions - conditions.select(&:persisted?)
|
|
68
254
|
end
|
|
69
255
|
|
|
256
|
+
# Returns the calculators allowed for this benefit type.
|
|
257
|
+
#
|
|
258
|
+
# @return [Array<Class>] calculator classes
|
|
70
259
|
def available_calculators
|
|
71
260
|
SolidusPromotions.config.promotion_calculators[self.class] || []
|
|
72
261
|
end
|
|
73
262
|
|
|
263
|
+
# Verifies if the promotable satisfies all applicable conditions of this benefit.
|
|
264
|
+
#
|
|
265
|
+
# When dry_run is true, an {SolidusPromotions::EligibilityResults} entry is
|
|
266
|
+
# recorded for each condition with success/error details; otherwise, the
|
|
267
|
+
# evaluation short-circuits on the first failure.
|
|
268
|
+
#
|
|
269
|
+
# @param promotable [Object] the entity being evaluated (e.g., Spree::Order, Spree::LineItem)
|
|
270
|
+
# @param dry_run [Boolean] whether to collect detailed eligibility information
|
|
271
|
+
# @return [Boolean] true when all applicable conditions are eligible
|
|
74
272
|
def eligible_by_applicable_conditions?(promotable, dry_run: false)
|
|
75
|
-
|
|
76
|
-
condition.applicable?(promotable)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
applicable_conditions.map do |applicable_condition|
|
|
80
|
-
eligible = applicable_condition.eligible?(promotable)
|
|
273
|
+
conditions.map do |condition|
|
|
274
|
+
next unless condition.applicable?(promotable)
|
|
275
|
+
eligible = condition.eligible?(promotable)
|
|
81
276
|
|
|
82
277
|
break [false] if !eligible && !dry_run
|
|
83
278
|
|
|
84
279
|
if dry_run
|
|
85
|
-
if
|
|
86
|
-
code =
|
|
87
|
-
message =
|
|
280
|
+
if condition.eligibility_errors.details[:base].first
|
|
281
|
+
code = condition.eligibility_errors.details[:base].first[:error_code]
|
|
282
|
+
message = condition.eligibility_errors.full_messages.first
|
|
88
283
|
end
|
|
89
284
|
promotion.eligibility_results.add(
|
|
90
285
|
item: promotable,
|
|
91
|
-
condition:
|
|
286
|
+
condition: condition,
|
|
92
287
|
success: eligible,
|
|
93
288
|
code: eligible ? nil : (code || :coupon_code_unknown_error),
|
|
94
289
|
message: eligible ? nil : (message || I18n.t(:coupon_code_unknown_error, scope: [:solidus_promotions, :eligibility_errors]))
|
|
@@ -96,21 +291,47 @@ module SolidusPromotions
|
|
|
96
291
|
end
|
|
97
292
|
|
|
98
293
|
eligible
|
|
99
|
-
end.all?
|
|
294
|
+
end.compact.all?
|
|
100
295
|
end
|
|
101
296
|
|
|
297
|
+
# All line items of the order that are eligible for this benefit.
|
|
298
|
+
#
|
|
299
|
+
# @param order [Spree::Order]
|
|
300
|
+
# @return [Array<Spree::LineItem>] eligible line items
|
|
102
301
|
def applicable_line_items(order)
|
|
103
302
|
order.discountable_line_items.select do |line_item|
|
|
104
303
|
eligible_by_applicable_conditions?(line_item)
|
|
105
304
|
end
|
|
106
305
|
end
|
|
107
306
|
|
|
307
|
+
# Base set of order-level condition classes available to all benefits.
|
|
308
|
+
#
|
|
309
|
+
# These generic order conditions apply regardless of the concrete benefit
|
|
310
|
+
# type, as every benefit ultimately operates within the context of an order.
|
|
311
|
+
# Concrete benefit subclasses may extend or override this to include
|
|
312
|
+
# additional applicable conditions that are specific to their discount
|
|
313
|
+
# target (e.g., line-item or shipment conditions).
|
|
314
|
+
#
|
|
315
|
+
# @return [Set<Class<SolidusPromotions::Condition>>]
|
|
108
316
|
def possible_conditions
|
|
109
|
-
|
|
317
|
+
Spree.deprecator.warn("Use #{self.class.name}.applicable_conditions instead.")
|
|
318
|
+
self.class.applicable_conditions
|
|
110
319
|
end
|
|
111
320
|
|
|
112
321
|
private
|
|
113
322
|
|
|
323
|
+
def discount_method_for(adjustable)
|
|
324
|
+
:"discount_#{adjustable.class.name.demodulize.underscore}"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def adjustment_label_method_for(adjustable)
|
|
328
|
+
:"#{adjustable.class.name.demodulize.underscore}_adjustment_label"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Prevents destroying a benefit when it has adjustments on completed orders.
|
|
332
|
+
#
|
|
333
|
+
# Adds an error and aborts the destroy callback chain when such adjustments exist.
|
|
334
|
+
# @api private
|
|
114
335
|
def raise_for_adjustments_for_completed_orders
|
|
115
336
|
if adjustments.joins(:order).merge(Spree::Order.complete).any?
|
|
116
337
|
errors.add(:base, :cannot_destroy_if_order_completed)
|
|
@@ -3,10 +3,35 @@
|
|
|
3
3
|
module SolidusPromotions
|
|
4
4
|
module Benefits
|
|
5
5
|
class AdjustLineItem < Benefit
|
|
6
|
-
|
|
6
|
+
def self.applicable_conditions
|
|
7
|
+
SolidusPromotions::Condition.applicable_to([Spree::Order, Spree::LineItem])
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def discount_line_item(line_item, ...)
|
|
11
|
+
adjustment = find_adjustment(line_item) || build_adjustment(line_item)
|
|
12
|
+
adjustment.amount = compute_amount(line_item, ...)
|
|
13
|
+
adjustment.label = adjustment_label(line_item)
|
|
14
|
+
adjustment
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def level
|
|
18
|
+
:line_item
|
|
19
|
+
end
|
|
20
|
+
deprecate :level, deprecator: Spree.deprecator
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def find_adjustment(line_item)
|
|
25
|
+
line_item.adjustments.detect do |adjustment|
|
|
26
|
+
adjustment.source == self
|
|
27
|
+
end
|
|
28
|
+
end
|
|
7
29
|
|
|
8
|
-
def
|
|
9
|
-
|
|
30
|
+
def build_adjustment(line_item)
|
|
31
|
+
line_item.adjustments.build(
|
|
32
|
+
order: line_item.order,
|
|
33
|
+
source: self
|
|
34
|
+
)
|
|
10
35
|
end
|
|
11
36
|
end
|
|
12
37
|
end
|
|
@@ -3,10 +3,52 @@
|
|
|
3
3
|
module SolidusPromotions
|
|
4
4
|
module Benefits
|
|
5
5
|
class AdjustShipment < Benefit
|
|
6
|
-
|
|
6
|
+
def self.applicable_conditions
|
|
7
|
+
SolidusPromotions::Condition.applicable_to([Spree::Order, Spree::Shipment])
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def discount_shipment(shipment, ...)
|
|
11
|
+
adjustment = find_adjustment(shipment) || build_adjustment(shipment)
|
|
12
|
+
adjustment.amount = compute_amount(shipment, ...)
|
|
13
|
+
adjustment.label = adjustment_label(shipment)
|
|
14
|
+
adjustment
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def discount_shipping_rate(shipping_rate, ...)
|
|
18
|
+
discount = find_discount(shipping_rate) || build_discount(shipping_rate)
|
|
19
|
+
discount.amount = compute_amount(shipping_rate, ...)
|
|
20
|
+
discount.label = adjustment_label(shipping_rate)
|
|
21
|
+
discount
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def level
|
|
25
|
+
:shipment
|
|
26
|
+
end
|
|
27
|
+
deprecate :level, deprecator: Spree.deprecator
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def find_adjustment(shipment)
|
|
32
|
+
shipment.adjustments.detect do |adjustment|
|
|
33
|
+
adjustment.source == self
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build_adjustment(shipment)
|
|
38
|
+
shipment.adjustments.build(
|
|
39
|
+
order: shipment.order,
|
|
40
|
+
source: self
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def find_discount(shipping_rate)
|
|
45
|
+
shipping_rate.discounts.detect do |discount|
|
|
46
|
+
discount.benefit == self
|
|
47
|
+
end
|
|
48
|
+
end
|
|
7
49
|
|
|
8
|
-
def
|
|
9
|
-
|
|
50
|
+
def build_discount(shipping_rate)
|
|
51
|
+
shipping_rate.discounts.build(benefit: self)
|
|
10
52
|
end
|
|
11
53
|
end
|
|
12
54
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidusPromotions
|
|
4
|
+
module Benefits
|
|
5
|
+
class AdvertisePrice < Benefit
|
|
6
|
+
def self.applicable_conditions
|
|
7
|
+
Condition.applicable_to([Spree::Order, Spree::Price])
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def discount_price(price, ...)
|
|
11
|
+
discount = find_discount(price) || build_discount
|
|
12
|
+
discount.amount = compute_amount(price, ...)
|
|
13
|
+
discount.label = adjustment_label(price)
|
|
14
|
+
discount
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def find_discount(price)
|
|
20
|
+
price.discounts.detect { |discount| discount.source == self }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def build_discount
|
|
24
|
+
SolidusPromotions::ItemDiscount.new(source: self)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -3,30 +3,53 @@
|
|
|
3
3
|
module SolidusPromotions
|
|
4
4
|
module Benefits
|
|
5
5
|
class CreateDiscountedItem < Benefit
|
|
6
|
-
include OrderBenefit
|
|
7
6
|
preference :variant_id, :integer
|
|
8
7
|
preference :quantity, :integer, default: 1
|
|
9
8
|
preference :necessary_quantity, :integer, default: 1
|
|
10
9
|
|
|
11
10
|
def perform(order)
|
|
12
|
-
line_item = find_item(order) ||
|
|
11
|
+
line_item = find_item(order) || build_item(order)
|
|
13
12
|
set_quantity(line_item, determine_item_quantity(order))
|
|
14
|
-
|
|
13
|
+
discount_line_item(line_item)
|
|
15
14
|
end
|
|
16
15
|
|
|
17
16
|
def remove_from(order)
|
|
18
|
-
|
|
19
|
-
order.line_items.destroy(line_item)
|
|
17
|
+
find_item(order)&.mark_for_destruction
|
|
20
18
|
end
|
|
21
19
|
|
|
20
|
+
def level
|
|
21
|
+
:order
|
|
22
|
+
end
|
|
23
|
+
deprecate :level, deprecator: Spree.deprecator
|
|
24
|
+
|
|
22
25
|
private
|
|
23
26
|
|
|
27
|
+
def discount_line_item(line_item, ...)
|
|
28
|
+
adjustment = find_adjustment(line_item) || build_adjustment(line_item)
|
|
29
|
+
adjustment.amount = compute_amount(line_item, ...)
|
|
30
|
+
adjustment.label = adjustment_label(line_item)
|
|
31
|
+
adjustment
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def find_adjustment(line_item)
|
|
35
|
+
line_item.adjustments.detect do |adjustment|
|
|
36
|
+
adjustment.source == self
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build_adjustment(line_item)
|
|
41
|
+
line_item.adjustments.build(
|
|
42
|
+
order: line_item.order,
|
|
43
|
+
source: self
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
24
47
|
def find_item(order)
|
|
25
48
|
order.line_items.detect { |line_item| line_item.managed_by_order_benefit == self }
|
|
26
49
|
end
|
|
27
50
|
|
|
28
|
-
def
|
|
29
|
-
order.line_items.
|
|
51
|
+
def build_item(order)
|
|
52
|
+
order.line_items.build(quantity: determine_item_quantity(order), variant: variant, managed_by_order_benefit: self)
|
|
30
53
|
end
|
|
31
54
|
|
|
32
55
|
def determine_item_quantity(order)
|
|
@@ -2,24 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
require_dependency "spree/calculator"
|
|
4
4
|
|
|
5
|
-
# This is a calculator for line item adjustment benefits. It accepts a line item
|
|
6
|
-
# and calculates its weighted adjustment amount based on the value of the
|
|
7
|
-
# preferred amount and the price of the other line items. More expensive line
|
|
8
|
-
# items will receive a greater share of the preferred amount.
|
|
9
5
|
module SolidusPromotions
|
|
10
6
|
module Calculators
|
|
7
|
+
# A calculator that distributes a fixed discount amount across line items based on their value.
|
|
8
|
+
#
|
|
9
|
+
# This calculator takes a preferred total discount amount and distributes it proportionally
|
|
10
|
+
# across applicable line items based on their prices. More expensive line items receive
|
|
11
|
+
# a greater share of the discount.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# # Given a $30 discount and line items worth $100, $50, and $50:
|
|
15
|
+
# # - $100 line item receives $15 discount (50% of total value)
|
|
16
|
+
# # - $50 line item receives $7.50 discount (25% of total value)
|
|
17
|
+
# # - $50 line item receives $7.50 discount (25% of total value)
|
|
11
18
|
class DistributedAmount < Spree::Calculator
|
|
12
19
|
include PromotionCalculator
|
|
13
20
|
|
|
14
|
-
preference :amount, :decimal, default:
|
|
21
|
+
preference :amount, :decimal, default: Spree::ZERO
|
|
15
22
|
preference :currency, :string, default: -> { Spree::Config[:currency] }
|
|
16
23
|
|
|
24
|
+
# Computes the weighted discount amount for a specific line item.
|
|
25
|
+
#
|
|
26
|
+
# The discount is calculated by distributing the preferred amount across all
|
|
27
|
+
# applicable line items, weighted by their prices. Returns 0 if:
|
|
28
|
+
# - The line item is nil
|
|
29
|
+
# - The currency doesn't match the preferred currency
|
|
30
|
+
# - The line item is not in the list of applicable line items
|
|
31
|
+
#
|
|
32
|
+
# @param line_item [Spree::LineItem] The line item to calculate the discount for
|
|
33
|
+
#
|
|
34
|
+
# @return [BigDecimal] The weighted discount amount for this line item
|
|
35
|
+
#
|
|
36
|
+
# @example Computing discount for a line item
|
|
37
|
+
# calculator = DistributedAmount.new(preferred_amount: 20, preferred_currency: 'USD')
|
|
38
|
+
# # Assuming there are 2 line items: one at $80, one at $20
|
|
39
|
+
# calculator.compute_line_item(expensive_line_item) # => 16.0 (80% of 20)
|
|
40
|
+
# calculator.compute_line_item(cheaper_line_item) # => 4.0 (20% of 20)
|
|
41
|
+
#
|
|
42
|
+
# @see DistributedAmountsHandler
|
|
17
43
|
def compute_line_item(line_item)
|
|
18
|
-
return
|
|
19
|
-
return
|
|
44
|
+
return Spree::ZERO unless line_item
|
|
45
|
+
return Spree::ZERO unless preferred_currency.casecmp(line_item.currency).zero?
|
|
20
46
|
|
|
21
47
|
distributable_line_items = calculable.applicable_line_items(line_item.order)
|
|
22
|
-
return
|
|
48
|
+
return Spree::ZERO unless line_item.in?(distributable_line_items)
|
|
23
49
|
|
|
24
50
|
DistributedAmountsHandler.new(
|
|
25
51
|
distributable_line_items,
|