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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fb19660bc9b7017b74df8b6b92fc2522cb84ef62a6a015f817484528b7e8c992
|
|
4
|
+
data.tar.gz: bac9e0d41a47af2e5452406f1c0d1ac268b687ebdf1ade714742aa689e5cae0a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f0a322ff461f40b5d0a8eb5522fb5f6b45f0a1f7526e9e797f255a0779ddfeabf0db9bfe6629b341d4ed03f71591c76dc3435928cdf28b7d98029c1164c0efe6
|
|
7
|
+
data.tar.gz: bd3ef61d467f2b481120080350fbf0f7c661e9fe81123c24869978a5177240766454fe248fb35cfc967445fbf049aa2fc483a06884573c13e6c4a33b9fda0b4e
|
|
@@ -9,6 +9,7 @@ module Spree
|
|
|
9
9
|
|
|
10
10
|
def perform(payment_method_id:, action:, payment_session_id:)
|
|
11
11
|
payment_method = Spree::PaymentMethod.find(payment_method_id)
|
|
12
|
+
Spree::Current.store = payment_method.store
|
|
12
13
|
payment_session = Spree::PaymentSession.find(payment_session_id)
|
|
13
14
|
|
|
14
15
|
Spree.payments_handle_webhook_workflow.call(
|
|
@@ -5,8 +5,13 @@ module Spree
|
|
|
5
5
|
# loader needs an admin to own its imports and downloads product images,
|
|
6
6
|
# so it can neither run before setup nor inside the setup request.
|
|
7
7
|
class LoadJob < Spree::BaseJob
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
# @param store_id [String, Integer, nil] the store to load into; the
|
|
9
|
+
# default store when omitted
|
|
10
|
+
def perform(store_id = nil)
|
|
11
|
+
return Spree::SampleData::Loader.call if store_id.nil?
|
|
12
|
+
|
|
13
|
+
store = Spree::Store.find_by(id: store_id)
|
|
14
|
+
Spree::SampleData::Loader.call(store: store) if store
|
|
10
15
|
end
|
|
11
16
|
end
|
|
12
17
|
end
|
|
@@ -26,6 +26,7 @@ module Spree
|
|
|
26
26
|
seller = Spree::Seller.find_by(id: seller_id)
|
|
27
27
|
return if seller.nil? || !seller.payouts_enabled?
|
|
28
28
|
|
|
29
|
+
Spree::Current.store = seller.store
|
|
29
30
|
provider = seller.store.payout_provider_instance
|
|
30
31
|
|
|
31
32
|
# Earnings only — see the scope. A reversal is retryable the same way
|
|
@@ -34,9 +34,9 @@ module Spree
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
# The dashboard SPA passes a validated redirect URL; the token is appended as
|
|
37
|
-
# a query param, falling back to the
|
|
37
|
+
# a query param, falling back to the dashboard's own reset page.
|
|
38
38
|
def password_reset_url(token, store, redirect_url)
|
|
39
|
-
append_token(redirect_url.presence || store
|
|
39
|
+
append_token(redirect_url.presence || "#{Spree::Stores::DashboardUrl.call(store: store)}/reset-password", token)
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
end
|
|
@@ -64,7 +64,14 @@ module Spree
|
|
|
64
64
|
has_many :promotion_rule_users, class_name: 'Spree::PromotionRuleUser', foreign_key: :customer_id, dependent: :destroy
|
|
65
65
|
has_many :promotion_rules, through: :promotion_rule_users, class_name: 'Spree::PromotionRule'
|
|
66
66
|
has_many :orders, foreign_key: :customer_id, class_name: 'Spree::Order'
|
|
67
|
-
|
|
67
|
+
# Open carts only. Since the Cart/Order split a cart lives on its own
|
|
68
|
+
# table and never becomes an order row, so an incomplete Order is a
|
|
69
|
+
# backoffice draft, not a cart. Completed carts stay behind as read-only
|
|
70
|
+
# audit records of a checkout and are reached through the order.
|
|
71
|
+
# No cascade on destroy, like #orders: a cart can be mid-checkout with
|
|
72
|
+
# money authorized against it, and erasure goes through
|
|
73
|
+
# Spree::Customers::Anonymize, which scrubs carts explicitly.
|
|
74
|
+
has_many :carts, -> { incomplete }, foreign_key: :customer_id, class_name: 'Spree::Cart', dependent: nil
|
|
68
75
|
has_many :completed_orders, -> { complete }, foreign_key: :customer_id, class_name: 'Spree::Order'
|
|
69
76
|
has_many :store_credits, class_name: 'Spree::StoreCredit', foreign_key: :customer_id, dependent: :destroy
|
|
70
77
|
# Everything the customer has bought and can download. Scoped to completed
|
|
@@ -272,11 +279,20 @@ module Spree
|
|
|
272
279
|
end
|
|
273
280
|
|
|
274
281
|
# Returns the last incomplete spree order for the current store
|
|
282
|
+
# @deprecated Carts are {Spree::Cart} since 6.0, so an incomplete order is
|
|
283
|
+
# a backoffice draft rather than the customer's cart. Use
|
|
284
|
+
# +carts.where(store: store)+ for carts, or +orders.drafts+ for drafts.
|
|
285
|
+
# Removed in Spree 6.1.
|
|
275
286
|
# @param [Spree::Store] store
|
|
276
287
|
# @param [Hash] options
|
|
277
288
|
# @option options [Array<Symbol>] :includes
|
|
278
289
|
# @return [Spree::Order]
|
|
279
290
|
def last_incomplete_spree_order(store, options = {})
|
|
291
|
+
Spree::Deprecation.warn(
|
|
292
|
+
'Spree::CustomerMethods#last_incomplete_spree_order is deprecated and will be removed in Spree 6.1. ' \
|
|
293
|
+
'Carts are Spree::Cart since 6.0 and this returns draft orders; use #carts for the customer\'s carts.'
|
|
294
|
+
)
|
|
295
|
+
|
|
280
296
|
orders.where(store: store).incomplete.not_canceled.
|
|
281
297
|
includes(options[:includes]).
|
|
282
298
|
order('created_at DESC').
|
|
@@ -99,9 +99,10 @@ module Spree
|
|
|
99
99
|
|
|
100
100
|
# Advisory only — a concurrent writer can take the number between this
|
|
101
101
|
# check and the insert, which is what the unique-index rescue below is
|
|
102
|
-
# for.
|
|
102
|
+
# for. Checked on the base class, unscoped, because the index is global —
|
|
103
|
+
# it spans every STI subclass and soft-deleted row.
|
|
103
104
|
def number_taken?(candidate)
|
|
104
|
-
self.class.unscoped.exists?(number: candidate)
|
|
105
|
+
self.class.base_class.unscoped.exists?(number: candidate)
|
|
105
106
|
end
|
|
106
107
|
|
|
107
108
|
# Last line of defence. The pre-check and the uniqueness validation both
|
|
@@ -168,8 +168,11 @@ module Spree
|
|
|
168
168
|
Spree::PrefixedId.decode_prefixed_id(prefixed_id)
|
|
169
169
|
end
|
|
170
170
|
|
|
171
|
+
# Model-scoped decode: answers nil for another model's prefixed ID, like
|
|
172
|
+
# +find_by_prefix_id+. Use +Spree::PrefixedId.decode_prefixed_id+ when
|
|
173
|
+
# any model's ID is acceptable.
|
|
171
174
|
def decode_prefixed_id(prefixed_id_string)
|
|
172
|
-
|
|
175
|
+
decode_own_prefixed_id(prefixed_id_string)
|
|
173
176
|
end
|
|
174
177
|
|
|
175
178
|
# Find by prefixed ID first, falling back to integer id for backwards compatibility.
|
|
@@ -35,8 +35,10 @@ module Spree
|
|
|
35
35
|
# unsaved changes — callers legitimately hold dirty attributes while
|
|
36
36
|
# a payment is destroyed (cart teardown, order merging), and this
|
|
37
37
|
# must never disturb their in-memory state.
|
|
38
|
+
# updated_at moves with it, or the API validates a cached response
|
|
39
|
+
# against an unchanged timestamp and serves the pre-payment figure.
|
|
38
40
|
settled = settled_payments_arel
|
|
39
|
-
self.class.where(id: id).update_all(payment_total: settled)
|
|
41
|
+
self.class.where(id: id).update_all(payment_total: settled, updated_at: Time.current)
|
|
40
42
|
self.payment_total = self.class.where(id: id).pick(:payment_total)
|
|
41
43
|
end
|
|
42
44
|
|
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
module Spree::RansackableAttributes
|
|
2
2
|
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
# Ransack `auth_object` values for callers outside the back office. The
|
|
5
|
+
# allowlists below were written for staff; a shopper or a seller filtering
|
|
6
|
+
# on them would get a yes/no answer about data their responses never show.
|
|
7
|
+
RESTRICTED_AUDIENCES = %i[store seller].freeze
|
|
8
|
+
|
|
3
9
|
included do
|
|
4
10
|
class_attribute :whitelisted_ransackable_associations
|
|
5
11
|
class_attribute :whitelisted_ransackable_attributes
|
|
6
12
|
class_attribute :whitelisted_ransackable_scopes
|
|
7
13
|
|
|
14
|
+
# Associations the Store API may filter through. Empty by default: every
|
|
15
|
+
# hop is a join into data the storefront was never meant to query, and a
|
|
16
|
+
# join back to the model it came from lets one filter chain them forever.
|
|
17
|
+
class_attribute :storefront_ransackable_associations, default: []
|
|
18
|
+
|
|
19
|
+
# `{ store: [...], seller: [...] }` — attributes and scopes a restricted
|
|
20
|
+
# audience may not filter on, although staff may.
|
|
21
|
+
class_attribute :private_ransackable_attributes, default: {}
|
|
22
|
+
class_attribute :private_ransackable_scopes, default: {}
|
|
23
|
+
|
|
8
24
|
class_attribute :default_ransackable_attributes
|
|
9
25
|
# `position` is included so any `acts_as_list` model is sortable by it
|
|
10
26
|
# without each subclass having to opt in. Ransack ignores attributes
|
|
@@ -12,19 +28,35 @@ module Spree::RansackableAttributes
|
|
|
12
28
|
# without a position column.
|
|
13
29
|
self.default_ransackable_attributes = %w[id name updated_at created_at position]
|
|
14
30
|
|
|
15
|
-
def self.ransackable_associations(
|
|
31
|
+
def self.ransackable_associations(auth_object = nil)
|
|
32
|
+
if Spree::RansackableAttributes.restricted_audience?(auth_object)
|
|
33
|
+
return auth_object.to_sym == :store ? storefront_ransackable_associations : []
|
|
34
|
+
end
|
|
35
|
+
|
|
16
36
|
base = whitelisted_ransackable_associations || []
|
|
17
37
|
base | Spree.ransack.custom_associations_for(self)
|
|
18
38
|
end
|
|
19
39
|
|
|
20
|
-
def self.ransackable_attributes(
|
|
40
|
+
def self.ransackable_attributes(auth_object = nil)
|
|
21
41
|
base = default_ransackable_attributes | (whitelisted_ransackable_attributes || [])
|
|
22
|
-
base
|
|
42
|
+
base |= Spree.ransack.custom_attributes_for(self)
|
|
43
|
+
return base unless Spree::RansackableAttributes.restricted_audience?(auth_object)
|
|
44
|
+
|
|
45
|
+
base - Array(private_ransackable_attributes[auth_object.to_sym])
|
|
23
46
|
end
|
|
24
47
|
|
|
25
|
-
def self.ransackable_scopes(
|
|
26
|
-
base = whitelisted_ransackable_scopes || []
|
|
27
|
-
base
|
|
48
|
+
def self.ransackable_scopes(auth_object = nil)
|
|
49
|
+
base = (whitelisted_ransackable_scopes || []).map(&:to_s)
|
|
50
|
+
base |= Spree.ransack.custom_scopes_for(self).map(&:to_s)
|
|
51
|
+
return base unless Spree::RansackableAttributes.restricted_audience?(auth_object)
|
|
52
|
+
|
|
53
|
+
base - Array(private_ransackable_scopes[auth_object.to_sym])
|
|
28
54
|
end
|
|
29
55
|
end
|
|
56
|
+
|
|
57
|
+
# @param auth_object [Symbol, nil] the Ransack auth object a search ran with
|
|
58
|
+
# @return [Boolean] whether it names a caller outside the back office
|
|
59
|
+
def self.restricted_audience?(auth_object)
|
|
60
|
+
auth_object.respond_to?(:to_sym) && RESTRICTED_AUDIENCES.include?(auth_object.to_sym)
|
|
61
|
+
end
|
|
30
62
|
end
|
|
@@ -48,13 +48,12 @@ module Spree
|
|
|
48
48
|
# Every record of this type belonging to +store+, for surfaces that read
|
|
49
49
|
# a whole translatable resource type at once (the coverage grid).
|
|
50
50
|
#
|
|
51
|
-
# +for_store+ resolves through the store's own association,
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
# the
|
|
57
|
-
# installation, so that case is narrowed here.
|
|
51
|
+
# +for_store+ resolves through the store's own association, or through a
|
|
52
|
+
# model's own +for_store+ scope where there is none to follow (OptionType
|
|
53
|
+
# by its store column, OptionValue through its option type). Its one
|
|
54
|
+
# blind spot is +Spree::Store+: with no `stores` association to follow it
|
|
55
|
+
# falls back to the UNSCOPED class, which would answer with every store in
|
|
56
|
+
# the installation, so that case is narrowed here.
|
|
58
57
|
#
|
|
59
58
|
# @param store [Spree::Store]
|
|
60
59
|
# @return [ActiveRecord::Relation]
|
|
@@ -5,10 +5,13 @@ module Spree
|
|
|
5
5
|
#
|
|
6
6
|
# Some events must carry a live credential to their subscriber — a storefront
|
|
7
7
|
# that owns its transactional emails needs the real password reset token to
|
|
8
|
-
# build the email
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
8
|
+
# build the email, and a mobile app needs the payment session client secret to
|
|
9
|
+
# confirm the payment. Delivering those over TLS to a merchant-configured
|
|
10
|
+
# endpoint is intended; keeping a readable copy in
|
|
11
|
+
# `spree_webhook_deliveries.payload` is not, because that column is queryable
|
|
12
|
+
# and is served back through the Admin API delivery log to any key holding
|
|
13
|
+
# `read_webhooks` — a scope an operator may grant precisely because it carries
|
|
14
|
+
# no access to payments or customers.
|
|
12
15
|
#
|
|
13
16
|
# Sensitive values are therefore replaced with {REDACTION_PLACEHOLDER} before
|
|
14
17
|
# the record is written, and re-attached in memory at send time so the
|
|
@@ -16,29 +19,61 @@ module Spree
|
|
|
16
19
|
module WebhookPayloadRedaction
|
|
17
20
|
extend ActiveSupport::Concern
|
|
18
21
|
|
|
19
|
-
# Payload keys
|
|
20
|
-
|
|
22
|
+
# Payload keys whose values are live credentials, at any depth under `data`.
|
|
23
|
+
#
|
|
24
|
+
# Payment session secrets are gateway-agnostic: `external_client_secret` is
|
|
25
|
+
# a core column and `external_data` is a free-form provider hash, so every
|
|
26
|
+
# provider that stores a confirmation credential there is covered.
|
|
27
|
+
#
|
|
28
|
+
# `token` is the guest cart credential (`X-Spree-Token`), which a placed
|
|
29
|
+
# order inherits; with it anyone can read and change the cart.
|
|
30
|
+
SENSITIVE_PAYLOAD_KEYS = %w[
|
|
31
|
+
token
|
|
32
|
+
reset_token
|
|
33
|
+
unsubscribe_token
|
|
34
|
+
verification_token
|
|
35
|
+
download_url
|
|
36
|
+
external_client_secret
|
|
37
|
+
client_secret
|
|
38
|
+
ephemeral_key_secret
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
# A gift card's `code` is what a shopper redeems, so it is spendable by
|
|
42
|
+
# whoever reads it. Only a gift card's own `code` is redacted — promotion,
|
|
43
|
+
# country and channel codes elsewhere in a payload are not credentials.
|
|
44
|
+
GIFT_CARD_CODE_KEY = 'code'
|
|
45
|
+
GIFT_CARD_NODE = 'gift_card'
|
|
46
|
+
GIFT_CARD_EVENT_PREFIX = 'gift_card.'
|
|
21
47
|
|
|
22
48
|
REDACTION_PLACEHOLDER = '[REDACTED]'
|
|
23
49
|
|
|
50
|
+
# Separates the segments of a secret's path. Segments are escaped before
|
|
51
|
+
# they are joined, so a key that itself contains the separator still maps
|
|
52
|
+
# to one unambiguous path.
|
|
53
|
+
PATH_SEPARATOR = '.'
|
|
54
|
+
|
|
55
|
+
# The root both `data` keys share. A payload is persisted as JSON and read
|
|
56
|
+
# back with string keys, so the symbol and string roots must key their
|
|
57
|
+
# secrets identically or nothing restores after a round trip.
|
|
58
|
+
ROOT_SEGMENT = 'data'
|
|
59
|
+
|
|
60
|
+
# A path segment escapes the separator so it cannot be read as a boundary,
|
|
61
|
+
# and the escape character itself so the escaping stays reversible.
|
|
62
|
+
PATH_ESCAPES = { '\\' => '\\\\', PATH_SEPARATOR => "\\#{PATH_SEPARATOR}" }.freeze
|
|
63
|
+
PATH_ESCAPES_PATTERN = /[\\#{Regexp.escape(PATH_SEPARATOR)}]/.freeze
|
|
64
|
+
|
|
24
65
|
# Splits a payload into the version safe to persist and the secrets held
|
|
25
66
|
# back from it.
|
|
26
67
|
#
|
|
27
68
|
# @param payload [Hash] the full event payload
|
|
28
69
|
# @return [Array(Hash, Hash)] redacted payload, and the extracted secrets
|
|
29
|
-
# keyed
|
|
70
|
+
# keyed by their dotted path under `data`
|
|
30
71
|
def self.split(payload)
|
|
31
72
|
secrets = {}
|
|
73
|
+
gift_card_event = event_name_of(payload).start_with?(GIFT_CARD_EVENT_PREFIX)
|
|
32
74
|
|
|
33
75
|
redacted = transform_data_hashes(payload) do |data|
|
|
34
|
-
data
|
|
35
|
-
if SENSITIVE_PAYLOAD_KEYS.include?(key.to_s) && value.present?
|
|
36
|
-
secrets[secret_key_for(key)] = value
|
|
37
|
-
[key, REDACTION_PLACEHOLDER]
|
|
38
|
-
else
|
|
39
|
-
[key, value]
|
|
40
|
-
end
|
|
41
|
-
end
|
|
76
|
+
redact(data, [ROOT_SEGMENT], secrets, gift_card_event)
|
|
42
77
|
end
|
|
43
78
|
|
|
44
79
|
secrets.empty? ? [payload, {}] : [redacted, secrets]
|
|
@@ -53,12 +88,84 @@ module Spree
|
|
|
53
88
|
return payload if secrets.blank?
|
|
54
89
|
|
|
55
90
|
transform_data_hashes(payload) do |data|
|
|
56
|
-
data
|
|
57
|
-
|
|
58
|
-
|
|
91
|
+
restore(data, [ROOT_SEGMENT], secrets)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Walks a hash replacing sensitive values, recording each one against its
|
|
96
|
+
# path so two secrets sharing a key name (`client_secret` at the top level
|
|
97
|
+
# and inside `external_data`) cannot overwrite one another.
|
|
98
|
+
def self.redact(node, path, secrets, gift_card_event = false)
|
|
99
|
+
if node.is_a?(Array)
|
|
100
|
+
return node.each_with_index.map { |element, index| redact(element, path + [index.to_s], secrets, gift_card_event) }
|
|
101
|
+
end
|
|
102
|
+
return node unless node.is_a?(Hash)
|
|
103
|
+
|
|
104
|
+
node.to_h do |key, value|
|
|
105
|
+
key_path = path + [key.to_s]
|
|
106
|
+
|
|
107
|
+
if sensitive?(key, value) || gift_card_code?(key, value, path, gift_card_event)
|
|
108
|
+
secrets[secret_key_for(key_path)] = value
|
|
109
|
+
[key, REDACTION_PLACEHOLDER]
|
|
110
|
+
else
|
|
111
|
+
[key, redact(value, key_path, secrets, gift_card_event)]
|
|
59
112
|
end
|
|
60
113
|
end
|
|
61
114
|
end
|
|
115
|
+
private_class_method :redact
|
|
116
|
+
|
|
117
|
+
# The mirror of {redact}. Only a slot still holding the placeholder is
|
|
118
|
+
# filled, so a real value the payload carries is never overwritten.
|
|
119
|
+
#
|
|
120
|
+
# Deliveries enqueued before path keying shipped carry secrets under a bare
|
|
121
|
+
# key name. Those only ever came from the top level of `data`, so the
|
|
122
|
+
# fallback is confined there — at depth it would fill every same-named slot
|
|
123
|
+
# in the tree with one secret.
|
|
124
|
+
def self.restore(node, path, secrets)
|
|
125
|
+
if node.is_a?(Array)
|
|
126
|
+
return node.each_with_index.map { |element, index| restore(element, path + [index.to_s], secrets) }
|
|
127
|
+
end
|
|
128
|
+
return node unless node.is_a?(Hash)
|
|
129
|
+
|
|
130
|
+
node.to_h do |key, value|
|
|
131
|
+
key_path = path + [key.to_s]
|
|
132
|
+
next [key, restore(value, key_path, secrets)] unless value == REDACTION_PLACEHOLDER
|
|
133
|
+
|
|
134
|
+
secret = secrets[secret_key_for(key_path)]
|
|
135
|
+
secret ||= secrets[key.to_s] if path.one?
|
|
136
|
+
|
|
137
|
+
[key, secret.presence || value]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
private_class_method :restore
|
|
141
|
+
|
|
142
|
+
def self.sensitive?(key, value)
|
|
143
|
+
SENSITIVE_PAYLOAD_KEYS.include?(key.to_s) && scalar?(value)
|
|
144
|
+
end
|
|
145
|
+
private_class_method :sensitive?
|
|
146
|
+
|
|
147
|
+
# The `code` of a gift card: at the root of a gift card event's data, or
|
|
148
|
+
# on a `gift_card` node nested in another record (an order or cart that
|
|
149
|
+
# has one applied).
|
|
150
|
+
def self.gift_card_code?(key, value, path, gift_card_event)
|
|
151
|
+
return false unless key.to_s == GIFT_CARD_CODE_KEY && scalar?(value)
|
|
152
|
+
return true if gift_card_event && path == [ROOT_SEGMENT]
|
|
153
|
+
|
|
154
|
+
path.last == GIFT_CARD_NODE
|
|
155
|
+
end
|
|
156
|
+
private_class_method :gift_card_code?
|
|
157
|
+
|
|
158
|
+
def self.scalar?(value)
|
|
159
|
+
value.present? && !value.is_a?(Hash) && !value.is_a?(Array)
|
|
160
|
+
end
|
|
161
|
+
private_class_method :scalar?
|
|
162
|
+
|
|
163
|
+
def self.event_name_of(payload)
|
|
164
|
+
return '' unless payload.is_a?(Hash)
|
|
165
|
+
|
|
166
|
+
(payload[:name] || payload['name']).to_s
|
|
167
|
+
end
|
|
168
|
+
private_class_method :event_name_of
|
|
62
169
|
|
|
63
170
|
# Applies +block+ to every `data` hash on the payload.
|
|
64
171
|
#
|
|
@@ -77,11 +184,15 @@ module Spree
|
|
|
77
184
|
end
|
|
78
185
|
private_class_method :transform_data_hashes
|
|
79
186
|
|
|
80
|
-
# Secrets are keyed by
|
|
187
|
+
# Secrets are keyed by path alone. They cross an ActiveJob serialization
|
|
81
188
|
# boundary, which coerces symbol keys to strings, so the key form at split
|
|
82
189
|
# time cannot be relied on to still match at merge time.
|
|
83
|
-
|
|
84
|
-
|
|
190
|
+
#
|
|
191
|
+
# Each segment escapes the separator (and the escape character) before the
|
|
192
|
+
# join, so `['a.b', 'c']` and `['a', 'b', 'c']` stay distinct keys.
|
|
193
|
+
def self.secret_key_for(path)
|
|
194
|
+
path.map { |segment| segment.gsub(PATH_ESCAPES_PATTERN) { |character| PATH_ESCAPES[character] } }.
|
|
195
|
+
join(PATH_SEPARATOR)
|
|
85
196
|
end
|
|
86
197
|
private_class_method :secret_key_for
|
|
87
198
|
end
|
|
@@ -113,7 +113,7 @@ module Spree
|
|
|
113
113
|
def line_item_candidates(line_item)
|
|
114
114
|
@line_item_candidates ||= {}
|
|
115
115
|
@line_item_candidates[line_item.id] ||= discount_actions(:line_item).filter_map do |action|
|
|
116
|
-
next unless action.
|
|
116
|
+
next unless action.applies_to_line_item?(order, line_item)
|
|
117
117
|
|
|
118
118
|
amount = action.compute_amount(line_item)
|
|
119
119
|
next if amount.zero?
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Authentication
|
|
3
|
+
# The login lockout contract, for every place that checks a secret the
|
|
4
|
+
# account owner knows. A check that skips it becomes an unthrottled way to
|
|
5
|
+
# guess the password: a locked account is refused, a miss counts towards
|
|
6
|
+
# the lockout and a hit clears the count.
|
|
7
|
+
#
|
|
8
|
+
# Duck-typed, so a custom user class that implements none of the lockout
|
|
9
|
+
# hooks is checked without them.
|
|
10
|
+
module Lockout
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# @param user [Object] the account whose secret is being checked
|
|
14
|
+
# @yieldreturn [Boolean] whether the secret matched
|
|
15
|
+
# @return [Symbol] +:locked+, +:valid+ or +:invalid+
|
|
16
|
+
def check(user)
|
|
17
|
+
return :locked if user.respond_to?(:locked?) && user.locked?
|
|
18
|
+
|
|
19
|
+
if yield
|
|
20
|
+
# Require the full contract before touching the counter — a custom
|
|
21
|
+
# user class may implement one hook but not the other.
|
|
22
|
+
if user.respond_to?(:failed_attempts) && user.respond_to?(:reset_failed_attempts!) &&
|
|
23
|
+
user.failed_attempts.to_i.positive?
|
|
24
|
+
user.reset_failed_attempts!
|
|
25
|
+
end
|
|
26
|
+
:valid
|
|
27
|
+
else
|
|
28
|
+
user.record_failed_attempt! if user.respond_to?(:record_failed_attempt!)
|
|
29
|
+
:invalid
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -11,19 +11,11 @@ module Spree
|
|
|
11
11
|
|
|
12
12
|
user = find_user_by_email(email)
|
|
13
13
|
return failure('Invalid email or password') unless user
|
|
14
|
-
return failure('Account temporarily locked. Try again later.') if user.respond_to?(:locked?) && user.locked?
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
user.failed_attempts.to_i.positive?
|
|
21
|
-
user.reset_failed_attempts!
|
|
22
|
-
end
|
|
23
|
-
success(user)
|
|
24
|
-
else
|
|
25
|
-
user.record_failed_attempt! if user.respond_to?(:record_failed_attempt!)
|
|
26
|
-
failure('Invalid email or password')
|
|
15
|
+
case Spree::Authentication::Lockout.check(user) { validate_password(user, password) }
|
|
16
|
+
when :locked then failure('Account temporarily locked. Try again later.')
|
|
17
|
+
when :valid then success(user)
|
|
18
|
+
else failure('Invalid email or password')
|
|
27
19
|
end
|
|
28
20
|
rescue => e
|
|
29
21
|
Rails.logger.error "EmailPasswordStrategy authentication failed: #{e.message}"
|
|
@@ -223,7 +223,7 @@ module Spree
|
|
|
223
223
|
end
|
|
224
224
|
|
|
225
225
|
def identity_info(claims)
|
|
226
|
-
claims.slice('email', 'name', '
|
|
226
|
+
claims.slice('email', 'name', 'given_name', 'family_name', 'picture')
|
|
227
227
|
end
|
|
228
228
|
|
|
229
229
|
# The key this provider is registered under — used as UserIdentity#provider
|
data/app/models/spree/claim.rb
CHANGED
|
@@ -33,6 +33,7 @@ module Spree
|
|
|
33
33
|
has_many :claim_line_items, class_name: 'Spree::ClaimLineItem',
|
|
34
34
|
dependent: :destroy, inverse_of: :claim
|
|
35
35
|
has_many :refunds, class_name: 'Spree::Refund', as: :originator, dependent: :nullify
|
|
36
|
+
has_many :store_credits, class_name: 'Spree::StoreCredit', as: :originator, dependent: :nullify
|
|
36
37
|
|
|
37
38
|
validates :claim_line_items, presence: true, on: :create
|
|
38
39
|
validates :resolution, inclusion: { in: RESOLUTIONS }, allow_nil: true
|
|
@@ -61,8 +61,10 @@ module Spree
|
|
|
61
61
|
scope :for_line_items, -> { where.not(line_item_id: nil) }
|
|
62
62
|
scope :for_fulfillments, -> { where.not(fulfillment_id: nil) }
|
|
63
63
|
|
|
64
|
-
self.whitelisted_ransackable_attributes = %w[amount tax_amount total currency kind rate]
|
|
65
|
-
|
|
64
|
+
self.whitelisted_ransackable_attributes = %w[amount tax_amount total currency kind rate order_id]
|
|
65
|
+
# No `order`: filtering through it reaches the buyer's email and address,
|
|
66
|
+
# which a commissions-only caller may not read. `order_id` covers the lookup.
|
|
67
|
+
self.whitelisted_ransackable_associations = %w[seller commission_rate]
|
|
66
68
|
|
|
67
69
|
extend Spree::DisplayMoney
|
|
68
70
|
money_methods :amount, :tax_amount, :total
|
|
@@ -16,7 +16,7 @@ module Spree
|
|
|
16
16
|
class CommissionRule < Spree.base_class
|
|
17
17
|
include Spree::PreferenceSchema
|
|
18
18
|
|
|
19
|
-
has_prefix_id :
|
|
19
|
+
has_prefix_id :comrule
|
|
20
20
|
|
|
21
21
|
# Retired rather than deleted, with its rate or on its own as an operator
|
|
22
22
|
# edits a rate's conditions. A commission line is a settlement record, and
|
|
@@ -10,6 +10,7 @@ module Spree
|
|
|
10
10
|
|
|
11
11
|
scope :used_with_code, ->(code) { used.where(code: code.downcase) }
|
|
12
12
|
scope :with_order, ->(order_id) { where(order_id: order_id) }
|
|
13
|
+
scope :held_by, ->(owner) { owner.is_a?(Spree::Cart) ? where(cart_id: owner.id) : where(order_id: owner.id) }
|
|
13
14
|
scope :in_promotions, ->(promotion_ids) { where(promotion_id: promotion_ids) }
|
|
14
15
|
scope :not_in_promotions, ->(promotion_ids) { where.not(promotion_id: promotion_ids) }
|
|
15
16
|
|
|
@@ -38,9 +39,20 @@ module Spree
|
|
|
38
39
|
used_with_code(code).any?
|
|
39
40
|
end
|
|
40
41
|
|
|
42
|
+
# Attaches the code to the cart or order presenting it. The code is not
|
|
43
|
+
# spent here: it stays unused until an order is placed with it, so a code
|
|
44
|
+
# left on an abandoned cart can still be presented by someone else.
|
|
45
|
+
#
|
|
46
|
+
# @param owner [Spree::Cart, Spree::Order]
|
|
47
|
+
# @return [Boolean]
|
|
41
48
|
def apply_order!(owner)
|
|
42
|
-
|
|
43
|
-
update(
|
|
49
|
+
cart = owner.is_a?(Spree::Cart) ? owner : nil
|
|
50
|
+
update(cart: cart, order: cart ? nil : owner)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Spree::Cart, Spree::Order, nil] the record currently holding this code
|
|
54
|
+
def holder
|
|
55
|
+
cart || order
|
|
44
56
|
end
|
|
45
57
|
|
|
46
58
|
def remove_from_order
|
data/app/models/spree/current.rb
CHANGED
|
@@ -5,6 +5,10 @@ module Spree
|
|
|
5
5
|
# Fallback chains ensure sensible defaults when attributes are not explicitly set.
|
|
6
6
|
class Current < ::ActiveSupport::CurrentAttributes
|
|
7
7
|
attribute :store, :channel, :market, :currency, :locale, :content_locale, :tax_country, :price_lists, :applicable_catalogs, :applicable_catalog_groups, :quantity_rules_resolvers, :standing_companies, :global_pricing_context, :provider_cache, :integrations
|
|
8
|
+
# Latch for the cross-store leak tripwire — see #store= below. Lives here
|
|
9
|
+
# so it clears exactly when the store context does, which Rails does per
|
|
10
|
+
# request and per job.
|
|
11
|
+
attribute :store_scope_guard_armed
|
|
8
12
|
|
|
9
13
|
# Scratch space for provider strategies to memoize a call across the
|
|
10
14
|
# request — part of the delivery rate provider contract (nothing in core
|
|
@@ -17,6 +21,17 @@ module Spree
|
|
|
17
21
|
super || (self.provider_cache = {})
|
|
18
22
|
end
|
|
19
23
|
|
|
24
|
+
# Declaring a store context arms the cross-store leak tripwire for the
|
|
25
|
+
# rest of this unit of work — a request, a job, a webhook — so the guard
|
|
26
|
+
# follows the context rather than a list of entry points
|
|
27
|
+
# (docs/plans/6.0-store-context-and-first-run-setup.md). Work that never
|
|
28
|
+
# declares a store is left alone: it reads the default store through the
|
|
29
|
+
# fallback below, which is not a scoping claim to check.
|
|
30
|
+
def store=(value)
|
|
31
|
+
Spree::StoreScopeGuard.arm! if value
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
|
|
20
35
|
# Returns the current store, falling back to the default store.
|
|
21
36
|
# @return [Spree::Store]
|
|
22
37
|
def store
|
|
@@ -32,6 +32,7 @@ module Spree
|
|
|
32
32
|
has_many :exchange_line_items, class_name: 'Spree::ExchangeLineItem',
|
|
33
33
|
dependent: :destroy, inverse_of: :exchange
|
|
34
34
|
has_many :refunds, class_name: 'Spree::Refund', as: :originator, dependent: :nullify
|
|
35
|
+
has_many :store_credits, class_name: 'Spree::StoreCredit', as: :originator, dependent: :nullify
|
|
35
36
|
|
|
36
37
|
validates :exchange_line_items, presence: true, on: :create
|
|
37
38
|
|
data/app/models/spree/export.rb
CHANGED
|
@@ -192,12 +192,13 @@ module Spree
|
|
|
192
192
|
raise SellerScopeUnavailable, model_class unless model_class.respond_to?(:for_seller)
|
|
193
193
|
|
|
194
194
|
scope = scope.for_seller(seller)
|
|
195
|
-
# A draft is either a checkout in flight or the operator's working
|
|
196
|
-
# document, and is not this seller's sale — the same exclusion every
|
|
197
|
-
# endpoint on the seller branch applies.
|
|
198
|
-
scope = scope.not_drafts if model_class.respond_to?(:not_drafts)
|
|
199
195
|
end
|
|
200
196
|
|
|
197
|
+
# A draft is either a checkout in flight or the operator's working
|
|
198
|
+
# document, not a sale — the orders list and every seller endpoint leave
|
|
199
|
+
# drafts out too.
|
|
200
|
+
scope = scope.not_drafts if model_class.respond_to?(:not_drafts)
|
|
201
|
+
|
|
201
202
|
# A staff-created export only contains what its creator may read; a
|
|
202
203
|
# userless export (console, system jobs) is unfiltered.
|
|
203
204
|
scope = scope.accessible_by(current_ability) if user.present?
|
|
@@ -219,7 +220,9 @@ module Spree
|
|
|
219
220
|
[scope, params]
|
|
220
221
|
end
|
|
221
222
|
|
|
222
|
-
|
|
223
|
+
# A seller's export filters as the seller's own listings do, so a
|
|
224
|
+
# condition on data the seller cannot read (the buyer's email) is ignored.
|
|
225
|
+
filtered_scope.ransack(params, auth_object: seller_id.present? ? :seller : nil).result
|
|
223
226
|
end
|
|
224
227
|
|
|
225
228
|
# `search_params` as a Hash — whether it is still the Hash a caller
|