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
@@ -1,15 +1,16 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'uri'
2
4
 
3
5
  module Spree
4
6
  class Store < Spree.base_class
5
- has_prefix_id :store # Spree-specific: store
7
+ has_prefix_id :store # Spree-specific: store
6
8
 
7
9
  include FriendlyId
8
10
  include Spree::TranslatableResource
9
11
  include Spree::Metafields
10
12
  include Spree::Metadata
11
13
  include Spree::Stores::Setup
12
- include Spree::Stores::Socials
13
14
  include Spree::Stores::Markets
14
15
  include Spree::Security::Stores if defined?(Spree::Security::Stores)
15
16
  include Spree::UserManagement
@@ -23,8 +24,7 @@ module Spree
23
24
  #
24
25
  # Translations
25
26
  #
26
- TRANSLATABLE_FIELDS = %i[name meta_description meta_keywords seo_title facebook
27
- twitter instagram customer_support_email
27
+ TRANSLATABLE_FIELDS = %i[name meta_description meta_keywords seo_title customer_support_email
28
28
  address contact_phone].freeze
29
29
  translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?)
30
30
  self::Translation.class_eval do
@@ -43,9 +43,6 @@ module Spree
43
43
  preference :unit_system, :string, default: 'imperial'
44
44
  # email preferences
45
45
  preference :send_consumer_transactional_emails, :boolean, default: true
46
- # SEO preferences
47
- preference :index_in_search_engines, :boolean, default: false
48
- preference :password_protected, :boolean, default: false
49
46
  # Checkout preferences
50
47
  preference :guest_checkout, :boolean, default: true
51
48
  preference :special_instructions_enabled, :boolean, default: false
@@ -86,6 +83,7 @@ module Spree
86
83
 
87
84
  has_many :taxonomies, class_name: 'Spree::Taxonomy'
88
85
  has_many :taxons, through: :taxonomies, class_name: 'Spree::Taxon'
86
+ has_many :categories, through: :taxonomies, class_name: 'Spree::Category', source: :taxons
89
87
 
90
88
  has_many :store_promotions, class_name: 'Spree::StorePromotion'
91
89
  has_many :promotions, through: :store_promotions, class_name: 'Spree::Promotion'
@@ -111,21 +109,6 @@ module Spree
111
109
 
112
110
  has_many :api_keys, class_name: 'Spree::ApiKey', dependent: :destroy
113
111
 
114
-
115
- #
116
- # ActionText
117
- #
118
- has_rich_text :checkout_message
119
- has_rich_text :customer_terms_of_service
120
- has_rich_text :customer_privacy_policy
121
- has_rich_text :customer_returns_policy
122
- has_rich_text :customer_shipping_policy
123
-
124
- #
125
- # Virtual attributes
126
- #
127
- store_accessor :private_metadata, :storefront_password
128
-
129
112
  #
130
113
  # Validations
131
114
  #
@@ -137,19 +120,17 @@ module Spree
137
120
  validates :mail_from_address, email: { allow_blank: false }
138
121
  # FIXME: we should remove this condition in v5
139
122
  if !ENV['SPREE_DISABLE_DB_CONNECTION'] &&
140
- connected? &&
141
- table_exists? &&
142
- connection.column_exists?(:spree_stores, :new_order_notifications_email)
123
+ connected? &&
124
+ table_exists? &&
125
+ connection.column_exists?(:spree_stores, :new_order_notifications_email)
143
126
  validates :new_order_notifications_email, email: { allow_blank: true }
144
127
  end
145
- validates :favicon_image, :social_image, :mailer_logo, content_type: Rails.application.config.active_storage.web_image_content_types
128
+ validates :mailer_logo, content_type: Rails.application.config.active_storage.web_image_content_types
146
129
 
147
130
  #
148
131
  # Attachments
149
132
  #
150
133
  has_one_attached :logo, service: Spree.public_storage_service_name
151
- has_one_attached :favicon_image, service: Spree.public_storage_service_name
152
- has_one_attached :social_image, service: Spree.public_storage_service_name
153
134
  has_one_attached :mailer_logo, service: Spree.public_storage_service_name
154
135
 
155
136
  #
@@ -157,8 +138,6 @@ module Spree
157
138
  before_validation :set_default_code, on: :create
158
139
  before_save :ensure_default_exists_and_is_unique
159
140
  after_create :ensure_default_market
160
- after_create :ensure_default_taxonomies_are_created
161
- after_create :ensure_default_automatic_taxons
162
141
  after_create :create_default_policies
163
142
 
164
143
  #
@@ -233,16 +212,6 @@ module Spree
233
212
  @default_country_for_market = country
234
213
  end
235
214
 
236
- def seo_meta_description
237
- if meta_description.present?
238
- meta_description
239
- elsif seo_title.present?
240
- seo_title
241
- else
242
- name
243
- end
244
- end
245
-
246
215
  def unique_name
247
216
  @unique_name ||= "#{name} (#{code})"
248
217
  end
@@ -312,18 +281,18 @@ module Spree
312
281
  # @return [ActiveRecord::Relation<Spree::Country>]
313
282
  def countries_with_shipping_coverage
314
283
  zone_ids = Spree::Zone
315
- .joins(:shipping_methods)
316
- .select(:id)
284
+ .joins(:shipping_methods)
285
+ .select(:id)
317
286
 
318
287
  country_zone_country_ids = Spree::ZoneMember
319
- .where(zone_id: zone_ids, zoneable_type: 'Spree::Country')
320
- .select(:zoneable_id)
288
+ .where(zone_id: zone_ids, zoneable_type: 'Spree::Country')
289
+ .select(:zoneable_id)
321
290
 
322
291
  state_zone_country_ids = Spree::State
323
- .where(id: Spree::ZoneMember
324
- .where(zone_id: zone_ids, zoneable_type: 'Spree::State')
325
- .select(:zoneable_id))
326
- .select(:country_id)
292
+ .where(id: Spree::ZoneMember
293
+ .where(zone_id: zone_ids, zoneable_type: 'Spree::State')
294
+ .select(:zoneable_id))
295
+ .select(:country_id)
327
296
 
328
297
  Spree::Country
329
298
  .where(id: country_zone_country_ids)
@@ -337,7 +306,8 @@ module Spree
337
306
  @default_stock_location ||= begin
338
307
  stock_location_scope = Spree::StockLocation.where(default: true)
339
308
  stock_location_scope.first || ActiveRecord::Base.connected_to(role: :writing) do
340
- stock_location_scope.create(default: true, name: Spree.t(:default_stock_location_name), country: default_country)
309
+ stock_location_scope.create(default: true, name: Spree.t(:default_stock_location_name),
310
+ country: default_country)
341
311
  end
342
312
  end
343
313
  end
@@ -348,12 +318,6 @@ module Spree
348
318
  users
349
319
  end
350
320
 
351
- def favicon
352
- return unless favicon_image.attached? && favicon_image.variable?
353
-
354
- favicon_image.variant(resize_to_limit: [32, 32])
355
- end
356
-
357
321
  def metric_unit_system?
358
322
  preferred_unit_system == 'metric'
359
323
  end
@@ -366,14 +330,6 @@ module Spree
366
330
  @digital_shipping_category ||= ShippingCategory.find_or_create_by(name: 'Digital')
367
331
  end
368
332
 
369
- %w[customer_terms_of_service customer_privacy_policy customer_returns_policy customer_shipping_policy].each do |policy_method|
370
- define_method policy_method do
371
- Spree::Deprecation.warn("Store##{policy_method} is deprecated and will be removed in Spree 5.5. Please use Store#policies instead.")
372
-
373
- ActionText::RichText.find_by(name: policy_method, record: self)
374
- end
375
- end
376
-
377
333
  private
378
334
 
379
335
  def ensure_default_market
@@ -395,7 +351,25 @@ module Spree
395
351
  end
396
352
  end
397
353
 
354
+ def create_default_policies
355
+ Spree::Events.disable do
356
+ [
357
+ translate_with_store_locale_fallback('spree.terms_of_service'),
358
+ translate_with_store_locale_fallback('spree.privacy_policy'),
359
+ translate_with_store_locale_fallback('spree.returns_policy'),
360
+ translate_with_store_locale_fallback('spree.shipping_policy')
361
+ ].each do |policy_name|
362
+ # Manual exists?/create to work around Mobility bug with find_or_create_by
363
+ next if policies.with_matching_name(policy_name).exists?
364
+
365
+ policies.create(name: policy_name)
366
+ end
367
+ end
368
+ end
369
+
398
370
  def ensure_default_taxonomies_are_created
371
+ Spree::Deprecation.warn('Store#ensure_default_taxonomies_are_created is deprecated and will be removed in Spree 5.5. Please remove it from your codebase')
372
+
399
373
  Spree::Events.disable do
400
374
  [
401
375
  translate_with_store_locale_fallback('spree.taxonomy_categories_name'),
@@ -411,13 +385,16 @@ module Spree
411
385
  end
412
386
 
413
387
  def ensure_default_automatic_taxons
388
+ Spree::Deprecation.warn('Store#ensure_default_automatic_taxons is deprecated and will be removed in Spree 5.5. Please remove it from your codebase')
389
+
414
390
  Spree::Events.disable do
415
391
  # Use Mobility-safe lookup for taxonomy
416
392
  collections_taxonomy = taxonomies.with_matching_name(translate_with_store_locale_fallback('spree.taxonomy_collections_name')).first
417
393
  return unless collections_taxonomy.present?
418
394
 
419
395
  automatic_taxons_config = [
420
- { name: translate_with_store_locale_fallback('spree.automatic_taxon_names.on_sale'), rule_type: 'Spree::TaxonRules::Sale', rule_value: 'true' },
396
+ { name: translate_with_store_locale_fallback('spree.automatic_taxon_names.on_sale'),
397
+ rule_type: 'Spree::TaxonRules::Sale', rule_value: 'true' },
421
398
  { name: translate_with_store_locale_fallback('spree.automatic_taxon_names.new_arrivals'), rule_type: 'Spree::TaxonRules::AvailableOn', rule_value: 30 }
422
399
  ]
423
400
 
@@ -439,22 +416,6 @@ module Spree
439
416
  end
440
417
  end
441
418
 
442
- def create_default_policies
443
- Spree::Events.disable do
444
- [
445
- translate_with_store_locale_fallback('spree.terms_of_service'),
446
- translate_with_store_locale_fallback('spree.privacy_policy'),
447
- translate_with_store_locale_fallback('spree.returns_policy'),
448
- translate_with_store_locale_fallback('spree.shipping_policy')
449
- ].each do |policy_name|
450
- # Manual exists?/create to work around Mobility bug with find_or_create_by
451
- next if policies.with_matching_name(policy_name).exists?
452
-
453
- policies.create(name: policy_name)
454
- end
455
- end
456
- end
457
-
458
419
  # Translates a key using the store's default locale with fallback to :en
459
420
  def translate_with_store_locale_fallback(key)
460
421
  locale = default_locale.presence&.to_sym || :en
@@ -1,20 +1,22 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'stringex'
2
4
 
3
5
  module Spree
4
6
  class Taxon < Spree.base_class
5
- has_prefix_id :txn # Spree-specific: taxon
7
+ has_prefix_id :ctg
6
8
 
7
9
  RULES_MATCH_POLICIES = %w[all any].freeze
8
- SORT_ORDERS = %w[
9
- manual
10
- best-selling
11
- name-a-z
12
- name-z-a
13
- price-high-to-low
14
- price-low-to-high
15
- newest-first
16
- oldest-first
17
- ]
10
+ SORT_ORDERS = [
11
+ 'manual',
12
+ 'best_selling',
13
+ 'price asc',
14
+ 'price desc',
15
+ 'available_on desc',
16
+ 'available_on asc',
17
+ 'name asc',
18
+ 'name desc'
19
+ ].freeze
18
20
 
19
21
  include Spree::TranslatableResource
20
22
  include Spree::TranslatableResourceSlug
@@ -54,9 +56,9 @@ module Spree
54
56
  #
55
57
  # Validations
56
58
  #
57
- validates :name, presence: true, uniqueness: { scope: [:parent_id, :taxonomy_id], case_sensitive: false }
59
+ validates :name, presence: true, uniqueness: { scope: %i[parent_id taxonomy_id], case_sensitive: false }
58
60
  validates :taxonomy, presence: true
59
- validates :permalink, uniqueness: { case_sensitive: false, scope: [:parent_id, :taxonomy_id] }
61
+ validates :permalink, uniqueness: { case_sensitive: false, scope: %i[parent_id taxonomy_id] }
60
62
  validates :hide_from_nav, inclusion: { in: [true, false] }
61
63
  validate :check_for_root, on: :create
62
64
  validate :parent_belongs_to_same_taxonomy
@@ -88,9 +90,9 @@ module Spree
88
90
  scope :for_stores, ->(stores) { joins(:taxonomy).where(spree_taxonomies: { store_id: stores.ids }) }
89
91
  scope :for_taxonomy, lambda { |taxonomy_name|
90
92
  if Spree.use_translations?
91
- joins(:taxonomy).
92
- join_translation_table(Taxonomy).
93
- where(
93
+ joins(:taxonomy)
94
+ .join_translation_table(Taxonomy)
95
+ .where(
94
96
  Taxonomy.arel_table_alias[:name].lower.matches(taxonomy_name.downcase.strip)
95
97
  )
96
98
  else
@@ -110,7 +112,7 @@ module Spree
110
112
  end
111
113
  end
112
114
 
113
- scope :with_matching_name, ->(name_to_match) do
115
+ scope :with_matching_name, lambda { |name_to_match|
114
116
  value = name_to_match.to_s.strip.downcase
115
117
 
116
118
  if Spree.use_translations?
@@ -118,13 +120,14 @@ module Spree
118
120
  else
119
121
  where(arel_table[:name].lower.eq(value))
120
122
  end
121
- end
123
+ }
122
124
 
123
125
  #
124
126
  # Ransack
125
127
  #
126
- self.whitelisted_ransackable_associations = %w[taxonomy]
127
- self.whitelisted_ransackable_attributes = %w[name permalink automatic]
128
+ self.whitelisted_ransackable_associations = %w[taxonomy parent]
129
+ self.whitelisted_ransackable_attributes = %w[name permalink automatic depth is_root children_count
130
+ classification_count hide_from_nav parent_id]
128
131
 
129
132
  #
130
133
  # Translations
@@ -142,7 +145,9 @@ module Spree
142
145
  validates :sort_order, inclusion: { in: SORT_ORDERS }, presence: true
143
146
 
144
147
  has_many :taxon_rules, class_name: 'Spree::TaxonRule', dependent: :destroy
145
- accepts_nested_attributes_for :taxon_rules, allow_destroy: true, reject_if: proc { |attributes| attributes['value'].blank? }
148
+ accepts_nested_attributes_for :taxon_rules, allow_destroy: true, reject_if: proc { |attributes|
149
+ attributes['value'].blank?
150
+ }
146
151
  alias rules taxon_rules
147
152
 
148
153
  scope :manual, -> { where.not(automatic: true) }
@@ -160,14 +165,14 @@ module Spree
160
165
  end
161
166
 
162
167
  def active_products_with_descendants
163
- @active_products_with_descendants ||= store.products.
164
- joins(:classifications).
165
- active.
166
- where(
167
- Spree::Classification.table_name => {
168
- taxon_id: descendants.ids + [id]
169
- }
170
- )
168
+ @active_products_with_descendants ||= store.products
169
+ .joins(:classifications)
170
+ .active
171
+ .where(
172
+ Spree::Classification.table_name => {
173
+ taxon_id: descendants.ids + [id]
174
+ }
175
+ )
171
176
  end
172
177
 
173
178
  def products_matching_rules(opts = {})
@@ -209,10 +214,10 @@ module Spree
209
214
  # so we can later use them for product filtering and so on
210
215
  # if we want to fire the service once during object lifecycle - pass only_once: true
211
216
  def regenerate_taxon_products(only_once: false)
212
- if marked_for_regenerate_taxon_products?
213
- Spree::Taxons::RegenerateProducts.call(taxon: self)
214
- self.marked_for_regenerate_taxon_products = false if !frozen? && only_once
215
- end
217
+ return unless marked_for_regenerate_taxon_products?
218
+
219
+ Spree::Taxons::RegenerateProducts.call(taxon: self)
220
+ self.marked_for_regenerate_taxon_products = false if !frozen? && only_once
216
221
  end
217
222
 
218
223
  def slug
@@ -283,7 +288,8 @@ module Spree
283
288
  end
284
289
 
285
290
  def generate_permalink_including_parent
286
- [parent_permalink_with_fallback, (permalink.blank? ? name_with_fallback.to_url : permalink.split('/').last.to_url)].join('/')
291
+ [parent_permalink_with_fallback,
292
+ (permalink.blank? ? name_with_fallback.to_url : permalink.split('/').last.to_url)].join('/')
287
293
  end
288
294
 
289
295
  def generate_pretty_name_including_parent
@@ -384,11 +390,10 @@ module Spree
384
390
  end
385
391
 
386
392
  def sync_taxonomy_name
387
- if saved_changes.key?(:name) && root?
388
- return if taxonomy.name.to_s == name.to_s
393
+ return unless saved_changes.key?(:name) && root?
394
+ return if taxonomy.name.to_s == name.to_s
389
395
 
390
- taxonomy.update(name: name)
391
- end
396
+ taxonomy.update(name: name)
392
397
  end
393
398
 
394
399
  def touch_ancestors_and_taxonomy
@@ -399,15 +404,15 @@ module Spree
399
404
  end
400
405
 
401
406
  def check_for_root
402
- if taxonomy.try(:root).present? && parent_id.nil?
403
- errors.add(:root_conflict, 'this taxonomy already has a root taxon')
404
- end
407
+ return unless taxonomy.try(:root).present? && parent_id.nil?
408
+
409
+ errors.add(:root_conflict, 'this taxonomy already has a root taxon')
405
410
  end
406
411
 
407
412
  def parent_belongs_to_same_taxonomy
408
- if parent.present? && parent.taxonomy_id != taxonomy_id
409
- errors.add(:parent, 'must belong to the same taxonomy')
410
- end
413
+ return unless parent.present? && parent.taxonomy_id != taxonomy_id
414
+
415
+ errors.add(:parent, 'must belong to the same taxonomy')
411
416
  end
412
417
 
413
418
  def copy_taxonomy_from_parent
@@ -103,7 +103,7 @@ module Spree
103
103
  scope :not_discontinued, lambda {
104
104
  where(
105
105
  arel_table[:discontinue_on].eq(nil).or(
106
- arel_table[:discontinue_on].gteq(Time.current)
106
+ arel_table[:discontinue_on].gteq(Time.current.beginning_of_minute)
107
107
  )
108
108
  )
109
109
  }
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'ssrf_filter'
4
+ require 'resolv'
5
+
3
6
  module Spree
4
7
  class WebhookEndpoint < Spree.base_class
5
8
  has_prefix_id :whe # Stripe: we_
@@ -8,12 +11,15 @@ module Spree
8
11
 
9
12
  include Spree::SingleStoreResource
10
13
 
14
+ encrypts :secret_key, deterministic: true if Rails.configuration.active_record.encryption.include?(:primary_key)
15
+
11
16
  belongs_to :store, class_name: 'Spree::Store'
12
17
  has_many :webhook_deliveries, class_name: 'Spree::WebhookDelivery', dependent: :destroy_async
13
18
 
14
19
  validates :store, :url, presence: true
15
20
  validates :url, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]), message: :invalid_url }
16
21
  validates :active, inclusion: { in: [true, false] }
22
+ validate :url_must_not_resolve_to_private_ip, if: -> { url.present? && url_changed? }
17
23
 
18
24
  before_create :generate_secret_key
19
25
 
@@ -51,5 +57,16 @@ module Spree
51
57
  def generate_secret_key
52
58
  self.secret_key ||= SecureRandom.hex(32)
53
59
  end
60
+
61
+ def url_must_not_resolve_to_private_ip
62
+ uri = URI.parse(url)
63
+ blacklist = SsrfFilter::IPV4_BLACKLIST + SsrfFilter::IPV6_BLACKLIST
64
+ addresses = Resolv.getaddresses(uri.host)
65
+ if addresses.any? { |addr| blacklist.any? { |range| range.include?(IPAddr.new(addr)) } }
66
+ errors.add(:url, :internal_address_not_allowed)
67
+ end
68
+ rescue URI::InvalidURIError, Resolv::ResolvError, IPAddr::InvalidAddressError, ArgumentError
69
+ # URI format validation handles invalid URLs; DNS failures are not SSRF
70
+ end
54
71
  end
55
72
  end
@@ -52,15 +52,27 @@ module Spree
52
52
 
53
53
  # Returns the matching zone with the highest priority zone type (State, Country, Zone.)
54
54
  # Returns nil in the case of no matches.
55
- def self.match(address)
56
- return unless address &&
57
- matches = includes(:zone_members).
58
- order('spree_zones.zone_members_count', 'spree_zones.created_at').
59
- where("(spree_zone_members.zoneable_type = 'Spree::Country' AND " \
60
- 'spree_zone_members.zoneable_id = ?) OR ' \
61
- "(spree_zone_members.zoneable_type = 'Spree::State' AND " \
62
- 'spree_zone_members.zoneable_id = ?)', address.country_id, address.state_id).
63
- references(:zones)
55
+ # Accepts either an address (with country_id/state_id) or a Spree::Country directly.
56
+ def self.match(address_or_country)
57
+ return unless address_or_country
58
+
59
+ if address_or_country.is_a?(Spree::Country)
60
+ country_id = address_or_country.id
61
+ state_id = nil
62
+ else
63
+ country_id = address_or_country.country_id
64
+ state_id = address_or_country.state_id
65
+ end
66
+
67
+ matches = includes(:zone_members).
68
+ order('spree_zones.zone_members_count', 'spree_zones.created_at').
69
+ where("(spree_zone_members.zoneable_type = 'Spree::Country' AND " \
70
+ 'spree_zone_members.zoneable_id = ?) OR ' \
71
+ "(spree_zone_members.zoneable_type = 'Spree::State' AND " \
72
+ 'spree_zone_members.zoneable_id = ?)', country_id, state_id).
73
+ references(:zones)
74
+
75
+ return if matches.empty?
64
76
 
65
77
  %w[state country].each do |zone_kind|
66
78
  if match = matches.detect { |zone| zone_kind == zone.kind }
@@ -0,0 +1,31 @@
1
+ module Spree
2
+ module CSV
3
+ class CouponCodePresenter
4
+ HEADERS = [
5
+ 'Code',
6
+ 'State',
7
+ 'Promotion Name',
8
+ 'Order Number',
9
+ 'Created At',
10
+ 'Updated At'
11
+ ].freeze
12
+
13
+ def initialize(coupon_code)
14
+ @coupon_code = coupon_code
15
+ end
16
+
17
+ attr_accessor :coupon_code
18
+
19
+ def call
20
+ [
21
+ coupon_code.display_code,
22
+ coupon_code.state,
23
+ coupon_code.promotion&.name,
24
+ coupon_code.order&.number,
25
+ coupon_code.created_at&.strftime('%Y-%m-%d %H:%M:%S'),
26
+ coupon_code.updated_at&.strftime('%Y-%m-%d %H:%M:%S')
27
+ ]
28
+ end
29
+ end
30
+ end
31
+ end
@@ -10,9 +10,11 @@ module Spree
10
10
  # @param public_metadata [Hash] public metadata for the order
11
11
  # @param private_metadata [Hash] private metadata for the order
12
12
  # @param order_params [Hash] additional order attributes
13
+ # @param line_items [Array<Hash>] line items to add, each with :variant_id (prefixed) and :quantity
13
14
  # @return [Spree::ServiceModule::Result]
14
- def call(user:, store:, currency:, locale: nil, metadata: {}, public_metadata: {}, private_metadata: {}, order_params: {})
15
+ def call(user:, store:, currency:, locale: nil, metadata: {}, public_metadata: {}, private_metadata: {}, order_params: {}, line_items: [])
15
16
  order_params ||= {}
17
+ line_items ||= []
16
18
 
17
19
  # we cannot create an order without store
18
20
  return failure(:store_is_required) if store.nil?
@@ -28,8 +30,22 @@ module Spree
28
30
  private_metadata: resolved_metadata.to_h
29
31
  }
30
32
 
31
- order = store.orders.create!(default_params.merge(order_params))
33
+ order = nil
34
+
35
+ ApplicationRecord.transaction do
36
+ order = store.orders.create!(default_params.merge(order_params))
37
+
38
+ if line_items.present?
39
+ result = Spree.cart_upsert_items_service.call(order: order, line_items: line_items)
40
+ raise StandardError, result.error.to_s if result.failure?
41
+ end
42
+ end
43
+
32
44
  success(order)
45
+ rescue ActiveRecord::RecordNotFound
46
+ raise
47
+ rescue StandardError => e
48
+ failure(order, e.message)
33
49
  end
34
50
  end
35
51
  end
@@ -0,0 +1,80 @@
1
+ module Spree
2
+ module Cart
3
+ # Bulk upsert line items on an order.
4
+ #
5
+ # For each entry in +line_items+:
6
+ # - If a line item for the variant already exists → sets its quantity
7
+ # - If no line item exists → creates one with the given quantity
8
+ #
9
+ # After all items are processed the order is recalculated once.
10
+ #
11
+ # Price calculation and tax adjustments are handled by LineItem model callbacks
12
+ # (copy_price, update_adjustments, update_tax_charge), so we only need to
13
+ # save each item and run a single order recalculation at the end.
14
+ #
15
+ # @example
16
+ # Spree::Cart::UpsertItems.call(
17
+ # order: order,
18
+ # line_items: [
19
+ # { variant_id: "variant_k5nR8xLq", quantity: 2 },
20
+ # { variant_id: "variant_m3Rp9wXz", quantity: 1 }
21
+ # ]
22
+ # )
23
+ #
24
+ class UpsertItems
25
+ prepend Spree::ServiceModule::Base
26
+
27
+ def call(order:, line_items:)
28
+ line_items = Array(line_items)
29
+ return success(order) if line_items.empty?
30
+
31
+ store = order.store || Spree::Current.store
32
+
33
+ ApplicationRecord.transaction do
34
+ line_items.each do |item_params|
35
+ item_params = item_params.to_h.deep_symbolize_keys
36
+ variant = resolve_variant(store, item_params[:variant_id])
37
+ next unless variant
38
+
39
+ quantity = (item_params[:quantity] || 1).to_i
40
+
41
+ return failure(variant, "#{variant.name} is not available in #{order.currency}") if variant.amount_in(order.currency).nil?
42
+
43
+ line_item = Spree.line_item_by_variant_finder.new.execute(order: order, variant: variant)
44
+
45
+ if line_item
46
+ line_item.quantity = quantity
47
+ line_item.metadata = line_item.metadata.merge(item_params[:metadata].to_h) if item_params[:metadata].present?
48
+ else
49
+ line_item = order.line_items.new(quantity: quantity, variant: variant, options: { currency: order.currency })
50
+ line_item.metadata = item_params[:metadata].to_h if item_params[:metadata].present?
51
+ end
52
+
53
+ return failure(line_item) unless line_item.save
54
+ end
55
+
56
+ order.update_with_updater!
57
+ end
58
+
59
+ success(order)
60
+ end
61
+
62
+ private
63
+
64
+ def resolve_variant(store, prefixed_id)
65
+ return nil if prefixed_id.blank?
66
+
67
+ variant = store.variants.find_by_prefix_id(prefixed_id)
68
+
69
+ raise ActiveRecord::RecordNotFound.new(
70
+ "Variant '#{prefixed_id}' not found in this store",
71
+ 'Spree::Variant',
72
+ 'prefix_id',
73
+ prefixed_id
74
+ ) unless variant
75
+
76
+ variant
77
+ end
78
+ end
79
+ end
80
+ end