spree_core 6.0.0.rc4 → 6.0.0.rc6

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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/images/save_from_url_job.rb +24 -5
  3. data/app/models/concerns/spree/account_deactivation.rb +54 -0
  4. data/app/models/concerns/spree/calculator/currency_amounts.rb +19 -6
  5. data/app/models/concerns/spree/derived_number.rb +1 -1
  6. data/app/models/concerns/spree/has_external_references.rb +24 -16
  7. data/app/models/concerns/spree/prefixed_id.rb +15 -5
  8. data/app/models/concerns/spree/publishable.rb +47 -1
  9. data/app/models/concerns/spree/purchase/addresses.rb +114 -36
  10. data/app/models/concerns/spree/purchase/digital_items.rb +17 -3
  11. data/app/models/concerns/spree/purchase/freight.rb +2 -1
  12. data/app/models/concerns/spree/purchase/payment_processing.rb +7 -1
  13. data/app/models/concerns/spree/purchase/quantity_rules.rb +2 -0
  14. data/app/models/concerns/spree/stores/channels.rb +3 -1
  15. data/app/models/concerns/spree/translatable_resource.rb +6 -1
  16. data/app/models/concerns/spree/webhook_payload_redaction.rb +1 -0
  17. data/app/models/spree/ability.rb +8 -0
  18. data/app/models/spree/address.rb +6 -0
  19. data/app/models/spree/admin_user.rb +1 -0
  20. data/app/models/spree/base.rb +33 -1
  21. data/app/models/spree/calculator/shipping/digital_delivery.rb +3 -1
  22. data/app/models/spree/calculator/shipping/flat_rate.rb +3 -1
  23. data/app/models/spree/calculator/shipping/per_item.rb +3 -1
  24. data/app/models/spree/cart.rb +7 -7
  25. data/app/models/spree/category.rb +76 -5
  26. data/app/models/spree/channel.rb +79 -40
  27. data/app/models/spree/company.rb +46 -3
  28. data/app/models/spree/customer.rb +1 -1
  29. data/app/models/spree/customer_group.rb +59 -41
  30. data/app/models/spree/data_request.rb +34 -1
  31. data/app/models/spree/delivery_method.rb +18 -8
  32. data/app/models/spree/delivery_profile.rb +18 -0
  33. data/app/models/spree/digital_asset.rb +14 -0
  34. data/app/models/spree/event.rb +20 -5
  35. data/app/models/spree/fulfillment.rb +8 -1
  36. data/app/models/spree/fulfillment_changer.rb +3 -1
  37. data/app/models/spree/fulfillment_item.rb +11 -0
  38. data/app/models/spree/import.rb +20 -2
  39. data/app/models/spree/order.rb +1 -1
  40. data/app/models/spree/order_routing/strategy/rules.rb +4 -1
  41. data/app/models/spree/price_list.rb +15 -0
  42. data/app/models/spree/product/channels.rb +16 -8
  43. data/app/models/spree/product.rb +7 -2
  44. data/app/models/spree/return.rb +8 -1
  45. data/app/models/spree/sales_context.rb +162 -0
  46. data/app/models/spree/search_provider/base.rb +8 -3
  47. data/app/models/spree/search_provider/database.rb +2 -10
  48. data/app/models/spree/seller.rb +35 -5
  49. data/app/models/spree/seller_balance.rb +28 -0
  50. data/app/models/spree/seller_requirements/delivery_method.rb +20 -2
  51. data/app/models/spree/stock/coordinator.rb +7 -4
  52. data/app/models/spree/stock/estimator.rb +6 -0
  53. data/app/models/spree/stock/inventory_unit_builder.rb +14 -2
  54. data/app/models/spree/stock/packer.rb +39 -5
  55. data/app/models/spree/stock/quantifier.rb +16 -0
  56. data/app/models/spree/stock_location.rb +15 -8
  57. data/app/models/spree/store.rb +31 -2
  58. data/app/models/spree/store_credit.rb +4 -4
  59. data/app/models/spree/tax_line.rb +5 -4
  60. data/app/models/spree/variant.rb +23 -6
  61. data/app/models/spree/webhook_endpoint.rb +45 -11
  62. data/app/services/spree/carts/destroy.rb +7 -2
  63. data/app/services/spree/carts/price_items.rb +3 -0
  64. data/app/services/spree/carts/split_by_seller.rb +3 -1
  65. data/app/services/spree/carts/update.rb +1 -4
  66. data/app/services/spree/catalogs/set_product_terms.rb +8 -3
  67. data/app/services/spree/collections/add_products.rb +1 -1
  68. data/app/services/spree/collections/regenerate_products.rb +1 -3
  69. data/app/services/spree/collections/remove_products.rb +1 -1
  70. data/app/services/spree/data_requests/confirm.rb +37 -0
  71. data/app/services/spree/data_requests/create.rb +61 -13
  72. data/app/services/spree/newsletter/subscribe.rb +9 -1
  73. data/app/services/spree/order_groups/allocate_payments.rb +7 -5
  74. data/app/services/spree/orders/update.rb +19 -0
  75. data/app/services/spree/product_memberships.rb +16 -6
  76. data/app/services/spree/products/duplicator.rb +12 -4
  77. data/app/services/spree/search_provider/custom_field_schema.rb +10 -2
  78. data/app/services/spree/stock_levels/correct.rb +11 -4
  79. data/app/services/spree/stock_reservations/reserve.rb +1 -1
  80. data/app/services/spree/stores/provision_defaults.rb +11 -7
  81. data/app/services/spree/tags/bulk_add.rb +16 -6
  82. data/app/workflows/spree/carts/complete.rb +25 -9
  83. data/app/workflows/spree/carts/upsert_items.rb +31 -4
  84. data/app/workflows/spree/customers/anonymize.rb +40 -19
  85. data/app/workflows/spree/data_requests/fulfill.rb +20 -6
  86. data/app/workflows/spree/digital_assets/create.rb +56 -0
  87. data/app/workflows/spree/digital_assets/update.rb +44 -0
  88. data/app/workflows/spree/digital_assets/writing.rb +108 -0
  89. data/app/workflows/spree/imports/start_mapping.rb +3 -1
  90. data/app/workflows/spree/orders/complete.rb +12 -5
  91. data/app/workflows/spree/orders/create.rb +41 -17
  92. data/app/workflows/spree/payments/capture.rb +12 -0
  93. data/app/workflows/spree/products/nested_attributes.rb +12 -28
  94. data/app/workflows/spree/products/propose.rb +9 -4
  95. data/app/workflows/spree/stock_levels/bulk_upsert.rb +28 -13
  96. data/app/workflows/spree/variants/nested_attributes.rb +35 -10
  97. data/config/locales/en.yml +14 -0
  98. data/db/migrate/20261022000001_store_credit_actor_polymorphic.rb +13 -0
  99. data/db/migrate/20261023100001_add_deactivated_at_to_spree_admin_users.rb +7 -0
  100. data/lib/generators/spree/dummy/templates/rails/test.rb +87 -0
  101. data/lib/spree/checkout/default_requirements.rb +12 -2
  102. data/lib/spree/core/dependencies.rb +5 -0
  103. data/lib/spree/core/engine.rb +2 -0
  104. data/lib/spree/core/permissions/default_catalog.rb +7 -2
  105. data/lib/spree/core/version.rb +1 -1
  106. data/lib/spree/reporting/default_vocabulary.rb +1 -1
  107. data/lib/spree/testing_support/factories/data_request_factory.rb +4 -0
  108. data/lib/spree/testing_support/factories/product_factory.rb +10 -4
  109. data/lib/spree/testing_support/factories/store_factory.rb +4 -1
  110. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +10 -0
  111. data/lib/tasks/backfill_tagging_tenants.rake +35 -0
  112. data/lib/tasks/seller_sample_data.rake +114 -112
  113. metadata +15 -6
@@ -20,6 +20,10 @@ module Spree
20
20
  validates :url, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]), message: :invalid_url }
21
21
  validates :active, inclusion: { in: [true, false] }
22
22
  validate :url_must_not_resolve_to_private_ip, if: -> { !Rails.env.development? && url.present? && url_changed? }
23
+ validate :subscriptions_must_be_event_patterns, if: :will_save_change_to_subscriptions?
24
+
25
+ attribute :subscriptions, default: -> { [] }
26
+ normalizes :subscriptions, apply_to_nil: true, with: ->(value) { Array(value).map { |entry| entry.to_s.strip }.reject(&:empty?).uniq }
23
27
 
24
28
  before_create :generate_secret_key
25
29
  after_create { @reveal_secret_in_response = true }
@@ -83,6 +87,23 @@ module Spree
83
87
  # Number of consecutive failed deliveries before auto-disabling
84
88
  AUTO_DISABLE_THRESHOLD = 15
85
89
 
90
+ MAX_SUBSCRIPTIONS = 200
91
+ MAX_SUBSCRIPTION_LENGTH = 100
92
+ SUBSCRIPTION_SEGMENT = /\A[a-zA-Z0-9_-]+\z/
93
+
94
+ # Whether +value+ is a shape `subscriptions` accepts: `*`, an event name,
95
+ # or a name with `*` in place of whole segments (`order.*`, `*.created`).
96
+ # Anything else — regexp syntax above all — is refused before it is
97
+ # stored, so the matcher only ever sees names and segment wildcards.
98
+ #
99
+ # @param value [Object]
100
+ # @return [Boolean]
101
+ def self.event_pattern?(value)
102
+ return false unless value.is_a?(String) && value.length.between?(1, MAX_SUBSCRIPTION_LENGTH)
103
+
104
+ value.split('.', -1).all? { |segment| segment == '*' || segment.match?(SUBSCRIPTION_SEGMENT) }
105
+ end
106
+
86
107
  # Check if this endpoint is subscribed to a specific event.
87
108
  #
88
109
  # Events whose payload carries a live credential (a password reset token)
@@ -93,16 +114,9 @@ module Spree
93
114
  # @return [Boolean]
94
115
  def subscribed_to?(event_name)
95
116
  return subscriptions.to_a.include?(event_name) if Spree::Events.catalog.credential?(event_name)
96
- return true if subscriptions.blank? || subscriptions.include?('*')
97
-
98
- subscriptions.any? do |subscription|
99
- if subscription.include?('*')
100
- pattern = Regexp.escape(subscription).gsub('\*', '.*')
101
- event_name.match?(Regexp.new("^#{pattern}$"))
102
- else
103
- subscription == event_name
104
- end
105
- end
117
+ return true if subscriptions.blank?
118
+
119
+ subscriptions.any? { |subscription| Spree::Event.matches?(event_name, subscription) }
106
120
  end
107
121
 
108
122
  # The credential-carrying events this endpoint names.
@@ -191,6 +205,16 @@ module Spree
191
205
  self.secret_key ||= SecureRandom.hex(32)
192
206
  end
193
207
 
208
+ def subscriptions_must_be_event_patterns
209
+ entries = subscriptions.to_a
210
+ errors.add(:subscriptions, :too_many, count: MAX_SUBSCRIPTIONS) if entries.size > MAX_SUBSCRIPTIONS
211
+
212
+ rejected = entries.reject { |entry| self.class.event_pattern?(entry) }
213
+ return if rejected.empty?
214
+
215
+ errors.add(:subscriptions, :invalid_pattern, entries: rejected.first(5).map { |entry| entry.truncate(40) }.join(', '))
216
+ end
217
+
194
218
  def clear_disabled_state_when_reactivated
195
219
  return unless will_save_change_to_active? && active
196
220
 
@@ -203,12 +227,22 @@ module Spree
203
227
 
204
228
  uri = URI.parse(url)
205
229
  blacklist = SsrfFilter::IPV4_BLACKLIST + SsrfFilter::IPV6_BLACKLIST
206
- addresses = Resolv.getaddresses(uri.host)
230
+ addresses = host_addresses(uri.host)
207
231
  if addresses.any? { |addr| blacklist.any? { |range| range.include?(IPAddr.new(addr)) } }
208
232
  errors.add(:url, :internal_address_not_allowed)
209
233
  end
210
234
  rescue URI::InvalidURIError, Resolv::ResolvError, IPAddr::InvalidAddressError, ArgumentError
211
235
  # URI format validation handles invalid URLs; DNS failures are not SSRF
212
236
  end
237
+
238
+ # A literal address first — including the forms a resolver answers
239
+ # nothing for, such as a bracketed IPv6 address, `127.1`, or a decimal or
240
+ # hex IPv4 — then DNS for a hostname.
241
+ def host_addresses(host)
242
+ literal = host.to_s.delete_prefix('[').delete_suffix(']')
243
+ Socket.getaddrinfo(literal, nil, nil, Socket::SOCK_STREAM, nil, Socket::AI_NUMERICHOST).map { |entry| entry[3] }.uniq
244
+ rescue SocketError
245
+ Resolv.getaddresses(host)
246
+ end
213
247
  end
214
248
  end
@@ -52,9 +52,14 @@ module Spree
52
52
  success(cart: cart)
53
53
  end
54
54
 
55
+ # Lets go of everything the cart was only borrowing, so the cascade that
56
+ # follows reaches nothing but the cart's own rows. An entry in somebody's
57
+ # address book stays in it, and a row a fulfillment or a completed order
58
+ # still points at is detached rather than soft-deleted out from under
59
+ # them.
55
60
  def clear_addresses(cart:)
56
- cart.ship_address = nil unless cart.ship_address&.can_be_deleted?
57
- cart.bill_address = nil unless cart.bill_address&.can_be_deleted?
61
+ cart.ship_address = nil unless cart.owns_address?(cart.ship_address) && cart.ship_address.can_be_deleted?
62
+ cart.bill_address = nil unless cart.owns_address?(cart.bill_address) && cart.bill_address.can_be_deleted?
58
63
 
59
64
  success(cart: cart)
60
65
  end
@@ -31,6 +31,9 @@ module Spree
31
31
  items = items.reject(&:manual_price?)
32
32
  return success([]) if items.empty?
33
33
 
34
+ # One read of every line's prices rather than one per variant.
35
+ ActiveRecord::Associations::Preloader.new(records: items.filter_map(&:variant).uniq, associations: :prices).call
36
+
34
37
  resolutions = items.filter_map do |line_item|
35
38
  price = resolve_price(line_item, cart)
36
39
  next if price.blank?
@@ -270,8 +270,10 @@ module Spree
270
270
  # describe what they held before the split, and the new one holds nothing
271
271
  # at all.
272
272
  def restate_freight_summaries(*fulfillments)
273
+ selected_rates = Spree::DeliveryRate.where(fulfillment_id: fulfillments.map(&:id), selected: true).order(:cost).to_a
274
+
273
275
  fulfillments.each do |fulfillment|
274
- rate = fulfillment.reload.selected_delivery_rate
276
+ rate = selected_rates.find { |selected_rate| selected_rate.fulfillment_id == fulfillment.id }
275
277
  next if rate.nil?
276
278
 
277
279
  metadata = rate.metadata
@@ -206,10 +206,7 @@ module Spree
206
206
  end
207
207
 
208
208
  def resolve_address_id(prefixed_id)
209
- return unless cart.customer
210
-
211
- decoded = Spree::Address.decode_prefixed_id(prefixed_id)
212
- decoded ? cart.customer.addresses.find_by(id: decoded)&.id : nil
209
+ cart.selectable_address(prefixed_id)&.id
213
210
  end
214
211
 
215
212
  def assign_market
@@ -50,7 +50,8 @@ module Spree
50
50
  def apply(catalog, product, values)
51
51
  minimum = normalize(values[:minimum_order_quantity])
52
52
  multiple = normalize(values[:order_multiple])
53
- variant_ids = product.variants.ids
53
+ variants = product.variants.to_a
54
+ variant_ids = variants.map(&:id)
54
55
 
55
56
  # Both blank is a deletion: the product falls back to the catalog's
56
57
  # own default, which is what an empty pair means on the row.
@@ -59,8 +60,12 @@ module Spree
59
60
  return nil
60
61
  end
61
62
 
62
- variant_ids.each do |variant_id|
63
- rule = catalog.quantity_rules.find_or_initialize_by(variant_id: variant_id)
63
+ existing_rules = catalog.quantity_rules.where(variant_id: variant_ids).index_by(&:variant_id)
64
+
65
+ variants.each do |variant|
66
+ rule = existing_rules[variant.id] || catalog.quantity_rules.build
67
+ # The loaded variant, so the store check reads its product in memory.
68
+ rule.variant = variant
64
69
  rule.assign_attributes(minimum_order_quantity: minimum, order_multiple: multiple)
65
70
  return rule unless rule.save
66
71
  end
@@ -22,7 +22,7 @@ module Spree
22
22
  def group_class = Spree::Collection
23
23
 
24
24
  def refresh_group_counters(collection_ids)
25
- collection_ids.each { |id| Spree::Collection.reset_counters(id, :product_collections) }
25
+ Spree::Collection.reset_counters_in_bulk(collection_ids, :product_collections)
26
26
  end
27
27
  end
28
28
  end
@@ -44,9 +44,7 @@ module Spree
44
44
 
45
45
  # counter caches — bulk insert/delete bypass the ProductCollection counter_cache callbacks
46
46
  Spree::Collection.reset_counters(collection.id, :product_collections)
47
- all_product_ids = (previous_products_ids + product_ids_to_insert).uniq
48
- existing_product_ids = Spree::Product.where(id: all_product_ids).pluck(:id)
49
- existing_product_ids.each { |id| Spree::Product.reset_counters(id, :product_collections) }
47
+ Spree::Product.reset_counters_in_bulk(previous_products_ids + product_ids_to_insert, :product_collections)
50
48
 
51
49
  success(collection)
52
50
  end
@@ -22,7 +22,7 @@ module Spree
22
22
  def group_class = Spree::Collection
23
23
 
24
24
  def refresh_group_counters(collection_ids)
25
- collection_ids.each { |id| Spree::Collection.reset_counters(id, :product_collections) }
25
+ Spree::Collection.reset_counters_in_bulk(collection_ids, :product_collections)
26
26
  end
27
27
  end
28
28
  end
@@ -0,0 +1,37 @@
1
+ module Spree
2
+ module DataRequests
3
+ # Confirms a request the customer raised, from the link emailed to the
4
+ # address it names, and queues the work.
5
+ #
6
+ # Confirming again is answered with the request as it stands, so a second
7
+ # click on the same link changes nothing.
8
+ class Confirm
9
+ prepend Spree::ServiceModule::Base
10
+
11
+ # @param data_request [Spree::DataRequest]
12
+ # @return [Spree::ServiceModule::Result] the confirmed request
13
+ def call(data_request:)
14
+ newly_confirmed = false
15
+
16
+ data_request.with_lock do
17
+ newly_confirmed = data_request.confirmable?
18
+
19
+ if newly_confirmed
20
+ data_request.update!(status: 'pending')
21
+ elsif data_request.awaiting_confirmation?
22
+ data_request.update!(status: 'lapsed')
23
+ end
24
+ end
25
+
26
+ if data_request.lapsed?
27
+ data_request.errors.add(:base, I18n.t('spree.data_request_errors.confirmation_invalid'))
28
+ return failure(data_request)
29
+ end
30
+
31
+ Spree::DataRequests::ProcessJob.perform_later(data_request.prefixed_id) if newly_confirmed
32
+
33
+ success(data_request)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -7,9 +7,15 @@ module Spree
7
7
  # whole history, and a customer holding the button down should not be able
8
8
  # to queue a hundred of them. It is also the honest answer — the request
9
9
  # they already made is still being worked on.
10
+ #
11
+ # A request the customer raised themselves waits for confirmation from its
12
+ # email address instead of being queued, because it reaches guest orders
13
+ # placed at that address. Asking again while it waits sends the email again.
10
14
  class Create
11
15
  prepend Spree::ServiceModule::Base
12
16
 
17
+ CONFIRMATION_EVENT = 'data_request.confirmation_requested'.freeze
18
+
13
19
  # @param store [Spree::Store]
14
20
  # @param customer [Spree::Customer]
15
21
  # @param kind [String] Spree::DataRequest::ACCESS or ERASURE
@@ -19,8 +25,12 @@ module Spree
19
25
  # caller fulfils the request itself — the admin export renders the file
20
26
  # inline for a waiting staffer, and queueing would additionally email
21
27
  # the customer a copy of something they did not ask for
28
+ # @param confirm_by_email [Boolean] hold the request until it is
29
+ # confirmed from its email address; true for the customer's own requests
30
+ # @param redirect_url [String, nil] the storefront page the confirmation
31
+ # link opens, already checked against the store's allowed origins
22
32
  # @return [Spree::ServiceModule::Result]
23
- def call(store:, customer:, kind:, requested_by: nil, process: true)
33
+ def call(store:, customer:, kind:, requested_by: nil, process: true, confirm_by_email: false, redirect_url: nil)
24
34
  # Nothing useful is left to answer with: an export would return the
25
35
  # tombstone and email a link to an address that no longer receives,
26
36
  # and a second erasure has nothing to erase.
@@ -38,7 +48,9 @@ module Spree
38
48
  # of the same person's whole history; taking the lock closes it without
39
49
  # a partial unique index, which MySQL does not support.
40
50
  customer.with_lock do
41
- existing = in_flight_request(store: store, customer: customer, kind: kind)
51
+ lapse_unconfirmed_requests(store: store, customer: customer, kind: kind, staff_request: !confirm_by_email)
52
+ existing = in_flight_request(store: store, customer: customer, kind: kind,
53
+ include_unconfirmed: confirm_by_email)
42
54
 
43
55
  unless existing
44
56
  data_request = Spree::DataRequest.new(
@@ -46,20 +58,27 @@ module Spree
46
58
  customer: customer,
47
59
  kind: kind.to_s,
48
60
  email: customer.email,
49
- requested_by: requested_by
61
+ requested_by: requested_by,
62
+ status: confirm_by_email ? 'awaiting_confirmation' : 'pending'
50
63
  )
51
64
  data_request.save
52
65
  end
53
66
  end
54
67
 
55
- return success(existing) if existing
56
- return failure(data_request) unless data_request.persisted?
68
+ return failure(data_request) if data_request && !data_request.persisted?
57
69
 
58
- # Enqueued after the lock is released: the job may start immediately,
59
- # and it should not contend for the row this flow still holds.
60
- Spree::DataRequests::ProcessJob.perform_later(data_request.prefixed_id) if process
70
+ request = existing || data_request
61
71
 
62
- success(data_request)
72
+ # Both happen after the lock is released: the job may start at once and
73
+ # should not contend for the row this flow holds, and the email must
74
+ # not go out before the row is committed.
75
+ if request.awaiting_confirmation?
76
+ request_confirmation(request, redirect_url: redirect_url)
77
+ elsif data_request && process
78
+ Spree::DataRequests::ProcessJob.perform_later(data_request.prefixed_id)
79
+ end
80
+
81
+ success(request)
63
82
  end
64
83
 
65
84
  private
@@ -67,10 +86,39 @@ module Spree
67
86
  # Only the person's own requests. A staff member exporting from the admin
68
87
  # opens their own row and answers it inline, and handing that back here
69
88
  # would close the customer's request with a file they can never download.
70
- def in_flight_request(store:, customer:, kind:)
71
- Spree::DataRequest.where(store_id: store.id, customer_id: customer.id, kind: kind.to_s,
72
- requested_by_id: nil).
73
- in_progress.recent_first.first
89
+ # A request still waiting for confirmation is returned only to the
90
+ # customer: staff have checked who they are dealing with, so theirs
91
+ # proceeds on its own row (see lapse_unconfirmed_requests).
92
+ def in_flight_request(store:, customer:, kind:, include_unconfirmed:)
93
+ requests = Spree::DataRequest.where(store_id: store.id, customer_id: customer.id, kind: kind.to_s,
94
+ requested_by_id: nil)
95
+ open_requests = include_unconfirmed ? requests.in_progress.or(requests.confirmable) : requests.in_progress
96
+
97
+ open_requests.recent_first.first
98
+ end
99
+
100
+ # Lapses what can no longer be confirmed: requests past the window, and
101
+ # requests naming an address the account has since moved away from, so
102
+ # asking again emails the current address. A staff request of the same
103
+ # kind replaces the customer's unconfirmed one, which would otherwise
104
+ # run the same work a second time once confirmed.
105
+ def lapse_unconfirmed_requests(store:, customer:, kind:, staff_request:)
106
+ unconfirmed = Spree::DataRequest.where(store_id: store.id, customer_id: customer.id).awaiting_confirmation
107
+ stale = unconfirmed.past_confirmation_window.or(unconfirmed.where.not(email: customer.email))
108
+ stale = stale.or(unconfirmed.where(kind: kind.to_s)) if staff_request
109
+
110
+ stale.find_each { |data_request| data_request.update!(status: 'lapsed') }
111
+ end
112
+
113
+ # The token travels in a credential event, which reaches only webhook
114
+ # endpoints that name it and keeps the token out of the delivery log.
115
+ def request_confirmation(data_request, redirect_url:)
116
+ payload = data_request.event_payload_for(
117
+ CONFIRMATION_EVENT,
118
+ confirmation_token: data_request.generate_token_for(:confirmation),
119
+ redirect_url: redirect_url.presence
120
+ )
121
+ data_request.publish_event(CONFIRMATION_EVENT, payload)
74
122
  end
75
123
  end
76
124
  end
@@ -54,8 +54,16 @@ module Spree
54
54
  @existed_subscription ||= Spree::NewsletterSubscriber.verified.find_by(email: email, store: current_store)
55
55
  end
56
56
 
57
+ # The account the subscription belongs to: the signed-in customer only
58
+ # when the address is theirs. Linking it to whoever is signed in would
59
+ # hand them another person's subscription — and with it the right to
60
+ # cancel it.
57
61
  def known_user
58
- @known_user ||= current_user || Spree.customer_class.find_by(email: email)
62
+ @known_user ||= if current_user&.email.to_s.casecmp?(email.to_s)
63
+ current_user
64
+ else
65
+ Spree.customer_class.find_by(email: email)
66
+ end
59
67
  end
60
68
  end
61
69
  end
@@ -82,14 +82,16 @@ module Spree
82
82
  )
83
83
  captured = payment.completed?
84
84
 
85
+ allocated_order_ids = Spree::PaymentSplit.where(payment_id: payment.id, order_id: orders.map(&:id)).pluck(:order_id).to_set
86
+
85
87
  orders.each_with_index do |order, index|
86
- split = Spree::PaymentSplit.find_or_initialize_by(payment_id: payment.id, order_id: order.id)
87
- next if split.persisted?
88
+ next if allocated_order_ids.include?(order.id)
88
89
 
89
90
  share = Spree::Money::Rounding.from_minor_units(shares[index], currency)
90
- # So re-summing its payment total on save reuses this instance rather
91
- # than loading a second copy of the row per share.
92
- split.order = order
91
+ # The payment and order are handed over rather than looked up, so
92
+ # validating and re-summing each share reuses these instances
93
+ # instead of loading a copy per share.
94
+ split = Spree::PaymentSplit.new(payment: payment, order: order)
93
95
  split.currency = payment.currency
94
96
  split.authorized_amount = share
95
97
  split.captured_amount = captured ? share : 0
@@ -14,6 +14,7 @@ module Spree
14
14
 
15
15
  items_param = @params.delete(:items)
16
16
  address_params = extract_address_params
17
+ scope_store_references
17
18
 
18
19
  # requires_new: the rescue below sits outside the block, so joining a
19
20
  # caller's open transaction (the API's order lock) would let it commit
@@ -63,6 +64,24 @@ module Spree
63
64
  end.compact_blank
64
65
  end
65
66
 
67
+ # Named by id in the payload and read through the order's own store, as
68
+ # Orders::Create does: the model only checks that the record exists, so
69
+ # another store's market, channel or warehouse would otherwise be
70
+ # accepted.
71
+ STORE_REFERENCES = {
72
+ market_id: :markets,
73
+ channel_id: :channels,
74
+ preferred_stock_location_id: :stock_locations
75
+ }.freeze
76
+
77
+ def scope_store_references
78
+ STORE_REFERENCES.each do |key, association|
79
+ next if @params[key].blank?
80
+
81
+ @params[key] = @order.store.public_send(association).find_by_param!(@params[key]).id
82
+ end
83
+ end
84
+
66
85
  def assign_addresses(address_params)
67
86
  address_params.each do |association, attributes|
68
87
  @order.public_send(:"#{association}_attributes=", attributes)
@@ -7,9 +7,15 @@ module Spree
7
7
  private
8
8
 
9
9
  def add_memberships(groups, products)
10
- rows = groups.pluck(:id).flat_map do |group_id|
11
- position = membership_class.where(group_key => group_id).count
12
- products.pluck(:id).map do |product_id|
10
+ group_ids = groups.pluck(:id)
11
+ product_ids = products.pluck(:id)
12
+ # Every group's current size in one grouped count, rather than a probe
13
+ # per group for the position to append at.
14
+ sizes = membership_class.where(group_key => group_ids).group(group_key).count
15
+
16
+ rows = group_ids.flat_map do |group_id|
17
+ position = sizes.fetch(group_id, 0)
18
+ product_ids.map do |product_id|
13
19
  {
14
20
  group_key => group_id, product_id: product_id, position: (position += 1),
15
21
  created_at: Time.current, updated_at: Time.current
@@ -18,7 +24,7 @@ module Spree
18
24
  end
19
25
  membership_class.insert_all(rows)
20
26
 
21
- refresh_after_membership_change(groups.pluck(:id), products)
27
+ refresh_after_membership_change(group_ids, products)
22
28
  end
23
29
 
24
30
  # Removes the products, then re-packs the remaining positions from 1.
@@ -28,8 +34,12 @@ module Spree
28
34
  ApplicationRecord.transaction do
29
35
  membership_class.where(group_key => group_ids, product_id: products.pluck(:id)).delete_all
30
36
 
37
+ # What survived the delete, for every group in one read.
38
+ remaining = membership_class.where(group_key => group_ids).order(:position).pluck(group_key, :product_id).
39
+ group_by(&:first).transform_values { |pairs| pairs.map(&:last) }
40
+
31
41
  rows = group_ids.flat_map do |group_id|
32
- membership_class.where(group_key => group_id).order(:position).pluck(:product_id).map.with_index(1) do |product_id, position|
42
+ remaining.fetch(group_id, []).map.with_index(1) do |product_id, position|
33
43
  {
34
44
  group_key => group_id, product_id: product_id, position: position,
35
45
  created_at: Time.current, updated_at: Time.current
@@ -46,7 +56,7 @@ module Spree
46
56
  # search index are refreshed here.
47
57
  def refresh_after_membership_change(group_ids, products)
48
58
  product_ids = products.pluck(:id)
49
- product_ids.each { |id| Spree::Product.reset_counters(id, membership_class.model_name.element.pluralize.to_sym) }
59
+ Spree::Product.reset_counters_in_bulk(product_ids, membership_class.model_name.element.pluralize.to_sym)
50
60
  refresh_group_counters(group_ids)
51
61
 
52
62
  Spree::Product.where(id: product_ids).touch_all
@@ -42,7 +42,7 @@ module Spree
42
42
 
43
43
  def duplicate_variant(variant, include_images)
44
44
  new_variant = variant.dup
45
- new_variant.sku = sku_generator(new_variant.sku)
45
+ new_variant.sku = sku_generator(variant)
46
46
  new_variant.deleted_at = nil
47
47
  new_variant.option_values = variant.option_values.map { |option_value| option_value }
48
48
  new_variant.prices = duplicate_prices(variant.prices)
@@ -82,10 +82,18 @@ module Spree
82
82
  new_image
83
83
  end
84
84
 
85
- def sku_generator(sku)
86
- return '' if sku.blank?
85
+ def sku_generator(variant)
86
+ return '' if variant.sku.blank?
87
87
 
88
- "COPY OF #{Variant.unscoped.where('sku like ?', "%#{sku}").order(:created_at).last.sku}"
88
+ store_product_ids = Product.with_deleted.where(store_id: variant.store_id).select(:id)
89
+ # Matched the way the SKU uniqueness check compares, without regard
90
+ # to case, and within the same seller scope it reads, so the name taken
91
+ # from the latest match is one this seller may use.
92
+ sku_pattern = Variant.arel_table[:sku].matches("%#{Variant.sanitize_sql_like(variant.sku)}", '\\', false)
93
+ latest_match = Variant.with_deleted.for_seller(variant.resolved_seller_id).
94
+ where(product_id: store_product_ids).where(sku_pattern).order(:created_at, :id).last
95
+
96
+ "COPY OF #{(latest_match || variant).sku}"
89
97
  end
90
98
 
91
99
  def duplicate_error_message(new_product)
@@ -15,13 +15,16 @@ module Spree
15
15
  ORDERED_FILTER_PREDICATES = (TEXT_FILTER_PREDICATES | NUMBER_FILTER_PREDICATES).
16
16
  sort_by { |predicate| -predicate.length }.freeze
17
17
 
18
- attr_reader :store
18
+ attr_reader :store, :audience
19
19
 
20
20
  # @param store [Spree::Store] the store whose custom-field schema this
21
21
  # describes — definitions are store-owned, so two stores have their own
22
22
  # filter vocabularies.
23
- def initialize(store)
23
+ # @param audience [Symbol, nil] who filters: `:store` (shoppers) sees only
24
+ # storefront-visible definitions; `nil` is the back office
25
+ def initialize(store, audience: nil)
24
26
  @store = store
27
+ @audience = audience
25
28
  end
26
29
 
27
30
  # @return [Hash{String => Spree::CustomFieldDefinition}]
@@ -134,8 +137,13 @@ module Spree
134
137
 
135
138
  private
136
139
 
140
+ # A definition hidden from the storefront stays out of its filter and
141
+ # sort vocabulary too: the serializer withholds the value, and a sort or
142
+ # a range filter on it would hand that value back one comparison at a
143
+ # time.
137
144
  def product_definitions
138
145
  scope = store.custom_field_definitions.for_resource_type('Spree::Product')
146
+ scope = scope.storefront_visible if audience == :store
139
147
  scope.where(searchable: true).or(scope.where(sortable: true))
140
148
  end
141
149
 
@@ -32,6 +32,11 @@ module Spree
32
32
  return failure(stock_level, I18n.t('spree.stock_level.errors.count_on_hand_not_an_integer')) if count_on_hand.present? && target.nil?
33
33
  return failure(stock_level, I18n.t('spree.stock_level.errors.adjustment_not_an_integer')) if adjustment.present? && delta.nil?
34
34
 
35
+ # Read before locking, which reloads the level and with it drops the
36
+ # variant and location a bulk caller loaded for every row at once.
37
+ stock_location = stock_level.stock_location
38
+ variant = stock_level.variant
39
+
35
40
  # Locked around the read: the delta is worked out from the count this
36
41
  # caller first saw, so two admins correcting the same level at once
37
42
  # would otherwise each measure against a shelf the other had already
@@ -42,12 +47,14 @@ module Spree
42
47
 
43
48
  # Through the location's own mover rather than building a movement
44
49
  # by hand: it types the row as an adjustment and records the reason,
45
- # so a correction reads like every other change in the history.
46
- stock_level.stock_location.adjust(stock_level.variant, move,
47
- reason: Spree::StockMovement.adjustment_reason_text(reason))
50
+ # so a correction reads like every other change in the history. The
51
+ # movement writes through this locked instance, which leaves it
52
+ # current without another reload.
53
+ stock_location.adjust(variant, move, reason: Spree::StockMovement.adjustment_reason_text(reason),
54
+ stock_level: stock_level)
48
55
  end
49
56
 
50
- success(stock_level.reload)
57
+ success(stock_level)
51
58
  end
52
59
 
53
60
  private
@@ -69,7 +69,7 @@ module Spree
69
69
  private
70
70
 
71
71
  def build_targets(cart)
72
- cart.line_items.includes(variant: { stock_levels: :stock_location }).filter_map do |line_item|
72
+ cart.line_items.includes(variant: [:product, { stock_levels: :stock_location }]).filter_map do |line_item|
73
73
  variant = line_item.variant
74
74
  next unless variant&.should_track_inventory?
75
75
 
@@ -31,13 +31,17 @@ module Spree
31
31
  @currency = resolve_currency(currency, country)
32
32
  @locale = resolve_locale(locale, country)
33
33
 
34
- ApplicationRecord.transaction do
35
- update_default_market
36
- provision_stock_location
37
- provision_delivery_zones
38
- provision_package_type
39
- provision_pickup
40
- restate_seeded_calculators
34
+ # Defaults are bookkeeping, not merchant actions — webhooks and
35
+ # subscribers should not hear about each zone and method created here.
36
+ Spree::Events.disable do
37
+ ApplicationRecord.transaction do
38
+ update_default_market
39
+ provision_stock_location
40
+ provision_delivery_zones
41
+ provision_package_type
42
+ provision_pickup
43
+ restate_seeded_calculators
44
+ end
41
45
  end
42
46
 
43
47
  store.reload