spree_core 5.4.1 → 5.4.2

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/imports/process_group_job.rb +45 -0
  3. data/app/jobs/spree/imports/process_rows_job.rb +51 -5
  4. data/app/models/concerns/spree/payment_source_concern.rb +21 -0
  5. data/app/models/spree/address.rb +1 -1
  6. data/app/models/spree/gateway/bogus.rb +10 -6
  7. data/app/models/spree/import.rb +15 -0
  8. data/app/models/spree/import_row.rb +14 -2
  9. data/app/models/spree/imports/product_translations.rb +4 -0
  10. data/app/models/spree/imports/products.rb +5 -0
  11. data/app/models/spree/payment.rb +1 -0
  12. data/app/models/spree/payment_session.rb +5 -0
  13. data/app/models/spree/product.rb +5 -10
  14. data/app/models/spree/search_provider/base.rb +13 -1
  15. data/app/models/spree/search_provider/database.rb +44 -29
  16. data/app/models/spree/search_provider/filters_result.rb +5 -0
  17. data/app/models/spree/search_provider/meilisearch.rb +99 -82
  18. data/app/models/spree/search_provider/search_result.rb +1 -1
  19. data/app/services/spree/credit_cards/destroy.rb +10 -26
  20. data/app/services/spree/gift_cards/apply.rb +5 -1
  21. data/app/services/spree/imports/row_processors/base.rb +19 -2
  22. data/app/services/spree/imports/row_processors/product_translation.rb +1 -1
  23. data/app/services/spree/imports/row_processors/product_variant.rb +1 -1
  24. data/app/services/spree/payments/handle_webhook.rb +6 -0
  25. data/app/subscribers/spree/event_log_subscriber.rb +9 -7
  26. data/config/initializers/carmen.rb +23 -0
  27. data/config/locales/en.yml +0 -1
  28. data/db/migrate/20260424000001_add_unique_index_to_spree_payments_response_code.rb +51 -0
  29. data/db/migrate/20260424100000_add_processing_groups_to_spree_imports.rb +6 -0
  30. data/db/sample_data/orders.rb +1 -1
  31. data/lib/generators/spree/dummy/dummy_generator.rb +1 -1
  32. data/lib/spree/core/configuration.rb +3 -0
  33. data/lib/spree/core/engine.rb +3 -6
  34. data/lib/spree/core/version.rb +1 -1
  35. data/lib/spree/events.rb +6 -0
  36. data/lib/spree/testing_support/factories/payment_factory.rb +1 -1
  37. metadata +13 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3bed2ace5a7bcb6bc6b8c598cfb3ec19cb3dc0ea02854744543ae228ed9c636b
4
- data.tar.gz: 81642ee18f7e01079090ec1c5d162dc25186aa5bd72ac2e799a5a3457ebd1a1a
3
+ metadata.gz: 23692bda70e0c85ecff286d989063498e74adc2e2422ae21719da5c6b6e97c98
4
+ data.tar.gz: 132486aca06d2912f82a54593d57eb9c400768b1a45b5412af77b61f2c822ac5
5
5
  SHA512:
6
- metadata.gz: 5a68e9d3b365c52c811c9a81fa7e2b3968de8f52ddfd7ef829ee47232c809b43715297b09d60f7a0d765b4c30fde3b9a0dfe181d4cff6bd50cb7ddd8c724f590
7
- data.tar.gz: 122681c279ef487105deb809c4701c318c9cf7e767e3b4e7c86b630b59246f46093e921ea51fb15f82cb72a888f07ae962420b79fd0521c0440253337e50f940
6
+ metadata.gz: 0dc0c862bb8c7313e53ac13b80fe2ce6dd697bfad36ed0841562220b12c5253e473ac5f2623e95ac038da6d8903b3a329a47a4dee813f47a37e498bde6a66bc4
7
+ data.tar.gz: facf65e2c8067fbd2cbba0c04ff49daadf53901b137a2a80ada8ae95e646f0ba2ae3df1a7def6651fc81c9aaf52d8d2268e9c86e42c982f4074b3560c7595d91
@@ -0,0 +1,45 @@
1
+ module Spree
2
+ module Imports
3
+ class ProcessGroupJob < Spree::BaseJob
4
+ queue_as Spree.queues.imports
5
+
6
+ def perform(import_id, row_ids)
7
+ import = Spree::Import.find(import_id)
8
+ Spree::Current.store = import.store
9
+
10
+ mappings = import.mappings.mapped.to_a
11
+ schema_fields = import.schema_fields
12
+ large = import.large_import?
13
+ rows = import.rows.where(id: row_ids).order(:row_number)
14
+
15
+ if large
16
+ Spree::Events.disable do
17
+ rows.each { |row| row.bulk_process!(mappings: mappings, schema_fields: schema_fields) }
18
+ end
19
+ else
20
+ rows.each do |row|
21
+ row.process!(mappings: mappings, schema_fields: schema_fields)
22
+ end
23
+ end
24
+
25
+ check_import_completion(import, large)
26
+ end
27
+
28
+ private
29
+
30
+ def check_import_completion(import, large)
31
+ Spree::Import.where(id: import.id).update_all(
32
+ 'completed_groups_count = COALESCE(completed_groups_count, 0) + 1'
33
+ )
34
+ import.reload
35
+
36
+ if import.completed_groups_count >= import.processing_groups_count
37
+ # Guard against concurrent workers both reaching this point
38
+ import.complete! if import.status == 'processing'
39
+ elsif large && (import.completed_groups_count % 10).zero?
40
+ import.publish_event('import.progress')
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -3,16 +3,62 @@ module Spree
3
3
  class ProcessRowsJob < Spree::BaseJob
4
4
  queue_as Spree.queues.imports
5
5
 
6
+ BATCH_SIZE = 100
7
+
6
8
  def perform(import_id)
7
9
  import = Spree::Import.find(import_id)
10
+ dispatch_groups(import)
11
+ end
12
+
13
+ private
14
+
15
+ def dispatch_groups(import)
16
+ group_field = import.group_column
17
+ group_mapping = group_field && import.mappings.mapped.find_by(schema_field: group_field)
18
+ file_column = group_mapping&.file_column
8
19
 
9
- # process all rows in sequential order
10
- import.rows.pending_and_failed.find_each(batch_size: 100) do |row|
11
- row.process!
20
+ if file_column
21
+ dispatch_grouped(import, file_column)
22
+ else
23
+ dispatch_batched(import)
12
24
  end
25
+ end
26
+
27
+ def dispatch_grouped(import, file_column)
28
+ groups = Hash.new { |h, k| h[k] = [] }
29
+
30
+ import.rows.pending_and_failed.order(:row_number).pluck(:id, :data).each do |id, data|
31
+ parsed = JSON.parse(data)
32
+ key = parsed[file_column].to_s.strip.downcase.presence || '__ungrouped__'
33
+ groups[key] << id
34
+ rescue JSON::ParserError
35
+ groups['__ungrouped__'] << id
36
+ end
37
+
38
+ # Set count before enqueuing so workers can't complete prematurely
39
+ import.update_columns(
40
+ processing_groups_count: groups.size,
41
+ completed_groups_count: 0,
42
+ updated_at: Time.current
43
+ )
44
+
45
+ groups.each_value { |row_ids| ProcessGroupJob.perform_later(import.id, row_ids) }
46
+ end
47
+
48
+ def dispatch_batched(import)
49
+ # Count first, then enqueue — prevents premature completion
50
+ row_id_batches = import.rows.pending_and_failed.order(:row_number)
51
+ .pluck(:id)
52
+ .each_slice(BATCH_SIZE)
53
+ .to_a
54
+
55
+ import.update_columns(
56
+ processing_groups_count: row_id_batches.size,
57
+ completed_groups_count: 0,
58
+ updated_at: Time.current
59
+ )
13
60
 
14
- # mark as complete
15
- import.complete!
61
+ row_id_batches.each { |row_ids| ProcessGroupJob.perform_later(import.id, row_ids) }
16
62
  end
17
63
  end
18
64
  end
@@ -2,6 +2,10 @@ module Spree
2
2
  module PaymentSourceConcern
3
3
  extend ActiveSupport::Concern
4
4
 
5
+ included do
6
+ before_destroy :cleanup_payments_on_incomplete_orders
7
+ end
8
+
5
9
  # Available actions for the payment source.
6
10
  # @return [Array<String>]
7
11
  def actions
@@ -35,5 +39,22 @@ module Spree
35
39
  def has_payment_profile?
36
40
  gateway_customer_profile_id.present? || gateway_payment_profile_id.present?
37
41
  end
42
+
43
+ private
44
+
45
+ # Cleans up payments on incomplete orders before the source is destroyed.
46
+ # Invalidates checkout-state payments and voids non-checkout payments.
47
+ # Skips payments whose payment_method was already nullified (e.g. when
48
+ # the payment method itself is being destroyed).
49
+ def cleanup_payments_on_incomplete_orders
50
+ incomplete_payments = Spree::Payment.valid
51
+ .where(source: self)
52
+ .where.not(payment_method_id: nil)
53
+ .joins(:order)
54
+ .merge(Spree::Order.incomplete)
55
+
56
+ incomplete_payments.checkout.each(&:invalidate!)
57
+ incomplete_payments.where.not(state: :checkout).each(&:void!)
58
+ end
38
59
  end
39
60
  end
@@ -79,7 +79,7 @@ module Spree
79
79
  end
80
80
  end
81
81
 
82
- delegate :name, :iso3, :iso, :iso_name, to: :country, prefix: true
82
+ delegate :name, :iso3, :iso, :iso_name, to: :country, prefix: true, allow_nil: true
83
83
  delegate :abbr, to: :state, prefix: true, allow_nil: true
84
84
 
85
85
  alias_attribute :postal_code, :zipcode
@@ -32,7 +32,7 @@ module Spree
32
32
  def authorize(_money, credit_card, _options = {})
33
33
  profile_id = credit_card.gateway_customer_profile_id
34
34
  if VALID_CCS.include?(credit_card.number) || (profile_id&.starts_with?('BGS-'))
35
- Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: '12345', avs_result: { code: 'D' })
35
+ Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: generate_authorization, avs_result: { code: 'D' })
36
36
  else
37
37
  Spree::PaymentResponse.new(false, 'Bogus Gateway: Forced failure', { message: 'Bogus Gateway: Forced failure' }, test: true)
38
38
  end
@@ -41,18 +41,18 @@ module Spree
41
41
  def purchase(_money, credit_card, _options = {})
42
42
  profile_id = credit_card.gateway_customer_profile_id
43
43
  if VALID_CCS.include?(credit_card.number) || (profile_id&.starts_with?('BGS-'))
44
- Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: '12345', avs_result: { code: 'M' })
44
+ Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: generate_authorization, avs_result: { code: 'M' })
45
45
  else
46
46
  Spree::PaymentResponse.new(false, 'Bogus Gateway: Forced failure', message: 'Bogus Gateway: Forced failure', test: true)
47
47
  end
48
48
  end
49
49
 
50
50
  def credit(_money, _credit_card, _response_code, _options = {})
51
- Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: '12345')
51
+ Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: generate_authorization)
52
52
  end
53
53
 
54
54
  def capture(_money, authorization, _gateway_options)
55
- if authorization == '12345'
55
+ if authorization&.start_with?('BGS-')
56
56
  Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true)
57
57
  else
58
58
  Spree::PaymentResponse.new(false, 'Bogus Gateway: Forced failure', error: 'Bogus Gateway: Forced failure', test: true)
@@ -60,11 +60,11 @@ module Spree
60
60
  end
61
61
 
62
62
  def void(_response_code, _credit_card, _options = {})
63
- Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: 'void-12345')
63
+ Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: "void-#{generate_authorization}")
64
64
  end
65
65
 
66
66
  def cancel(_response_code, _payment = nil)
67
- Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: '12345')
67
+ Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: generate_authorization)
68
68
  end
69
69
 
70
70
  def test?
@@ -150,6 +150,10 @@ module Spree
150
150
 
151
151
  private
152
152
 
153
+ def generate_authorization
154
+ "BGS-#{SecureRandom.hex(6)}"
155
+ end
156
+
153
157
  def generate_profile_id(success)
154
158
  record = true
155
159
  prefix = success ? 'BGS' : 'FAIL'
@@ -76,6 +76,21 @@ module Spree
76
76
  #
77
77
  preference :delimiter, :string, default: ','
78
78
 
79
+ # Returns true if the import has more rows than the large import threshold.
80
+ # Large imports skip per-row UI broadcasts and use bulk processing.
81
+ # @return [Boolean]
82
+ def large_import?
83
+ rows_count >= Spree::Config[:large_import_threshold]
84
+ end
85
+
86
+ # Returns the schema field name used to group rows for parallel processing.
87
+ # Rows sharing the same value in this field are processed together in one job.
88
+ # Returns nil for imports where rows are independent (default — batched in chunks).
89
+ # @return [String, nil]
90
+ def group_column
91
+ nil
92
+ end
93
+
79
94
  # Returns true if the import is in mapping state
80
95
  # @return [Boolean]
81
96
  def mapping?
@@ -77,9 +77,9 @@ module Spree
77
77
  data_json[mapping.file_column]
78
78
  end
79
79
 
80
- def process!
80
+ def process!(mappings: nil, schema_fields: nil)
81
81
  start_processing!
82
- self.item = import.row_processor_class.new(self).process!
82
+ self.item = import.row_processor_class.new(self, mappings: mappings, schema_fields: schema_fields).process!
83
83
  complete!
84
84
  rescue StandardError => e
85
85
  Rails.error.report(e, handled: true, context: { import_row_id: id }, source: 'spree.core')
@@ -87,6 +87,18 @@ module Spree
87
87
  fail!
88
88
  end
89
89
 
90
+ # Bulk processing mode for large imports.
91
+ # Uses update_columns to skip callbacks, validations, and event publishing.
92
+ def bulk_process!(mappings:, schema_fields:)
93
+ update_columns(status: 'processing', updated_at: Time.current)
94
+ processor = import.row_processor_class.new(self, mappings: mappings, schema_fields: schema_fields)
95
+ self.item = processor.process!
96
+ update_columns(status: 'completed', item_type: item.class.name, item_id: item.id, updated_at: Time.current)
97
+ rescue StandardError => e
98
+ Rails.error.report(e, handled: true, context: { import_row_id: id }, source: 'spree.core')
99
+ update_columns(status: 'failed', validation_errors: e.message, updated_at: Time.current)
100
+ end
101
+
90
102
  def publish_import_row_completed_event
91
103
  publish_event('import_row.completed')
92
104
  end
@@ -5,6 +5,10 @@ module Spree
5
5
  Spree::Imports::RowProcessors::ProductTranslation
6
6
  end
7
7
 
8
+ def group_column
9
+ 'slug'
10
+ end
11
+
8
12
  def model_class
9
13
  Spree::Product
10
14
  end
@@ -4,6 +4,11 @@ module Spree
4
4
  def row_processor_class
5
5
  Spree::Imports::RowProcessors::ProductVariant
6
6
  end
7
+
8
+ # Group by slug: product row + its variant rows must be processed together
9
+ def group_column
10
+ 'slug'
11
+ end
7
12
  end
8
13
  end
9
14
  end
@@ -42,6 +42,7 @@ module Spree
42
42
 
43
43
  validates :payment_method, presence: true
44
44
  validates :source, presence: true, if: :source_required?
45
+ validates :response_code, uniqueness: { scope: [:order_id, :payment_method_id] }, allow_nil: true
45
46
  validate :payment_method_available_for_order, on: :create
46
47
 
47
48
  before_validation :validate_source
@@ -92,6 +92,11 @@ module Spree
92
92
  p.amount = amount
93
93
  p.skip_source_requirement = true
94
94
  end
95
+ rescue ActiveRecord::RecordNotUnique
96
+ order.payments.find_by!(
97
+ payment_method: payment_method,
98
+ response_code: external_id
99
+ )
95
100
  end
96
101
 
97
102
  private
@@ -249,16 +249,11 @@ module Spree
249
249
 
250
250
  products_to_auto_match_ids = store.products.not_deleted.not_archived.where(id: product_ids).ids
251
251
 
252
- # for ActiveJob 7.1+
253
- if ActiveJob.respond_to?(:perform_all_later)
254
- auto_match_taxons_jobs = products_to_auto_match_ids.map do |product_id|
255
- Spree::Products::AutoMatchTaxonsJob.new(product_id)
256
- end
257
-
258
- ActiveJob.perform_all_later(auto_match_taxons_jobs)
259
- else
260
- products_to_auto_match_ids.each { |product_id| Spree::Products::AutoMatchTaxonsJob.perform_later(product_id) }
252
+ auto_match_taxons_jobs = products_to_auto_match_ids.map do |product_id|
253
+ Spree::Products::AutoMatchTaxonsJob.new(product_id).tap { |job| job.scheduled_at = 30.seconds.from_now }
261
254
  end
255
+
256
+ ActiveJob.perform_all_later(auto_match_taxons_jobs)
262
257
  end
263
258
 
264
259
  # Can't use short form block syntax due to https://github.com/Netflix/fast_jsonapi/issues/259
@@ -582,7 +577,7 @@ module Spree
582
577
  store = stores.find_by(default: true) || stores.first
583
578
  return if store.nil? || store.taxons.automatic.none?
584
579
 
585
- Spree::Products::AutoMatchTaxonsJob.perform_later(id)
580
+ Spree::Products::AutoMatchTaxonsJob.set(wait: 30.seconds).perform_later(id)
586
581
  end
587
582
 
588
583
  def to_csv(store = nil)
@@ -13,7 +13,7 @@ module Spree
13
13
  @store = store
14
14
  end
15
15
 
16
- # Search, filter, and return facets in one call.
16
+ # Search and paginate products. Does NOT compute filter facets use #filters for that.
17
17
  #
18
18
  # @param scope [ActiveRecord::Relation] base scope (store-scoped, visibility-filtered, authorized)
19
19
  # @param query [String, nil] text search query
@@ -26,6 +26,18 @@ module Spree
26
26
  raise NotImplementedError
27
27
  end
28
28
 
29
+ # Compute filter facets, sort options, and total count for the given scope.
30
+ # Called by the dedicated filters endpoint — kept separate from search_and_filter
31
+ # to avoid expensive facet queries on every product listing.
32
+ #
33
+ # @param scope [ActiveRecord::Relation] base scope
34
+ # @param query [String, nil] text search query
35
+ # @param filters [Hash] structured filters
36
+ # @return [FiltersResult]
37
+ def filters(scope:, query: nil, filters: {})
38
+ raise NotImplementedError
39
+ end
40
+
29
41
  # Index a product — called after product save. No-op for database provider.
30
42
  #
31
43
  # @param product [Spree::Product] the product to index
@@ -8,35 +8,16 @@ module Spree
8
8
  }.freeze
9
9
 
10
10
  def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
11
- # 1. Text search
12
- scope = scope.search(query) if query.present?
13
-
14
- # 2. Extract internal params before passing to Ransack
15
- category = filters.is_a?(Hash) ? filters.delete('_category') || filters.delete(:_category) : nil
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
-
20
- # 3. Structured filtering via Ransack
21
- ransack_filters = sanitize_filters(filters)
22
- if ransack_filters.present?
23
- search = scope.ransack(ransack_filters)
24
- scope = search.result(distinct: true)
25
- end
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
11
+ filters = filters.is_a?(Hash) ? filters.dup : {}
12
+ option_value_ids = filters.delete('with_option_value_ids') || filters.delete(:with_option_value_ids)
32
13
 
33
- # 4. Facets (before sorting to avoid computed column conflicts with count)
34
- filter_facets = build_facets(scope, category: category, option_value_ids: Array(option_value_ids), scope_before_options: scope_before_options)
14
+ scope = apply_search_and_filters(scope, query: query, filters: filters)
15
+ scope = scope.with_option_value_ids(Array(option_value_ids)) if option_value_ids.present?
35
16
 
36
- # 5. Total count (before sorting to avoid computed column conflicts with count)
17
+ # Total count (before sorting to avoid computed column conflicts with count)
37
18
  total = scope.distinct.count
38
19
 
39
- # 6. Sorting + pagination
20
+ # Sorting + pagination
40
21
  scope = apply_sort(scope, sort)
41
22
  page = [page.to_i, 1].max
42
23
  limit = limit.to_i.clamp(1, 100)
@@ -44,22 +25,56 @@ module Spree
44
25
 
45
26
  SearchResult.new(
46
27
  products: products,
28
+ total_count: total,
29
+ pagy: build_pagy(total, page, limit)
30
+ )
31
+ end
32
+
33
+ def filters(scope:, query: nil, filters: {})
34
+ filters = filters.is_a?(Hash) ? filters.dup : {}
35
+ category = filters.delete('_category') || filters.delete(:_category)
36
+ option_value_ids = Array(filters.delete('with_option_value_ids') || filters.delete(:with_option_value_ids))
37
+
38
+ # Apply text search + ransack filters (without option values)
39
+ scope_before_options = apply_search_and_filters(scope, query: query, filters: filters)
40
+
41
+ # Apply option value filters for the final scope
42
+ scope_with_options = if option_value_ids.present?
43
+ scope_before_options.with_option_value_ids(option_value_ids)
44
+ else
45
+ scope_before_options
46
+ end
47
+
48
+ filter_facets = build_facets(scope_with_options, category: category, option_value_ids: option_value_ids, scope_before_options: scope_before_options)
49
+
50
+ FiltersResult.new(
47
51
  filters: filter_facets[:filters],
48
52
  sort_options: filter_facets[:sort_options],
49
53
  default_sort: filter_facets[:default_sort],
50
- total_count: total,
51
- pagy: build_pagy(total, page, limit)
54
+ total_count: filter_facets[:total_count]
52
55
  )
53
56
  end
54
57
 
55
58
  private
56
59
 
60
+ def apply_search_and_filters(scope, query: nil, filters: {})
61
+ scope = scope.search(query) if query.present?
62
+
63
+ ransack_filters = sanitize_filters(filters)
64
+ if ransack_filters.present?
65
+ search = scope.ransack(ransack_filters)
66
+ scope = search.result(distinct: true)
67
+ end
68
+
69
+ scope
70
+ end
71
+
57
72
  def build_pagy(count, page, limit)
58
73
  Pagy::Offset.new(count: count, page: page, limit: limit)
59
74
  end
60
75
 
61
76
  def build_facets(scope, category: nil, option_value_ids: [], scope_before_options: nil)
62
- return { filters: [], sort_options: available_sort_options, default_sort: 'manual' } unless defined?(Spree::Api::V3::FiltersAggregator)
77
+ return { filters: [], sort_options: available_sort_options, default_sort: 'manual', total_count: scope.distinct.count } unless defined?(Spree::Api::V3::FiltersAggregator)
63
78
 
64
79
  Spree::Api::V3::FiltersAggregator.new(
65
80
  scope: scope,
@@ -74,7 +89,7 @@ module Spree
74
89
  return {} if filters.blank?
75
90
 
76
91
  filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
77
- filters.except('search', :search, '_category', :_category)
92
+ filters.except('search', :search, '_category', :_category, 'with_option_value_ids', :with_option_value_ids)
78
93
  end
79
94
 
80
95
  def apply_sort(scope, sort)
@@ -0,0 +1,5 @@
1
+ module Spree
2
+ module SearchProvider
3
+ FiltersResult = Struct.new(:filters, :sort_options, :default_sort, :total_count, keyword_init: true)
4
+ end
5
+ end
@@ -20,84 +20,9 @@ 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
33
-
34
- search_params = {
35
- filter: all_conditions,
36
- facets: facet_attributes,
37
- sort: build_sort(sort),
38
- offset: (page - 1) * limit,
39
- limit: limit
40
- }
41
-
42
- Rails.logger.debug { "[Meilisearch] index=#{index_name} query=#{query.inspect} #{search_params.compact.inspect}" }
43
-
44
- begin
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
23
 
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
94
- rescue ::Meilisearch::ApiError => e
95
- Rails.logger.warn { "[Meilisearch] Search failed: #{e.message}. Run `rake spree:search:reindex` to initialize the index." }
96
- Rails.error.report(e, handled: true, context: { index: index_name, query: query })
97
- return empty_result(scope, page, limit)
98
- end
99
-
100
- Rails.logger.debug { "[Meilisearch] #{ms_result['estimatedTotalHits']} hits in #{ms_result['processingTimeMs']}ms" }
24
+ ms_result, _ = execute_search(query: query, filters: filters, sort: sort, page: page, limit: limit)
25
+ return empty_result(scope, page, limit) unless ms_result
101
26
 
102
27
  # Hits have composite prefixed_id (prod_abc_en_USD), extract product_id (prod_abc)
103
28
  product_prefixed_ids = ms_result['hits'].map { |h| h['product_id'] }.uniq
@@ -115,11 +40,28 @@ module Spree
115
40
 
116
41
  SearchResult.new(
117
42
  products: products,
43
+ total_count: ms_result['estimatedTotalHits'] || 0,
44
+ pagy: pagy
45
+ )
46
+ end
47
+
48
+ def filters(scope:, query: nil, filters: {})
49
+ ms_result, facet_distribution = execute_search(query: query, filters: filters, sort: nil, page: 1, limit: 0, return_facets: true)
50
+
51
+ unless ms_result
52
+ return FiltersResult.new(
53
+ filters: [],
54
+ sort_options: available_sort_options.map { |id| { id: id } },
55
+ default_sort: 'manual',
56
+ total_count: 0
57
+ )
58
+ end
59
+
60
+ FiltersResult.new(
118
61
  filters: build_facet_response(facet_distribution),
119
62
  sort_options: available_sort_options.map { |id| { id: id } },
120
63
  default_sort: 'manual',
121
- total_count: ms_result['estimatedTotalHits'] || 0,
122
- pagy: pagy
64
+ total_count: ms_result['estimatedTotalHits'] || 0
123
65
  )
124
66
  end
125
67
 
@@ -193,6 +135,84 @@ module Spree
193
135
 
194
136
  private
195
137
 
138
+ # Execute a Meilisearch query. Returns [ms_result, facet_distribution].
139
+ # facet_distribution is empty when return_facets is false. Returns nil on API error.
140
+ def execute_search(query:, filters:, sort:, page:, limit:, return_facets: false)
141
+ filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
142
+ filters = (filters || {}).stringify_keys
143
+
144
+ option_value_ids = extract_and_delete(filters, 'with_option_value_ids')
145
+ grouped_options = group_option_values_by_type(Array(option_value_ids))
146
+
147
+ base_conditions = build_filters(filters)
148
+ option_conditions = build_grouped_option_conditions(grouped_options)
149
+ all_conditions = base_conditions + option_conditions
150
+
151
+ search_params = {
152
+ filter: all_conditions,
153
+ facets: return_facets ? facet_attributes : nil,
154
+ sort: build_sort(sort),
155
+ offset: (page - 1) * limit,
156
+ limit: limit
157
+ }.compact
158
+
159
+ Rails.logger.debug { "[Meilisearch] index=#{index_name} query=#{query.inspect} #{search_params.compact.inspect}" }
160
+
161
+ begin
162
+ if return_facets && grouped_options.any?
163
+ queries = [{ indexUid: index_name, q: query.to_s, **search_params }]
164
+ option_type_ids_ordered = grouped_options.keys
165
+ option_type_ids_ordered.each do |option_type_id|
166
+ without_this = build_grouped_option_conditions(grouped_options.except(option_type_id))
167
+ queries << { indexUid: index_name, q: query.to_s, filter: base_conditions + without_this, facets: ['option_value_ids'], limit: 0 }
168
+ end
169
+
170
+ results = client.multi_search(queries)
171
+ ms_result = results['results'][0]
172
+ facet_distribution = merge_disjunctive_facets(ms_result, results['results'][1..], option_type_ids_ordered)
173
+ else
174
+ ms_result = client.index(index_name).search(query.to_s, search_params)
175
+ facet_distribution = ms_result['facetDistribution'] || {}
176
+ end
177
+ rescue ::Meilisearch::ApiError => e
178
+ Rails.logger.warn { "[Meilisearch] Search failed: #{e.message}. Run `rake spree:search:reindex` to initialize the index." }
179
+ Rails.error.report(e, handled: true, context: { index: index_name, query: query })
180
+ return nil
181
+ end
182
+
183
+ Rails.logger.debug { "[Meilisearch] #{ms_result['estimatedTotalHits']} hits in #{ms_result['processingTimeMs']}ms" }
184
+
185
+ [ms_result, facet_distribution]
186
+ end
187
+
188
+ def merge_disjunctive_facets(ms_result, disjunctive_results, option_type_ids_ordered)
189
+ main_ov_dist = ms_result.dig('facetDistribution', 'option_value_ids') || {}
190
+
191
+ all_ov_prefixed_ids = Set.new
192
+ disjunctive_dists = {}
193
+ disjunctive_results.each_with_index do |r, idx|
194
+ dist = r.dig('facetDistribution', 'option_value_ids') || {}
195
+ disjunctive_dists[option_type_ids_ordered[idx]] = dist
196
+ all_ov_prefixed_ids.merge(dist.keys)
197
+ end
198
+
199
+ all_raw_ids = all_ov_prefixed_ids.filter_map { |pid| Spree::OptionValue.decode_prefixed_id(pid) }
200
+ ov_to_type = Spree::OptionValue.where(id: all_raw_ids).pluck(:id, :option_type_id).to_h
201
+ prefixed_to_type = all_ov_prefixed_ids.each_with_object({}) do |pid, h|
202
+ raw = Spree::OptionValue.decode_prefixed_id(pid)
203
+ h[pid] = ov_to_type[raw] if raw
204
+ end
205
+
206
+ merged_ov_dist = main_ov_dist.dup
207
+ disjunctive_dists.each do |option_type_id, dist|
208
+ dist.each do |pid, count|
209
+ merged_ov_dist[pid] = count if prefixed_to_type[pid] == option_type_id
210
+ end
211
+ end
212
+
213
+ (ms_result['facetDistribution'] || {}).merge('option_value_ids' => merged_ov_dist)
214
+ end
215
+
196
216
  def presenter_class
197
217
  Spree::Dependencies.search_product_presenter_class
198
218
  end
@@ -436,9 +456,6 @@ module Spree
436
456
  def empty_result(scope, page, limit)
437
457
  SearchResult.new(
438
458
  products: scope.none,
439
- filters: [],
440
- sort_options: available_sort_options.map { |id| { id: id } },
441
- default_sort: 'manual',
442
459
  total_count: 0,
443
460
  pagy: Pagy::Offset.new(count: 0, page: page, limit: limit)
444
461
  )
@@ -1,5 +1,5 @@
1
1
  module Spree
2
2
  module SearchProvider
3
- SearchResult = Struct.new(:products, :filters, :sort_options, :default_sort, :total_count, :pagy, keyword_init: true)
3
+ SearchResult = Struct.new(:products, :total_count, :pagy, keyword_init: true)
4
4
  end
5
5
  end
@@ -1,41 +1,25 @@
1
1
  module Spree
2
2
  module CreditCards
3
+ # @deprecated Use card.destroy directly instead. Payment cleanup is now
4
+ # handled by the before_destroy callback in Spree::PaymentSourceConcern.
5
+ # This service will be removed in Spree 6.0.
3
6
  class Destroy
4
7
  prepend Spree::ServiceModule::Base
5
8
 
6
9
  def call(card:)
7
- ApplicationRecord.transaction do
8
- run :invalidate_payments
9
- run :void_payments
10
- run :destroy
11
- end
12
- end
13
-
14
- protected
15
-
16
- def invalidate_payments(card:)
17
- payment_scope(card).checkout.each(&:invalidate!)
18
-
19
- success(card: card)
20
- end
10
+ Spree::Deprecation.warn(
11
+ "#{self.class.name} is deprecated and will be removed in Spree 6.0. " \
12
+ 'Use card.destroy directly instead. Payment cleanup is now handled ' \
13
+ 'automatically by the before_destroy callback in PaymentSourceConcern.',
14
+ caller
15
+ )
21
16
 
22
- def void_payments(card:)
23
- payment_scope(card).where.not(state: :checkout).each(&:void!)
24
-
25
- success(card: card)
26
- end
27
-
28
- def destroy(card:)
29
17
  if card.destroy
30
18
  success(card: card)
31
19
  else
32
- failure(card.errors.full_messages.to_sentance)
20
+ failure(card.errors.full_messages.to_sentence)
33
21
  end
34
22
  end
35
-
36
- def payment_scope(card)
37
- card.payments.valid.joins(:order).merge(Spree::Order.incomplete)
38
- end
39
23
  end
40
24
  end
41
25
  end
@@ -68,7 +68,11 @@ module Spree
68
68
  )
69
69
  payment_method.name ||= Spree.t(:store_credit_name)
70
70
  payment_method.active = true
71
- payment_method.save! if payment_method.new_record?
71
+
72
+ if payment_method.new_record?
73
+ payment_method.stores << store unless payment_method.stores.include?(store)
74
+ payment_method.save!
75
+ end
72
76
 
73
77
  payment_method
74
78
  end
@@ -2,10 +2,14 @@ module Spree
2
2
  module Imports
3
3
  module RowProcessors
4
4
  class Base
5
- def initialize(row)
5
+ def initialize(row, mappings: nil, schema_fields: nil)
6
6
  @row = row
7
7
  @import = row.import
8
- @attributes = row.to_schema_hash
8
+ @attributes = if mappings && schema_fields
9
+ build_schema_hash(row, mappings, schema_fields)
10
+ else
11
+ row.to_schema_hash
12
+ end
9
13
  end
10
14
 
11
15
  attr_reader :row, :import, :attributes
@@ -13,6 +17,19 @@ module Spree
13
17
  def process!
14
18
  raise NotImplementedError, 'Subclasses must implement the process! method'
15
19
  end
20
+
21
+ private
22
+
23
+ def build_schema_hash(row, mappings, schema_fields)
24
+ attributes = {}
25
+ schema_fields.each do |field|
26
+ mapping = mappings.find { |m| m.schema_field == field[:name] }
27
+ next unless mapping&.mapped?
28
+
29
+ attributes[field[:name]] = row.data_json[mapping.file_column]
30
+ end
31
+ attributes
32
+ end
16
33
  end
17
34
  end
18
35
  end
@@ -4,7 +4,7 @@ module Spree
4
4
  class ProductTranslation < Base
5
5
  TRANSLATABLE_FIELDS = %w[name description meta_title meta_description].freeze
6
6
 
7
- def initialize(row)
7
+ def initialize(row, **)
8
8
  super
9
9
  @store = row.store
10
10
  end
@@ -4,7 +4,7 @@ module Spree
4
4
  class ProductVariant < Base
5
5
  OPTION_TYPES_COUNT = 3
6
6
 
7
- def initialize(row)
7
+ def initialize(row, **)
8
8
  super
9
9
  @store = row.store
10
10
  @product = ensure_product_exists
@@ -30,6 +30,12 @@ module Spree
30
30
 
31
31
  def handle_success(payment_session, order, metadata)
32
32
  order.with_lock do
33
+ # Idempotency: if the session was already completed (by the API
34
+ # endpoint or a previous webhook), skip duplicate processing.
35
+ if payment_session.reload.completed?
36
+ return success(payment_session)
37
+ end
38
+
33
39
  # Ensure payment record exists
34
40
  payment = payment_session.find_or_create_payment!(metadata)
35
41
 
@@ -16,25 +16,27 @@ module Spree
16
16
 
17
17
  class << self
18
18
  def attach_to_notifications
19
- # Always detach first to ensure clean state after code reload
19
+ # Always detach first to ensure clean state after code reload.
20
+ # The subscription reference is stored on Spree::Events (in lib/, not
21
+ # reloaded by Zeitwerk) so repeated reloads in development do not leak
22
+ # stale AS::N subscriptions when this class is reloaded.
20
23
  detach_from_notifications
21
24
 
22
- @subscription = ActiveSupport::Notifications.subscribe(/\.#{NAMESPACE}$/) do |name, start, finish, _id, payload|
25
+ Spree::Events.log_subscription = ActiveSupport::Notifications.subscribe(/\.#{NAMESPACE}$/) do |name, start, finish, _id, payload|
23
26
  log_event(name, start, finish, payload)
24
27
  end
25
28
 
26
- @attached = true
27
29
  Rails.logger.info "[Spree Events] Event logging enabled"
28
30
  end
29
31
 
30
32
  def detach_from_notifications
31
- ActiveSupport::Notifications.unsubscribe(@subscription) if @subscription
32
- @subscription = nil
33
- @attached = false
33
+ subscription = Spree::Events.log_subscription
34
+ ActiveSupport::Notifications.unsubscribe(subscription) if subscription
35
+ Spree::Events.log_subscription = nil
34
36
  end
35
37
 
36
38
  def attached?
37
- @attached || false
39
+ !Spree::Events.log_subscription.nil?
38
40
  end
39
41
 
40
42
  private
@@ -0,0 +1,23 @@
1
+ # Patches Carmen::Querying#normalise_name to avoid the deprecated
2
+ # ActiveSupport::Multibyte::Chars API (mb_chars), which will be removed in
3
+ # Rails 8.2.
4
+ #
5
+ # In Ruby 2.4+, String#downcase is already Unicode-aware, making the mb_chars
6
+ # call redundant. Since Spree requires Ruby >= 3.2, we can drop it safely.
7
+ #
8
+ # This patch can be removed once the upstream gem is fixed.
9
+ #
10
+ # See: https://github.com/carmen-ruby/carmen/issues/304
11
+ require 'carmen'
12
+
13
+ module Spree
14
+ module CarmenQueryingPatch
15
+ private
16
+
17
+ def normalise_name(name)
18
+ name.downcase.unicode_normalize(:nfkc)
19
+ end
20
+ end
21
+ end
22
+
23
+ Carmen::Querying.prepend(Spree::CarmenQueryingPatch)
@@ -1342,7 +1342,6 @@ en:
1342
1342
  items_in_rmas: Items in Return Authorizations
1343
1343
  items_reimbursed: Items reimbursed
1344
1344
  items_to_be_reimbursed: Items to be reimbursed
1345
- join_slack: Join Slack
1346
1345
  key: Key
1347
1346
  kind: Kind
1348
1347
  label: Label
@@ -0,0 +1,51 @@
1
+ class AddUniqueIndexToSpreePaymentsResponseCode < ActiveRecord::Migration[7.2]
2
+ def up
3
+ # Remove duplicate payments per (order, payment_method, response_code),
4
+ # preferring to keep completed payments over incomplete ones.
5
+ # Among same-state duplicates, keeps the most recent (highest id).
6
+ # Uses derived table for MySQL compatibility.
7
+ execute <<~SQL
8
+ DELETE FROM spree_payments
9
+ WHERE response_code IS NOT NULL
10
+ AND id NOT IN (
11
+ SELECT keeper_id FROM (
12
+ SELECT (
13
+ SELECT id FROM spree_payments p2
14
+ WHERE p2.order_id = p1.order_id
15
+ AND p2.payment_method_id = p1.payment_method_id
16
+ AND p2.response_code = p1.response_code
17
+ ORDER BY
18
+ CASE p2.state
19
+ WHEN 'completed' THEN 0
20
+ WHEN 'pending' THEN 1
21
+ WHEN 'processing' THEN 2
22
+ ELSE 3
23
+ END,
24
+ p2.id DESC
25
+ LIMIT 1
26
+ ) AS keeper_id
27
+ FROM spree_payments p1
28
+ WHERE p1.response_code IS NOT NULL
29
+ GROUP BY p1.order_id, p1.payment_method_id, p1.response_code
30
+ ) AS keeper_ids
31
+ )
32
+ SQL
33
+
34
+ if ActiveRecord::Base.connection.adapter_name == 'Mysql2'
35
+ # MySQL doesn't support partial indexes, but treats NULL as distinct
36
+ # in unique indexes so multiple payments with NULL response_code are allowed
37
+ add_index :spree_payments, [:order_id, :payment_method_id, :response_code],
38
+ unique: true,
39
+ name: 'idx_payments_order_method_response_code'
40
+ else
41
+ add_index :spree_payments, [:order_id, :payment_method_id, :response_code],
42
+ unique: true,
43
+ where: 'response_code IS NOT NULL',
44
+ name: 'idx_payments_order_method_response_code'
45
+ end
46
+ end
47
+
48
+ def down
49
+ remove_index :spree_payments, name: 'idx_payments_order_method_response_code'
50
+ end
51
+ end
@@ -0,0 +1,6 @@
1
+ class AddProcessingGroupsToSpreeImports < ActiveRecord::Migration[7.2]
2
+ def change
3
+ add_column :spree_imports, :processing_groups_count, :integer, default: 0
4
+ add_column :spree_imports, :completed_groups_count, :integer, default: 0
5
+ end
6
+ end
@@ -122,7 +122,7 @@ if method
122
122
  source: credit_card.clone,
123
123
  payment_method: method
124
124
  ).first_or_create!
125
- payment.update_columns(state: 'pending', response_code: '12345')
125
+ payment.update_columns(state: 'pending', response_code: "BGS-#{SecureRandom.hex(6)}")
126
126
  end
127
127
  end
128
128
 
@@ -106,7 +106,7 @@ module Spree
106
106
  def inject_yaml_permitted_classes
107
107
  inside dummy_path do
108
108
  inject_into_file 'config/application.rb', %Q[
109
- config.active_record.yaml_column_permitted_classes = [Symbol, BigDecimal, ActiveSupport::HashWithIndifferentAccess, ActiveSupport::TimeWithZone]
109
+ config.active_record.yaml_column_permitted_classes = [Symbol, BigDecimal, ActiveSupport::HashWithIndifferentAccess, ActiveSupport::TimeWithZone, ActiveSupport::TimeZone, Time]
110
110
  ], after: /config\.load_defaults.*$/, verbose: true
111
111
  end
112
112
  end
@@ -117,6 +117,9 @@ module Spree
117
117
  preference :gift_card_batch_web_limit, :integer, default: 500 # number of gift card codes to be generated in the web process, more than this will be generated in a background job
118
118
  preference :gift_card_batch_limit, :integer, default: 50_000
119
119
 
120
+ # imports
121
+ preference :large_import_threshold, :integer, default: 500 # imports with more rows than this skip per-row UI broadcasts and use bulk processing
122
+
120
123
  end
121
124
  end
122
125
  end
@@ -47,7 +47,7 @@ module Spree
47
47
  app.config.spree = Environment.new(SpreeCalculators.new, SpreeValidators.new, Spree::Core::Configuration.new, Spree::Core::Dependencies.new)
48
48
 
49
49
  app.config.active_record.yaml_column_permitted_classes ||= []
50
- app.config.active_record.yaml_column_permitted_classes.concat([Symbol, BigDecimal, ActiveSupport::HashWithIndifferentAccess, ActiveSupport::TimeWithZone])
50
+ app.config.active_record.yaml_column_permitted_classes.concat([Symbol, BigDecimal, ActiveSupport::HashWithIndifferentAccess, ActiveSupport::TimeWithZone, ActiveSupport::TimeZone, Time])
51
51
  Spree::Config = app.config.spree.preferences
52
52
  Spree::RuntimeConfig = app.config.spree.preferences # for compatibility
53
53
  Spree::Dependencies = app.config.spree.dependencies
@@ -289,13 +289,10 @@ module Spree
289
289
  Spree::Addresses::PhoneValidator
290
290
  ]
291
291
 
292
- # Attach event log subscriber if enabled
293
- if Spree::Config.events_log_enabled
294
- Spree::EventLogSubscriber.attach_to_notifications
295
- end
296
-
297
292
  # Add core event subscribers
298
293
  # Other engines add their subscribers in their own after_initialize blocks
294
+ # Note: Spree::EventLogSubscriber is attached in to_prepare (below) so it
295
+ # survives Zeitwerk code reloads in development.
299
296
  Spree.subscribers.concat [
300
297
  Spree::ExportSubscriber,
301
298
  Spree::ReportSubscriber,
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '5.4.1'.freeze
2
+ VERSION = '5.4.2'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
data/lib/spree/events.rb CHANGED
@@ -28,6 +28,12 @@ module Spree
28
28
  #
29
29
  module Events
30
30
  class << self
31
+ # Reference to the AS::N subscription created by Spree::EventLogSubscriber.
32
+ # Stored here (on a module in lib/, not reloaded by Zeitwerk) so that code
33
+ # reloads in development don't orphan the subscription and cause events to
34
+ # be logged multiple times.
35
+ attr_accessor :log_subscription
36
+
31
37
  # Publish an event to all matching subscribers
32
38
  #
33
39
  # @param name [String] The event name (e.g., 'order.complete')
@@ -3,7 +3,7 @@ FactoryBot.define do
3
3
  order { create(:order, total: amount) }
4
4
  amount { 45.75 }
5
5
  state { 'checkout' }
6
- response_code { '12345' }
6
+ response_code { "BGS-#{SecureRandom.hex(6)}" }
7
7
 
8
8
  payment_method { create(:credit_card_payment_method, stores: [order.store]) }
9
9
  association(:source, factory: :credit_card)
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.1
4
+ version: 5.4.2
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-04-14 00:00:00.000000000 Z
13
+ date: 2026-04-27 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: i18n-tasks
@@ -590,16 +590,16 @@ dependencies:
590
590
  name: pagy
591
591
  requirement: !ruby/object:Gem::Requirement
592
592
  requirements:
593
- - - "~>"
593
+ - - ">="
594
594
  - !ruby/object:Gem::Version
595
- version: '43.0'
595
+ version: '43.3'
596
596
  type: :runtime
597
597
  prerelease: false
598
598
  version_requirements: !ruby/object:Gem::Requirement
599
599
  requirements:
600
- - - "~>"
600
+ - - ">="
601
601
  - !ruby/object:Gem::Version
602
- version: '43.0'
602
+ version: '43.3'
603
603
  description: Spree Models, Helpers, Services and core libraries
604
604
  email: hello@spreecommerce.org
605
605
  executables: []
@@ -865,6 +865,7 @@ files:
865
865
  - app/jobs/spree/gift_cards/bulk_generate_job.rb
866
866
  - app/jobs/spree/images/save_from_url_job.rb
867
867
  - app/jobs/spree/imports/create_rows_job.rb
868
+ - app/jobs/spree/imports/process_group_job.rb
868
869
  - app/jobs/spree/imports/process_rows_job.rb
869
870
  - app/jobs/spree/payments/handle_webhook_job.rb
870
871
  - app/jobs/spree/products/auto_match_taxons_job.rb
@@ -1150,6 +1151,7 @@ files:
1150
1151
  - app/models/spree/role_user.rb
1151
1152
  - app/models/spree/search_provider/base.rb
1152
1153
  - app/models/spree/search_provider/database.rb
1154
+ - app/models/spree/search_provider/filters_result.rb
1153
1155
  - app/models/spree/search_provider/meilisearch.rb
1154
1156
  - app/models/spree/search_provider/search_result.rb
1155
1157
  - app/models/spree/shipment.rb
@@ -1365,6 +1367,7 @@ files:
1365
1367
  - config/importmap.rb
1366
1368
  - config/initializers/active_storage.rb
1367
1369
  - config/initializers/acts_as_taggable_on.rb
1370
+ - config/initializers/carmen.rb
1368
1371
  - config/initializers/friendly_id.rb
1369
1372
  - config/initializers/inflections.rb
1370
1373
  - config/initializers/mobility.rb
@@ -1509,6 +1512,8 @@ files:
1509
1512
  - db/migrate/20260402000002_add_color_code_to_spree_option_values.rb
1510
1513
  - db/migrate/20260403000000_add_market_to_spree_orders.rb
1511
1514
  - db/migrate/20260408000001_add_unique_index_to_spree_zone_members.rb
1515
+ - db/migrate/20260424000001_add_unique_index_to_spree_payments_response_code.rb
1516
+ - db/migrate/20260424100000_add_processing_groups_to_spree_imports.rb
1512
1517
  - db/sample_data/customers.csv
1513
1518
  - db/sample_data/markets.rb
1514
1519
  - db/sample_data/metafield_definitions.rb
@@ -1739,9 +1744,9 @@ licenses:
1739
1744
  - BSD-3-Clause
1740
1745
  metadata:
1741
1746
  bug_tracker_uri: https://github.com/spree/spree/issues
1742
- changelog_uri: https://github.com/spree/spree/releases/tag/v5.4.1
1747
+ changelog_uri: https://github.com/spree/spree/releases/tag/v5.4.2
1743
1748
  documentation_uri: https://docs.spreecommerce.org/
1744
- source_code_uri: https://github.com/spree/spree/tree/v5.4.1
1749
+ source_code_uri: https://github.com/spree/spree/tree/v5.4.2
1745
1750
  post_install_message:
1746
1751
  rdoc_options: []
1747
1752
  require_paths: