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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3bed2ace5a7bcb6bc6b8c598cfb3ec19cb3dc0ea02854744543ae228ed9c636b
4
- data.tar.gz: 81642ee18f7e01079090ec1c5d162dc25186aa5bd72ac2e799a5a3457ebd1a1a
3
+ metadata.gz: d8ff8a33cfaa4b9554bb3658995a2ed99d1715cb2365d3da012fce5dd2bbd518
4
+ data.tar.gz: fd19b449aa211be4cbec760db28e573064f9112d8c0922bf78517e51c0fc5002
5
5
  SHA512:
6
- metadata.gz: 5a68e9d3b365c52c811c9a81fa7e2b3968de8f52ddfd7ef829ee47232c809b43715297b09d60f7a0d765b4c30fde3b9a0dfe181d4cff6bd50cb7ddd8c724f590
7
- data.tar.gz: 122681c279ef487105deb809c4701c318c9cf7e767e3b4e7c86b630b59246f46093e921ea51fb15f82cb72a888f07ae962420b79fd0521c0440253337e50f940
6
+ metadata.gz: 5a392b8f08c1732f3b661a8758d37d63ac70e2aa7cf39d01dff5521c0e3267b1ee87ed190ea2cdddf914c7bf83462ebb6c7809736b5243610ab6d5e55c411300
7
+ data.tar.gz: 841e2707d2535a628a1cc2c743d594276a1cda0a969dd243ed8d82329f53f5d96bda3433517e40d0e261ef79159a7faae9cb57ebb1c5975b6bd2ea00f17d7641
@@ -10,7 +10,7 @@ module Spree
10
10
  # @example Direct usage (typically called by the adapter)
11
11
  # Spree::Events::SubscriberJob.perform_later(
12
12
  # 'MySubscriber',
13
- # { name: 'order.complete', payload: {...}, ... }
13
+ # { name: 'order.completed', payload: {...}, ... }
14
14
  # )
15
15
  #
16
16
  class SubscriberJob < Spree::BaseJob
@@ -0,0 +1,45 @@
1
+ module Spree
2
+ module Imports
3
+ class ProcessGroupJob < Spree::BaseJob
4
+ queue_as Spree.queues.imports
5
+
6
+ def perform(import_id, row_ids)
7
+ import = Spree::Import.find(import_id)
8
+ Spree::Current.store = import.store
9
+
10
+ mappings = import.mappings.mapped.to_a
11
+ schema_fields = import.schema_fields
12
+ large = import.large_import?
13
+ rows = import.rows.where(id: row_ids).order(:row_number)
14
+
15
+ if large
16
+ Spree::Events.disable do
17
+ rows.each { |row| row.bulk_process!(mappings: mappings, schema_fields: schema_fields) }
18
+ end
19
+ else
20
+ rows.each do |row|
21
+ row.process!(mappings: mappings, schema_fields: schema_fields)
22
+ end
23
+ end
24
+
25
+ check_import_completion(import, large)
26
+ end
27
+
28
+ private
29
+
30
+ def check_import_completion(import, large)
31
+ Spree::Import.where(id: import.id).update_all(
32
+ 'completed_groups_count = COALESCE(completed_groups_count, 0) + 1'
33
+ )
34
+ import.reload
35
+
36
+ if import.completed_groups_count >= import.processing_groups_count
37
+ # Guard against concurrent workers both reaching this point
38
+ import.complete! if import.status == 'processing'
39
+ elsif large && (import.completed_groups_count % 10).zero?
40
+ import.publish_event('import.progress')
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -3,16 +3,62 @@ module Spree
3
3
  class ProcessRowsJob < Spree::BaseJob
4
4
  queue_as Spree.queues.imports
5
5
 
6
+ BATCH_SIZE = 100
7
+
6
8
  def perform(import_id)
7
9
  import = Spree::Import.find(import_id)
10
+ dispatch_groups(import)
11
+ end
12
+
13
+ private
14
+
15
+ def dispatch_groups(import)
16
+ group_field = import.group_column
17
+ group_mapping = group_field && import.mappings.mapped.find_by(schema_field: group_field)
18
+ file_column = group_mapping&.file_column
8
19
 
9
- # process all rows in sequential order
10
- import.rows.pending_and_failed.find_each(batch_size: 100) do |row|
11
- row.process!
20
+ if file_column
21
+ dispatch_grouped(import, file_column)
22
+ else
23
+ dispatch_batched(import)
12
24
  end
25
+ end
26
+
27
+ def dispatch_grouped(import, file_column)
28
+ groups = Hash.new { |h, k| h[k] = [] }
29
+
30
+ import.rows.pending_and_failed.order(:row_number).pluck(:id, :data).each do |id, data|
31
+ parsed = JSON.parse(data)
32
+ key = parsed[file_column].to_s.strip.downcase.presence || '__ungrouped__'
33
+ groups[key] << id
34
+ rescue JSON::ParserError
35
+ groups['__ungrouped__'] << id
36
+ end
37
+
38
+ # Set count before enqueuing so workers can't complete prematurely
39
+ import.update_columns(
40
+ processing_groups_count: groups.size,
41
+ completed_groups_count: 0,
42
+ updated_at: Time.current
43
+ )
44
+
45
+ groups.each_value { |row_ids| ProcessGroupJob.perform_later(import.id, row_ids) }
46
+ end
47
+
48
+ def dispatch_batched(import)
49
+ # Count first, then enqueue — prevents premature completion
50
+ row_id_batches = import.rows.pending_and_failed.order(:row_number)
51
+ .pluck(:id)
52
+ .each_slice(BATCH_SIZE)
53
+ .to_a
54
+
55
+ import.update_columns(
56
+ processing_groups_count: row_id_batches.size,
57
+ completed_groups_count: 0,
58
+ updated_at: Time.current
59
+ )
13
60
 
14
- # mark as complete
15
- import.complete!
61
+ row_id_batches.each { |row_ids| ProcessGroupJob.perform_later(import.id, row_ids) }
16
62
  end
17
63
  end
18
64
  end
@@ -2,6 +2,10 @@ module Spree
2
2
  module PaymentSourceConcern
3
3
  extend ActiveSupport::Concern
4
4
 
5
+ included do
6
+ before_destroy :cleanup_payments_on_incomplete_orders
7
+ end
8
+
5
9
  # Available actions for the payment source.
6
10
  # @return [Array<String>]
7
11
  def actions
@@ -35,5 +39,22 @@ module Spree
35
39
  def has_payment_profile?
36
40
  gateway_customer_profile_id.present? || gateway_payment_profile_id.present?
37
41
  end
42
+
43
+ private
44
+
45
+ # Cleans up payments on incomplete orders before the source is destroyed.
46
+ # Invalidates checkout-state payments and voids non-checkout payments.
47
+ # Skips payments whose payment_method was already nullified (e.g. when
48
+ # the payment method itself is being destroyed).
49
+ def cleanup_payments_on_incomplete_orders
50
+ incomplete_payments = Spree::Payment.valid
51
+ .where(source: self)
52
+ .where.not(payment_method_id: nil)
53
+ .joins(:order)
54
+ .merge(Spree::Order.incomplete)
55
+
56
+ incomplete_payments.checkout.each(&:invalidate!)
57
+ incomplete_payments.where.not(state: :checkout).each(&:void!)
58
+ end
38
59
  end
39
60
  end
@@ -122,7 +122,7 @@ module Spree
122
122
 
123
123
  # Publish an event with this model's data
124
124
  #
125
- # @param event_name [String] The event name (e.g., 'order.complete')
125
+ # @param event_name [String] The event name (e.g., 'order.completed')
126
126
  # @param payload [Hash, nil] Custom payload (defaults to event_payload)
127
127
  # @param metadata [Hash] Additional metadata
128
128
  # @return [Spree::Event] The published event
@@ -79,7 +79,7 @@ module Spree
79
79
  end
80
80
  end
81
81
 
82
- delegate :name, :iso3, :iso, :iso_name, to: :country, prefix: true
82
+ delegate :name, :iso3, :iso, :iso_name, to: :country, prefix: true, allow_nil: true
83
83
  delegate :abbr, to: :state, prefix: true, allow_nil: true
84
84
 
85
85
  alias_attribute :postal_code, :zipcode
@@ -153,31 +153,31 @@ module Spree
153
153
  end
154
154
 
155
155
  def increment_viewable_media_count
156
- case viewable_type
157
- when 'Spree::Variant'
156
+ case viewable
157
+ when Spree::Variant
158
158
  Spree::Variant.increment_counter(:media_count, viewable_id)
159
159
  Spree::Product.increment_counter(:media_count, viewable.product_id)
160
- when 'Spree::Product'
160
+ when Spree::Product
161
161
  Spree::Product.increment_counter(:media_count, viewable_id)
162
162
  end
163
163
  end
164
164
 
165
165
  def decrement_viewable_media_count
166
- case viewable_type
167
- when 'Spree::Variant'
166
+ case viewable
167
+ when Spree::Variant
168
168
  Spree::Variant.decrement_counter(:media_count, viewable_id)
169
169
  Spree::Product.decrement_counter(:media_count, viewable.product_id)
170
- when 'Spree::Product'
170
+ when Spree::Product
171
171
  Spree::Product.decrement_counter(:media_count, viewable_id)
172
172
  end
173
173
  end
174
174
 
175
175
  def update_viewable_thumbnail
176
- case viewable_type
177
- when 'Spree::Variant'
176
+ case viewable
177
+ when Spree::Variant
178
178
  viewable.update_thumbnail!
179
179
  viewable.product.update_thumbnail!
180
- when 'Spree::Product'
180
+ when Spree::Product
181
181
  viewable.update_thumbnail!
182
182
  end
183
183
  end
@@ -6,20 +6,20 @@ module Spree
6
6
  # Events are immutable objects that carry information about something
7
7
  # that happened in the system. They contain:
8
8
  # - An id (UUID)
9
- # - A name (e.g., 'order.complete', 'product.create')
9
+ # - A name (e.g., 'order.completed', 'product.created')
10
10
  # - A store_id (the store where the event originated)
11
11
  # - A payload (serialized data about the event)
12
12
  # - Metadata (contextual information like spree_version)
13
13
  #
14
14
  # @example Creating an event
15
15
  # event = Spree::Event.new(
16
- # name: 'order.complete',
16
+ # name: 'order.completed',
17
17
  # payload: order.serializable_hash
18
18
  # )
19
19
  #
20
20
  # @example Accessing event data
21
21
  # event.id # => "550e8400-e29b-41d4-a716-446655440000"
22
- # event.name # => 'order.complete'
22
+ # event.name # => 'order.completed'
23
23
  # event.store_id # => 1
24
24
  # event.payload # => { 'id' => 1, 'number' => 'R123456' }
25
25
  # event.created_at # => 2024-01-15 10:30:00 UTC
@@ -61,19 +61,19 @@ module Spree
61
61
  end
62
62
 
63
63
  # Returns the resource type from the event name
64
- # @return [String] The resource type (e.g., 'order' from 'order.complete')
64
+ # @return [String] The resource type (e.g., 'order' from 'order.completed')
65
65
  def resource_type
66
66
  @resource_type ||= name.to_s.split('.').first
67
67
  end
68
68
 
69
69
  # Returns the action from the event name
70
- # @return [String] The action (e.g., 'complete' from 'order.complete')
70
+ # @return [String] The action (e.g., 'completed' from 'order.completed')
71
71
  def action
72
72
  @action ||= name.to_s.split('.').drop(1).join('.')
73
73
  end
74
74
 
75
75
  # Checks if the event matches a pattern
76
- # Supports wildcards: 'order.*' matches 'order.complete', 'order.cancel'
76
+ # Supports wildcards: 'order.*' matches 'order.completed', 'order.canceled'
77
77
  # @param pattern [String] The pattern to match against
78
78
  # @return [Boolean]
79
79
  def matches?(pattern)
@@ -83,10 +83,10 @@ module Spree
83
83
  batch.each do |record|
84
84
  if multi_line_csv?
85
85
  record.to_csv(store).each do |line|
86
- csv << line
86
+ csv << Spree::CSV::FormulaSanitizer.row(line)
87
87
  end
88
88
  else
89
- csv << record.to_csv(store)
89
+ csv << Spree::CSV::FormulaSanitizer.row(record.to_csv(store))
90
90
  end
91
91
  end
92
92
  end
@@ -30,7 +30,7 @@ module Spree
30
30
  records_to_export.includes(scope_includes).find_in_batches do |batch|
31
31
  batch.each do |product|
32
32
  product.to_translation_csv(store, locales).each do |line|
33
- csv << line
33
+ csv << Spree::CSV::FormulaSanitizer.row(line)
34
34
  end
35
35
  end
36
36
  end
@@ -32,7 +32,7 @@ module Spree
32
32
  def authorize(_money, credit_card, _options = {})
33
33
  profile_id = credit_card.gateway_customer_profile_id
34
34
  if VALID_CCS.include?(credit_card.number) || (profile_id&.starts_with?('BGS-'))
35
- Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: '12345', avs_result: { code: 'D' })
35
+ Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: generate_authorization, avs_result: { code: 'D' })
36
36
  else
37
37
  Spree::PaymentResponse.new(false, 'Bogus Gateway: Forced failure', { message: 'Bogus Gateway: Forced failure' }, test: true)
38
38
  end
@@ -41,18 +41,18 @@ module Spree
41
41
  def purchase(_money, credit_card, _options = {})
42
42
  profile_id = credit_card.gateway_customer_profile_id
43
43
  if VALID_CCS.include?(credit_card.number) || (profile_id&.starts_with?('BGS-'))
44
- Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: '12345', avs_result: { code: 'M' })
44
+ Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: generate_authorization, avs_result: { code: 'M' })
45
45
  else
46
46
  Spree::PaymentResponse.new(false, 'Bogus Gateway: Forced failure', message: 'Bogus Gateway: Forced failure', test: true)
47
47
  end
48
48
  end
49
49
 
50
50
  def credit(_money, _credit_card, _response_code, _options = {})
51
- Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: '12345')
51
+ Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: generate_authorization)
52
52
  end
53
53
 
54
54
  def capture(_money, authorization, _gateway_options)
55
- if authorization == '12345'
55
+ if authorization&.start_with?('BGS-')
56
56
  Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true)
57
57
  else
58
58
  Spree::PaymentResponse.new(false, 'Bogus Gateway: Forced failure', error: 'Bogus Gateway: Forced failure', test: true)
@@ -60,11 +60,11 @@ module Spree
60
60
  end
61
61
 
62
62
  def void(_response_code, _credit_card, _options = {})
63
- Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: 'void-12345')
63
+ Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: "void-#{generate_authorization}")
64
64
  end
65
65
 
66
66
  def cancel(_response_code, _payment = nil)
67
- Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: '12345')
67
+ Spree::PaymentResponse.new(true, 'Bogus Gateway: Forced success', {}, test: true, authorization: generate_authorization)
68
68
  end
69
69
 
70
70
  def test?
@@ -120,9 +120,14 @@ module Spree
120
120
  true
121
121
  end
122
122
 
123
+ def payment_setup_session_class
124
+ PaymentSetupSessions::Bogus
125
+ end
126
+
123
127
  def create_payment_setup_session(customer:, external_data: {})
124
- payment_setup_sessions.create(
128
+ payment_setup_session_class.create(
125
129
  customer: customer,
130
+ payment_method: self,
126
131
  status: 'pending',
127
132
  external_id: "bogus_seti_#{SecureRandom.hex(12)}",
128
133
  external_client_secret: "bogus_seti_secret_#{SecureRandom.hex(8)}",
@@ -150,6 +155,10 @@ module Spree
150
155
 
151
156
  private
152
157
 
158
+ def generate_authorization
159
+ "BGS-#{SecureRandom.hex(6)}"
160
+ end
161
+
153
162
  def generate_profile_id(success)
154
163
  record = true
155
164
  prefix = success ? 'BGS' : 'FAIL'
@@ -76,6 +76,21 @@ module Spree
76
76
  #
77
77
  preference :delimiter, :string, default: ','
78
78
 
79
+ # Returns true if the import has more rows than the large import threshold.
80
+ # Large imports skip per-row UI broadcasts and use bulk processing.
81
+ # @return [Boolean]
82
+ def large_import?
83
+ rows_count >= Spree::Config[:large_import_threshold]
84
+ end
85
+
86
+ # Returns the schema field name used to group rows for parallel processing.
87
+ # Rows sharing the same value in this field are processed together in one job.
88
+ # Returns nil for imports where rows are independent (default — batched in chunks).
89
+ # @return [String, nil]
90
+ def group_column
91
+ nil
92
+ end
93
+
79
94
  # Returns true if the import is in mapping state
80
95
  # @return [Boolean]
81
96
  def mapping?
@@ -77,9 +77,9 @@ module Spree
77
77
  data_json[mapping.file_column]
78
78
  end
79
79
 
80
- def process!
80
+ def process!(mappings: nil, schema_fields: nil)
81
81
  start_processing!
82
- self.item = import.row_processor_class.new(self).process!
82
+ self.item = import.row_processor_class.new(self, mappings: mappings, schema_fields: schema_fields).process!
83
83
  complete!
84
84
  rescue StandardError => e
85
85
  Rails.error.report(e, handled: true, context: { import_row_id: id }, source: 'spree.core')
@@ -87,6 +87,18 @@ module Spree
87
87
  fail!
88
88
  end
89
89
 
90
+ # Bulk processing mode for large imports.
91
+ # Uses update_columns to skip callbacks, validations, and event publishing.
92
+ def bulk_process!(mappings:, schema_fields:)
93
+ update_columns(status: 'processing', updated_at: Time.current)
94
+ processor = import.row_processor_class.new(self, mappings: mappings, schema_fields: schema_fields)
95
+ self.item = processor.process!
96
+ update_columns(status: 'completed', item_type: item.class.name, item_id: item.id, updated_at: Time.current)
97
+ rescue StandardError => e
98
+ Rails.error.report(e, handled: true, context: { import_row_id: id }, source: 'spree.core')
99
+ update_columns(status: 'failed', validation_errors: e.message, updated_at: Time.current)
100
+ end
101
+
90
102
  def publish_import_row_completed_event
91
103
  publish_event('import_row.completed')
92
104
  end
@@ -5,6 +5,10 @@ module Spree
5
5
  Spree::Imports::RowProcessors::ProductTranslation
6
6
  end
7
7
 
8
+ def group_column
9
+ 'slug'
10
+ end
11
+
8
12
  def model_class
9
13
  Spree::Product
10
14
  end
@@ -4,6 +4,11 @@ module Spree
4
4
  def row_processor_class
5
5
  Spree::Imports::RowProcessors::ProductVariant
6
6
  end
7
+
8
+ # Group by slug: product row + its variant rows must be processed together
9
+ def group_column
10
+ 'slug'
11
+ end
7
12
  end
8
13
  end
9
14
  end
@@ -293,7 +293,7 @@ module Spree
293
293
  end
294
294
 
295
295
  def verify_order_inventory_before_destroy
296
- Spree::OrderInventory.new(order, self).verify(target_shipment)
296
+ Spree::OrderInventory.new(order, self).verify(target_shipment, removing: true)
297
297
  end
298
298
 
299
299
  def update_adjustments
@@ -28,6 +28,7 @@ module Spree
28
28
  # Callbacks
29
29
  #
30
30
  before_save :ensure_single_default
31
+ before_destroy :ensure_can_be_deleted
31
32
 
32
33
  #
33
34
  # Scopes
@@ -80,12 +81,36 @@ module Spree
80
81
  @supported_locales_list ||= (supported_locales.to_s.split(',').map(&:strip) << default_locale).compact.uniq.sort
81
82
  end
82
83
 
84
+ # Returns true when the market is safe to delete. A market cannot be deleted
85
+ # if it is the default market or the only market in the store, since
86
+ # Spree::Current.currency would have no fallback.
87
+ #
88
+ # @return [Boolean]
89
+ def can_be_deleted?
90
+ !default? && !last_in_store?
91
+ end
92
+
83
93
  private
84
94
 
95
+ def last_in_store?
96
+ !self.class.where(store_id: store_id).where.not(id: id).exists?
97
+ end
98
+
85
99
  def ensure_single_default
86
100
  return unless default? && default_changed?
87
101
 
88
102
  self.class.where(store_id: store_id, default: true).where.not(id: id).update_all(default: false)
89
103
  end
104
+
105
+ def ensure_can_be_deleted
106
+ return if can_be_deleted?
107
+
108
+ if default?
109
+ errors.add(:base, :cannot_destroy_default_market)
110
+ else
111
+ errors.add(:base, :cannot_destroy_last_market)
112
+ end
113
+ throw(:abort)
114
+ end
90
115
  end
91
116
  end
@@ -17,13 +17,23 @@ module Spree
17
17
  # In case shipment is passed the stock location should only unstock or
18
18
  # restock items if the order is completed. That is so because stock items
19
19
  # are always unstocked when the order is completed through +shipment.finalize+
20
- def verify(shipment = nil, is_updated: false)
20
+ def verify(shipment = nil, is_updated: false, removing: false)
21
21
  return unless order.completed? || shipment.present?
22
22
 
23
23
  units_count = inventory_units.reload.sum(&:quantity)
24
24
  line_item_changed = is_updated ? !line_item.saved_changes? : !line_item.changed?
25
25
 
26
- if units_count < line_item.quantity
26
+ if removing
27
+ # When the line item is being destroyed, only remove existing inventory.
28
+ # Adding here would create units that the LineItem `dependent: :destroy`
29
+ # cascade can't see (set_up_inventory writes through shipment.inventory_units,
30
+ # leaving line_item.inventory_units stale), producing an orphaned unit.
31
+ #
32
+ # Bypass `remove` because it routes through `set_quantity_to_remove` which
33
+ # assumes a quantity-change scenario; here we want to drain everything tied
34
+ # to this line item regardless of `line_item.quantity`.
35
+ remove_all_units(units_count, shipment) if units_count.positive?
36
+ elsif units_count < line_item.quantity
27
37
  quantity = line_item.quantity - units_count
28
38
 
29
39
  shipment ||= determine_target_shipment
@@ -49,6 +59,18 @@ module Spree
49
59
  end
50
60
  end
51
61
 
62
+ def remove_all_units(quantity, target_shipment = nil)
63
+ if target_shipment.present?
64
+ remove_from_shipment(target_shipment, quantity)
65
+ else
66
+ order.shipments.each do |shipment|
67
+ break if quantity.zero?
68
+
69
+ quantity -= remove_from_shipment(shipment, quantity)
70
+ end
71
+ end
72
+ end
73
+
52
74
  def set_quantity_to_remove(units_count)
53
75
  if (units_count - line_item.quantity).zero?
54
76
  line_item.quantity
@@ -42,6 +42,7 @@ module Spree
42
42
 
43
43
  validates :payment_method, presence: true
44
44
  validates :source, presence: true, if: :source_required?
45
+ validates :response_code, uniqueness: { scope: [:order_id, :payment_method_id] }, allow_nil: true
45
46
  validate :payment_method_available_for_order, on: :create
46
47
 
47
48
  before_validation :validate_source
@@ -92,6 +92,11 @@ module Spree
92
92
  p.amount = amount
93
93
  p.skip_source_requirement = true
94
94
  end
95
+ rescue ActiveRecord::RecordNotUnique
96
+ order.payments.find_by!(
97
+ payment_method: payment_method,
98
+ response_code: external_id
99
+ )
95
100
  end
96
101
 
97
102
  private
@@ -0,0 +1,4 @@
1
+ module Spree
2
+ class PaymentSetupSessions::Bogus < PaymentSetupSession
3
+ end
4
+ end
@@ -249,16 +249,11 @@ module Spree
249
249
 
250
250
  products_to_auto_match_ids = store.products.not_deleted.not_archived.where(id: product_ids).ids
251
251
 
252
- # for ActiveJob 7.1+
253
- if ActiveJob.respond_to?(:perform_all_later)
254
- auto_match_taxons_jobs = products_to_auto_match_ids.map do |product_id|
255
- Spree::Products::AutoMatchTaxonsJob.new(product_id)
256
- end
257
-
258
- ActiveJob.perform_all_later(auto_match_taxons_jobs)
259
- else
260
- products_to_auto_match_ids.each { |product_id| Spree::Products::AutoMatchTaxonsJob.perform_later(product_id) }
252
+ auto_match_taxons_jobs = products_to_auto_match_ids.map do |product_id|
253
+ Spree::Products::AutoMatchTaxonsJob.new(product_id).tap { |job| job.scheduled_at = 30.seconds.from_now }
261
254
  end
255
+
256
+ ActiveJob.perform_all_later(auto_match_taxons_jobs)
262
257
  end
263
258
 
264
259
  # Can't use short form block syntax due to https://github.com/Netflix/fast_jsonapi/issues/259
@@ -582,7 +577,7 @@ module Spree
582
577
  store = stores.find_by(default: true) || stores.first
583
578
  return if store.nil? || store.taxons.automatic.none?
584
579
 
585
- Spree::Products::AutoMatchTaxonsJob.perform_later(id)
580
+ Spree::Products::AutoMatchTaxonsJob.set(wait: 30.seconds).perform_later(id)
586
581
  end
587
582
 
588
583
  def to_csv(store = nil)
@@ -13,7 +13,7 @@ module Spree
13
13
  @store = store
14
14
  end
15
15
 
16
- # Search, filter, and return facets in one call.
16
+ # Search and paginate products. Does NOT compute filter facets use #filters for that.
17
17
  #
18
18
  # @param scope [ActiveRecord::Relation] base scope (store-scoped, visibility-filtered, authorized)
19
19
  # @param query [String, nil] text search query
@@ -26,6 +26,18 @@ module Spree
26
26
  raise NotImplementedError
27
27
  end
28
28
 
29
+ # Compute filter facets, sort options, and total count for the given scope.
30
+ # Called by the dedicated filters endpoint — kept separate from search_and_filter
31
+ # to avoid expensive facet queries on every product listing.
32
+ #
33
+ # @param scope [ActiveRecord::Relation] base scope
34
+ # @param query [String, nil] text search query
35
+ # @param filters [Hash] structured filters
36
+ # @return [FiltersResult]
37
+ def filters(scope:, query: nil, filters: {})
38
+ raise NotImplementedError
39
+ end
40
+
29
41
  # Index a product — called after product save. No-op for database provider.
30
42
  #
31
43
  # @param product [Spree::Product] the product to index