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.
Files changed (164) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/payments/handle_webhook_job.rb +1 -0
  3. data/app/jobs/spree/sample_data/load_job.rb +18 -0
  4. data/app/jobs/spree/seller_transfers/execute_pending_job.rb +1 -0
  5. data/app/mailers/spree/admin_user_mailer.rb +2 -2
  6. data/app/models/concerns/spree/acted_by.rb +210 -0
  7. data/app/models/concerns/spree/actor.rb +51 -0
  8. data/app/models/concerns/spree/admin_user_methods.rb +48 -17
  9. data/app/models/concerns/spree/customer_methods.rb +17 -1
  10. data/app/models/concerns/spree/has_number.rb +3 -2
  11. data/app/models/concerns/spree/prefixed_id.rb +4 -1
  12. data/app/models/concerns/spree/purchase/payment_processing.rb +1 -1
  13. data/app/models/concerns/spree/purchase/totals.rb +3 -1
  14. data/app/models/concerns/spree/ransackable_attributes.rb +38 -6
  15. data/app/models/concerns/spree/translatable_resource.rb +6 -7
  16. data/app/models/concerns/spree/webhook_payload_redaction.rb +132 -21
  17. data/app/models/spree/adjusters/promotion.rb +1 -1
  18. data/app/models/spree/api_key.rb +5 -3
  19. data/app/models/spree/authentication/lockout.rb +34 -0
  20. data/app/models/spree/authentication/strategies/email_password_strategy.rb +4 -12
  21. data/app/models/spree/authentication/strategies/oidc_strategy.rb +1 -1
  22. data/app/models/spree/catalog.rb +50 -13
  23. data/app/models/spree/claim.rb +3 -1
  24. data/app/models/spree/commission_line.rb +4 -2
  25. data/app/models/spree/commission_rule.rb +1 -1
  26. data/app/models/spree/coupon_code.rb +14 -2
  27. data/app/models/spree/current.rb +56 -13
  28. data/app/models/spree/exchange.rb +3 -1
  29. data/app/models/spree/export.rb +8 -5
  30. data/app/models/spree/exports/customers.rb +2 -2
  31. data/app/models/spree/fulfillment_group.rb +127 -0
  32. data/app/models/spree/gift_card.rb +9 -0
  33. data/app/models/spree/invitation.rb +18 -5
  34. data/app/models/spree/line_item.rb +1 -1
  35. data/app/models/spree/number_generators/sequential.rb +2 -2
  36. data/app/models/spree/option_value.rb +7 -0
  37. data/app/models/spree/order.rb +89 -18
  38. data/app/models/spree/order_group.rb +78 -6
  39. data/app/models/spree/payment_source.rb +1 -1
  40. data/app/models/spree/payment_split.rb +24 -0
  41. data/app/models/spree/price.rb +74 -0
  42. data/app/models/spree/pricing_provider/internal.rb +37 -15
  43. data/app/models/spree/product.rb +4 -0
  44. data/app/models/spree/product_option_type.rb +11 -0
  45. data/app/models/spree/promotion/actions/create_line_items.rb +92 -30
  46. data/app/models/spree/promotion/rules/category.rb +14 -9
  47. data/app/models/spree/promotion/rules/product.rb +17 -1
  48. data/app/models/spree/promotion.rb +1 -1
  49. data/app/models/spree/promotion_action.rb +12 -1
  50. data/app/models/spree/promotion_handler/coupon.rb +50 -5
  51. data/app/models/spree/promotion_rule.rb +14 -0
  52. data/app/models/spree/refund.rb +2 -1
  53. data/app/models/spree/return.rb +18 -7
  54. data/app/models/spree/return_line_item.rb +19 -0
  55. data/app/models/spree/search_provider/database.rb +10 -2
  56. data/app/models/spree/seller_payout.rb +3 -1
  57. data/app/models/spree/seller_transfer.rb +3 -1
  58. data/app/models/spree/stock_receipt.rb +7 -2
  59. data/app/models/spree/store.rb +4 -0
  60. data/app/models/spree/store_credit.rb +4 -0
  61. data/app/models/spree/translations/batch.rb +4 -2
  62. data/app/models/spree/user_identity.rb +10 -6
  63. data/app/models/spree/variant.rb +8 -3
  64. data/app/models/spree/webhook_delivery.rb +28 -0
  65. data/app/models/spree/webhook_endpoint.rb +14 -0
  66. data/app/presenters/spree/csv/order_line_item_presenter.rb +2 -2
  67. data/app/services/spree/carts/split_by_seller.rb +22 -12
  68. data/app/services/spree/companies/add_member.rb +8 -2
  69. data/app/services/spree/imports/row_processors/price_list_price.rb +6 -0
  70. data/app/services/spree/imports/row_processors/product_translation.rb +1 -1
  71. data/app/services/spree/imports/row_processors/product_variant.rb +17 -11
  72. data/app/services/spree/order_groups/allocate_payments.rb +3 -0
  73. data/app/services/spree/orders/approve.rb +3 -2
  74. data/app/services/spree/orders/build_fulfillments.rb +6 -0
  75. data/app/services/spree/orders/update_statuses.rb +8 -2
  76. data/app/services/spree/prices/bulk_upsert.rb +186 -47
  77. data/app/services/spree/sample_data/import_builder.rb +1 -1
  78. data/app/services/spree/sample_data/loader.rb +54 -40
  79. data/app/services/spree/seeds/all.rb +1 -26
  80. data/app/services/spree/seeds/allowed_origins.rb +5 -4
  81. data/app/services/spree/seeds/api_keys.rb +10 -9
  82. data/app/services/spree/seeds/channels.rb +9 -8
  83. data/app/services/spree/seeds/commission_rates.rb +21 -20
  84. data/app/services/spree/seeds/customer_groups.rb +5 -4
  85. data/app/services/spree/seeds/digital_delivery.rb +7 -8
  86. data/app/services/spree/seeds/payment_methods.rb +12 -11
  87. data/app/services/spree/seeds/product_types.rb +7 -8
  88. data/app/services/spree/seeds/returns_environment.rb +12 -11
  89. data/app/services/spree/seeds/roles.rb +8 -7
  90. data/app/services/spree/seeds/saved_reports.rb +19 -20
  91. data/app/services/spree/seeds/seller_requirements.rb +5 -4
  92. data/app/services/spree/seeds/store_resources.rb +41 -0
  93. data/app/services/spree/seeds/store_scoped.rb +15 -0
  94. data/app/services/spree/seeds/tax_categories.rb +10 -9
  95. data/app/services/spree/stock_locations/stock_items/create.rb +1 -1
  96. data/app/services/spree/stock_locations/stock_levels/create.rb +19 -3
  97. data/app/subscribers/spree/order_status_subscriber.rb +15 -7
  98. data/app/workflows/spree/carts/add_item.rb +5 -7
  99. data/app/workflows/spree/carts/complete.rb +20 -64
  100. data/app/workflows/spree/carts/recalculate_totals.rb +3 -1
  101. data/app/workflows/spree/claims/create.rb +31 -1
  102. data/app/workflows/spree/claims/resolve.rb +7 -11
  103. data/app/workflows/spree/exchanges/create.rb +11 -0
  104. data/app/workflows/spree/exchanges/fulfill.rb +21 -13
  105. data/app/workflows/spree/orders/cancel.rb +3 -2
  106. data/app/workflows/spree/orders/complete.rb +99 -2
  107. data/app/workflows/spree/price_lists/update.rb +17 -0
  108. data/app/workflows/spree/refunds/create.rb +1 -1
  109. data/app/workflows/spree/refunds/order_payments.rb +55 -1
  110. data/app/workflows/spree/returns/approve.rb +1 -1
  111. data/app/workflows/spree/returns/create.rb +11 -17
  112. data/app/workflows/spree/returns/refund.rb +16 -25
  113. data/app/workflows/spree/returns/returnable_quantity.rb +53 -0
  114. data/app/workflows/spree/sellers/invite.rb +1 -1
  115. data/config/locales/en.yml +8 -119
  116. data/db/migrate/20260916000001_add_email_delivery_flags_to_spree_order_groups.rb +14 -0
  117. data/db/migrate/20260916000001_order_operation_actors_polymorphic.rb +30 -0
  118. data/db/migrate/20260922000001_add_refunded_order_to_spree_store_credits.rb +5 -0
  119. data/db/migrate/20260923000001_change_spree_user_identities_tokens_to_text.rb +10 -0
  120. data/db/sample_data/channels.rb +1 -1
  121. data/db/sample_data/collections.rb +1 -1
  122. data/db/sample_data/custom_field_definitions.rb +1 -1
  123. data/db/sample_data/digital_products.rb +1 -1
  124. data/db/sample_data/fulfillment.rb +1 -1
  125. data/db/sample_data/options.rb +4 -2
  126. data/db/sample_data/orders.rb +9 -10
  127. data/db/sample_data/payment_methods.rb +18 -18
  128. data/db/sample_data/product_type_categories.rb +1 -1
  129. data/db/sample_data/product_types.rb +2 -2
  130. data/db/sample_data/promotions.rb +2 -4
  131. data/db/sample_data/wholesale.rb +1 -1
  132. data/lib/generators/spree/api_resource/api_resource_generator.rb +121 -6
  133. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +8 -2
  134. data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +3 -0
  135. data/lib/generators/spree/api_resource/templates/permissions.en.yml.tt +6 -0
  136. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +2 -2
  137. data/lib/generators/spree/api_resource/templates/store_controller_spec.rb.tt +4 -1
  138. data/lib/generators/spree/dummy/templates/rails/test.rb +7 -0
  139. data/lib/generators/spree/model/model_generator.rb +40 -1
  140. data/lib/generators/spree/model/templates/create_table_migration.rb.tt +9 -0
  141. data/lib/generators/spree/model/templates/model.rb.tt +10 -4
  142. data/lib/generators/spree/subscriber/subscriber_generator.rb +3 -3
  143. data/lib/spree/core/configuration.rb +25 -25
  144. data/lib/spree/core/engine.rb +35 -8
  145. data/lib/spree/core/preferences/runtime_configuration.rb +76 -2
  146. data/lib/spree/core/version.rb +1 -1
  147. data/lib/spree/core.rb +27 -0
  148. data/lib/spree/iso_data.rb +23 -4
  149. data/lib/spree/store_scope_guard.rb +16 -1
  150. data/lib/spree/testing_support/factories/invitation_factory.rb +1 -0
  151. data/lib/spree/testing_support/factories/order_group_factory.rb +49 -0
  152. data/lib/spree/testing_support/factories/payment_factory.rb +7 -2
  153. data/lib/spree/testing_support/factories/user_factory.rb +0 -1
  154. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
  155. data/lib/tasks/backfill_actor_types.rake +35 -0
  156. data/lib/tasks/sample_data.rake +8 -2
  157. data/lib/tasks/store_settings.rake +1 -0
  158. data/lib/tasks/upgrade.rake +6 -21
  159. metadata +23 -9
  160. /data/{app/models → lib}/spree/checkout/default_requirements.rb +0 -0
  161. /data/{app/models → lib}/spree/checkout/registry.rb +0 -0
  162. /data/{app/models → lib}/spree/checkout/requirement.rb +0 -0
  163. /data/{app/models → lib}/spree/checkout/requirements.rb +0 -0
  164. /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. Delivering it over TLS to a merchant-configured endpoint is
9
- # intended; keeping a readable copy in `spree_webhook_deliveries.payload` is
10
- # not, because that column is queryable and is served back through the Admin
11
- # API delivery log.
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 under `data` whose values are live credentials.
20
- SENSITIVE_PAYLOAD_KEYS = %w[reset_token unsubscribe_token verification_token download_url].freeze
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 as they appeared under `data`
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.to_h do |key, value|
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.to_h do |key, value|
57
- secret = secrets[secret_key_for(key)]
58
- [key, secret.presence || value]
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 name alone. They cross an ActiveJob serialization
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
- def self.secret_key_for(key)
84
- key.to_s
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.promotion.line_item_actionable?(order, line_item)
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?
@@ -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 user [Object, nil] the user who performed the revocation
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!(user = nil)
130
- update!(revoked_at: Time.current, revoked_by: user)
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
- if validate_password(user, password)
17
- # Require the full lockout contract before touching the counter — a
18
- # custom customer_class may implement one hook but not the other.
19
- if user.respond_to?(:failed_attempts) && user.respond_to?(:reset_failed_attempts!) &&
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', 'gisel_name', 'family_name', 'picture')
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
@@ -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 and pricing takes the price list of the nearest
18
- # assignment first (docs/plans/6.0-b2b-companies-and-catalogs.md).
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
- nodes.flat_map do |node|
114
- Array(assignments[node.id]).map(&:catalog).select(&:active?).sort_by { |catalog| catalog.position.to_i }
115
- end.uniq
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
- catalogs = store.catalogs.for_company(company)
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 catalogs.empty? && user
162
- groups = user.try(:customer_groups)&.where(store_id: store.id) || []
163
- catalogs = store.catalogs.for_customer_groups(groups)
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
- catalogs = [channel&.default_catalog].compact.select(&:active?) if catalogs.empty?
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
- catalogs
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.catalogs_for(company: company, user: customer, channel: channel)
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
- for_context(store: store, company: company, user: customer, channel: channel)
238
+ groups_for_context(store: store, company: company, user: customer, channel: channel)
202
239
  end
203
240
  end
204
241
 
@@ -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
- belongs_to :created_by, class_name: Spree.admin_user_class.to_s, optional: true
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
- self.whitelisted_ransackable_associations = %w[order seller commission_rate]
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 :crule
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
- owner_key = owner.is_a?(Spree::Cart) ? :cart : :order
43
- update(owner_key => owner, state: 'used')
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
@@ -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
- channel ||= self.channel
107
- # Normalized before the key is built, not just before the query: the
108
- # key is an id, so an admin and a customer sharing one would otherwise
109
- # share an entry and whichever resolved first would decide for both.
110
- # Only a customer has customer-group catalogs to resolve.
111
- user = nil unless user.is_a?(Spree.customer_class)
112
-
113
- key = [store&.id, company&.id, user&.id, channel&.id]
114
- applicable_catalogs[key] ||= Spree::Catalog.for_context(
115
- store: store, company: company, user: user, channel: channel
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
- channel ||= self.channel
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
- belongs_to :created_by, class_name: Spree.admin_user_class.to_s, optional: true
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
 
@@ -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
- filtered_scope.ransack(params).result
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