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
@@ -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
 
@@ -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