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
@@ -23,6 +23,7 @@ module Spree
23
23
  @amount = capture_amount(amount)
24
24
 
25
25
  step :ensure_capturable
26
+ step :ensure_within_payment_amount
26
27
  # Veto point — fraud holds, manual review. Before any money moves.
27
28
  run_hooks :validate
28
29
  run_hooks :before_capture
@@ -69,6 +70,17 @@ module Spree
69
70
  failure(payment, :payment_not_capturable)
70
71
  end
71
72
 
73
+ # A capture takes some or all of what was authorized, never more: a
74
+ # gateway may let a larger figure through, and the books would then
75
+ # carry a capture the authorization never covered. Nor nothing, or less
76
+ # than nothing — every caller gets the same rule, not only the API.
77
+ def ensure_within_payment_amount
78
+ failure(payment, :capture_amount_not_positive) unless @amount.positive?
79
+ return if @amount <= payment.amount
80
+
81
+ failure(payment, :capture_exceeds_payment_amount)
82
+ end
83
+
72
84
  def claim
73
85
  halt!(payment) unless payment.started_processing!
74
86
  rescue Spree::Core::GatewayError => error
@@ -195,6 +195,10 @@ module Spree
195
195
  # accumulate copies. Omitting an entry leaves it alone; removal is the
196
196
  # nested digital_assets DELETE endpoint's job.
197
197
  #
198
+ # Delegates to the digital asset workflows, so a file written as part of
199
+ # a product payload passes the same gate — and the same :validate
200
+ # handlers — as one written through the digital assets endpoint.
201
+ #
198
202
  # @param product [Spree::Product]
199
203
  # @param digital_assets_params [Array<Hash>, nil]
200
204
  # @return [void]
@@ -203,38 +207,18 @@ module Spree
203
207
 
204
208
  digital_assets_params.each do |raw|
205
209
  attrs = (raw.respond_to?(:to_h) ? raw.to_h : raw).with_indifferent_access
206
-
207
210
  asset_id = attrs.delete(:id)
208
- signed_id = attrs.delete(:signed_id)
209
- variant_id = attrs.delete(:variant_id)
210
-
211
- # An id patches an existing asset; without one this is a new asset on
212
- # the named variant (or the default). A patch for an asset that is
213
- # gone is skipped rather than recreated.
214
- if asset_id.present?
215
- asset = product.digital_assets.find_by_param(asset_id) || next
216
- else
217
- target = variant_id.present? ? product.variants.find_by_param!(variant_id) : product.default_variant
218
- next unless target
219
211
 
220
- asset = target.digital_assets.build
221
- end
222
-
223
- asset.assign_attributes(attrs)
224
-
225
- if signed_id.present?
226
- # Attaching never moves a blob between storage services, so a file
227
- # uploaded to the public bucket would stay publicly readable. Refuse
228
- # it rather than attaching it wrongly.
229
- blob = ActiveStorage::Blob.find_signed(signed_id)
230
- unless blob && blob.service_name.to_s == Spree.private_storage_service_name.to_s
231
- asset.errors.add(:attachment, :digital_assets_attachment_must_be_private, message: I18n.t('spree.digital_assets_attachment_must_be_private'))
232
- raise ActiveRecord::RecordInvalid.new(asset)
212
+ result =
213
+ if asset_id.present?
214
+ # A patch for an asset that is gone is skipped rather than recreated.
215
+ asset = product.digital_assets.find_by_param(asset_id) || next
216
+ Spree.digital_asset_update_workflow.call(digital_asset: asset, attributes: attrs)
217
+ else
218
+ Spree.digital_asset_create_workflow.call(product: product, attributes: attrs)
233
219
  end
234
220
 
235
- asset.attachment.attach(signed_id)
236
- end
237
- asset.save!
221
+ raise ActiveRecord::RecordInvalid, result.value if result.failure?
238
222
  end
239
223
  end
240
224
  end
@@ -13,16 +13,20 @@ module Spree
13
13
 
14
14
  # @param product [Spree::Product]
15
15
  # @param submitted_by [Spree.admin_user_class, nil] who asked
16
+ # @param reason [String, nil] why a listing already on sale goes back to
17
+ # review — what changed on it. Recorded on the submission row. Without
18
+ # one, only a draft or a rejected product can be submitted.
16
19
  # @return [Spree::ServiceModule::Result] value is the product
17
- def perform(product:, submitted_by: nil)
20
+ def perform(product:, submitted_by: nil, reason: nil)
18
21
  super
19
22
 
20
23
  # Only a listing that is being worked on can be submitted. Guarding
21
24
  # `active?` alone let an archived product through — and with
22
25
  # auto-approval on, a withdrawn listing went straight back on sale
23
26
  # without anybody reviewing it. Re-submitting one already in review
24
- # is equally meaningless.
25
- unless product.draft? || product.rejected?
27
+ # is equally meaningless. An active listing reopens review only when
28
+ # the caller says what changed, which no seller-facing action does.
29
+ unless product.draft? || product.rejected? || (reason.present? && product.active?)
26
30
  reject!(:cannot_propose, message: I18n.t('activerecord.errors.models.spree/product.attributes.base.cannot_propose'))
27
31
  end
28
32
 
@@ -51,7 +55,8 @@ module Spree
51
55
  # one: the trail is how many times this was asked for, and rewriting
52
56
  # the last answer would erase that.
53
57
  def open_submission
54
- product.submissions.create!(status: 'pending', submitted_by: submitted_by)
58
+ product.submissions.create!(status: 'pending', submitted_by: submitted_by,
59
+ metadata: reason.present? ? { 'reason' => reason } : {})
55
60
  end
56
61
 
57
62
  def auto_approve?
@@ -35,9 +35,10 @@ module Spree
35
35
  # means the later line, not the sum of both.
36
36
  deduped = keyed.reverse.uniq { |row| [row[:variant_id].to_s, row[:stock_location_id].to_s] }.reverse
37
37
 
38
+ existing = existing_stock_levels(deduped)
38
39
  count = 0
39
40
  Spree::StockLevel.transaction do
40
- deduped.each { |row| count += 1 if apply_row(row) }
41
+ deduped.each { |row| count += 1 if apply_row(row, existing) }
41
42
  end
42
43
 
43
44
  success(stock_level_count: count)
@@ -52,27 +53,41 @@ module Spree
52
53
  # happens to name — including another merchant's. One caller decoding
53
54
  # ids correctly does not make the workflow safe for the next one.
54
55
  def resolve(rows)
55
- variants = scoped_ids(store_variants, Spree::Variant, rows.map { |row| row[:variant_id] })
56
- locations = scoped_ids(store_stock_locations, Spree::StockLocation,
57
- rows.map { |row| row[:stock_location_id] })
56
+ @variants = scoped_records(store_variants, Spree::Variant, rows.map { |row| row[:variant_id] })
57
+ @stock_locations = scoped_records(store_stock_locations, Spree::StockLocation,
58
+ rows.map { |row| row[:stock_location_id] })
59
+ @variants_by_id = @variants.values.index_by(&:id)
60
+ @stock_locations_by_id = @stock_locations.values.index_by(&:id)
58
61
 
59
62
  rows.map do |row|
60
63
  row.merge(
61
- variant_id: variants[row[:variant_id]],
62
- stock_location_id: locations[row[:stock_location_id]]
64
+ variant_id: @variants[row[:variant_id]]&.id,
65
+ stock_location_id: @stock_locations[row[:stock_location_id]]&.id
63
66
  ).compact
64
67
  end
65
68
  end
66
69
 
67
70
  # Accepts a prefixed id or a raw one: the Admin API speaks prefixed,
68
71
  # while the importer and seeds hold records they already loaded.
69
- def scoped_ids(scope, model, values)
72
+ #
73
+ # @return [Hash{Object => ActiveRecord::Base}] each value as given => this store's record
74
+ def scoped_records(scope, model, values)
70
75
  decoded = values.compact.uniq.index_with do |value|
71
76
  model.decode_own_prefixed_id(value) || value
72
77
  end
73
- found = scope.where(id: decoded.values.compact).pluck(:id).map(&:to_s).to_set
78
+ found = scope.where(id: decoded.values.compact).index_by { |record| record.id.to_s }
79
+
80
+ decoded.transform_values { |id| found[id.to_s] if id }.compact
81
+ end
74
82
 
75
- decoded.transform_values { |id| id if id && found.include?(id.to_s) }
83
+ # Every shelf the feed names, in one query rather than one per row.
84
+ # Lowest id wins, as an unordered lookup per pair would return.
85
+ def existing_stock_levels(rows)
86
+ Spree::StockLevel.
87
+ where(variant_id: rows.map { |row| row[:variant_id] }.uniq,
88
+ stock_location_id: rows.map { |row| row[:stock_location_id] }.uniq).
89
+ order(id: :desc).
90
+ index_by { |stock_level| [stock_level.variant_id.to_s, stock_level.stock_location_id.to_s] }
76
91
  end
77
92
 
78
93
  def store_variants
@@ -83,10 +98,10 @@ module Spree
83
98
  @store.stock_locations
84
99
  end
85
100
 
86
- def apply_row(row)
87
- stock_level = Spree::StockLevel.find_or_initialize_by(
88
- variant_id: row[:variant_id], stock_location_id: row[:stock_location_id]
89
- )
101
+ def apply_row(row, existing)
102
+ stock_level = existing[[row[:variant_id].to_s, row[:stock_location_id].to_s]] || Spree::StockLevel.new
103
+ stock_level.variant = @variants_by_id.fetch(row[:variant_id])
104
+ stock_level.stock_location = @stock_locations_by_id.fetch(row[:stock_location_id])
90
105
  stock_level.backorderable = row[:backorderable] if row.key?(:backorderable)
91
106
  stock_level.save! if stock_level.new_record? || stock_level.changed?
92
107
 
@@ -48,6 +48,8 @@ module Spree
48
48
  return if prices_params.nil?
49
49
 
50
50
  currencies = []
51
+ # Loaded once so each currency's write finds its row in memory.
52
+ variant.prices.load if variant.persisted?
51
53
 
52
54
  Array(prices_params).each do |raw|
53
55
  price = raw.to_h.with_indifferent_access
@@ -59,7 +61,8 @@ module Spree
59
61
 
60
62
  return unless variant.persisted?
61
63
 
62
- variant.prices.base_prices.where.not(currency: currencies).destroy_all
64
+ stale = variant.prices.select { |price| price.persisted? && price.price_list_id.nil? && currencies.exclude?(price.currency) }
65
+ variant.prices.destroy(*stale) if stale.any?
63
66
  end
64
67
 
65
68
  # @param variant [Spree::Variant]
@@ -68,22 +71,44 @@ module Spree
68
71
  def apply_stock_levels(variant, stock_levels_params)
69
72
  return if stock_levels_params.blank?
70
73
 
71
- Array(stock_levels_params).each do |raw|
72
- row = raw.to_h.with_indifferent_access
73
- next if row[:stock_location_id].blank?
74
+ rows = Array(stock_levels_params).map { |raw| raw.to_h.with_indifferent_access }
75
+ rows.reject! { |row| row[:stock_location_id].blank? }
76
+ return if rows.empty?
74
77
 
75
- # Scoped through the product's own store: Spree::StockLocation
76
- # lookup is global, so an id belonging to another store would
77
- # otherwise put this variant's stock in that store's warehouse.
78
- location = stock_location_for(variant, row[:stock_location_id])
78
+ locations = stock_locations_by_param(variant, rows.map { |row| row[:stock_location_id] })
79
+ # Loaded once so each row's write finds its level in memory.
80
+ variant.stock_levels.load if variant.persisted?
81
+
82
+ rows.each do |row|
83
+ location = locations[row[:stock_location_id].to_s]
79
84
  next if location.nil?
80
85
 
81
86
  variant.set_stock(row[:count_on_hand], row[:backorderable], location)
82
87
  end
88
+
89
+ # A count change locks and reloads its level, so the locations the
90
+ # response's stock figures read are loaded for every level afterwards.
91
+ ActiveRecord::Associations::Preloader.new(records: variant.stock_levels.to_a, associations: :stock_location).call
83
92
  end
84
93
 
85
- def stock_location_for(variant, param)
86
- variant.product&.store&.stock_locations&.find_by_param(param)
94
+ # Resolves every stock location the payload names in one read. Scoped
95
+ # through the product's own store: Spree::StockLocation lookup is
96
+ # global, so an id belonging to another store would otherwise put this
97
+ # variant's stock in that store's warehouse. A store keeps few
98
+ # locations, so loading them beats a lookup per row.
99
+ #
100
+ # @return [Hash{String => Spree::StockLocation}] keyed by both the
101
+ # prefixed id and the raw id, which is what the payload may carry
102
+ def stock_locations_by_param(variant, params)
103
+ scope = variant.product&.store&.stock_locations
104
+ return {} if scope.nil? || params.empty?
105
+
106
+ wanted = params.map(&:to_s).uniq
107
+ scope.each_with_object({}) do |location, memo|
108
+ [location.prefixed_id.to_s, location.id.to_s].each do |key|
109
+ memo[key] = location if wanted.include?(key)
110
+ end
111
+ end
87
112
  end
88
113
  end
89
114
  end
@@ -463,6 +463,9 @@ en:
463
463
  required_for_carton_purchase_unit: is required when the purchase unit is cartons
464
464
  spree/webhook_endpoint:
465
465
  attributes:
466
+ subscriptions:
467
+ invalid_pattern: 'must hold event names or patterns such as order.* or *.created (rejected: %{entries})'
468
+ too_many: can hold at most %{count} entries
466
469
  url:
467
470
  internal_address_not_allowed: must not point to an internal or private network address
468
471
  invalid_url: must be a valid http or https URL
@@ -685,6 +688,8 @@ en:
685
688
  canceler: Canceler
686
689
  cannot_be_destroyed: Order cannot be destroyed.
687
690
  capture: Capture
691
+ capture_amount_not_positive: The capture amount must be greater than zero.
692
+ capture_exceeds_payment_amount: The capture amount cannot exceed the payment amount.
688
693
  cart: Cart
689
694
  cart_already_updated: The cart has already been updated.
690
695
  cart_completion_in_progress: The cart is being completed. Please retry in a moment.
@@ -765,6 +770,7 @@ en:
765
770
  created_at: Created at
766
771
  credit_card: Credit Card
767
772
  currency: Currency
773
+ currency_does_not_match_market: must be %{currency}, the currency of the chosen market
768
774
  currency_not_supported_by_store: is not supported by this store
769
775
  custom_field_definitions: Custom Field Definitions
770
776
  custom_fields: Custom Fields
@@ -773,7 +779,9 @@ en:
773
779
  anonymize_failed: The erasure could not be completed and nothing was changed
774
780
  customers: Customers
775
781
  data_request_errors:
782
+ address_changed: The account's email address changed after this request was made
776
783
  already_anonymized: This customer's personal data has already been erased
784
+ confirmation_invalid: This confirmation link has expired or is no longer valid
777
785
  download_unavailable: This download link has expired or is no longer valid
778
786
  not_pending: This request has already been processed
779
787
  default: Default
@@ -814,6 +822,11 @@ en:
814
822
  digital_delivery: Digital Delivery
815
823
  digital_assets: Digital assets
816
824
  digital_assets_attachment_must_be_private: Upload the file to private storage before attaching it
825
+ digital_assets_content_type_list_invalid: 'Not a content type: %{types}. Use the type/subtype form, such as application/pdf'
826
+ digital_assets_content_type_not_accepted: "%{content_type} files are not accepted on this marketplace"
827
+ digital_assets_provider_takes_no_file: This file is delivered by the marketplace's own system, so a replacement file cannot be uploaded
828
+ digital_assets_sellers_disabled: This marketplace does not let sellers add or change digital files
829
+ digital_assets_upload_not_found: The uploaded file could not be found. Upload it again
817
830
  discontinued: Discontinued
818
831
  discontinued_variants_present: Some line items in this order have products that are no longer available.
819
832
  display_delivery_range: Delivery in %{delivery_range} business days
@@ -1016,6 +1029,7 @@ en:
1016
1029
  line_items: Line items
1017
1030
  link: Link
1018
1031
  locale: Locale
1032
+ locale_not_supported_by_market: is not offered by the chosen market
1019
1033
  locale_not_supported_by_store: is not supported by this store
1020
1034
  locales_not_listed: 'includes locales Spree does not support: %{codes}'
1021
1035
  manual: Manual
@@ -0,0 +1,13 @@
1
+ # Makes a store credit's issuer polymorphic, so a credit issued with a secret
2
+ # API key — a return an integration refunds to store credit — records the key
3
+ # rather than failing. See docs/plans/6.0-action-actors.md.
4
+ class StoreCreditActorPolymorphic < ActiveRecord::Migration[8.1]
5
+ def change
6
+ # Nullable with no default: the type is data, filled by
7
+ # `rake spree:upgrade:backfill_actor_types`. Until it runs, the
8
+ # transitional reader resolves these rows through the admin user class.
9
+ add_column :spree_store_credits, :created_by_type, :string
10
+ add_index :spree_store_credits, [:created_by_type, :created_by_id],
11
+ name: 'index_spree_store_credits_on_created_by_actor'
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # Lets a staff account be switched off without deleting it, so records that
2
+ # name it as their actor keep their history. See Spree::AccountDeactivation.
3
+ class AddDeactivatedAtToSpreeAdminUsers < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :spree_admin_users, :deactivated_at, :datetime
6
+ end
7
+ end
@@ -77,4 +77,91 @@ Dummy::Application.configure do
77
77
  config.active_record.encryption.key_derivation_salt = 'spree-test-key-derivation-salt'
78
78
 
79
79
  routes.default_url_options = { host: 'localhost', port: 3000 }
80
+
81
+ # An N+1 inside an API request fails the example instead of being logged,
82
+ # so a serializer or model method that queries once per record cannot land
83
+ # unnoticed. Wrap a deliberately repeated lookup in Prosopite.pause.
84
+ if defined?(Prosopite)
85
+ # Hooked from Spree's own test app rather than declared in the
86
+ # controller, so a host application that bundles Prosopite for itself is
87
+ # never opted into Spree's guard. Prepended so the lookups made by the
88
+ # authentication and parent callbacks are scanned too.
89
+ config.to_prepare do
90
+ if defined?(Spree::Api::V3::BaseController)
91
+ Spree::Api::V3::BaseController.prepend_around_action { |_controller, action| Prosopite.scan(&action) }
92
+ end
93
+ end
94
+
95
+ Prosopite.rails_logger = true
96
+ # Raise only under RSpec, where a report belongs to the example that
97
+ # caused it. The same dummy app serves a real server for the SDK and
98
+ # browser suites, and there a raise becomes a 500 the client cannot act
99
+ # on — those runs log instead. SPREE_N_PLUS_ONE_GUARD overrides either
100
+ # way ('raise', 'log').
101
+ running_specs = ENV.key?('TEST_ENV_NUMBER') || $PROGRAM_NAME.end_with?('rspec')
102
+ guard_mode = ENV.fetch('SPREE_N_PLUS_ONE_GUARD') { running_specs ? 'raise' : 'log' }
103
+ Prosopite.raise = guard_mode == 'raise'
104
+
105
+ # The Rails cleaner keeps only the host app's frames, which hides the
106
+ # serializer or model line that issued the repeated query.
107
+ Prosopite.backtrace_cleaner = ActiveSupport::BacktraceCleaner.new.tap do |cleaner|
108
+ cleaner.add_silencer { |line| line.include?('/gems/') || line.include?('/rspec') }
109
+ end
110
+
111
+ # The guard covers what a request reads. Writes run their callbacks one
112
+ # record at a time by design — a `dependent: :destroy` cascade, a number
113
+ # or uniqueness probe per created row — so statements issued under a
114
+ # save or destroy are not reported.
115
+ Prosopite.allow_stack_paths = [
116
+ %r{active_record/persistence\.rb:\d+:in '[^']*#(save|save!|destroy|destroy!|touch)'},
117
+ # A touch is a write too, and Spree overrides `touch` in its own
118
+ # concern, so the frame is Spree's rather than Active Record's.
119
+ %r{active_record/timestamp\.rb:\d+:in '[^']*#touch'},
120
+ %r{/spree/publishable\.rb:\d+:in '[^']*#touch'},
121
+ %r{/paranoia\.rb:\d+:in '[^']*destroy'},
122
+ # Validations run before the persistence frame starts, so a uniqueness
123
+ # or ladder probe on the row being saved needs its own entry.
124
+ %r{active_record/validations\.rb:\d+:in '[^']*#(save|save!)'},
125
+ # after_commit callbacks are the same per-record write work, deferred
126
+ # until the transaction that wrote the rows ends.
127
+ %r{active_record/transactions\.rb:\d+:in '[^']*#committed!'},
128
+ # Locking a row re-reads it, once per row the caller is about to write.
129
+ %r{active_record/locking/pessimistic\.rb:\d+:in '[^']*#lock!'},
130
+ # A product payload writes each nested variant and media row through its
131
+ # own workflow — SKU probe, option lookup, blob resolution — once per row.
132
+ %r{/spree/products/nested_attributes\.rb:\d+:in '[^']*#(write_variant|apply_media)'},
133
+ # A settlement is swept per currency, and each sweep writes its own payout.
134
+ %r{/spree/seller_payouts/sweep\.rb:\d+:in '[^']*#collect_transfers'},
135
+ # A split checkout re-sums and then places each seller's order it just
136
+ # wrote, one workflow run per order.
137
+ %r{/spree/carts/split_by_seller\.rb:\d+:in '[^']*#resum_totals'},
138
+ %r{/spree/orders/complete\.rb:\d+:in '[^']*#place_sibling'},
139
+ # Each requested line is priced, stock-checked, saved and taxed as it
140
+ # is written.
141
+ %r{/spree/carts/upsert_items\.rb:\d+:in '[^']*#apply_item'}
142
+ ]
143
+
144
+ # A purchase's billing and shipping addresses, and a transfer's two
145
+ # warehouses, are two links into one table read through the same code,
146
+ # so one record reads as a repeat. Lists still load each link in one
147
+ # query for the whole page.
148
+ Prosopite.ignore_queries = [
149
+ /FROM [`"]?(spree_addresses|spree_stock_locations)[`"]? WHERE (.+ AND )?[`"]?\1[`"]?\.[`"]?id[`"]? (=|IN)/
150
+ ]
151
+
152
+ # Prosopite fingerprints through pg_query, which rejects SQLite's `?`
153
+ # binds, so SQLite borrows the text normalizer written for MySQL. That
154
+ # normalizer blanks anything double-quoted, which is a string literal in
155
+ # MySQL but an identifier in SQLite — left alone it erases every table
156
+ # name, collapsing unrelated queries into one fingerprint and reporting
157
+ # a single record's distinct associations as an N+1. Re-quoting
158
+ # identifiers as backticks first keeps table names in the fingerprint.
159
+ Prosopite.singleton_class.prepend(Module.new do
160
+ def fingerprint(query)
161
+ return super unless ActiveRecord::Base.connection_db_config.adapter == 'sqlite3'
162
+
163
+ mysql_fingerprint(query.gsub(/"([^"]*)"/) { "`#{Regexp.last_match(1)}`" })
164
+ end
165
+ end)
166
+ end
80
167
  end
@@ -107,7 +107,11 @@ module Spree
107
107
  end
108
108
 
109
109
  def completion_line_items
110
- @completion_line_items ||= @cart.line_items.includes(variant: :product).to_a
110
+ # Everything the discontinuation and stock checks read, for every line
111
+ # at once.
112
+ @completion_line_items ||= @cart.line_items.includes(
113
+ variant: [{ product: %i[store product_publications] }, { stock_levels: %i[stock_location active_stock_reservations] }]
114
+ ).to_a
111
115
  end
112
116
 
113
117
  def delivery_step_required?
@@ -125,7 +129,13 @@ module Spree
125
129
  # What checkout has to collect, which is the whole total unless an
126
130
  # arrangement collects only part of it up front.
127
131
  def payment_satisfied?
128
- @cart.payments.valid.sum(:amount) >= @cart.amount_due_at_checkout
132
+ paid = if @cart.read_association_in_memory?(:payments)
133
+ @cart.payments.reject(&:has_invalid_status?).sum(BigDecimal('0'), &:amount)
134
+ else
135
+ @cart.payments.valid.sum(:amount)
136
+ end
137
+
138
+ paid >= @cart.amount_due_at_checkout
129
139
  end
130
140
 
131
141
  # Advisory rather than completion-only: the buyer has to be told the
@@ -186,6 +186,11 @@ module Spree
186
186
  # handler sees a nested payload and the variants endpoint alike
187
187
  variant_create_workflow: 'Spree::Variants::Create',
188
188
  variant_update_workflow: 'Spree::Variants::Update',
189
+
190
+ # digital assets — the operator's and the seller's endpoints and the
191
+ # nested list on a product write all run through these
192
+ digital_asset_create_workflow: 'Spree::DigitalAssets::Create',
193
+ digital_asset_update_workflow: 'Spree::DigitalAssets::Update',
189
194
  product_archive_workflow: 'Spree::Products::Archive',
190
195
  product_draft_workflow: 'Spree::Products::Draft',
191
196
 
@@ -506,6 +506,7 @@ module Spree
506
506
  Spree::Address,
507
507
  Spree::Claim,
508
508
  Spree::Collection,
509
+ Spree::Company,
509
510
  Spree::CreditCard,
510
511
  Spree::Exchange,
511
512
  Spree::GiftCard,
@@ -521,6 +522,7 @@ module Spree
521
522
  Spree::Product,
522
523
  Spree::ProductType,
523
524
  Spree::Promotion,
525
+ Spree::PurchaseOrder,
524
526
  Spree::Refund,
525
527
  Spree::Return,
526
528
  Spree::Fulfillment,
@@ -165,7 +165,7 @@ module Spree
165
165
  [Spree::Store, Spree::PaymentMethod, Spree::Gateway,
166
166
  Spree::DeliveryZone, Spree::DeliveryZoneMember,
167
167
  Spree::StockLocation, Spree::DeliveryProfile,
168
- Spree::Market, Spree::TaxCategory, Spree::TaxRate, Spree::AllowedOrigin,
168
+ Spree::Market, Spree::TaxCategory, Spree::TaxRate,
169
169
  Spree::RefundReason, Spree::ReturnReason, Spree::ClaimReason,
170
170
  Spree::OrderCancellationReason, Spree::TaxExemptionReason, Spree::Channel,
171
171
  Spree::OrderRoutingRule, Spree::CustomFieldDefinition, Spree::Policy]
@@ -201,7 +201,12 @@ module Spree
201
201
  })
202
202
  catalog.register_scope(:integrations, group: :settings, resources: -> { [Spree::Integration] })
203
203
 
204
- catalog.register_scope(:api_keys, group: :access, resources: -> { [Spree::ApiKey] })
204
+ # The CORS allowlist is a credential as much as a key is: an origin on
205
+ # it may call the cookie-authenticated refresh endpoint from a browser
206
+ # and read back any signed-in admin's access token. So it sits with
207
+ # API keys rather than settings, under a scope a delegated OAuth token
208
+ # never holds.
209
+ catalog.register_scope(:api_keys, group: :access, resources: -> { [Spree::ApiKey, Spree::AllowedOrigin] })
205
210
  # Connecting an AI agent hands a third party a standing credential to
206
211
  # the store, so it is its own permission rather than part of settings
207
212
  # — a merchandiser who may edit a delivery zone is not thereby someone
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '6.0.0.rc4'.freeze
2
+ VERSION = '6.0.0.rc6'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
@@ -541,7 +541,7 @@ module Spree
541
541
  # and tax category per row and throw all of it away.
542
542
  hydrate: lambda { |store, ids, _params|
543
543
  currency = Spree::Current.currency || store.default_currency
544
- products = store.products.with_deleted.includes(:primary_media).where(id: ids)
544
+ products = store.products.with_deleted.includes(:primary_media, default_variant: :prices).where(id: ids)
545
545
  products.to_h do |product|
546
546
  amount = product.default_variant&.amount_in(currency)
547
547
  [product.id, {
@@ -9,6 +9,10 @@ FactoryBot.define do
9
9
  kind { Spree::DataRequest::ERASURE }
10
10
  end
11
11
 
12
+ trait :awaiting_confirmation do
13
+ status { 'awaiting_confirmation' }
14
+ end
15
+
12
16
  trait :completed do
13
17
  status { 'completed' }
14
18
  completed_at { Time.current }
@@ -15,8 +15,11 @@ FactoryBot.define do
15
15
  end
16
16
  after(:create) do |product|
17
17
  if product.store&.default_channel && product.product_publications.empty?
18
- Spree::ProductPublication.create!(
19
- product: product,
18
+ # Built through the association, not Spree::ProductPublication.create!:
19
+ # the `empty?` above loads and caches the collection, so a row written
20
+ # around it leaves the product holding an association that reads empty
21
+ # while the table has the row.
22
+ product.product_publications.create!(
20
23
  channel: product.store.default_channel,
21
24
  published_at: product.available_on,
22
25
  unpublished_at: product.discontinue_on
@@ -69,8 +72,11 @@ FactoryBot.define do
69
72
  # keep passing. Production callers must publish explicitly via the
70
73
  # Admin SDK / Dashboard create form.
71
74
  if product.store&.default_channel && product.product_publications.empty?
72
- Spree::ProductPublication.create!(
73
- product: product,
75
+ # Built through the association, not Spree::ProductPublication.create!:
76
+ # the `empty?` above loads and caches the collection, so a row written
77
+ # around it leaves the product holding an association that reads empty
78
+ # while the table has the row.
79
+ product.product_publications.create!(
74
80
  channel: product.store.default_channel,
75
81
  published_at: product.available_on,
76
82
  unpublished_at: product.discontinue_on
@@ -1,6 +1,9 @@
1
1
  FactoryBot.define do
2
2
  factory :store, class: Spree::Store do
3
- sequence(:code) { |i| "spree_#{i}" }
3
+ # The suite-wide default store outlives the process that created it, while
4
+ # a sequence restarts with every process, so the code also steps past
5
+ # whatever is already in the database.
6
+ sequence(:code) { |i| "spree_#{i + Spree::Store.unscoped.count}" }
4
7
  name { 'Spree Test Store' }
5
8
  url { 'www.example.com' }
6
9
  mail_from_address { 'no-reply@example.com' }
@@ -819,3 +819,13 @@ steps:
819
819
  Run AFTER the steps that create or move attachments (media, categories,
820
820
  returns), so their files are covered. Idempotent — files that already
821
821
  have a store are skipped.
822
+
823
+ - id: backfill_tagging_tenants
824
+ name: "Stamp the store on taggings written without one"
825
+ task: "spree:upgrade:backfill_tagging_tenants"
826
+ notes: |
827
+ Tags added through the bulk endpoints carried no store on their
828
+ taggings, and the admin tag list filters by it, so those tags were
829
+ missing from the list. This stamps the store of the record on every
830
+ tagging without one, for each taggable that keeps its taggings per
831
+ store. Idempotent — taggings that already carry a store are skipped.
@@ -0,0 +1,35 @@
1
+ namespace :spree do
2
+ namespace :upgrade do
3
+ desc <<~DESC
4
+ Stamps the store on taggings written without one, for every taggable
5
+ that keeps its taggings per store. Tags added through the bulk endpoints
6
+ before Spree 6.0 carried no store, and the admin tag list filters by it,
7
+ so those tags were missing from the list. Idempotent — taggings that
8
+ already carry a store are skipped.
9
+ DESC
10
+ task backfill_tagging_tenants: :environment do
11
+ total = 0
12
+
13
+ Spree.taggable_types.map(&:constantize).each do |taggable|
14
+ column = taggable.try(:tenant_column)
15
+ next if column.blank?
16
+
17
+ count = 0
18
+ ActsAsTaggableOn::Tagging.where(taggable_type: taggable.polymorphic_name, tenant: nil).in_batches do |taggings|
19
+ tenants = taggable.unscoped.where(id: taggings.select(:taggable_id)).pluck(:id, column).to_h
20
+
21
+ tenants.compact.group_by { |_id, tenant| tenant }.each do |tenant, pairs|
22
+ count += taggings.where(taggable_id: pairs.map(&:first)).update_all(tenant: tenant.to_s)
23
+ end
24
+ end
25
+
26
+ next if count.zero?
27
+
28
+ total += count
29
+ puts " #{taggable.name}: #{count} tagging(s) stamped with the #{column}."
30
+ end
31
+
32
+ puts total.zero? ? ' Nothing to backfill.' : " Backfilled #{total} tagging(s)."
33
+ end
34
+ end
35
+ end