spree_core 5.5.0.rc1 → 5.5.0.rc3

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/app/models/concerns/spree/store_scoped_resource.rb +4 -1
  3. data/app/models/concerns/spree/typed_associations.rb +8 -1
  4. data/app/models/spree/ability.rb +7 -4
  5. data/app/models/spree/allowed_origin.rb +41 -6
  6. data/app/models/spree/api_key.rb +22 -4
  7. data/app/models/spree/current.rb +2 -2
  8. data/app/models/spree/export.rb +15 -0
  9. data/app/models/spree/exports/coupon_codes.rb +4 -0
  10. data/app/models/spree/exports/newsletter_subscribers.rb +4 -0
  11. data/app/models/spree/exports/product_translations.rb +4 -0
  12. data/app/models/spree/order_routing/strategy/rules.rb +3 -1
  13. data/app/models/spree/payment_method.rb +1 -1
  14. data/app/models/spree/price_list.rb +10 -0
  15. data/app/models/spree/product/channels.rb +18 -1
  16. data/app/models/spree/product.rb +39 -3
  17. data/app/models/spree/promotion.rb +3 -1
  18. data/app/models/spree/store.rb +3 -13
  19. data/app/models/spree/store_payment_method.rb +1 -1
  20. data/app/models/spree/variant.rb +14 -1
  21. data/app/services/spree/gift_cards/apply.rb +1 -4
  22. data/app/services/spree/imports/row_processors/product_variant.rb +3 -0
  23. data/app/services/spree/seeds/payment_methods.rb +10 -7
  24. data/config/locales/en.yml +4 -0
  25. data/db/migrate/20260612000001_change_spree_user_identities_info_to_jsonb.rb +13 -0
  26. data/db/sample_data/payment_methods.rb +18 -18
  27. data/lib/generators/spree/api_resource/templates/store_serializer.rb.tt +4 -1
  28. data/lib/generators/spree/subscriber/subscriber_generator.rb +116 -0
  29. data/lib/generators/spree/subscriber/templates/subscriber.rb.tt +17 -0
  30. data/lib/generators/spree/subscriber/templates/subscriber_spec.rb.tt +9 -0
  31. data/lib/spree/core/version.rb +1 -1
  32. data/lib/tasks/cli.rake +2 -1
  33. metadata +8 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '017291169758b3fc4f9791b940c3264c91df65cd9197621ca0cafd8e3ade2ba1'
4
- data.tar.gz: a4f6cfd15c25ce054ce792d545eaf6db7e815b023d66348b4c44d5a226abb3c2
3
+ metadata.gz: 710ad2cd963e80f3cd21d2b481246a8a163dc2033fca523292f33ec17fd86525
4
+ data.tar.gz: c59f53d88277a62e55d327cd99c025e8808b30ecf1392ff3ccc9a98ff7cd9026
5
5
  SHA512:
6
- metadata.gz: 83d3c2a6c3b13d4c508ab4f4c5c15deb544eb3ba7da48ddd966d872d76ec301f7b87093bc0eb42bd12fe1507575ca09b82919796b079a4aa4e66d71d76dd6332
7
- data.tar.gz: 4453580426836b0b3333309c1eaadabf4bca2fa1fbd8cea51ab1fc1b59c9e02409d098f63e7c77e5dcce222746263d53e021ec3f1b724fd2daa71c623ca573d0
6
+ metadata.gz: 67c2f7227e1b20972b41b4dbfceeb2a00cb8c7c21cc0b295fe907d496a581b59b9eb919b41f6a89f6d7f4b19d5c5c6a405733b003915c8e8c6fb4811c15f43a6
7
+ data.tar.gz: 6355791ca10c5e668752f27b3ff7918b3f4ea27bad3b0308f597a310ec518cf139c93d6f562e2da55938524dc381f5a759b4618bc19ad8d684cb7a30841bf821
@@ -12,7 +12,10 @@ module Spree
12
12
 
13
13
  def set_default_store
14
14
  return if disable_store_presence_validation?
15
- return if stores.any?
15
+ # records built through a store's association (`store.payment_methods.build`)
16
+ # carry their link on the join association — `stores` can't see it until save
17
+ join_association = self.class.reflect_on_association(:stores).through_reflection.name
18
+ return if stores.any? || send(join_association).any?
16
19
 
17
20
  stores << Spree::Store.default
18
21
  end
@@ -54,7 +54,14 @@ module Spree
54
54
  def reconcile_typed_association(association, rows)
55
55
  collection = public_send(association)
56
56
  kept_ids = rows.filter_map { |row| save_typed_association_row(collection, row) }
57
- collection.where.not(id: kept_ids).destroy_all if kept_ids.any? || rows.empty?
57
+ if kept_ids.any? || rows.empty?
58
+ collection.where.not(id: kept_ids).destroy_all
59
+ # `where.not(...).destroy_all` deletes the dropped rows from the DB but
60
+ # leaves them in the already-loaded association target. Reset so a
61
+ # same-request read (e.g. the serializer rendering `rule_ids`) reflects
62
+ # the removal instead of echoing ghosts.
63
+ collection.reset
64
+ end
58
65
  end
59
66
 
60
67
  def save_typed_association_row(collection, row)
@@ -44,15 +44,18 @@ module Spree
44
44
  activate_permission_sets(permission_sets)
45
45
  end
46
46
 
47
- # Determines the role names for the current user.
47
+ # Determines the role names for the current user, scoped to the current
48
+ # store. A +Spree::RoleUser+ binds a role to a store via +resource+, so a
49
+ # role held on one store does not apply on another.
48
50
  #
49
51
  # @return [Array<Symbol>] the role names
50
52
  def determine_role_names
51
53
  return [:default] unless @user.persisted?
52
54
 
53
- # First, try to get roles from the spree_roles association
54
- if @user.respond_to?(:spree_roles)
55
- role_names = @user.spree_roles.pluck(:name).map(&:to_sym)
55
+ if @user.respond_to?(:role_users)
56
+ role_names = @user.role_users.where(resource: @store).
57
+ joins(:role).
58
+ pluck("#{Spree::Role.table_name}.name").map(&:to_sym)
56
59
  return role_names if role_names.any?
57
60
  end
58
61
 
@@ -2,6 +2,10 @@
2
2
 
3
3
  module Spree
4
4
  class AllowedOrigin < Spree.base_class
5
+ # Loopback/development hosts that match any port, so storing `http://localhost`
6
+ # keeps matching `http://localhost:3000`, `:4000`, etc.
7
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 0.0.0.0].freeze
8
+
5
9
  has_prefix_id :ao
6
10
 
7
11
  include Spree::SingleStoreResource
@@ -12,6 +16,42 @@ module Spree
12
16
  validates :origin, uniqueness: { scope: [:store_id, *spree_base_uniqueness_scope] }
13
17
  validate :origin_must_be_valid_http_url
14
18
 
19
+ # Parses a URL into its comparable origin components, or nil when the URL is
20
+ # invalid or not http(s). The host is downcased and has a single trailing dot
21
+ # stripped, and the port is the URI default (80/443) when not explicitly given.
22
+ #
23
+ # @param url [String] the URL to parse
24
+ # @return [Hash, nil] `{ scheme:, host:, port: }` or nil
25
+ def self.parse_origin(url)
26
+ uri = URI.parse(url.to_s)
27
+ return nil unless uri.is_a?(URI::HTTP)
28
+ return nil if uri.host.blank?
29
+
30
+ { scheme: uri.scheme.downcase, host: uri.host.downcase.chomp('.'), port: uri.port }
31
+ rescue URI::InvalidURIError
32
+ nil
33
+ end
34
+
35
+ # Returns true if the given URL's origin matches this stored origin.
36
+ #
37
+ # Scheme and host must match exactly (host comparison is case- and trailing-dot-
38
+ # insensitive). Port must also match, with the scheme default applied, so storing
39
+ # `https://shop.com` matches `https://shop.com:443`. Loopback/development hosts
40
+ # ({LOOPBACK_HOSTS}) are exempt from the port check, so `http://localhost` still
41
+ # matches `http://localhost:3000`, `:4000`, etc.
42
+ #
43
+ # @param url [String] the candidate URL to check
44
+ # @return [Boolean]
45
+ def matches?(url)
46
+ candidate = self.class.parse_origin(url)
47
+ allowed = self.class.parse_origin(origin)
48
+ return false if candidate.nil? || allowed.nil?
49
+ return false unless allowed[:scheme] == candidate[:scheme]
50
+ return false unless allowed[:host] == candidate[:host]
51
+
52
+ LOOPBACK_HOSTS.include?(allowed[:host]) || allowed[:port] == candidate[:port]
53
+ end
54
+
15
55
  private
16
56
 
17
57
  def origin_must_be_valid_http_url
@@ -19,12 +59,7 @@ module Spree
19
59
 
20
60
  uri = URI.parse(origin)
21
61
 
22
- unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
23
- errors.add(:origin, :invalid)
24
- return
25
- end
26
-
27
- if uri.host.blank?
62
+ if self.class.parse_origin(origin).nil?
28
63
  errors.add(:origin, :invalid)
29
64
  return
30
65
  end
@@ -12,6 +12,7 @@ module Spree
12
12
  SCOPES = %w[
13
13
  read_orders write_orders
14
14
  read_products write_products
15
+ read_promotions write_promotions
15
16
  read_customers write_customers
16
17
  read_payments write_payments
17
18
  read_fulfillments write_fulfillments
@@ -20,10 +21,9 @@ module Spree
20
21
  read_store_credits write_store_credits
21
22
  read_stock write_stock
22
23
  read_categories write_categories
23
- read_custom_field_definitions write_custom_field_definitions
24
- read_exports write_exports
25
24
  read_settings write_settings
26
25
  read_webhooks write_webhooks
26
+ read_api_keys write_api_keys
27
27
  read_dashboard
28
28
  read_all write_all
29
29
  ].freeze
@@ -55,8 +55,15 @@ module Spree
55
55
  validates :token_digest, presence: true, uniqueness: true, if: :secret?
56
56
  validates :token_prefix, presence: true, if: :secret?
57
57
  validates :store, presence: true
58
- validates :scopes, presence: true, if: :secret?
59
- validate :validate_known_scopes, if: :secret?
58
+ validates :scopes, presence: true, if: -> { secret? && scopes_enforceable? }
59
+ validate :validate_known_scopes, if: -> { secret? && scopes_enforceable? }
60
+ # Scopes are fixed for the life of the secret — mirroring Stripe/GitHub/AWS:
61
+ # authority is changed by issuing a new key and revoking the old one, never by
62
+ # re-scoping an existing secret in place. This keeps the audit boundary clean
63
+ # and prevents a shared/leaked key from silently gaining write access. The
64
+ # guard is at the model so every entry point (API, legacy admin, rake tasks)
65
+ # is covered uniformly.
66
+ validate :scopes_immutable, if: -> { secret? && persisted? && scopes_changed? }
60
67
 
61
68
  before_validation :generate_token, on: :create
62
69
 
@@ -133,11 +140,22 @@ module Spree
133
140
 
134
141
  private
135
142
 
143
+ # Enforce the scope vocabulary on create and whenever scopes change, but
144
+ # not on unrelated updates — revoking a key minted under an older
145
+ # vocabulary (scopes since removed or renamed) must never fail validation.
146
+ def scopes_enforceable?
147
+ new_record? || scopes_changed?
148
+ end
149
+
136
150
  def validate_known_scopes
137
151
  invalid = scopes - SCOPES
138
152
  errors.add(:scopes, "contains unknown scopes: #{invalid.join(', ')}") if invalid.any?
139
153
  end
140
154
 
155
+ def scopes_immutable
156
+ errors.add(:scopes, Spree.t(:api_key_scopes_immutable))
157
+ end
158
+
141
159
  # Generates the token on creation. For publishable keys, stores the raw token
142
160
  # in the +token+ column. For secret keys, computes an HMAC-SHA256 digest stored
143
161
  # in +token_digest+, saves the first 12 characters as +token_prefix+ for display,
@@ -32,10 +32,10 @@ module Spree
32
32
  end
33
33
 
34
34
  # Returns the current locale.
35
- # Fallback: market default locale -> store default locale.
35
+ # Fallback: market default locale -> store default locale -> I18n default.
36
36
  # @return [String] locale code, e.g. +"en"+, +"de"+
37
37
  def locale
38
- super || market&.default_locale || store&.default_locale
38
+ super || market&.default_locale.presence || store&.default_locale.presence || I18n.default_locale.to_s
39
39
  end
40
40
 
41
41
  # Returns the current tax zone.
@@ -216,6 +216,21 @@ module Spree
216
216
  Spree.export_types
217
217
  end
218
218
 
219
+ # Admin API scope family gating this export type — an export is a bulk
220
+ # read, so an API key needs `read_<required_scope>` to create, view, and
221
+ # download it. Derived from the class name
222
+ # (Spree::Exports::Customers => :customers); override in subclasses whose
223
+ # records are gated by a different scope (e.g. coupon codes =>
224
+ # :promotions). Returns nil on the base class, so unmapped types are
225
+ # only accessible to `read_all`/`write_all` keys.
226
+ #
227
+ # @return [Symbol, nil]
228
+ def required_scope
229
+ return nil if self == Spree::Export
230
+
231
+ to_s.demodulize.underscore.to_sym
232
+ end
233
+
219
234
  def available_models
220
235
  available_types.map(&:model_class)
221
236
  end
@@ -1,6 +1,10 @@
1
1
  module Spree
2
2
  module Exports
3
3
  class CouponCodes < Spree::Export
4
+ def self.required_scope
5
+ :promotions
6
+ end
7
+
4
8
  def csv_headers
5
9
  Spree::CSV::CouponCodePresenter::HEADERS
6
10
  end
@@ -1,6 +1,10 @@
1
1
  module Spree
2
2
  module Exports
3
3
  class NewsletterSubscribers < Spree::Export
4
+ def self.required_scope
5
+ :customers
6
+ end
7
+
4
8
  def scope_includes
5
9
  [:user, { metafields: :metafield_definition }]
6
10
  end
@@ -1,6 +1,10 @@
1
1
  module Spree
2
2
  module Exports
3
3
  class ProductTranslations < Spree::Export
4
+ def self.required_scope
5
+ :products
6
+ end
7
+
4
8
  def scope_includes
5
9
  []
6
10
  end
@@ -13,7 +13,9 @@ module Spree
13
13
  locations = eligible_locations
14
14
  return [] if locations.empty?
15
15
 
16
- ordered = Reducer.new(applicable_rules.to_a, order: order).rank_all(locations)
16
+ ordered = Spree::OrderRouting::Strategy::Reducer
17
+ .new(applicable_rules.to_a, order: order)
18
+ .rank_all(locations)
17
19
  return [] if ordered.empty?
18
20
 
19
21
  packages = build_packages(ordered)
@@ -22,7 +22,7 @@ module Spree
22
22
  validates :name, presence: true
23
23
  normalizes :name, with: ->(value) { value&.to_s&.squish&.presence }
24
24
 
25
- has_many :store_payment_methods, class_name: 'Spree::StorePaymentMethod'
25
+ has_many :store_payment_methods, class_name: 'Spree::StorePaymentMethod', inverse_of: :payment_method
26
26
  has_many :stores, class_name: 'Spree::Store', through: :store_payment_methods
27
27
 
28
28
  has_many :payments, class_name: 'Spree::Payment', inverse_of: :payment_method, dependent: :nullify
@@ -303,8 +303,18 @@ module Spree
303
303
  to_remove = current - desired
304
304
  to_add = desired - current
305
305
 
306
+ return unless to_remove.any? || to_add.any?
307
+
306
308
  remove_products(to_remove) if to_remove.any?
307
309
  add_products(to_add) if to_add.any?
310
+
311
+ # Membership is changed via raw `upsert_all`/`delete_all` on `prices`,
312
+ # which bypasses the `variants`/`products` association caches (and the
313
+ # `product_ids` ids-reader memo `current` populated above). Reset them so
314
+ # a same-request read — e.g. the serializer's `product_ids` — reflects the
315
+ # new membership instead of the pre-change set.
316
+ association(:variants).reset
317
+ association(:products).reset
308
318
  end
309
319
 
310
320
  def apply_pending_prices
@@ -96,6 +96,13 @@ module Spree
96
96
  self.store ||= Spree::Current.store || Spree::Store.default
97
97
  end
98
98
 
99
+ # The store a product's associations (categories, channels) must belong
100
+ # to. Falls back to the request/default store for not-yet-persisted
101
+ # products whose `store` hasn't been assigned.
102
+ def assignable_store
103
+ store || Spree::Current.store || Spree::Store.default
104
+ end
105
+
99
106
  def pending_publications?
100
107
  @pending_publications_params.present?
101
108
  end
@@ -126,9 +133,14 @@ module Spree
126
133
  publication.update!(publication_data.slice(:published_at, :unpublished_at))
127
134
  publication_ids_in_payload << publication.id
128
135
  elsif channel_id.present?
136
+ # Only publish to channels owned by the product's store; ignore
137
+ # ids referencing another store's channel.
138
+ channel = publishable_channels.find_by(id: channel_id)
139
+ next unless channel
140
+
129
141
  # Upsert by channel_id so repeat submissions are idempotent
130
142
  # against the unique (product_id, channel_id) index.
131
- publication = product_publications.find_or_initialize_by(channel_id: channel_id)
143
+ publication = product_publications.find_or_initialize_by(channel_id: channel.id)
132
144
  publication.assign_attributes(publication_data.slice(:published_at, :unpublished_at))
133
145
  publication.save!
134
146
  publication_ids_in_payload << publication.id
@@ -144,6 +156,11 @@ module Spree
144
156
 
145
157
  Spree::PrefixedId.decode_prefixed_id(value) || value
146
158
  end
159
+
160
+ # Channels the product may be published to — those owned by its store.
161
+ def publishable_channels
162
+ assignable_store.channels
163
+ end
147
164
  end
148
165
  end
149
166
  end
@@ -211,11 +211,14 @@ module Spree
211
211
  end
212
212
 
213
213
  # Maps 6.0 API name (category_ids) to model column (taxon_ids).
214
- # Accepts both prefixed IDs and raw integer IDs.
214
+ # Accepts both prefixed IDs and raw integer IDs. Only taxons belonging to
215
+ # the product's own store are assigned — ids from another store's
216
+ # taxonomies are dropped, preventing cross-store category attachment.
215
217
  def category_ids=(ids)
216
- self.taxon_ids = Array(ids).filter_map do |id|
218
+ decoded_ids = Array(ids).filter_map do |id|
217
219
  id.to_s.include?('_') ? Spree::Taxon.decode_prefixed_id(id) : id
218
220
  end
221
+ self.taxon_ids = Spree::Taxon.for_store(assignable_store).where(id: decoded_ids).ids
219
222
  end
220
223
 
221
224
  # Sync media inline. Entries with `id` patch the existing asset
@@ -757,6 +760,7 @@ module Spree
757
760
  def apply_variants(variants_params)
758
761
  variant_ids_in_payload = []
759
762
  master_touched = false
763
+ mutated = false
760
764
 
761
765
  variants_params.each do |variant_data|
762
766
  variant_data = variant_data.to_h.with_indifferent_access
@@ -767,6 +771,7 @@ module Spree
767
771
  variant = variants_including_master.find_by_param!(variant_id)
768
772
  variant.update!(variant_data)
769
773
  variant_ids_in_payload << variant.id
774
+ mutated = true
770
775
  elsif options.blank? || (options.is_a?(Array) && options.empty?)
771
776
  # An entry with no options addresses the master variant. Building a
772
777
  # non-master here would create a phantom duplicate (the auto-built
@@ -778,18 +783,46 @@ module Spree
778
783
  target.assign_attributes(variant_data)
779
784
  target.save!
780
785
  master_touched = true
786
+ mutated = true
781
787
  else
782
788
  variant = variants.build
783
789
  variant.assign_attributes(variant_data)
784
790
  variant.save!
785
791
  variant_ids_in_payload << variant.id
792
+ mutated = true
786
793
  end
787
794
  end
788
795
 
789
796
  # Remove variants not in the payload (only non-master). If only the
790
797
  # master was touched (simple product), leave existing non-master
791
798
  # variants alone — the payload is partial, not a full replacement.
792
- variants.where.not(id: variant_ids_in_payload).destroy_all if variant_ids_in_payload.any? && !master_touched
799
+ if variant_ids_in_payload.any? && !master_touched
800
+ removed = variants.where.not(id: variant_ids_in_payload).destroy_all
801
+ mutated ||= removed.any?
802
+ end
803
+
804
+ sync_variant_state! if mutated
805
+ end
806
+
807
+ # Re-syncs the in-memory derived variant state after `apply_variants`
808
+ # mutates variants out-of-band (the variant counter is bumped via
809
+ # `Spree::Product.increment_counter` in a Variant callback, and
810
+ # `default_variant` is memoized). Without this, a freshly built/updated
811
+ # product serialized in the same request returns a stale `variant_count`
812
+ # and a stale `price` (delegated to the memoized `default_variant`).
813
+ #
814
+ # The `default_variant` memo reset here changes shape once master is
815
+ # removed (it becomes a `belongs_to` FK). See
816
+ # docs/plans/6.0-remove-master-variant.md (Phase 3).
817
+ # @return [void]
818
+ def sync_variant_state!
819
+ # `variant_count` is maintained by a direct counter update in a Variant
820
+ # callback, so the in-memory attribute is stale here — re-read it from the
821
+ # row without reloading the whole record.
822
+ self[:variant_count] = self.class.where(id: id).pick(:variant_count)
823
+ variants.reset
824
+ @default_variant = nil
825
+ @default_variant_id = nil
793
826
  end
794
827
 
795
828
  def add_associations_from_prototype
@@ -811,6 +844,7 @@ module Spree
811
844
 
812
845
  # Builds variants from a hash of option types & values
813
846
  def build_variants_from_option_values_hash
847
+ Spree::Deprecation.warn('Spree::Product#build_variants_from_option_values_hash is deprecated and will be removed in Spree 6.0.')
814
848
  ensure_option_types_exist_for_values_hash
815
849
  values = option_values_hash.values
816
850
  values = values.inject(values.shift) { |memo, value| memo.product(value).map(&:flatten) }
@@ -925,12 +959,14 @@ module Spree
925
959
  end
926
960
 
927
961
  def discontinue_on_must_be_later_than_make_active_at
962
+ Spree::Deprecation.warn('Spree::Product#discontinue_on_must_be_later_than_make_active_at is deprecated and will be removed in Spree 6.0.')
928
963
  if discontinue_on < make_active_at
929
964
  errors.add(:discontinue_on, :invalid_date_range)
930
965
  end
931
966
  end
932
967
 
933
968
  def requires_price?
969
+ Spree::Deprecation.warn('Spree::Product#requires_price? is deprecated and will be removed in Spree 6.0.')
934
970
  Spree::Config[:require_master_price]
935
971
  end
936
972
 
@@ -86,7 +86,9 @@ module Spree
86
86
  #
87
87
  # Ransack
88
88
  #
89
- self.whitelisted_ransackable_attributes = ['path', 'promotion_category_id', 'code', 'starts_at', 'expires_at']
89
+ # `name` is whitelisted so the admin global search / command palette can
90
+ # filter via the `name_or_code_cont` predicate without a dedicated scope.
91
+ self.whitelisted_ransackable_attributes = ['name', 'path', 'promotion_category_id', 'code', 'starts_at', 'expires_at']
90
92
  self.whitelisted_ransackable_associations = %w[coupon_codes]
91
93
 
92
94
  def self.with_coupon_code(coupon_code)
@@ -82,6 +82,7 @@ module Spree
82
82
  has_many :variants, through: :products, class_name: 'Spree::Variant', source: :variants_including_master
83
83
  has_many :stock_items, through: :variants, class_name: 'Spree::StockItem'
84
84
  has_many :prices, through: :variants, class_name: 'Spree::Price'
85
+ has_many :price_lists, class_name: 'Spree::PriceList', inverse_of: :store
85
86
  has_many :inventory_units, through: :variants, class_name: 'Spree::InventoryUnit'
86
87
  has_many :option_value_variants, through: :variants, class_name: 'Spree::OptionValueVariant'
87
88
  has_many :customer_returns, class_name: 'Spree::CustomerReturn', inverse_of: :store
@@ -278,25 +279,14 @@ module Spree
278
279
  end
279
280
 
280
281
  # Returns true if the given URL's origin matches one of the store's allowed origins.
281
- # Comparison is port-less: only scheme + host are matched, so storing
282
- # `http://localhost` will match `http://localhost:3000`, `http://localhost:4000`, etc.
282
+ # See {Spree::AllowedOrigin#matches?} for the matching rules (scheme/host/port).
283
283
  #
284
284
  # @param url [String] the full URL to check
285
285
  # @return [Boolean]
286
286
  def allowed_origin?(url)
287
287
  return false if url.blank?
288
288
 
289
- uri = URI.parse(url)
290
- request_origin = "#{uri.scheme}://#{uri.host}"
291
-
292
- allowed_origins.pluck(:origin).any? do |stored|
293
- stored_uri = URI.parse(stored)
294
- "#{stored_uri.scheme}://#{stored_uri.host}" == request_origin
295
- rescue URI::InvalidURIError
296
- false
297
- end
298
- rescue URI::InvalidURIError
299
- false
289
+ allowed_origins.any? { |allowed_origin| allowed_origin.matches?(url) }
300
290
  end
301
291
 
302
292
  # Returns the states available for checkout for the store
@@ -3,7 +3,7 @@ module Spree
3
3
  self.table_name = 'spree_payment_methods_stores'
4
4
 
5
5
  belongs_to :store, class_name: 'Spree::Store', touch: true
6
- belongs_to :payment_method, class_name: 'Spree::PaymentMethod', touch: true
6
+ belongs_to :payment_method, class_name: 'Spree::PaymentMethod', touch: true, inverse_of: :store_payment_methods
7
7
 
8
8
  validates :store, :payment_method, presence: true
9
9
  validates :store_id, uniqueness: { scope: :payment_method_id }
@@ -539,7 +539,20 @@ module Spree
539
539
  # @param compare_at_amount [BigDecimal] the compare at amount to set
540
540
  # @return [void]
541
541
  def set_price(currency, amount, compare_at_amount = nil)
542
- price = prices.base_prices.find_or_initialize_by(currency: currency)
542
+ # When the prices association is already loaded (eager-loaded for
543
+ # serialization), reuse the cached base-price instance so readers that
544
+ # branch on `prices.loaded?` (Pricing::Resolver, #price_in, serializers)
545
+ # observe the write without a reload. `base_prices.find_or_initialize_by`
546
+ # would issue a fresh query and return a detached object, leaving the
547
+ # loaded collection — and the serialized response — stale.
548
+ price =
549
+ if prices.loaded?
550
+ prices.detect { |p| p.price_list_id.nil? && p.currency == currency } ||
551
+ prices.build(currency: currency)
552
+ else
553
+ prices.base_prices.find_or_initialize_by(currency: currency)
554
+ end
555
+
543
556
  price.amount = amount
544
557
  price.compare_at_amount = compare_at_amount
545
558
  price.save! if persisted?
@@ -69,10 +69,7 @@ module Spree
69
69
  payment_method.name ||= Spree.t(:store_credit_name)
70
70
  payment_method.active = true
71
71
 
72
- if payment_method.new_record?
73
- payment_method.stores << store unless payment_method.stores.include?(store)
74
- payment_method.save!
75
- end
72
+ payment_method.save! if payment_method.new_record?
76
73
 
77
74
  payment_method
78
75
  end
@@ -99,6 +99,9 @@ module Spree
99
99
  product.slug = attributes['slug']
100
100
  product.sku = attributes['sku'] if attributes['sku'].present? && options.empty?
101
101
  product.store = store
102
+ # Publish to the store's default channel so imported products surface
103
+ # in the storefront.
104
+ product.channels << store.default_channel if store.default_channel && product.channels.empty?
102
105
  end
103
106
 
104
107
  product.name = attributes['name'] if attributes['name'].present?
@@ -4,14 +4,17 @@ module Spree
4
4
  prepend Spree::ServiceModule::Base
5
5
 
6
6
  def call
7
- payment_method = Spree::PaymentMethod::StoreCredit.find_or_initialize_by(
8
- name: Spree.t(:store_credit_name),
9
- description: Spree.t(:store_credit_name),
10
- active: true
11
- )
7
+ Spree::Store.all.find_each do |store|
8
+ payment_method = store.payment_methods.find_or_initialize_by(
9
+ type: 'Spree::PaymentMethod::StoreCredit'
10
+ )
11
+ next if payment_method.persisted?
12
12
 
13
- payment_method.stores = Spree::Store.all if payment_method.new_record?
14
- payment_method.save!
13
+ payment_method.name = Spree.t(:store_credit_name)
14
+ payment_method.description = Spree.t(:store_credit_name)
15
+ payment_method.active = true
16
+ payment_method.save!
17
+ end
15
18
  end
16
19
  end
17
20
  end
@@ -578,6 +578,7 @@ en:
578
578
  invalid_token: Invalid authentication token.
579
579
  locked: Your account is locked.
580
580
  timeout: Your session expired, please sign in again to continue.
581
+ too_many_attempts: Too many attempts. Please wait a moment and try again.
581
582
  unauthenticated: You need to sign in or sign up before continuing.
582
583
  unconfirmed: You have to confirm your account before continuing.
583
584
  mailer:
@@ -773,6 +774,8 @@ en:
773
774
  one: "...and %{count} more"
774
775
  other: "...and %{count} more"
775
776
  api_key: API Key
777
+ api_key_no_current_key: No API key authenticated this request
778
+ 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
776
779
  api_keys: API Keys
777
780
  apis:
778
781
  admin: Admin API
@@ -1424,6 +1427,7 @@ en:
1424
1427
  max: Max
1425
1428
  max_items: Max Items
1426
1429
  max_quantity: Max Quantity
1430
+ me_no_current_user: No admin user authenticated this request
1427
1431
  media: Media
1428
1432
  memo: Memo
1429
1433
  meta_description: Meta Description
@@ -0,0 +1,13 @@
1
+ class ChangeSpreeUserIdentitiesInfoToJsonb < ActiveRecord::Migration[7.2]
2
+ def up
3
+ change_table :spree_user_identities do |t|
4
+ t.change :info, :jsonb, using: 'info::jsonb' if t.respond_to?(:jsonb)
5
+ end
6
+ end
7
+
8
+ def down
9
+ change_table :spree_user_identities do |t|
10
+ t.change :info, :json, using: 'info::json' if t.respond_to?(:jsonb)
11
+ end
12
+ end
13
+ end
@@ -1,19 +1,19 @@
1
- cc_payment_method = Spree::Gateway::Bogus.where(
2
- name: 'Credit Card',
3
- description: 'Bogus payment gateway.',
4
- active: true
5
- ).first_or_initialize
1
+ Spree::Store.all.find_each do |store|
2
+ cc_payment_method = store.payment_methods.find_or_initialize_by(
3
+ type: 'Spree::Gateway::Bogus',
4
+ name: 'Credit Card',
5
+ description: 'Bogus payment gateway.',
6
+ active: true
7
+ )
8
+ cc_payment_method.display_on = 'back_end'
9
+ cc_payment_method.save!
6
10
 
7
- cc_payment_method.display_on = 'back_end'
8
- cc_payment_method.stores = Spree::Store.all
9
- cc_payment_method.save!
10
-
11
- check_payment_method = Spree::PaymentMethod::Check.where(
12
- name: 'Check',
13
- description: 'Pay by check.',
14
- active: true
15
- ).first_or_initialize
16
-
17
- check_payment_method.display_on = 'back_end'
18
- check_payment_method.stores = Spree::Store.all
19
- check_payment_method.save!
11
+ check_payment_method = store.payment_methods.find_or_initialize_by(
12
+ type: 'Spree::PaymentMethod::Check',
13
+ name: 'Check',
14
+ description: 'Pay by check.',
15
+ active: true
16
+ )
17
+ check_payment_method.display_on = 'back_end'
18
+ check_payment_method.save!
19
+ end
@@ -1,10 +1,13 @@
1
+ <%# Decimal columns serialize as strings on the wire (Alba's oj_rails backend
2
+ casts BigDecimal via as_json), so they must be typed :string — only
3
+ integer/float are real JSON numbers. -%>
1
4
  <% typelizable = attributes.reject(&:reference?).select { |a| %i[boolean integer decimal float].include?(a.type) } -%>
2
5
  module Spree
3
6
  module Api
4
7
  module V3
5
8
  class <%= bare_class_name %>Serializer < BaseSerializer
6
9
  <% if typelizable.any? -%>
7
- typelize <%= typelizable.map { |a| "#{a.name}: :#{a.type == :boolean ? 'boolean' : 'number'}" }.join(', ') %>
10
+ typelize <%= typelizable.map { |a| type = a.type == :boolean ? 'boolean' : (a.type == :decimal ? 'string' : 'number'); "#{a.name}: :#{type}" }.join(', ') %>
8
11
 
9
12
  <% end -%>
10
13
  attributes <%= permitted_attribute_names.map { |a| ":#{a}" }.join(', ') %>
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ # spree:subscriber — scaffold an event subscriber and register it.
5
+ #
6
+ # bin/rails g spree:subscriber OmsOrderSync order.completed order.canceled
7
+ # bin/rails g spree:subscriber MyApp::BrandSync brand.created brand.updated
8
+ # bin/rails g spree:subscriber CriticalSync order.completed --sync
9
+ #
10
+ # Subscribers are not auto-discovered — they must be appended to the
11
+ # Spree.subscribers array. Forgetting that step produces a silent no-op,
12
+ # so this generator also maintains the registration in
13
+ # config/initializers/spree.rb with an idempotent line per subscriber.
14
+ class SubscriberGenerator < Rails::Generators::NamedBase
15
+ desc 'Creates a Spree event subscriber and registers it in an initializer'
16
+
17
+ # Every Spree app ships config/initializers/spree.rb (created by
18
+ # spree:install) — registrations go there rather than into a second
19
+ # initializer file.
20
+ INITIALIZER_PATH = 'config/initializers/spree.rb'
21
+ REGISTRATION_ANCHOR = "Rails.application.config.after_initialize do\n"
22
+
23
+ argument :events, type: :array, default: [], banner: 'event.name event.name'
24
+
25
+ class_option :sync,
26
+ type: :boolean,
27
+ default: false,
28
+ desc: 'Run the subscriber synchronously (async: false) instead of via ActiveJob'
29
+
30
+ class_option :skip_spec,
31
+ type: :boolean,
32
+ default: false,
33
+ desc: "Don't generate a spec file"
34
+
35
+ def self.source_paths
36
+ [File.expand_path('templates', __dir__), *superclass.source_paths]
37
+ end
38
+
39
+ def create_subscriber_file
40
+ template 'subscriber.rb.tt', File.join('app/subscribers', class_path, "#{subscriber_file_name}.rb")
41
+ end
42
+
43
+ def register_subscriber
44
+ if File.exist?(destination_path(INITIALIZER_PATH))
45
+ content = File.read(destination_path(INITIALIZER_PATH))
46
+ if content.include?(registration_line.strip)
47
+ say_status :identical, "#{INITIALIZER_PATH} (#{subscriber_class_name} already registered)", :blue
48
+ elsif content.include?(REGISTRATION_ANCHOR)
49
+ inject_into_file INITIALIZER_PATH, registration_line, after: REGISTRATION_ANCHOR
50
+ else
51
+ append_to_file INITIALIZER_PATH, registration_block
52
+ end
53
+ else
54
+ create_file INITIALIZER_PATH, registration_block.lstrip
55
+ end
56
+ end
57
+
58
+ def create_spec_file
59
+ return if options[:skip_spec]
60
+
61
+ template 'subscriber_spec.rb.tt', File.join('spec/subscribers', class_path, "#{subscriber_file_name}_spec.rb")
62
+ end
63
+
64
+ def warn_about_missing_events
65
+ return if events.any?
66
+
67
+ say_status :note, "no events given — edit `subscribes_to` in the generated subscriber (e.g. 'order.completed')", :yellow
68
+ end
69
+
70
+ private
71
+
72
+ # "OmsOrderSync" and "OmsOrderSyncSubscriber" both produce
73
+ # OmsOrderSyncSubscriber / oms_order_sync_subscriber.
74
+ def bare_subscriber_name
75
+ base = class_name.demodulize.sub(/Subscriber\z/, '')
76
+ "#{base}Subscriber"
77
+ end
78
+
79
+ def subscriber_file_name
80
+ bare_subscriber_name.underscore
81
+ end
82
+
83
+ def subscriber_class_name
84
+ (class_path.map { |part| part.camelize } + [bare_subscriber_name]).join('::')
85
+ end
86
+
87
+ def namespace_modules
88
+ class_path.map(&:camelize)
89
+ end
90
+
91
+ def subscribes_to_arguments
92
+ listed = events.any? ? events.map { |e| "'#{e}'" } : ["'TODO.replace_with_event_name'"]
93
+ listed << 'async: false' if options[:sync]
94
+ listed.join(', ')
95
+ end
96
+
97
+ def registration_line
98
+ " Spree.subscribers << #{subscriber_class_name}\n"
99
+ end
100
+
101
+ def registration_block
102
+ <<~RUBY
103
+
104
+ # Event subscribers must be registered explicitly — Spree does not
105
+ # auto-discover classes in app/subscribers/.
106
+ Rails.application.config.after_initialize do
107
+ #{registration_line.chomp}
108
+ end
109
+ RUBY
110
+ end
111
+
112
+ def destination_path(relative)
113
+ File.join(destination_root, relative)
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,17 @@
1
+ <% namespace_modules.each_with_index do |mod, i| -%>
2
+ <%= ' ' * i %>module <%= mod %>
3
+ <% end -%>
4
+ <% depth = namespace_modules.size -%>
5
+ <%= ' ' * depth %>class <%= bare_subscriber_name %> < Spree::Subscriber
6
+ <%= ' ' * (depth + 1) %>subscribes_to <%= subscribes_to_arguments %>
7
+
8
+ <%= ' ' * (depth + 1) %>def handle(event)
9
+ <%= ' ' * (depth + 2) %># Event payloads carry prefixed IDs — look records up with find_by_prefix_id:
10
+ <%= ' ' * (depth + 2) %>#
11
+ <%= ' ' * (depth + 2) %># record = Spree::Order.find_by_prefix_id(event.payload['id'])
12
+ <%= ' ' * (depth + 2) %># return unless record
13
+ <%= ' ' * (depth + 1) %>end
14
+ <%= ' ' * depth %>end
15
+ <% namespace_modules.each_with_index do |_, i| -%>
16
+ <%= ' ' * (namespace_modules.size - 1 - i) %>end
17
+ <% end -%>
@@ -0,0 +1,9 @@
1
+ require 'rails_helper'
2
+
3
+ RSpec.describe <%= subscriber_class_name %> do
4
+ it 'handles <%= events.first || 'the subscribed event' %>' do
5
+ event = Spree::Event.new(name: <%= (events.first || 'some.event').inspect %>, payload: { 'id' => 'prefixed_id_here' })
6
+
7
+ expect { described_class.new.handle(event) }.not_to raise_error
8
+ end
9
+ end
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '5.5.0.rc1'.freeze
2
+ VERSION = '5.5.0.rc3'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
data/lib/tasks/cli.rake CHANGED
@@ -12,8 +12,9 @@ namespace :spree do
12
12
  task create_api_key: :environment do
13
13
  name = ENV.fetch('NAME')
14
14
  key_type = ENV.fetch('KEY_TYPE')
15
+ scopes = ENV.fetch('SCOPES', '').split(',').map(&:strip).reject(&:empty?)
15
16
  store = Spree::Store.default
16
- key = store.api_keys.create!(name: name, key_type: key_type)
17
+ key = store.api_keys.create!(name: name, key_type: key_type, scopes: scopes)
17
18
  print key.plaintext_token
18
19
  end
19
20
 
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.5.0.rc1
4
+ version: 5.5.0.rc3
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-06-10 00:00:00.000000000 Z
13
+ date: 2026-06-18 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: i18n-tasks
@@ -1565,6 +1565,7 @@ files:
1565
1565
  - db/migrate/20260601000001_create_spree_product_publications.rb
1566
1566
  - db/migrate/20260601000002_add_store_id_to_spree_products.rb
1567
1567
  - db/migrate/20260602000001_add_default_to_spree_channels.rb
1568
+ - db/migrate/20260612000001_change_spree_user_identities_info_to_jsonb.rb
1568
1569
  - db/sample_data/channels.rb
1569
1570
  - db/sample_data/customers.csv
1570
1571
  - db/sample_data/markets.rb
@@ -1612,6 +1613,9 @@ files:
1612
1613
  - lib/generators/spree/model/templates/model.rb.tt
1613
1614
  - lib/generators/spree/model_decorator/model_decorator_generator.rb
1614
1615
  - lib/generators/spree/model_decorator/templates/model_decorator.rb.tt
1616
+ - lib/generators/spree/subscriber/subscriber_generator.rb
1617
+ - lib/generators/spree/subscriber/templates/subscriber.rb.tt
1618
+ - lib/generators/spree/subscriber/templates/subscriber_spec.rb.tt
1615
1619
  - lib/mobility/plugins/store_based_fallbacks.rb
1616
1620
  - lib/spree/analytics.rb
1617
1621
  - lib/spree/core.rb
@@ -1812,9 +1816,9 @@ licenses:
1812
1816
  - BSD-3-Clause
1813
1817
  metadata:
1814
1818
  bug_tracker_uri: https://github.com/spree/spree/issues
1815
- changelog_uri: https://github.com/spree/spree/releases/tag/v5.5.0.rc1
1819
+ changelog_uri: https://github.com/spree/spree/releases/tag/v5.5.0.rc3
1816
1820
  documentation_uri: https://docs.spreecommerce.org/
1817
- source_code_uri: https://github.com/spree/spree/tree/v5.5.0.rc1
1821
+ source_code_uri: https://github.com/spree/spree/tree/v5.5.0.rc3
1818
1822
  post_install_message:
1819
1823
  rdoc_options: []
1820
1824
  require_paths: