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 +4 -4
- data/app/models/concerns/spree/webhook_payload_redaction.rb +88 -0
- data/app/models/spree/api_key.rb +22 -0
- data/app/models/spree/channel.rb +20 -2
- data/app/models/spree/webhook_delivery.rb +22 -0
- data/app/presenters/spree/data_feeds/google_presenter.rb +28 -1
- data/app/services/spree/sample_data/loader.rb +7 -0
- data/app/services/spree/seeds/all.rb +2 -0
- data/app/services/spree/seeds/api_keys.rb +8 -4
- data/app/services/spree/seeds/channels.rb +25 -0
- data/app/services/spree/seeds/customer_groups.rb +18 -0
- data/config/locales/en.yml +4 -0
- data/db/migrate/20260719000001_add_channel_id_to_spree_api_keys.rb +5 -0
- data/db/sample_data/channels.rb +12 -1
- data/db/sample_data/wholesale.rb +59 -0
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/permitted_attributes.rb +1 -1
- metadata +9 -5
- data/app/services/spree/products/prepare_nested_attributes.rb +0 -265
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 81bf3080a8d43c9bc6a6d76561c9fae751c9282a1e8b30dfb388ca6abe2511d7
|
|
4
|
+
data.tar.gz: 423cf415d52c180297db3e26b581ea4078cfcf0e7816fc3084d12034b3d74a87
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/app/models/spree/api_key.rb
CHANGED
|
@@ -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,
|
data/app/models/spree/channel.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
@@ -4,11 +4,15 @@ module Spree
|
|
|
4
4
|
prepend Spree::ServiceModule::Base
|
|
5
5
|
|
|
6
6
|
def call
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
store.api_keys.
|
|
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
|
data/config/locales/en.yml
CHANGED
|
@@ -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
|
data/db/sample_data/channels.rb
CHANGED
|
@@ -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
|
-
|
|
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?
|
data/lib/spree/core/version.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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.
|
|
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.
|
|
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
|