spree_core 6.0.0.beta1 → 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 +18 -0
- 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/acted_by.rb +210 -0
- data/app/models/concerns/spree/actor.rb +51 -0
- data/app/models/concerns/spree/admin_user_methods.rb +48 -17
- 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/payment_processing.rb +1 -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/api_key.rb +5 -3
- 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/catalog.rb +50 -13
- data/app/models/spree/claim.rb +3 -1
- 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 +56 -13
- data/app/models/spree/exchange.rb +3 -1
- 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/line_item.rb +1 -1
- 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 +89 -18
- 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/pricing_provider/internal.rb +37 -15
- 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/refund.rb +2 -1
- data/app/models/spree/return.rb +18 -7
- 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/stock_receipt.rb +7 -2
- data/app/models/spree/store.rb +4 -0
- 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/approve.rb +3 -2
- 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/cancel.rb +3 -2
- data/app/workflows/spree/orders/complete.rb +99 -2
- data/app/workflows/spree/price_lists/update.rb +17 -0
- data/app/workflows/spree/refunds/create.rb +1 -1
- data/app/workflows/spree/refunds/order_payments.rb +55 -1
- data/app/workflows/spree/returns/approve.rb +1 -1
- data/app/workflows/spree/returns/create.rb +11 -17
- data/app/workflows/spree/returns/refund.rb +16 -25
- data/app/workflows/spree/returns/returnable_quantity.rb +53 -0
- data/app/workflows/spree/sellers/invite.rb +1 -1
- data/config/locales/en.yml +8 -119
- data/db/migrate/20260916000001_add_email_delivery_flags_to_spree_order_groups.rb +14 -0
- data/db/migrate/20260916000001_order_operation_actors_polymorphic.rb +30 -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 +121 -6
- data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +8 -2
- data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +3 -0
- 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/api_resource/templates/store_controller_spec.rb.tt +4 -1
- data/lib/generators/spree/dummy/templates/rails/test.rb +7 -0
- data/lib/generators/spree/model/model_generator.rb +40 -1
- data/lib/generators/spree/model/templates/create_table_migration.rb.tt +9 -0
- data/lib/generators/spree/model/templates/model.rb.tt +10 -4
- data/lib/generators/spree/subscriber/subscriber_generator.rb +3 -3
- data/lib/spree/core/configuration.rb +25 -25
- data/lib/spree/core/engine.rb +35 -8
- data/lib/spree/core/preferences/runtime_configuration.rb +76 -2
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +27 -0
- 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/testing_support/factories/user_factory.rb +0 -1
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
- data/lib/tasks/backfill_actor_types.rake +35 -0
- data/lib/tasks/sample_data.rake +8 -2
- data/lib/tasks/store_settings.rake +1 -0
- data/lib/tasks/upgrade.rake +6 -21
- metadata +23 -9
- /data/{app/models → lib}/spree/checkout/default_requirements.rb +0 -0
- /data/{app/models → lib}/spree/checkout/registry.rb +0 -0
- /data/{app/models → lib}/spree/checkout/requirement.rb +0 -0
- /data/{app/models → lib}/spree/checkout/requirements.rb +0 -0
- /data/{app/models → lib}/spree/checkout/step.rb +0 -0
|
@@ -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?
|
data/app/models/spree/api_key.rb
CHANGED
|
@@ -2,6 +2,7 @@ module Spree
|
|
|
2
2
|
class ApiKey < Spree.base_class
|
|
3
3
|
has_prefix_id :key # Spree-specific: api key
|
|
4
4
|
|
|
5
|
+
include Spree::Actor
|
|
5
6
|
include Spree::SingleStoreResource
|
|
6
7
|
|
|
7
8
|
KEY_TYPES = %w[publishable secret].freeze
|
|
@@ -124,10 +125,11 @@ module Spree
|
|
|
124
125
|
|
|
125
126
|
# Revokes this API key by setting +revoked_at+ to the current time.
|
|
126
127
|
#
|
|
127
|
-
# @param
|
|
128
|
+
# @param actor [Object, nil] who performed the revocation — an admin user
|
|
129
|
+
# or another API key (see Spree.actor_classes)
|
|
128
130
|
# @return [Boolean] true if the update succeeded
|
|
129
|
-
def revoke!(
|
|
130
|
-
update!(revoked_at: Time.current, revoked_by:
|
|
131
|
+
def revoke!(actor = nil)
|
|
132
|
+
update!(revoked_at: Time.current, revoked_by: actor)
|
|
131
133
|
end
|
|
132
134
|
|
|
133
135
|
# Whether this key carries the given scope. `write_*` implies the matching
|
|
@@ -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/catalog.rb
CHANGED
|
@@ -14,8 +14,9 @@ module Spree
|
|
|
14
14
|
# catalog to those companies or divisions rather than to a group.
|
|
15
15
|
#
|
|
16
16
|
# Within whichever audience answered, visibility is the union of its
|
|
17
|
-
# catalogs' assortments
|
|
18
|
-
#
|
|
17
|
+
# catalogs' assortments; pricing walks the assigning nodes nearest first and
|
|
18
|
+
# within one node charges the best price any of its catalogs gives
|
|
19
|
+
# (docs/plans/6.0-b2b-companies-and-catalogs.md).
|
|
19
20
|
class Catalog < Spree.base_class
|
|
20
21
|
has_prefix_id :cat
|
|
21
22
|
|
|
@@ -97,6 +98,17 @@ module Spree
|
|
|
97
98
|
# @param company [Spree::Company, nil]
|
|
98
99
|
# @return [Array<Spree::Catalog>]
|
|
99
100
|
def self.for_company(company)
|
|
101
|
+
groups_for_company(company).flatten
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The same catalogs, grouped by the node that assigns them, nearest first.
|
|
105
|
+
# Only pricing needs the boundaries: catalogs sharing a node rank equally
|
|
106
|
+
# and the best price among them wins, while a nearer node outranks a
|
|
107
|
+
# further one. Everything else takes the flat {.for_company}.
|
|
108
|
+
#
|
|
109
|
+
# @param company [Spree::Company, nil]
|
|
110
|
+
# @return [Array<Array<Spree::Catalog>>]
|
|
111
|
+
def self.groups_for_company(company)
|
|
100
112
|
return [] if company.nil?
|
|
101
113
|
|
|
102
114
|
nodes = company.self_and_ancestors
|
|
@@ -110,9 +122,15 @@ module Spree
|
|
|
110
122
|
includes(:catalog).
|
|
111
123
|
group_by(&:assignable_id)
|
|
112
124
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
125
|
+
# A catalog assigned to a node and to an ancestor answers from the nearer.
|
|
126
|
+
seen = Set.new
|
|
127
|
+
|
|
128
|
+
nodes.filter_map do |node|
|
|
129
|
+
group = Array(assignments[node.id]).map(&:catalog).
|
|
130
|
+
select { |catalog| catalog.active? && seen.add?(catalog.id) }.
|
|
131
|
+
sort_by { |catalog| catalog.position.to_i }
|
|
132
|
+
group.presence
|
|
133
|
+
end
|
|
116
134
|
end
|
|
117
135
|
|
|
118
136
|
# The catalogs assigned to any of the given customer groups, by position.
|
|
@@ -151,18 +169,30 @@ module Spree
|
|
|
151
169
|
# @param channel [Spree::Channel, nil]
|
|
152
170
|
# @return [Array<Spree::Catalog>]
|
|
153
171
|
def self.for_context(store:, company: nil, user: nil, channel: nil)
|
|
172
|
+
groups_for_context(store: store, company: company, user: user, channel: channel).flatten
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The same resolution, grouped by precedence rank ({.groups_for_company}).
|
|
176
|
+
# Only the company axis has more than one rank.
|
|
177
|
+
#
|
|
178
|
+
# @return [Array<Array<Spree::Catalog>>]
|
|
179
|
+
def self.groups_for_context(store:, company: nil, user: nil, channel: nil)
|
|
154
180
|
return [] if store.nil?
|
|
155
181
|
|
|
156
|
-
|
|
182
|
+
groups = store.catalogs.groups_for_company(company)
|
|
183
|
+
|
|
157
184
|
# Only when the company axis found nothing: a buyer purchasing for a
|
|
158
185
|
# company is on that company's agreement, and adding their personal
|
|
159
186
|
# segment's catalogs on top would price one buyer under two agreements
|
|
160
187
|
# at once.
|
|
161
|
-
if
|
|
162
|
-
|
|
163
|
-
|
|
188
|
+
if groups.empty? && user
|
|
189
|
+
customer_groups = user.try(:customer_groups)&.where(store_id: store.id) || []
|
|
190
|
+
groups = [store.catalogs.for_customer_groups(customer_groups).presence].compact
|
|
164
191
|
end
|
|
165
|
-
|
|
192
|
+
|
|
193
|
+
groups = [[channel&.default_catalog].compact.select(&:active?).presence].compact if groups.empty?
|
|
194
|
+
|
|
195
|
+
catalogs = groups.flatten
|
|
166
196
|
# Pricing reads each catalog's price list, and the list's contextual
|
|
167
197
|
# rules decide whether it applies to the quantity being bought; without
|
|
168
198
|
# this the walk is one query per catalog again, plus one per list.
|
|
@@ -171,7 +201,7 @@ module Spree
|
|
|
171
201
|
records: catalogs, associations: { price_list: [:price_rules, :price_adjustment_tiers] }
|
|
172
202
|
).call
|
|
173
203
|
end
|
|
174
|
-
|
|
204
|
+
groups
|
|
175
205
|
end
|
|
176
206
|
|
|
177
207
|
# The catalogs applying to a buyer, resolving the company for them when
|
|
@@ -190,15 +220,22 @@ module Spree
|
|
|
190
220
|
# @param channel [Spree::Channel, nil]
|
|
191
221
|
# @return [Array<Spree::Catalog>]
|
|
192
222
|
def self.for_buyer(store:, customer: nil, company: nil, channel: nil)
|
|
223
|
+
groups_for_buyer(store: store, customer: customer, company: company, channel: channel).flatten
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# {.for_buyer}, grouped by precedence rank. What pricing walks.
|
|
227
|
+
#
|
|
228
|
+
# @return [Array<Array<Spree::Catalog>>]
|
|
229
|
+
def self.groups_for_buyer(store:, customer: nil, company: nil, channel: nil)
|
|
193
230
|
return [] if store.nil?
|
|
194
231
|
|
|
195
232
|
if store == Spree::Current.store
|
|
196
233
|
company ||= Spree::Current.standing_company_for(customer)
|
|
197
234
|
channel ||= Spree::Current.channel
|
|
198
|
-
Spree::Current.
|
|
235
|
+
Spree::Current.catalog_groups_for(company: company, user: customer, channel: channel)
|
|
199
236
|
else
|
|
200
237
|
company ||= Spree::Company.sole_standing_for(store: store, customer: customer)
|
|
201
|
-
|
|
238
|
+
groups_for_context(store: store, company: company, user: customer, channel: channel)
|
|
202
239
|
end
|
|
203
240
|
end
|
|
204
241
|
|
data/app/models/spree/claim.rb
CHANGED
|
@@ -10,6 +10,7 @@ module Spree
|
|
|
10
10
|
has_prefix_id :claim
|
|
11
11
|
|
|
12
12
|
has_spree_number prefix: 'CLM'
|
|
13
|
+
include Spree::ActedBy
|
|
13
14
|
include Spree::NumberIdentifier
|
|
14
15
|
include Spree::SingleStoreResource
|
|
15
16
|
include Spree::HasStatus
|
|
@@ -27,11 +28,12 @@ module Spree
|
|
|
27
28
|
belongs_to :store, class_name: 'Spree::Store'
|
|
28
29
|
belongs_to :order, class_name: 'Spree::Order', inverse_of: :claims
|
|
29
30
|
belongs_to :reason, class_name: 'Spree::ClaimReason', optional: true, inverse_of: :claims
|
|
30
|
-
|
|
31
|
+
acted_by :created_by
|
|
31
32
|
|
|
32
33
|
has_many :claim_line_items, class_name: 'Spree::ClaimLineItem',
|
|
33
34
|
dependent: :destroy, inverse_of: :claim
|
|
34
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
|
|
35
37
|
|
|
36
38
|
validates :claim_line_items, presence: true, on: :create
|
|
37
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
|
@@ -4,7 +4,11 @@ module Spree
|
|
|
4
4
|
# All attributes are automatically reset between requests by Rails.
|
|
5
5
|
# Fallback chains ensure sensible defaults when attributes are not explicitly set.
|
|
6
6
|
class Current < ::ActiveSupport::CurrentAttributes
|
|
7
|
-
attribute :store, :channel, :market, :currency, :locale, :content_locale, :tax_country, :price_lists, :applicable_catalogs, :quantity_rules_resolvers, :standing_companies, :global_pricing_context, :provider_cache, :integrations
|
|
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
|
|
@@ -103,16 +118,22 @@ module Spree
|
|
|
103
118
|
# @param channel [Spree::Channel, nil]
|
|
104
119
|
# @return [Array<Spree::Catalog>]
|
|
105
120
|
def catalogs_for(company: nil, user: nil, channel: nil)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
key = catalog_memo_key(company: company, user: user, channel: channel)
|
|
122
|
+
applicable_catalogs[key] ||= catalog_groups_for(company: company, user: user, channel: channel).flatten
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The same catalogs grouped by precedence rank, which is what pricing
|
|
126
|
+
# walks ({Spree::Catalog.groups_for_company}). The resolved form — the
|
|
127
|
+
# flat set above is a cached flattening of it, never a second resolution.
|
|
128
|
+
#
|
|
129
|
+
# @param company [Spree::Company, nil]
|
|
130
|
+
# @param user [Object, nil]
|
|
131
|
+
# @param channel [Spree::Channel, nil]
|
|
132
|
+
# @return [Array<Array<Spree::Catalog>>]
|
|
133
|
+
def catalog_groups_for(company: nil, user: nil, channel: nil)
|
|
134
|
+
key = catalog_memo_key(company: company, user: user, channel: channel)
|
|
135
|
+
applicable_catalog_groups[key] ||= Spree::Catalog.groups_for_context(
|
|
136
|
+
store: store, company: company, user: normalized_catalog_user(user), channel: channel || self.channel
|
|
116
137
|
)
|
|
117
138
|
end
|
|
118
139
|
|
|
@@ -121,6 +142,11 @@ module Spree
|
|
|
121
142
|
super || (self.applicable_catalogs = {})
|
|
122
143
|
end
|
|
123
144
|
|
|
145
|
+
# @return [Hash]
|
|
146
|
+
def applicable_catalog_groups
|
|
147
|
+
super || (self.applicable_catalog_groups = {})
|
|
148
|
+
end
|
|
149
|
+
|
|
124
150
|
# The quantity-rule resolver for a buyer in this request, keyed the same
|
|
125
151
|
# way their catalogs are.
|
|
126
152
|
#
|
|
@@ -133,8 +159,7 @@ module Spree
|
|
|
133
159
|
# @param channel [Spree::Channel, nil]
|
|
134
160
|
# @return [Spree::Catalogs::ResolveQuantityRules]
|
|
135
161
|
def quantity_rules_resolver_for(company: nil, user: nil, channel: nil)
|
|
136
|
-
|
|
137
|
-
key = [store&.id, company&.id, user&.id, channel&.id]
|
|
162
|
+
key = catalog_memo_key(company: company, user: user, channel: channel)
|
|
138
163
|
quantity_rules_resolvers[key] ||= Spree::Catalogs::ResolveQuantityRules.new(
|
|
139
164
|
catalogs_for(company: company, user: user, channel: channel)
|
|
140
165
|
)
|
|
@@ -176,6 +201,7 @@ module Spree
|
|
|
176
201
|
# @return [void]
|
|
177
202
|
def reset_catalog_memos
|
|
178
203
|
self.applicable_catalogs = nil
|
|
204
|
+
self.applicable_catalog_groups = nil
|
|
179
205
|
self.quantity_rules_resolvers = nil
|
|
180
206
|
self.standing_companies = nil
|
|
181
207
|
self.price_lists = nil
|
|
@@ -194,5 +220,22 @@ module Spree
|
|
|
194
220
|
)
|
|
195
221
|
end
|
|
196
222
|
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
# One key for every buyer-derived memo, so they cannot drift apart.
|
|
227
|
+
#
|
|
228
|
+
# @return [Array]
|
|
229
|
+
def catalog_memo_key(company: nil, user: nil, channel: nil)
|
|
230
|
+
[store&.id, company&.id, normalized_catalog_user(user)&.id, (channel || self.channel)&.id]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Normalized before the key is built, not just before the query: the key
|
|
234
|
+
# is an id, so an admin and a customer sharing one would share an entry.
|
|
235
|
+
#
|
|
236
|
+
# @return [Object, nil]
|
|
237
|
+
def normalized_catalog_user(user)
|
|
238
|
+
user if user.is_a?(Spree.customer_class)
|
|
239
|
+
end
|
|
197
240
|
end
|
|
198
241
|
end
|
|
@@ -11,6 +11,7 @@ module Spree
|
|
|
11
11
|
has_prefix_id :exch
|
|
12
12
|
|
|
13
13
|
has_spree_number prefix: 'EX'
|
|
14
|
+
include Spree::ActedBy
|
|
14
15
|
include Spree::NumberIdentifier
|
|
15
16
|
include Spree::SingleStoreResource
|
|
16
17
|
include Spree::HasStatus
|
|
@@ -26,11 +27,12 @@ module Spree
|
|
|
26
27
|
belongs_to :order, class_name: 'Spree::Order', inverse_of: :exchanges
|
|
27
28
|
belongs_to :stock_location, class_name: 'Spree::StockLocation'
|
|
28
29
|
belongs_to :reason, class_name: 'Spree::ReturnReason', optional: true, inverse_of: :exchanges
|
|
29
|
-
|
|
30
|
+
acted_by :created_by
|
|
30
31
|
|
|
31
32
|
has_many :exchange_line_items, class_name: 'Spree::ExchangeLineItem',
|
|
32
33
|
dependent: :destroy, inverse_of: :exchange
|
|
33
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
|
|
34
36
|
|
|
35
37
|
validates :exchange_line_items, presence: true, on: :create
|
|
36
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
|