spree_core 5.5.3 → 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 (133) 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 +27 -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/option_type.rb +11 -13
  41. data/app/models/spree/option_value.rb +5 -17
  42. data/app/models/spree/order.rb +19 -2
  43. data/app/models/spree/payment_method.rb +6 -5
  44. data/app/models/spree/policy.rb +2 -1
  45. data/app/models/spree/product.rb +23 -2
  46. data/app/models/spree/promotion/rules/channel.rb +37 -0
  47. data/app/models/spree/promotion/rules/market.rb +37 -0
  48. data/app/models/spree/promotion.rb +5 -3
  49. data/app/models/spree/promotion_handler/promotion_duplicator.rb +1 -1
  50. data/app/models/spree/role_user.rb +4 -0
  51. data/app/models/spree/search_provider/meilisearch.rb +22 -9
  52. data/app/models/spree/setup_task.rb +12 -0
  53. data/app/models/spree/setup_tasks.rb +86 -0
  54. data/app/models/spree/shipment.rb +38 -1
  55. data/app/models/spree/shipment_handler.rb +1 -1
  56. data/app/models/spree/stock/availability_validator.rb +1 -1
  57. data/app/models/spree/stock/quantifier.rb +21 -1
  58. data/app/models/spree/stock_item.rb +2 -1
  59. data/app/models/spree/stock_location.rb +4 -1
  60. data/app/models/spree/store.rb +45 -9
  61. data/app/models/spree/store_payment_method.rb +5 -1
  62. data/app/models/spree/store_promotion.rb +4 -0
  63. data/app/models/spree/taxon.rb +112 -9
  64. data/app/models/spree/taxonomy.rb +1 -1
  65. data/app/models/spree/translations/batch.rb +104 -0
  66. data/app/models/spree/variant.rb +29 -2
  67. data/app/presenters/spree/search_provider/product_presenter.rb +3 -0
  68. data/app/services/spree/carts/complete.rb +3 -0
  69. data/app/services/spree/fulfillments/create.rb +259 -0
  70. data/app/services/spree/locales/set_fallback_locale_for_store.rb +7 -1
  71. data/app/services/spree/locales.rb +52 -0
  72. data/app/services/spree/products/prepare_nested_attributes.rb +40 -12
  73. data/app/services/spree/shipments/update.rb +1 -1
  74. data/app/services/spree/stock_reservations/reserve.rb +4 -1
  75. data/app/services/spree/taxons/add_products.rb +3 -0
  76. data/app/services/spree/taxons/remove_products.rb +3 -0
  77. data/app/subscribers/spree/admin_user_email_subscriber.rb +29 -0
  78. data/app/subscribers/spree/import_email_subscriber.rb +26 -0
  79. data/app/views/layouts/spree/base_mailer.html.erb +14 -17
  80. data/app/views/spree/admin_user_mailer/confirmation_email.html.erb +7 -0
  81. data/app/views/spree/admin_user_mailer/password_reset_email.html.erb +8 -0
  82. data/app/views/spree/export_mailer/export_done.html.erb +6 -14
  83. data/app/views/spree/import_mailer/import_done.html.erb +12 -0
  84. data/app/views/spree/invitation_mailer/invitation_accepted.html.erb +4 -12
  85. data/app/views/spree/invitation_mailer/invitation_email.html.erb +11 -24
  86. data/app/views/spree/report_mailer/report_done.html.erb +6 -14
  87. data/app/views/spree/shared/_base_mailer_footer.html.erb +15 -8
  88. data/app/views/spree/shared/_base_mailer_header.html.erb +7 -5
  89. data/app/views/spree/shared/_base_mailer_stylesheets.html.erb +211 -273
  90. data/app/views/spree/shared/_mailer_button.html.erb +10 -0
  91. data/app/views/spree/shared/_mailer_hero.html.erb +12 -0
  92. data/app/views/spree/shared/_purchased_items_styles.html.erb +112 -0
  93. data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +39 -14
  94. data/config/initializers/spree_store_setup_tasks.rb +23 -0
  95. data/config/locales/en.yml +42 -992
  96. data/db/migrate/20260613000001_add_store_id_to_spree_role_users.rb +10 -0
  97. data/db/migrate/20260626000001_add_store_id_to_spree_taxons.rb +11 -0
  98. data/db/migrate/20260627000001_add_products_count_to_spree_taxons.rb +6 -0
  99. data/db/migrate/20260628000001_add_store_id_to_spree_promotions.rb +10 -0
  100. data/db/migrate/20260628000002_add_store_id_to_spree_payment_methods.rb +11 -0
  101. data/db/migrate/20260630000001_add_preorder_fields_to_spree_variants.rb +7 -0
  102. data/db/migrate/20260707000001_add_fingerprint_to_spree_credit_cards.rb +39 -0
  103. data/db/migrate/20260710000001_add_import_id_status_index_to_spree_import_rows.rb +7 -0
  104. data/db/migrate/20260711000001_add_preferences_to_spree_exports.rb +7 -0
  105. data/db/sample_data/promotions.rb +1 -1
  106. data/lib/generators/spree/authentication/dummy/templates/create_spree_admin_users.rb.tt +0 -1
  107. data/lib/mobility/plugins/store_based_fallbacks.rb +7 -5
  108. data/lib/spree/core/configuration.rb +1 -0
  109. data/lib/spree/core/controller_helpers/locale.rb +4 -1
  110. data/lib/spree/core/controller_helpers/strong_parameters.rb +1 -0
  111. data/lib/spree/core/dependencies.rb +3 -0
  112. data/lib/spree/core/engine.rb +24 -0
  113. data/lib/spree/core/preferences/preferable.rb +6 -1
  114. data/lib/spree/core/previews/admin_user_preview.rb +22 -0
  115. data/lib/spree/core/previews/export_preview.rb +37 -0
  116. data/lib/spree/core/previews/invitation_preview.rb +47 -0
  117. data/lib/spree/core/previews/preview_data.rb +52 -0
  118. data/lib/spree/core/previews/report_preview.rb +40 -0
  119. data/lib/spree/core/previews/webhook_preview.rb +34 -0
  120. data/lib/spree/core/version.rb +1 -1
  121. data/lib/spree/core.rb +22 -1
  122. data/lib/spree/permitted_attributes.rb +5 -4
  123. data/lib/spree/testing_support/factories/export_factory.rb +4 -0
  124. data/lib/spree/testing_support/factories/payment_method_factory.rb +1 -9
  125. data/lib/spree/testing_support/factories/promotion_factory.rb +1 -9
  126. data/lib/spree/testing_support/factories/promotion_rule_factory.rb +24 -0
  127. data/lib/spree/translations.rb +113 -0
  128. data/lib/spree/upgrades/5_5_to_5_6/manifest.yml +59 -0
  129. data/lib/tasks/role_users.rake +21 -0
  130. data/lib/tasks/single_store_associations.rake +65 -0
  131. data/lib/tasks/taxons.rake +62 -1
  132. metadata +47 -5
  133. data/app/models/spree/product/legacy_multi_store_support.rb +0 -40
@@ -43,6 +43,15 @@ module Spree
43
43
  #
44
44
  self.whitelisted_ransackable_attributes = %w[number type format vendor_id]
45
45
 
46
+ #
47
+ # Preferences
48
+ #
49
+ # Absolute URL of the admin view for this export, captured at create by
50
+ # the caller (legacy admin passes its export page; see the import
51
+ # counterpart for the dashboard mechanics) — the export-done email links
52
+ # back to it. No URL, no button.
53
+ preference :results_url, :string, default: nil
54
+
46
55
  #
47
56
  # Attachments
48
57
  #
@@ -205,6 +214,16 @@ module Spree
205
214
  Rails.root.join('tmp', export_file_name)
206
215
  end
207
216
 
217
+ # Public API name for the +results_url+ preference (read/write symmetry).
218
+ # String preferences round-trip nil as "" — normalize blank to nil.
219
+ def results_url
220
+ preferred_results_url.presence
221
+ end
222
+
223
+ def results_url=(value)
224
+ self.preferred_results_url = value
225
+ end
226
+
208
227
  def send_export_done_email
209
228
  return if user.blank? # App-created exports (secret API key) have no user to email.
210
229
 
@@ -14,8 +14,7 @@ module Spree
14
14
  end
15
15
 
16
16
  def scope
17
- model_class.joins(promotion: :stores).where(spree_stores: { id: store.id })
18
- .accessible_by(current_ability)
17
+ model_class.where(promotion: store.promotions).accessible_by(current_ability, :show)
19
18
  end
20
19
  end
21
20
  end
@@ -34,7 +34,12 @@ module Spree
34
34
  #
35
35
  # Ransack configuration
36
36
  #
37
- self.whitelisted_ransackable_attributes = %w[number type]
37
+ self.whitelisted_ransackable_attributes = %w[number type status]
38
+
39
+ #
40
+ # Scopes
41
+ #
42
+ scope :for_store, ->(store) { where(owner: store) }
38
43
 
39
44
  #
40
45
  # Attachments
@@ -70,12 +75,23 @@ module Spree
70
75
  event :fail do
71
76
  transition to: :failed
72
77
  end
78
+
79
+ # Re-processes rows that failed: the dispatcher targets pending_and_failed
80
+ # rows, so retry is just a transition + re-dispatch.
81
+ event :retry_failed_rows do
82
+ transition from: :completed, to: :processing, if: ->(import) { import.rows.failed.exists? }
83
+ end
84
+ after_transition on: :retry_failed_rows, do: :process_rows_async
73
85
  end
74
86
 
75
87
  #
76
88
  # Preferences
77
89
  #
78
90
  preference :delimiter, :string, default: ','
91
+ # Absolute URL of the dashboard's imports view, captured at create and
92
+ # validated against the store's allowed origins — the import-done email
93
+ # links back to it. Blank for legacy-admin or app-created imports.
94
+ preference :results_url, :string, default: nil
79
95
 
80
96
  # Returns true if the import has more rows than the large import threshold.
81
97
  # Large imports skip per-row UI broadcasts and use bulk processing.
@@ -110,6 +126,59 @@ module Spree
110
126
  status == 'completed'
111
127
  end
112
128
 
129
+ # Imports are deletable only while no processing jobs can be in flight —
130
+ # CreateRowsJob/ProcessGroupJob re-load the record mid-run.
131
+ # @return [Boolean]
132
+ def can_be_deleted?
133
+ %w[pending mapping completed failed].include?(status)
134
+ end
135
+
136
+ # Per-status row counts for API status payloads, memoized so one request
137
+ # issues a single grouped COUNT instead of one per status.
138
+ # @return [Hash{String => Integer}]
139
+ def rows_status_counts
140
+ @rows_status_counts ||= rows.group(:status).count
141
+ end
142
+
143
+ # Public API name for the +results_url+ preference (read/write symmetry).
144
+ # String preferences round-trip nil as "" — normalize blank to nil.
145
+ def results_url
146
+ preferred_results_url.presence
147
+ end
148
+
149
+ def results_url=(value)
150
+ self.preferred_results_url = value
151
+ end
152
+
153
+ # URL of the wizard/results view for this import — the caller-provided
154
+ # +results_url+ with the wizard's `?import=` param appended (a no-op for
155
+ # the legacy admin's show URL, whose path already identifies the import).
156
+ # Nil when the preference is absent; the mailer then renders no link.
157
+ # @return [String, nil]
158
+ def results_page_url
159
+ return if preferred_results_url.blank?
160
+
161
+ uri = URI.parse(preferred_results_url)
162
+ params = URI.decode_www_form(uri.query.to_s)
163
+ params << ['import', prefixed_id]
164
+ uri.query = URI.encode_www_form(params)
165
+ uri.to_s
166
+ rescue URI::InvalidURIError
167
+ nil
168
+ end
169
+
170
+ # First data row as { header => value }, reading a single line of the
171
+ # attached CSV — used by the mapping UI to show example values.
172
+ # @return [Hash]
173
+ def sample_row
174
+ return {} if attachment_file_content.blank?
175
+
176
+ row = ::CSV.foreach(StringIO.new(attachment_file_content), headers: true, col_sep: preferred_delimiter).first
177
+ row&.to_h || {}
178
+ rescue ::CSV::MalformedCSVError, EncodingError
179
+ {}
180
+ end
181
+
113
182
  # Returns the model class for the import
114
183
  # @return [Class]
115
184
  def model_class
@@ -277,6 +346,20 @@ module Spree
277
346
  available_types.find { |type| type.model_class.to_s == model.to_s }
278
347
  end
279
348
 
349
+ # Admin API scope family gating this import type — an import is a bulk
350
+ # write, so an API key needs `write_<required_scope>` to create and
351
+ # manage it. Derived from the class name (Spree::Imports::Customers =>
352
+ # :customers); override in subclasses gated by a different scope.
353
+ # Returns nil on the base class, so unmapped types are only accessible
354
+ # to write_all keys.
355
+ #
356
+ # @return [Symbol, nil]
357
+ def required_scope
358
+ return nil if self == Spree::Import
359
+
360
+ to_s.demodulize.underscore.to_sym
361
+ end
362
+
280
363
  # eg. Spree::Imports::Orders => Spree::Order
281
364
  def model_class
282
365
  return Spree.user_class if to_s == 'Spree::Imports::Customers'
@@ -18,6 +18,11 @@ module Spree
18
18
  validates :import, :data, presence: true
19
19
  validates :row_number, uniqueness: { scope: :import_id }, numericality: { only_integer: true, greater_than: 0 }, presence: true
20
20
 
21
+ #
22
+ # Ransack configuration
23
+ #
24
+ self.whitelisted_ransackable_attributes = %w[status row_number]
25
+
21
26
  #
22
27
  # State machine
23
28
  #
@@ -92,6 +97,7 @@ module Spree
92
97
  def process!(mappings: nil, schema_fields: nil)
93
98
  start_processing!
94
99
  self.item = import.row_processor_class.new(self, mappings: mappings, schema_fields: schema_fields).process!
100
+ self.validation_errors = nil # clear a stale error when a retried row succeeds
95
101
  complete!
96
102
  rescue StandardError => e
97
103
  Rails.error.report(e, handled: true, context: { import_row_id: id }, source: 'spree.core')
@@ -105,7 +111,7 @@ module Spree
105
111
  update_columns(status: 'processing', updated_at: Time.current)
106
112
  processor = import.row_processor_class.new(self, mappings: mappings, schema_fields: schema_fields)
107
113
  self.item = processor.process!
108
- update_columns(status: 'completed', item_type: item.class.name, item_id: item.id, updated_at: Time.current)
114
+ update_columns(status: 'completed', item_type: item.class.name, item_id: item.id, validation_errors: nil, updated_at: Time.current)
109
115
  rescue StandardError => e
110
116
  Rails.error.report(e, handled: true, context: { import_row_id: id }, source: 'spree.core')
111
117
  update_columns(status: 'failed', validation_errors: e.message, updated_at: Time.current)
@@ -16,6 +16,12 @@ module Spree
16
16
  def self.model_class
17
17
  Spree::Product
18
18
  end
19
+
20
+ # Translation imports write products, so they share the products scope
21
+ # (mirrors Spree::Exports::ProductTranslations.required_scope).
22
+ def self.required_scope
23
+ :products
24
+ end
19
25
  end
20
26
  end
21
27
  end
@@ -0,0 +1,104 @@
1
+ module Spree
2
+ # Virtual model for a supported locale. Wraps a locale +code+ and exposes its
3
+ # display name, layout direction, and whether it is a store's default — the
4
+ # single home for that logic (RTL list, name resolution) instead of being
5
+ # restated in serializers, helpers, and the admin RTL module.
6
+ class Locale
7
+ include ActiveModel::Model
8
+ include Comparable
9
+
10
+ # ISO 639-1 language codes that use right-to-left scripts.
11
+ RTL_LANGUAGE_CODES = %w[ar he fa ur yi].freeze
12
+
13
+ # @!attribute code
14
+ # @return [String] the locale code, e.g. "en", "pt-BR"
15
+ attr_accessor :code
16
+
17
+ # Display name, e.g. "English", "Deutsch". Falls back to the code for an
18
+ # unknown locale.
19
+ # @return [String]
20
+ def name
21
+ code = self.code.to_s
22
+
23
+ if I18n.exists?('spree.i18n.this_file_language', locale: code, fallback: false)
24
+ return normalize_name(Spree.t('i18n.this_file_language', locale: code))
25
+ end
26
+
27
+ if defined?(SpreeI18n::Locale) && (name = SpreeI18n::Locale.local_language_name(code))
28
+ return normalize_name(name)
29
+ end
30
+
31
+ return 'English' if code == 'en'
32
+
33
+ code
34
+ end
35
+
36
+ # Select label, e.g. "EN — English".
37
+ # @return [String]
38
+ def label
39
+ upper = code.to_s.upcase
40
+ return upper if name.blank? || name.casecmp?(code.to_s)
41
+
42
+ "#{upper} — #{name}"
43
+ end
44
+
45
+ # @return [Spree::Store] the current store
46
+ def store
47
+ @store ||= Spree::Store.current
48
+ end
49
+
50
+ # @return [Boolean] whether this is the store's default (source) locale
51
+ def default?
52
+ store.present? && code.to_s == store.default_locale.to_s
53
+ end
54
+
55
+ # @return [Boolean] whether the locale uses a right-to-left script
56
+ def rtl?
57
+ RTL_LANGUAGE_CODES.include?(language_code)
58
+ end
59
+
60
+ # @return [String] "rtl" or "ltr"
61
+ def direction
62
+ rtl? ? 'rtl' : 'ltr'
63
+ end
64
+
65
+ # The base ISO 639-1 language code, dropping any region (e.g. "pt-BR" → "pt").
66
+ # @return [String]
67
+ def language_code
68
+ code.to_s.downcase.tr('_', '-').split('-').first
69
+ end
70
+
71
+ def to_s
72
+ code.to_s
73
+ end
74
+
75
+ # Compare/equality by code so a Locale slots into string-keyed collections
76
+ # (e.g. `locale == "en"`, `[locale].sort`).
77
+ def <=>(other)
78
+ to_s <=> other.to_s
79
+ end
80
+
81
+ def eql?(other)
82
+ other.is_a?(Spree::Locale) && code.to_s == other.code.to_s
83
+ end
84
+
85
+ def hash
86
+ code.to_s.hash
87
+ end
88
+
89
+ private
90
+
91
+ # Strip a trailing " (CODE)" suffix from Spree I18n locale labels. Uses
92
+ # plain string ops rather than a regex to avoid polynomial backtracking on
93
+ # adversarial input (ReDoS).
94
+ def normalize_name(name)
95
+ name = name.to_s.rstrip
96
+ return name unless name.end_with?(')')
97
+
98
+ open_paren = name.rindex('(')
99
+ return name unless open_paren
100
+
101
+ name[0...open_paren].rstrip
102
+ end
103
+ end
104
+ end
@@ -9,10 +9,16 @@ module Spree
9
9
  include Spree::UniqueName
10
10
  include Spree::Metafields
11
11
  include Spree::Metadata
12
- include Spree::TranslatableResource
12
+ # presentation column, exposed publicly as `label` + translated via Mobility.
13
+ include Spree::PresentationTranslatable
13
14
 
14
- TRANSLATABLE_FIELDS = %i[presentation].freeze
15
- translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?)
15
+ TRANSLATABLE_FIELDS = Spree::PresentationTranslatable::TRANSLATABLE_FIELDS
16
+
17
+ # Option values are translated alongside their type — the translations
18
+ # endpoint nests their matrices so the editor fetches both in one read.
19
+ def self.translatable_children
20
+ :option_values
21
+ end
16
22
 
17
23
  self::Translation.class_eval do
18
24
  normalizes :presentation, with: ->(value) { value&.to_s&.squish&.presence }
@@ -41,16 +47,8 @@ module Spree
41
47
  has_many :option_type_prototypes, class_name: 'Spree::OptionTypePrototype'
42
48
  has_many :prototypes, through: :option_type_prototypes, class_name: 'Spree::Prototype'
43
49
 
44
- # 5.5 API naming bridge (DB column rename in 6.0)
45
- # NOTE: alias_attribute bypasses Mobility's locale-aware reader/writer,
46
- # so we define explicit delegating methods instead.
47
- def label(*args)
48
- presentation(*args)
49
- end
50
-
51
- def label=(value)
52
- self.presentation = value
53
- end
50
+ # 5.5 API naming bridge (`label` `presentation`) lives in
51
+ # Spree::PresentationTranslatable.
54
52
 
55
53
  #
56
54
  # Validations
@@ -5,14 +5,10 @@ module Spree
5
5
  include Spree::ParameterizableName
6
6
  include Spree::Metafields
7
7
  include Spree::Metadata
8
- include Spree::TranslatableResource
8
+ # presentation column, exposed publicly as `label` + translated via Mobility.
9
+ include Spree::PresentationTranslatable
9
10
 
10
- TRANSLATABLE_FIELDS = %i[presentation].freeze
11
- translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?)
12
-
13
- self::Translation.class_eval do
14
- normalizes :presentation, with: ->(value) { value&.to_s&.squish&.presence }
15
- end
11
+ TRANSLATABLE_FIELDS = Spree::PresentationTranslatable::TRANSLATABLE_FIELDS
16
12
 
17
13
  #
18
14
  # Magic methods
@@ -33,16 +29,8 @@ module Spree
33
29
  has_many :variants, through: :option_value_variants, class_name: 'Spree::Variant'
34
30
  has_many :products, through: :variants, class_name: 'Spree::Product'
35
31
 
36
- # 5.5 API naming bridge (DB column rename in 6.0)
37
- # NOTE: alias_attribute bypasses Mobility's locale-aware reader/writer,
38
- # so we define explicit delegating methods instead.
39
- def label(*args)
40
- presentation(*args)
41
- end
42
-
43
- def label=(value)
44
- self.presentation = value
45
- end
32
+ # 5.5 API naming bridge (`label` `presentation`) lives in
33
+ # Spree::PresentationTranslatable.
46
34
 
47
35
  #
48
36
  # Validations
@@ -224,7 +224,6 @@ module Spree
224
224
  accepts_nested_attributes_for :shipments
225
225
 
226
226
  # Needs to happen before save_permalink is called
227
- before_validation :ensure_store_presence
228
227
  before_validation :ensure_market_presence
229
228
  before_validation :ensure_channel_presence
230
229
  before_validation :ensure_currency_presence
@@ -500,7 +499,8 @@ module Spree
500
499
  end
501
500
 
502
501
  def ensure_store_presence
503
- self.store ||= Spree::Store.default
502
+ Spree::Deprecation.warn('Spree::Order#ensure_store_presence is deprecated and will be removed in Spree 6.0. ensure_store instead.')
503
+ ensure_store
504
504
  end
505
505
 
506
506
  def ensure_market_presence
@@ -513,6 +513,23 @@ module Spree
513
513
  self.channel = store&.default_channel
514
514
  end
515
515
 
516
+ # @return [Boolean] true when this order has no registered user and its
517
+ # channel forbids guest checkout (see Spree::Channel::Gating). Enforced by
518
+ # the checkout completion service and the v3 Store API so every completion
519
+ # path (controller, payment webhook) is covered.
520
+ #
521
+ # A +prices_hidden+ channel also disallows guest completion regardless of
522
+ # the +guest_checkout+ flag — prices are withheld from guests, and a buyer
523
+ # who cannot see prices cannot meaningfully place an order. This dissolves
524
+ # the otherwise contradictory "prices hidden but guests may buy" config.
525
+ def guest_checkout_disallowed?
526
+ return false if user_id.present?
527
+ return false if channel.blank?
528
+ return true if channel.storefront_prices_hidden?
529
+
530
+ !channel.resolved_guest_checkout
531
+ end
532
+
516
533
  def allow_cancel?
517
534
  return false if !completed? || canceled?
518
535
 
@@ -5,13 +5,15 @@ module Spree
5
5
  acts_as_paranoid
6
6
  acts_as_list
7
7
 
8
- include Spree::StoreScopedResource
8
+ include Spree::SingleStoreResource
9
9
  include Spree::Metafields
10
10
  include Spree::Metadata
11
11
  include Spree::DisplayOn
12
12
  if defined?(Spree::Security::PaymentMethods)
13
13
  include Spree::Security::PaymentMethods
14
14
  end
15
+ # Multi-store sharing moved to the spree_multi_store extension in 5.6.
16
+ include Spree::LegacyMultiStoreSupport unless defined?(SpreeMultiStore)
15
17
 
16
18
  scope :active, -> { where(active: true).order(position: :asc) }
17
19
  scope :available, -> { active.where(display_on: [:front_end, :back_end, :both]) }
@@ -20,10 +22,10 @@ module Spree
20
22
  after_initialize :set_name, if: :new_record?
21
23
 
22
24
  validates :name, presence: true
25
+ validates :store, presence: true, unless: -> { Spree::Config[:disable_store_presence_validation] }
23
26
  normalizes :name, with: ->(value) { value&.to_s&.squish&.presence }
24
27
 
25
- has_many :store_payment_methods, class_name: 'Spree::StorePaymentMethod', inverse_of: :payment_method
26
- has_many :stores, class_name: 'Spree::Store', through: :store_payment_methods
28
+ belongs_to :store, class_name: 'Spree::Store'
27
29
 
28
30
  has_many :payments, class_name: 'Spree::Payment', inverse_of: :payment_method, dependent: :nullify
29
31
  has_many :credit_cards, class_name: 'Spree::CreditCard', dependent: :destroy # CCs are soft deleted
@@ -101,7 +103,6 @@ module Spree
101
103
  # Returns the webhook URL for this payment method.
102
104
  # @return [String, nil]
103
105
  def webhook_url
104
- store = stores.first
105
106
  return nil unless store
106
107
 
107
108
  "#{store.url_or_custom_domain}/api/v3/webhooks/payments/#{prefixed_id}"
@@ -200,7 +201,7 @@ module Spree
200
201
  def available_for_store?(store)
201
202
  return true if store.blank?
202
203
 
203
- store_ids.include?(store.id)
204
+ store_id == store.id
204
205
  end
205
206
 
206
207
  def public_preferences
@@ -21,7 +21,8 @@ module Spree
21
21
  # Translations
22
22
  #
23
23
  TRANSLATABLE_FIELDS = %i[name body].freeze
24
- translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?)
24
+ RICH_TEXT_TRANSLATABLE_FIELDS = %i[body].freeze
25
+ translates(*TRANSLATABLE_FIELDS, column_fallback: Spree.mobility_column_fallback)
25
26
 
26
27
  #
27
28
  # ActionText
@@ -34,7 +34,7 @@ module Spree
34
34
  include Spree::Product::Webhooks
35
35
  include Spree::Product::Slugs
36
36
  include Spree::Product::Channels
37
- include Spree::Product::LegacyMultiStoreSupport unless defined?(SpreeMultiStore)
37
+ include Spree::LegacyMultiStoreSupport unless defined?(SpreeMultiStore)
38
38
  include Spree::SearchIndexable
39
39
  if defined?(Spree::VendorConcern)
40
40
  include Spree::VendorConcern
@@ -56,7 +56,8 @@ module Spree
56
56
  }.freeze
57
57
 
58
58
  TRANSLATABLE_FIELDS = %i[name description slug meta_description meta_title].freeze
59
- translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?)
59
+ RICH_TEXT_TRANSLATABLE_FIELDS = %i[description].freeze
60
+ translates(*TRANSLATABLE_FIELDS, column_fallback: Spree.mobility_column_fallback)
60
61
 
61
62
  self::Translation.class_eval do
62
63
  normalizes :name, :meta_title, with: ->(value) { value&.to_s&.squish&.presence }
@@ -522,6 +523,26 @@ module Spree
522
523
  active? && !deleted? && (available_on.nil? || available_on <= Time.current)
523
524
  end
524
525
 
526
+ # True when the product is currently offered as a pre-order: at least one
527
+ # variant is sold before it is in stock (see {Variant#preorder?}).
528
+ # Independent of publishing — a pre-order product is live in the catalog,
529
+ # just flagged "buy now, ships later".
530
+ # @return [Boolean]
531
+ def preorder?
532
+ default_variant.preorder? || variants.any?(&:preorder?)
533
+ end
534
+
535
+ # The latest "ships by" date across the product's pre-order variants, so a
536
+ # storefront can show a single "ships by" promise for the product. Nil when
537
+ # the product is not a pre-order. Scans the same sellable set {#preorder?}
538
+ # consults (the master only counts when it is the sole, purchasable
539
+ # variant), reading preloaded associations to avoid an N+1.
540
+ # @return [ActiveSupport::TimeWithZone, nil]
541
+ def preorder_ships_at
542
+ candidates = has_variants? ? variants : [master]
543
+ candidates.select(&:preorder?).filter_map(&:preorder_ships_at).max
544
+ end
545
+
525
546
  def discontinue!
526
547
  self.discontinue_on = Time.current
527
548
  self.status = 'archived'
@@ -0,0 +1,37 @@
1
+ # A rule to limit a promotion based on the order's channel.
2
+ module Spree
3
+ class Promotion
4
+ module Rules
5
+ class Channel < PromotionRule
6
+ # Stored as raw IDs. Accepts prefixed IDs (`ch_…`) from API
7
+ # callers and decodes them on write so eligibility checks compare
8
+ # against the order's raw `channel_id` directly. Scope confines the
9
+ # existence check to the promotion's store so cross-store channel
10
+ # IDs can't sneak in.
11
+ preference :channel_ids, :array, default: [],
12
+ parse_on_set: normalize_id_preference(
13
+ klass: Spree::Channel,
14
+ scope: ->(rule) { rule.promotion&.store&.channels || Spree::Channel.none }
15
+ )
16
+
17
+ def applicable?(promotable)
18
+ promotable.is_a?(Spree::Order)
19
+ end
20
+
21
+ def channels
22
+ return Spree::Channel.none if preferred_channel_ids.blank?
23
+
24
+ promotion.store.channels.where(id: preferred_channel_ids)
25
+ end
26
+
27
+ def eligible?(order, _options = {})
28
+ return false if preferred_channel_ids.empty?
29
+ return true if preferred_channel_ids.map(&:to_s).include?(order.channel_id.to_s)
30
+
31
+ eligibility_errors.add(:base, eligibility_error_message(:no_matching_channel))
32
+ false
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -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
  #