spree_core 5.6.0.rc2 → 5.6.0.rc4

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/app/helpers/spree/rich_text_helper.rb +37 -0
  3. data/app/jobs/spree/imports/process_group_job.rb +46 -0
  4. data/app/models/concerns/spree/publishable.rb +29 -1
  5. data/app/models/concerns/spree/webhook_payload_redaction.rb +88 -0
  6. data/app/models/spree/address.rb +7 -0
  7. data/app/models/spree/api_key.rb +22 -0
  8. data/app/models/spree/calculator/default_tax.rb +31 -2
  9. data/app/models/spree/channel.rb +20 -2
  10. data/app/models/spree/line_item.rb +23 -0
  11. data/app/models/spree/order.rb +10 -0
  12. data/app/models/spree/shipment.rb +9 -0
  13. data/app/models/spree/tax_rate.rb +7 -0
  14. data/app/models/spree/webhook_delivery.rb +22 -0
  15. data/app/presenters/spree/data_feeds/google_presenter.rb +28 -1
  16. data/app/services/spree/addresses/update.rb +32 -25
  17. data/app/services/spree/sample_data/loader.rb +7 -0
  18. data/app/services/spree/seeds/all.rb +2 -0
  19. data/app/services/spree/seeds/api_keys.rb +8 -4
  20. data/app/services/spree/seeds/channels.rb +25 -0
  21. data/app/services/spree/seeds/customer_groups.rb +18 -0
  22. data/config/locales/en.yml +4 -0
  23. data/db/migrate/20260719000001_add_channel_id_to_spree_api_keys.rb +5 -0
  24. data/db/sample_data/channels.rb +12 -1
  25. data/db/sample_data/wholesale.rb +59 -0
  26. data/lib/generators/spree/dummy/templates/rails/database.yml +1 -0
  27. data/lib/spree/core/version.rb +1 -1
  28. data/lib/spree/events.rb +31 -7
  29. data/lib/spree/permitted_attributes.rb +1 -1
  30. data/lib/spree/testing_support/lifecycle_events.rb +12 -0
  31. metadata +10 -5
  32. data/app/services/spree/products/prepare_nested_attributes.rb +0 -265
@@ -7,7 +7,16 @@ module Spree
7
7
  attr_accessor :country
8
8
 
9
9
  def call(address:, address_params:, **opts)
10
- order = opts[:order]
10
+ ApplicationRecord.transaction do
11
+ perform(address: address, address_params: address_params, **opts)
12
+ end
13
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotDestroyed => e
14
+ failure(e.record)
15
+ end
16
+
17
+ private
18
+
19
+ def perform(address:, address_params:, **opts)
11
20
  default_billing = address_params.key?(:is_default_billing) ? address_params.delete(:is_default_billing) : opts.fetch(:default_billing, false)
12
21
  default_shipping = address_params.key?(:is_default_shipping) ? address_params.delete(:is_default_shipping) : opts.fetch(:default_shipping, false)
13
22
  address_changes_except = opts.fetch(:address_changes_except, [])
@@ -35,24 +44,23 @@ module Spree
35
44
  return success(address) unless address_changed
36
45
 
37
46
  if address.editable?
38
- if address.update(address_params)
39
- if address.user.present?
40
- assign_to_user_as_default(
41
- user: address.user,
42
- address_id: address.id,
43
- default_billing: default_billing,
44
- default_shipping: default_shipping
45
- )
46
- end
47
-
48
- order.update(state: 'address') if order.present?
49
-
50
- success(address)
51
- else
52
- failure(address)
47
+ address.update!(address_params)
48
+
49
+ if address.user.present?
50
+ assign_to_user_as_default(
51
+ user: address.user,
52
+ address_id: address.id,
53
+ default_billing: default_billing,
54
+ default_shipping: default_shipping
55
+ )
53
56
  end
57
+
58
+ reassign_incomplete_orders(address.id, address)
59
+
60
+ success(address)
54
61
  elsif new_address(address_params).valid?
55
- address.destroy
62
+ old_address_id = address.id
63
+ address.destroy!
56
64
 
57
65
  if new_address.user.present?
58
66
  default_billing = address.user_default_billing? || default_billing
@@ -66,12 +74,7 @@ module Spree
66
74
  )
67
75
  end
68
76
 
69
- if order.present?
70
- order.ship_address = new_address if order.ship_address_id == address.id
71
- order.bill_address = new_address if order.bill_address_id == address.id
72
- order.state = 'address'
73
- order.save
74
- end
77
+ reassign_incomplete_orders(old_address_id, new_address)
75
78
 
76
79
  success(new_address)
77
80
  else
@@ -79,8 +82,6 @@ module Spree
79
82
  end
80
83
  end
81
84
 
82
- private
83
-
84
85
  def prepare_address_params!(address, address_params)
85
86
  address_params[:user_id] = address&.user_id
86
87
  address_params[:country_id] ||= address.country_id
@@ -88,6 +89,12 @@ module Spree
88
89
  address_params.transform_values!(&:presence)
89
90
  end
90
91
 
92
+ def reassign_incomplete_orders(old_address_id, new_address)
93
+ orders = Spree::Order.incomplete.where(user_id: new_address.user_id)
94
+ orders.where(ship_address_id: old_address_id).update_all(ship_address_id: new_address.id, state: 'address', updated_at: Time.current)
95
+ orders.where(bill_address_id: old_address_id).update_all(bill_address_id: new_address.id, state: 'address', updated_at: Time.current)
96
+ end
97
+
91
98
  def defaults_changed?(address, default_billing, default_shipping)
92
99
  user = address.user
93
100
 
@@ -39,6 +39,9 @@ module Spree
39
39
  puts 'Loading sample customers...'
40
40
  load_customers
41
41
 
42
+ puts 'Loading wholesale demo data...'
43
+ load_ruby_file('wholesale')
44
+
42
45
  puts 'Loading sample orders...'
43
46
  load_ruby_file('orders')
44
47
 
@@ -76,9 +79,13 @@ module Spree
76
79
  Spree::SampleData::ImportRunner.call(csv_path: csv_path, import_class: Spree::Imports::Products)
77
80
  end
78
81
 
82
+ # Publishes the catalog to both surfaces: the public default channel and
83
+ # the gated wholesale channel (catalog parity; wholesale differentiation
84
+ # comes from the price list in +wholesale.rb+ and the channel's gate).
79
85
  def publish_sample_products
80
86
  store = Spree::Store.default
81
87
  store.default_channel.add_products(store.product_ids)
88
+ store.channels.find_by(code: Spree::Seeds::Channels::WHOLESALE_CODE)&.add_products(store.product_ids)
82
89
  end
83
90
 
84
91
  def load_categories
@@ -23,11 +23,13 @@ module Spree
23
23
 
24
24
  # store & stock location
25
25
  Stores.call
26
+ Channels.call
26
27
  StockLocations.call
27
28
  AdminUser.call
28
29
 
29
30
  # add store resources
30
31
  PaymentMethods.call
32
+ CustomerGroups.call
31
33
  ApiKeys.call
32
34
  AllowedOrigins.call
33
35
  end
@@ -4,11 +4,15 @@ module Spree
4
4
  prepend Spree::ServiceModule::Base
5
5
 
6
6
  def call
7
- store = Spree::Store.default
8
- return unless store&.persisted?
7
+ Spree::Store.find_each do |store|
8
+ unless store.api_keys.active.publishable.where(channel_id: nil).exists?
9
+ store.api_keys.create!(name: 'Default', key_type: 'publishable')
10
+ end
9
11
 
10
- unless store.api_keys.active.publishable.exists?
11
- store.api_keys.create!(name: 'Default', key_type: 'publishable')
12
+ wholesale = store.channels.find_by(code: Channels::WHOLESALE_CODE)
13
+ if wholesale && !store.api_keys.active.publishable.where(channel: wholesale).exists?
14
+ store.api_keys.create!(name: 'Storefront (Wholesale)', key_type: 'publishable', channel: wholesale)
15
+ end
12
16
  end
13
17
  end
14
18
  end
@@ -0,0 +1,25 @@
1
+ module Spree
2
+ module Seeds
3
+ # Seeds the gated Wholesale channel every store ships with by default —
4
+ # the reference "blended DTC + B2B" setup: the default channel stays
5
+ # public while wholesale requires login and forbids guest checkout.
6
+ # See docs/plans/5.6-store-channel-context-and-key-binding.md.
7
+ class Channels
8
+ prepend Spree::ServiceModule::Base
9
+
10
+ WHOLESALE_CODE = 'wholesale'.freeze
11
+
12
+ def call
13
+ Spree::Store.find_each do |store|
14
+ store.ensure_default_channel
15
+
16
+ store.channels.find_or_create_by!(code: WHOLESALE_CODE) do |channel|
17
+ channel.name = 'Wholesale'
18
+ channel.preferred_storefront_access = 'login_required'
19
+ channel.preferred_guest_checkout = false
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,18 @@
1
+ module Spree
2
+ module Seeds
3
+ # The Wholesale group is the B2B approval primitive until 6.1 Company
4
+ # accounts land: membership marks an approved wholesale buyer and is what
5
+ # wholesale price lists key off.
6
+ class CustomerGroups
7
+ prepend Spree::ServiceModule::Base
8
+
9
+ WHOLESALE_NAME = 'Wholesale'.freeze
10
+
11
+ def call
12
+ Spree::Store.find_each do |store|
13
+ store.customer_groups.find_or_create_by!(name: WHOLESALE_NAME)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -691,6 +691,9 @@ en:
691
691
  analytics: Analytics
692
692
  and: and
693
693
  api_key: API Key
694
+ api_key_channel_immutable: cannot be changed after the key is created; create a new key bound to the channel you need and revoke this one
695
+ api_key_channel_must_belong_to_store: must belong to the same store as the key
696
+ api_key_channel_publishable_only: can only be set on publishable keys
694
697
  api_key_no_current_key: No API key authenticated this request
695
698
  api_key_scopes_immutable: cannot be changed after the key is created; create a new key with the scopes you need and revoke this one
696
699
  api_keys: API Keys
@@ -913,6 +916,7 @@ en:
913
916
  error_user_does_not_have_any_store_credits: User does not have any Store Credits available
914
917
  errors:
915
918
  messages:
919
+ cannot_delete_channel_with_active_api_keys: Channel cannot be deleted while active API keys are bound to it. Revoke those keys first.
916
920
  cannot_delete_default_channel: Default channel cannot be deleted. Promote another channel to default first.
917
921
  channel_store_mismatch: must belong to the same store
918
922
  invalid_order_routing_rule: is not a registered order routing rule
@@ -0,0 +1,5 @@
1
+ class AddChannelIdToSpreeApiKeys < ActiveRecord::Migration[7.2]
2
+ def change
3
+ add_reference :spree_api_keys, :channel, null: true, if_not_exists: true
4
+ end
5
+ end
@@ -7,6 +7,17 @@ store.channels.find_or_create_by!(code: 'pos') do |channel|
7
7
  channel.name = 'Point of Sale'
8
8
  end
9
9
 
10
- store.channels.find_or_create_by!(code: 'wholesale') do |channel|
10
+ # Normally seeded by Spree::Seeds::Channels; created here too so installs
11
+ # seeded before the gated-wholesale seed existed still get the channel, and
12
+ # upgraded with the gated posture when it's missing.
13
+ wholesale = store.channels.find_or_create_by!(code: 'wholesale') do |channel|
11
14
  channel.name = 'Wholesale'
15
+ channel.preferred_storefront_access = 'login_required'
16
+ channel.preferred_guest_checkout = false
17
+ end
18
+
19
+ if wholesale.preferred_storefront_access.blank?
20
+ wholesale.preferred_storefront_access = 'login_required'
21
+ wholesale.preferred_guest_checkout = false
22
+ wholesale.save!
12
23
  end
@@ -0,0 +1,59 @@
1
+ # Wholesale demo setup on top of the gated channel seeded by
2
+ # Spree::Seeds::Channels: an approved demo buyer and a customer-group price
3
+ # list, so a fresh install can walk the whole B2B portal story — sign in as
4
+ # the buyer, see wholesale prices, check out on the wholesale channel.
5
+ store = Spree::Store.default
6
+
7
+ wholesale_group = store.customer_groups.find_or_create_by!(name: Spree::Seeds::CustomerGroups::WHOLESALE_NAME)
8
+
9
+ buyer = Spree.user_class.find_by(email: 'wholesale@example.com')
10
+ if buyer.nil?
11
+ buyer = Spree.user_class.new(
12
+ email: 'wholesale@example.com',
13
+ password: 'spree123',
14
+ password_confirmation: 'spree123',
15
+ first_name: 'Wanda',
16
+ last_name: 'Wholesale'
17
+ )
18
+ buyer.save!
19
+ end
20
+ wholesale_group.add_customers([buyer.id])
21
+
22
+ price_list = store.price_lists.find_or_create_by!(name: 'Wholesale') do |list|
23
+ list.description = 'Wholesale pricing for approved B2B buyers (40% off retail)'
24
+ end
25
+
26
+ unless price_list.price_rules.any? { |rule| rule.is_a?(Spree::PriceRules::CustomerGroupRule) }
27
+ # price_list must be assigned before the preference — the customer-group-id
28
+ # normalizer resolves groups through rule.store (delegated to price_list).
29
+ rule = Spree::PriceRules::CustomerGroupRule.new(price_list: price_list)
30
+ rule.preferred_customer_group_ids = [wholesale_group.id]
31
+ rule.save!
32
+ end
33
+
34
+ if price_list.prices.none?
35
+ currency = store.default_currency
36
+ now = Time.current
37
+ rows = Spree::Variant.joins(:product)
38
+ .where(spree_products: { store_id: store.id })
39
+ .joins(:prices)
40
+ .where(spree_prices: { currency: currency, price_list_id: nil })
41
+ .pluck(:id, Spree::Price.arel_table[:amount])
42
+ .to_h # one row per variant — last base price wins
43
+ .filter_map do |variant_id, amount|
44
+ next if amount.blank?
45
+
46
+ {
47
+ variant_id: variant_id,
48
+ price_list_id: price_list.id,
49
+ amount: (amount * 0.6).round(2),
50
+ currency: currency,
51
+ created_at: now,
52
+ updated_at: now
53
+ }
54
+ end
55
+
56
+ Spree::Price.insert_all(rows) if rows.any?
57
+ end
58
+
59
+ price_list.activate if price_list.can_activate?
@@ -75,6 +75,7 @@ development:
75
75
  test:
76
76
  adapter: sqlite3
77
77
  database: db/spree_test<%%= ENV['TEST_ENV_NUMBER'] %>.sqlite3
78
+ timeout: 10000
78
79
  production:
79
80
  adapter: sqlite3
80
81
  database: db/spree_production.sqlite3
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '5.6.0.rc2'.freeze
2
+ VERSION = '5.6.0.rc4'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
data/lib/spree/events.rb CHANGED
@@ -187,7 +187,7 @@ module Spree
187
187
  #
188
188
  # @return [Boolean]
189
189
  def enabled?
190
- !@globally_disabled && !Thread.current[:spree_events_disabled]
190
+ !@globally_disabled && !RequestStore.store[:spree_events_disabled]
191
191
  end
192
192
 
193
193
  # Temporarily disable events within a block
@@ -202,11 +202,35 @@ module Spree
202
202
  # end
203
203
  #
204
204
  def disable
205
- previous = Thread.current[:spree_events_disabled]
206
- Thread.current[:spree_events_disabled] = true
205
+ previous = RequestStore.store[:spree_events_disabled]
206
+ RequestStore.store[:spree_events_disabled] = true
207
207
  yield
208
208
  ensure
209
- Thread.current[:spree_events_disabled] = previous
209
+ RequestStore.store[:spree_events_disabled] = previous
210
+ end
211
+
212
+ # Check if automatic lifecycle events (*.created/updated/deleted) are enabled
213
+ #
214
+ # @return [Boolean]
215
+ def lifecycle_enabled?
216
+ enabled? && !RequestStore.store[:spree_lifecycle_events_disabled]
217
+ end
218
+
219
+ # Temporarily disable automatic lifecycle events within a block
220
+ #
221
+ # Unlike {disable}, explicitly published events (`publish_event`) still
222
+ # flow — only the per-record *.created/updated/deleted callbacks are
223
+ # suppressed. Used by bulk operations that emit their own coarser events
224
+ # afterwards (e.g. one product event per imported group).
225
+ #
226
+ # @yield Block during which lifecycle events are disabled
227
+ # @return [Object] Return value of the block
228
+ def disable_lifecycle
229
+ previous = RequestStore.store[:spree_lifecycle_events_disabled]
230
+ RequestStore.store[:spree_lifecycle_events_disabled] = true
231
+ yield
232
+ ensure
233
+ RequestStore.store[:spree_lifecycle_events_disabled] = previous
210
234
  end
211
235
 
212
236
  # Globally disable events
@@ -233,13 +257,13 @@ module Spree
233
257
  #
234
258
  def enable
235
259
  previous_global = @globally_disabled
236
- previous_thread = Thread.current[:spree_events_disabled]
260
+ previous_thread = RequestStore.store[:spree_events_disabled]
237
261
  @globally_disabled = false
238
- Thread.current[:spree_events_disabled] = false
262
+ RequestStore.store[:spree_events_disabled] = false
239
263
  yield
240
264
  ensure
241
265
  @globally_disabled = previous_global
242
- Thread.current[:spree_events_disabled] = previous_thread
266
+ RequestStore.store[:spree_events_disabled] = previous_thread
243
267
  end
244
268
 
245
269
  # Globally enable events
@@ -91,7 +91,7 @@ module Spree
91
91
 
92
92
  @@allowed_origin_attributes = [:origin]
93
93
 
94
- @@api_key_attributes = [:name, :key_type, { scopes: [] }]
94
+ @@api_key_attributes = [:name, :key_type, :channel_id, { scopes: [] }]
95
95
 
96
96
  @@asset_attributes = [:type, :viewable_id, :viewable_type, :attachment, :alt, :position, :url, :signed_id]
97
97
 
@@ -21,6 +21,18 @@ shared_examples_for 'lifecycle events' do |factory: nil, event_prefix: nil|
21
21
  expect(record).to receive(:publish_event).with("#{lifecycle_event_prefix}.updated")
22
22
  allow(record).to receive(:publish_event).with(anything)
23
23
 
24
+ # update_attribute, not update!: Spree::Shipment#update! shadows the
25
+ # Active Record method with the legacy state-recalculation API.
26
+ Timecop.travel(1.minute.from_now) do
27
+ record.update_attribute(:updated_at, Time.current)
28
+ end
29
+ end
30
+
31
+ it 'does not publish updated event on a bare touch' do
32
+ record = create(lifecycle_factory)
33
+ expect(record).not_to receive(:publish_event).with("#{lifecycle_event_prefix}.updated")
34
+ allow(record).to receive(:publish_event).with(anything)
35
+
24
36
  record.touch
25
37
  end
26
38
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_core
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.6.0.rc2
4
+ version: 5.6.0.rc4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Schofield
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2026-07-17 00:00:00.000000000 Z
13
+ date: 2026-07-20 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: i18n-tasks
@@ -849,6 +849,7 @@ files:
849
849
  - app/helpers/spree/locale_helper.rb
850
850
  - app/helpers/spree/localized_names_helper.rb
851
851
  - app/helpers/spree/products_helper.rb
852
+ - app/helpers/spree/rich_text_helper.rb
852
853
  - app/helpers/spree/shipment_helper.rb
853
854
  - app/javascript/spree/core/controllers/address_autocomplete_controller.js
854
855
  - app/javascript/spree/core/controllers/address_form_controller.js
@@ -935,6 +936,7 @@ files:
935
936
  - app/models/concerns/spree/user_reporting.rb
936
937
  - app/models/concerns/spree/user_roles.rb
937
938
  - app/models/concerns/spree/vat_price_calculation.rb
939
+ - app/models/concerns/spree/webhook_payload_redaction.rb
938
940
  - app/models/spree/ability.rb
939
941
  - app/models/spree/address.rb
940
942
  - app/models/spree/adjustable/adjuster/base.rb
@@ -1342,14 +1344,15 @@ files:
1342
1344
  - app/services/spree/prices/bulk_upsert.rb
1343
1345
  - app/services/spree/products/auto_match_taxons.rb
1344
1346
  - app/services/spree/products/duplicator.rb
1345
- - app/services/spree/products/prepare_nested_attributes.rb
1346
1347
  - app/services/spree/sample_data/import_runner.rb
1347
1348
  - app/services/spree/sample_data/loader.rb
1348
1349
  - app/services/spree/seeds/admin_user.rb
1349
1350
  - app/services/spree/seeds/all.rb
1350
1351
  - app/services/spree/seeds/allowed_origins.rb
1351
1352
  - app/services/spree/seeds/api_keys.rb
1353
+ - app/services/spree/seeds/channels.rb
1352
1354
  - app/services/spree/seeds/countries.rb
1355
+ - app/services/spree/seeds/customer_groups.rb
1353
1356
  - app/services/spree/seeds/default_reimbursement_types.rb
1354
1357
  - app/services/spree/seeds/digital_delivery.rb
1355
1358
  - app/services/spree/seeds/payment_methods.rb
@@ -1600,6 +1603,7 @@ files:
1600
1603
  - db/migrate/20260707000001_add_fingerprint_to_spree_credit_cards.rb
1601
1604
  - db/migrate/20260710000001_add_import_id_status_index_to_spree_import_rows.rb
1602
1605
  - db/migrate/20260711000001_add_preferences_to_spree_exports.rb
1606
+ - db/migrate/20260719000001_add_channel_id_to_spree_api_keys.rb
1603
1607
  - db/sample_data/channels.rb
1604
1608
  - db/sample_data/customers.csv
1605
1609
  - db/sample_data/markets.rb
@@ -1611,6 +1615,7 @@ files:
1611
1615
  - db/sample_data/products.csv
1612
1616
  - db/sample_data/promotions.rb
1613
1617
  - db/sample_data/shipping_methods.rb
1618
+ - db/sample_data/wholesale.rb
1614
1619
  - db/seeds.rb
1615
1620
  - lib/friendly_id/history_decorator.rb
1616
1621
  - lib/friendly_id/paranoia.rb
@@ -1860,9 +1865,9 @@ licenses:
1860
1865
  - BSD-3-Clause
1861
1866
  metadata:
1862
1867
  bug_tracker_uri: https://github.com/spree/spree/issues
1863
- changelog_uri: https://github.com/spree/spree/releases/tag/v5.6.0.rc2
1868
+ changelog_uri: https://github.com/spree/spree/releases/tag/v5.6.0.rc4
1864
1869
  documentation_uri: https://docs.spreecommerce.org/
1865
- source_code_uri: https://github.com/spree/spree/tree/v5.6.0.rc2
1870
+ source_code_uri: https://github.com/spree/spree/tree/v5.6.0.rc4
1866
1871
  post_install_message:
1867
1872
  rdoc_options: []
1868
1873
  require_paths: