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.
- checksums.yaml +4 -4
- data/app/mailers/spree/webhook_mailer.rb +17 -0
- data/app/models/concerns/spree/product_scopes.rb +60 -16
- data/app/models/spree/order/gift_card.rb +23 -3
- data/app/models/spree/order.rb +18 -1
- data/app/models/spree/price.rb +29 -0
- data/app/models/spree/price_history.rb +29 -0
- data/app/models/spree/product.rb +1 -1
- data/app/models/spree/promotion_handler/coupon.rb +9 -2
- data/app/models/spree/search_provider/base.rb +1 -1
- data/app/models/spree/search_provider/database.rb +14 -3
- data/app/models/spree/search_provider/meilisearch.rb +94 -8
- data/app/models/spree/webhook_delivery.rb +28 -2
- data/app/models/spree/webhook_endpoint.rb +71 -1
- data/app/presenters/spree/search_provider/product_presenter.rb +9 -1
- data/app/services/spree/cart/remove_out_of_stock_items.rb +20 -5
- data/app/services/spree/carts/update.rb +9 -7
- data/app/services/spree/checkout/add_store_credit.rb +26 -2
- data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +26 -0
- data/app/views/spree/webhook_mailer/endpoint_disabled.text.erb +10 -0
- data/config/locales/en.yml +11 -0
- data/db/migrate/20260323000000_create_spree_price_histories.rb +20 -0
- data/db/migrate/20260326000001_improve_spree_webhooks.rb +17 -0
- data/lib/spree/core/configuration.rb +2 -0
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/permitted_attributes.rb +1 -1
- data/lib/spree/testing_support/factories/price_history_factory.rb +9 -0
- data/lib/spree/testing_support/factories/webhook_endpoint_factory.rb +7 -0
- data/lib/tasks/price_history.rake +36 -0
- metadata +12 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7bc3a50296fda5fe9d3f9cfa5ec4547b44e8dc7c718634f0de73314d55210479
|
|
4
|
+
data.tar.gz: 74757285458b4d4a322d5cdc0ea550c4fe7e9ca9de25095fba0371b6c1fb1e93
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
141
|
-
|
|
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
|
-
#
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
data/app/models/spree/order.rb
CHANGED
|
@@ -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
|
data/app/models/spree/price.rb
CHANGED
|
@@ -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
|
data/app/models/spree/product.rb
CHANGED
|
@@ -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,
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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 '
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
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)
|
|
15
|
+
existing = @order.payments.store_credits.where(state: :checkout)
|
|
16
16
|
|
|
17
|
-
|
|
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') %>
|
data/config/locales/en.yml
CHANGED
|
@@ -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.
|
data/lib/spree/core/version.rb
CHANGED
|
@@ -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
|
|
|
@@ -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.
|
|
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-
|
|
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.
|
|
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.
|
|
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:
|