spree_core 6.0.0.beta2 → 6.0.0.beta3
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/app/jobs/spree/payments/handle_webhook_job.rb +1 -0
- data/app/jobs/spree/sample_data/load_job.rb +7 -2
- data/app/jobs/spree/seller_transfers/execute_pending_job.rb +1 -0
- data/app/mailers/spree/admin_user_mailer.rb +2 -2
- data/app/models/concerns/spree/customer_methods.rb +17 -1
- data/app/models/concerns/spree/has_number.rb +3 -2
- data/app/models/concerns/spree/prefixed_id.rb +4 -1
- data/app/models/concerns/spree/purchase/totals.rb +3 -1
- data/app/models/concerns/spree/ransackable_attributes.rb +38 -6
- data/app/models/concerns/spree/translatable_resource.rb +6 -7
- data/app/models/concerns/spree/webhook_payload_redaction.rb +132 -21
- data/app/models/spree/adjusters/promotion.rb +1 -1
- data/app/models/spree/authentication/lockout.rb +34 -0
- data/app/models/spree/authentication/strategies/email_password_strategy.rb +4 -12
- data/app/models/spree/authentication/strategies/oidc_strategy.rb +1 -1
- data/app/models/spree/claim.rb +1 -0
- data/app/models/spree/commission_line.rb +4 -2
- data/app/models/spree/commission_rule.rb +1 -1
- data/app/models/spree/coupon_code.rb +14 -2
- data/app/models/spree/current.rb +15 -0
- data/app/models/spree/exchange.rb +1 -0
- data/app/models/spree/export.rb +8 -5
- data/app/models/spree/exports/customers.rb +2 -2
- data/app/models/spree/fulfillment_group.rb +127 -0
- data/app/models/spree/gift_card.rb +9 -0
- data/app/models/spree/invitation.rb +18 -5
- data/app/models/spree/number_generators/sequential.rb +2 -2
- data/app/models/spree/option_value.rb +7 -0
- data/app/models/spree/order.rb +35 -12
- data/app/models/spree/order_group.rb +78 -6
- data/app/models/spree/payment_source.rb +1 -1
- data/app/models/spree/payment_split.rb +24 -0
- data/app/models/spree/price.rb +74 -0
- data/app/models/spree/product.rb +4 -0
- data/app/models/spree/product_option_type.rb +11 -0
- data/app/models/spree/promotion/actions/create_line_items.rb +92 -30
- data/app/models/spree/promotion/rules/category.rb +14 -9
- data/app/models/spree/promotion/rules/product.rb +17 -1
- data/app/models/spree/promotion.rb +1 -1
- data/app/models/spree/promotion_action.rb +12 -1
- data/app/models/spree/promotion_handler/coupon.rb +50 -5
- data/app/models/spree/promotion_rule.rb +14 -0
- data/app/models/spree/return.rb +16 -6
- data/app/models/spree/return_line_item.rb +19 -0
- data/app/models/spree/search_provider/database.rb +10 -2
- data/app/models/spree/seller_payout.rb +3 -1
- data/app/models/spree/seller_transfer.rb +3 -1
- data/app/models/spree/store_credit.rb +4 -0
- data/app/models/spree/translations/batch.rb +4 -2
- data/app/models/spree/user_identity.rb +10 -6
- data/app/models/spree/variant.rb +8 -3
- data/app/models/spree/webhook_delivery.rb +28 -0
- data/app/models/spree/webhook_endpoint.rb +14 -0
- data/app/presenters/spree/csv/order_line_item_presenter.rb +2 -2
- data/app/services/spree/carts/split_by_seller.rb +22 -12
- data/app/services/spree/companies/add_member.rb +8 -2
- data/app/services/spree/imports/row_processors/price_list_price.rb +6 -0
- data/app/services/spree/imports/row_processors/product_translation.rb +1 -1
- data/app/services/spree/imports/row_processors/product_variant.rb +17 -11
- data/app/services/spree/order_groups/allocate_payments.rb +3 -0
- data/app/services/spree/orders/build_fulfillments.rb +6 -0
- data/app/services/spree/orders/update_statuses.rb +8 -2
- data/app/services/spree/prices/bulk_upsert.rb +186 -47
- data/app/services/spree/sample_data/import_builder.rb +1 -1
- data/app/services/spree/sample_data/loader.rb +54 -40
- data/app/services/spree/seeds/all.rb +1 -26
- data/app/services/spree/seeds/allowed_origins.rb +5 -4
- data/app/services/spree/seeds/api_keys.rb +10 -9
- data/app/services/spree/seeds/channels.rb +9 -8
- data/app/services/spree/seeds/commission_rates.rb +21 -20
- data/app/services/spree/seeds/customer_groups.rb +5 -4
- data/app/services/spree/seeds/digital_delivery.rb +7 -8
- data/app/services/spree/seeds/payment_methods.rb +12 -11
- data/app/services/spree/seeds/product_types.rb +7 -8
- data/app/services/spree/seeds/returns_environment.rb +12 -11
- data/app/services/spree/seeds/roles.rb +8 -7
- data/app/services/spree/seeds/saved_reports.rb +19 -20
- data/app/services/spree/seeds/seller_requirements.rb +5 -4
- data/app/services/spree/seeds/store_resources.rb +41 -0
- data/app/services/spree/seeds/store_scoped.rb +15 -0
- data/app/services/spree/seeds/tax_categories.rb +10 -9
- data/app/services/spree/stock_locations/stock_items/create.rb +1 -1
- data/app/services/spree/stock_locations/stock_levels/create.rb +19 -3
- data/app/subscribers/spree/order_status_subscriber.rb +15 -7
- data/app/workflows/spree/carts/add_item.rb +5 -7
- data/app/workflows/spree/carts/complete.rb +20 -64
- data/app/workflows/spree/carts/recalculate_totals.rb +3 -1
- data/app/workflows/spree/claims/create.rb +31 -1
- data/app/workflows/spree/claims/resolve.rb +7 -11
- data/app/workflows/spree/exchanges/create.rb +11 -0
- data/app/workflows/spree/exchanges/fulfill.rb +21 -13
- data/app/workflows/spree/orders/complete.rb +99 -2
- data/app/workflows/spree/price_lists/update.rb +17 -0
- data/app/workflows/spree/refunds/order_payments.rb +55 -1
- data/app/workflows/spree/returns/create.rb +9 -15
- data/app/workflows/spree/returns/refund.rb +15 -24
- data/app/workflows/spree/returns/returnable_quantity.rb +53 -0
- data/app/workflows/spree/sellers/invite.rb +1 -1
- data/config/locales/en.yml +7 -119
- data/db/migrate/20260916000001_add_email_delivery_flags_to_spree_order_groups.rb +14 -0
- data/db/migrate/20260922000001_add_refunded_order_to_spree_store_credits.rb +5 -0
- data/db/migrate/20260923000001_change_spree_user_identities_tokens_to_text.rb +10 -0
- data/db/sample_data/channels.rb +1 -1
- data/db/sample_data/collections.rb +1 -1
- data/db/sample_data/custom_field_definitions.rb +1 -1
- data/db/sample_data/digital_products.rb +1 -1
- data/db/sample_data/fulfillment.rb +1 -1
- data/db/sample_data/options.rb +4 -2
- data/db/sample_data/orders.rb +9 -10
- data/db/sample_data/payment_methods.rb +18 -18
- data/db/sample_data/product_type_categories.rb +1 -1
- data/db/sample_data/product_types.rb +2 -2
- data/db/sample_data/promotions.rb +2 -4
- data/db/sample_data/wholesale.rb +1 -1
- data/lib/generators/spree/api_resource/api_resource_generator.rb +58 -1
- data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +6 -5
- data/lib/generators/spree/api_resource/templates/permissions.en.yml.tt +6 -0
- data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +2 -2
- data/lib/generators/spree/dummy/templates/rails/test.rb +7 -0
- data/lib/generators/spree/subscriber/subscriber_generator.rb +3 -3
- data/lib/spree/core/engine.rb +8 -8
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/iso_data.rb +23 -4
- data/lib/spree/store_scope_guard.rb +16 -1
- data/lib/spree/testing_support/factories/invitation_factory.rb +1 -0
- data/lib/spree/testing_support/factories/order_group_factory.rb +49 -0
- data/lib/spree/testing_support/factories/payment_factory.rb +7 -2
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
- data/lib/tasks/sample_data.rake +8 -2
- data/lib/tasks/upgrade.rake +6 -21
- metadata +13 -4
|
@@ -19,7 +19,33 @@ module Spree
|
|
|
19
19
|
|
|
20
20
|
delegate :eligible?, to: :promotion
|
|
21
21
|
|
|
22
|
-
#
|
|
22
|
+
# @return [Symbol]
|
|
23
|
+
def discount_scope
|
|
24
|
+
:line_item
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The gift is rarely the product the rules name ("buy a coffee maker,
|
|
28
|
+
# get a free tote"), so the rules decide whether the promotion applies
|
|
29
|
+
# and this action decides which lines it pays for.
|
|
30
|
+
#
|
|
31
|
+
# @param order [Spree::Order, Spree::Cart]
|
|
32
|
+
# @param line_item [Spree::LineItem]
|
|
33
|
+
# @return [Boolean]
|
|
34
|
+
def applies_to_line_item?(order, line_item)
|
|
35
|
+
gifted_item?(line_item) && qualifies_beyond_the_gift?(order)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Covers the gifted units of the line at their own price, leaving any
|
|
39
|
+
# further units of the same variant the shopper bought themselves to be
|
|
40
|
+
# paid for.
|
|
41
|
+
#
|
|
42
|
+
# @param line_item [Spree::LineItem]
|
|
43
|
+
# @return [BigDecimal] never positive
|
|
44
|
+
def compute_amount(line_item)
|
|
45
|
+
line_item.price * gifted_quantity_of(line_item) * -1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Tops the order up to the promised quantity and discounts it.
|
|
23
49
|
#
|
|
24
50
|
# This doesn't play right with Add to Cart events because at the moment
|
|
25
51
|
# the item was added to cart the promo may not be eligible. However it
|
|
@@ -32,46 +58,26 @@ module Spree
|
|
|
32
58
|
# - Customer increases item quantity to 5 (order total goes to $50)
|
|
33
59
|
# - Now the order is eligible for the promo and the action should perform
|
|
34
60
|
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
# e.g.
|
|
39
|
-
# - Promo adds 1 item A to cart if order total greater then $30
|
|
40
|
-
# - Customer add 2 items B to cart, current order total is $40
|
|
41
|
-
# - This action performs adding item A to cart since order is eligible
|
|
42
|
-
# - Customer changes his mind and updates item B quantity to 1
|
|
43
|
-
# - At this point order is no longer eligible and one might expect
|
|
44
|
-
# that item A should be removed
|
|
45
|
-
#
|
|
46
|
-
# It doesn't remove items from the order here because there's no way
|
|
47
|
-
# it can know whether that item was added via this promo action or if
|
|
48
|
-
# it was manually populated somewhere else. In that case the item
|
|
49
|
-
# needs to be manually removed from the order by the customer
|
|
61
|
+
# A shopper who already has the gift variant is given theirs free rather
|
|
62
|
+
# than a duplicate, so the promotion still applies when it has nothing
|
|
63
|
+
# to add.
|
|
50
64
|
def perform(options = {})
|
|
51
65
|
order = options[:order]
|
|
52
66
|
return unless eligible? order
|
|
67
|
+
return unless qualifies_beyond_the_gift?(order)
|
|
53
68
|
|
|
54
|
-
|
|
55
|
-
promotion_action_line_items.each do |item|
|
|
56
|
-
current_quantity = order.quantity_of(item.variant)
|
|
57
|
-
next unless current_quantity < item.quantity && item_available?(item)
|
|
69
|
+
added = add_missing_line_items(order)
|
|
58
70
|
|
|
59
|
-
|
|
60
|
-
line_item = add_service.call(**{ (order.is_a?(Spree::Cart) ? :cart : :order) => order },
|
|
61
|
-
variant: item.variant,
|
|
62
|
-
quantity: item.quantity - current_quantity).value
|
|
63
|
-
action_taken = true if line_item.try(:valid?)
|
|
64
|
-
end
|
|
65
|
-
action_taken
|
|
71
|
+
apply_via_adjuster(options) || added
|
|
66
72
|
end
|
|
67
73
|
|
|
68
74
|
# Called by promotion handler when a promotion is removed
|
|
69
75
|
# This will find any line item matching the ones defined in the PromotionAction
|
|
70
76
|
# and remove the same quantity as was added by the PromotionAction.
|
|
71
|
-
# Should help to prevent some of cases listed above the #perform method
|
|
72
77
|
def revert(options = {})
|
|
73
78
|
order = options[:order]
|
|
74
79
|
return if eligible?(order)
|
|
80
|
+
return unless order.promotions.include?(promotion)
|
|
75
81
|
|
|
76
82
|
action_taken = false
|
|
77
83
|
promotion_action_line_items.each do |item|
|
|
@@ -89,13 +95,69 @@ module Spree
|
|
|
89
95
|
end
|
|
90
96
|
|
|
91
97
|
# Checks that there's enough stock to add the line item to the order
|
|
92
|
-
|
|
98
|
+
#
|
|
99
|
+
# @param item [Spree::PromotionActionLineItem]
|
|
100
|
+
# @param quantity [Integer] units wanted, defaulting to the whole gift
|
|
101
|
+
# @return [Boolean]
|
|
102
|
+
def item_available?(item, quantity = item.quantity)
|
|
93
103
|
quantifier = Spree::Stock::Quantifier.new(item.variant)
|
|
94
|
-
quantifier.can_supply?
|
|
104
|
+
quantifier.can_supply? quantity
|
|
95
105
|
end
|
|
96
106
|
|
|
97
107
|
private
|
|
98
108
|
|
|
109
|
+
# Whether this line holds a variant the promotion gives away.
|
|
110
|
+
def gifted_item?(line_item)
|
|
111
|
+
gifted_quantity_of(line_item).positive?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A gift cannot be the thing that qualifies the order for the promotion
|
|
115
|
+
# giving it away: a rule naming the gift's own product would otherwise
|
|
116
|
+
# hand the item over to anyone who put it in their cart. The order has
|
|
117
|
+
# to hold a line the rules count with units this action is not already
|
|
118
|
+
# covering. A promotion with no rules qualifies on anything, so there is
|
|
119
|
+
# nothing for the gift to stand in for.
|
|
120
|
+
def qualifies_beyond_the_gift?(order)
|
|
121
|
+
return true if promotion.promotion_rules.empty?
|
|
122
|
+
|
|
123
|
+
order.line_items.any? do |line_item|
|
|
124
|
+
promotion.line_item_actionable?(order, line_item) &&
|
|
125
|
+
gifted_quantity_of(line_item) < line_item.quantity
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# How many units of this line the promotion pays for — never more than
|
|
130
|
+
# the line holds, so a shopper buying three of a variant gifted once
|
|
131
|
+
# still pays for two. `quantity` is nullable and written by `upsert_all`,
|
|
132
|
+
# which skips validation, so a missing or negative one gifts nothing
|
|
133
|
+
# rather than raising or turning the discount into a surcharge.
|
|
134
|
+
#
|
|
135
|
+
# @param line_item [Spree::LineItem]
|
|
136
|
+
# @return [Integer]
|
|
137
|
+
def gifted_quantity_of(line_item)
|
|
138
|
+
gifted = promotion_action_line_items.detect { |item| item.variant_id == line_item.variant_id }
|
|
139
|
+
return 0 if gifted.nil?
|
|
140
|
+
|
|
141
|
+
[[line_item.quantity, gifted.quantity.to_i].min, 0].max
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def add_missing_line_items(order)
|
|
145
|
+
added_results = promotion_action_line_items.map do |item|
|
|
146
|
+
missing = item.quantity.to_i - order.quantity_of(item.variant)
|
|
147
|
+
next false unless missing.positive? && item_available?(item, missing)
|
|
148
|
+
|
|
149
|
+
add_service = order.is_a?(Spree::Cart) ? Spree.cart_add_item_workflow : Spree.order_add_item_service
|
|
150
|
+
result = add_service.call(**{ (order.is_a?(Spree::Cart) ? :cart : :order) => order },
|
|
151
|
+
variant: item.variant,
|
|
152
|
+
quantity: missing)
|
|
153
|
+
result.success?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
order.line_items.reload if added_results.any?
|
|
157
|
+
|
|
158
|
+
added_results.any?
|
|
159
|
+
end
|
|
160
|
+
|
|
99
161
|
def promotion_variants_scope
|
|
100
162
|
promotion.store.variants
|
|
101
163
|
end
|
|
@@ -21,18 +21,16 @@ module Spree
|
|
|
21
21
|
'category'
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
# another store raises rather than quietly linking — the promotion
|
|
29
|
-
# would otherwise discount against a catalog its store cannot see.
|
|
24
|
+
# Resolved through the promotion's own store, prefixed and raw ids
|
|
25
|
+
# alike, so an id belonging to another store raises rather than
|
|
26
|
+
# quietly linking — the promotion would otherwise discount against a
|
|
27
|
+
# catalog its store cannot see.
|
|
30
28
|
def category_ids=(ids)
|
|
31
|
-
super(
|
|
32
|
-
Spree::PrefixedId.prefixed_id?(id) ? promotion_categories_scope.find_by_param!(id).id : id
|
|
33
|
-
end)
|
|
29
|
+
super(ids_within_store(ids, promotion && promotion_categories_scope))
|
|
34
30
|
end
|
|
35
31
|
|
|
32
|
+
validate :categories_belong_to_store
|
|
33
|
+
|
|
36
34
|
#
|
|
37
35
|
# Preferences
|
|
38
36
|
#
|
|
@@ -140,6 +138,13 @@ module Spree
|
|
|
140
138
|
promotion.store.categories
|
|
141
139
|
end
|
|
142
140
|
|
|
141
|
+
def categories_belong_to_store
|
|
142
|
+
return if promotion.nil? || category_ids.empty?
|
|
143
|
+
return if (category_ids - promotion_categories_scope.where(id: category_ids).ids).empty?
|
|
144
|
+
|
|
145
|
+
errors.add(:categories, :invalid)
|
|
146
|
+
end
|
|
147
|
+
|
|
143
148
|
# IDs of categories in rule including all their children
|
|
144
149
|
def eligible_category_ids_including_children
|
|
145
150
|
@eligible_category_ids_including_children ||= begin
|
|
@@ -15,6 +15,15 @@ module Spree
|
|
|
15
15
|
|
|
16
16
|
self.additional_permitted_attributes = [product_ids: []]
|
|
17
17
|
|
|
18
|
+
validate :products_belong_to_store
|
|
19
|
+
|
|
20
|
+
# Resolved through the promotion's own store, so another store's
|
|
21
|
+
# product raises rather than being linked and then served back with
|
|
22
|
+
# its admin data.
|
|
23
|
+
def product_ids=(ids)
|
|
24
|
+
super(ids_within_store(ids, promotion&.store&.products))
|
|
25
|
+
end
|
|
26
|
+
|
|
18
27
|
#
|
|
19
28
|
# Preferences
|
|
20
29
|
#
|
|
@@ -86,11 +95,18 @@ module Spree
|
|
|
86
95
|
ActiveSupport::Deprecation.warn(
|
|
87
96
|
'Please use `product_ids=` instead.'
|
|
88
97
|
)
|
|
89
|
-
self.product_ids = s
|
|
98
|
+
self.product_ids = s.to_s.split(',')
|
|
90
99
|
end
|
|
91
100
|
|
|
92
101
|
private
|
|
93
102
|
|
|
103
|
+
def products_belong_to_store
|
|
104
|
+
return if promotion.nil? || product_ids.empty?
|
|
105
|
+
return if (product_ids - promotion.store.products.where(id: product_ids).ids).empty?
|
|
106
|
+
|
|
107
|
+
errors.add(:products, :invalid)
|
|
108
|
+
end
|
|
109
|
+
|
|
94
110
|
def add_products
|
|
95
111
|
return if product_ids_to_add.nil?
|
|
96
112
|
|
|
@@ -37,6 +37,17 @@ module Spree
|
|
|
37
37
|
false
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Whether this action's line-level discount may land on +line_item+.
|
|
41
|
+
# Promotion rules narrow it by default; an action that creates its own
|
|
42
|
+
# lines answers for itself.
|
|
43
|
+
#
|
|
44
|
+
# @param order [Spree::Order, Spree::Cart]
|
|
45
|
+
# @param line_item [Spree::LineItem]
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def applies_to_line_item?(order, line_item)
|
|
48
|
+
promotion.line_item_actionable?(order, line_item)
|
|
49
|
+
end
|
|
50
|
+
|
|
40
51
|
# Returns true if the promotion action is a free shipping action
|
|
41
52
|
#
|
|
42
53
|
# @return [Boolean]
|
|
@@ -53,7 +64,7 @@ module Spree
|
|
|
53
64
|
def self.types_for_discount_scope(scope)
|
|
54
65
|
case scope
|
|
55
66
|
when :line_item
|
|
56
|
-
%w[Spree::Promotion::Actions::CreateItemAdjustments Spree::Promotion::Actions::
|
|
67
|
+
%w[Spree::Promotion::Actions::CreateItemAdjustments Spree::Promotion::Actions::CreateLineItems]
|
|
57
68
|
when :fulfillment
|
|
58
69
|
%w[Spree::Promotion::Actions::FreeShipping Spree::Promotion::Actions::FreeShipping]
|
|
59
70
|
when :order
|
|
@@ -80,8 +80,7 @@ module Spree
|
|
|
80
80
|
clear_persisted_coupon_code(coupon_code)
|
|
81
81
|
|
|
82
82
|
if promotion.multi_codes?
|
|
83
|
-
|
|
84
|
-
coupon_code&.remove_from_order
|
|
83
|
+
promotion.coupon_codes.held_by(order).first&.remove_from_order
|
|
85
84
|
else
|
|
86
85
|
promotion.touch
|
|
87
86
|
end
|
|
@@ -217,7 +216,10 @@ module Spree
|
|
|
217
216
|
end
|
|
218
217
|
|
|
219
218
|
if discount || created_line_items
|
|
220
|
-
|
|
219
|
+
if discount && !claim_coupon_code(discount, coupon_code)
|
|
220
|
+
release_coupon_code(order, coupon_code)
|
|
221
|
+
return set_error_code :coupon_code_used
|
|
222
|
+
end
|
|
221
223
|
|
|
222
224
|
order.recalculate_totals!
|
|
223
225
|
set_success_code :coupon_code_applied
|
|
@@ -236,8 +238,51 @@ module Spree
|
|
|
236
238
|
end
|
|
237
239
|
end
|
|
238
240
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
+
# A single-use code is spent only when an order is placed with it, so a
|
|
242
|
+
# code sitting on another open cart is taken from it rather than
|
|
243
|
+
# refused: whoever presents it now gets the discount, and whoever places
|
|
244
|
+
# an order first keeps the code. A cart in the middle of checkout keeps
|
|
245
|
+
# it, and so does a draft order, since its totals are fixed and money
|
|
246
|
+
# may already be at the gateway.
|
|
247
|
+
#
|
|
248
|
+
# The holder is locked before the code, the same order checkout takes
|
|
249
|
+
# them in, so the two serialize instead of deadlocking.
|
|
250
|
+
#
|
|
251
|
+
# @return [Boolean] false when the code can no longer be claimed
|
|
252
|
+
def claim_coupon_code(discount, coupon_code)
|
|
253
|
+
record = Spree::CouponCode.find_by(promotion_id: discount.promotion_id, code: coupon_code)
|
|
254
|
+
return true if record.nil?
|
|
255
|
+
|
|
256
|
+
holder = record.holder
|
|
257
|
+
holder = nil if holder && same_record?(holder, order)
|
|
258
|
+
|
|
259
|
+
Spree::CouponCode.transaction do
|
|
260
|
+
holder&.lock!
|
|
261
|
+
record.lock!
|
|
262
|
+
next false if record.used? || !same_holder?(record.holder, holder) || !releasable?(holder)
|
|
263
|
+
|
|
264
|
+
release_coupon_code(holder, coupon_code) if holder
|
|
265
|
+
record.apply_order!(order)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Gift cards are turned off so a holder paying with one keeps it.
|
|
270
|
+
def release_coupon_code(holder, coupon_code)
|
|
271
|
+
self.class.new(holder, enable_gift_cards: false).remove(coupon_code)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def releasable?(holder)
|
|
275
|
+
return true if holder.nil?
|
|
276
|
+
|
|
277
|
+
holder.is_a?(Spree::Cart) && holder.completed_at.nil? && !holder.completion_claimed?
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def same_record?(holder, other)
|
|
281
|
+
holder.class == other.class && holder.id == other.id
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def same_holder?(current, expected)
|
|
285
|
+
current.nil? || same_record?(current, expected || order)
|
|
241
286
|
end
|
|
242
287
|
|
|
243
288
|
# Whether the coupon handler should also handle gift card codes.
|
|
@@ -55,6 +55,20 @@ module Spree
|
|
|
55
55
|
|
|
56
56
|
private
|
|
57
57
|
|
|
58
|
+
# Resolves ids, prefixed or raw, through the promotion's store, so another
|
|
59
|
+
# store's record raises instead of being linked. A rule built before its
|
|
60
|
+
# promotion is known passes the ids through; its validation checks them.
|
|
61
|
+
#
|
|
62
|
+
# @param ids [Array<String, Integer>]
|
|
63
|
+
# @param scope [ActiveRecord::Relation, nil] the store's records
|
|
64
|
+
# @return [Array<Integer>]
|
|
65
|
+
def ids_within_store(ids, scope)
|
|
66
|
+
ids = Array(ids).reject(&:blank?)
|
|
67
|
+
return ids if scope.nil?
|
|
68
|
+
|
|
69
|
+
ids.map { |id| Spree::PrefixedId.prefixed_id?(id) ? scope.find_by_param!(id).id : scope.find(id).id }
|
|
70
|
+
end
|
|
71
|
+
|
|
58
72
|
def eligibility_error_message(key, options = {})
|
|
59
73
|
Spree.t(key, Hash[scope: [:eligibility_errors, :messages]].merge(options))
|
|
60
74
|
end
|
data/app/models/spree/return.rb
CHANGED
|
@@ -111,21 +111,31 @@ module Spree
|
|
|
111
111
|
end
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
+
# Whether the warehouse has reported on this return.
|
|
115
|
+
#
|
|
116
|
+
# @return [Boolean]
|
|
117
|
+
def counted?
|
|
118
|
+
received? || refunded?
|
|
119
|
+
end
|
|
120
|
+
|
|
114
121
|
# What the customer is owed for the items being returned.
|
|
115
122
|
def refund_total
|
|
116
|
-
return_line_items.sum(&:
|
|
123
|
+
return_line_items.sum(&:refund_amount)
|
|
117
124
|
end
|
|
118
125
|
|
|
119
|
-
# What a refund from this return paid for, line by line.
|
|
120
|
-
# warehouse
|
|
121
|
-
#
|
|
126
|
+
# What a refund from this return paid for, line by line. Only ever what
|
|
127
|
+
# the warehouse counted: the seller's clawback is built from this, and a
|
|
128
|
+
# line nobody counted earned nothing back.
|
|
122
129
|
#
|
|
123
130
|
# @return [Hash{Integer => BigDecimal}] line item id => amount
|
|
124
131
|
def refunded_line_amounts
|
|
132
|
+
return {} unless counted?
|
|
133
|
+
|
|
125
134
|
return_line_items.each_with_object(Hash.new(0)) do |line, amounts|
|
|
126
|
-
|
|
135
|
+
amount = line.refund_amount
|
|
136
|
+
next if amount.zero?
|
|
127
137
|
|
|
128
|
-
amounts[line.line_item_id] +=
|
|
138
|
+
amounts[line.line_item_id] += amount
|
|
129
139
|
end
|
|
130
140
|
end
|
|
131
141
|
|
|
@@ -19,6 +19,25 @@ module Spree
|
|
|
19
19
|
|
|
20
20
|
delegate :order, :currency, to: :return
|
|
21
21
|
|
|
22
|
+
# What this line refunds. Once the warehouse has counted, only the units
|
|
23
|
+
# that arrived are paid for — a customer who announced three and sent two
|
|
24
|
+
# is owed two — and before the count there is nothing to go on but the
|
|
25
|
+
# announced quantity.
|
|
26
|
+
#
|
|
27
|
+
# Rounded to the currency, because this is the ceiling a refund is checked
|
|
28
|
+
# against as well as the figure the dialog offers: a third of $29.99 taken
|
|
29
|
+
# three times is not $29.99 until it is.
|
|
30
|
+
#
|
|
31
|
+
# @return [BigDecimal]
|
|
32
|
+
def refund_amount
|
|
33
|
+
return pre_tax_amount unless self.return.counted?
|
|
34
|
+
return 0.to_d if quantity.to_i.zero?
|
|
35
|
+
|
|
36
|
+
Spree::Money::Rounding.to_currency(
|
|
37
|
+
(pre_tax_amount / quantity) * received_quantity.to_i, currency
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
22
41
|
def display_pre_tax_amount
|
|
23
42
|
Spree::Money.new(pre_tax_amount, currency: currency)
|
|
24
43
|
end
|
|
@@ -7,6 +7,14 @@ module Spree
|
|
|
7
7
|
'best_selling' => :by_best_selling
|
|
8
8
|
}.freeze
|
|
9
9
|
|
|
10
|
+
# @param store [Spree::Store]
|
|
11
|
+
# @param audience [Symbol, nil] who filters: `:store` (shoppers) limits
|
|
12
|
+
# filters to what the storefront may query; `nil` is the back office
|
|
13
|
+
def initialize(store, audience: :store)
|
|
14
|
+
super(store)
|
|
15
|
+
@audience = audience
|
|
16
|
+
end
|
|
17
|
+
|
|
10
18
|
def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
|
|
11
19
|
filters = filters.is_a?(Hash) ? filters.dup : {}
|
|
12
20
|
option_value_ids = filters.delete('with_option_value_ids') || filters.delete(:with_option_value_ids)
|
|
@@ -72,7 +80,7 @@ module Spree
|
|
|
72
80
|
|
|
73
81
|
ransack_filters = sanitize_filters(filters)
|
|
74
82
|
if ransack_filters.present?
|
|
75
|
-
search = scope.ransack(ransack_filters)
|
|
83
|
+
search = scope.ransack(ransack_filters, auth_object: @audience)
|
|
76
84
|
scope = search.result(distinct: true)
|
|
77
85
|
end
|
|
78
86
|
|
|
@@ -127,7 +135,7 @@ module Spree
|
|
|
127
135
|
end
|
|
128
136
|
}.join(',')
|
|
129
137
|
|
|
130
|
-
scope.ransack(s: ransack_sort).result
|
|
138
|
+
scope.ransack({ s: ransack_sort }, auth_object: @audience).result
|
|
131
139
|
end
|
|
132
140
|
end
|
|
133
141
|
|
|
@@ -58,7 +58,9 @@ module Spree
|
|
|
58
58
|
scope :owed, -> { where(status: %w[pending processing]) }
|
|
59
59
|
|
|
60
60
|
self.whitelisted_ransackable_attributes = %w[amount currency status provider reference period_start period_end created_at seller_id]
|
|
61
|
-
|
|
61
|
+
# No `transfers`: they lead on to the order and its buyer, which a
|
|
62
|
+
# payouts-only caller may not read.
|
|
63
|
+
self.whitelisted_ransackable_associations = %w[seller]
|
|
62
64
|
|
|
63
65
|
extend Spree::DisplayMoney
|
|
64
66
|
money_methods :amount
|
|
@@ -112,7 +112,9 @@ module Spree
|
|
|
112
112
|
end
|
|
113
113
|
|
|
114
114
|
self.whitelisted_ransackable_attributes = %w[amount currency kind status provider reference created_at seller_id order_id payout_id]
|
|
115
|
-
|
|
115
|
+
# No `order`: filtering through it reaches the buyer's email and address,
|
|
116
|
+
# which a payouts-only caller may not read. `order_id` covers the lookup.
|
|
117
|
+
self.whitelisted_ransackable_associations = %w[seller payout refund]
|
|
116
118
|
|
|
117
119
|
extend Spree::DisplayMoney
|
|
118
120
|
money_methods :amount
|
|
@@ -25,6 +25,8 @@ module Spree
|
|
|
25
25
|
include Spree::DeprecatedCustomerAlias
|
|
26
26
|
belongs_to :created_by, class_name: Spree.admin_user_class.to_s, foreign_key: 'created_by_id', optional: true
|
|
27
27
|
belongs_to :originator, polymorphic: true, optional: true
|
|
28
|
+
belongs_to :refunded_order, class_name: 'Spree::Order', optional: true,
|
|
29
|
+
inverse_of: :store_credit_refunds
|
|
28
30
|
|
|
29
31
|
has_many :store_credit_events, class_name: 'Spree::StoreCreditEvent'
|
|
30
32
|
has_many :payments, as: :source, class_name: 'Spree::Payment'
|
|
@@ -111,6 +113,8 @@ module Spree
|
|
|
111
113
|
self.whitelisted_ransackable_attributes = %w[customer_id created_by_id amount currency memo]
|
|
112
114
|
self.whitelisted_ransackable_associations = %w[customer created_by]
|
|
113
115
|
self.whitelisted_ransackable_scopes = %w[outstanding from_gift_card]
|
|
116
|
+
# Staff notes and who issued the credit are back-office data.
|
|
117
|
+
self.private_ransackable_attributes = { store: %w[memo created_by_id] }
|
|
114
118
|
|
|
115
119
|
# Two-state scopes: see Spree::Base.ransack_flag? for why the cast is
|
|
116
120
|
# opted out of here and done inside each scope instead.
|
|
@@ -38,8 +38,10 @@ module Spree
|
|
|
38
38
|
attr_reader :records
|
|
39
39
|
|
|
40
40
|
# @param entries [Array<Hash>] [{ resource_type:, resource_id:, values: { locale => { field => value } } }]
|
|
41
|
-
|
|
41
|
+
# @param store [Spree::Store] the store every record must belong to
|
|
42
|
+
def initialize(entries, store: Spree::Store.current)
|
|
42
43
|
@entries = Array(entries)
|
|
44
|
+
@store = store
|
|
43
45
|
@records = []
|
|
44
46
|
end
|
|
45
47
|
|
|
@@ -86,7 +88,7 @@ module Spree
|
|
|
86
88
|
klass = resource_class(entry[:resource_type])
|
|
87
89
|
raise EntryError.new("Unknown translatable resource type: #{entry[:resource_type]}", index) if klass.nil?
|
|
88
90
|
|
|
89
|
-
relation = klass.respond_to?(:
|
|
91
|
+
relation = klass.respond_to?(:translatable_scope) ? klass.translatable_scope(@store) : klass.for_store(@store)
|
|
90
92
|
relation.find_by_prefix_id!(entry[:resource_id])
|
|
91
93
|
rescue ActiveRecord::RecordNotFound
|
|
92
94
|
raise EntryError.new("Resource not found: #{entry[:resource_id]}", index)
|
|
@@ -9,15 +9,19 @@ module Spree
|
|
|
9
9
|
|
|
10
10
|
validates :provider, inclusion: {
|
|
11
11
|
in: lambda { |_record|
|
|
12
|
-
(
|
|
12
|
+
(
|
|
13
|
+
Spree.store_authentication_strategies.keys +
|
|
14
|
+
Spree.admin_authentication_strategies.keys +
|
|
15
|
+
Spree.seller_authentication_strategies.keys
|
|
16
|
+
).uniq.map(&:to_s)
|
|
13
17
|
}
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
# Keys may come from config or from encrypted credentials. Rows written before
|
|
21
|
+
# encryption was enabled stay readable and are encrypted on their next write.
|
|
22
|
+
if ActiveRecord::Encryption.config.has_primary_key?
|
|
23
|
+
encrypts :access_token, :refresh_token, support_unencrypted_data: true
|
|
24
|
+
end
|
|
21
25
|
|
|
22
26
|
# Find or create user from OAuth data
|
|
23
27
|
def self.find_or_create_from_oauth(provider:, uid:, info:, tokens: {}, user_class: nil)
|
data/app/models/spree/variant.rb
CHANGED
|
@@ -330,6 +330,7 @@ module Spree
|
|
|
330
330
|
carton_package_type_id carton_weight cartons_per_pallet]
|
|
331
331
|
self.whitelisted_ransackable_scopes = %i(product_name_or_sku_cont search_by_product_name_or_sku search
|
|
332
332
|
available_at_stock_location)
|
|
333
|
+
self.private_ransackable_attributes = { store: %w[cost_price cost_currency deleted_at] }
|
|
333
334
|
|
|
334
335
|
def self.product_name_or_sku_cont(query)
|
|
335
336
|
sanitized_query = ActiveRecord::Base.sanitize_sql_like(query.to_s.downcase.strip)
|
|
@@ -604,8 +605,11 @@ module Spree
|
|
|
604
605
|
# @param opt_type_position [Integer] the position of the option type
|
|
605
606
|
# @return [void]
|
|
606
607
|
def set_option_value(opt_name, opt_value, opt_type_position = nil)
|
|
607
|
-
|
|
608
|
+
# The product's own store: option types are store-owned, and matching one
|
|
609
|
+
# by name elsewhere would attach and extend another store's type.
|
|
610
|
+
option_type = Spree::OptionType.for_store(product.store).where(name: opt_name.parameterize).first_or_initialize do |o|
|
|
608
611
|
o.name = o.label = opt_name
|
|
612
|
+
o.store = product.store
|
|
609
613
|
o.save!
|
|
610
614
|
end
|
|
611
615
|
|
|
@@ -1190,10 +1194,11 @@ module Spree
|
|
|
1190
1194
|
end
|
|
1191
1195
|
|
|
1192
1196
|
# Only the product's own store's locations — a new variant must not grow
|
|
1193
|
-
# stock items in every other store's warehouses
|
|
1197
|
+
# stock items in every other store's warehouses — and of those, a seller's
|
|
1198
|
+
# location only stocks what that seller sells.
|
|
1194
1199
|
def create_stock_levels
|
|
1195
1200
|
locations = product&.store ? product.store.stock_locations : StockLocation.all
|
|
1196
|
-
locations.where(propagate_all_variants: true).each do |stock_location|
|
|
1201
|
+
locations.where(propagate_all_variants: true, seller_id: [nil, resolved_seller_id].uniq).each do |stock_location|
|
|
1197
1202
|
stock_location.propagate_variant(self)
|
|
1198
1203
|
end
|
|
1199
1204
|
end
|
|
@@ -26,6 +26,34 @@ module Spree
|
|
|
26
26
|
# Ransack configuration
|
|
27
27
|
self.whitelisted_ransackable_attributes = %w[event_name response_code execution_time success delivered_at created_at]
|
|
28
28
|
|
|
29
|
+
# Event subjects whose permission is not found by class name alone.
|
|
30
|
+
PAYLOAD_SUBJECT_CLASSES = {
|
|
31
|
+
'cart' => 'Spree::Order',
|
|
32
|
+
'customer' => -> { Spree.customer_class },
|
|
33
|
+
'newsletter_subscriber' => -> { Spree.customer_class }
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# Event subjects whose payload holds no record of anyone's data.
|
|
37
|
+
PUBLIC_PAYLOAD_SUBJECTS = %w[webhook].freeze
|
|
38
|
+
|
|
39
|
+
# The catalog key a caller needs to read this delivery's payload. The
|
|
40
|
+
# payload is the full record the event is about — an order's addresses, a
|
|
41
|
+
# customer's email — so reading the log is no wider than reading the
|
|
42
|
+
# record. A subject no permission scope covers falls back to customer
|
|
43
|
+
# access, since an unknown payload may hold personal data.
|
|
44
|
+
#
|
|
45
|
+
# @return [String, nil] nil when anyone who can read the log may see it
|
|
46
|
+
def payload_permission_key
|
|
47
|
+
subject = event_name.to_s.split('.').first.to_s
|
|
48
|
+
return if PUBLIC_PAYLOAD_SUBJECTS.include?(subject)
|
|
49
|
+
|
|
50
|
+
source = PAYLOAD_SUBJECT_CLASSES[subject]
|
|
51
|
+
klass = source.respond_to?(:call) ? source.call : (source || "Spree::#{subject.camelize}").to_s.safe_constantize
|
|
52
|
+
scope = Spree.permissions.scope_for_resource(klass)
|
|
53
|
+
|
|
54
|
+
scope ? "read_#{scope.name}" : 'read_customers'
|
|
55
|
+
end
|
|
56
|
+
|
|
29
57
|
# Check if the delivery was successful
|
|
30
58
|
#
|
|
31
59
|
# @return [Boolean]
|
|
@@ -50,11 +50,18 @@ module Spree
|
|
|
50
50
|
# Number of consecutive failed deliveries before auto-disabling
|
|
51
51
|
AUTO_DISABLE_THRESHOLD = 15
|
|
52
52
|
|
|
53
|
+
# Events whose payload carries a live customer credential (a password
|
|
54
|
+
# reset token). Receiving one is as good as holding the customer's account,
|
|
55
|
+
# so they reach only an endpoint that names them — never through `*` or a
|
|
56
|
+
# pattern — and the API asks for customer write access to point one at them.
|
|
57
|
+
CREDENTIAL_EVENTS = %w[customer.password_reset_requested].freeze
|
|
58
|
+
|
|
53
59
|
# Check if this endpoint is subscribed to a specific event
|
|
54
60
|
#
|
|
55
61
|
# @param event_name [String] the event name to check
|
|
56
62
|
# @return [Boolean]
|
|
57
63
|
def subscribed_to?(event_name)
|
|
64
|
+
return subscriptions.to_a.include?(event_name) if CREDENTIAL_EVENTS.include?(event_name)
|
|
58
65
|
return true if subscriptions.blank? || subscriptions.include?('*')
|
|
59
66
|
|
|
60
67
|
subscriptions.any? do |subscription|
|
|
@@ -67,6 +74,13 @@ module Spree
|
|
|
67
74
|
end
|
|
68
75
|
end
|
|
69
76
|
|
|
77
|
+
# Whether this endpoint receives customer credentials.
|
|
78
|
+
#
|
|
79
|
+
# @return [Boolean]
|
|
80
|
+
def receives_credentials?
|
|
81
|
+
(subscriptions.to_a & CREDENTIAL_EVENTS).any?
|
|
82
|
+
end
|
|
83
|
+
|
|
70
84
|
# Returns all events this endpoint is subscribed to
|
|
71
85
|
#
|
|
72
86
|
# @return [Array<String>]
|