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.
- 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/order.rb +0 -6
- 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/promotion.rb +2 -2
- 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 +5 -1
- data/app/models/spree/shipping_rate.rb +26 -1
- data/app/models/spree/taxon.rb +1 -2
- data/app/models/spree/variant.rb +6 -3
- 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 +2 -2
- data/app/services/spree/payments/handle_webhook.rb +6 -0
- data/app/services/spree/seeds/zones.rb +8 -2
- 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/markets.rb +34 -0
- data/db/sample_data/orders.rb +1 -1
- data/db/sample_data/shipping_methods.rb +25 -3
- 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/permitted_attributes.rb +3 -3
- data/lib/spree/testing_support/factories/payment_factory.rb +1 -1
- data/lib/tasks/markets.rake +2 -5
- 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
|
-
|
|
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
|
)
|
|
@@ -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
|
#
|
data/app/models/spree/taxon.rb
CHANGED
|
@@ -72,10 +72,9 @@ module Spree
|
|
|
72
72
|
#
|
|
73
73
|
# Callbacks
|
|
74
74
|
#
|
|
75
|
-
before_validation :set_permalink,
|
|
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
|
data/app/models/spree/variant.rb
CHANGED
|
@@ -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
|
|
474
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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: '
|
|
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
|
-
|
|
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
|