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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc7dbcacac8f33d70f236f52c52070ee495d7d4e366797055c17d4b119eec6d4
4
- data.tar.gz: 3a2e290f02c8d3c9f367c2b4d649d94f69e58449a3ca47179ea58480a3cb1375
3
+ metadata.gz: 6ac170eb6008053ca9eea6d6694bb98d49cc4e66e28826f4aac5bc97fad29e15
4
+ data.tar.gz: 8a883557c01ab2211232eab518443521435e1d6bc10dee6778f3f96119ea6334
5
5
  SHA512:
6
- metadata.gz: ec7a1dc11ffe7aaa8b4cdd82e8e050f39d2d38fedb81c2b3a70901c3db5dbedd0b51bfad12164d2e28962b231ffff777c9ff22a884acc78e631e19a4741ab283
7
- data.tar.gz: 4e7f9a59e472892e07722d15a598ca76dc1257c76c1cc969c77dcba8a73b7d78ceccfe88a61c9aa2fb084f633064256df3493bc2b91e79cecb86a9ca6a545259
6
+ metadata.gz: df3bff4364790fe3205baec9790c4fa123afc7945b68340afb583e56966e73acbd694f1c3016f11e9b880ba9a34bb1622620ee568970e3b625fca96da4807a09
7
+ data.tar.gz: 85bb8941d3648914ad037195c0e090b5e1df40ce3fb46b67aa6ca86a18784af3f1a0cb3ca1a9d656d54e52aead1050a2510fec3b1ccc5288e25d0f5f1f66a309
@@ -75,6 +75,7 @@ module Spree
75
75
 
76
76
  def download_and_attach_image(external_url, image)
77
77
  max_size = Spree::Config.max_image_download_size
78
+ streamed_body = nil
78
79
 
79
80
  response = SsrfFilter.get(
80
81
  external_url,
@@ -90,12 +91,12 @@ module Spree
90
91
  read_timeout: 60,
91
92
  open_timeout: 30
92
93
  }
93
- )
94
+ ) { |fetched| streamed_body = read_within(fetched, max_size) }
94
95
 
95
- body = response.body
96
- if body.bytesize > max_size
97
- raise StandardError, "Image file size exceeds the maximum allowed size of #{max_size} bytes"
98
- end
96
+ # A fetch that did not stream (a test double, say) is read whole and
97
+ # held to the same limit.
98
+ body = streamed_body || response.body
99
+ raise_too_large(max_size) if body.bytesize > max_size
99
100
 
100
101
  uri = URI.parse(external_url)
101
102
  filename = File.basename(uri.path)
@@ -114,6 +115,24 @@ module Spree
114
115
  end
115
116
  end
116
117
 
118
+ # Reads the body as it arrives and stops at the limit, so an oversized
119
+ # or endless response is refused mid-stream rather than held in memory
120
+ # first; a declared length over the limit is refused before any of it.
121
+ def read_within(response, max_size)
122
+ raise_too_large(max_size) if response['Content-Length'].to_i > max_size
123
+
124
+ buffer = +''
125
+ response.read_body do |chunk|
126
+ buffer << chunk
127
+ raise_too_large(max_size) if buffer.bytesize > max_size
128
+ end
129
+ buffer
130
+ end
131
+
132
+ def raise_too_large(max_size)
133
+ raise StandardError, "Image file size exceeds the maximum allowed size of #{max_size} bytes"
134
+ end
135
+
117
136
  def image_already_saved?(image, external_url)
118
137
  image.persisted? && image.attachment.attached? && image.external_url.present? && external_url == image.external_url
119
138
  end
@@ -0,0 +1,54 @@
1
+ module Spree
2
+ # Switches a staff account off without deleting it. Deleting a staff member
3
+ # clears them from every record that names them as its actor, so the history
4
+ # of who approved, refunded or created what is lost; deactivating keeps the
5
+ # account and refuses every way back in.
6
+ #
7
+ # Enforcement lives in the API: a deactivated account cannot sign in by any
8
+ # strategy, accept an invitation, finish a password reset or refresh a
9
+ # session, and its access tokens stop working on the next request. Including
10
+ # this concern opts a model in; the checks are duck-typed, so a custom admin
11
+ # user class without it is never refused.
12
+ #
13
+ # Secret API keys are left alone: they belong to the store, not to the person
14
+ # who created them, and revoking them would break the store's integrations.
15
+ module AccountDeactivation
16
+ # @return [Boolean]
17
+ def deactivated?
18
+ deactivated_at.present?
19
+ end
20
+
21
+ # Deactivates the account and ends every session it holds: refresh tokens
22
+ # are deleted, and OAuth tokens and grants issued to apps on its behalf are
23
+ # revoked. Access tokens already issued are refused from the next request.
24
+ #
25
+ # Safe to call again. The first deactivation time is kept, but sessions
26
+ # are ended every time, so an account whose column was set some other way
27
+ # still loses the credentials it holds.
28
+ #
29
+ # Skips validations: switching an account off must not fail because some
30
+ # unrelated attribute no longer passes today's rules.
31
+ #
32
+ # @return [void]
33
+ def deactivate!
34
+ now = Time.current
35
+ transaction do
36
+ update_columns(deactivated_at: now, updated_at: now) unless deactivated?
37
+ Spree::RefreshToken.revoke_all_for(self)
38
+ oauth_owner = { resource_owner_type: self.class.polymorphic_name, resource_owner_id: id, revoked_at: nil }
39
+ Spree::OauthAccessToken.where(oauth_owner).update_all(revoked_at: now)
40
+ Spree::OauthAccessGrant.where(oauth_owner).update_all(revoked_at: now)
41
+ end
42
+ end
43
+
44
+ # Lets the account sign in again. Sessions ended by deactivation stay
45
+ # ended; the person signs in afresh.
46
+ #
47
+ # @return [void]
48
+ def reactivate!
49
+ return unless deactivated?
50
+
51
+ update_columns(deactivated_at: nil, updated_at: Time.current)
52
+ end
53
+ end
54
+ end
@@ -6,9 +6,12 @@ module Spree
6
6
  # currency directly, so one method serves every currency it has an
7
7
  # amount for — no more method-per-currency duplication.
8
8
  #
9
- # The 5.x single `amount` + `currency` preferences keep working as the
10
- # fallback for their own currency, so upgraded stores quote unchanged
11
- # until they fill in the hash.
9
+ # Once the hash holds an amount it alone decides, and a currency it leaves
10
+ # out is not offered. The 5.x single `amount` + `currency` pair quotes its
11
+ # own currency only while the hash is empty, so upgraded stores quote
12
+ # unchanged until they fill it in. The pair's untouched default (0 in the
13
+ # store's currency) can never be read as a price someone set beside the
14
+ # hash.
12
15
  module CurrencyAmounts
13
16
  extend ActiveSupport::Concern
14
17
 
@@ -17,14 +20,15 @@ module Spree
17
20
  end
18
21
 
19
22
  # The amount this calculator quotes for a currency: the per-currency
20
- # hash first, then the legacy single amount when its currency matches.
23
+ # hash when it holds any amount, otherwise the legacy single amount for
24
+ # its own currency.
21
25
  #
22
26
  # @param currency [String]
23
27
  # @return [BigDecimal, nil] nil when the currency has no amount — the
24
28
  # method is not offered for that cart
25
29
  def amount_for(currency)
26
- explicit = preferred_amounts&.transform_keys { |key| key.to_s.upcase }&.dig(currency.to_s.upcase)
27
- return BigDecimal(explicit.to_s) if explicit.present?
30
+ amounts = currency_amounts
31
+ return amounts[currency.to_s.upcase] if amounts.any?
28
32
 
29
33
  legacy_currency = respond_to?(:preferred_currency) ? preferred_currency : nil
30
34
  return preferred_amount if legacy_currency.blank? || legacy_currency.casecmp(currency.to_s).zero?
@@ -38,6 +42,15 @@ module Spree
38
42
  def supports_currency?(currency)
39
43
  amount_for(currency).present?
40
44
  end
45
+
46
+ private
47
+
48
+ # The hash's amounts by upper-case currency, without blank entries.
49
+ def currency_amounts
50
+ (preferred_amounts || {}).each_with_object({}) do |(key, value), amounts|
51
+ amounts[key.to_s.upcase] = BigDecimal(value.to_s) if value.present?
52
+ end
53
+ end
41
54
  end
42
55
  end
43
56
  end
@@ -58,7 +58,7 @@ module Spree
58
58
  end
59
59
 
60
60
  def sibling_association_name
61
- self.class.name.demodulize.underscore.pluralize
61
+ self.class.name.demodulize.underscore.pluralize.to_sym
62
62
  end
63
63
  end
64
64
  end
@@ -43,8 +43,31 @@ module Spree
43
43
  # @return [Spree::ExternalReference, nil] nil when the reference was removed
44
44
  def set_external_id(system, external_id, metadata: nil)
45
45
  key = Spree::ExternalReference.normalize_system(system)
46
- reference = external_references.for_system(key).first
46
+ write_external_reference(key, external_references.for_system(key).first, external_id, metadata)
47
+ end
47
48
 
49
+ # Writes several references at once, leaving systems the payload does not
50
+ # mention untouched — a connector states its own key without disturbing
51
+ # another system's. Accepts the `{ system => external_id }` map the admin
52
+ # serializers render, or a list of `{ system:, external_id: }` entries.
53
+ #
54
+ # @param references [Hash{String,Symbol=>String}, Array<Hash>]
55
+ # @return [void]
56
+ def assign_external_references(references)
57
+ entries = Spree::ExternalReference.normalize_references(references)
58
+ return if entries.empty?
59
+
60
+ keys = entries.map { |entry| Spree::ExternalReference.normalize_system(entry[:system]) }
61
+ existing = external_references.where(system: keys).order(:id).group_by(&:system).transform_values(&:first)
62
+
63
+ entries.zip(keys).each do |entry, key|
64
+ existing[key] = write_external_reference(key, existing[key], entry[:external_id], entry[:metadata])
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def write_external_reference(key, reference, external_id, metadata)
48
71
  if external_id.to_s.strip.blank?
49
72
  reference&.destroy
50
73
  external_references.reset
@@ -64,21 +87,6 @@ module Spree
64
87
  reference
65
88
  end
66
89
 
67
- # Writes several references at once, leaving systems the payload does not
68
- # mention untouched — a connector states its own key without disturbing
69
- # another system's. Accepts the `{ system => external_id }` map the admin
70
- # serializers render, or a list of `{ system:, external_id: }` entries.
71
- #
72
- # @param references [Hash{String,Symbol=>String}, Array<Hash>]
73
- # @return [void]
74
- def assign_external_references(references)
75
- Spree::ExternalReference.normalize_references(references).each do |reference|
76
- set_external_id(reference[:system], reference[:external_id], metadata: reference[:metadata])
77
- end
78
- end
79
-
80
- private
81
-
82
90
  # A reference belongs to the same store as the record it identifies.
83
91
  #
84
92
  # Store-owning models answer directly;
@@ -123,12 +123,22 @@ module Spree
123
123
 
124
124
  return values unless klass
125
125
 
126
+ # A model with a lookup of its own (Order) resolves each id through it.
127
+ unless klass.method(:find_by_param).owner == Spree::PrefixedId::ClassMethods
128
+ return values.map { |v| Spree::PrefixedId.prefixed_id?(v) ? klass.find_by_param!(v).id : v }
129
+ end
130
+
131
+ # One lookup for the whole list rather than one per id.
132
+ decoded = values.select { |v| Spree::PrefixedId.prefixed_id?(v) }.index_with { |v| klass.decode_own_prefixed_id(v) }
133
+ existing_ids = decoded.empty? ? [] : klass.where(id: decoded.values.compact).pluck(:id)
134
+
126
135
  values.map do |v|
127
- if Spree::PrefixedId.prefixed_id?(v)
128
- klass.find_by_param!(v).id
129
- else
130
- v
131
- end
136
+ next v unless decoded.key?(v)
137
+
138
+ id = decoded[v]
139
+ raise ActiveRecord::RecordNotFound.new("Couldn't find #{klass.name} with param=#{v}", klass.name) unless id && existing_ids.include?(id)
140
+
141
+ id
132
142
  end
133
143
  end
134
144
 
@@ -91,9 +91,14 @@ module Spree
91
91
  end
92
92
 
93
93
  if events.include?(:delete)
94
- before_destroy :capture_pre_destroy_payload, if: :should_publish_events?
94
+ before_destroy :capture_pre_destroy_payload, if: :lifecycle_publishing_enabled?
95
95
  after_commit :publish_delete_event, on: :destroy, if: :should_publish_events?
96
96
  end
97
+
98
+ after_save :note_lifecycle_write
99
+ after_destroy :note_lifecycle_destroy
100
+ after_commit :close_lifecycle_write
101
+ after_rollback :close_lifecycle_write
97
102
  end
98
103
 
99
104
  # A subclass publishes its lifecycle events under its own prefix, which may
@@ -308,9 +313,50 @@ module Spree
308
313
  end
309
314
 
310
315
  def should_publish_events?
316
+ !silenced_lifecycle_write? && lifecycle_publishing_enabled?
317
+ end
318
+
319
+ def lifecycle_publishing_enabled?
311
320
  self.class.publish_events && Spree::Events.lifecycle_enabled?
312
321
  end
313
322
 
323
+ # Lifecycle events publish at commit, which can come after the block that
324
+ # silenced the write ended — a default record created in its parent's
325
+ # `after_create` commits together with the parent. Each write records
326
+ # whether events were on, so a record written only while they were off
327
+ # stays silent at commit; one written with them on still publishes.
328
+ #
329
+ # The marker is read, never cleared, at commit: Rails may check several
330
+ # commit callbacks for one commit (a soft delete is also an update), and
331
+ # the order commit callbacks run in is the host app's setting. A commit or
332
+ # rollback only closes it, and the next write starts a fresh one.
333
+ def note_lifecycle_write
334
+ reopen_lifecycle_write
335
+ @_spree_lifecycle_write_enabled ||= Spree::Events.lifecycle_enabled?
336
+ end
337
+
338
+ # A destroy commits as the delete event alone, so an earlier update made
339
+ # with events on must not carry it through.
340
+ def note_lifecycle_destroy
341
+ reopen_lifecycle_write
342
+ @_spree_lifecycle_write_enabled = Spree::Events.lifecycle_enabled?
343
+ end
344
+
345
+ def silenced_lifecycle_write?
346
+ @_spree_lifecycle_write_enabled == false
347
+ end
348
+
349
+ def close_lifecycle_write
350
+ @_spree_lifecycle_write_closed = true
351
+ end
352
+
353
+ def reopen_lifecycle_write
354
+ return unless @_spree_lifecycle_write_closed
355
+
356
+ @_spree_lifecycle_write_closed = false
357
+ @_spree_lifecycle_write_enabled = nil
358
+ end
359
+
314
360
  # True when this commit was caused only by `touch` — no real save ran
315
361
  # (`after_update` fires for saves, never for touches). Flags reset every
316
362
  # commit so a later genuine update publishes normally.
@@ -3,8 +3,8 @@ module Spree
3
3
  # The full address surface shared by Spree::Cart and Spree::Order:
4
4
  # the bill/ship associations and their public aliases, default filling
5
5
  # from the customer's saved addresses, address-row deduplication on
6
- # write, ownership-guarded address_id assignment, promotion of checkout
7
- # addresses to the customer's defaults, and the ship→bill cloning
6
+ # write, ownership-guarded address_id assignment and deletion, promotion
7
+ # of checkout addresses to the customer's defaults, and the ship→bill cloning
8
8
  # predicate. The shipping address is canonical: use_shipping copies it
9
9
  # onto the billing address; the reverse use_billing direction is a
10
10
  # deprecated 6.1-removal bridge. Clone callbacks are wired per model.
@@ -12,8 +12,13 @@ module Spree
12
12
  extend ActiveSupport::Concern
13
13
 
14
14
  included do
15
- belongs_to :bill_address, class_name: 'Spree::Address', optional: true, dependent: :destroy
16
- belongs_to :ship_address, class_name: 'Spree::Address', optional: true, dependent: :destroy
15
+ belongs_to :bill_address, class_name: 'Spree::Address', optional: true
16
+ belongs_to :ship_address, class_name: 'Spree::Address', optional: true
17
+
18
+ # Stands in for `dependent: :destroy`, which would take any attached
19
+ # row down with the purchase. See {#owns_address?} for why only some
20
+ # of them may go.
21
+ after_destroy :destroy_own_addresses
17
22
 
18
23
  # The writers these generate are shadowed by the hand-written ones
19
24
  # below, which deduplicate address rows. The declarations still earn
@@ -40,9 +45,12 @@ module Spree
40
45
  before_validation :clone_shipping_address, if: :use_shipping?
41
46
  end
42
47
 
48
+ # Whether both addresses are present and match. With neither present
49
+ # there is nothing to call the same.
50
+ #
43
51
  # @return [Boolean]
44
52
  def shipping_eq_billing_address?
45
- bill_address == ship_address
53
+ ship_address.present? && bill_address == ship_address
46
54
  end
47
55
 
48
56
  # Whether checkout must collect a customer shipping address. Selected
@@ -54,7 +62,11 @@ module Spree
54
62
  #
55
63
  # @return [Boolean]
56
64
  def shipping_address_required?
57
- selected_methods = fulfillments.filter_map(&:delivery_method)
65
+ candidates = fulfillments.to_a
66
+ if candidates.many? && candidates.all?(ActiveRecord::Base)
67
+ ActiveRecord::Associations::Preloader.new(records: candidates, associations: { selected_delivery_rate: :delivery_method }).call
68
+ end
69
+ selected_methods = candidates.filter_map(&:delivery_method)
58
70
  return selected_methods.any?(&:requires_address?) if selected_methods.any?
59
71
 
60
72
  return false if preferred_stock_location_id.present?
@@ -90,6 +102,68 @@ module Spree
90
102
  use_shipping.in?([true, 'true', '1'])
91
103
  end
92
104
 
105
+ # The address behind an id a client may select, or nil when the id names
106
+ # one this purchase has no business shipping to. Takes a prefixed id or a
107
+ # raw primary key, so the services that resolve a client-supplied id and
108
+ # the writers below share one rule rather than each inventing their own.
109
+ #
110
+ # Two books qualify. The buyer's own is the ordinary case. The company's
111
+ # is the B2B one: a purchase for a node ships to that node's sites, and
112
+ # to its ancestors' — the same self-and-ancestors chain the node's
113
+ # default address is prefilled from, so a division can pick the
114
+ # headquarters address it already inherits.
115
+ #
116
+ # @param id [Integer, String, nil]
117
+ # @return [Spree::Address, nil]
118
+ def selectable_address(id)
119
+ address = ::Spree::Address.not_deleted.find_by_param(id) || ::Spree::Address.not_deleted.find_by(id: id)
120
+ return if address.nil?
121
+
122
+ return address if customer_id.present? && address.customer_owned? && address.owner_id == customer_id
123
+
124
+ company = readable_company
125
+ return if company.nil? || address.owner_type != 'Spree::Company'
126
+
127
+ company.self_and_ancestors.any? { |node| node.id == address.owner_id } ? address : nil
128
+ end
129
+
130
+ # Everything a checkout address picker may offer for this purchase: the
131
+ # buyer's own book plus the book of the company it names, that node's
132
+ # own entries and the ones it inherits, limited to the countries the
133
+ # purchase's market sells to. A subset of what {#selectable_address}
134
+ # accepts, so a picker built from it never offers an address the
135
+ # checkout cannot deliver to.
136
+ #
137
+ # @return [ActiveRecord::Relation<Spree::Address>]
138
+ def selectable_addresses
139
+ books = []
140
+ books << ::Spree::Address.not_deleted.where(owner_type: ::Spree.customer_class.to_s, owner_id: customer_id) if customer_id.present?
141
+ books << readable_company&.address_book
142
+
143
+ addresses = books.compact.reduce { |left, right| left.or(right) } || ::Spree::Address.none
144
+ return addresses if market.nil?
145
+
146
+ addresses.where(country_code: market.market_countries.select(:country_code))
147
+ end
148
+
149
+ # Whether an attached address row is this purchase's own to delete.
150
+ #
151
+ # Only the ownerless rows are: the one a guest typed in, and the
152
+ # snapshot completion copies onto the order. Everything else is an entry
153
+ # in somebody's address book — the buyer's, a company node's, a seller's
154
+ # — which the purchase is merely pointing at. A cart may name a company
155
+ # site every other member ships from, so taking an attached row down
156
+ # with the cart would let one member empty the shared book.
157
+ #
158
+ # A business snapshot keeps its +owner_type+ to stay addressed to a
159
+ # company rather than to a person, so ownership is the id, not the type.
160
+ #
161
+ # @param address [Spree::Address, nil]
162
+ # @return [Boolean]
163
+ def owns_address?(address)
164
+ address.present? && address.owner_id.blank?
165
+ end
166
+
93
167
  # Fills any blank bill/ship address from the customer's valid saved
94
168
  # defaults. The ship address is skipped when no physical delivery is
95
169
  # required, so shipping-address validations never fire on digital-only
@@ -174,46 +248,50 @@ module Spree
174
248
 
175
249
  private
176
250
 
251
+ # The company whose book this purchase may read: only the one the
252
+ # client named, never one inferred from a sole membership. A cart asks
253
+ # again rather than trusting the column, which nothing clears when a
254
+ # membership is revoked; an order's authority is staff or a completed
255
+ # cart, so it keeps what it was given.
256
+ #
257
+ # @return [Spree::Company, nil]
258
+ def readable_company
259
+ return company if company.nil? || !is_a?(Spree::Cart)
260
+
261
+ company if customer&.standing_for?(company)
262
+ end
263
+
264
+ # Both slots often hold the same row, so destroy it once.
265
+ def destroy_own_addresses
266
+ [bill_address, ship_address].compact.uniq.each do |address|
267
+ address.destroy if owns_address?(address)
268
+ end
269
+ end
270
+
177
271
  def items_require_shipping_address?
178
272
  # Answered by the profile kind, resolved once per profile.
179
273
  profile_requires_address = Hash.new do |cache, profile|
180
274
  cache[profile] = profile.present? && profile.requires_shipping_address?
181
275
  end
182
276
 
183
- # Both profile paths are preloaded: the variant's own override, and the
184
- # product's for the variants that inherit it.
185
- line_items.includes(variant: [:delivery_profile, { product: :delivery_profile }]).any? do |line_item|
277
+ # On a list the association batches across the page, so a fresh
278
+ # `includes` query would load every cart's items separately. Read
279
+ # alone, both profile paths are preloaded: the variant's own override,
280
+ # and the product's for the variants that inherit it.
281
+ profile_paths = { variant: [:delivery_profile, { product: :delivery_profile }] }
282
+ items = if !read_association_in_memory?(:line_items)
283
+ line_items.includes(profile_paths)
284
+ elsif lazy_preload_context.nil?
285
+ line_items.tap { |loaded| ActiveRecord::Associations::Preloader.new(records: loaded.to_a, associations: profile_paths).call }
286
+ else
287
+ line_items
288
+ end
289
+
290
+ items.any? do |line_item|
186
291
  profile_requires_address[line_item.variant&.resolved_delivery_profile]
187
292
  end
188
293
  end
189
294
 
190
- # The address behind an id a client may select, or nil when the id names
191
- # one this purchase has no business shipping to.
192
- #
193
- # Two books qualify. The buyer's own is the ordinary case. The company's
194
- # is the B2B one: a purchase for a node ships to that node's sites, and
195
- # to its ancestors' — the same self-and-ancestors chain the node's
196
- # default address is prefilled from, so a division can pick the
197
- # headquarters address it already inherits.
198
- #
199
- # Standing is not re-checked here: a cart cannot name a company its
200
- # customer lacks standing over, so reaching this point already means the
201
- # buyer may act for the node.
202
- #
203
- # @param id [Integer, String, nil]
204
- # @return [Spree::Address, nil]
205
- def selectable_address(id)
206
- address = ::Spree::Address.find_by(id: id)
207
- return nil if address.nil?
208
-
209
- return address if customer_id.present? && address.customer_owned? && address.owner_id == customer_id
210
-
211
- company = resolved_company
212
- return nil if company.nil? || address.owner_type != 'Spree::Company'
213
-
214
- company.self_and_ancestors.any? { |node| node.id == address.owner_id } ? address : nil
215
- end
216
-
217
295
  # Reuse never reaches past what the buyer already owns: a signed-in buyer
218
296
  # may name any entry of their own address book, and a guest — who keeps
219
297
  # no book — may only edit the row already in the slot being written.
@@ -9,7 +9,7 @@ module Spree
9
9
  if total_quantity.zero? || line_items.empty?
10
10
  false
11
11
  else
12
- line_items.includes(variant: :product).all?(&:digital?)
12
+ line_items_with_variants(variant: :product).all?(&:digital?)
13
13
  end
14
14
  end
15
15
 
@@ -18,7 +18,7 @@ module Spree
18
18
  if total_quantity.zero? || line_items.empty?
19
19
  false
20
20
  else
21
- line_items.includes(variant: :product).any?(&:digital?)
21
+ line_items_with_variants(variant: :product).any?(&:digital?)
22
22
  end
23
23
  end
24
24
 
@@ -27,7 +27,7 @@ module Spree
27
27
  if total_quantity.zero? || line_items.empty?
28
28
  false
29
29
  else
30
- line_items.includes(:variant).any?(&:with_digital_assets?)
30
+ line_items_with_variants(variant: :digital_assets).any?(&:with_digital_assets?)
31
31
  end
32
32
  end
33
33
 
@@ -55,6 +55,20 @@ module Spree
55
55
  def delivery_step_required?
56
56
  line_items.any? && !digital?
57
57
  end
58
+
59
+ private
60
+
61
+ # Lines already in memory get the associations preloaded onto them —
62
+ # onto every line of their preload context when they sit in a list, so
63
+ # the list batches them instead of querying once per purchase.
64
+ def line_items_with_variants(associations)
65
+ return line_items.includes(associations) unless read_association_in_memory?(:line_items)
66
+
67
+ loaded = line_items.to_a
68
+ records = loaded.first&.lazy_preload_context&.records || loaded
69
+ ActiveRecord::Associations::Preloader.new(records: records, associations: associations).call
70
+ loaded
71
+ end
58
72
  end
59
73
  end
60
74
  end
@@ -42,7 +42,8 @@ module Spree
42
42
  # twice. Two freight consignments are still one load to the forwarder,
43
43
  # so they are merged rather than listed.
44
44
  def build_freight_summary
45
- summaries = fulfillments.valid.filter_map { |fulfillment| fulfillment.selected_delivery_rate&.freight_summary }
45
+ shipping = read_association_in_memory?(:fulfillments) ? fulfillments.reject(&:canceled?) : fulfillments.valid
46
+ summaries = shipping.filter_map { |fulfillment| fulfillment.selected_delivery_rate&.freight_summary }
46
47
  return if summaries.empty?
47
48
 
48
49
  Spree::FreightSummary.merge(summaries)
@@ -55,7 +55,7 @@ module Spree
55
55
  # @return [Boolean]
56
56
  def confirmation_required?
57
57
  Spree::StorePreferences.read(store, :always_include_confirm_step) ||
58
- payments.valid.map(&:payment_method).compact.any?(&:confirmation_required?)
58
+ valid_payments.filter_map(&:payment_method).any?(&:confirmation_required?)
59
59
  end
60
60
 
61
61
  def unprocessed_payments
@@ -64,6 +64,12 @@ module Spree
64
64
 
65
65
  private
66
66
 
67
+ # On a list the association batches across the page, so the loaded rows
68
+ # are filtered rather than queried through `valid` once per record.
69
+ def valid_payments
70
+ read_association_in_memory?(:payments) ? payments.reject(&:has_invalid_status?) : payments.valid
71
+ end
72
+
67
73
  def process_payments_with(action)
68
74
  # Don't run if there is nothing to pay. Read from the database, not
69
75
  # the in-memory attribute: settlements land through the order status
@@ -104,6 +104,8 @@ module Spree
104
104
  # accuse a completed sale of breaking a rule written after it.
105
105
  return [] if staff_initiated? || (is_a?(Spree::Order) && completed?)
106
106
 
107
+ ActiveRecord::Associations::Preloader.new(records: line_items.to_a, associations: { variant: :product }).call
108
+
107
109
  line_items.filter_map do |line_item|
108
110
  variant = line_item.variant
109
111
  next if variant.nil?
@@ -13,7 +13,9 @@ module Spree
13
13
  def ensure_default_channel
14
14
  return if default_channel
15
15
 
16
- channels.create!(name: 'Online Store', code: Spree::Channel::DEFAULT_CODE)
16
+ Spree::Events.disable do
17
+ channels.create!(name: 'Online Store', code: Spree::Channel::DEFAULT_CODE)
18
+ end
17
19
  end
18
20
  end
19
21
  end
@@ -168,7 +168,12 @@ module Spree
168
168
  def translatable_store
169
169
  return self if is_a?(Spree::Store)
170
170
 
171
- try(:store) || Spree::Current.store
171
+ current_store = Spree::Current.store
172
+ # The request's store instance already carries its markets, so a batch
173
+ # of records does not re-read them once per record's own store copy.
174
+ return current_store if current_store && try(:store_id) == current_store.id
175
+
176
+ try(:store) || current_store
172
177
  end
173
178
 
174
179
  private
@@ -32,6 +32,7 @@ module Spree
32
32
  reset_token
33
33
  unsubscribe_token
34
34
  verification_token
35
+ confirmation_token
35
36
  download_url
36
37
  external_client_secret
37
38
  client_secret