spree_core 5.4.0 → 5.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/imports/process_group_job.rb +45 -0
  3. data/app/jobs/spree/imports/process_rows_job.rb +51 -5
  4. data/app/models/concerns/spree/payment_source_concern.rb +21 -0
  5. data/app/models/spree/address.rb +1 -1
  6. data/app/models/spree/gateway/bogus.rb +10 -6
  7. data/app/models/spree/import.rb +15 -0
  8. data/app/models/spree/import_row.rb +14 -2
  9. data/app/models/spree/imports/product_translations.rb +4 -0
  10. data/app/models/spree/imports/products.rb +5 -0
  11. data/app/models/spree/order.rb +0 -6
  12. data/app/models/spree/payment.rb +1 -0
  13. data/app/models/spree/payment_session.rb +5 -0
  14. data/app/models/spree/product.rb +5 -10
  15. data/app/models/spree/promotion.rb +2 -2
  16. data/app/models/spree/search_provider/base.rb +13 -1
  17. data/app/models/spree/search_provider/database.rb +44 -29
  18. data/app/models/spree/search_provider/filters_result.rb +5 -0
  19. data/app/models/spree/search_provider/meilisearch.rb +99 -82
  20. data/app/models/spree/search_provider/search_result.rb +1 -1
  21. data/app/models/spree/shipment.rb +5 -1
  22. data/app/models/spree/shipping_rate.rb +26 -1
  23. data/app/models/spree/taxon.rb +1 -2
  24. data/app/models/spree/variant.rb +6 -3
  25. data/app/services/spree/credit_cards/destroy.rb +10 -26
  26. data/app/services/spree/gift_cards/apply.rb +5 -1
  27. data/app/services/spree/imports/row_processors/base.rb +19 -2
  28. data/app/services/spree/imports/row_processors/product_translation.rb +1 -1
  29. data/app/services/spree/imports/row_processors/product_variant.rb +2 -2
  30. data/app/services/spree/payments/handle_webhook.rb +6 -0
  31. data/app/services/spree/seeds/zones.rb +8 -2
  32. data/app/subscribers/spree/event_log_subscriber.rb +9 -7
  33. data/config/initializers/carmen.rb +23 -0
  34. data/config/locales/en.yml +0 -1
  35. data/db/migrate/20260424000001_add_unique_index_to_spree_payments_response_code.rb +51 -0
  36. data/db/migrate/20260424100000_add_processing_groups_to_spree_imports.rb +6 -0
  37. data/db/sample_data/markets.rb +34 -0
  38. data/db/sample_data/orders.rb +1 -1
  39. data/db/sample_data/shipping_methods.rb +25 -3
  40. data/lib/generators/spree/dummy/dummy_generator.rb +1 -1
  41. data/lib/spree/core/configuration.rb +3 -0
  42. data/lib/spree/core/engine.rb +3 -6
  43. data/lib/spree/core/version.rb +1 -1
  44. data/lib/spree/events.rb +6 -0
  45. data/lib/spree/permitted_attributes.rb +3 -3
  46. data/lib/spree/testing_support/factories/payment_factory.rb +1 -1
  47. data/lib/tasks/markets.rake +2 -5
  48. metadata +23 -12
@@ -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
@@ -112,8 +112,10 @@ module Spree
112
112
  self.whitelisted_ransackable_attributes = ['number']
113
113
 
114
114
  extend DisplayMoney
115
- money_methods :cost, :discounted_cost, :final_price, :item_cost, :additional_tax_total, :included_tax_total, :tax_total
115
+ money_methods :cost, :discounted_cost, :final_price, :item_cost, :additional_tax_total, :included_tax_total, :tax_total, :promo_total
116
116
  alias display_amount display_cost
117
+ alias_attribute :discount_total, :promo_total
118
+ alias display_discount_total display_promo_total
117
119
 
118
120
  normalizes :tracking, with: ->(value) { value&.to_s&.squish&.presence }
119
121
 
@@ -197,6 +199,8 @@ module Spree
197
199
  def final_price
198
200
  cost + adjustment_total
199
201
  end
202
+ alias total final_price
203
+ alias display_total display_final_price
200
204
 
201
205
  def final_price_with_items
202
206
  item_cost + final_price
@@ -7,7 +7,7 @@ module Spree
7
7
  belongs_to :shipping_method, -> { with_deleted }, class_name: 'Spree::ShippingMethod', inverse_of: :shipping_rates
8
8
  extend Spree::DisplayMoney
9
9
 
10
- money_methods :base_price, :final_price, :tax_amount
10
+ money_methods :base_price, :final_price, :tax_amount, :additional_tax_total, :included_tax_total, :tax_total
11
11
 
12
12
  delegate :order, :currency, :with_free_shipping_promotion?, to: :shipment
13
13
  delegate :name, to: :shipping_method
@@ -29,14 +29,37 @@ module Spree
29
29
  alias display_cost display_price
30
30
  alias_attribute :base_price, :cost
31
31
 
32
+ # Returns true if the shipping rate is free
33
+ #
34
+ # @return [Boolean]
32
35
  def free?
33
36
  final_price.zero?
34
37
  end
35
38
 
39
+ # Returns the tax amount for the shipping rate
40
+ #
41
+ # @return [BigDecimal]
36
42
  def tax_amount
37
43
  @tax_amount ||= tax_rate&.calculator&.compute_shipping_rate(self) || BigDecimal(0)
38
44
  end
39
45
 
46
+ # Returns the additional tax total for the shipping rate
47
+ #
48
+ # @return [BigDecimal]
49
+ def additional_tax_total
50
+ tax_rate&.included_in_price? ? BigDecimal(0) : tax_amount
51
+ end
52
+
53
+ # Returns the included tax total for the shipping rate
54
+ #
55
+ # @return [BigDecimal]
56
+ def included_tax_total
57
+ tax_rate&.included_in_price? ? tax_amount : BigDecimal(0)
58
+ end
59
+
60
+ alias tax_total tax_amount
61
+ alias display_tax_total display_tax_amount
62
+
40
63
  # returns base price - any available discounts for this Shipment
41
64
  # useful when you want to present a list of available shipping rates
42
65
  def final_price
@@ -46,6 +69,8 @@ module Spree
46
69
  cost + discount_amount
47
70
  end
48
71
  end
72
+ alias total final_price
73
+ alias display_total display_final_price
49
74
 
50
75
  # Returns the delivery range for the shipping method
51
76
  #
@@ -72,10 +72,9 @@ module Spree
72
72
  #
73
73
  # Callbacks
74
74
  #
75
- before_validation :set_permalink, on: :create, if: :name
75
+ before_validation :set_permalink, if: :name
76
76
  before_validation :copy_taxonomy_from_parent
77
77
  before_save :set_pretty_name
78
- before_save :set_permalink
79
78
  after_save :touch_ancestors_and_taxonomy
80
79
  after_update :sync_taxonomy_name
81
80
  after_touch :touch_ancestors_and_taxonomy
@@ -470,14 +470,17 @@ module Spree
470
470
  # @param backorderable [Boolean] the backorderable flag
471
471
  # @param stock_location [Spree::StockLocation] the stock location to set the stock for
472
472
  # @return [void]
473
- def set_stock(count_on_hand, backorderable = nil, stock_location = nil)
474
- stock_location ||= Spree::Store.current.default_stock_location
475
- stock_item = stock_items.find_or_initialize_by(stock_location: stock_location)
473
+ def set_stock(count_on_hand, backorderable = nil)
474
+ stock_item = stock_items.find_or_initialize_by(stock_location: default_stock_location)
476
475
  stock_item.count_on_hand = count_on_hand
477
476
  stock_item.backorderable = backorderable if backorderable.present?
478
477
  stock_item.save!
479
478
  end
480
479
 
480
+ def default_stock_location
481
+ Spree::Store.current.default_stock_location
482
+ end
483
+
481
484
  def price_modifier_amount_in(currency, options = {})
482
485
  return 0 unless options.present?
483
486
 
@@ -1,41 +1,25 @@
1
1
  module Spree
2
2
  module CreditCards
3
+ # @deprecated Use card.destroy directly instead. Payment cleanup is now
4
+ # handled by the before_destroy callback in Spree::PaymentSourceConcern.
5
+ # This service will be removed in Spree 6.0.
3
6
  class Destroy
4
7
  prepend Spree::ServiceModule::Base
5
8
 
6
9
  def call(card:)
7
- ApplicationRecord.transaction do
8
- run :invalidate_payments
9
- run :void_payments
10
- run :destroy
11
- end
12
- end
13
-
14
- protected
15
-
16
- def invalidate_payments(card:)
17
- payment_scope(card).checkout.each(&:invalidate!)
18
-
19
- success(card: card)
20
- end
10
+ Spree::Deprecation.warn(
11
+ "#{self.class.name} is deprecated and will be removed in Spree 6.0. " \
12
+ 'Use card.destroy directly instead. Payment cleanup is now handled ' \
13
+ 'automatically by the before_destroy callback in PaymentSourceConcern.',
14
+ caller
15
+ )
21
16
 
22
- def void_payments(card:)
23
- payment_scope(card).where.not(state: :checkout).each(&:void!)
24
-
25
- success(card: card)
26
- end
27
-
28
- def destroy(card:)
29
17
  if card.destroy
30
18
  success(card: card)
31
19
  else
32
- failure(card.errors.full_messages.to_sentance)
20
+ failure(card.errors.full_messages.to_sentence)
33
21
  end
34
22
  end
35
-
36
- def payment_scope(card)
37
- card.payments.valid.joins(:order).merge(Spree::Order.incomplete)
38
- end
39
23
  end
40
24
  end
41
25
  end
@@ -68,7 +68,11 @@ module Spree
68
68
  )
69
69
  payment_method.name ||= Spree.t(:store_credit_name)
70
70
  payment_method.active = true
71
- payment_method.save! if payment_method.new_record?
71
+
72
+ if payment_method.new_record?
73
+ payment_method.stores << store unless payment_method.stores.include?(store)
74
+ payment_method.save!
75
+ end
72
76
 
73
77
  payment_method
74
78
  end
@@ -2,10 +2,14 @@ module Spree
2
2
  module Imports
3
3
  module RowProcessors
4
4
  class Base
5
- def initialize(row)
5
+ def initialize(row, mappings: nil, schema_fields: nil)
6
6
  @row = row
7
7
  @import = row.import
8
- @attributes = row.to_schema_hash
8
+ @attributes = if mappings && schema_fields
9
+ build_schema_hash(row, mappings, schema_fields)
10
+ else
11
+ row.to_schema_hash
12
+ end
9
13
  end
10
14
 
11
15
  attr_reader :row, :import, :attributes
@@ -13,6 +17,19 @@ module Spree
13
17
  def process!
14
18
  raise NotImplementedError, 'Subclasses must implement the process! method'
15
19
  end
20
+
21
+ private
22
+
23
+ def build_schema_hash(row, mappings, schema_fields)
24
+ attributes = {}
25
+ schema_fields.each do |field|
26
+ mapping = mappings.find { |m| m.schema_field == field[:name] }
27
+ next unless mapping&.mapped?
28
+
29
+ attributes[field[:name]] = row.data_json[mapping.file_column]
30
+ end
31
+ attributes
32
+ end
16
33
  end
17
34
  end
18
35
  end
@@ -4,7 +4,7 @@ module Spree
4
4
  class ProductTranslation < Base
5
5
  TRANSLATABLE_FIELDS = %w[name description meta_title meta_description].freeze
6
6
 
7
- def initialize(row)
7
+ def initialize(row, **)
8
8
  super
9
9
  @store = row.store
10
10
  end
@@ -4,7 +4,7 @@ module Spree
4
4
  class ProductVariant < Base
5
5
  OPTION_TYPES_COUNT = 3
6
6
 
7
- def initialize(row)
7
+ def initialize(row, **)
8
8
  super
9
9
  @store = row.store
10
10
  @product = ensure_product_exists
@@ -47,7 +47,7 @@ module Spree
47
47
  end
48
48
 
49
49
  if attributes['inventory_count'].present?
50
- variant.set_stock(attributes['inventory_count'].to_i, attributes['inventory_backorderable']&.to_b, store.default_stock_location)
50
+ variant.set_stock(attributes['inventory_count'].to_i, attributes['inventory_backorderable']&.to_b)
51
51
  end
52
52
 
53
53
  handle_images(variant)
@@ -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
 
@@ -7,17 +7,23 @@ module Spree
7
7
  eu_vat = Spree::Zone.where(name: 'EU_VAT', description: 'Countries that make up the EU VAT zone.', kind: 'country').first_or_create!
8
8
  uk_vat = Spree::Zone.where(name: 'UK_VAT', kind: 'country').first_or_create!
9
9
  north_america = Spree::Zone.where(name: 'North America', description: 'USA + Canada', kind: 'country').first_or_create!
10
- Spree::Zone.where(name: 'South America', description: 'South America', kind: 'country').first_or_create!
10
+ central_america_and_caribbean = Spree::Zone.where(name: 'Central America and Caribbean', description: 'Central America and Caribbean', kind: 'country').first_or_create!
11
+ south_america = Spree::Zone.where(name: 'South America', description: 'South America', kind: 'country').first_or_create!
11
12
  middle_east = Spree::Zone.where(name: 'Middle East', description: 'Middle East', kind: 'country').first_or_create!
13
+ africa = Spree::Zone.where(name: 'Africa', description: 'Africa', kind: 'country').first_or_create!
12
14
  asia = Spree::Zone.where(name: 'Asia', description: 'Asia', kind: 'country').first_or_create!
13
15
  australia_and_oceania = Spree::Zone.where(name: 'Australia and Oceania', description: 'Australia and Oceania', kind: 'country').first_or_create!
14
16
 
15
17
  create_zone_members(eu_vat, %w(PL FI PT RO DE FR SK HU SI IE AT ES IT BE SE LV BG LT CY LU MT DK NL EE HR CZ GR))
16
18
  create_zone_members(north_america, %w(US CA))
19
+ create_zone_members(central_america_and_caribbean, %w(MX GT BZ SV HN NI CR PA CU DO HT JM BS BB TT PR AG DM GD KN LC VC AI AW BM KY CW GP MQ MS BL MF SX TC VG VI))
20
+ create_zone_members(south_america, %w(AR BO BR CL CO EC FK GF GY PY PE SR UY VE))
17
21
  create_zone_members(middle_east, %w(BH CY EG IR IQ IL JO KW LB OM QA SA SY TR AE YE))
22
+ create_zone_members(africa, %w(DZ AO BJ BW BF BI CV CM CF TD KM CG CD CI DJ EG GQ ER SZ ET GA GM GH GN GW KE LS LR LY
23
+ MG MW ML MR MU YT MA MZ NA NE NG RE RW SH ST SN SC SL SO ZA SS SD TZ TG TN UG ZM ZW))
18
24
  create_zone_members(asia, %w(AF AM AZ BH BD BT BN KH CN CX CC GE HK IN ID IR IQ IL JP JO KZ KW KG LA LB MO MY MV MN MM NP
19
25
  KP OM PK PS PH QA SA SG KR LK SY TW TJ TH TR TM AE UZ VN YE))
20
- create_zone_members(australia_and_oceania, %w(AU NZ))
26
+ create_zone_members(australia_and_oceania, %w(AU NZ PG FJ SB VU NC PF WS AS GU KI MH FM NR NU NF MP PW PN TK TO TV WF CK))
21
27
  uk_vat.zone_members.where(zoneable: Spree::Country.find_by(iso: 'GB')).first_or_create!
22
28
  end
23
29
 
@@ -16,25 +16,27 @@ module Spree
16
16
 
17
17
  class << self
18
18
  def attach_to_notifications
19
- # Always detach first to ensure clean state after code reload
19
+ # Always detach first to ensure clean state after code reload.
20
+ # The subscription reference is stored on Spree::Events (in lib/, not
21
+ # reloaded by Zeitwerk) so repeated reloads in development do not leak
22
+ # stale AS::N subscriptions when this class is reloaded.
20
23
  detach_from_notifications
21
24
 
22
- @subscription = ActiveSupport::Notifications.subscribe(/\.#{NAMESPACE}$/) do |name, start, finish, _id, payload|
25
+ Spree::Events.log_subscription = ActiveSupport::Notifications.subscribe(/\.#{NAMESPACE}$/) do |name, start, finish, _id, payload|
23
26
  log_event(name, start, finish, payload)
24
27
  end
25
28
 
26
- @attached = true
27
29
  Rails.logger.info "[Spree Events] Event logging enabled"
28
30
  end
29
31
 
30
32
  def detach_from_notifications
31
- ActiveSupport::Notifications.unsubscribe(@subscription) if @subscription
32
- @subscription = nil
33
- @attached = false
33
+ subscription = Spree::Events.log_subscription
34
+ ActiveSupport::Notifications.unsubscribe(subscription) if subscription
35
+ Spree::Events.log_subscription = nil
34
36
  end
35
37
 
36
38
  def attached?
37
- @attached || false
39
+ !Spree::Events.log_subscription.nil?
38
40
  end
39
41
 
40
42
  private
@@ -0,0 +1,23 @@
1
+ # Patches Carmen::Querying#normalise_name to avoid the deprecated
2
+ # ActiveSupport::Multibyte::Chars API (mb_chars), which will be removed in
3
+ # Rails 8.2.
4
+ #
5
+ # In Ruby 2.4+, String#downcase is already Unicode-aware, making the mb_chars
6
+ # call redundant. Since Spree requires Ruby >= 3.2, we can drop it safely.
7
+ #
8
+ # This patch can be removed once the upstream gem is fixed.
9
+ #
10
+ # See: https://github.com/carmen-ruby/carmen/issues/304
11
+ require 'carmen'
12
+
13
+ module Spree
14
+ module CarmenQueryingPatch
15
+ private
16
+
17
+ def normalise_name(name)
18
+ name.downcase.unicode_normalize(:nfkc)
19
+ end
20
+ end
21
+ end
22
+
23
+ Carmen::Querying.prepend(Spree::CarmenQueryingPatch)
@@ -1342,7 +1342,6 @@ en:
1342
1342
  items_in_rmas: Items in Return Authorizations
1343
1343
  items_reimbursed: Items reimbursed
1344
1344
  items_to_be_reimbursed: Items to be reimbursed
1345
- join_slack: Join Slack
1346
1345
  key: Key
1347
1346
  kind: Kind
1348
1347
  label: Label
@@ -0,0 +1,51 @@
1
+ class AddUniqueIndexToSpreePaymentsResponseCode < ActiveRecord::Migration[7.2]
2
+ def up
3
+ # Remove duplicate payments per (order, payment_method, response_code),
4
+ # preferring to keep completed payments over incomplete ones.
5
+ # Among same-state duplicates, keeps the most recent (highest id).
6
+ # Uses derived table for MySQL compatibility.
7
+ execute <<~SQL
8
+ DELETE FROM spree_payments
9
+ WHERE response_code IS NOT NULL
10
+ AND id NOT IN (
11
+ SELECT keeper_id FROM (
12
+ SELECT (
13
+ SELECT id FROM spree_payments p2
14
+ WHERE p2.order_id = p1.order_id
15
+ AND p2.payment_method_id = p1.payment_method_id
16
+ AND p2.response_code = p1.response_code
17
+ ORDER BY
18
+ CASE p2.state
19
+ WHEN 'completed' THEN 0
20
+ WHEN 'pending' THEN 1
21
+ WHEN 'processing' THEN 2
22
+ ELSE 3
23
+ END,
24
+ p2.id DESC
25
+ LIMIT 1
26
+ ) AS keeper_id
27
+ FROM spree_payments p1
28
+ WHERE p1.response_code IS NOT NULL
29
+ GROUP BY p1.order_id, p1.payment_method_id, p1.response_code
30
+ ) AS keeper_ids
31
+ )
32
+ SQL
33
+
34
+ if ActiveRecord::Base.connection.adapter_name == 'Mysql2'
35
+ # MySQL doesn't support partial indexes, but treats NULL as distinct
36
+ # in unique indexes so multiple payments with NULL response_code are allowed
37
+ add_index :spree_payments, [:order_id, :payment_method_id, :response_code],
38
+ unique: true,
39
+ name: 'idx_payments_order_method_response_code'
40
+ else
41
+ add_index :spree_payments, [:order_id, :payment_method_id, :response_code],
42
+ unique: true,
43
+ where: 'response_code IS NOT NULL',
44
+ name: 'idx_payments_order_method_response_code'
45
+ end
46
+ end
47
+
48
+ def down
49
+ remove_index :spree_payments, name: 'idx_payments_order_method_response_code'
50
+ end
51
+ end
@@ -0,0 +1,6 @@
1
+ class AddProcessingGroupsToSpreeImports < ActiveRecord::Migration[7.2]
2
+ def change
3
+ add_column :spree_imports, :processing_groups_count, :integer, default: 0
4
+ add_column :spree_imports, :completed_groups_count, :integer, default: 0
5
+ end
6
+ end