spree_core 5.4.0.beta3 → 5.4.0.beta5

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/exports/generate_job.rb +1 -1
  3. data/app/jobs/spree/images/save_from_url_job.rb +47 -23
  4. data/app/jobs/spree/reports/generate_job.rb +1 -1
  5. data/app/models/concerns/spree/product_scopes.rb +78 -42
  6. data/app/models/concerns/spree/user_methods.rb +1 -0
  7. data/app/models/spree/address.rb +0 -14
  8. data/app/models/spree/category.rb +6 -0
  9. data/app/models/spree/country.rb +2 -23
  10. data/app/models/spree/coupon_code.rb +6 -1
  11. data/app/models/spree/current.rb +3 -2
  12. data/app/models/spree/exports/coupon_codes.rb +18 -0
  13. data/app/models/spree/market.rb +8 -0
  14. data/app/models/spree/payment/gateway_options.rb +5 -0
  15. data/app/models/spree/product.rb +7 -34
  16. data/app/models/spree/store.rb +41 -80
  17. data/app/models/spree/taxon.rb +49 -44
  18. data/app/models/spree/variant.rb +1 -1
  19. data/app/models/spree/webhook_endpoint.rb +17 -0
  20. data/app/models/spree/zone.rb +21 -9
  21. data/app/presenters/spree/csv/coupon_code_presenter.rb +31 -0
  22. data/app/services/spree/cart/create.rb +18 -2
  23. data/app/services/spree/cart/upsert_items.rb +80 -0
  24. data/app/services/spree/gift_cards/apply.rb +5 -4
  25. data/app/services/spree/orders/update.rb +121 -0
  26. data/config/locales/en.yml +5 -0
  27. data/lib/spree/core/configuration.rb +1 -0
  28. data/lib/spree/core/controller_helpers/strong_parameters.rb +1 -1
  29. data/lib/spree/core/dependencies.rb +2 -0
  30. data/lib/spree/core/engine.rb +2 -1
  31. data/lib/spree/core/version.rb +1 -1
  32. data/lib/spree/permitted_attributes.rb +3 -5
  33. data/lib/spree/testing_support/factories/store_factory.rb +0 -9
  34. data/lib/spree_core.rb +0 -1
  35. data/lib/tasks/core.rake +0 -28
  36. metadata +23 -20
  37. data/app/models/concerns/spree/stores/socials.rb +0 -72
  38. data/lib/normalize_string.rb +0 -18
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ea9fcfc9e4ac5937296c789f151d5d431b60a2fadb131e5a7bca034cae47b2ab
4
- data.tar.gz: e89e3fd21bf14b0bb0bda0de42c0ecc49d173add3d9ce58c29b3c781eafb55fa
3
+ metadata.gz: a2a4eae0b4e102ba4160e435fe81d656ced27b014ed013670226bb8a522b5996
4
+ data.tar.gz: c8c6737253472a4456f29b06f1ae028b2c4144732fba6dae7d8dd8c5e2b92d8f
5
5
  SHA512:
6
- metadata.gz: b9bd748be9f9b4949d9b71887990fe3ac2377a6f4e7ed3d3060b0be073d6c199bd92a633bb2c62e7b8ee13dc0d7dae07e1bedd3d4d9dc5edebf3e4fc3839cae7
7
- data.tar.gz: 8f8639ca080722372b01f91df71e54e0dc3f8f39d82cdbd1b3ad72103d995dbaf9bc50ef0f2a6032b0d400c2e94387edd2e06f64a52f3932391ed49ee5650014
6
+ metadata.gz: ec9479d52b6bcbd036354511897d0ee360da7784aef051f20affe23991b1eaebe8525b7a0173fbc7cdc1310a977909e4b78a33b0cd677869d579d1666bbada7a
7
+ data.tar.gz: 44b40ddca311ed24ae26cd99b5455a7259a0aa986cb8a9a76598101e4c42ea48042b8f1256365b2734c7e43218337ec7963c78b0bcffc5ab70f62e0f794d4b9e
@@ -4,7 +4,7 @@ module Spree
4
4
  queue_as Spree.queues.exports
5
5
 
6
6
  def perform(export_id)
7
- export = Spree::Export.find(export_id)
7
+ export = Spree::Export.find_by_prefix_id!(export_id)
8
8
  export.generate
9
9
  end
10
10
  end
@@ -1,12 +1,15 @@
1
1
  require 'open-uri'
2
2
  require 'openssl'
3
+ require 'ssrf_filter'
4
+ require 'tempfile'
3
5
 
4
6
  module Spree
5
7
  module Images
6
8
  class SaveFromUrlJob < ::Spree::BaseJob
7
9
  queue_as Spree.queues.images
8
- retry_on ActiveRecord::RecordInvalid, OpenURI::HTTPError, wait: :polynomially_longer, attempts: Spree::Config.images_save_from_url_job_attempts.to_i
10
+ retry_on ActiveRecord::RecordInvalid, wait: :polynomially_longer, attempts: Spree::Config.images_save_from_url_job_attempts.to_i
9
11
  discard_on URI::InvalidURIError
12
+ discard_on SsrfFilter::Error
10
13
 
11
14
  def perform(viewable_id, viewable_type, external_url, external_id = nil, position = nil)
12
15
  viewable = viewable_type.safe_constantize.find(viewable_id)
@@ -29,34 +32,55 @@ module Spree
29
32
  # still trigger save! if position has changed
30
33
  image.save! and return if image_already_saved?(image, external_url)
31
34
 
32
- uri = URI.parse(external_url)
33
- unless %w[http https].include?(uri.scheme)
34
- raise URI::InvalidURIError, "Invalid URL scheme: #{uri.scheme}. Only http and https are allowed."
35
- end
36
-
37
- file = uri.open(
38
- 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
39
- 'Accept' => 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
40
- 'Accept-Language' => 'en-US,en;q=0.9',
41
- 'Accept-Encoding' => 'gzip, deflate, br',
42
- 'Cache-Control' => 'no-cache',
43
- 'Pragma' => 'no-cache',
44
- read_timeout: 60,
45
- ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER,
46
- redirect: true
47
- )
48
- filename = File.basename(uri.path)
49
-
50
- image.attachment.attach(io: file, filename: filename)
51
- image.external_url = external_url
52
- image.external_id = external_id if external_id.present? && image.respond_to?(:external_id)
53
- image.save!
35
+ download_and_attach_image(external_url, image, external_id)
54
36
  rescue ActiveStorage::IntegrityError => e
55
37
  raise e unless Rails.env.test?
56
38
  end
57
39
 
58
40
  private
59
41
 
42
+ def download_and_attach_image(external_url, image, external_id)
43
+ max_size = Spree::Config.max_image_download_size
44
+
45
+ response = SsrfFilter.get(
46
+ external_url,
47
+ headers: {
48
+ 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
49
+ 'Accept' => 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
50
+ 'Accept-Language' => 'en-US,en;q=0.9',
51
+ 'Accept-Encoding' => 'gzip, deflate, br',
52
+ 'Cache-Control' => 'no-cache',
53
+ 'Pragma' => 'no-cache'
54
+ },
55
+ http_options: {
56
+ read_timeout: 60,
57
+ open_timeout: 30
58
+ }
59
+ )
60
+
61
+ body = response.body
62
+ if body.bytesize > max_size
63
+ raise StandardError, "Image file size exceeds the maximum allowed size of #{max_size} bytes"
64
+ end
65
+
66
+ uri = URI.parse(external_url)
67
+ filename = File.basename(uri.path)
68
+ tempfile = Tempfile.new(['spree_image', File.extname(uri.path)], binmode: true)
69
+
70
+ begin
71
+ tempfile.write(body)
72
+ tempfile.rewind
73
+
74
+ image.attachment.attach(io: tempfile, filename: filename)
75
+ image.external_url = external_url
76
+ image.external_id = external_id if external_id.present? && image.respond_to?(:external_id)
77
+ image.save!
78
+ ensure
79
+ tempfile.close
80
+ tempfile.unlink
81
+ end
82
+ end
83
+
60
84
  def image_already_saved?(image, external_url)
61
85
  image.persisted? && image.attachment.attached? && image.external_url.present? && external_url == image.external_url
62
86
  end
@@ -4,7 +4,7 @@ module Spree
4
4
  queue_as Spree.queues.reports
5
5
 
6
6
  def perform(report_id)
7
- report = Spree::Report.find(report_id)
7
+ report = Spree::Report.find_by_prefix_id!(report_id)
8
8
  report.generate
9
9
  end
10
10
  end
@@ -41,32 +41,38 @@ module Spree
41
41
  order(price_table_name => { amount: :desc })
42
42
  end
43
43
 
44
- # Price sorting scopes that use subqueries to get prices across all variants
45
- # These ensure products with only variant prices (no master price) are included in results
44
+ # Price sorting scopes that use a derived table JOIN to get prices across all variants.
45
+ # These ensure products with only variant prices (no master price) are included in results.
46
+ #
47
+ # Uses Arel::Nodes::As for select expressions so that:
48
+ # - PG allows ORDER BY with DISTINCT (expressions must appear in SELECT list)
49
+ # - Mobility's select_for_count can safely call .right on all select_values
46
50
  add_search_scope :ascend_by_price do
47
- price_subquery = Price
48
- .non_zero
49
- .joins(:variant)
50
- .where("#{Variant.table_name}.product_id = #{Product.table_name}.id")
51
- .select('MIN(amount)')
51
+ price_agg_sql = Price.non_zero.joins(:variant)
52
+ .select("#{Variant.table_name}.product_id AS product_id, MIN(#{Price.table_name}.amount) AS agg_price")
53
+ .group("#{Variant.table_name}.product_id")
54
+ .to_sql
52
55
 
53
- price_sort_sql = "COALESCE((#{price_subquery.to_sql}), 999999999)"
56
+ price_expr = Arel.sql('COALESCE(price_agg.agg_price, 999999999)')
54
57
 
55
- select("#{Product.table_name}.*", "#{price_sort_sql} AS min_price").
56
- order(Arel.sql("#{price_sort_sql} ASC"))
58
+ joins("LEFT JOIN (#{price_agg_sql}) AS price_agg ON price_agg.product_id = #{Product.table_name}.id").
59
+ select("#{Product.table_name}.*").
60
+ select(Arel::Nodes::As.new(price_expr, Arel.sql('min_price'))).
61
+ order(price_expr.asc)
57
62
  end
58
63
 
59
64
  add_search_scope :descend_by_price do
60
- price_subquery = Price
61
- .non_zero
62
- .joins(:variant)
63
- .where("#{Variant.table_name}.product_id = #{Product.table_name}.id")
64
- .select('MAX(amount)')
65
+ price_agg_sql = Price.non_zero.joins(:variant)
66
+ .select("#{Variant.table_name}.product_id AS product_id, MAX(#{Price.table_name}.amount) AS agg_price")
67
+ .group("#{Variant.table_name}.product_id")
68
+ .to_sql
65
69
 
66
- price_sort_sql = "COALESCE((#{price_subquery.to_sql}), 0)"
70
+ price_expr = Arel.sql('COALESCE(price_agg.agg_price, 0)')
67
71
 
68
- select("#{Product.table_name}.*", "#{price_sort_sql} AS max_price").
69
- order(Arel.sql("#{price_sort_sql} DESC"))
72
+ joins("LEFT JOIN (#{price_agg_sql}) AS price_agg ON price_agg.product_id = #{Product.table_name}.id").
73
+ select("#{Product.table_name}.*").
74
+ select(Arel::Nodes::As.new(price_expr, Arel.sql('max_price'))).
75
+ order(price_expr.desc)
70
76
  end
71
77
 
72
78
  add_search_scope :price_between do |low, high|
@@ -81,6 +87,25 @@ module Spree
81
87
  where(Price.table_name => { amount: price.. })
82
88
  end
83
89
 
90
+ # Joins spree_variants and spree_stock_items directly (without association
91
+ # aliases) so that the table names stay as-is. This avoids alias conflicts
92
+ # when combined with other scopes (e.g., price sorting) that also join
93
+ # spree_variants through associations which generate aliases.
94
+ def self.join_variants_and_stock_items
95
+ joins("INNER JOIN #{Variant.table_name} ON #{Variant.table_name}.deleted_at IS NULL AND #{Variant.table_name}.product_id = #{Product.table_name}.id").
96
+ joins("LEFT OUTER JOIN #{StockItem.table_name} ON #{StockItem.table_name}.deleted_at IS NULL AND #{StockItem.table_name}.variant_id = #{Variant.table_name}.id")
97
+ end
98
+ private_class_method :join_variants_and_stock_items
99
+
100
+ # Mirrors Spree::Variant.in_stock_or_backorderable logic using raw table
101
+ # names (to pair with join_variants_and_stock_items).
102
+ scope :in_stock_or_backorderable_condition, -> {
103
+ where(
104
+ "#{Variant.table_name}.track_inventory = ? OR #{StockItem.table_name}.count_on_hand > ? OR #{StockItem.table_name}.backorderable = ?",
105
+ false, 0, true
106
+ )
107
+ }
108
+
84
109
  # Can't use add_search_scope for this as it needs a default argument
85
110
  # Ransack calls with '1' to activate, '0' or nil to skip
86
111
  # In Ruby code: in_stock(true) for in-stock, in_stock(false) for out-of-stock
@@ -88,26 +113,34 @@ module Spree
88
113
  if in_stock == '0' || !in_stock
89
114
  all
90
115
  else
91
- joins(:variants_including_master).merge(Spree::Variant.in_stock_or_backorderable)
116
+ join_variants_and_stock_items.in_stock_or_backorderable_condition
92
117
  end
93
118
  end
119
+
120
+ add_search_scope :price_lte do |price|
121
+ where(Price.table_name => { amount: ..price })
122
+ end
123
+
124
+ add_search_scope :price_gte do |price|
125
+ where(Price.table_name => { amount: price.. })
126
+ end
94
127
  search_scopes << :in_stock
95
128
 
96
129
  def self.out_of_stock(out_of_stock = true)
97
130
  if out_of_stock == '0' || !out_of_stock
98
131
  all
99
132
  else
100
- where.not(id: joins(:variants_including_master).merge(Spree::Variant.in_stock_or_backorderable))
133
+ where.not(id: join_variants_and_stock_items.in_stock_or_backorderable_condition)
101
134
  end
102
135
  end
103
136
  search_scopes << :out_of_stock
104
137
 
105
138
  add_search_scope :backorderable do
106
- joins(:variants_including_master).merge(Spree::Variant.backorderable)
139
+ join_variants_and_stock_items.where(StockItem.table_name => { backorderable: true })
107
140
  end
108
141
 
109
142
  add_search_scope :in_stock_or_backorderable do
110
- joins(:variants_including_master).merge(Spree::Variant.in_stock_or_backorderable)
143
+ join_variants_and_stock_items.in_stock_or_backorderable_condition
111
144
  end
112
145
 
113
146
  # This scope selects products in taxon AND all its descendants
@@ -132,6 +165,11 @@ module Spree
132
165
  where("#{Classification.table_name}.taxon_id" => taxon.cached_self_and_descendants_ids).distinct
133
166
  end
134
167
 
168
+ # Alias for in_taxon — public API name
169
+ add_search_scope :in_category do |category|
170
+ in_taxon(category)
171
+ end
172
+
135
173
  # This scope selects products in all taxons AND all its descendants
136
174
  # If you need products only within one taxon use
137
175
  #
@@ -196,9 +234,7 @@ module Spree
196
234
 
197
235
  return none if actual_ids.empty?
198
236
 
199
- group("#{Spree::Product.table_name}.id").
200
- joins(variants: :option_values).
201
- where(Spree::OptionValue.table_name => { id: actual_ids })
237
+ joins(variants: :option_values).where(Spree::OptionValue.table_name => { id: actual_ids })
202
238
  end
203
239
 
204
240
  # Finds all products which have an option value with the name matching the one given
@@ -260,7 +296,7 @@ module Spree
260
296
 
261
297
  def self.not_discontinued(only_not_discontinued = true)
262
298
  if only_not_discontinued != '0' && only_not_discontinued
263
- where(discontinue_on: [nil, Time.current..])
299
+ where(discontinue_on: [nil, Time.current.beginning_of_minute..])
264
300
  else
265
301
  all
266
302
  end
@@ -278,7 +314,10 @@ module Spree
278
314
  # Can't use add_search_scope for this as it needs a default argument
279
315
  def self.available(available_on = nil, currency = nil)
280
316
  scope = not_discontinued.where(status: 'active')
281
- scope = scope.where("#{Product.quoted_table_name}.available_on <= ?", available_on) if available_on
317
+ if available_on
318
+ available_on = available_on.beginning_of_minute if available_on.respond_to?(:beginning_of_minute)
319
+ scope = scope.where("#{Product.quoted_table_name}.available_on <= ?", available_on)
320
+ end
282
321
 
283
322
  unless Spree::Config.show_products_without_price
284
323
  currency ||= Spree::Store.default.default_currency
@@ -314,25 +353,22 @@ module Spree
314
353
  end
315
354
 
316
355
  # Orders products by best selling based on units_sold_count and revenue
317
- # stored in spree_products_stores table.
356
+ # from spree_products_stores (already joined via store.products).
318
357
  #
319
- # These metrics are updated asynchronously when orders are completed
320
- # via the ProductMetricsSubscriber.
321
- #
322
- # @param order_direction [Symbol] :desc (default) or :asc
323
- # @return [ActiveRecord::Relation]
358
+ # Uses Arel::Nodes::As so that ORDER BY expressions appear in SELECT
359
+ # and work with DISTINCT (same pattern as the price sorting scopes).
324
360
  add_search_scope :by_best_selling do |order_direction = :desc|
325
- store_id = Spree::Current.store&.id
326
- sp_table = StoreProduct.arel_table
327
- products_table = Product.arel_table
328
-
329
- conditions = sp_table[:product_id].eq(products_table[:id]).and(sp_table[:store_id].eq(store_id))
330
-
331
- units_sold = Arel::Nodes::NamedFunction.new('COALESCE', [sp_table.project(sp_table[:units_sold_count]).where(conditions), 0])
332
- revenue = Arel::Nodes::NamedFunction.new('COALESCE', [sp_table.project(sp_table[:revenue]).where(conditions), 0])
361
+ sp_table = StoreProduct.table_name
362
+ units_expr = Arel.sql("COALESCE(#{sp_table}.units_sold_count, 0)")
363
+ revenue_expr = Arel.sql("COALESCE(#{sp_table}.revenue, 0)")
333
364
 
334
365
  order_dir = order_direction == :desc ? :desc : :asc
335
- order(units_sold.send(order_dir)).order(revenue.send(order_dir))
366
+
367
+ select("#{Product.table_name}.*").
368
+ select(Arel::Nodes::As.new(units_expr, Arel.sql('best_selling_units'))).
369
+ select(Arel::Nodes::As.new(revenue_expr, Arel.sql('best_selling_revenue'))).
370
+ order(units_expr.send(order_dir)).
371
+ order(revenue_expr.send(order_dir))
336
372
  end
337
373
 
338
374
  # .search_by_name
@@ -4,6 +4,7 @@ module Spree
4
4
 
5
5
  include Spree::PrefixedId
6
6
  include Spree::Metafields
7
+ include Spree::Metadata
7
8
  include Spree::UserPaymentSource
8
9
  include Spree::UserReporting
9
10
  include Spree::UserRoles
@@ -27,8 +27,6 @@ module Spree
27
27
  # those attributes depending of the logic of their applications
28
28
  ADDRESS_FIELDS = %w(firstname lastname company address1 address2 city state zipcode country phone)
29
29
  EXCLUDED_KEYS_FOR_COMPARISON = %w(id updated_at created_at deleted_at label user_id public_metadata private_metadata)
30
- FIELDS_TO_NORMALIZE = %w(firstname lastname phone alternative_phone company address1 address2 city zipcode)
31
-
32
30
  if defined?(Spree::Security::Addresses)
33
31
  include Spree::Security::Addresses
34
32
  end
@@ -53,7 +51,6 @@ module Spree
53
51
  before_validation :normalize_country
54
52
  before_validation :normalize_state
55
53
  before_validation :clear_invalid_state_entities, if: -> { country.present? }, on: :update
56
- before_validation :remove_emoji_and_normalize
57
54
 
58
55
  after_create :set_user_attributes, if: -> { user.present? }
59
56
 
@@ -294,17 +291,6 @@ module Spree
294
291
  end
295
292
  end
296
293
 
297
- def remove_emoji_and_normalize
298
- attributes_to_normalize = attributes.slice(*FIELDS_TO_NORMALIZE)
299
- normalized_attributes = attributes_to_normalize.compact_blank.deep_transform_values do |value|
300
- NormalizeString.remove_emoji_and_normalize(value.to_s).strip
301
- end
302
-
303
- normalized_attributes.transform_keys! { |key| key.gsub('original_', '') } if defined?(Spree::Security::Addresses)
304
-
305
- assign_attributes(normalized_attributes)
306
- end
307
-
308
294
  def set_user_attributes
309
295
  if user.name.blank?
310
296
  user.first_name = firstname
@@ -0,0 +1,6 @@
1
+ module Spree
2
+ # Public API name for Taxon. Will become the base class in 6.0
3
+ # when spree_taxons table is renamed to spree_categories.
4
+ class Category < Taxon
5
+ end
6
+ end
@@ -38,29 +38,8 @@ module Spree
38
38
  iso.upcase.chars.map { |c| (c.ord + 127397).chr(Encoding::UTF_8) }.join
39
39
  end
40
40
 
41
- # Returns the currency for this country from its market in the current store.
42
- # Looks up which market contains this country and returns that market's currency.
43
- #
44
- # @return [String, nil] currency code (e.g., 'USD', 'EUR') or nil if no market found
45
- def market_currency
46
- current_market&.currency
47
- end
48
-
49
- # Returns the default locale for this country from its market in the current store.
50
- # Looks up which market contains this country and returns that market's default locale.
51
- #
52
- # @return [String, nil] locale code (e.g., 'en', 'de') or nil if no market found
53
- def market_locale
54
- current_market&.default_locale
55
- end
56
-
57
- # Returns the supported locales for this country from its market in the current store.
58
- #
59
- # @return [Array<String>] locale codes (e.g., ['en', 'fr']) or empty array if no market found
60
- def market_supported_locales
61
- current_market&.supported_locales_list || []
62
- end
63
-
41
+ # Lookups Market for Country for the current Store
42
+ # @return [Spree::Market, nil]
64
43
  def current_market
65
44
  @current_market ||= Spree::Current.store&.market_for_country(self)
66
45
  end
@@ -19,7 +19,8 @@ module Spree
19
19
  validates :code, presence: true, uniqueness: { scope: spree_base_uniqueness_scope, conditions: -> { where(deleted_at: nil) } }
20
20
  validates :state, :promotion, presence: true
21
21
 
22
- self.whitelisted_ransackable_attributes = %w[state code]
22
+ self.whitelisted_ransackable_attributes = %w[state code promotion_id]
23
+ self.whitelisted_ransackable_associations = %w[promotion]
23
24
 
24
25
  def self.used?(code)
25
26
  used_with_code(code).any?
@@ -36,5 +37,9 @@ module Spree
36
37
  def display_code
37
38
  code.upcase
38
39
  end
40
+
41
+ def to_csv(_store = nil)
42
+ Spree::CSV::CouponCodePresenter.new(self).call
43
+ end
39
44
  end
40
45
  end
@@ -34,10 +34,11 @@ module Spree
34
34
  super || market&.default_locale || store&.default_locale
35
35
  end
36
36
 
37
- # Returns the current tax zone, falling back to the default tax zone.
37
+ # Returns the current tax zone.
38
+ # Fallback: market's tax zone (from default country) -> global default tax zone.
38
39
  # @return [Spree::Zone, nil]
39
40
  def zone
40
- super || default_tax_zone
41
+ super || market&.tax_zone || default_tax_zone
41
42
  end
42
43
 
43
44
  # Returns the default tax zone (memoized per request).
@@ -0,0 +1,18 @@
1
+ module Spree
2
+ module Exports
3
+ class CouponCodes < Spree::Export
4
+ def csv_headers
5
+ Spree::CSV::CouponCodePresenter::HEADERS
6
+ end
7
+
8
+ def scope_includes
9
+ [:promotion, :order]
10
+ end
11
+
12
+ def scope
13
+ model_class.joins(promotion: :stores).where(spree_stores: { id: store.id })
14
+ .accessible_by(current_ability)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -64,6 +64,14 @@ module Spree
64
64
  countries.order(:name).first
65
65
  end
66
66
 
67
+ # Returns the tax zone matching this market's default country.
68
+ # Used by Spree::Current to determine the browsing tax zone before a customer enters an address.
69
+ #
70
+ # @return [Spree::Zone, nil]
71
+ def tax_zone
72
+ @tax_zone ||= Spree::Zone.match(default_country)
73
+ end
74
+
67
75
  # Returns supported locales as an array, always including default_locale
68
76
  #
69
77
  # @return [Array<String>]
@@ -34,6 +34,10 @@ module Spree
34
34
  payment.number
35
35
  end
36
36
 
37
+ def idempotency_key
38
+ "spree-#{payment.number}"
39
+ end
40
+
37
41
  def shipping
38
42
  order.ship_total * exchange_multiplier
39
43
  end
@@ -66,6 +70,7 @@ module Spree
66
70
  :ip,
67
71
  :order_id,
68
72
  :payment_id,
73
+ :idempotency_key,
69
74
  :shipping,
70
75
  :tax,
71
76
  :subtotal,
@@ -40,9 +40,9 @@ module Spree
40
40
 
41
41
  publishes_lifecycle_events
42
42
 
43
- MEMOIZED_METHODS = %w[total_on_hand taxonomy_ids taxon_and_ancestors category
43
+ MEMOIZED_METHODS = %w[total_on_hand taxonomy_ids taxon_and_ancestors
44
44
  default_variant_id tax_category default_variant variant_for_images
45
- category_taxon brand_taxon main_taxon
45
+ brand_taxon main_taxon
46
46
  purchasable? in_stock? backorderable? digital?]
47
47
 
48
48
  STATUSES = %w[draft active archived].freeze
@@ -74,6 +74,7 @@ module Spree
74
74
  has_many :option_types, through: :product_option_types
75
75
  has_many :classifications, -> { order(created_at: :asc) }, dependent: :delete_all, inverse_of: :product
76
76
  has_many :taxons, through: :classifications, before_remove: :remove_taxon
77
+ has_many :categories, through: :classifications, class_name: 'Spree::Category', source: :taxon
77
78
  has_many :taxonomies, through: :taxons
78
79
 
79
80
  has_many :product_promotion_rules, class_name: 'Spree::ProductPromotionRule'
@@ -217,10 +218,12 @@ module Spree
217
218
  alias options product_option_types
218
219
 
219
220
  self.whitelisted_ransackable_attributes = %w[description name slug discontinue_on status available_on created_at updated_at]
220
- self.whitelisted_ransackable_associations = %w[taxons stores variants_including_master master variants tags labels
221
+ self.whitelisted_ransackable_associations = %w[taxons categories stores variants_including_master master variants tags labels
221
222
  shipping_category classifications option_types]
222
223
  self.whitelisted_ransackable_scopes = %w[not_discontinued search_by_name in_taxon price_between
224
+ price_lte price_gte
223
225
  multi_search in_stock out_of_stock with_option_value_ids
226
+
224
227
  ascend_by_price descend_by_price]
225
228
 
226
229
  [
@@ -554,40 +557,10 @@ module Spree
554
557
  brand&.name
555
558
  end
556
559
 
557
- # Returns the category for the product
558
- # If a category association is defined (e.g., belongs_to :category), it will be used
559
- # Otherwise, falls back to category_taxon for compatibility
560
- # @return [Spree::Category, Spree::Taxon]
561
- def category
562
- if self.class.reflect_on_association(:category)
563
- super
564
- else
565
- Spree::Deprecation.warn('Spree::Product#category is deprecated and will be removed in Spree 5.5. Please use Spree::Product#category_taxon instead.')
566
- category_taxon
567
- end
568
- end
569
-
570
- # Returns the category taxon for the product
571
- # @return [Spree::Taxon]
572
- def category_taxon
573
- @category_taxon ||= if classification_count.zero?
574
- nil
575
- elsif Spree.use_translations?
576
- taxons.joins(:taxonomy).
577
- join_translation_table(Taxonomy).
578
- order(depth: :desc).
579
- find_by(Taxonomy.translation_table_alias => { name: Spree.t(:taxonomy_categories_name) })
580
- elsif taxons.loaded?
581
- taxons.find { |taxon| taxon.taxonomy.name == Spree.t(:taxonomy_categories_name) }
582
- else
583
- taxons.joins(:taxonomy).order(depth: :desc).find_by(Taxonomy.table_name => { name: Spree.t(:taxonomy_categories_name) })
584
- end
585
- end
586
-
587
560
  def main_taxon
588
561
  return if classification_count.zero?
589
562
 
590
- @main_taxon ||= category_taxon || taxons.first
563
+ @main_taxon ||= taxons.first
591
564
  end
592
565
 
593
566
  def taxons_for_store(store)