spree_core 5.5.2 → 5.6.0.rc1

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 (137) 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/jobs/spree/imports/process_group_job.rb +20 -9
  7. data/app/jobs/spree/imports/process_rows_job.rb +12 -4
  8. data/app/mailers/spree/admin_user_mailer.rb +71 -0
  9. data/app/mailers/spree/base_mailer.rb +73 -11
  10. data/app/mailers/spree/export_mailer.rb +7 -7
  11. data/app/mailers/spree/import_mailer.rb +19 -0
  12. data/app/mailers/spree/invitation_mailer.rb +16 -11
  13. data/app/mailers/spree/report_mailer.rb +6 -6
  14. data/app/mailers/spree/webhook_mailer.rb +7 -6
  15. data/app/models/concerns/spree/admin_user_methods.rb +44 -0
  16. data/app/models/concerns/spree/legacy_multi_store_support.rb +48 -0
  17. data/app/models/concerns/spree/presentation_translatable.rb +39 -0
  18. data/app/models/concerns/spree/product_scopes.rb +21 -3
  19. data/app/models/concerns/spree/single_store_resource.rb +5 -0
  20. data/app/models/concerns/spree/store_scoped_resource.rb +10 -0
  21. data/app/models/concerns/spree/stores/markets.rb +9 -1
  22. data/app/models/concerns/spree/stores/setup.rb +37 -23
  23. data/app/models/concerns/spree/translatable_resource.rb +95 -5
  24. data/app/models/concerns/spree/user_methods.rb +27 -2
  25. data/app/models/spree/ability.rb +5 -4
  26. data/app/models/spree/allowed_origin.rb +31 -0
  27. data/app/models/spree/category.rb +12 -2
  28. data/app/models/spree/channel/gating.rb +67 -0
  29. data/app/models/spree/channel.rb +1 -0
  30. data/app/models/spree/classification.rb +12 -0
  31. data/app/models/spree/country.rb +22 -1
  32. data/app/models/spree/credit_card.rb +29 -0
  33. data/app/models/spree/currency.rb +47 -0
  34. data/app/models/spree/current.rb +12 -1
  35. data/app/models/spree/customer_group.rb +1 -0
  36. data/app/models/spree/export.rb +19 -0
  37. data/app/models/spree/exports/coupon_codes.rb +1 -2
  38. data/app/models/spree/import.rb +93 -1
  39. data/app/models/spree/import_row.rb +7 -1
  40. data/app/models/spree/imports/product_translations.rb +6 -0
  41. data/app/models/spree/locale.rb +104 -0
  42. data/app/models/spree/option_type.rb +11 -13
  43. data/app/models/spree/option_value.rb +5 -17
  44. data/app/models/spree/order.rb +19 -2
  45. data/app/models/spree/payment_method.rb +6 -5
  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/variant.rb +29 -2
  69. data/app/presenters/spree/search_provider/product_presenter.rb +3 -0
  70. data/app/services/spree/carts/complete.rb +3 -0
  71. data/app/services/spree/fulfillments/create.rb +259 -0
  72. data/app/services/spree/imports/row_processors/base.rb +9 -0
  73. data/app/services/spree/imports/row_processors/product_variant.rb +37 -19
  74. data/app/services/spree/locales/set_fallback_locale_for_store.rb +7 -1
  75. data/app/services/spree/locales.rb +52 -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 +22 -1
  126. data/lib/spree/permitted_attributes.rb +6 -5
  127. data/lib/spree/testing_support/factories/export_factory.rb +4 -0
  128. data/lib/spree/testing_support/factories/payment_method_factory.rb +1 -9
  129. data/lib/spree/testing_support/factories/promotion_factory.rb +1 -9
  130. data/lib/spree/testing_support/factories/promotion_rule_factory.rb +24 -0
  131. data/lib/spree/translations.rb +113 -0
  132. data/lib/spree/upgrades/5_5_to_5_6/manifest.yml +59 -0
  133. data/lib/tasks/role_users.rake +21 -0
  134. data/lib/tasks/single_store_associations.rake +65 -0
  135. data/lib/tasks/taxons.rake +62 -1
  136. metadata +47 -5
  137. data/app/models/spree/product/legacy_multi_store_support.rb +0 -40
@@ -0,0 +1,37 @@
1
+ # A rule to limit a promotion based on the order's market.
2
+ module Spree
3
+ class Promotion
4
+ module Rules
5
+ class Market < PromotionRule
6
+ # Stored as raw IDs. Accepts prefixed IDs (`mkt_…`) from API
7
+ # callers and decodes them on write so eligibility checks compare
8
+ # against the order's raw `market_id` directly. Scope confines the
9
+ # existence check to the promotion's store so cross-store market
10
+ # IDs can't sneak in.
11
+ preference :market_ids, :array, default: [],
12
+ parse_on_set: normalize_id_preference(
13
+ klass: Spree::Market,
14
+ scope: ->(rule) { rule.promotion&.store&.markets || Spree::Market.none }
15
+ )
16
+
17
+ def applicable?(promotable)
18
+ promotable.is_a?(Spree::Order)
19
+ end
20
+
21
+ def markets
22
+ return Spree::Market.none if preferred_market_ids.blank?
23
+
24
+ promotion.store.markets.where(id: preferred_market_ids)
25
+ end
26
+
27
+ def eligible?(order, _options = {})
28
+ return false if preferred_market_ids.empty?
29
+ return true if preferred_market_ids.map(&:to_s).include?(order.market_id.to_s)
30
+
31
+ eligibility_errors.add(:base, eligibility_error_message(:no_matching_market))
32
+ false
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -2,12 +2,14 @@ module Spree
2
2
  class Promotion < Spree.base_class
3
3
  has_prefix_id :promo # Spree-specific: promotion
4
4
 
5
- include Spree::StoreScopedResource
5
+ include Spree::SingleStoreResource
6
6
  include Spree::Metafields
7
7
  include Spree::Metadata
8
8
  if defined?(Spree::Security::Promotions)
9
9
  include Spree::Security::Promotions
10
10
  end
11
+ # Multi-store sharing moved to the spree_multi_store extension in 5.6.
12
+ include Spree::LegacyMultiStoreSupport unless defined?(SpreeMultiStore)
11
13
 
12
14
  publishes_lifecycle_events
13
15
 
@@ -37,8 +39,7 @@ module Spree
37
39
  has_many :coupon_codes, -> { order(created_at: :asc) }, dependent: :destroy, class_name: 'Spree::CouponCode'
38
40
  has_many :order_promotions, class_name: 'Spree::OrderPromotion'
39
41
  has_many :orders, through: :order_promotions, class_name: 'Spree::Order'
40
- has_many :store_promotions, class_name: 'Spree::StorePromotion'
41
- has_many :stores, class_name: 'Spree::Store', through: :store_promotions
42
+ belongs_to :store, class_name: 'Spree::Store'
42
43
 
43
44
  after_save :apply_pending_rules_and_actions, if: :pending_rules_or_actions?
44
45
 
@@ -60,6 +61,7 @@ module Spree
60
61
  #
61
62
  validates_associated :rules
62
63
  validates :name, presence: true
64
+ validates :store, presence: true, unless: -> { Spree::Config[:disable_store_presence_validation] }
63
65
  validates :usage_limit, numericality: { greater_than: 0, allow_nil: true }
64
66
  validates :description, length: { maximum: 255 }, allow_blank: true
65
67
  validate :expires_at_must_be_later_than_starts_at, if: -> { starts_at && expires_at }
@@ -11,7 +11,7 @@ module Spree
11
11
  @new_promotion.path = "#{@promotion.path}_#{@random_string}"
12
12
  @new_promotion.name = "New #{@promotion.name}"
13
13
  @new_promotion.code = "#{@promotion.code}_#{@random_string}"
14
- @new_promotion.stores = @promotion.stores
14
+ @new_promotion.store = @promotion.store
15
15
 
16
16
  ActiveRecord::Base.transaction do
17
17
  @new_promotion.save
@@ -1,11 +1,14 @@
1
1
  module Spree
2
2
  class RoleUser < Spree.base_class
3
+ include Spree::SingleStoreResource
4
+
3
5
  #
4
6
  # Associations
5
7
  #
6
8
  belongs_to :role, class_name: 'Spree::Role', foreign_key: :role_id
7
9
  belongs_to :user, polymorphic: true
8
10
  belongs_to :resource, polymorphic: true
11
+ belongs_to :store, class_name: 'Spree::Store'
9
12
  belongs_to :invitation, class_name: 'Spree::Invitation', optional: true, inverse_of: :role_user
10
13
 
11
14
  #
@@ -14,6 +17,7 @@ module Spree
14
17
  validates :role, presence: true
15
18
  validates :user, presence: true
16
19
  validates :resource, presence: true
20
+ validates :store, presence: true
17
21
  validates :role_id, uniqueness: { scope: [:user_id, :resource_id, :user_type, :resource_type] }
18
22
 
19
23
  #
@@ -158,6 +158,7 @@ module Spree
158
158
 
159
159
  Rails.logger.debug { "[Meilisearch] index=#{index_name} query=#{query.inspect} #{search_params.compact.inspect}" }
160
160
 
161
+ settings_refreshed = false
161
162
  begin
162
163
  if return_facets && grouped_options.any?
163
164
  queries = [{ indexUid: index_name, q: query.to_s, **search_params }]
@@ -175,6 +176,16 @@ module Spree
175
176
  facet_distribution = ms_result['facetDistribution'] || {}
176
177
  end
177
178
  rescue ::Meilisearch::ApiError => e
179
+ # Self-heal when a newly added filterable attribute (e.g. `preorder`)
180
+ # is referenced before the index settings were refreshed — otherwise
181
+ # every search on an upgraded store returns empty until a reindex or
182
+ # the next product write.
183
+ if e.code == 'invalid_search_filter' && !settings_refreshed
184
+ settings_refreshed = true
185
+ ensure_index_settings!
186
+ retry
187
+ end
188
+
178
189
  Rails.logger.warn { "[Meilisearch] Search failed: #{e.message}. Run `rake spree:search:reindex` to initialize the index." }
179
190
  Rails.error.report(e, handled: true, context: { index: index_name, query: query })
180
191
  return nil
@@ -233,7 +244,7 @@ module Spree
233
244
  end
234
245
 
235
246
  def filterable_attributes
236
- %w[product_id status in_stock store_ids channel_ids locale currency available_on discontinue_on price category_ids tags option_value_ids]
247
+ %w[product_id status in_stock preorder store_ids channel_ids locale currency available_on discontinue_on price category_ids tags option_value_ids]
237
248
  end
238
249
 
239
250
  def sortable_attributes
@@ -266,14 +277,16 @@ module Spree
266
277
  conditions << "status = 'active'"
267
278
  conditions << "locale = '#{locale.to_s.gsub(/[^a-zA-Z_-]/, '')}'"
268
279
  conditions << "currency = '#{currency.to_s.gsub(/[^A-Z]/, '')}'"
269
- # Exclude future-dated products — mirrors +Product.available(Time.current)+.
270
- # ISO 8601 strings sort lexicographically in chronological order, so the
271
- # string compare is sound. +NOT EXISTS+ catches docs indexed before this
272
- # attribute was emitted (legacy indexes), +IS NULL+ catches docs where
273
- # the field was emitted as explicit null, and the +<=+ clause filters
274
- # the remaining future-dated docs so the upgrade is non-breaking and
275
- # no reindex is required.
276
- conditions << "(available_on NOT EXISTS OR available_on IS NULL OR available_on <= '#{now.iso8601}')"
280
+ # Exclude future-dated products — mirrors
281
+ # +Product.available(Time.current, include_preorderable: true)+. ISO 8601
282
+ # strings sort lexicographically in chronological order, so the string
283
+ # compare is sound; +NOT EXISTS+/+IS NULL+ keep the available_on clause
284
+ # backward-compatible with legacy docs. The trailing +preorder = true+
285
+ # keeps scheduled "coming soon" pre-orders searchable before their
286
+ # publish date. It filters on the new +preorder+ attribute, so a
287
+ # +rake spree:search:reindex+ (or the next product write, which refreshes
288
+ # the index settings) is required after deploy before it takes effect.
289
+ conditions << "(available_on NOT EXISTS OR available_on IS NULL OR available_on <= '#{now.iso8601}' OR preorder = true)"
277
290
  conditions << "(discontinue_on = 0 OR discontinue_on > #{now.to_i})"
278
291
  conditions
279
292
  end
@@ -0,0 +1,12 @@
1
+ module Spree
2
+ # An evaluated onboarding task — the serializable result of running a
3
+ # {Spree::SetupTasks} registry entry against its subject (e.g. a store).
4
+ # See {Spree::Store#setup_tasks}.
5
+ class SetupTask
6
+ include ActiveModel::Model
7
+ include ActiveModel::Attributes
8
+
9
+ attribute :name, :string
10
+ attribute :done, :boolean
11
+ end
12
+ end
@@ -0,0 +1,86 @@
1
+ module Spree
2
+ # Registry of onboarding tasks for a given subject type. `Spree.store_setup_tasks`
3
+ # holds the Getting Started checklist evaluated against a store; future
4
+ # registries (e.g. vendor onboarding) instantiate the same class against
5
+ # their own subject. Defaults are registered in
6
+ # `config/initializers/spree_store_setup_tasks.rb`; extensions and host apps
7
+ # can add or remove tasks at boot:
8
+ #
9
+ # Rails.application.config.after_initialize do
10
+ # Spree.store_setup_tasks.add :connect_stripe,
11
+ # position: 25,
12
+ # done: ->(store) { store.payment_methods.active.exists?(type: 'Spree::PaymentMethod::Stripe') }
13
+ #
14
+ # Spree.store_setup_tasks.remove :setup_taxes_collection
15
+ # end
16
+ #
17
+ # The registry holds domain data only — each frontend maps task keys to its
18
+ # own presentation: the legacy admin renders the
19
+ # `spree/admin/dashboard/setup_tasks/_<key>` partial titled by the
20
+ # `admin.store_setup_tasks.<key>` translation (both resolved across engine
21
+ # paths, so extensions ship theirs by convention), and the React dashboard
22
+ # consumes tasks as {Spree::SetupTask} records via the Admin API.
23
+ class SetupTasks
24
+ class Definition
25
+ attr_reader :key, :position
26
+
27
+ def initialize(key, position:, done:, **options)
28
+ raise ArgumentError, "done: must respond to #call" unless done.respond_to?(:call)
29
+
30
+ @key = key.to_sym
31
+ @position = position
32
+ @done = done
33
+ @if = options[:if]
34
+ end
35
+
36
+ # @param subject [Object] the record the checklist belongs to (e.g. a store)
37
+ # @return [Boolean] whether the task is complete
38
+ def done?(subject)
39
+ !!@done.call(subject)
40
+ end
41
+
42
+ # @param subject [Object] the record the checklist belongs to
43
+ # @return [Boolean] whether the task applies to this subject at all
44
+ def available?(subject)
45
+ @if.nil? || !!@if.call(subject)
46
+ end
47
+ end
48
+
49
+ def initialize
50
+ @tasks = {}
51
+ end
52
+
53
+ # Registers a task (replacing any existing task with the same key).
54
+ #
55
+ # @param key [Symbol] identifies the task; frontends derive presentation from it
56
+ # @param position [Integer] sort order in the checklist
57
+ # @param done [#call] receives the subject, returns whether the task is complete
58
+ # @param options [Hash] optional :if (per-subject visibility callable)
59
+ # @return [Definition]
60
+ def add(key, position:, done:, **options)
61
+ key = key.to_sym
62
+ @tasks[key] = Definition.new(key, position: position, done: done, **options)
63
+ end
64
+
65
+ # @param key [Symbol]
66
+ # @return [Definition, nil] the removed task
67
+ def remove(key)
68
+ @tasks.delete(key.to_sym)
69
+ end
70
+
71
+ def find(key)
72
+ @tasks[key.to_sym]
73
+ end
74
+
75
+ # @return [Array<Definition>] all tasks sorted by position
76
+ def tasks
77
+ @tasks.values.sort_by(&:position)
78
+ end
79
+
80
+ # @param subject [Object] the record the checklist belongs to (e.g. a store)
81
+ # @return [Array<Definition>] the tasks applicable to the given subject, sorted by position
82
+ def for(subject)
83
+ tasks.select { |task| task.available?(subject) }
84
+ end
85
+ end
86
+ end
@@ -130,6 +130,17 @@ module Spree
130
130
  cost
131
131
  end
132
132
 
133
+ # Strict decimal bridge for string input — raises ArgumentError on
134
+ # malformed values instead of letting Rails' cast silently truncate
135
+ # ("12 boxes" would otherwise become 12.0). Blank strings cast to nil,
136
+ # matching the default Rails behavior.
137
+ #
138
+ # @param value [String, Numeric, nil]
139
+ def cost=(value)
140
+ value = value.blank? ? nil : BigDecimal(value.strip) if value.is_a?(String)
141
+ super
142
+ end
143
+
133
144
  def digital?
134
145
  shipping_method&.digital? || false
135
146
  end
@@ -347,6 +358,23 @@ module Spree
347
358
  shipping_rates
348
359
  end
349
360
 
361
+ # Public API v3 name for the selected rate (see docs/plans/6.0-fulfillment-and-delivery.md).
362
+ #
363
+ # @return [String, nil] the selected delivery rate's prefixed ID
364
+ def selected_delivery_rate_id
365
+ selected_shipping_rate&.prefixed_id
366
+ end
367
+
368
+ # Selects a delivery rate by its public prefixed ID (+dr_...+); raw IDs
369
+ # are accepted for internal callers. The model owns this naming bridge so
370
+ # API clients never deal with the legacy shipping-rate name.
371
+ #
372
+ # @param id [String, Integer] delivery rate prefixed or raw ID
373
+ def selected_delivery_rate_id=(id)
374
+ rate = Spree::PrefixedId.prefixed_id?(id) ? shipping_rates.find_by_prefix_id!(id) : shipping_rates.find(id)
375
+ self.selected_shipping_rate_id = rate.id
376
+ end
377
+
350
378
  def selected_shipping_rate_id
351
379
  selected_shipping_rate.try(:id)
352
380
  end
@@ -422,8 +450,17 @@ module Spree
422
450
  package
423
451
  end
424
452
 
453
+ # External systems (3PLs, courier APIs) often hand over a complete
454
+ # tracking link rather than a bare tracking code — returned as-is
455
+ # instead of being templated into the delivery method's tracking URL.
456
+ #
457
+ # @return [String, nil]
425
458
  def tracking_url
426
- @tracking_url ||= shipping_method&.build_tracking_url(tracking)
459
+ @tracking_url ||= if tracking&.start_with?('https://')
460
+ tracking
461
+ else
462
+ shipping_method&.build_tracking_url(tracking)
463
+ end
427
464
  end
428
465
 
429
466
  def update_amounts
@@ -7,7 +7,7 @@ module Spree
7
7
  # Do we have a specialized shipping-method-specific handler? e.g:
8
8
  # Given shipment.shipping_method = Spree::ShippingMethod::DigitalDownload
9
9
  # do we have Spree::ShipmentHandler::DigitalDownload?
10
- if sm_handler = "Spree::ShipmentHandler::#{shipment.shipping_method.name.split('::').last}".safe_constantize
10
+ if sm_handler = "Spree::ShipmentHandler::#{shipment.shipping_method&.name&.split('::')&.last}".safe_constantize
11
11
  sm_handler.new(shipment)
12
12
  else
13
13
  new(shipment)
@@ -14,7 +14,7 @@ module Spree
14
14
  display_name = variant.name.to_s
15
15
  display_name += " (#{variant.options_text})" unless variant.options_text.blank?
16
16
 
17
- if variant.available?
17
+ if variant.available? || variant.preorder?
18
18
  line_item.errors.add(:quantity,
19
19
  :selected_quantity_not_available,
20
20
  message: Spree.t(:selected_quantity_not_available, item: display_name.inspect))
@@ -88,8 +88,28 @@ module Spree
88
88
  @backorderable ||= stock_items.any?(&:backorderable)
89
89
  end
90
90
 
91
+ # Whether the requested quantity can be supplied. Beyond on-hand stock a
92
+ # variant may oversell two ways: +backorderable+ stock, or a pre-order
93
+ # (which also lifts the publish gate for a scheduled launch). Either way
94
+ # the variant's +backorder_limit+ caps how far below zero it may go; a nil
95
+ # (empty) limit means unlimited.
91
96
  def can_supply?(required = 1)
92
- variant.available? && (backorderable? || total_on_hand >= required)
97
+ return false unless variant.available? || variant.preorder?
98
+ return true unless variant.should_track_inventory?
99
+
100
+ oversellable = backorderable? || variant.preorder?
101
+ limit = variant.backorder_limit
102
+ return true if oversellable && limit.nil?
103
+
104
+ # On-hand stock, plus — for a capped oversell — the room to sell below
105
+ # zero down to +-backorder_limit+ (a single clamp of signed on-hand plus
106
+ # the limit).
107
+ supplyable = if oversellable
108
+ [available_stock - reserved_quantity + limit, 0].max
109
+ else
110
+ total_on_hand
111
+ end
112
+ supplyable >= required
93
113
  end
94
114
 
95
115
  def stock_items
@@ -107,7 +107,8 @@ module Spree
107
107
  private
108
108
 
109
109
  def verify_count_on_hand?
110
- count_on_hand_changed? && !backorderable? && (count_on_hand < count_on_hand_was) && (count_on_hand < 0)
110
+ count_on_hand_changed? && !backorderable? && !variant.preorder? &&
111
+ (count_on_hand < count_on_hand_was) && (count_on_hand < 0)
111
112
  end
112
113
 
113
114
  # Process backorders based on amount of stock received
@@ -157,7 +157,10 @@ module Spree
157
157
  else
158
158
  on_hand = item.count_on_hand
159
159
  on_hand = 0 if on_hand < 0
160
- backordered = item.backorderable? ? (quantity - on_hand) : 0
160
+ # A pre-order oversells like a backorder: the shortfall becomes
161
+ # backordered inventory units (the backorder_limit cap is enforced
162
+ # upstream by Stock::Quantifier#can_supply?).
163
+ backordered = item.backorderable? || variant.preorder? ? (quantity - on_hand) : 0
161
164
  end
162
165
 
163
166
  [on_hand, backordered]
@@ -28,7 +28,7 @@ module Spree
28
28
  #
29
29
  TRANSLATABLE_FIELDS = %i[name meta_description meta_keywords seo_title customer_support_email
30
30
  address contact_phone].freeze
31
- translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?)
31
+ translates(*TRANSLATABLE_FIELDS, column_fallback: Spree.mobility_column_fallback)
32
32
  self::Translation.class_eval do
33
33
  acts_as_paranoid
34
34
  # deleted translation values still need to be accessible - remove deleted_at scope
@@ -46,7 +46,14 @@ module Spree
46
46
  # email preferences
47
47
  preference :send_consumer_transactional_emails, :boolean, default: true
48
48
  # Checkout preferences
49
+ # Store-level fallback for the channel-owned `guest_checkout` preference
50
+ # (see Spree::Channel::Gating). Retained so existing accessors keep working.
49
51
  preference :guest_checkout, :boolean, default: true
52
+ # Store-level fallback for the channel-owned `storefront_access` posture.
53
+ preference :storefront_access, :string, default: 'public'
54
+ # Canonical storefront origin used in customer-facing emails and links,
55
+ # e.g. "https://myshop.com" — see #storefront_url for the fallback chain.
56
+ preference :storefront_url, :string
50
57
  preference :special_instructions_enabled, :boolean, default: false
51
58
  preference :stock_reservation_ttl_minutes, :integer, default: 10
52
59
  # Address preferences
@@ -74,8 +81,9 @@ module Spree
74
81
  has_many :return_authorizations, through: :orders, class_name: 'Spree::ReturnAuthorization'
75
82
  # has_many :reimbursements, through: :orders, class_name: 'Spree::Reimbursement' FIXME: we should fetch this via Customer Return
76
83
 
77
- has_many :store_payment_methods, class_name: 'Spree::StorePaymentMethod'
78
- has_many :payment_methods, through: :store_payment_methods, class_name: 'Spree::PaymentMethod'
84
+ # :nullify (not :destroy) — clearing the collection must not cascade into
85
+ # Promotion#not_used? / payment records; orphaned rows are detached, not deleted.
86
+ has_many :payment_methods, class_name: 'Spree::PaymentMethod', dependent: :nullify
79
87
 
80
88
  has_many :products, class_name: 'Spree::Product', dependent: :nullify
81
89
  has_many :product_publications, through: :channels, source: :publications, class_name: 'Spree::ProductPublication'
@@ -91,11 +99,10 @@ module Spree
91
99
  has_many :store_credit_events, through: :store_credits, class_name: 'Spree::StoreCreditEvent'
92
100
 
93
101
  has_many :taxonomies, class_name: 'Spree::Taxonomy'
94
- has_many :taxons, through: :taxonomies, class_name: 'Spree::Taxon'
95
- has_many :categories, through: :taxonomies, class_name: 'Spree::Category', source: :taxons
102
+ has_many :taxons, class_name: 'Spree::Taxon'
103
+ has_many :categories, class_name: 'Spree::Category'
96
104
 
97
- has_many :store_promotions, class_name: 'Spree::StorePromotion'
98
- has_many :promotions, through: :store_promotions, class_name: 'Spree::Promotion'
105
+ has_many :promotions, class_name: 'Spree::Promotion', dependent: :nullify
99
106
 
100
107
  has_many :wishlists, class_name: 'Spree::Wishlist'
101
108
 
@@ -133,7 +140,10 @@ module Spree
133
140
  validates :preferred_digital_asset_authorized_clicks, numericality: { only_integer: true, greater_than: 0 }
134
141
  validates :preferred_digital_asset_authorized_days, numericality: { only_integer: true, greater_than: 0 }
135
142
  validates :preferred_stock_reservation_ttl_minutes, numericality: { only_integer: true, greater_than: 0 }
143
+ validates :preferred_storefront_access, inclusion: { in: Spree::Channel::Gating::STOREFRONT_ACCESS }
144
+ validate :preferred_storefront_url_is_an_origin
136
145
  validates :mail_from_address, email: { allow_blank: false }
146
+ validates :customer_support_email, email: { allow_blank: true }
137
147
  # FIXME: we should remove this condition in v5
138
148
  if !ENV['SPREE_DISABLE_DB_CONNECTION'] &&
139
149
  connected? &&
@@ -152,6 +162,7 @@ module Spree
152
162
  #
153
163
  # Callbacks
154
164
  before_validation :set_default_code, on: :create
165
+ before_validation :normalize_preferred_storefront_url
155
166
  before_save :ensure_default_exists_and_is_unique
156
167
  after_create :create_default_policies
157
168
 
@@ -276,11 +287,15 @@ module Spree
276
287
  end
277
288
 
278
289
  # Returns the storefront origin URL for use in customer-facing emails and links.
279
- # Uses the first allowed origin if configured, otherwise falls back to formatted_url.
290
+ # Uses the `storefront_url` preference when set, then the oldest non-loopback
291
+ # allowed origin (the `http://localhost` origin seeded on install must never
292
+ # leak into customer emails), otherwise falls back to formatted_url.
280
293
  #
281
294
  # @return [String] e.g. "https://myshop.com"
282
295
  def storefront_url
283
- allowed_origins.order(:created_at).pick(:origin) || formatted_url
296
+ preferred_storefront_url.presence ||
297
+ allowed_origins.order(:created_at).reject(&:loopback?).first&.origin ||
298
+ formatted_url
284
299
  end
285
300
 
286
301
  # Returns true if the given URL's origin matches one of the store's allowed origins.
@@ -412,5 +427,26 @@ module Spree
412
427
  def set_default_code
413
428
  self.code = 'default' if code.blank?
414
429
  end
430
+
431
+ # The storefront URL preference must always hold a canonical origin — it
432
+ # becomes the base for customer-email links and completes the storefront
433
+ # setup task, and the v3 Admin API writes it without any controller-side
434
+ # normalization. Parseable input is canonicalized here; garbage is left
435
+ # in place for the validation below to reject.
436
+ def normalize_preferred_storefront_url
437
+ raw = preferred_storefront_url
438
+ return if raw.blank?
439
+
440
+ normalized = Spree::AllowedOrigin.normalize_origin(raw)
441
+ self.preferred_storefront_url = normalized if normalized
442
+ end
443
+
444
+ def preferred_storefront_url_is_an_origin
445
+ raw = preferred_storefront_url
446
+ return if raw.blank?
447
+ return if Spree::AllowedOrigin.normalize_origin(raw)
448
+
449
+ errors.add(:preferred_storefront_url, :invalid)
450
+ end
415
451
  end
416
452
  end
@@ -1,9 +1,13 @@
1
1
  module Spree
2
+ # Legacy join between Store and PaymentMethod. Superseded by the single-store
3
+ # +PaymentMethod#store+ FK in 5.6; retained only so the +spree_multi_store+
4
+ # extension can restore +has_many :stores+ and so the backfill task can read
5
+ # historic attachments. Dropped in 6.0.
2
6
  class StorePaymentMethod < Spree.base_class
3
7
  self.table_name = 'spree_payment_methods_stores'
4
8
 
5
9
  belongs_to :store, class_name: 'Spree::Store', touch: true
6
- belongs_to :payment_method, class_name: 'Spree::PaymentMethod', touch: true, inverse_of: :store_payment_methods
10
+ belongs_to :payment_method, class_name: 'Spree::PaymentMethod', touch: true
7
11
 
8
12
  validates :store, :payment_method, presence: true
9
13
  validates :store_id, uniqueness: { scope: :payment_method_id }
@@ -1,4 +1,8 @@
1
1
  module Spree
2
+ # Legacy join between Store and Promotion. Superseded by the single-store
3
+ # +Promotion#store+ FK in 5.6; retained only so the +spree_multi_store+
4
+ # extension can restore +has_many :stores+ and so the backfill task can read
5
+ # historic attachments. Dropped in 6.0.
2
6
  class StorePromotion < Spree.base_class
3
7
  self.table_name = 'spree_promotions_stores'
4
8