spree_core 5.5.3 → 5.6.0.rc2

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 (140) hide show
  1. checksums.yaml +4 -4
  2. data/app/helpers/spree/base_helper.rb +12 -2
  3. data/app/helpers/spree/currency_helper.rb +1 -4
  4. data/app/helpers/spree/locale_helper.rb +18 -13
  5. data/app/helpers/spree/localized_names_helper.rb +25 -0
  6. data/app/mailers/spree/admin_user_mailer.rb +71 -0
  7. data/app/mailers/spree/base_mailer.rb +73 -11
  8. data/app/mailers/spree/export_mailer.rb +7 -7
  9. data/app/mailers/spree/import_mailer.rb +19 -0
  10. data/app/mailers/spree/invitation_mailer.rb +16 -11
  11. data/app/mailers/spree/report_mailer.rb +6 -6
  12. data/app/mailers/spree/webhook_mailer.rb +7 -6
  13. data/app/models/concerns/spree/admin_user_methods.rb +44 -0
  14. data/app/models/concerns/spree/legacy_multi_store_support.rb +48 -0
  15. data/app/models/concerns/spree/presentation_translatable.rb +39 -0
  16. data/app/models/concerns/spree/product_scopes.rb +21 -3
  17. data/app/models/concerns/spree/single_store_resource.rb +5 -0
  18. data/app/models/concerns/spree/store_scoped_resource.rb +10 -0
  19. data/app/models/concerns/spree/stores/markets.rb +9 -1
  20. data/app/models/concerns/spree/stores/setup.rb +37 -23
  21. data/app/models/concerns/spree/translatable_resource.rb +95 -5
  22. data/app/models/concerns/spree/user_methods.rb +47 -2
  23. data/app/models/spree/ability.rb +5 -4
  24. data/app/models/spree/allowed_origin.rb +31 -0
  25. data/app/models/spree/category.rb +12 -2
  26. data/app/models/spree/channel/gating.rb +67 -0
  27. data/app/models/spree/channel.rb +1 -0
  28. data/app/models/spree/classification.rb +12 -0
  29. data/app/models/spree/country.rb +22 -1
  30. data/app/models/spree/credit_card.rb +29 -0
  31. data/app/models/spree/currency.rb +47 -0
  32. data/app/models/spree/current.rb +12 -1
  33. data/app/models/spree/customer_group.rb +1 -0
  34. data/app/models/spree/export.rb +19 -0
  35. data/app/models/spree/exports/coupon_codes.rb +1 -2
  36. data/app/models/spree/import.rb +84 -1
  37. data/app/models/spree/import_row.rb +7 -1
  38. data/app/models/spree/imports/product_translations.rb +6 -0
  39. data/app/models/spree/locale.rb +104 -0
  40. data/app/models/spree/newsletter_subscriber.rb +4 -0
  41. data/app/models/spree/option_type.rb +11 -13
  42. data/app/models/spree/option_value.rb +5 -17
  43. data/app/models/spree/order.rb +19 -2
  44. data/app/models/spree/payment_method.rb +6 -5
  45. data/app/models/spree/permission_sets/default_customer.rb +3 -0
  46. data/app/models/spree/policy.rb +2 -1
  47. data/app/models/spree/product.rb +23 -2
  48. data/app/models/spree/promotion/rules/channel.rb +37 -0
  49. data/app/models/spree/promotion/rules/market.rb +37 -0
  50. data/app/models/spree/promotion.rb +5 -3
  51. data/app/models/spree/promotion_handler/promotion_duplicator.rb +1 -1
  52. data/app/models/spree/role_user.rb +4 -0
  53. data/app/models/spree/search_provider/meilisearch.rb +22 -9
  54. data/app/models/spree/setup_task.rb +12 -0
  55. data/app/models/spree/setup_tasks.rb +86 -0
  56. data/app/models/spree/shipment.rb +38 -1
  57. data/app/models/spree/shipment_handler.rb +1 -1
  58. data/app/models/spree/stock/availability_validator.rb +1 -1
  59. data/app/models/spree/stock/quantifier.rb +21 -1
  60. data/app/models/spree/stock_item.rb +2 -1
  61. data/app/models/spree/stock_location.rb +4 -1
  62. data/app/models/spree/store.rb +45 -9
  63. data/app/models/spree/store_payment_method.rb +5 -1
  64. data/app/models/spree/store_promotion.rb +4 -0
  65. data/app/models/spree/taxon.rb +112 -9
  66. data/app/models/spree/taxonomy.rb +1 -1
  67. data/app/models/spree/translations/batch.rb +104 -0
  68. data/app/models/spree/user_identity.rb +35 -24
  69. data/app/models/spree/variant.rb +29 -2
  70. data/app/presenters/spree/search_provider/product_presenter.rb +3 -0
  71. data/app/services/spree/carts/complete.rb +3 -0
  72. data/app/services/spree/fulfillments/create.rb +259 -0
  73. data/app/services/spree/locales/set_fallback_locale_for_store.rb +7 -1
  74. data/app/services/spree/locales.rb +52 -0
  75. data/app/services/spree/newsletter/subscribe.rb +1 -0
  76. data/app/services/spree/products/prepare_nested_attributes.rb +40 -12
  77. data/app/services/spree/shipments/update.rb +1 -1
  78. data/app/services/spree/stock_reservations/reserve.rb +4 -1
  79. data/app/services/spree/taxons/add_products.rb +3 -0
  80. data/app/services/spree/taxons/remove_products.rb +3 -0
  81. data/app/subscribers/spree/admin_user_email_subscriber.rb +29 -0
  82. data/app/subscribers/spree/import_email_subscriber.rb +26 -0
  83. data/app/views/layouts/spree/base_mailer.html.erb +14 -17
  84. data/app/views/spree/admin_user_mailer/confirmation_email.html.erb +7 -0
  85. data/app/views/spree/admin_user_mailer/password_reset_email.html.erb +8 -0
  86. data/app/views/spree/export_mailer/export_done.html.erb +6 -14
  87. data/app/views/spree/import_mailer/import_done.html.erb +12 -0
  88. data/app/views/spree/invitation_mailer/invitation_accepted.html.erb +4 -12
  89. data/app/views/spree/invitation_mailer/invitation_email.html.erb +11 -24
  90. data/app/views/spree/report_mailer/report_done.html.erb +6 -14
  91. data/app/views/spree/shared/_base_mailer_footer.html.erb +15 -8
  92. data/app/views/spree/shared/_base_mailer_header.html.erb +7 -5
  93. data/app/views/spree/shared/_base_mailer_stylesheets.html.erb +211 -273
  94. data/app/views/spree/shared/_mailer_button.html.erb +10 -0
  95. data/app/views/spree/shared/_mailer_hero.html.erb +12 -0
  96. data/app/views/spree/shared/_purchased_items_styles.html.erb +112 -0
  97. data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +39 -14
  98. data/config/initializers/spree_store_setup_tasks.rb +23 -0
  99. data/config/locales/en.yml +42 -992
  100. data/db/migrate/20260613000001_add_store_id_to_spree_role_users.rb +10 -0
  101. data/db/migrate/20260626000001_add_store_id_to_spree_taxons.rb +11 -0
  102. data/db/migrate/20260627000001_add_products_count_to_spree_taxons.rb +6 -0
  103. data/db/migrate/20260628000001_add_store_id_to_spree_promotions.rb +10 -0
  104. data/db/migrate/20260628000002_add_store_id_to_spree_payment_methods.rb +11 -0
  105. data/db/migrate/20260630000001_add_preorder_fields_to_spree_variants.rb +7 -0
  106. data/db/migrate/20260707000001_add_fingerprint_to_spree_credit_cards.rb +39 -0
  107. data/db/migrate/20260710000001_add_import_id_status_index_to_spree_import_rows.rb +7 -0
  108. data/db/migrate/20260711000001_add_preferences_to_spree_exports.rb +7 -0
  109. data/db/sample_data/promotions.rb +1 -1
  110. data/lib/generators/spree/authentication/dummy/templates/create_spree_admin_users.rb.tt +0 -1
  111. data/lib/mobility/plugins/store_based_fallbacks.rb +7 -5
  112. data/lib/spree/core/configuration.rb +1 -0
  113. data/lib/spree/core/controller_helpers/locale.rb +4 -1
  114. data/lib/spree/core/controller_helpers/strong_parameters.rb +1 -0
  115. data/lib/spree/core/dependencies.rb +3 -0
  116. data/lib/spree/core/engine.rb +24 -0
  117. data/lib/spree/core/preferences/preferable.rb +6 -1
  118. data/lib/spree/core/previews/admin_user_preview.rb +22 -0
  119. data/lib/spree/core/previews/export_preview.rb +37 -0
  120. data/lib/spree/core/previews/invitation_preview.rb +47 -0
  121. data/lib/spree/core/previews/preview_data.rb +52 -0
  122. data/lib/spree/core/previews/report_preview.rb +40 -0
  123. data/lib/spree/core/previews/webhook_preview.rb +34 -0
  124. data/lib/spree/core/version.rb +1 -1
  125. data/lib/spree/core.rb +38 -1
  126. data/lib/spree/permitted_attributes.rb +5 -4
  127. data/lib/spree/testing_support/factories/export_factory.rb +4 -0
  128. data/lib/spree/testing_support/factories/order_factory.rb +1 -1
  129. data/lib/spree/testing_support/factories/payment_factory.rb +3 -3
  130. data/lib/spree/testing_support/factories/payment_method_factory.rb +1 -9
  131. data/lib/spree/testing_support/factories/payment_session_factory.rb +2 -2
  132. data/lib/spree/testing_support/factories/promotion_factory.rb +1 -9
  133. data/lib/spree/testing_support/factories/promotion_rule_factory.rb +24 -0
  134. data/lib/spree/translations.rb +113 -0
  135. data/lib/spree/upgrades/5_5_to_5_6/manifest.yml +59 -0
  136. data/lib/tasks/role_users.rake +21 -0
  137. data/lib/tasks/single_store_associations.rake +65 -0
  138. data/lib/tasks/taxons.rake +62 -1
  139. metadata +47 -5
  140. data/app/models/spree/product/legacy_multi_store_support.rb +0 -40
@@ -4,6 +4,8 @@ require 'stringex'
4
4
 
5
5
  module Spree
6
6
  class Taxon < Spree.base_class
7
+ include Spree::SingleStoreResource
8
+
7
9
  has_prefix_id :ctg
8
10
 
9
11
  RULES_MATCH_POLICIES = %w[all any].freeze
@@ -37,7 +39,7 @@ module Spree
37
39
  # Associations
38
40
  #
39
41
  belongs_to :taxonomy, class_name: 'Spree::Taxonomy', inverse_of: :taxons
40
- has_one :store, through: :taxonomy
42
+ belongs_to :store, class_name: 'Spree::Store', optional: true
41
43
  has_many :classifications, -> { order(:position) }, dependent: :destroy_async, inverse_of: :taxon
42
44
  has_many :products, through: :classifications
43
45
 
@@ -56,9 +58,16 @@ module Spree
56
58
  #
57
59
  # Validations
58
60
  #
59
- validates :name, presence: true, uniqueness: { scope: %i[parent_id taxonomy_id], case_sensitive: false }
60
- validates :taxonomy, presence: true
61
- validates :permalink, uniqueness: { case_sensitive: false, scope: %i[parent_id taxonomy_id] }
61
+ validates :name, presence: true
62
+ validates :taxonomy, presence: true, if: :requires_taxonomy?
63
+ validates :store, presence: true
64
+ # Taxonomy-backed taxons are unique within their taxonomy; taxonomy-less
65
+ # categories (store-owned) are unique within their store, so two stores can
66
+ # each have a top-level "Shoes".
67
+ validates :name, uniqueness: { scope: %i[parent_id taxonomy_id], case_sensitive: false }, if: :requires_taxonomy?
68
+ validates :permalink, uniqueness: { scope: %i[parent_id taxonomy_id], case_sensitive: false }, if: :requires_taxonomy?
69
+ validates :name, uniqueness: { scope: %i[parent_id store_id], case_sensitive: false }, unless: :requires_taxonomy?
70
+ validates :permalink, uniqueness: { scope: %i[parent_id store_id], case_sensitive: false }, unless: :requires_taxonomy?
62
71
  validates :hide_from_nav, inclusion: { in: [true, false] }
63
72
  validate :check_for_root, on: :create
64
73
  validate :parent_belongs_to_same_taxonomy
@@ -81,13 +90,32 @@ module Spree
81
90
  after_commit :regenerate_pretty_name_and_permalink, on: :update, if: :should_regenerate_pretty_name_and_permalink?
82
91
  after_move :regenerate_pretty_name_and_permalink
83
92
  after_move :regenerate_translations_pretty_name_and_permalink
93
+ # Moving a subtree shifts its products under a new ancestor chain (and away
94
+ # from the old one), so recompute both branches' inclusive products_count.
95
+ # Capture the old parent before the move; recompute both chains after.
96
+ before_move :capture_parent_before_move
97
+ after_move :recalculate_products_count_after_move
98
+ # Destroying a taxon drops its subtree's products from every ancestor's
99
+ # inclusive count. Recompute the ancestors here (while the doomed subtree's
100
+ # rows still exist) excluding that subtree, since the Classification destroy
101
+ # callbacks can't help: classifications are removed via destroy_async, so
102
+ # they outlive this callback and their taxon may already be gone.
103
+ before_destroy :recalculate_ancestors_before_destroy, prepend: true
84
104
 
85
105
  #
86
106
  # Scopes
87
107
  #
88
- scope :for_store, ->(store) { joins(:taxonomy).where(spree_taxonomies: { store_id: store.id }) }
89
- scope :for_stores, ->(stores) { joins(:taxonomy).where(spree_taxonomies: { store_id: stores.ids }) }
108
+ # Prefer the direct store_id column; fall back to the taxonomy join for rows
109
+ # not yet backfilled (store_id IS NULL) so legacy behaviour is preserved.
110
+ scope :for_store, ->(store) { for_stores([store]) }
111
+ scope :for_stores, lambda { |stores|
112
+ store_ids = Array(stores).map(&:id)
113
+ taxonomy_ids = Spree::Taxonomy.where(store_id: store_ids).select(:id)
114
+ where(store_id: store_ids).or(where(store_id: nil, taxonomy_id: taxonomy_ids))
115
+ }
90
116
  scope :for_taxonomy, lambda { |taxonomy_name|
117
+ Spree::Deprecation.warn('Spree::Taxon.for_taxonomy is deprecated and will be removed in Spree 6. Please use for_store instead.')
118
+
91
119
  if Spree.use_translations?
92
120
  joins(:taxonomy)
93
121
  .join_translation_table(Taxonomy)
@@ -121,13 +149,14 @@ module Spree
121
149
  #
122
150
  self.whitelisted_ransackable_associations = %w[taxonomy parent]
123
151
  self.whitelisted_ransackable_attributes = %w[name permalink automatic depth is_root children_count
124
- classification_count hide_from_nav parent_id]
152
+ classification_count products_count pretty_name hide_from_nav parent_id]
125
153
 
126
154
  #
127
155
  # Translations
128
156
  #
129
157
  TRANSLATABLE_FIELDS = %i[name pretty_name description permalink].freeze
130
- translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?)
158
+ RICH_TEXT_TRANSLATABLE_FIELDS = %i[description].freeze
159
+ translates(*TRANSLATABLE_FIELDS, column_fallback: Spree.mobility_column_fallback)
131
160
 
132
161
  #
133
162
  # Action Text
@@ -154,6 +183,18 @@ module Spree
154
183
  !automatic?
155
184
  end
156
185
 
186
+ # The owning store. Prefers the direct +store_id+; falls back to the
187
+ # taxonomy's store for legacy rows not yet backfilled.
188
+ def store
189
+ super || taxonomy&.store
190
+ end
191
+
192
+ # Whether a taxonomy is mandatory. Legacy +Spree::Taxon+ requires one;
193
+ # +Spree::Category+ overrides this to false (it is owned via +store_id+).
194
+ def requires_taxonomy?
195
+ true
196
+ end
197
+
157
198
  def manual_sort_order?
158
199
  sort_order == 'manual'
159
200
  end
@@ -169,6 +210,26 @@ module Spree
169
210
  )
170
211
  end
171
212
 
213
+ # Recomputes the stored, descendant-inclusive +products_count+ for the given
214
+ # taxons AND all of their ancestors — the nodes whose inclusive count can
215
+ # shift when a classification changes. Counts unique products classified
216
+ # under each node or its descendants (a product reachable through several
217
+ # nodes is counted once per ancestor). Call after any classification change.
218
+ #
219
+ # @param taxon_ids [Array<Integer>] taxons whose branch counts changed
220
+ def self.recalculate_products_count(taxon_ids)
221
+ changed = unscoped.where(id: Array(taxon_ids).compact.uniq)
222
+
223
+ # The affected nodes are each changed taxon plus its ancestors.
224
+ affected = changed.flat_map { |taxon| taxon.self_and_ancestors.to_a }.uniq(&:id)
225
+
226
+ affected.each do |taxon|
227
+ count = Spree::Classification.where(taxon_id: taxon.self_and_descendants.select(:id))
228
+ .distinct.count(:product_id)
229
+ taxon.update_column(:products_count, count) if taxon.products_count != count
230
+ end
231
+ end
232
+
172
233
  def products_matching_rules(opts = {})
173
234
  return Spree::Product.none if manual? || rules.empty?
174
235
 
@@ -379,6 +440,7 @@ module Spree
379
440
  end
380
441
 
381
442
  def sync_taxonomy_name
443
+ return unless taxonomy.present?
382
444
  return unless saved_changes.key?(:name) && root?
383
445
  return if taxonomy.name.to_s == name.to_s
384
446
 
@@ -389,7 +451,33 @@ module Spree
389
451
  # Touches all ancestors at once to avoid recursive taxonomy touch, and reduce queries.
390
452
  ancestors.update_all(updated_at: Time.current)
391
453
  # Have taxonomy touch happen in #touch_ancestors_and_taxonomy rather than association option in order for imports to override.
392
- taxonomy.try!(:touch)
454
+ taxonomy.touch if taxonomy.present?
455
+ end
456
+
457
+ def capture_parent_before_move
458
+ @parent_id_before_move = parent_id_in_database
459
+ end
460
+
461
+ # Recompute the inclusive products_count for the moved node's new ancestor
462
+ # chain and the previous parent's chain too (whose subtree just lost this
463
+ # node's products).
464
+ def recalculate_products_count_after_move
465
+ affected = [id, @parent_id_before_move].compact
466
+ @parent_id_before_move = nil
467
+ self.class.recalculate_products_count(affected)
468
+ end
469
+
470
+ # Recomputes each ancestor's inclusive products_count as if this subtree were
471
+ # already gone (its classifications are destroyed asynchronously, so they're
472
+ # still present here). Excludes self_and_descendants from the count.
473
+ def recalculate_ancestors_before_destroy
474
+ doomed_ids = self_and_descendants.ids
475
+ ancestors.each do |ancestor|
476
+ remaining = Spree::Classification.
477
+ where(taxon_id: ancestor.self_and_descendants.where.not(id: doomed_ids).select(:id)).
478
+ distinct.count(:product_id)
479
+ ancestor.update_column(:products_count, remaining) if ancestor.products_count != remaining
480
+ end
393
481
  end
394
482
 
395
483
  def check_for_root
@@ -408,6 +496,21 @@ module Spree
408
496
  self.taxonomy = parent.taxonomy if parent.present? && taxonomy.blank?
409
497
  end
410
498
 
499
+ def set_store
500
+ Spree::Deprecation.warn('Spree::Taxon#set_store is deprecated and will be removed in Spree 6.0. ensure_store instead.')
501
+ ensure_store
502
+ end
503
+
504
+ # Every taxon is store-owned. Resolve the store from the taxonomy, then the
505
+ # parent, finally the current store — so the direct column is always
506
+ # populated for new records. Guards on the raw +store_id+ column rather than
507
+ # +#store+, whose reader masks an unset column with the taxonomy fallback.
508
+ def ensure_store
509
+ return if store_id.present?
510
+
511
+ self.store = taxonomy&.store || parent&.store || Spree::Store.current
512
+ end
513
+
411
514
  def regenerate_translations_pretty_name_and_permalink
412
515
  translations.each(&:regenerate_pretty_name_and_permalink)
413
516
  end
@@ -8,7 +8,7 @@ module Spree
8
8
  include Spree::SingleStoreResource
9
9
 
10
10
  TRANSLATABLE_FIELDS = %i[name].freeze
11
- translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?)
11
+ translates(*TRANSLATABLE_FIELDS, column_fallback: Spree.mobility_column_fallback)
12
12
 
13
13
  acts_as_list
14
14
 
@@ -0,0 +1,104 @@
1
+ module Spree
2
+ module Translations
3
+ # Atomically upserts translations across MANY records of (possibly)
4
+ # different translatable resource types in one operation — the domain
5
+ # behind +POST /api/v3/admin/translations/batch+.
6
+ #
7
+ # The input is a flat list of independent registry writes, each naming its
8
+ # own +resource_type+ + +resource_id+ (no nested parent-owns-children
9
+ # payload), so there's no per-model branching. Records are resolved within
10
+ # the current store, then upserted in a single transaction: any failure
11
+ # rolls back the whole batch and surfaces the offending entry's index via a
12
+ # typed {EntryError}.
13
+ #
14
+ # @example
15
+ # Spree::Translations::Batch.new(entries).process! do |record|
16
+ # authorize!(:update, record)
17
+ # end
18
+ class Batch
19
+ include ActiveModel::Model
20
+
21
+ # Raised when the batch payload is empty (or not a list of entries).
22
+ class EmptyError < StandardError; end
23
+
24
+ # Raised when an entry can't be processed — unknown/non-translatable
25
+ # resource type, a record missing in the current store, or an invalid
26
+ # save. Carries the entry +index+ so the caller can map the error back to
27
+ # the offending row.
28
+ class EntryError < StandardError
29
+ attr_reader :index
30
+
31
+ def initialize(message, index)
32
+ @index = index
33
+ super(message)
34
+ end
35
+ end
36
+
37
+ # @return [Array<Spree.base_class>] records written by the last successful {#process!}
38
+ attr_reader :records
39
+
40
+ # @param entries [Array<Hash>] [{ resource_type:, resource_id:, values: { locale => { field => value } } }]
41
+ def initialize(entries)
42
+ @entries = Array(entries)
43
+ @records = []
44
+ end
45
+
46
+ # Distinct +write_<resource>+ scopes a caller needs to authorize this
47
+ # batch — one per resource type present. Lets the API layer gate an
48
+ # API-key request without re-deriving scope names.
49
+ # @return [Array<String>]
50
+ def required_scopes
51
+ @entries.map { |entry| "write_#{entry[:resource_type].to_s.pluralize}" }.uniq
52
+ end
53
+
54
+ # Upserts every entry in one transaction. Yields each resolved record
55
+ # before it is written so the caller can authorize it; a raised error in
56
+ # the block (or any entry failure) rolls back the whole batch.
57
+ #
58
+ # @yieldparam record [Spree.base_class] the resolved record, pre-write
59
+ # @raise [EmptyError] when there are no entries
60
+ # @raise [EntryError] when an entry can't be resolved or saved
61
+ # @return [Array<Spree.base_class>] the written records
62
+ def process!
63
+ raise EmptyError if @entries.empty?
64
+
65
+ @records = []
66
+ ActiveRecord::Base.transaction do
67
+ @entries.each_with_index do |entry, index|
68
+ record = resolve_record!(entry, index)
69
+ yield record if block_given?
70
+ upsert!(record, entry[:values], index)
71
+ @records << record
72
+ end
73
+ end
74
+ @records
75
+ end
76
+
77
+ private
78
+
79
+ def upsert!(record, values, index)
80
+ record.upsert_translations(values)
81
+ rescue ActiveRecord::RecordInvalid => e
82
+ raise EntryError.new(e.record.errors.full_messages.join(', '), index)
83
+ end
84
+
85
+ def resolve_record!(entry, index)
86
+ klass = resource_class(entry[:resource_type])
87
+ raise EntryError.new("Unknown translatable resource type: #{entry[:resource_type]}", index) if klass.nil?
88
+
89
+ relation = klass.respond_to?(:for_store) ? klass.for_store(Spree::Store.current) : klass
90
+ relation.find_by_prefix_id!(entry[:resource_id])
91
+ rescue ActiveRecord::RecordNotFound
92
+ raise EntryError.new("Resource not found: #{entry[:resource_id]}", index)
93
+ end
94
+
95
+ # Memoized per instance so a batch of N entries doesn't rebuild the
96
+ # registry map N times. A Batch is request-scoped, so dev-mode class
97
+ # reloads are still picked up on the next request.
98
+ def resource_class(token)
99
+ @resource_class_map ||= Hash.new { |h, t| h[t] = Spree::Translations.resource_class(t) }
100
+ @resource_class_map[token]
101
+ end
102
+ end
103
+ end
104
+ end
@@ -5,10 +5,10 @@ module Spree
5
5
  belongs_to :user, polymorphic: true, optional: false
6
6
 
7
7
  validates :provider, presence: true
8
- validates :uid, presence: true, uniqueness: { scope: [:provider, :user_type] }
8
+ validates :uid, presence: true, uniqueness: { scope: %i[provider user_type] }
9
9
 
10
10
  validates :provider, inclusion: {
11
- in: ->(_record) {
11
+ in: lambda { |_record|
12
12
  (Spree.store_authentication_strategies.keys + Spree.admin_authentication_strategies.keys).uniq.map(&:to_s)
13
13
  }
14
14
  }
@@ -27,14 +27,7 @@ module Spree
27
27
  identity = find_by(provider: provider, uid: uid, user_type: user_type)
28
28
 
29
29
  if identity
30
- # Update existing identity with fresh tokens
31
- identity.update(
32
- info: info,
33
- access_token: tokens[:access_token],
34
- refresh_token: tokens[:refresh_token],
35
- expires_at: tokens[:expires_at]
36
- )
37
- identity.user
30
+ refresh_identity(identity, info: info, tokens: tokens).user
38
31
  else
39
32
  # Create new user and identity
40
33
  create_user_from_oauth(
@@ -45,28 +38,46 @@ module Spree
45
38
  user_class: user_class
46
39
  )
47
40
  end
41
+ rescue ActiveRecord::RecordNotUnique
42
+ refresh_identity(
43
+ find_by!(provider: provider, uid: uid, user_type: user_type),
44
+ info: info,
45
+ tokens: tokens
46
+ ).user
48
47
  end
49
48
 
50
- def self.create_user_from_oauth(provider:, uid:, info:, tokens: {}, user_class: nil)
51
- user_class ||= Spree.user_class
52
-
53
- user = user_class.create!(
54
- email: info[:email] || generate_temp_email(provider, uid),
55
- password: SecureRandom.hex(32), # Random password for OAuth users
56
- first_name: info[:first_name],
57
- last_name: info[:last_name]
58
- )
59
-
60
- user.identities.create!(
61
- provider: provider,
62
- uid: uid,
49
+ def self.refresh_identity(identity, info:, tokens: {})
50
+ identity.update(
63
51
  info: info,
64
52
  access_token: tokens[:access_token],
65
53
  refresh_token: tokens[:refresh_token],
66
54
  expires_at: tokens[:expires_at]
67
55
  )
56
+ identity
57
+ end
58
+
59
+ def self.create_user_from_oauth(provider:, uid:, info:, tokens: {}, user_class: nil)
60
+ user_class ||= Spree.user_class
61
+
62
+ transaction do
63
+ user = user_class.create!(
64
+ email: info[:email] || generate_temp_email(provider, uid),
65
+ password: SecureRandom.hex(32), # Random password for OAuth users
66
+ first_name: info[:first_name],
67
+ last_name: info[:last_name]
68
+ )
68
69
 
69
- user
70
+ user.identities.create!(
71
+ provider: provider,
72
+ uid: uid,
73
+ info: info,
74
+ access_token: tokens[:access_token],
75
+ refresh_token: tokens[:refresh_token],
76
+ expires_at: tokens[:expires_at]
77
+ )
78
+
79
+ user
80
+ end
70
81
  end
71
82
 
72
83
  def self.generate_temp_email(provider, uid)
@@ -13,7 +13,7 @@ module Spree
13
13
 
14
14
  publishes_lifecycle_events
15
15
 
16
- MEMOIZED_METHODS = %w(purchasable in_stock on_sale backorderable tax_category options_text compare_at_price)
16
+ MEMOIZED_METHODS = %w(in_stock on_sale backorderable tax_category options_text compare_at_price)
17
17
 
18
18
  DIMENSION_UNITS = %w[mm cm in ft]
19
19
  WEIGHT_UNITS = %w[g kg lb oz]
@@ -88,6 +88,8 @@ module Spree
88
88
  validates :dimensions_unit, inclusion: { in: DIMENSION_UNITS }, allow_blank: true
89
89
  validates :weight_unit, inclusion: { in: WEIGHT_UNITS }, allow_blank: true
90
90
 
91
+ validates :backorder_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0, allow_nil: true }
92
+
91
93
  after_create :create_stock_items
92
94
  after_create :set_master_out_of_stock, unless: :is_master?
93
95
  after_commit :clear_line_items_cache, on: :update
@@ -238,6 +240,19 @@ module Spree
238
240
  !discontinued? && product.available?
239
241
  end
240
242
 
243
+ # Returns true if the variant is sold as a pre-order: the product is active
244
+ # and not deleted, the variant is flagged preorderable and not discontinued,
245
+ # and the "ships by" date is open-ended or still in the future.
246
+ # Pre-order relaxes only the publish-date embargo, not the rest of the
247
+ # availability rules. Like backorder, a pre-order can make the variant
248
+ # purchasable beyond on-hand stock; the oversell cap is +backorder_limit+
249
+ # (empty ⇒ unlimited), and it adds the ship-by promise.
250
+ # @return [Boolean] true if the variant is a pre-order
251
+ def preorder?
252
+ !discontinued? && preorderable? && product.active? && !product.deleted? &&
253
+ (preorder_ships_at.nil? || preorder_ships_at > Time.current)
254
+ end
255
+
241
256
  # Returns true if the variant is in stock or backorderable.
242
257
  # @return [Boolean] true if the variant is in stock or backorderable
243
258
  def in_stock_or_backorderable?
@@ -647,7 +662,19 @@ module Spree
647
662
  alias is_backorderable? backorderable?
648
663
 
649
664
  def purchasable?
650
- @purchasable ||= in_stock? || backorderable?
665
+ in_stock? || oversellable_now?
666
+ end
667
+
668
+ # Whether the variant can currently be bought by overselling — via
669
+ # backorder or pre-order. Unlimited when +backorder_limit+ is nil (empty =
670
+ # no cap); with a limit, purchasable only while the oversell allowance
671
+ # remains.
672
+ #
673
+ # @return [Boolean]
674
+ def oversellable_now?
675
+ return false unless backorderable? || preorder?
676
+
677
+ backorder_limit.nil? || can_supply?(1)
651
678
  end
652
679
 
653
680
  # Shortcut method to determine if inventory tracking is enabled for this variant
@@ -58,6 +58,9 @@ module Spree
58
58
  status: product.status,
59
59
  sku: product.sku,
60
60
  in_stock: product.in_stock?,
61
+ # True when the product has an active pre-order variant, so a
62
+ # scheduled (future-published) launch still surfaces in search.
63
+ preorder: product.preorder?,
61
64
  store_ids: Array(product.store_id).map(&:to_s),
62
65
  channel_ids: channel_ids_for_store,
63
66
  discontinue_on: product.discontinue_on&.to_i || 0,
@@ -10,6 +10,9 @@ module Spree
10
10
  def call(cart:)
11
11
  return success(cart) if cart.completed?
12
12
  return failure(cart, 'Order is canceled') if cart.canceled?
13
+ # Enforced here (not only in the controller) so every completion path —
14
+ # API, payment-session webhook — honors the channel's guest-checkout gate.
15
+ return failure(cart, Spree.t(:guest_checkout_not_allowed)) if cart.guest_checkout_disallowed?
13
16
 
14
17
  cart.with_lock do
15
18
  process_payments!(cart) if cart.payment_required?