spree_core 5.4.0.rc2 → 5.4.0.rc4

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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/app/mailers/spree/webhook_mailer.rb +17 -0
  3. data/app/models/concerns/spree/product_scopes.rb +60 -16
  4. data/app/models/spree/order/gift_card.rb +23 -3
  5. data/app/models/spree/order.rb +18 -1
  6. data/app/models/spree/price.rb +29 -0
  7. data/app/models/spree/price_history.rb +29 -0
  8. data/app/models/spree/product.rb +1 -1
  9. data/app/models/spree/promotion_handler/coupon.rb +9 -2
  10. data/app/models/spree/search_provider/base.rb +1 -1
  11. data/app/models/spree/search_provider/database.rb +14 -3
  12. data/app/models/spree/search_provider/meilisearch.rb +94 -8
  13. data/app/models/spree/webhook_delivery.rb +28 -2
  14. data/app/models/spree/webhook_endpoint.rb +71 -1
  15. data/app/presenters/spree/search_provider/product_presenter.rb +9 -1
  16. data/app/services/spree/cart/remove_out_of_stock_items.rb +20 -5
  17. data/app/services/spree/carts/update.rb +9 -7
  18. data/app/services/spree/checkout/add_store_credit.rb +26 -2
  19. data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +26 -0
  20. data/app/views/spree/webhook_mailer/endpoint_disabled.text.erb +10 -0
  21. data/config/locales/en.yml +11 -0
  22. data/db/migrate/20260323000000_create_spree_price_histories.rb +20 -0
  23. data/db/migrate/20260326000001_improve_spree_webhooks.rb +17 -0
  24. data/lib/spree/core/configuration.rb +2 -0
  25. data/lib/spree/core/version.rb +1 -1
  26. data/lib/spree/permitted_attributes.rb +1 -1
  27. data/lib/spree/testing_support/factories/price_history_factory.rb +9 -0
  28. data/lib/spree/testing_support/factories/webhook_endpoint_factory.rb +7 -0
  29. data/lib/tasks/price_history.rake +36 -0
  30. metadata +12 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5de0595dccb2046acb60805390c641e66c9e87c070c25df2587d5a8eaf9bc358
4
- data.tar.gz: 6cd99e1b81481dd826565d1f579ea64965dfa04a808905f830505110f330473d
3
+ metadata.gz: 7bc3a50296fda5fe9d3f9cfa5ec4547b44e8dc7c718634f0de73314d55210479
4
+ data.tar.gz: 74757285458b4d4a322d5cdc0ea550c4fe7e9ca9de25095fba0371b6c1fb1e93
5
5
  SHA512:
6
- metadata.gz: 1d27ab08a797c2f5419f36bd3ad368f1cecaf6610d1ca9289e9b5dea81f62a4856b8b767b23129b1dd20e0e0fd0c02f619581e58b7b347642cc801a2808de453
7
- data.tar.gz: d551a808c3d989cf88e02c18b00bd9582b7672459b59e9c0ea281b6797cf5b0b95b698e32f98adc7a180723ab98e84aae99bc163ac5ee7de7a3d6745abc97e80
6
+ metadata.gz: c9ce63a0797b37332944d4d1df8e60f132c27af526e970fd290f993d03b0d9fbf1fcbb1c2c3b55d37184cc10f72b884c79ec68b42398928cbc490a5b55a982c5
7
+ data.tar.gz: 14e3357af8ce8cdb75764e15b14759fe4ff37f132a32242b87c269d3137c6f41d11071415be24975c71e4086df5a45b5853840a68dca91ea9541857bd7b18d78
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spree
4
+ class WebhookMailer < BaseMailer
5
+ def endpoint_disabled(webhook_endpoint)
6
+ @endpoint = webhook_endpoint
7
+ @current_store = webhook_endpoint.store
8
+
9
+ mail(
10
+ to: @current_store.new_order_notifications_email.presence || @current_store.mail_from_address,
11
+ from: from_address,
12
+ subject: Spree.t('webhook_mailer.endpoint_disabled.subject', endpoint_name: @endpoint.name || @endpoint.url),
13
+ store_url: @current_store.formatted_url
14
+ )
15
+ end
16
+ end
17
+ end
@@ -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") }
@@ -137,8 +147,32 @@ module Spree
137
147
  where("#{Classification.table_name}.taxon_id" => taxon.cached_self_and_descendants_ids).distinct
138
148
  }
139
149
 
140
- # Alias for in_taxon public API name
141
- scope :in_category, ->(category) { in_taxon(category) }
150
+ # Products in a category AND all its descendants.
151
+ # Accepts a Category record or a prefixed ID string (e.g. 'ctg_xxx').
152
+ def self.in_category(category_or_id)
153
+ category = category_or_id.is_a?(String) ? Spree::Taxon.find_by_prefix_id(category_or_id) : category_or_id
154
+ return none unless category
155
+
156
+ in_taxon(category)
157
+ end
158
+
159
+ # Products in ANY of the given categories (OR logic), each including descendants.
160
+ # Accepts an array of Category records, prefixed ID strings, or a mix.
161
+ def self.in_categories(*categories_or_ids)
162
+ categories_or_ids = categories_or_ids.flatten.compact
163
+ return none if categories_or_ids.empty?
164
+
165
+ ids, records = categories_or_ids.partition { |c| c.is_a?(String) }
166
+ if ids.any?
167
+ decoded = ids.filter_map { |id| Spree::Taxon.decode_prefixed_id(id) }
168
+ records += Spree::Taxon.where(id: decoded).to_a if decoded.any?
169
+ end
170
+ return none if records.empty?
171
+
172
+ taxon_ids = records.flat_map(&:cached_self_and_descendants_ids).uniq
173
+
174
+ joins(:classifications).where(Classification.table_name => { taxon_id: taxon_ids }).distinct
175
+ end
142
176
 
143
177
  # Deprecated — remove in 6.0. Use in_taxon instead.
144
178
  def self.in_taxons(*taxons)
@@ -190,21 +224,31 @@ module Spree
190
224
  where(Spree::OptionValue.table_name => { name: value, option_type_id: option_type_id })
191
225
  }
192
226
 
193
- # Filters products by option value IDs (prefix IDs like 'optval_xxx')
194
- # Accepts an array of option value IDs
195
- scope :with_option_value_ids, ->(*ids) {
227
+ # Filters products by option value IDs (prefix IDs like 'optval_xxx').
228
+ # Groups values by option type automatically:
229
+ # - Within the same option type: OR (Blue OR Red)
230
+ # - Across different option types: AND ((Blue OR Red) AND (S OR M))
231
+ def self.with_option_value_ids(*ids)
196
232
  ids = ids.flatten.compact
197
- next none if ids.empty?
198
-
199
- # Handle prefixed IDs (optval_xxx) by decoding to actual IDs
200
- actual_ids = ids.map do |id|
201
- id.to_s.include?('_') ? OptionValue.decode_prefixed_id(id) : id
202
- end.compact
203
-
204
- next none if actual_ids.empty?
205
-
206
- joins(variants: :option_values).where(Spree::OptionValue.table_name => { id: actual_ids })
207
- }
233
+ return none if ids.empty?
234
+
235
+ actual_ids = ids.map { |id| id.to_s.include?('_') ? OptionValue.decode_prefixed_id(id) : id }.compact
236
+ return none if actual_ids.empty?
237
+
238
+ grouped = OptionValue.where(id: actual_ids).group_by(&:option_type_id)
239
+ return none if grouped.empty?
240
+
241
+ scope = all
242
+ grouped.each_value do |option_values|
243
+ ov_ids = option_values.map(&:id)
244
+ matching_product_ids = Variant.where(deleted_at: nil)
245
+ .joins(:option_value_variants)
246
+ .where(OptionValueVariant.table_name => { option_value_id: ov_ids })
247
+ .select(:product_id)
248
+ scope = scope.where(id: matching_product_ids)
249
+ end
250
+ scope
251
+ end
208
252
 
209
253
  # Deprecated — remove in 6.0. Not used internally.
210
254
  def self.with(value)
@@ -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
@@ -200,7 +200,7 @@ module Spree
200
200
  self.whitelisted_ransackable_attributes = %w[description name slug discontinue_on status available_on created_at updated_at]
201
201
  self.whitelisted_ransackable_associations = %w[taxons categories stores variants_including_master master variants tags labels
202
202
  shipping_category classifications option_types]
203
- self.whitelisted_ransackable_scopes = %w[not_discontinued search_by_name in_taxon price_between
203
+ self.whitelisted_ransackable_scopes = %w[not_discontinued search_by_name in_taxon in_category in_categories price_between
204
204
  price_lte price_gte
205
205
  search multi_search in_stock out_of_stock with_option_value_ids
206
206
 
@@ -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
 
@@ -17,7 +17,7 @@ module Spree
17
17
  #
18
18
  # @param scope [ActiveRecord::Relation] base scope (store-scoped, visibility-filtered, authorized)
19
19
  # @param query [String, nil] text search query
20
- # @param filters [Hash] structured filters (price_gte, with_option_value_ids, categories_id_eq, etc.)
20
+ # @param filters [Hash] structured filters (price_gte, with_option_value_ids, in_category, in_categories, etc.)
21
21
  # @param sort [String, nil] sort param (e.g. 'price', '-price', 'best_selling')
22
22
  # @param page [Integer] page number
23
23
  # @param limit [Integer] results per page
@@ -14,6 +14,9 @@ module Spree
14
14
  # 2. Extract internal params before passing to Ransack
15
15
  category = filters.is_a?(Hash) ? filters.delete('_category') || filters.delete(:_category) : nil
16
16
 
17
+ # 2b. Extract option value IDs — handled by scope (OR within type, AND across)
18
+ option_value_ids = filters.is_a?(Hash) ? filters.delete('with_option_value_ids') || filters.delete(:with_option_value_ids) : nil
19
+
17
20
  # 3. Structured filtering via Ransack
18
21
  ransack_filters = sanitize_filters(filters)
19
22
  if ransack_filters.present?
@@ -21,8 +24,14 @@ module Spree
21
24
  scope = search.result(distinct: true)
22
25
  end
23
26
 
27
+ # Save scope before option filters for disjunctive facet counts
28
+ scope_before_options = scope
29
+ if option_value_ids.present?
30
+ scope = scope.with_option_value_ids(Array(option_value_ids))
31
+ end
32
+
24
33
  # 4. Facets (before sorting to avoid computed column conflicts with count)
25
- filter_facets = build_facets(scope, category: category)
34
+ filter_facets = build_facets(scope, category: category, option_value_ids: Array(option_value_ids), scope_before_options: scope_before_options)
26
35
 
27
36
  # 5. Total count (before sorting to avoid computed column conflicts with count)
28
37
  total = scope.distinct.count
@@ -49,13 +58,15 @@ module Spree
49
58
  Pagy::Offset.new(count: count, page: page, limit: limit)
50
59
  end
51
60
 
52
- def build_facets(scope, category: nil)
61
+ def build_facets(scope, category: nil, option_value_ids: [], scope_before_options: nil)
53
62
  return { filters: [], sort_options: available_sort_options, default_sort: 'manual' } unless defined?(Spree::Api::V3::FiltersAggregator)
54
63
 
55
64
  Spree::Api::V3::FiltersAggregator.new(
56
65
  scope: scope,
57
66
  currency: currency,
58
- category: category
67
+ category: category,
68
+ option_value_ids: option_value_ids,
69
+ scope_before_options: scope_before_options || scope
59
70
  ).call
60
71
  end
61
72
 
@@ -20,9 +20,19 @@ module Spree
20
20
  def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
21
21
  page = [page.to_i, 1].max
22
22
  limit = limit.to_i.clamp(1, 100)
23
+ filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
24
+ filters = (filters || {}).stringify_keys
25
+
26
+ # Extract and group option values by option type for proper OR/AND semantics
27
+ option_value_ids = extract_and_delete(filters, 'with_option_value_ids')
28
+ grouped_options = group_option_values_by_type(Array(option_value_ids))
29
+
30
+ base_conditions = build_filters(filters)
31
+ option_conditions = build_grouped_option_conditions(grouped_options)
32
+ all_conditions = base_conditions + option_conditions
23
33
 
24
34
  search_params = {
25
- filter: build_filters(filters),
35
+ filter: all_conditions,
26
36
  facets: facet_attributes,
27
37
  sort: build_sort(sort),
28
38
  offset: (page - 1) * limit,
@@ -32,7 +42,55 @@ module Spree
32
42
  Rails.logger.debug { "[Meilisearch] index=#{index_name} query=#{query.inspect} #{search_params.compact.inspect}" }
33
43
 
34
44
  begin
35
- ms_result = client.index(index_name).search(query.to_s, search_params)
45
+ if grouped_options.any?
46
+ # N+1 multi-search: 1 hit query + 1 disjunctive facet query per active option type
47
+ queries = [{ indexUid: index_name, q: query.to_s, **search_params }]
48
+ option_type_ids_ordered = grouped_options.keys
49
+ option_type_ids_ordered.each do |option_type_id|
50
+ without_this = build_grouped_option_conditions(grouped_options.except(option_type_id))
51
+ queries << { indexUid: index_name, q: query.to_s, filter: base_conditions + without_this, facets: ['option_value_ids'], limit: 0 }
52
+ end
53
+
54
+ results = client.multi_search(queries)
55
+ ms_result = results['results'][0]
56
+
57
+ # Merge disjunctive counts per option type.
58
+ # Each disjunctive query excluded one option type's filter.
59
+ # Use that query's full option_value_ids distribution for that option type's values,
60
+ # and the main query's distribution for everything else.
61
+ main_ov_dist = ms_result.dig('facetDistribution', 'option_value_ids') || {}
62
+
63
+ # Build a set of prefixed IDs per option type (including unselected values)
64
+ # by looking up which option type each option value belongs to.
65
+ all_ov_prefixed_ids = Set.new
66
+ disjunctive_dists = {}
67
+ results['results'][1..].each_with_index do |r, idx|
68
+ dist = r.dig('facetDistribution', 'option_value_ids') || {}
69
+ disjunctive_dists[option_type_ids_ordered[idx]] = dist
70
+ all_ov_prefixed_ids.merge(dist.keys)
71
+ end
72
+
73
+ # Resolve which prefixed IDs belong to which option type
74
+ all_raw_ids = all_ov_prefixed_ids.filter_map { |pid| Spree::OptionValue.decode_prefixed_id(pid) }
75
+ ov_to_type = Spree::OptionValue.where(id: all_raw_ids).pluck(:id, :option_type_id).to_h
76
+ prefixed_to_type = all_ov_prefixed_ids.each_with_object({}) do |pid, h|
77
+ raw = Spree::OptionValue.decode_prefixed_id(pid)
78
+ h[pid] = ov_to_type[raw] if raw
79
+ end
80
+
81
+ # Start with main query's distribution, overlay disjunctive counts for active option types
82
+ merged_ov_dist = main_ov_dist.dup
83
+ disjunctive_dists.each do |option_type_id, dist|
84
+ dist.each do |pid, count|
85
+ merged_ov_dist[pid] = count if prefixed_to_type[pid] == option_type_id
86
+ end
87
+ end
88
+
89
+ facet_distribution = (ms_result['facetDistribution'] || {}).merge('option_value_ids' => merged_ov_dist)
90
+ else
91
+ ms_result = client.index(index_name).search(query.to_s, search_params)
92
+ facet_distribution = ms_result['facetDistribution'] || {}
93
+ end
36
94
  rescue ::Meilisearch::ApiError => e
37
95
  Rails.logger.warn { "[Meilisearch] Search failed: #{e.message}. Run `rake spree:search:reindex` to initialize the index." }
38
96
  Rails.error.report(e, handled: true, context: { index: index_name, query: query })
@@ -46,20 +104,17 @@ module Spree
46
104
  raw_ids = product_prefixed_ids.filter_map { |pid| Spree::Product.decode_prefixed_id(pid) }
47
105
 
48
106
  # Intersect with AR scope for security/visibility.
49
- # Since we filter by store/status/currency/discontinue_on in Meilisearch,
50
- # the AR scope is a safety net — it should not filter anything out.
51
107
  products = if raw_ids.any?
52
108
  scope.where(id: raw_ids).reorder(nil)
53
109
  else
54
110
  scope.none
55
111
  end
56
112
 
57
- # Build Pagy object from Meilisearch response (passive mode)
58
113
  pagy = build_pagy(ms_result, page, limit)
59
114
 
60
115
  SearchResult.new(
61
116
  products: products,
62
- filters: build_facet_response(ms_result['facetDistribution'] || {}),
117
+ filters: build_facet_response(facet_distribution),
63
118
  sort_options: available_sort_options.map { |id| { id: id } },
64
119
  default_sort: 'manual',
65
120
  total_count: ms_result['estimatedTotalHits'] || 0,
@@ -198,11 +253,42 @@ module Spree
198
253
  'in_stock = true' if value.to_s != '0'
199
254
  when 'out_of_stock'
200
255
  'in_stock = false' if value.to_s != '0'
201
- when 'categories_id_eq'
256
+ when 'in_category'
202
257
  "category_ids = '#{sanitize_prefixed_id(value)}'" if valid_prefixed_id?(value)
258
+ when 'in_categories'
259
+ parts = Array(value).filter_map { |id| "category_ids = '#{sanitize_prefixed_id(id)}'" if valid_prefixed_id?(id) }
260
+ parts.length > 1 ? "(#{parts.join(' OR ')})" : parts.first
203
261
  when 'with_option_value_ids'
204
- Array(value).filter_map { |ov| "option_value_ids = '#{sanitize_prefixed_id(ov)}'" if valid_prefixed_id?(ov) }
262
+ # Handled by grouped option conditions in search_and_filter — skip here
263
+ nil
264
+ end
265
+ end
266
+
267
+ # Group prefixed option value IDs by option type (single DB query).
268
+ # Returns { option_type_id => ['optval_abc', 'optval_def'], ... }
269
+ def group_option_values_by_type(prefixed_ids)
270
+ prefixed_ids = prefixed_ids.flatten.compact.select { |id| valid_prefixed_id?(id) }
271
+ return {} if prefixed_ids.empty?
272
+
273
+ raw_ids = prefixed_ids.filter_map { |id| Spree::OptionValue.decode_prefixed_id(id) }
274
+ Spree::OptionValue.where(id: raw_ids).group_by(&:option_type_id).transform_values { |ovs| ovs.map(&:prefixed_id) }
275
+ end
276
+
277
+ # Build Meilisearch filter conditions from grouped option values.
278
+ # OR within each option type, AND across option types.
279
+ def build_grouped_option_conditions(grouped)
280
+ grouped.map do |_, prefixed_ids|
281
+ parts = prefixed_ids.map { |id| "option_value_ids = '#{sanitize_prefixed_id(id)}'" }
282
+ parts.length > 1 ? "(#{parts.join(' OR ')})" : parts.first
283
+ end
284
+ end
285
+
286
+ def extract_and_delete(hash, *keys)
287
+ keys.each do |key|
288
+ value = hash.delete(key) || hash.delete(key.to_sym)
289
+ return value if value.present?
205
290
  end
291
+ nil
206
292
  end
207
293
 
208
294
  # Sort param to Meilisearch sort syntax.
@@ -42,21 +42,47 @@ module Spree
42
42
  delivered_at.nil?
43
43
  end
44
44
 
45
- # Mark delivery as completed with HTTP response
45
+ # Mark delivery as completed with HTTP response.
46
+ # Triggers auto-disable check on the endpoint after failures.
46
47
  #
47
48
  # @param response_code [Integer] HTTP response code
48
49
  # @param execution_time [Integer] time in milliseconds
49
50
  # @param response_body [String] response body from the webhook endpoint
50
51
  def complete!(response_code: nil, execution_time:, error_type: nil, request_errors: nil, response_body: nil)
52
+ is_success = response_code.present? && response_code.to_s.start_with?('2')
53
+
51
54
  update!(
52
55
  response_code: response_code,
53
56
  execution_time: execution_time,
54
57
  error_type: error_type,
55
58
  request_errors: request_errors,
56
59
  response_body: response_body,
57
- success: response_code.present? && response_code.to_s.start_with?('2'),
60
+ success: is_success,
58
61
  delivered_at: Time.current
59
62
  )
63
+
64
+ webhook_endpoint.check_auto_disable! unless is_success
65
+ end
66
+
67
+ # Create a new delivery with the same payload and queue it.
68
+ # Used to retry failed deliveries manually.
69
+ #
70
+ # @return [Spree::WebhookDelivery] the new delivery
71
+ def redeliver!
72
+ new_delivery = webhook_endpoint.webhook_deliveries.create!(
73
+ event_name: event_name,
74
+ event_id: nil, # new delivery, not a duplicate
75
+ payload: payload
76
+ )
77
+
78
+ new_delivery.queue_for_delivery!
79
+ new_delivery
80
+ end
81
+
82
+ # Queue this delivery for processing.
83
+ # Resolves the job class dynamically since it lives in the api gem.
84
+ def queue_for_delivery!
85
+ 'Spree::WebhookDeliveryJob'.constantize.perform_later(id)
60
86
  end
61
87
  end
62
88
  end
@@ -19,12 +19,18 @@ module Spree
19
19
  validates :store, :url, presence: true
20
20
  validates :url, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]), message: :invalid_url }
21
21
  validates :active, inclusion: { in: [true, false] }
22
- validate :url_must_not_resolve_to_private_ip, if: -> { url.present? && url_changed? }
22
+ validate :url_must_not_resolve_to_private_ip, if: -> { !Rails.env.development? && url.present? && url_changed? }
23
23
 
24
24
  before_create :generate_secret_key
25
25
 
26
+ self.whitelisted_ransackable_attributes = %w[name url active]
27
+
26
28
  scope :active, -> { where(active: true) }
27
29
  scope :inactive, -> { where(active: false) }
30
+ scope :enabled, -> { active.where(disabled_at: nil) }
31
+
32
+ # Number of consecutive failed deliveries before auto-disabling
33
+ AUTO_DISABLE_THRESHOLD = 15
28
34
 
29
35
  # Check if this endpoint is subscribed to a specific event
30
36
  #
@@ -52,6 +58,70 @@ module Spree
52
58
  subscriptions
53
59
  end
54
60
 
61
+ # Send a test/ping webhook to verify the endpoint is reachable.
62
+ # Creates a delivery record and queues it for delivery.
63
+ #
64
+ # @return [Spree::WebhookDelivery]
65
+ def send_test!
66
+ delivery = webhook_deliveries.create!(
67
+ event_name: 'webhook.test',
68
+ payload: {
69
+ id: SecureRandom.uuid,
70
+ name: 'webhook.test',
71
+ created_at: Time.current.iso8601,
72
+ data: { message: 'This is a test webhook from Spree.' },
73
+ metadata: { spree_version: Spree.version }
74
+ }
75
+ )
76
+
77
+ delivery.queue_for_delivery!
78
+ delivery
79
+ end
80
+
81
+ # Disable this endpoint due to repeated failures.
82
+ # Sends a notification email to the store staff.
83
+ #
84
+ # @param reason [String]
85
+ # @param notify [Boolean] whether to send an email notification (default: true)
86
+ def disable!(reason: 'Automatically disabled after repeated delivery failures', notify: true)
87
+ update!(active: false, disabled_reason: reason, disabled_at: Time.current)
88
+ Spree::WebhookMailer.endpoint_disabled(self).deliver_later if notify
89
+ end
90
+
91
+ # Re-enable a previously disabled endpoint.
92
+ def enable!
93
+ update!(active: true, disabled_reason: nil, disabled_at: nil)
94
+ end
95
+
96
+ # Check if the endpoint was auto-disabled
97
+ #
98
+ # @return [Boolean]
99
+ def auto_disabled?
100
+ disabled_at.present?
101
+ end
102
+
103
+ # Check if auto-disable threshold has been reached
104
+ # and disable if so.
105
+ def check_auto_disable!
106
+ return if auto_disabled?
107
+
108
+ consecutive_failures = webhook_deliveries
109
+ .where(success: false)
110
+ .where.not(delivered_at: nil)
111
+ .order(delivered_at: :desc)
112
+ .limit(AUTO_DISABLE_THRESHOLD)
113
+
114
+ return if consecutive_failures.count < AUTO_DISABLE_THRESHOLD
115
+
116
+ # Verify they're all failures (no successes interspersed)
117
+ last_success = webhook_deliveries.successful.order(delivered_at: :desc).pick(:delivered_at)
118
+ oldest_failure = consecutive_failures.last&.delivered_at
119
+
120
+ if last_success.nil? || (oldest_failure && oldest_failure > last_success)
121
+ disable!
122
+ end
123
+ end
124
+
55
125
  private
56
126
 
57
127
  def generate_secret_key
@@ -55,7 +55,7 @@ module Spree
55
55
  in_stock: product.in_stock?,
56
56
  store_ids: cached_store_ids,
57
57
  discontinue_on: product.discontinue_on&.to_i || 0,
58
- category_ids: product.taxons.map(&:prefixed_id),
58
+ category_ids: category_ids_with_ancestors,
59
59
  category_names: product.taxons.map { |t| translated(t, :name, fallback_locale) },
60
60
  option_type_ids: product.option_types.map(&:prefixed_id),
61
61
  option_type_names: product.option_types.map { |ot| translated(ot, :presentation, fallback_locale) },
@@ -103,6 +103,14 @@ module Spree
103
103
  @compare_at_cache[currency]
104
104
  end
105
105
 
106
+ # Include ancestor category IDs so filtering by a parent category
107
+ # matches products classified under its descendants.
108
+ def category_ids_with_ancestors
109
+ @category_ids_with_ancestors ||= product.taxons.flat_map { |t|
110
+ t.self_and_ancestors.map(&:prefixed_id)
111
+ }.uniq
112
+ end
113
+
106
114
  # Memoized — avoids N+1 when called per document
107
115
  def cached_store_ids
108
116
  @cached_store_ids ||= product.store_ids.map(&:to_s)
@@ -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
@@ -0,0 +1,26 @@
1
+ <h1><%= Spree.t('webhook_mailer.endpoint_disabled.heading') %></h1>
2
+
3
+ <p><%= Spree.t('webhook_mailer.endpoint_disabled.message', endpoint_name: @endpoint.name || @endpoint.url) %></p>
4
+
5
+ <table role="presentation" style="width: 100%; border-collapse: collapse; margin: 20px 0;">
6
+ <tr>
7
+ <td style="padding: 8px 0; color: #6b7280;"><strong><%= Spree.t('webhook_mailer.endpoint_disabled.url_label') %></strong></td>
8
+ <td style="padding: 8px 0;"><%= @endpoint.url %></td>
9
+ </tr>
10
+ <% if @endpoint.name.present? %>
11
+ <tr>
12
+ <td style="padding: 8px 0; color: #6b7280;"><strong><%= Spree.t('webhook_mailer.endpoint_disabled.name_label') %></strong></td>
13
+ <td style="padding: 8px 0;"><%= @endpoint.name %></td>
14
+ </tr>
15
+ <% end %>
16
+ <tr>
17
+ <td style="padding: 8px 0; color: #6b7280;"><strong><%= Spree.t('webhook_mailer.endpoint_disabled.reason_label') %></strong></td>
18
+ <td style="padding: 8px 0;"><%= @endpoint.disabled_reason %></td>
19
+ </tr>
20
+ <tr>
21
+ <td style="padding: 8px 0; color: #6b7280;"><strong><%= Spree.t('webhook_mailer.endpoint_disabled.disabled_at_label') %></strong></td>
22
+ <td style="padding: 8px 0;"><%= @endpoint.disabled_at&.strftime('%Y-%m-%d %H:%M %Z') %></td>
23
+ </tr>
24
+ </table>
25
+
26
+ <p><%= Spree.t('webhook_mailer.endpoint_disabled.instructions') %></p>
@@ -0,0 +1,10 @@
1
+ <%= Spree.t('webhook_mailer.endpoint_disabled.heading') %>
2
+
3
+ <%= Spree.t('webhook_mailer.endpoint_disabled.message', endpoint_name: @endpoint.name || @endpoint.url) %>
4
+
5
+ URL: <%= @endpoint.url %>
6
+ <% if @endpoint.name.present? %>Name: <%= @endpoint.name %><% end %>
7
+ Reason: <%= @endpoint.disabled_reason %>
8
+ Disabled at: <%= @endpoint.disabled_at&.strftime('%Y-%m-%d %H:%M %Z') %>
9
+
10
+ <%= Spree.t('webhook_mailer.endpoint_disabled.instructions') %>
@@ -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
@@ -2479,6 +2480,16 @@ en:
2479
2480
  we_are_busy_updating_page: We're busy updating this page.
2480
2481
  we_will_be_back: We will be back.
2481
2482
  webhook_endpoints: Webhook Endpoints
2483
+ webhook_mailer:
2484
+ endpoint_disabled:
2485
+ disabled_at_label: Disabled at
2486
+ heading: Webhook Endpoint Disabled
2487
+ instructions: Please check the endpoint URL and service, then re-enable it in Settings → Developers → Webhooks.
2488
+ message: The webhook endpoint "%{endpoint_name}" has been automatically disabled after repeated delivery failures.
2489
+ name_label: Name
2490
+ reason_label: Reason
2491
+ subject: 'Webhook endpoint disabled: %{endpoint_name}'
2492
+ url_label: URL
2482
2493
  webhooks: Webhooks
2483
2494
  weight: Weight
2484
2495
  weight_unit: Weight unit
@@ -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
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ImproveSpreeWebhooks < ActiveRecord::Migration[7.2]
4
+ def change
5
+ # Endpoint name + auto-disable tracking
6
+ add_column :spree_webhook_endpoints, :name, :string
7
+ add_column :spree_webhook_endpoints, :disabled_reason, :string
8
+ add_column :spree_webhook_endpoints, :disabled_at, :datetime
9
+
10
+ # Event ID for delivery deduplication
11
+ add_column :spree_webhook_deliveries, :event_id, :string
12
+ add_index :spree_webhook_deliveries, [:webhook_endpoint_id, :event_id],
13
+ unique: true,
14
+ where: 'event_id IS NOT NULL',
15
+ name: 'index_spree_webhook_deliveries_on_endpoint_and_event'
16
+ end
17
+ 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.
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '5.4.0.rc2'.freeze
2
+ VERSION = '5.4.0.rc4'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
@@ -319,7 +319,7 @@ module Spree
319
319
  }
320
320
  ]
321
321
 
322
- @@webhook_endpoint_attributes = [:url, :secret, :active, subscriptions: []]
322
+ @@webhook_endpoint_attributes = [:name, :url, :secret, :active, subscriptions: []]
323
323
 
324
324
  @@wishlist_attributes = [:name, :is_default, :is_private]
325
325
 
@@ -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
@@ -3,6 +3,7 @@
3
3
  FactoryBot.define do
4
4
  factory :webhook_endpoint, class: Spree::WebhookEndpoint do
5
5
  store
6
+ sequence(:name) { |n| "Endpoint #{n}" }
6
7
  sequence(:url) { |n| "https://example.com/webhooks/#{n}" }
7
8
  active { true }
8
9
  subscriptions { [] }
@@ -18,5 +19,11 @@ FactoryBot.define do
18
19
  trait :all_events do
19
20
  subscriptions { ['*'] }
20
21
  end
22
+
23
+ trait :auto_disabled do
24
+ active { false }
25
+ disabled_at { Time.current }
26
+ disabled_reason { 'Automatically disabled after repeated delivery failures' }
27
+ end
21
28
  end
22
29
  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.rc2
4
+ version: 5.4.0.rc4
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-23 00:00:00.000000000 Z
13
+ date: 2026-03-26 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: i18n-tasks
@@ -869,6 +869,7 @@ files:
869
869
  - app/mailers/spree/export_mailer.rb
870
870
  - app/mailers/spree/invitation_mailer.rb
871
871
  - app/mailers/spree/report_mailer.rb
872
+ - app/mailers/spree/webhook_mailer.rb
872
873
  - app/models/acts_as_taggable_on/tag_decorator.rb
873
874
  - app/models/concerns/spree/adjustment_source.rb
874
875
  - app/models/concerns/spree/admin_user_methods.rb
@@ -1052,6 +1053,7 @@ files:
1052
1053
  - app/models/spree/policy.rb
1053
1054
  - app/models/spree/preference.rb
1054
1055
  - app/models/spree/price.rb
1056
+ - app/models/spree/price_history.rb
1055
1057
  - app/models/spree/price_list.rb
1056
1058
  - app/models/spree/price_rule.rb
1057
1059
  - app/models/spree/price_rules/customer_group_rule.rb
@@ -1335,6 +1337,8 @@ files:
1335
1337
  - app/views/spree/shared/_mailer_line_item.html.erb
1336
1338
  - app/views/spree/shared/_mailer_logo.html.erb
1337
1339
  - app/views/spree/shared/_payment.html.erb
1340
+ - app/views/spree/webhook_mailer/endpoint_disabled.html.erb
1341
+ - app/views/spree/webhook_mailer/endpoint_disabled.text.erb
1338
1342
  - config/brakeman.ignore
1339
1343
  - config/i18n-tasks.yml
1340
1344
  - config/importmap.rb
@@ -1472,6 +1476,8 @@ files:
1472
1476
  - db/migrate/20260315000000_create_spree_allowed_origins.rb
1473
1477
  - db/migrate/20260315100000_add_product_media_support.rb
1474
1478
  - db/migrate/20260317000000_create_spree_refresh_tokens.rb
1479
+ - db/migrate/20260323000000_create_spree_price_histories.rb
1480
+ - db/migrate/20260326000001_improve_spree_webhooks.rb
1475
1481
  - db/sample_data/customers.csv
1476
1482
  - db/sample_data/metafield_definitions.rb
1477
1483
  - db/sample_data/orders.rb
@@ -1599,6 +1605,7 @@ files:
1599
1605
  - lib/spree/testing_support/factories/payment_source_factory.rb
1600
1606
  - lib/spree/testing_support/factories/policy_factory.rb
1601
1607
  - lib/spree/testing_support/factories/price_factory.rb
1608
+ - lib/spree/testing_support/factories/price_history_factory.rb
1602
1609
  - lib/spree/testing_support/factories/price_list_factory.rb
1603
1610
  - lib/spree/testing_support/factories/price_rule_factory.rb
1604
1611
  - lib/spree/testing_support/factories/product_factory.rb
@@ -1670,6 +1677,7 @@ files:
1670
1677
  - lib/tasks/dependencies.rake
1671
1678
  - lib/tasks/images.rake
1672
1679
  - lib/tasks/markets.rake
1680
+ - lib/tasks/price_history.rake
1673
1681
  - lib/tasks/products.rake
1674
1682
  - lib/tasks/sample_data.rake
1675
1683
  - lib/tasks/search.rake
@@ -1695,9 +1703,9 @@ licenses:
1695
1703
  - BSD-3-Clause
1696
1704
  metadata:
1697
1705
  bug_tracker_uri: https://github.com/spree/spree/issues
1698
- changelog_uri: https://github.com/spree/spree/releases/tag/v5.4.0.rc2
1706
+ changelog_uri: https://github.com/spree/spree/releases/tag/v5.4.0.rc4
1699
1707
  documentation_uri: https://docs.spreecommerce.org/
1700
- source_code_uri: https://github.com/spree/spree/tree/v5.4.0.rc2
1708
+ source_code_uri: https://github.com/spree/spree/tree/v5.4.0.rc4
1701
1709
  post_install_message:
1702
1710
  rdoc_options: []
1703
1711
  require_paths: