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.
- checksums.yaml +4 -4
- data/app/jobs/spree/imports/process_group_job.rb +45 -0
- data/app/jobs/spree/imports/process_rows_job.rb +51 -5
- data/app/models/concerns/spree/payment_source_concern.rb +21 -0
- data/app/models/spree/address.rb +1 -1
- data/app/models/spree/gateway/bogus.rb +10 -6
- data/app/models/spree/import.rb +15 -0
- data/app/models/spree/import_row.rb +14 -2
- data/app/models/spree/imports/product_translations.rb +4 -0
- data/app/models/spree/imports/products.rb +5 -0
- data/app/models/spree/payment.rb +1 -0
- data/app/models/spree/payment_session.rb +5 -0
- data/app/models/spree/product.rb +5 -10
- data/app/models/spree/search_provider/base.rb +13 -1
- data/app/models/spree/search_provider/database.rb +44 -29
- data/app/models/spree/search_provider/filters_result.rb +5 -0
- data/app/models/spree/search_provider/meilisearch.rb +99 -82
- data/app/models/spree/search_provider/search_result.rb +1 -1
- data/app/services/spree/credit_cards/destroy.rb +10 -26
- data/app/services/spree/gift_cards/apply.rb +5 -1
- data/app/services/spree/imports/row_processors/base.rb +19 -2
- data/app/services/spree/imports/row_processors/product_translation.rb +1 -1
- data/app/services/spree/imports/row_processors/product_variant.rb +1 -1
- data/app/services/spree/payments/handle_webhook.rb +6 -0
- data/app/subscribers/spree/event_log_subscriber.rb +9 -7
- data/config/initializers/carmen.rb +23 -0
- data/config/locales/en.yml +0 -1
- data/db/migrate/20260424000001_add_unique_index_to_spree_payments_response_code.rb +51 -0
- data/db/migrate/20260424100000_add_processing_groups_to_spree_imports.rb +6 -0
- data/db/sample_data/orders.rb +1 -1
- data/lib/generators/spree/dummy/dummy_generator.rb +1 -1
- data/lib/spree/core/configuration.rb +3 -0
- data/lib/spree/core/engine.rb +3 -6
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/events.rb +6 -0
- data/lib/spree/testing_support/factories/payment_factory.rb +1 -1
- metadata +13 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 23692bda70e0c85ecff286d989063498e74adc2e2422ae21719da5c6b6e97c98
|
|
4
|
+
data.tar.gz: 132486aca06d2912f82a54593d57eb9c400768b1a45b5412af77b61f2c822ac5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
data/app/models/spree/address.rb
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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'
|
data/app/models/spree/import.rb
CHANGED
|
@@ -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
|
data/app/models/spree/payment.rb
CHANGED
|
@@ -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
|
data/app/models/spree/product.rb
CHANGED
|
@@ -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
|
-
|
|
253
|
-
|
|
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
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
#
|
|
17
|
+
# Total count (before sorting to avoid computed column conflicts with count)
|
|
37
18
|
total = scope.distinct.count
|
|
38
19
|
|
|
39
|
-
#
|
|
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:
|
|
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)
|
|
@@ -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
|
-
|
|
74
|
-
|
|
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,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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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)
|
data/config/locales/en.yml
CHANGED
|
@@ -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
|
data/db/sample_data/orders.rb
CHANGED
|
@@ -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:
|
|
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
|
data/lib/spree/core/engine.rb
CHANGED
|
@@ -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,
|
data/lib/spree/core/version.rb
CHANGED
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 {
|
|
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.
|
|
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-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|