spree_core 5.4.1 → 5.4.3
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/events/subscriber_job.rb +1 -1
- 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/concerns/spree/publishable.rb +1 -1
- data/app/models/spree/address.rb +1 -1
- data/app/models/spree/asset.rb +9 -9
- data/app/models/spree/event.rb +6 -6
- data/app/models/spree/export.rb +2 -2
- data/app/models/spree/exports/product_translations.rb +1 -1
- data/app/models/spree/gateway/bogus.rb +16 -7
- 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/line_item.rb +1 -1
- data/app/models/spree/market.rb +25 -0
- data/app/models/spree/order_inventory.rb +24 -2
- data/app/models/spree/payment.rb +1 -0
- data/app/models/spree/payment_session.rb +5 -0
- data/app/models/spree/payment_setup_sessions/bogus.rb +4 -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/models/spree/shipment.rb +10 -4
- data/app/models/spree/subscriber.rb +12 -12
- data/app/presenters/spree/csv/formula_sanitizer.rb +28 -0
- 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 +8 -9
- data/app/subscribers/spree/event_log_subscriber.rb +10 -8
- data/config/initializers/carmen.rb +23 -0
- data/config/locales/en.yml +8 -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/migrate/20260504103113_add_type_to_spree_payment_setup_sessions.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/adapters/active_support_notifications.rb +1 -1
- data/lib/spree/events/adapters/base.rb +3 -3
- data/lib/spree/events/registry.rb +1 -1
- data/lib/spree/events.rb +7 -1
- data/lib/spree/testing_support/factories/payment_factory.rb +1 -1
- metadata +16 -8
|
@@ -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
|
)
|
|
@@ -272,16 +272,22 @@ module Spree
|
|
|
272
272
|
def manifest
|
|
273
273
|
# Grouping by the ID means that we don't have to call out to the association accessor
|
|
274
274
|
# This makes the grouping by faster because it results in less SQL cache hits.
|
|
275
|
-
inventory_units.group_by(&:variant_id).
|
|
276
|
-
units.group_by(&:line_item_id).
|
|
275
|
+
inventory_units.group_by(&:variant_id).flat_map do |_variant_id, units|
|
|
276
|
+
units.group_by(&:line_item_id).filter_map do |_line_item_id, units|
|
|
277
|
+
line_item = units.first.line_item
|
|
278
|
+
# Defensively skip orphaned inventory units (line item destroyed
|
|
279
|
+
# without cascading) so a single bad row doesn't crash callers that
|
|
280
|
+
# rely on line_item being present (item_cost, item_weight, the admin
|
|
281
|
+
# shipment manifest view, etc.).
|
|
282
|
+
next if line_item.nil?
|
|
283
|
+
|
|
277
284
|
states = {}
|
|
278
285
|
units.group_by(&:state).each { |state, iu| states[state] = iu.sum(&:quantity) }
|
|
279
286
|
|
|
280
|
-
line_item = units.first.line_item
|
|
281
287
|
variant = units.first.variant
|
|
282
288
|
ManifestItem.new(line_item, variant, units.sum(&:quantity), states)
|
|
283
289
|
end
|
|
284
|
-
end
|
|
290
|
+
end
|
|
285
291
|
end
|
|
286
292
|
|
|
287
293
|
def process_order_payments
|
|
@@ -9,7 +9,7 @@ module Spree
|
|
|
9
9
|
#
|
|
10
10
|
# @example Basic subscriber
|
|
11
11
|
# class OrderCompletedNotifier < Spree::Subscriber
|
|
12
|
-
# subscribes_to 'order.
|
|
12
|
+
# subscribes_to 'order.completed'
|
|
13
13
|
#
|
|
14
14
|
# def call(event)
|
|
15
15
|
# order_id = event.payload['id']
|
|
@@ -19,7 +19,7 @@ module Spree
|
|
|
19
19
|
#
|
|
20
20
|
# @example Multi-event subscriber
|
|
21
21
|
# class OrderAuditLogger < Spree::Subscriber
|
|
22
|
-
# subscribes_to 'order.
|
|
22
|
+
# subscribes_to 'order.completed', 'order.canceled', 'order.resumed'
|
|
23
23
|
#
|
|
24
24
|
# def call(event)
|
|
25
25
|
# AuditLog.create!(
|
|
@@ -41,11 +41,11 @@ module Spree
|
|
|
41
41
|
#
|
|
42
42
|
# @example Subscriber with method routing
|
|
43
43
|
# class PaymentSubscriber < Spree::Subscriber
|
|
44
|
-
# subscribes_to 'payment.
|
|
44
|
+
# subscribes_to 'payment.completed', 'payment.voided', 'refund.created'
|
|
45
45
|
#
|
|
46
|
-
# on 'payment.
|
|
47
|
-
# on 'payment.
|
|
48
|
-
# on '
|
|
46
|
+
# on 'payment.completed', :handle_complete
|
|
47
|
+
# on 'payment.voided', :handle_void
|
|
48
|
+
# on 'refund.created', :handle_refund
|
|
49
49
|
#
|
|
50
50
|
# private
|
|
51
51
|
#
|
|
@@ -64,7 +64,7 @@ module Spree
|
|
|
64
64
|
#
|
|
65
65
|
# @example Synchronous subscriber (runs immediately, not via ActiveJob)
|
|
66
66
|
# class CriticalOrderHandler < Spree::Subscriber
|
|
67
|
-
# subscribes_to 'order.
|
|
67
|
+
# subscribes_to 'order.completed', async: false
|
|
68
68
|
#
|
|
69
69
|
# def call(event)
|
|
70
70
|
# # This runs synchronously
|
|
@@ -81,10 +81,10 @@ module Spree
|
|
|
81
81
|
# @return [void]
|
|
82
82
|
#
|
|
83
83
|
# @example
|
|
84
|
-
# subscribes_to 'order.
|
|
85
|
-
# subscribes_to 'order.
|
|
84
|
+
# subscribes_to 'order.completed'
|
|
85
|
+
# subscribes_to 'order.completed', 'order.canceled'
|
|
86
86
|
# subscribes_to 'order.*'
|
|
87
|
-
# subscribes_to 'order.
|
|
87
|
+
# subscribes_to 'order.completed', async: false
|
|
88
88
|
#
|
|
89
89
|
def subscribes_to(*patterns, **options)
|
|
90
90
|
@subscription_patterns ||= []
|
|
@@ -102,8 +102,8 @@ module Spree
|
|
|
102
102
|
# @return [void]
|
|
103
103
|
#
|
|
104
104
|
# @example
|
|
105
|
-
# on 'payment.
|
|
106
|
-
# on 'payment.
|
|
105
|
+
# on 'payment.completed', :handle_complete
|
|
106
|
+
# on 'payment.voided', :handle_void
|
|
107
107
|
#
|
|
108
108
|
def on(pattern, method_name)
|
|
109
109
|
@event_handlers ||= {}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module CSV
|
|
3
|
+
# Neutralizes CSV formula injection (CWE-1236 / OWASP "CSV Injection")
|
|
4
|
+
# by prefixing cells that would otherwise be evaluated as a formula
|
|
5
|
+
# when the exported file is opened in Excel, Google Sheets, LibreOffice,
|
|
6
|
+
# or Numbers.
|
|
7
|
+
#
|
|
8
|
+
# The leading apostrophe is the OWASP-recommended marker — spreadsheets
|
|
9
|
+
# render the cell as plain text without displaying the apostrophe.
|
|
10
|
+
module FormulaSanitizer
|
|
11
|
+
TRIGGERS = ["=", "+", "-", "@", "\t", "\r", "\n"].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def cell(value)
|
|
16
|
+
return value unless value.is_a?(String)
|
|
17
|
+
return value if value.empty?
|
|
18
|
+
return value unless TRIGGERS.include?(value[0])
|
|
19
|
+
|
|
20
|
+
"'#{value}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def row(values)
|
|
24
|
+
values.map { |v| cell(v) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
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
|
-
|
|
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_locations(2)
|
|
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
|
|
@@ -28,21 +28,20 @@ module Spree
|
|
|
28
28
|
|
|
29
29
|
private
|
|
30
30
|
|
|
31
|
+
# `Spree::Payment#confirm!` honors the payment method's `auto_capture?` setting:
|
|
32
|
+
# auto_capture → complete! + capture_event; otherwise → pend! (auth-only, payment_state=balance_due).
|
|
31
33
|
def handle_success(payment_session, order, metadata)
|
|
32
34
|
order.with_lock do
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if payment.present? && !payment.completed?
|
|
38
|
-
payment.started_processing! if payment.checkout?
|
|
39
|
-
payment.complete! if payment.can_complete?
|
|
35
|
+
# Idempotency: if the session was already completed (by the API
|
|
36
|
+
# endpoint or a previous webhook), skip duplicate processing.
|
|
37
|
+
if payment_session.reload.completed?
|
|
38
|
+
return success(payment_session)
|
|
40
39
|
end
|
|
41
40
|
|
|
42
|
-
|
|
41
|
+
payment = payment_session.find_or_create_payment!(metadata)
|
|
42
|
+
payment.confirm! if payment.present? && !payment.completed?
|
|
43
43
|
payment_session.complete if payment_session.can_complete?
|
|
44
44
|
|
|
45
|
-
# Complete order if not already done
|
|
46
45
|
unless order.reload.completed?
|
|
47
46
|
Spree::Dependencies.carts_complete_service.constantize.call(cart: order)
|
|
48
47
|
end
|
|
@@ -9,32 +9,34 @@ module Spree
|
|
|
9
9
|
# Rails.application.config.filter_parameters.
|
|
10
10
|
#
|
|
11
11
|
# @example Output
|
|
12
|
-
# [Spree Event] order.
|
|
12
|
+
# [Spree Event] order.completed | payload: {"id"=>1} | 0.5ms
|
|
13
13
|
#
|
|
14
14
|
class EventLogSubscriber
|
|
15
15
|
NAMESPACE = '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
|