spree_core 5.6.0.rc3 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45a9c3a76133036f89b3eabe73c9e53cd57dfd6417ee32211f88335424b9da94
4
- data.tar.gz: f409d6453ba2a5a6fa9f66c3b26ffa8686c05723bcae99110cb7eede6d3fedb1
3
+ metadata.gz: 81bf3080a8d43c9bc6a6d76561c9fae751c9282a1e8b30dfb388ca6abe2511d7
4
+ data.tar.gz: 423cf415d52c180297db3e26b581ea4078cfcf0e7816fc3084d12034b3d74a87
5
5
  SHA512:
6
- metadata.gz: 596464be183b137236eb76e984a2b8d84091ea95e22a553d7ffb3ab0a5bc3055d380d1283946517ad20e3a8bbd3617d9a246ebbe2157923943e64b054a014aaa
7
- data.tar.gz: 58e3bb7aed929b0d17ac96510dee26475cdcfc645b49e491fa38625c958d5228ef81e5f9d703488dbe576167e2ef8c2078d993cce6a37feaad714f62c0b6b8c6
6
+ metadata.gz: 351598372ce3b7d938815929ac76d050a45679aad1a4734c07a5642f932b81e0e688e0903538e2446c30516545083d2c6d52963b82598650d6e998606aeb8341
7
+ data.tar.gz: 258324d4c6e23afbe5702782daf6e19811af60136b4f93bbd99d27824830ee7211426fde12f1330175132b4c82c281cfc6295ca0d7cd6f2cc400cce5e3f7e729
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ # Keeps single-use credentials out of the persisted webhook delivery log.
5
+ #
6
+ # Some events must carry a live credential to their subscriber — a storefront
7
+ # that owns its transactional emails needs the real password reset token to
8
+ # build the email. Delivering it over TLS to a merchant-configured endpoint is
9
+ # intended; keeping a readable copy in `spree_webhook_deliveries.payload` is
10
+ # not, because that column is queryable and is served back through the Admin
11
+ # API delivery log.
12
+ #
13
+ # Sensitive values are therefore replaced with {REDACTION_PLACEHOLDER} before
14
+ # the record is written, and re-attached in memory at send time so the
15
+ # outgoing request body is unchanged.
16
+ module WebhookPayloadRedaction
17
+ extend ActiveSupport::Concern
18
+
19
+ # Payload keys under `data` whose values are live credentials.
20
+ SENSITIVE_PAYLOAD_KEYS = %w[reset_token unsubscribe_token].freeze
21
+
22
+ REDACTION_PLACEHOLDER = '[REDACTED]'
23
+
24
+ # Splits a payload into the version safe to persist and the secrets held
25
+ # back from it.
26
+ #
27
+ # @param payload [Hash] the full event payload
28
+ # @return [Array(Hash, Hash)] redacted payload, and the extracted secrets
29
+ # keyed as they appeared under `data`
30
+ def self.split(payload)
31
+ secrets = {}
32
+
33
+ redacted = transform_data_hashes(payload) do |data|
34
+ data.to_h do |key, value|
35
+ if SENSITIVE_PAYLOAD_KEYS.include?(key.to_s) && value.present?
36
+ secrets[secret_key_for(key)] = value
37
+ [key, REDACTION_PLACEHOLDER]
38
+ else
39
+ [key, value]
40
+ end
41
+ end
42
+ end
43
+
44
+ secrets.empty? ? [payload, {}] : [redacted, secrets]
45
+ end
46
+
47
+ # Re-attaches previously extracted secrets to a redacted payload.
48
+ #
49
+ # @param payload [Hash] the redacted payload
50
+ # @param secrets [Hash] secrets returned by {split}
51
+ # @return [Hash] the payload as it should go over the wire
52
+ def self.merge(payload, secrets)
53
+ return payload if secrets.blank?
54
+
55
+ transform_data_hashes(payload) do |data|
56
+ data.to_h do |key, value|
57
+ secret = secrets[secret_key_for(key)]
58
+ [key, secret.presence || value]
59
+ end
60
+ end
61
+ end
62
+
63
+ # Applies +block+ to every `data` hash on the payload.
64
+ #
65
+ # Both key forms are visited rather than only the first match: a payload
66
+ # carrying `:data` *and* `'data'` would otherwise leave one of them
67
+ # unredacted.
68
+ def self.transform_data_hashes(payload)
69
+ return payload unless payload.is_a?(Hash)
70
+
71
+ [:data, 'data'].reduce(payload) do |result, data_key|
72
+ data = result[data_key]
73
+ next result unless data.is_a?(Hash)
74
+
75
+ result.merge(data_key => yield(data))
76
+ end
77
+ end
78
+ private_class_method :transform_data_hashes
79
+
80
+ # Secrets are keyed by name alone. They cross an ActiveJob serialization
81
+ # boundary, which coerces symbol keys to strings, so the key form at split
82
+ # time cannot be relied on to still match at merge time.
83
+ def self.secret_key_for(key)
84
+ key.to_s
85
+ end
86
+ private_class_method :secret_key_for
87
+ end
88
+ end
@@ -46,6 +46,11 @@ module Spree
46
46
  end
47
47
 
48
48
  belongs_to :store, class_name: 'Spree::Store'
49
+ # Optional single-channel binding for publishable keys. A bound key
50
+ # server-assigns the request's channel (see Api::V3::ChannelResolution),
51
+ # making channel identity unforgeable instead of client-asserted via the
52
+ # X-Spree-Channel header. See docs/plans/5.6-store-channel-context-and-key-binding.md.
53
+ belongs_to :channel, class_name: 'Spree::Channel', optional: true
49
54
  belongs_to :created_by, polymorphic: true, optional: true
50
55
  belongs_to :revoked_by, polymorphic: true, optional: true
51
56
 
@@ -64,6 +69,11 @@ module Spree
64
69
  # guard is at the model so every entry point (API, legacy admin, rake tasks)
65
70
  # is covered uniformly.
66
71
  validate :scopes_immutable, if: -> { secret? && persisted? && scopes_changed? }
72
+ validate :channel_binding_valid, if: -> { channel_id.present? }
73
+ # Like scopes, the binding is fixed for the life of the key — rebinding a
74
+ # live credential to a different channel would silently repoint every
75
+ # client holding it; mint a new key instead.
76
+ validate :channel_immutable, if: -> { persisted? && channel_id_changed? }
67
77
 
68
78
  before_validation :generate_token, on: :create
69
79
 
@@ -156,6 +166,18 @@ module Spree
156
166
  errors.add(:scopes, Spree.t(:api_key_scopes_immutable))
157
167
  end
158
168
 
169
+ def channel_binding_valid
170
+ if secret?
171
+ errors.add(:channel, Spree.t(:api_key_channel_publishable_only))
172
+ elsif channel && channel.store_id != store_id
173
+ errors.add(:channel, Spree.t(:api_key_channel_must_belong_to_store))
174
+ end
175
+ end
176
+
177
+ def channel_immutable
178
+ errors.add(:channel, Spree.t(:api_key_channel_immutable))
179
+ end
180
+
159
181
  # Generates the token on creation. For publishable keys, stores the raw token
160
182
  # in the +token+ column. For secret keys, computes an HMAC-SHA256 digest stored
161
183
  # in +token_digest+, saves the first 12 characters as +token_prefix+ for display,
@@ -22,6 +22,7 @@ module Spree
22
22
  has_many :order_routing_rules, class_name: 'Spree::OrderRoutingRule', dependent: :destroy
23
23
  has_many :publications, class_name: 'Spree::ProductPublication', dependent: :destroy
24
24
  has_many :products, through: :publications, class_name: 'Spree::Product'
25
+ has_many :api_keys, class_name: 'Spree::ApiKey', dependent: :nullify
25
26
 
26
27
  attribute :active, :boolean, default: true
27
28
 
@@ -40,6 +41,10 @@ module Spree
40
41
  # arrives at a single default without relying on DB constraints.
41
42
  before_save :demote_other_defaults, if: -> { default? && will_save_change_to_default? }
42
43
  before_destroy :ensure_not_default
44
+ # prepend: the +dependent: :nullify+ on +api_keys+ registers its own
45
+ # before_destroy at association-declaration time; without prepend it would
46
+ # clear every binding before this guard could see them.
47
+ before_destroy :ensure_no_active_bound_api_keys, prepend: true
43
48
  after_create :ensure_default_order_routing_rules
44
49
 
45
50
  scope :active, -> { where(active: true) }
@@ -115,19 +120,32 @@ module Spree
115
120
 
116
121
  # @return [Boolean]
117
122
  def can_be_deleted?
118
- !default?
123
+ !default? && !api_keys.active.exists?
119
124
  end
120
125
 
121
126
  private
122
127
 
123
128
  def ensure_not_default
124
- return if can_be_deleted?
129
+ return unless default?
125
130
  return if destroyed_by_association.present?
126
131
 
127
132
  errors.add(:base, Spree.t('errors.messages.cannot_delete_default_channel'))
128
133
  throw :abort
129
134
  end
130
135
 
136
+ # Fail closed: deleting a channel must not silently convert its bound
137
+ # publishable keys into unbound (store-wide) credentials via the
138
+ # +dependent: :nullify+ above. Active keys block deletion — revoke or
139
+ # rebind them first; revoked keys are historical and nullify freely.
140
+ # Store-cascade deletes skip the guard (the keys die with the store).
141
+ def ensure_no_active_bound_api_keys
142
+ return unless api_keys.active.exists?
143
+ return if destroyed_by_association.present?
144
+
145
+ errors.add(:base, Spree.t('errors.messages.cannot_delete_channel_with_active_api_keys'))
146
+ throw :abort
147
+ end
148
+
131
149
  def backfill_code_from_name
132
150
  self.code = name
133
151
  end
@@ -7,6 +7,11 @@ module Spree
7
7
  belongs_to :webhook_endpoint, class_name: 'Spree::WebhookEndpoint'
8
8
  delegate :url, to: :webhook_endpoint
9
9
 
10
+ # Credentials stripped from the persisted payload. Never written to the
11
+ # database — held in memory on a single delivery instance, assigned by
12
+ # WebhookDeliveryJob when it runs. See {Spree::WebhookPayloadRedaction}.
13
+ attr_accessor :payload_secrets
14
+
10
15
  validates :event_name, presence: true
11
16
  validates :payload, presence: true
12
17
 
@@ -64,6 +69,23 @@ module Spree
64
69
  webhook_endpoint.check_auto_disable! unless is_success
65
70
  end
66
71
 
72
+ # Payload as it should be sent over the wire.
73
+ #
74
+ # Re-attaches any credentials withheld from the persisted column. Once this
75
+ # record has been reloaded from the database those secrets are gone for
76
+ # good, so a redelivery sends the redacted placeholder — single-use tokens
77
+ # are not replayable anyway.
78
+ #
79
+ # This is deliberate: keeping a recoverable copy would put the credential
80
+ # back at rest, which is what redaction exists to prevent. If a delivery is
81
+ # lost (worker crash between creation and delivery), the customer requests
82
+ # a new reset, which mints a fresh token.
83
+ #
84
+ # @return [Hash]
85
+ def deliverable_payload
86
+ Spree::WebhookPayloadRedaction.merge(payload, payload_secrets)
87
+ end
88
+
67
89
  # Create a new delivery with the same payload and queue it.
68
90
  # Used to retry failed deliveries manually.
69
91
  #
@@ -3,6 +3,22 @@ require 'nokogiri'
3
3
  module Spree
4
4
  module DataFeeds
5
5
  class GooglePresenter < BasePresenter
6
+ # Optional Google Merchant Center product attributes sourced from
7
+ # metafields. See https://support.google.com/merchants/answer/7052112
8
+ OPTIONAL_ATTRIBUTES = %w[
9
+ brand gtin mpn identifier_exists condition adult multipack is_bundle
10
+ age_group color gender material pattern size size_type size_system
11
+ product_length product_width product_height product_weight
12
+ google_product_category product_type sale_price sale_price_effective_date
13
+ cost_of_goods_sold unit_pricing_measure unit_pricing_base_measure
14
+ shipping shipping_label shipping_weight shipping_length shipping_width
15
+ shipping_height ships_from_country transit_time_label max_handling_time
16
+ min_handling_time tax tax_category energy_efficiency_class
17
+ min_energy_efficiency_class max_energy_efficiency_class
18
+ gtin_source expiration_date custom_label_0 custom_label_1 custom_label_2
19
+ custom_label_3 custom_label_4 mobile_link additional_image_link
20
+ ].freeze
21
+
6
22
  # @return [String] RSS XML feed for Google Merchant Center
7
23
  def call
8
24
  builder = Nokogiri::XML::Builder.new do |xml|
@@ -59,10 +75,21 @@ module Spree
59
75
 
60
76
  def build_optional_attributes(xml, product)
61
77
  product.public_metafields.each do |metafield|
62
- xml['g'].send(metafield.metafield_definition.key.parameterize.underscore, metafield.value)
78
+ key = metafield.metafield_definition.key.parameterize.underscore
79
+ next unless OPTIONAL_ATTRIBUTES.include?(key)
80
+
81
+ append_g_element(xml, key, metafield.value)
63
82
  end
64
83
  end
65
84
 
85
+ def append_g_element(xml, name, value)
86
+ parent = xml.parent
87
+ node = Nokogiri::XML::Node.new(name, parent.document)
88
+ node.namespace = parent.namespace_scopes.find { |ns| ns.prefix == 'g' }
89
+ node.content = value
90
+ parent << node
91
+ end
92
+
66
93
  def format_title(product, variant)
67
94
  parts = [product.name]
68
95
  variant.option_values.each do |option_value|
@@ -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?
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '5.6.0.rc3'.freeze
2
+ VERSION = '5.6.0.rc4'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
@@ -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
 
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.rc3
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-19 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
@@ -936,6 +936,7 @@ files:
936
936
  - app/models/concerns/spree/user_reporting.rb
937
937
  - app/models/concerns/spree/user_roles.rb
938
938
  - app/models/concerns/spree/vat_price_calculation.rb
939
+ - app/models/concerns/spree/webhook_payload_redaction.rb
939
940
  - app/models/spree/ability.rb
940
941
  - app/models/spree/address.rb
941
942
  - app/models/spree/adjustable/adjuster/base.rb
@@ -1343,14 +1344,15 @@ files:
1343
1344
  - app/services/spree/prices/bulk_upsert.rb
1344
1345
  - app/services/spree/products/auto_match_taxons.rb
1345
1346
  - app/services/spree/products/duplicator.rb
1346
- - app/services/spree/products/prepare_nested_attributes.rb
1347
1347
  - app/services/spree/sample_data/import_runner.rb
1348
1348
  - app/services/spree/sample_data/loader.rb
1349
1349
  - app/services/spree/seeds/admin_user.rb
1350
1350
  - app/services/spree/seeds/all.rb
1351
1351
  - app/services/spree/seeds/allowed_origins.rb
1352
1352
  - app/services/spree/seeds/api_keys.rb
1353
+ - app/services/spree/seeds/channels.rb
1353
1354
  - app/services/spree/seeds/countries.rb
1355
+ - app/services/spree/seeds/customer_groups.rb
1354
1356
  - app/services/spree/seeds/default_reimbursement_types.rb
1355
1357
  - app/services/spree/seeds/digital_delivery.rb
1356
1358
  - app/services/spree/seeds/payment_methods.rb
@@ -1601,6 +1603,7 @@ files:
1601
1603
  - db/migrate/20260707000001_add_fingerprint_to_spree_credit_cards.rb
1602
1604
  - db/migrate/20260710000001_add_import_id_status_index_to_spree_import_rows.rb
1603
1605
  - db/migrate/20260711000001_add_preferences_to_spree_exports.rb
1606
+ - db/migrate/20260719000001_add_channel_id_to_spree_api_keys.rb
1604
1607
  - db/sample_data/channels.rb
1605
1608
  - db/sample_data/customers.csv
1606
1609
  - db/sample_data/markets.rb
@@ -1612,6 +1615,7 @@ files:
1612
1615
  - db/sample_data/products.csv
1613
1616
  - db/sample_data/promotions.rb
1614
1617
  - db/sample_data/shipping_methods.rb
1618
+ - db/sample_data/wholesale.rb
1615
1619
  - db/seeds.rb
1616
1620
  - lib/friendly_id/history_decorator.rb
1617
1621
  - lib/friendly_id/paranoia.rb
@@ -1861,9 +1865,9 @@ licenses:
1861
1865
  - BSD-3-Clause
1862
1866
  metadata:
1863
1867
  bug_tracker_uri: https://github.com/spree/spree/issues
1864
- changelog_uri: https://github.com/spree/spree/releases/tag/v5.6.0.rc3
1868
+ changelog_uri: https://github.com/spree/spree/releases/tag/v5.6.0.rc4
1865
1869
  documentation_uri: https://docs.spreecommerce.org/
1866
- source_code_uri: https://github.com/spree/spree/tree/v5.6.0.rc3
1870
+ source_code_uri: https://github.com/spree/spree/tree/v5.6.0.rc4
1867
1871
  post_install_message:
1868
1872
  rdoc_options: []
1869
1873
  require_paths:
@@ -1,265 +0,0 @@
1
- module Spree
2
- module Products
3
- # Prepares nested attributes for product updates, handling multi-store scenarios
4
- # and permissions.
5
- #
6
- # This service ensures that when editing a product in one store, taxon associations
7
- # from other stores are preserved. This prevents accidental data loss when a store
8
- # admin updates product categories in their store without affecting other stores.
9
- #
10
- # Variant removal is opt-in: only variants explicitly listed in the
11
- # `removed_variant_ids` param are marked for destruction (or collected for
12
- # discontinuation when they have completed orders). Variants merely absent from
13
- # `variants_attributes` are left untouched, so a partially rendered or broken
14
- # form can never silently mass-delete variants.
15
- #
16
- # @example
17
- # service = Spree::Products::PrepareNestedAttributes.new(
18
- # product,
19
- # current_store,
20
- # params,
21
- # current_ability
22
- # )
23
- # prepared_params = service.call
24
- #
25
- class PrepareNestedAttributes
26
- attr_reader :variants_to_discontinue
27
-
28
- def initialize(product, store, params, ability)
29
- @product = product
30
- @store = store
31
- @params = params
32
- @ability = ability
33
- @variants_to_discontinue = []
34
- end
35
-
36
- def call
37
- extract_removed_variant_ids
38
-
39
- if params[:variants_attributes]
40
- params[:variants_attributes].each do |key, variant_params|
41
- existing_variant = variant_params[:id].presence && @product.variants.find_by(id: variant_params[:id])
42
- # a re-submitted variant always wins over a removal request
43
- variants_to_remove.delete(variant_params[:id].to_s) if variant_params[:id].present?
44
-
45
- variant_params.delete(:price) # remove legacy price param
46
-
47
- if can_update_prices?
48
- backfill_price_ids!(variant_params, existing_variant)
49
-
50
- variant_params[:prices_attributes]&.each do |price_key, price_params|
51
- variant_params[:prices_attributes][price_key]['_destroy'] = '1' if price_params[:amount].blank?
52
- end
53
- else
54
- variant_params.delete(:prices_attributes)
55
- end
56
-
57
- variant_params[:option_value_variants_attributes] = update_option_value_variants(variant_params.delete(:options), existing_variant)
58
-
59
- variant_params.delete(:stock_items_attributes) unless can_update_stock_items?
60
-
61
- params[:variants_attributes].delete(key) if variant_params.blank?
62
- end
63
- params[:variants_attributes] = params[:variants_attributes].merge(removed_variants_attributes)
64
-
65
- params[:product_option_types_attributes] = product_option_types_params.merge(removed_product_option_types_attributes)
66
- elsif params[:master_attributes]
67
- params[:master_attributes].delete(:stock_items_attributes) unless can_update_stock_items?
68
-
69
- if can_update_prices?
70
- # If the master price is nil then mark it for destruction
71
- params.dig(:master_attributes, :prices_attributes)&.each do |price_key, price_params|
72
- params[:master_attributes][:prices_attributes][price_key]['_destroy'] = '1' if price_params[:amount].blank?
73
- end
74
- else
75
- params[:master_attributes].delete(:prices_attributes)
76
- end
77
- end
78
-
79
- params.delete(:legacy_product_publications_attributes) unless can?(:manage, Spree::ProductPublication)
80
-
81
- # ensure the product is owned by a store
82
- params[:store_id] = store.id if params[:store_id].blank? && product.store_id.blank?
83
-
84
- # The variants matrix was emptied: no variant rows re-submitted, removals sent instead.
85
- # Option types are detached only when the removal list covers every variant, so a
86
- # partial (possibly broken) submission never turns the product into a simple one.
87
- # Variants kept alive by discontinuation still count as removed from the matrix,
88
- # so the detachment can't hinge on any of them yielding a `_destroy` row.
89
- if params[:variants_attributes].blank? && variants_to_remove.any? && can_remove_variants?
90
- attributes = removed_variants_attributes
91
-
92
- params[:option_type_ids] = [] if removing_all_variants? && !params.key?(:option_type_ids)
93
-
94
- if attributes.any?
95
- params[:variants_attributes] = attributes
96
- params[:variants_attributes].permit!
97
- end
98
- end
99
-
100
- params
101
- end
102
-
103
- private
104
-
105
- attr_reader :product, :store, :params, :ability
106
-
107
- delegate :can?, :cannot?, to: :ability
108
-
109
- # Backfill IDs for prices_attributes entries that reference existing prices
110
- # so that ActiveRecord updates them instead of inserting duplicates
111
- def backfill_price_ids!(variant_params, existing_variant)
112
- return unless existing_variant && variant_params[:prices_attributes]
113
-
114
- variant_params[:prices_attributes].each do |_key, price_params|
115
- next if price_params[:id].present?
116
- next if price_params[:currency].blank?
117
-
118
- existing_price = existing_variant.prices.base_prices.find_by(currency: price_params[:currency])
119
- price_params[:id] = existing_price.id if existing_price
120
- end
121
- end
122
-
123
- def product_option_types_params
124
- @product_option_types_params ||= {}
125
- end
126
-
127
- def product_option_types_to_remove
128
- @product_option_types_to_remove ||= product.product_option_type_ids
129
- end
130
-
131
- # Pulls `removed_variant_ids` out of the params so it never reaches Product#update.
132
- # Must run before any `variants_to_remove` access.
133
- def extract_removed_variant_ids
134
- @removed_variant_ids = Array(params.delete(:removed_variant_ids)).map(&:to_s)
135
- end
136
-
137
- # Only variants the client explicitly asked to remove, and only ones that
138
- # actually belong to this product.
139
- def variants_to_remove
140
- @variants_to_remove ||= (@removed_variant_ids || []).uniq & all_variant_ids
141
- end
142
-
143
- def all_variant_ids
144
- @all_variant_ids ||= product.variant_ids.map(&:to_s)
145
- end
146
-
147
- def removing_all_variants?
148
- (all_variant_ids - variants_to_remove).empty?
149
- end
150
-
151
- def can_update_prices?
152
- @can_update_prices ||= product.new_record? || can?(:manage, Spree::Price.new(variant_id: product.default_variant.id))
153
- end
154
-
155
- def can_manage_option_types?
156
- @can_manage_option_types ||= product.new_record? || can?(:manage_option_types, product)
157
- end
158
-
159
- def can_update_stock_items?
160
- @can_update_stock_items ||= product.new_record? || can?(:manage, Spree::StockItem.new(variant_id: product.default_variant.id))
161
- end
162
-
163
- def can_remove_variants?
164
- @can_remove_variants ||= product.persisted? && can?(:destroy, product.default_variant)
165
- end
166
-
167
- def removed_variants_attributes
168
- return {} unless can_remove_variants?
169
-
170
- populate_variants_to_discontinue
171
-
172
- attributes = {}
173
- last_index = params[:variants_attributes].presence&.keys&.map(&:to_i)&.max || -1
174
- variant_ids_to_destroy.each_with_index do |variant_id, index|
175
- attributes[(last_index + 1 + index).to_s] = { id: variant_id, _destroy: '1' }
176
- end
177
-
178
- attributes
179
- end
180
-
181
- def populate_variants_to_discontinue
182
- ids = variants_to_remove.select { |vid| variant_ids_with_completed_orders.include?(vid) }
183
- @variants_to_discontinue = product.variants.where(id: ids).to_a if ids.any?
184
- end
185
-
186
- def variant_ids_to_destroy
187
- variants_to_remove - variant_ids_with_completed_orders
188
- end
189
-
190
- def variant_ids_with_completed_orders
191
- @variant_ids_with_completed_orders ||=
192
- product.variants
193
- .joins(:orders)
194
- .merge(Spree::Order.complete)
195
- .reorder(nil)
196
- .distinct
197
- .pluck(:id)
198
- .map(&:to_s)
199
- end
200
-
201
- def removed_product_option_types_attributes
202
- return {} unless can_manage_option_types?
203
-
204
- attributes = {}
205
- last_index = product_option_types_params.keys.map(&:to_i).max
206
- product_option_types_to_remove.each_with_index do |product_option_type_id, index|
207
- attributes[(last_index + index + 1).to_s] = { id: product_option_type_id, _destroy: '1' }
208
- end
209
-
210
- attributes
211
- end
212
-
213
- def update_option_value_variants(option_value_params, existing_variant)
214
- return {} unless option_value_params.present?
215
- return {} unless can_manage_option_types?
216
-
217
- option_value_variant_params = {}
218
-
219
- option_value_params.each_with_index do |opt, index|
220
- option_type = Spree::OptionType.find_by_param(opt[:id]) if opt.fetch(:id)
221
- option_type ||= Spree::OptionType.where(name: opt[:name].parameterize).first_or_initialize do |o|
222
- o.name = o.presentation = opt[:name]
223
- o.position = opt[:position]
224
- o.save!
225
- end
226
-
227
- option_value_identificator = if opt[:option_value_name].present?
228
- opt[:option_value_name]
229
- else
230
- opt[:option_value_presentation]
231
- end.parameterize.strip
232
-
233
- option_value = option_type.option_values.where(name: option_value_identificator).first_or_initialize do |o|
234
- o.presentation = opt[:option_value_presentation]
235
- o.save!
236
- end
237
-
238
- existing_option_value_variant = existing_variant&.option_value_variants&.find { |ovv| ovv.option_value_id == option_value.id }
239
-
240
- option_value_variant_params[index.to_s] = { id: existing_option_value_variant&.id, option_value_id: option_value.id }.compact_blank
241
-
242
- next if product_option_types_params.find { |_i, v| v[:option_type_id] == option_type.id }
243
-
244
- existing_product_option_type = @product.product_option_types.find { |pot| pot.option_type_id == option_type.id }
245
-
246
- if existing_product_option_type
247
- product_option_types_to_remove.delete(existing_product_option_type.id)
248
- product_option_types_params[opt[:position]] = {
249
- id: existing_product_option_type.id,
250
- position: opt[:position],
251
- option_type_id: option_type.id
252
- }
253
- else
254
- product_option_types_params[opt[:position]] = {
255
- option_type_id: option_type.id,
256
- position: opt[:position]
257
- }
258
- end
259
- end
260
-
261
- option_value_variant_params
262
- end
263
- end
264
- end
265
- end