spree_core 5.4.0.rc1 → 5.4.0.rc3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb701f06817ccd3c82b765594a0c66bbb23e6daec6d4c33600ecbfa34f0581f5
4
- data.tar.gz: be65bdb6937fefda9bd82cefe65f1115259ff268f7f8ff59dce5b483919f4192
3
+ metadata.gz: feb2c702a17f0e10532d677c7c6cdb4a4092476ce33966db067ff095e0fd3d51
4
+ data.tar.gz: 6cfb1613127976496083e9d11d5a073e20c7d94b139d2df2f621e4c18ae52936
5
5
  SHA512:
6
- metadata.gz: dcc8cb038dd29a19596e68875c041f9d3a2247e6fdac6548647ef58b0d41446afbd6524ca4efb2e0e09acc5d15fa7043bec9f5d57cf25b1676c2c9209888ba52
7
- data.tar.gz: 2ebdb54fc12315d6102df129c38f663ec56c4bc267fdd7d2d2729cb4b39494b7b033cc14b949efc2f370c0a63ad6aafd17c1dc3ebffb8662ff94123a2cc78f8e
6
+ metadata.gz: 8abff3fa43c5226f1eb1c3dd1a5d1bd84fedfd9ef62895da5df6402df1bd9e0fcb68a9fd646790d90ea5efa04292330aeab50e9269d1352a8970c2c8ff362d39
7
+ data.tar.gz: 2cde221d1575d9d48c633d4aacdc588d775aff1445c0bdc5c895ff82e77444e3a5341d5fc1677d6c5013accd73f182f1e2ea5bb5b9532470d13dae4a7e316061
@@ -1,3 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
2
- <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.75M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm3 0h.008v.008H18V10.5Zm-12 0h.008v.008H6V10.5Z" />
3
- </svg>
1
+ <svg width="38" height="24" viewBox="0 0 38 24" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="pi-banktransfer"><title id="pi-banktransfer">Bank Transfer</title><g clip-path="url(#pi-banktransfer-clip)"><path opacity=".07" d="M35 0H3C1.3 0 0 1.3 0 3v18c0 1.7 1.4 3 3 3h32c1.7 0 3-1.3 3-3V3c0-1.7-1.4-3-3-3z" fill="#000"/><path d="M35 1c1.1 0 2 .9 2 2v18c0 1.1-.9 2-2 2H3c-1.1 0-2-.9-2-2V3c0-1.1.9-2 2-2h32z" fill="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M10 7l-4 2v1h8V9L10 7zM7 10.75v3.5h1v-3.5H7zm2 0v3.5h1v-3.5H9zm2 0v3.5h1.5v-3.5H11zM6 15v1h8v-1H6zM28 7l-4 2v1h8V9l-4-2zm-3 3.75v3.5h1v-3.5h-1zm2 0v3.5h1v-3.5h-1zm2 0v3.5h1.5v-3.5H29zM24 15v1h8v-1h-8z" fill="#4A4A4A"/><path d="M15.5 10h7M15.5 10l1.5-1.25M15.5 10l1.5 1.25" stroke="#4A4A4A" stroke-width=".9" stroke-linecap="round" stroke-linejoin="round"/><path d="M22.5 14h-7M22.5 14l-1.5-1.25M22.5 14l-1.5 1.25" stroke="#4A4A4A" stroke-width=".9" stroke-linecap="round" stroke-linejoin="round"/></g><defs><clipPath id="pi-banktransfer-clip"><path fill="#fff" d="M0 0h38v24H0z"/></clipPath></defs></svg>
@@ -3,6 +3,16 @@ module Spree
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  included do
6
+ cattr_accessor :search_scopes do
7
+ []
8
+ end
9
+
10
+ def self.add_search_scope(name, &block)
11
+ Spree::Deprecation.warn("add_search_scope is deprecated and will be removed in Spree 6.0. Use scope :#{name}, &block instead or use method instead")
12
+ singleton_class.send(:define_method, name.to_sym, &block)
13
+ search_scopes << name.to_sym
14
+ end
15
+
6
16
  scope :ascend_by_updated_at, -> { order("#{Product.quoted_table_name}.updated_at ASC") }
7
17
  scope :descend_by_updated_at, -> { order("#{Product.quoted_table_name}.updated_at DESC") }
8
18
  scope :ascend_by_name, -> { order("#{Product.quoted_table_name}.name ASC") }
@@ -29,7 +29,7 @@ module Spree
29
29
  # => { id: 1, name: "Shirt", price_USD: 19.99, ... }
30
30
  def search_presentation(store = nil)
31
31
  store ||= Spree::Current.store
32
- Spree::SearchProvider::ProductPresenter.new(self, store).call
32
+ Spree::Dependencies.search_product_presenter_class.new(self, store).call
33
33
  end
34
34
 
35
35
  # Remove this record from search index synchronously (inline, no job).
@@ -106,12 +106,26 @@ module Spree
106
106
  end
107
107
 
108
108
  def user_default_billing?
109
- user.present? && id == user.bill_address_id
109
+ Spree::Deprecation.warn('Spree::Address#user_default_billing? is deprecated and will be removed in Spree 6.0. Use #is_default_billing? instead.')
110
+ is_default_billing?
110
111
  end
111
112
 
112
113
  def user_default_shipping?
114
+ Spree::Deprecation.warn('Spree::Address#user_default_shipping? is deprecated and will be removed in Spree 6.0. Use #is_default_shipping? instead.')
115
+ is_default_shipping?
116
+ end
117
+
118
+ # In 6.0 these become real columns on Address, replacing User#bill_address_id / ship_address_id.
119
+ # For now they delegate to the User FK so the API shape is stable.
120
+ def is_default_billing?
121
+ user.present? && id == user.bill_address_id
122
+ end
123
+ alias_method :is_default_billing, :is_default_billing?
124
+
125
+ def is_default_shipping?
113
126
  user.present? && id == user.ship_address_id
114
127
  end
128
+ alias_method :is_default_shipping, :is_default_shipping?
115
129
 
116
130
  # first_name / last_name aliases are defined via alias_attribute above
117
131
 
@@ -34,11 +34,31 @@ module Spree
34
34
  Spree.gift_card_remove_service.call(order: self)
35
35
  end
36
36
 
37
+ # Recalculates the gift card payment amount based on the current order total.
38
+ # Updates the existing payment in place instead of remove + re-apply
39
+ # to avoid creating unnecessary invalid payment records.
37
40
  def recalculate_gift_card
38
- applied_gift_card = gift_card
41
+ return unless gift_card.present?
42
+
43
+ payment = payments.checkout.store_credits.where(source: gift_card.store_credits).first
44
+ return unless payment
45
+
46
+ # with_lock acquires a row lock and wraps in a transaction.
47
+ # The entire read-compute-write must be inside the lock to prevent
48
+ # stale amount_remaining from concurrent requests.
49
+ gift_card.with_lock do
50
+ new_amount = [gift_card.amount_remaining + payment.amount, total].min
51
+ next if payment.amount == new_amount
39
52
 
40
- remove_gift_card
41
- apply_gift_card(applied_gift_card)
53
+ difference = new_amount - payment.amount
54
+ # Uses update_column to bypass Payment#max_amount validation which
55
+ # can fail during recalculation due to stale in-memory order state.
56
+ # Bounds are enforced via min() above.
57
+ payment.update_column(:amount, new_amount)
58
+ payment.source.update_column(:amount, new_amount)
59
+ gift_card.amount_used += difference
60
+ gift_card.save!
61
+ end
42
62
  end
43
63
 
44
64
  def redeem_gift_card
@@ -49,10 +49,27 @@ module Spree
49
49
  :included_tax_total, :additional_tax_total, :tax_total,
50
50
  :shipment_total, :promo_total, :total,
51
51
  :cart_promo_total, :pre_tax_item_amount, :pre_tax_total,
52
- :payment_total
52
+ :payment_total, :amount_due
53
53
 
54
54
  alias display_ship_total display_shipment_total
55
55
  alias_attribute :ship_total, :shipment_total
56
+ def amount_due
57
+ outstanding_balance
58
+ end
59
+
60
+ # Transient warnings populated by remove_out_of_stock_items!
61
+ attribute :warnings, default: -> { [] }
62
+
63
+ # Removes out-of-stock/discontinued items and populates warnings.
64
+ # Returns self (reloaded if items were removed) with warnings set.
65
+ def remove_out_of_stock_items!
66
+ result = Spree::Cart::RemoveOutOfStockItems.call(order: self)
67
+ return self unless result.success?
68
+
69
+ order, _messages, warnings = result.value
70
+ order.warnings = warnings || []
71
+ order
72
+ end
56
73
 
57
74
  # 5.5 API naming bridges (DB columns rename in 6.0)
58
75
  alias_attribute :discount_total, :promo_total
@@ -13,8 +13,11 @@ module Spree
13
13
  belongs_to :variant, -> { with_deleted }, class_name: 'Spree::Variant', inverse_of: :prices, touch: true
14
14
  belongs_to :price_list, class_name: 'Spree::PriceList', optional: true
15
15
 
16
+ has_many :price_histories, class_name: 'Spree::PriceHistory', dependent: :delete_all
17
+
16
18
  before_validation :ensure_currency
17
19
  before_save :remove_compare_at_amount_if_equals_amount
20
+ after_save :record_price_history, if: :should_record_price_history?
18
21
 
19
22
  # legacy behavior
20
23
  validates :amount, allow_nil: true, numericality: {
@@ -156,8 +159,34 @@ module Spree
156
159
  !zero?
157
160
  end
158
161
 
162
+ # Returns the price history record with the lowest amount in the last 30 days
163
+ # Used for EU Omnibus Directive compliance
164
+ #
165
+ # @return [Spree::PriceHistory, nil]
166
+ def prior_price
167
+ price_histories.where(recorded_at: 30.days.ago..).order(:amount).first
168
+ end
169
+
159
170
  private
160
171
 
172
+ def should_record_price_history?
173
+ price_list_id.nil? &&
174
+ amount.present? &&
175
+ saved_change_to_amount? &&
176
+ Spree::Config[:track_price_history]
177
+ end
178
+
179
+ def record_price_history
180
+ Spree::PriceHistory.create!(
181
+ price: self,
182
+ variant_id: variant_id,
183
+ amount: amount,
184
+ compare_at_amount: compare_at_amount,
185
+ currency: currency,
186
+ recorded_at: Time.current
187
+ )
188
+ end
189
+
161
190
  def ensure_currency
162
191
  self.currency ||= Spree::Store.default.default_currency
163
192
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class PriceHistory < Spree.base_class
5
+ belongs_to :price, class_name: 'Spree::Price'
6
+ belongs_to :variant, class_name: 'Spree::Variant'
7
+
8
+ validates :amount, presence: true, numericality: { greater_than_or_equal_to: 0 }
9
+ validates :currency, presence: true
10
+ validates :recorded_at, presence: true
11
+
12
+ scope :for_variant, ->(variant_id) { where(variant_id: variant_id) }
13
+ scope :for_currency, ->(currency) { where(currency: currency) }
14
+ scope :in_period, ->(from, to = Time.current) { where(recorded_at: from..to) }
15
+ scope :recent, ->(days = 30) { in_period(days.days.ago) }
16
+
17
+ def money
18
+ Spree::Money.new(amount || 0, currency: currency)
19
+ end
20
+
21
+ def display_amount
22
+ money.to_s
23
+ end
24
+
25
+ def amount_in_cents
26
+ money.amount_in_cents
27
+ end
28
+ end
29
+ end
@@ -11,7 +11,7 @@ module Spree
11
11
  end
12
12
 
13
13
  def apply
14
- if load_gift_card_code
14
+ if gift_cards_enabled? && load_gift_card_code
15
15
 
16
16
  if @gift_card.expired?
17
17
  set_error_code :gift_card_expired
@@ -47,7 +47,7 @@ module Spree
47
47
  end
48
48
 
49
49
  def remove(coupon_code)
50
- if order.gift_card
50
+ if gift_cards_enabled? && order.gift_card
51
51
  result = order.remove_gift_card
52
52
 
53
53
  if result.success?
@@ -212,6 +212,13 @@ module Spree
212
212
  Spree::CouponCode.unused.find_by(promotion_id: discount.source.promotion_id, code: coupon_code)&.apply_order!(order)
213
213
  end
214
214
 
215
+ # Whether the coupon handler should also handle gift card codes.
216
+ # Defaults to true for backwards compatibility. Pass enable_gift_cards: false
217
+ # when using the dedicated gift card endpoint.
218
+ def gift_cards_enabled?
219
+ options.fetch(:enable_gift_cards, true)
220
+ end
221
+
215
222
  def load_gift_card_code
216
223
  return unless order.coupon_code.present?
217
224
 
@@ -1,3 +1,5 @@
1
+ require 'pagy/toolbox/paginators/meilisearch'
2
+
1
3
  module Spree
2
4
  module SearchProvider
3
5
  class Meilisearch < Base
@@ -66,7 +68,7 @@ module Spree
66
68
  end
67
69
 
68
70
  def index(product)
69
- documents = ProductPresenter.new(product, store).call
71
+ documents = presenter_class.new(product, store).call
70
72
  client.index(index_name).add_documents(documents, 'prefixed_id')
71
73
  end
72
74
 
@@ -91,9 +93,9 @@ module Spree
91
93
  ensure_index_settings!
92
94
 
93
95
  scope.reorder(id: :asc)
94
- .preload(*ProductPresenter::REQUIRED_PRELOADS)
96
+ .preload_associations_lazily
95
97
  .find_in_batches(batch_size: 500) do |batch|
96
- documents = batch.flat_map { |product| ProductPresenter.new(product, store).call }
98
+ documents = batch.flat_map { |product| presenter_class.new(product, store).call }
97
99
  index_batch(documents)
98
100
  end
99
101
  end
@@ -109,6 +111,10 @@ module Spree
109
111
 
110
112
  private
111
113
 
114
+ def presenter_class
115
+ Spree::Dependencies.search_product_presenter_class
116
+ end
117
+
112
118
  def client
113
119
  @client ||= ::Meilisearch::Client.new(
114
120
  ENV.fetch('MEILISEARCH_URL', 'http://localhost:7700'),
@@ -140,152 +146,165 @@ module Spree
140
146
  %w[price -price name -name -available_on available_on best_selling]
141
147
  end
142
148
 
143
- # Convert Ransack-style filters to Meilisearch filter syntax.
144
- # Always applies store scoping, active status, and currency availability
145
- # so that Meilisearch results match what the AR scope would return.
146
- # All values are sanitized to prevent filter injection.
149
+ # Build Meilisearch filter conditions from API params.
150
+ # Combines system scoping (always applied) with user-facing filters.
147
151
  def build_filters(filters)
148
- conditions = []
152
+ conditions = system_filter_conditions
153
+ conditions.concat(user_filter_conditions(filters))
154
+ conditions
155
+ end
149
156
 
150
- # Always scope to current store, locale, currency, active, not discontinued.
151
- # This mirrors the AR scope: store.products.active(currency) with locale
157
+ # System scoping always applied. Rarely overridden.
158
+ # Mirrors the AR scope: store.products.active(currency) with locale.
159
+ def system_filter_conditions
160
+ conditions = []
152
161
  conditions << "store_ids = '#{store.id}'"
153
162
  conditions << "status = 'active'"
154
163
  conditions << "locale = '#{locale.to_s.gsub(/[^a-zA-Z_-]/, '')}'"
155
164
  conditions << "currency = '#{currency.to_s.gsub(/[^A-Z]/, '')}'"
156
165
  conditions << "(discontinue_on = 0 OR discontinue_on > #{Time.current.to_i})"
166
+ conditions
167
+ end
157
168
 
169
+ # User-facing filters — override to add custom filter pre/post processing.
170
+ def user_filter_conditions(filters)
171
+ conditions = []
158
172
  filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
159
173
  return conditions if filters.blank?
160
174
 
161
175
  filters.each do |key, value|
162
176
  next if value.blank?
163
177
 
164
- key = key.to_s
165
- case key
166
- when 'price_gte'
167
- conditions << "price >= #{value.to_f}"
168
- when 'price_lte'
169
- conditions << "price <= #{value.to_f}"
170
- when 'in_stock'
171
- conditions << 'in_stock = true' if value.to_s != '0'
172
- when 'out_of_stock'
173
- conditions << 'in_stock = false' if value.to_s != '0'
174
- when 'categories_id_eq'
175
- conditions << "category_ids = '#{sanitize_prefixed_id(value)}'" if valid_prefixed_id?(value)
176
- when 'with_option_value_ids'
177
- Array(value).each do |ov|
178
- conditions << "option_value_ids = '#{sanitize_prefixed_id(ov)}'" if valid_prefixed_id?(ov)
179
- end
178
+ condition = build_filter_condition(key.to_s, value)
179
+ if condition.is_a?(Array)
180
+ conditions.concat(condition)
181
+ elsif condition
182
+ conditions << condition
180
183
  end
181
184
  end
182
185
 
183
186
  conditions
184
187
  end
185
188
 
189
+ # Translate a single Ransack-style filter param into Meilisearch filter syntax.
190
+ # Override in subclasses to handle custom filter keys — call super for built-in filters.
191
+ def build_filter_condition(key, value)
192
+ case key
193
+ when 'price_gte'
194
+ "price >= #{value.to_f}"
195
+ when 'price_lte'
196
+ "price <= #{value.to_f}"
197
+ when 'in_stock'
198
+ 'in_stock = true' if value.to_s != '0'
199
+ when 'out_of_stock'
200
+ 'in_stock = false' if value.to_s != '0'
201
+ when 'categories_id_eq'
202
+ "category_ids = '#{sanitize_prefixed_id(value)}'" if valid_prefixed_id?(value)
203
+ when 'with_option_value_ids'
204
+ Array(value).filter_map { |ov| "option_value_ids = '#{sanitize_prefixed_id(ov)}'" if valid_prefixed_id?(ov) }
205
+ end
206
+ end
207
+
208
+ # Sort param to Meilisearch sort syntax.
209
+ # Override in subclasses to handle custom sort keys — call super for built-in sorts.
186
210
  def build_sort(sort)
187
211
  return nil if sort.blank?
188
212
 
213
+ sort_mapping(sort)
214
+ end
215
+
216
+ # Map a sort param to Meilisearch sort syntax.
217
+ # Override in subclasses to add custom sorts — call super for built-in sorts.
218
+ def sort_mapping(sort)
189
219
  case sort
190
- when 'price'
191
- ['price:asc']
192
- when '-price'
193
- ['price:desc']
194
- when 'name'
195
- ['name:asc']
196
- when '-name'
197
- ['name:desc']
198
- when '-available_on'
199
- ['available_on:desc']
200
- when 'available_on'
201
- ['available_on:asc']
202
- when 'best_selling'
203
- ['units_sold_count:desc']
220
+ when 'price' then ['price:asc']
221
+ when '-price' then ['price:desc']
222
+ when 'name' then ['name:asc']
223
+ when '-name' then ['name:desc']
224
+ when '-available_on' then ['available_on:desc']
225
+ when 'available_on' then ['available_on:asc']
226
+ when 'best_selling' then ['units_sold_count:desc']
204
227
  end
205
228
  end
206
229
 
207
- # Transform Meilisearch facetDistribution into our standard filter response format
230
+ # Transform Meilisearch facetDistribution into standard filter response format.
231
+ # Override in subclasses to add custom facets — call super and append.
208
232
  def build_facet_response(facet_distribution)
209
- filters = []
210
-
211
- # Price range
212
- if facet_distribution['price'].present?
213
- amounts = facet_distribution['price'].keys.map(&:to_f)
214
- filters << {
215
- id: 'price',
216
- type: 'price_range',
217
- min: amounts.min,
218
- max: amounts.max,
219
- currency: currency
220
- }
221
- end
233
+ facets = []
234
+ facets << build_price_facet(facet_distribution['price']) if facet_distribution['price'].present?
235
+ facets << build_availability_facet(facet_distribution['in_stock']) if facet_distribution['in_stock'].present?
236
+ facets.concat(build_option_facets(facet_distribution['option_value_ids'])) if facet_distribution['option_value_ids'].present?
237
+ facets << build_category_facet(facet_distribution['category_ids']) if facet_distribution['category_ids'].present?
238
+ facets.compact
239
+ end
222
240
 
223
- # Availability
224
- if facet_distribution['in_stock'].present?
225
- in_stock = facet_distribution['in_stock']['true'] || 0
226
- out_of_stock = facet_distribution['in_stock']['false'] || 0
227
- filters << {
228
- id: 'availability',
229
- type: 'availability',
230
- options: [
231
- { id: 'in_stock', count: in_stock },
232
- { id: 'out_of_stock', count: out_of_stock }
233
- ]
234
- }
235
- end
241
+ def build_price_facet(distribution)
242
+ amounts = distribution.keys.map(&:to_f)
243
+ {
244
+ id: 'price',
245
+ type: 'price_range',
246
+ min: amounts.min,
247
+ max: amounts.max,
248
+ currency: currency
249
+ }
250
+ end
236
251
 
237
- # Option values — group by option type for faceted display
238
- if facet_distribution['option_value_ids'].present?
239
- prefixed_ids = facet_distribution['option_value_ids'].keys
240
- raw_ids = prefixed_ids.filter_map { |pid| Spree::OptionValue.decode_prefixed_id(pid) }
241
- option_values = Spree::OptionValue.where(id: raw_ids).includes(:option_type).preload_associations_lazily.index_by(&:prefixed_id)
242
-
243
- # Group by option type
244
- by_option_type = {}
245
- facet_distribution['option_value_ids'].each do |ov_prefixed_id, count|
246
- ov = option_values[ov_prefixed_id]
247
- next unless ov
248
-
249
- ot = ov.option_type
250
- by_option_type[ot] ||= []
251
- by_option_type[ot] << { id: ov.prefixed_id, name: ov.name, label: ov.label, position: ov.position, count: count }
252
- end
252
+ def build_availability_facet(distribution)
253
+ {
254
+ id: 'availability',
255
+ type: 'availability',
256
+ options: [
257
+ { id: 'in_stock', count: distribution['true'] || 0 },
258
+ { id: 'out_of_stock', count: distribution['false'] || 0 }
259
+ ]
260
+ }
261
+ end
253
262
 
254
- by_option_type.each do |option_type, values|
255
- filters << {
256
- id: option_type.prefixed_id,
257
- type: 'option',
258
- name: option_type.name,
259
- label: option_type.label,
260
- options: values.sort_by { |o| o[:position] }
261
- }
262
- end
263
+ def build_option_facets(distribution)
264
+ prefixed_ids = distribution.keys
265
+ raw_ids = prefixed_ids.filter_map { |pid| Spree::OptionValue.decode_prefixed_id(pid) }
266
+ option_values = Spree::OptionValue.where(id: raw_ids).includes(:option_type).preload_associations_lazily.index_by(&:prefixed_id)
267
+
268
+ # Group by option type
269
+ by_option_type = {}
270
+ distribution.each do |ov_prefixed_id, count|
271
+ ov = option_values[ov_prefixed_id]
272
+ next unless ov
273
+
274
+ ot = ov.option_type
275
+ by_option_type[ot] ||= []
276
+ by_option_type[ot] << { id: ov.prefixed_id, name: ov.name, label: ov.label, position: ov.position, count: count }
263
277
  end
264
278
 
265
- # Categories
266
- if facet_distribution['category_ids'].present?
267
- prefixed_ids = facet_distribution['category_ids'].keys
268
- raw_ids = prefixed_ids.filter_map { |pid| Spree::Taxon.decode_prefixed_id(pid) }
269
- categories = Spree::Taxon.where(id: raw_ids).index_by(&:prefixed_id)
270
-
271
- filters << {
272
- id: 'categories',
273
- type: 'category',
274
- options: facet_distribution['category_ids'].filter_map do |prefixed_id, count|
275
- cat = categories[prefixed_id]
276
- next unless cat
277
-
278
- { id: cat.prefixed_id, name: cat.name, permalink: cat.permalink, count: count }
279
- end
279
+ by_option_type.map do |option_type, values|
280
+ {
281
+ id: option_type.prefixed_id,
282
+ type: 'option',
283
+ name: option_type.name,
284
+ label: option_type.label,
285
+ options: values.sort_by { |o| o[:position] }
280
286
  }
281
287
  end
288
+ end
289
+
290
+ def build_category_facet(distribution)
291
+ prefixed_ids = distribution.keys
292
+ raw_ids = prefixed_ids.filter_map { |pid| Spree::Taxon.decode_prefixed_id(pid) }
293
+ categories = Spree::Taxon.where(id: raw_ids).index_by(&:prefixed_id)
282
294
 
283
- filters
295
+ {
296
+ id: 'categories',
297
+ type: 'category',
298
+ options: distribution.filter_map do |prefixed_id, count|
299
+ cat = categories[prefixed_id]
300
+ next unless cat
301
+
302
+ { id: cat.prefixed_id, name: cat.name, permalink: cat.permalink, count: count }
303
+ end
304
+ }
284
305
  end
285
306
 
286
307
  def build_pagy(ms_result, page, limit)
287
- require 'pagy/toolbox/paginators/meilisearch'
288
-
289
308
  fake_result = Struct.new(:raw_answer).new({
290
309
  'totalHits' => ms_result['estimatedTotalHits'] || 0,
291
310
  'hitsPerPage' => limit,
@@ -1,12 +1,6 @@
1
1
  module Spree
2
2
  module SearchProvider
3
3
  class ProductPresenter
4
- # Associations needed by this presenter — used by reindex and rake task for preloading
5
- REQUIRED_PRELOADS = [
6
- :taxons, :option_types, :primary_media, :store_products,
7
- { variants_including_master: [:prices, :option_values] }
8
- ].freeze
9
-
10
4
  attr_reader :product, :store
11
5
 
12
6
  def initialize(product, store)
@@ -8,8 +8,8 @@ module Spree
8
8
 
9
9
  def call(address_params: {}, user: nil, **opts)
10
10
  order = opts[:order]
11
- default_billing = opts.fetch(:default_billing, false)
12
- default_shipping = opts.fetch(:default_shipping, false)
11
+ default_billing = address_params.key?(:is_default_billing) ? address_params.delete(:is_default_billing) : opts.fetch(:default_billing, false)
12
+ default_shipping = address_params.key?(:is_default_shipping) ? address_params.delete(:is_default_shipping) : opts.fetch(:default_shipping, false)
13
13
 
14
14
  address_params = fill_country_and_state_ids(address_params)
15
15
 
@@ -8,8 +8,8 @@ module Spree
8
8
 
9
9
  def call(address:, address_params:, **opts)
10
10
  order = opts[:order]
11
- default_billing = opts.fetch(:default_billing, false)
12
- default_shipping = opts.fetch(:default_shipping, false)
11
+ default_billing = address_params.key?(:is_default_billing) ? address_params.delete(:is_default_billing) : opts.fetch(:default_billing, false)
12
+ default_shipping = address_params.key?(:is_default_shipping) ? address_params.delete(:is_default_shipping) : opts.fetch(:default_shipping, false)
13
13
  address_changes_except = opts.fetch(:address_changes_except, [])
14
14
  create_new_address_on_update = opts.fetch(:create_new_address_on_update, false)
15
15
  Spree::Deprecation.warn('Spree::Addresses::Update create_new_address_on_update parameter is deprecated and will be removed in Spree 5.5.') if create_new_address_on_update
@@ -5,8 +5,9 @@ module Spree
5
5
 
6
6
  def call(order:)
7
7
  @messages = []
8
+ @warnings = []
8
9
 
9
- return success([order, @messages]) if order.item_count.zero? || order.line_items.none?
10
+ return success([order, @messages, @warnings]) if order.item_count.zero? || order.line_items.none?
10
11
 
11
12
  line_items = order.line_items.includes(variant: [:product, :stock_items, :stock_locations, { stock_items: :stock_location }])
12
13
 
@@ -17,9 +18,9 @@ module Spree
17
18
  end
18
19
 
19
20
  if @messages.any? # If any line item was removed, reload the order
20
- success([order.reload, @messages])
21
+ success([order.reload, @messages, @warnings])
21
22
  else
22
- success([order, @messages])
23
+ success([order, @messages, @warnings])
23
24
  end
24
25
  end
25
26
 
@@ -28,7 +29,14 @@ module Spree
28
29
  def valid_status?(line_item)
29
30
  product = line_item.product
30
31
  if !product.active? || product.deleted? || product.discontinued? || line_item.variant.discontinued?
31
- @messages << Spree.t('cart_line_item.discontinued', li_name: line_item.name)
32
+ message = Spree.t('cart_line_item.discontinued', li_name: line_item.name)
33
+ @messages << message
34
+ @warnings << {
35
+ code: 'line_item_removed',
36
+ message: message,
37
+ line_item_id: line_item.prefixed_id,
38
+ variant_id: line_item.variant.prefixed_id
39
+ }
32
40
  return false
33
41
  end
34
42
  true
@@ -36,7 +44,14 @@ module Spree
36
44
 
37
45
  def stock_available?(line_item)
38
46
  if line_item.insufficient_stock?
39
- @messages << Spree.t('cart_line_item.out_of_stock', li_name: line_item.name)
47
+ message = Spree.t('cart_line_item.out_of_stock', li_name: line_item.name)
48
+ @messages << message
49
+ @warnings << {
50
+ code: 'line_item_removed',
51
+ message: message,
52
+ line_item_id: line_item.prefixed_id,
53
+ variant_id: line_item.variant.prefixed_id
54
+ }
40
55
  return false
41
56
  end
42
57
  true
@@ -89,18 +89,20 @@ module Spree
89
89
  cart.state = 'address'
90
90
  end
91
91
 
92
- # Auto-advance as far as the checkout state machine allows.
93
- # Loops cart.next until the cart can't progress further (e.g. missing
94
- # payment) or reaches confirm/complete. Stops at the first step whose
95
- # before_transition guard fails the `requirements` array in the
96
- # serialized response tells the frontend what's still missing.
97
- # Failure is swallowed — the update itself already succeeded.
92
+ # Auto-advance as far as the checkout state machine allows, but never
93
+ # to complete. The complete transition must always be explicit via
94
+ # the /carts/:id/complete endpoint otherwise gift cards or store
95
+ # credits that fully cover the order total would auto-complete the
96
+ # cart during address/delivery updates.
98
97
  def try_advance
99
98
  return if cart.complete? || cart.canceled?
100
99
 
100
+ steps = cart.checkout_steps
101
101
  loop do
102
+ current_index = steps.index(cart.state).to_i
103
+ next_step = steps[current_index + 1]
104
+ break if next_step.nil? || next_step == 'complete'
102
105
  break unless cart.next
103
- break if cart.confirm? || cart.complete?
104
106
  end
105
107
  rescue StandardError => e
106
108
  Rails.error.report(e, context: { order_id: cart.id, state: cart.state }, source: 'spree.checkout')
@@ -12,9 +12,13 @@ module Spree
12
12
  return failure(nil, Spree.t(:error_user_does_not_have_any_store_credits)) unless @order.user&.store_credits&.any?
13
13
 
14
14
  ApplicationRecord.transaction do
15
- @order.payments.store_credits.where(state: :checkout).map(&:invalidate!)
15
+ existing = @order.payments.store_credits.where(state: :checkout)
16
16
 
17
- apply_store_credits(remaining_total)
17
+ if existing.any?
18
+ update_existing_payments(existing, remaining_total)
19
+ else
20
+ apply_store_credits(remaining_total)
21
+ end
18
22
  end
19
23
 
20
24
  if @order.reload.payments.store_credits.valid.any?
@@ -27,6 +31,26 @@ module Spree
27
31
 
28
32
  private
29
33
 
34
+ # Update existing checkout store credit payments in place to avoid
35
+ # creating unnecessary invalid payment records on every recalculation.
36
+ def update_existing_payments(payments, remaining_total)
37
+ payments.each do |payment|
38
+ credit = payment.source
39
+ available = credit.amount_remaining + payment.amount
40
+ new_amount = [available, remaining_total].min
41
+
42
+ if new_amount.positive?
43
+ payment.update_column(:amount, new_amount)
44
+ remaining_total -= new_amount
45
+ else
46
+ payment.invalidate!
47
+ end
48
+ end
49
+
50
+ # If there's still remaining total, apply from additional store credits
51
+ apply_store_credits(remaining_total) if remaining_total.positive?
52
+ end
53
+
30
54
  def apply_store_credits(remaining_total)
31
55
  payment_method = Spree::PaymentMethod::StoreCredit.available.first
32
56
  raise 'Store credit payment method could not be found' unless payment_method
@@ -1222,6 +1222,7 @@ en:
1222
1222
  gift_card_expired: The Gift Card has expired.
1223
1223
  gift_card_mismatched_currency: Gift Card cannot be applied with current currency.
1224
1224
  gift_card_mismatched_customer: This gift card is associated with another user.
1225
+ gift_card_not_found: The Gift Card was not found.
1225
1226
  gift_card_removed: The Gift Card was successfully removed from your order
1226
1227
  gift_card_using_store_credit_error: You can't apply the Gift Card after you applied the store credit.
1227
1228
  gift_cards: Gift Cards
@@ -0,0 +1,20 @@
1
+ class CreateSpreePriceHistories < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :spree_price_histories do |t|
4
+ t.references :price, null: false, index: false
5
+ t.references :variant, null: false, index: false
6
+ t.decimal :amount, precision: 10, scale: 2, null: false
7
+ t.decimal :compare_at_amount, precision: 10, scale: 2
8
+ t.string :currency, null: false
9
+ t.datetime :recorded_at, null: false
10
+ t.datetime :created_at, null: false
11
+ end
12
+
13
+ add_index :spree_price_histories, [:variant_id, :currency, :recorded_at],
14
+ name: 'idx_price_histories_variant_currency_recorded'
15
+ add_index :spree_price_histories, [:price_id, :recorded_at],
16
+ name: 'idx_price_histories_price_recorded'
17
+ add_index :spree_price_histories, :recorded_at,
18
+ name: 'idx_price_histories_recorded_at'
19
+ end
20
+ end
@@ -96,6 +96,8 @@ module Spree
96
96
  preference :tax_using_ship_address, :boolean, default: true
97
97
  preference :title_site_name_separator, :string, deprecated: true
98
98
  preference :track_inventory_levels, :boolean, default: true # Determines whether to track on_hand values for variants / products.
99
+ preference :track_price_history, :boolean, default: true # Records price changes for Omnibus Directive compliance. Disable for non-EU stores.
100
+ preference :price_history_retention_days, :integer, default: 30 # Days to retain price history records. Used by spree:price_history:prune rake task.
99
101
  preference :use_user_locale, :boolean, default: true
100
102
 
101
103
  # Sets the path used for products, taxons and pages.
@@ -103,7 +103,10 @@ module Spree
103
103
  products_finder: 'Spree::Products::Find',
104
104
  taxon_finder: 'Spree::Taxons::Find',
105
105
  line_item_by_variant_finder: 'Spree::LineItems::FindByVariant',
106
- variant_finder: 'Spree::Variants::Find'
106
+ variant_finder: 'Spree::Variants::Find',
107
+
108
+ # search
109
+ search_product_presenter: 'Spree::SearchProvider::ProductPresenter'
107
110
  }.freeze
108
111
 
109
112
  include Spree::DependenciesHelper
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '5.4.0.rc1'.freeze
2
+ VERSION = '5.4.0.rc3'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
@@ -0,0 +1,9 @@
1
+ FactoryBot.define do
2
+ factory :price_history, class: Spree::PriceHistory do
3
+ price
4
+ variant { price.variant }
5
+ amount { price.amount }
6
+ currency { price.currency }
7
+ recorded_at { Time.current }
8
+ end
9
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :spree do
4
+ namespace :price_history do
5
+ desc 'Seed price history from existing base prices (run once after migration)'
6
+ task seed: :environment do
7
+ count = 0
8
+ Spree::Price.where(deleted_at: nil, price_list_id: nil).find_each do |price|
9
+ next if price.amount.nil?
10
+ next if Spree::PriceHistory.exists?(price_id: price.id)
11
+
12
+ Spree::PriceHistory.create!(
13
+ price: price,
14
+ variant_id: price.variant_id,
15
+ amount: price.amount,
16
+ compare_at_amount: price.compare_at_amount,
17
+ currency: price.currency,
18
+ recorded_at: price.updated_at || Time.current
19
+ )
20
+ count += 1
21
+ end
22
+
23
+ puts "Seeded #{count} price history records"
24
+ end
25
+
26
+ desc 'Prune price history older than retention period'
27
+ task prune: :environment do
28
+ retention_days = Spree::Config[:price_history_retention_days] || 30
29
+ deleted = Spree::PriceHistory
30
+ .where('recorded_at < ?', retention_days.days.ago)
31
+ .delete_all
32
+
33
+ puts "Pruned #{deleted} price history records older than #{retention_days} days"
34
+ end
35
+ end
36
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_core
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.4.0.rc1
4
+ version: 5.4.0.rc3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Schofield
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2026-03-20 00:00:00.000000000 Z
13
+ date: 2026-03-25 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: i18n-tasks
@@ -1052,6 +1052,7 @@ files:
1052
1052
  - app/models/spree/policy.rb
1053
1053
  - app/models/spree/preference.rb
1054
1054
  - app/models/spree/price.rb
1055
+ - app/models/spree/price_history.rb
1055
1056
  - app/models/spree/price_list.rb
1056
1057
  - app/models/spree/price_rule.rb
1057
1058
  - app/models/spree/price_rules/customer_group_rule.rb
@@ -1472,6 +1473,7 @@ files:
1472
1473
  - db/migrate/20260315000000_create_spree_allowed_origins.rb
1473
1474
  - db/migrate/20260315100000_add_product_media_support.rb
1474
1475
  - db/migrate/20260317000000_create_spree_refresh_tokens.rb
1476
+ - db/migrate/20260323000000_create_spree_price_histories.rb
1475
1477
  - db/sample_data/customers.csv
1476
1478
  - db/sample_data/metafield_definitions.rb
1477
1479
  - db/sample_data/orders.rb
@@ -1599,6 +1601,7 @@ files:
1599
1601
  - lib/spree/testing_support/factories/payment_source_factory.rb
1600
1602
  - lib/spree/testing_support/factories/policy_factory.rb
1601
1603
  - lib/spree/testing_support/factories/price_factory.rb
1604
+ - lib/spree/testing_support/factories/price_history_factory.rb
1602
1605
  - lib/spree/testing_support/factories/price_list_factory.rb
1603
1606
  - lib/spree/testing_support/factories/price_rule_factory.rb
1604
1607
  - lib/spree/testing_support/factories/product_factory.rb
@@ -1670,6 +1673,7 @@ files:
1670
1673
  - lib/tasks/dependencies.rake
1671
1674
  - lib/tasks/images.rake
1672
1675
  - lib/tasks/markets.rake
1676
+ - lib/tasks/price_history.rake
1673
1677
  - lib/tasks/products.rake
1674
1678
  - lib/tasks/sample_data.rake
1675
1679
  - lib/tasks/search.rake
@@ -1695,9 +1699,9 @@ licenses:
1695
1699
  - BSD-3-Clause
1696
1700
  metadata:
1697
1701
  bug_tracker_uri: https://github.com/spree/spree/issues
1698
- changelog_uri: https://github.com/spree/spree/releases/tag/v5.4.0.rc1
1702
+ changelog_uri: https://github.com/spree/spree/releases/tag/v5.4.0.rc3
1699
1703
  documentation_uri: https://docs.spreecommerce.org/
1700
- source_code_uri: https://github.com/spree/spree/tree/v5.4.0.rc1
1704
+ source_code_uri: https://github.com/spree/spree/tree/v5.4.0.rc3
1701
1705
  post_install_message:
1702
1706
  rdoc_options: []
1703
1707
  require_paths: