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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/events/subscriber_job.rb +1 -1
  3. data/app/jobs/spree/imports/process_group_job.rb +45 -0
  4. data/app/jobs/spree/imports/process_rows_job.rb +51 -5
  5. data/app/models/concerns/spree/payment_source_concern.rb +21 -0
  6. data/app/models/concerns/spree/publishable.rb +1 -1
  7. data/app/models/spree/address.rb +1 -1
  8. data/app/models/spree/asset.rb +9 -9
  9. data/app/models/spree/event.rb +6 -6
  10. data/app/models/spree/export.rb +2 -2
  11. data/app/models/spree/exports/product_translations.rb +1 -1
  12. data/app/models/spree/gateway/bogus.rb +16 -7
  13. data/app/models/spree/import.rb +15 -0
  14. data/app/models/spree/import_row.rb +14 -2
  15. data/app/models/spree/imports/product_translations.rb +4 -0
  16. data/app/models/spree/imports/products.rb +5 -0
  17. data/app/models/spree/line_item.rb +1 -1
  18. data/app/models/spree/market.rb +25 -0
  19. data/app/models/spree/order_inventory.rb +24 -2
  20. data/app/models/spree/payment.rb +1 -0
  21. data/app/models/spree/payment_session.rb +5 -0
  22. data/app/models/spree/payment_setup_sessions/bogus.rb +4 -0
  23. data/app/models/spree/product.rb +5 -10
  24. data/app/models/spree/search_provider/base.rb +13 -1
  25. data/app/models/spree/search_provider/database.rb +44 -29
  26. data/app/models/spree/search_provider/filters_result.rb +5 -0
  27. data/app/models/spree/search_provider/meilisearch.rb +99 -82
  28. data/app/models/spree/search_provider/search_result.rb +1 -1
  29. data/app/models/spree/shipment.rb +10 -4
  30. data/app/models/spree/subscriber.rb +12 -12
  31. data/app/presenters/spree/csv/formula_sanitizer.rb +28 -0
  32. data/app/services/spree/credit_cards/destroy.rb +10 -26
  33. data/app/services/spree/gift_cards/apply.rb +5 -1
  34. data/app/services/spree/imports/row_processors/base.rb +19 -2
  35. data/app/services/spree/imports/row_processors/product_translation.rb +1 -1
  36. data/app/services/spree/imports/row_processors/product_variant.rb +1 -1
  37. data/app/services/spree/payments/handle_webhook.rb +8 -9
  38. data/app/subscribers/spree/event_log_subscriber.rb +10 -8
  39. data/config/initializers/carmen.rb +23 -0
  40. data/config/locales/en.yml +8 -1
  41. data/db/migrate/20260424000001_add_unique_index_to_spree_payments_response_code.rb +51 -0
  42. data/db/migrate/20260424100000_add_processing_groups_to_spree_imports.rb +6 -0
  43. data/db/migrate/20260504103113_add_type_to_spree_payment_setup_sessions.rb +6 -0
  44. data/db/sample_data/orders.rb +1 -1
  45. data/lib/generators/spree/dummy/dummy_generator.rb +1 -1
  46. data/lib/spree/core/configuration.rb +3 -0
  47. data/lib/spree/core/engine.rb +3 -6
  48. data/lib/spree/core/version.rb +1 -1
  49. data/lib/spree/events/adapters/active_support_notifications.rb +1 -1
  50. data/lib/spree/events/adapters/base.rb +3 -3
  51. data/lib/spree/events/registry.rb +1 -1
  52. data/lib/spree/events.rb +7 -1
  53. data/lib/spree/testing_support/factories/payment_factory.rb +1 -1
  54. 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
- # 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
@@ -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).map do |_variant_id, units|
276
- units.group_by(&:line_item_id).map do |_line_item_id, units|
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.flatten
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.complete'
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.complete', 'order.cancel', 'order.resume'
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.complete', 'payment.void', 'payment.refund'
44
+ # subscribes_to 'payment.completed', 'payment.voided', 'refund.created'
45
45
  #
46
- # on 'payment.complete', :handle_complete
47
- # on 'payment.void', :handle_void
48
- # on 'payment.refund', :handle_refund
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.complete', async: false
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.complete'
85
- # subscribes_to 'order.complete', 'order.cancel'
84
+ # subscribes_to 'order.completed'
85
+ # subscribes_to 'order.completed', 'order.canceled'
86
86
  # subscribes_to 'order.*'
87
- # subscribes_to 'order.complete', async: false
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.complete', :handle_complete
106
- # on 'payment.void', :handle_void
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
- 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_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.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
@@ -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
- # Ensure payment record exists
34
- payment = payment_session.find_or_create_payment!(metadata)
35
-
36
- # Mark payment as completed — the webhook confirms the gateway processed it
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
- # Mark session as completed
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.complete | payload: {"id"=>1} | 0.5ms
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
- @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