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.
Files changed (132) 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 +7 -2
  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/customer_methods.rb +17 -1
  7. data/app/models/concerns/spree/has_number.rb +3 -2
  8. data/app/models/concerns/spree/prefixed_id.rb +4 -1
  9. data/app/models/concerns/spree/purchase/totals.rb +3 -1
  10. data/app/models/concerns/spree/ransackable_attributes.rb +38 -6
  11. data/app/models/concerns/spree/translatable_resource.rb +6 -7
  12. data/app/models/concerns/spree/webhook_payload_redaction.rb +132 -21
  13. data/app/models/spree/adjusters/promotion.rb +1 -1
  14. data/app/models/spree/authentication/lockout.rb +34 -0
  15. data/app/models/spree/authentication/strategies/email_password_strategy.rb +4 -12
  16. data/app/models/spree/authentication/strategies/oidc_strategy.rb +1 -1
  17. data/app/models/spree/claim.rb +1 -0
  18. data/app/models/spree/commission_line.rb +4 -2
  19. data/app/models/spree/commission_rule.rb +1 -1
  20. data/app/models/spree/coupon_code.rb +14 -2
  21. data/app/models/spree/current.rb +15 -0
  22. data/app/models/spree/exchange.rb +1 -0
  23. data/app/models/spree/export.rb +8 -5
  24. data/app/models/spree/exports/customers.rb +2 -2
  25. data/app/models/spree/fulfillment_group.rb +127 -0
  26. data/app/models/spree/gift_card.rb +9 -0
  27. data/app/models/spree/invitation.rb +18 -5
  28. data/app/models/spree/number_generators/sequential.rb +2 -2
  29. data/app/models/spree/option_value.rb +7 -0
  30. data/app/models/spree/order.rb +35 -12
  31. data/app/models/spree/order_group.rb +78 -6
  32. data/app/models/spree/payment_source.rb +1 -1
  33. data/app/models/spree/payment_split.rb +24 -0
  34. data/app/models/spree/price.rb +74 -0
  35. data/app/models/spree/product.rb +4 -0
  36. data/app/models/spree/product_option_type.rb +11 -0
  37. data/app/models/spree/promotion/actions/create_line_items.rb +92 -30
  38. data/app/models/spree/promotion/rules/category.rb +14 -9
  39. data/app/models/spree/promotion/rules/product.rb +17 -1
  40. data/app/models/spree/promotion.rb +1 -1
  41. data/app/models/spree/promotion_action.rb +12 -1
  42. data/app/models/spree/promotion_handler/coupon.rb +50 -5
  43. data/app/models/spree/promotion_rule.rb +14 -0
  44. data/app/models/spree/return.rb +16 -6
  45. data/app/models/spree/return_line_item.rb +19 -0
  46. data/app/models/spree/search_provider/database.rb +10 -2
  47. data/app/models/spree/seller_payout.rb +3 -1
  48. data/app/models/spree/seller_transfer.rb +3 -1
  49. data/app/models/spree/store_credit.rb +4 -0
  50. data/app/models/spree/translations/batch.rb +4 -2
  51. data/app/models/spree/user_identity.rb +10 -6
  52. data/app/models/spree/variant.rb +8 -3
  53. data/app/models/spree/webhook_delivery.rb +28 -0
  54. data/app/models/spree/webhook_endpoint.rb +14 -0
  55. data/app/presenters/spree/csv/order_line_item_presenter.rb +2 -2
  56. data/app/services/spree/carts/split_by_seller.rb +22 -12
  57. data/app/services/spree/companies/add_member.rb +8 -2
  58. data/app/services/spree/imports/row_processors/price_list_price.rb +6 -0
  59. data/app/services/spree/imports/row_processors/product_translation.rb +1 -1
  60. data/app/services/spree/imports/row_processors/product_variant.rb +17 -11
  61. data/app/services/spree/order_groups/allocate_payments.rb +3 -0
  62. data/app/services/spree/orders/build_fulfillments.rb +6 -0
  63. data/app/services/spree/orders/update_statuses.rb +8 -2
  64. data/app/services/spree/prices/bulk_upsert.rb +186 -47
  65. data/app/services/spree/sample_data/import_builder.rb +1 -1
  66. data/app/services/spree/sample_data/loader.rb +54 -40
  67. data/app/services/spree/seeds/all.rb +1 -26
  68. data/app/services/spree/seeds/allowed_origins.rb +5 -4
  69. data/app/services/spree/seeds/api_keys.rb +10 -9
  70. data/app/services/spree/seeds/channels.rb +9 -8
  71. data/app/services/spree/seeds/commission_rates.rb +21 -20
  72. data/app/services/spree/seeds/customer_groups.rb +5 -4
  73. data/app/services/spree/seeds/digital_delivery.rb +7 -8
  74. data/app/services/spree/seeds/payment_methods.rb +12 -11
  75. data/app/services/spree/seeds/product_types.rb +7 -8
  76. data/app/services/spree/seeds/returns_environment.rb +12 -11
  77. data/app/services/spree/seeds/roles.rb +8 -7
  78. data/app/services/spree/seeds/saved_reports.rb +19 -20
  79. data/app/services/spree/seeds/seller_requirements.rb +5 -4
  80. data/app/services/spree/seeds/store_resources.rb +41 -0
  81. data/app/services/spree/seeds/store_scoped.rb +15 -0
  82. data/app/services/spree/seeds/tax_categories.rb +10 -9
  83. data/app/services/spree/stock_locations/stock_items/create.rb +1 -1
  84. data/app/services/spree/stock_locations/stock_levels/create.rb +19 -3
  85. data/app/subscribers/spree/order_status_subscriber.rb +15 -7
  86. data/app/workflows/spree/carts/add_item.rb +5 -7
  87. data/app/workflows/spree/carts/complete.rb +20 -64
  88. data/app/workflows/spree/carts/recalculate_totals.rb +3 -1
  89. data/app/workflows/spree/claims/create.rb +31 -1
  90. data/app/workflows/spree/claims/resolve.rb +7 -11
  91. data/app/workflows/spree/exchanges/create.rb +11 -0
  92. data/app/workflows/spree/exchanges/fulfill.rb +21 -13
  93. data/app/workflows/spree/orders/complete.rb +99 -2
  94. data/app/workflows/spree/price_lists/update.rb +17 -0
  95. data/app/workflows/spree/refunds/order_payments.rb +55 -1
  96. data/app/workflows/spree/returns/create.rb +9 -15
  97. data/app/workflows/spree/returns/refund.rb +15 -24
  98. data/app/workflows/spree/returns/returnable_quantity.rb +53 -0
  99. data/app/workflows/spree/sellers/invite.rb +1 -1
  100. data/config/locales/en.yml +7 -119
  101. data/db/migrate/20260916000001_add_email_delivery_flags_to_spree_order_groups.rb +14 -0
  102. data/db/migrate/20260922000001_add_refunded_order_to_spree_store_credits.rb +5 -0
  103. data/db/migrate/20260923000001_change_spree_user_identities_tokens_to_text.rb +10 -0
  104. data/db/sample_data/channels.rb +1 -1
  105. data/db/sample_data/collections.rb +1 -1
  106. data/db/sample_data/custom_field_definitions.rb +1 -1
  107. data/db/sample_data/digital_products.rb +1 -1
  108. data/db/sample_data/fulfillment.rb +1 -1
  109. data/db/sample_data/options.rb +4 -2
  110. data/db/sample_data/orders.rb +9 -10
  111. data/db/sample_data/payment_methods.rb +18 -18
  112. data/db/sample_data/product_type_categories.rb +1 -1
  113. data/db/sample_data/product_types.rb +2 -2
  114. data/db/sample_data/promotions.rb +2 -4
  115. data/db/sample_data/wholesale.rb +1 -1
  116. data/lib/generators/spree/api_resource/api_resource_generator.rb +58 -1
  117. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +6 -5
  118. data/lib/generators/spree/api_resource/templates/permissions.en.yml.tt +6 -0
  119. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +2 -2
  120. data/lib/generators/spree/dummy/templates/rails/test.rb +7 -0
  121. data/lib/generators/spree/subscriber/subscriber_generator.rb +3 -3
  122. data/lib/spree/core/engine.rb +8 -8
  123. data/lib/spree/core/version.rb +1 -1
  124. data/lib/spree/iso_data.rb +23 -4
  125. data/lib/spree/store_scope_guard.rb +16 -1
  126. data/lib/spree/testing_support/factories/invitation_factory.rb +1 -0
  127. data/lib/spree/testing_support/factories/order_group_factory.rb +49 -0
  128. data/lib/spree/testing_support/factories/payment_factory.rb +7 -2
  129. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
  130. data/lib/tasks/sample_data.rake +8 -2
  131. data/lib/tasks/upgrade.rake +6 -21
  132. metadata +13 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c2e491640b87a7c66d80915167c1b3e0e342ebc527f98ab6be473494850ed8c
4
- data.tar.gz: 9507987225c9b98ea0676417742b9c22e338796d9d155f309f3d0f5262617f4d
3
+ metadata.gz: fb19660bc9b7017b74df8b6b92fc2522cb84ef62a6a015f817484528b7e8c992
4
+ data.tar.gz: bac9e0d41a47af2e5452406f1c0d1ac268b687ebdf1ade714742aa689e5cae0a
5
5
  SHA512:
6
- metadata.gz: 25b1c9003b48955d08e1d26f0f64d630ae4eafb07043cd49bfb70b22736255b0315f85b6e6f22ab873fc2e24b64cb0a3a7c715ac77b522d58afe1190939f4b78
7
- data.tar.gz: b7379fef995f651f02229b55260b8685a8e4318e7748be5f2a64aa0476a40577d877f1e32897a3d6542f47beee847e18a431bb027cc1237c189f2bde8e0c0493
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
- def perform
9
- Spree::SampleData::Loader.call
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 store URL.
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.formatted_url, token)
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
- has_many :carts, -> { incomplete }, foreign_key: :customer_id, class_name: 'Spree::Order'
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. Unscoped because the index is global.
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
- Spree::PrefixedId.decode_prefixed_id(prefixed_id_string)
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(*_args)
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(*_args)
40
+ def self.ransackable_attributes(auth_object = nil)
21
41
  base = default_ransackable_attributes | (whitelisted_ransackable_attributes || [])
22
- base | Spree.ransack.custom_attributes_for(self)
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(*_args)
26
- base = whitelisted_ransackable_scopes || []
27
- base | Spree.ransack.custom_scopes_for(self)
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, so it is
52
- # right for a store column (Product), a polymorphic owner (Policy) and
53
- # genuinely global reference data alike (OptionType, which has no store
54
- # dimension and returns everything by design). Its one blind spot is
55
- # +Spree::Store+: with no `stores` association to follow it falls back to
56
- # the UNSCOPED class, which would answer with every store in 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. 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?
@@ -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
@@ -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
- 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
@@ -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
 
@@ -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