spree_core 6.0.0.rc4 → 6.0.0.rc6

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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/images/save_from_url_job.rb +24 -5
  3. data/app/models/concerns/spree/account_deactivation.rb +54 -0
  4. data/app/models/concerns/spree/calculator/currency_amounts.rb +19 -6
  5. data/app/models/concerns/spree/derived_number.rb +1 -1
  6. data/app/models/concerns/spree/has_external_references.rb +24 -16
  7. data/app/models/concerns/spree/prefixed_id.rb +15 -5
  8. data/app/models/concerns/spree/publishable.rb +47 -1
  9. data/app/models/concerns/spree/purchase/addresses.rb +114 -36
  10. data/app/models/concerns/spree/purchase/digital_items.rb +17 -3
  11. data/app/models/concerns/spree/purchase/freight.rb +2 -1
  12. data/app/models/concerns/spree/purchase/payment_processing.rb +7 -1
  13. data/app/models/concerns/spree/purchase/quantity_rules.rb +2 -0
  14. data/app/models/concerns/spree/stores/channels.rb +3 -1
  15. data/app/models/concerns/spree/translatable_resource.rb +6 -1
  16. data/app/models/concerns/spree/webhook_payload_redaction.rb +1 -0
  17. data/app/models/spree/ability.rb +8 -0
  18. data/app/models/spree/address.rb +6 -0
  19. data/app/models/spree/admin_user.rb +1 -0
  20. data/app/models/spree/base.rb +33 -1
  21. data/app/models/spree/calculator/shipping/digital_delivery.rb +3 -1
  22. data/app/models/spree/calculator/shipping/flat_rate.rb +3 -1
  23. data/app/models/spree/calculator/shipping/per_item.rb +3 -1
  24. data/app/models/spree/cart.rb +7 -7
  25. data/app/models/spree/category.rb +76 -5
  26. data/app/models/spree/channel.rb +79 -40
  27. data/app/models/spree/company.rb +46 -3
  28. data/app/models/spree/customer.rb +1 -1
  29. data/app/models/spree/customer_group.rb +59 -41
  30. data/app/models/spree/data_request.rb +34 -1
  31. data/app/models/spree/delivery_method.rb +18 -8
  32. data/app/models/spree/delivery_profile.rb +18 -0
  33. data/app/models/spree/digital_asset.rb +14 -0
  34. data/app/models/spree/event.rb +20 -5
  35. data/app/models/spree/fulfillment.rb +8 -1
  36. data/app/models/spree/fulfillment_changer.rb +3 -1
  37. data/app/models/spree/fulfillment_item.rb +11 -0
  38. data/app/models/spree/import.rb +20 -2
  39. data/app/models/spree/order.rb +1 -1
  40. data/app/models/spree/order_routing/strategy/rules.rb +4 -1
  41. data/app/models/spree/price_list.rb +15 -0
  42. data/app/models/spree/product/channels.rb +16 -8
  43. data/app/models/spree/product.rb +7 -2
  44. data/app/models/spree/return.rb +8 -1
  45. data/app/models/spree/sales_context.rb +162 -0
  46. data/app/models/spree/search_provider/base.rb +8 -3
  47. data/app/models/spree/search_provider/database.rb +2 -10
  48. data/app/models/spree/seller.rb +35 -5
  49. data/app/models/spree/seller_balance.rb +28 -0
  50. data/app/models/spree/seller_requirements/delivery_method.rb +20 -2
  51. data/app/models/spree/stock/coordinator.rb +7 -4
  52. data/app/models/spree/stock/estimator.rb +6 -0
  53. data/app/models/spree/stock/inventory_unit_builder.rb +14 -2
  54. data/app/models/spree/stock/packer.rb +39 -5
  55. data/app/models/spree/stock/quantifier.rb +16 -0
  56. data/app/models/spree/stock_location.rb +15 -8
  57. data/app/models/spree/store.rb +31 -2
  58. data/app/models/spree/store_credit.rb +4 -4
  59. data/app/models/spree/tax_line.rb +5 -4
  60. data/app/models/spree/variant.rb +23 -6
  61. data/app/models/spree/webhook_endpoint.rb +45 -11
  62. data/app/services/spree/carts/destroy.rb +7 -2
  63. data/app/services/spree/carts/price_items.rb +3 -0
  64. data/app/services/spree/carts/split_by_seller.rb +3 -1
  65. data/app/services/spree/carts/update.rb +1 -4
  66. data/app/services/spree/catalogs/set_product_terms.rb +8 -3
  67. data/app/services/spree/collections/add_products.rb +1 -1
  68. data/app/services/spree/collections/regenerate_products.rb +1 -3
  69. data/app/services/spree/collections/remove_products.rb +1 -1
  70. data/app/services/spree/data_requests/confirm.rb +37 -0
  71. data/app/services/spree/data_requests/create.rb +61 -13
  72. data/app/services/spree/newsletter/subscribe.rb +9 -1
  73. data/app/services/spree/order_groups/allocate_payments.rb +7 -5
  74. data/app/services/spree/orders/update.rb +19 -0
  75. data/app/services/spree/product_memberships.rb +16 -6
  76. data/app/services/spree/products/duplicator.rb +12 -4
  77. data/app/services/spree/search_provider/custom_field_schema.rb +10 -2
  78. data/app/services/spree/stock_levels/correct.rb +11 -4
  79. data/app/services/spree/stock_reservations/reserve.rb +1 -1
  80. data/app/services/spree/stores/provision_defaults.rb +11 -7
  81. data/app/services/spree/tags/bulk_add.rb +16 -6
  82. data/app/workflows/spree/carts/complete.rb +25 -9
  83. data/app/workflows/spree/carts/upsert_items.rb +31 -4
  84. data/app/workflows/spree/customers/anonymize.rb +40 -19
  85. data/app/workflows/spree/data_requests/fulfill.rb +20 -6
  86. data/app/workflows/spree/digital_assets/create.rb +56 -0
  87. data/app/workflows/spree/digital_assets/update.rb +44 -0
  88. data/app/workflows/spree/digital_assets/writing.rb +108 -0
  89. data/app/workflows/spree/imports/start_mapping.rb +3 -1
  90. data/app/workflows/spree/orders/complete.rb +12 -5
  91. data/app/workflows/spree/orders/create.rb +41 -17
  92. data/app/workflows/spree/payments/capture.rb +12 -0
  93. data/app/workflows/spree/products/nested_attributes.rb +12 -28
  94. data/app/workflows/spree/products/propose.rb +9 -4
  95. data/app/workflows/spree/stock_levels/bulk_upsert.rb +28 -13
  96. data/app/workflows/spree/variants/nested_attributes.rb +35 -10
  97. data/config/locales/en.yml +14 -0
  98. data/db/migrate/20261022000001_store_credit_actor_polymorphic.rb +13 -0
  99. data/db/migrate/20261023100001_add_deactivated_at_to_spree_admin_users.rb +7 -0
  100. data/lib/generators/spree/dummy/templates/rails/test.rb +87 -0
  101. data/lib/spree/checkout/default_requirements.rb +12 -2
  102. data/lib/spree/core/dependencies.rb +5 -0
  103. data/lib/spree/core/engine.rb +2 -0
  104. data/lib/spree/core/permissions/default_catalog.rb +7 -2
  105. data/lib/spree/core/version.rb +1 -1
  106. data/lib/spree/reporting/default_vocabulary.rb +1 -1
  107. data/lib/spree/testing_support/factories/data_request_factory.rb +4 -0
  108. data/lib/spree/testing_support/factories/product_factory.rb +10 -4
  109. data/lib/spree/testing_support/factories/store_factory.rb +4 -1
  110. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +10 -0
  111. data/lib/tasks/backfill_tagging_tenants.rake +35 -0
  112. data/lib/tasks/seller_sample_data.rake +114 -112
  113. metadata +15 -6
@@ -20,9 +20,12 @@ module Spree
20
20
 
21
21
  publishes_lifecycle_events
22
22
  publishes_event :completed
23
+ publishes_event :confirmation_requested,
24
+ serializer: 'Spree::Api::V3::DataRequestConfirmationRequestedEventSerializer',
25
+ credential: 'write_customers'
23
26
 
24
27
  include Spree::HasStatus
25
- has_status :pending, :processing, :completed, :failed, default: :pending
28
+ has_status :awaiting_confirmation, :pending, :processing, :completed, :failed, :lapsed, default: :pending
26
29
 
27
30
  ACCESS = 'access'.freeze
28
31
  ERASURE = 'erasure'.freeze
@@ -33,11 +36,23 @@ module Spree
33
36
  # about them is not left lying around.
34
37
  DEFAULT_EXPIRY = 7.days
35
38
 
39
+ # How long a request a customer raised waits for confirmation from its
40
+ # email address before it lapses.
41
+ CONFIRMATION_WINDOW = 24.hours
42
+
36
43
  # Addresses the finished export. Plaintext like the digital-link token:
37
44
  # the URL is the credential, it is only ever sent to the address that
38
45
  # asked, and it stops working when the request expires.
39
46
  has_secure_token :download_token
40
47
 
48
+ # Proves that whoever confirms reads mail at the address the request
49
+ # names. Bound to
50
+ # the download token, so an erasure, which clears it, voids every link
51
+ # already sent.
52
+ generates_token_for :confirmation do
53
+ download_token
54
+ end
55
+
41
56
  belongs_to :customer, class_name: Spree.customer_class.to_s
42
57
  # Null when the subject asked for it themselves; set when staff acted on a
43
58
  # request that arrived by email.
@@ -52,6 +67,8 @@ module Spree
52
67
  scope :access, -> { where(kind: ACCESS) }
53
68
  scope :erasure, -> { where(kind: ERASURE) }
54
69
  scope :in_progress, -> { with_status(:pending, :processing) }
70
+ scope :confirmable, -> { awaiting_confirmation.where(requested_at: CONFIRMATION_WINDOW.ago..) }
71
+ scope :past_confirmation_window, -> { awaiting_confirmation.where(requested_at: ...CONFIRMATION_WINDOW.ago) }
55
72
  scope :recent_first, -> { order(requested_at: :desc) }
56
73
 
57
74
  self.whitelisted_ransackable_attributes = %w[number kind status email requested_at completed_at]
@@ -89,6 +106,22 @@ module Spree
89
106
  expires_at.present? && expires_at.past?
90
107
  end
91
108
 
109
+ # Whether the request is still waiting for confirmation, within the window
110
+ # to receive it, and still names the account's address.
111
+ # @return [Boolean]
112
+ def confirmable?
113
+ awaiting_confirmation? && requested_at > CONFIRMATION_WINDOW.ago && addressed_to_customer?
114
+ end
115
+
116
+ # Whether the request names the address the account has now. The work
117
+ # matches guest rows by the account's address when it runs, so a request
118
+ # for an address the account has since left would reach rows it never
119
+ # named.
120
+ # @return [Boolean]
121
+ def addressed_to_customer?
122
+ customer.present? && email.to_s.casecmp?(customer.email.to_s)
123
+ end
124
+
92
125
  private
93
126
 
94
127
  def set_defaults
@@ -451,10 +451,13 @@ module Spree
451
451
  calculator.description + ': ' +
452
452
 
453
453
  if calculator.is_a?(Spree::Calculator::Shipping::FlatRate)
454
- if calculator.preferred_amount == 0
454
+ amount = calculator.amount_for(calculator.preferred_currency)
455
+ if amount.nil?
456
+ ''
457
+ elsif amount.zero?
455
458
  I18n.t('spree.free')
456
459
  else
457
- Spree::Money.new(calculator.preferred_amount, { currency: calculator.preferred_currency }).to_s
460
+ Spree::Money.new(amount, { currency: calculator.preferred_currency }).to_s
458
461
  end
459
462
  elsif calculator.is_a?(Spree::Calculator::Shipping::FlexiRate)
460
463
  Spree::Money.new(calculator.preferred_first_item, { currency: calculator.preferred_currency }).to_s
@@ -497,14 +500,21 @@ module Spree
497
500
  reconcile_services(pending)
498
501
  end
499
502
 
503
+ # Matched against the method's rows loaded once, rather than a lookup per
504
+ # row. A row created here joins that list, so a later row naming the
505
+ # same (carrier, service) updates it as a lookup would.
500
506
  def reconcile_services(rows)
507
+ existing = services.reload.to_a
501
508
  kept_ids = rows.filter_map do |row|
502
- record =
503
- if row[:id].present?
504
- id = Spree::PrefixedId.prefixed_id?(row[:id]) ? Spree::PrefixedId.decode_prefixed_id(row[:id]) : row[:id]
505
- services.find_by(id: id)
506
- end
507
- record ||= services.find_or_initialize_by(carrier: row[:carrier], service: row[:service])
509
+ if row[:id].present?
510
+ id = Spree::PrefixedId.prefixed_id?(row[:id]) ? Spree::PrefixedId.decode_prefixed_id(row[:id]) : row[:id]
511
+ record = existing.find { |service| service.id.to_s == id.to_s }
512
+ end
513
+ record ||= existing.find { |service| service.carrier == row[:carrier] && service.service == row[:service] }
514
+ unless record
515
+ record = services.build(carrier: row[:carrier], service: row[:service])
516
+ existing << record
517
+ end
508
518
  record.assign_attributes(row.slice(:carrier, :service, :label, :markup_flat, :markup_percent, :position).to_h)
509
519
  record.save!
510
520
  record.id
@@ -123,6 +123,24 @@ module Spree
123
123
  delivery_origin_groups.first
124
124
  end
125
125
 
126
+ # How many products use this profile. Counted for every profile of a
127
+ # lazily preloaded list in one grouped query, since loading the products
128
+ # themselves to count them would fetch the whole catalog.
129
+ #
130
+ # @return [Integer]
131
+ def products_count
132
+ return @products_count if defined?(@products_count)
133
+
134
+ profiles = lazy_preload_context&.records.presence || [self]
135
+ counts = Spree::Product.where(delivery_profile_id: profiles.map(&:id)).group(:delivery_profile_id).count
136
+ profiles.each { |profile| profile.products_count = counts.fetch(profile.id, 0) }
137
+ @products_count
138
+ end
139
+
140
+ protected
141
+
142
+ attr_writer :products_count
143
+
126
144
  private
127
145
 
128
146
  def create_default_origin_group
@@ -25,6 +25,20 @@ module Spree
25
25
  # and provider-backed assets (which carry no file) are exempt.
26
26
  DEFAULT_PROVIDER = 'Spree::DigitalAssetProvider::File'.freeze
27
27
 
28
+ # What a seller may upload unless the marketplace says otherwise: the
29
+ # formats digital goods usually come in. Executables, scripts and
30
+ # unrecognised binaries are left out on purpose, as is SVG, which can
31
+ # carry scripts. A marketplace selling a niche format adds it in settings.
32
+ # The text formats have no signature, so they are taken at the type the
33
+ # upload declared.
34
+ DEFAULT_SELLER_CONTENT_TYPES = %w[
35
+ application/pdf application/epub+zip application/zip application/x-7z-compressed application/gzip
36
+ application/json text/plain text/csv
37
+ audio/mpeg audio/mp4 audio/aac audio/flac audio/ogg audio/wav audio/x-wav audio/midi
38
+ video/mp4 video/webm video/quicktime
39
+ image/jpeg image/png image/gif image/webp image/tiff
40
+ ].freeze
41
+
28
42
  # The API names a provider by its shorthand (`file`); the column keeps the
29
43
  # class name.
30
44
  normalizes :provider_type, with: ->(value) { Spree::ApiTyped.class_name_for(Spree.digital_asset_providers, value) }
@@ -86,14 +86,29 @@ module Spree
86
86
  # @return [Boolean]
87
87
  def self.matches?(event_name, pattern)
88
88
  return true if pattern == '*'
89
+ return event_name == pattern unless pattern.include?('*')
89
90
 
90
- if pattern.include?('*')
91
- regex = Regexp.new("^#{Regexp.escape(pattern).gsub('\*', '.*')}$")
92
- event_name.match?(regex)
93
- else
94
- event_name == pattern
91
+ wildcard_match?(event_name, pattern)
92
+ end
93
+
94
+ # `*` stands for any run of characters, as in a shell glob. The comparison
95
+ # is plain string searching so a pattern taken from a merchant never
96
+ # reaches the regexp engine, which has had memory bugs in pattern parsing.
97
+ def self.wildcard_match?(event_name, pattern)
98
+ head, *middle, tail = pattern.split('*', -1)
99
+ return false unless event_name.start_with?(head) && event_name.end_with?(tail)
100
+ return false if event_name.length < head.length + tail.length
101
+
102
+ position = head.length
103
+ limit = event_name.length - tail.length
104
+ middle.all? do |segment|
105
+ found = event_name.index(segment, position)
106
+ next false if found.nil? || found + segment.length > limit
107
+
108
+ position = found + segment.length
95
109
  end
96
110
  end
111
+ private_class_method :wildcard_match?
97
112
 
98
113
  alias_method :to_h, :attributes
99
114
 
@@ -653,7 +653,14 @@ module Spree
653
653
  #
654
654
  # @return [Spree::DeliveryMethod]
655
655
  def delivery_method
656
- selected_delivery_rate&.delivery_method || delivery_rates.first&.delivery_method
656
+ selected = selected_delivery_rate
657
+ # The same row read through the rate list, when that list is to hand,
658
+ # so its method loads with every other rate's instead of on its own.
659
+ if selected && read_association_in_memory?(:delivery_rates)
660
+ selected = delivery_rates.detect { |rate| rate.id == selected.id } || selected
661
+ end
662
+
663
+ selected&.delivery_method || delivery_rates.first&.delivery_method
657
664
  end
658
665
 
659
666
  # The fulfillment provider strategy handling this fulfillment's
@@ -138,7 +138,9 @@ module Spree
138
138
  end
139
139
 
140
140
  def reload_shipment_inventory_units
141
- [current_shipment, desired_shipment].each { |shipment| shipment.fulfillment_items.reload }
141
+ shipments = [current_shipment, desired_shipment]
142
+ shipments.each { |shipment| shipment.association(:fulfillment_items).reset }
143
+ ActiveRecord::Associations::Preloader.new(records: shipments, associations: :fulfillment_items).call
142
144
  end
143
145
 
144
146
  def current_quantity
@@ -107,6 +107,17 @@ module Spree
107
107
  split
108
108
  end
109
109
 
110
+ # `dup` copies the foreign keys but not the loaded records, so every copy
111
+ # made while packing and splitting would look its line item and variant
112
+ # up again.
113
+ def initialize_dup(other)
114
+ super
115
+ %i[line_item variant].each do |name|
116
+ association = other.association(name)
117
+ self.association(name).target = association.target if association.loaded?
118
+ end
119
+ end
120
+
110
121
  # This will fail if extract >= available_quantity
111
122
  def split_inventory!(extract_quantity)
112
123
  split = self.class.split(self, extract_quantity)
@@ -215,10 +215,22 @@ module Spree
215
215
  end
216
216
 
217
217
  # Per-status row counts for API status payloads, memoized so one request
218
- # issues a single grouped COUNT instead of one per status.
218
+ # issues a single grouped COUNT instead of one per status — and, on a
219
+ # lazily preloaded list, one for every import of the list.
219
220
  # @return [Hash{String => Integer}]
220
221
  def rows_status_counts
221
- @rows_status_counts ||= rows.group(:status).count
222
+ return @rows_status_counts if @rows_status_counts
223
+
224
+ siblings = lazy_preload_context&.records&.grep(Spree::Import)
225
+ return @rows_status_counts = rows.group(:status).count if siblings.blank?
226
+
227
+ counts = Spree::ImportRow.where(import_id: siblings.map(&:id)).group(:import_id, :status).count
228
+ siblings.each do |import|
229
+ import.preloaded_rows_status_counts = counts.each_with_object({}) do |((import_id, status), count), by_status|
230
+ by_status[status] = count if import_id == import.id
231
+ end
232
+ end
233
+ @rows_status_counts
222
234
  end
223
235
 
224
236
  # Public API name for the +results_url+ preference (read/write symmetry).
@@ -474,6 +486,12 @@ module Spree
474
486
  end
475
487
  end
476
488
 
489
+ protected
490
+
491
+ def preloaded_rows_status_counts=(counts)
492
+ @rows_status_counts = counts
493
+ end
494
+
477
495
  private
478
496
 
479
497
  # Mirrors the machine's bang events: an illegal move raised.
@@ -152,7 +152,7 @@ module Spree
152
152
  self.whitelisted_ransackable_attributes = %w[
153
153
  completed_at email number status payment_status payment_state fulfillment_status shipment_state delivery_total
154
154
  total item_total total_quantity considered_risky channel_id currency coupon_code customer_id seller_id
155
- order_group_id po_number
155
+ order_group_id po_number market_id locale
156
156
  ]
157
157
  self.whitelisted_ransackable_scopes = %w[complete incomplete refunded partially_refunded search]
158
158
  self.ransackable_scope_types = %w[complete incomplete refunded partially_refunded].index_with('boolean')
@@ -59,8 +59,11 @@ module Spree
59
59
  # rank order so the Prioritizer's first-package-wins-on-hand logic
60
60
  # honors the routing decision.
61
61
  def build_packages(locations)
62
+ stock_levels = Spree::Stock::Packer.stock_levels_by_location(locations, requested_variant_ids)
63
+
62
64
  locations.flat_map do |location|
63
- Spree::Stock::Packer.new(location, inventory_units, Spree.stock_splitters, owner: order).packages
65
+ Spree::Stock::Packer.new(location, inventory_units, Spree.stock_splitters, owner: order,
66
+ stock_levels: stock_levels.fetch(location.id, {})).packages
64
67
  end
65
68
  end
66
69
 
@@ -38,6 +38,21 @@ module Spree
38
38
  # flip or a catalog attach/detach changes which lists match generically.
39
39
  after_commit -> { Spree::Current.price_lists = nil }
40
40
 
41
+ # Distinct products per list for a page of lists, in one grouped query.
42
+ # @param price_list_ids [Array<Integer>]
43
+ # @return [Hash{Integer => Integer}] keyed by price list id; lists with no products are absent
44
+ def self.products_count_by_list(price_list_ids)
45
+ where(id: price_list_ids).joins(:variants).group(arel_table[:id]).
46
+ distinct.count(Spree::Variant.arel_table[:product_id])
47
+ end
48
+
49
+ # Prices per list for a page of lists, in one grouped query.
50
+ # @param price_list_ids [Array<Integer>]
51
+ # @return [Hash{Integer => Integer}] keyed by price list id; lists with no prices are absent
52
+ def self.prices_count_by_list(price_list_ids)
53
+ Spree::Price.where(price_list_id: price_list_ids).group(:price_list_id).count
54
+ end
55
+
41
56
  # Flat-payload writer for `rules`. See
42
57
  # {Spree::TypedAssociations#assign_typed_association}.
43
58
  def rules=(rows)
@@ -64,8 +64,8 @@ module Spree
64
64
  def publication_for(channel)
65
65
  return nil unless channel
66
66
 
67
- if product_publications.loaded?
68
- product_publications.find { |p| p.channel_id == channel.id }
67
+ if read_association_in_memory?(:product_publications)
68
+ product_publications.find { |publication| publication.channel_id == channel.id }
69
69
  else
70
70
  product_publications.find_by(channel_id: channel.id)
71
71
  end
@@ -122,10 +122,16 @@ module Spree
122
122
  end
123
123
 
124
124
  def apply_product_publications(publications_params)
125
+ rows = publications_params.map { |publication_data| publication_data.to_h.with_indifferent_access }
125
126
  publication_ids_in_payload = []
126
127
 
127
- publications_params.each do |publication_data|
128
- publication_data = publication_data.to_h.with_indifferent_access
128
+ # The product's publications and every channel the payload names are
129
+ # read once, rather than a lookup per row.
130
+ named_channel_ids = rows.filter_map { |row| decode_publication_channel_id(row[:channel_id]) if row[:id].blank? }
131
+ channels_by_id = named_channel_ids.empty? ? {} : publishable_channels.where(id: named_channel_ids).index_by { |channel| channel.id.to_s }
132
+ product_publications.load
133
+
134
+ rows.each do |publication_data|
129
135
  publication_id = publication_data.delete(:id)
130
136
  channel_id = decode_publication_channel_id(publication_data[:channel_id])
131
137
 
@@ -133,7 +139,7 @@ module Spree
133
139
  decoded_id = Spree::PrefixedId.prefixed_id?(publication_id) ?
134
140
  Spree::PrefixedId.decode_prefixed_id(publication_id) :
135
141
  publication_id
136
- publication = product_publications.find_by(id: decoded_id)
142
+ publication = product_publications.detect { |existing| existing.id.to_s == decoded_id.to_s }
137
143
  next unless publication
138
144
 
139
145
  # Channel is immutable; ignore any rebind attempt.
@@ -142,19 +148,21 @@ module Spree
142
148
  elsif channel_id.present?
143
149
  # Only publish to channels owned by the product's store; ignore
144
150
  # ids referencing another store's channel.
145
- channel = publishable_channels.find_by(id: channel_id)
151
+ channel = channels_by_id[channel_id.to_s]
146
152
  next unless channel
147
153
 
148
154
  # Upsert by channel_id so repeat submissions are idempotent
149
155
  # against the unique (product_id, channel_id) index.
150
- publication = product_publications.find_or_initialize_by(channel_id: channel.id)
156
+ publication = product_publications.detect { |existing| existing.channel_id == channel.id } ||
157
+ product_publications.build(channel: channel)
151
158
  publication.assign_attributes(publication_data.slice(:published_at, :unpublished_at))
152
159
  publication.save!
153
160
  publication_ids_in_payload << publication.id
154
161
  end
155
162
  end
156
163
 
157
- product_publications.where.not(id: publication_ids_in_payload).destroy_all
164
+ removed = product_publications.reject { |publication| publication_ids_in_payload.include?(publication.id) }
165
+ product_publications.destroy(*removed) if removed.any?
158
166
  end
159
167
 
160
168
  def decode_publication_channel_id(value)
@@ -277,7 +277,9 @@ module Spree
277
277
  # the search. Definitions are store-owned, so the caller says which store
278
278
  # is searching; `Spree::Current.store` is the request's answer and a
279
279
  # background caller (an export, say) passes its own.
280
- scope :search, ->(query, store = Spree::Current.store) {
280
+ # @param audience [Symbol, nil] `:store` keeps a shopper's search off the
281
+ # custom fields hidden from the storefront; `nil` is the back office
282
+ scope :search, ->(query, store = Spree::Current.store, audience: nil) {
281
283
  next none if query.blank?
282
284
 
283
285
  product_ids = Spree::Variant.search_by_product_name_or_sku(query).pluck(:product_id)
@@ -285,6 +287,7 @@ module Spree
285
287
  searchable_definitions = store.custom_field_definitions.
286
288
  for_resource_type('Spree::Product').
287
289
  searchable
290
+ searchable_definitions = searchable_definitions.storefront_visible if audience == :store
288
291
 
289
292
  # Only pay for the custom_field scan when some definition opts into search;
290
293
  # the leading-wildcard LIKE over spree_custom_fields can't use an index.
@@ -494,7 +497,9 @@ module Spree
494
497
  def purchasable?
495
498
  return @purchasable unless @purchasable.nil?
496
499
 
497
- @purchasable = sellable_variants.any?(&:purchasable?)
500
+ variants_to_check = sellable_variants
501
+ Spree::Stock::Quantifier.preload(variants_to_check)
502
+ @purchasable = variants_to_check.any?(&:purchasable?)
498
503
  end
499
504
 
500
505
  def in_stock?
@@ -165,7 +165,7 @@ module Spree
165
165
  # more than one step (part to store credit, part to the card), and store
166
166
  # credit is its own ledger rather than a Spree::Refund row.
167
167
  def refunded_total
168
- refunds.sum(:amount) + store_credits.sum(:amount)
168
+ ledger_total(:refunds) + ledger_total(:store_credits)
169
169
  end
170
170
 
171
171
  def refundable_total
@@ -187,5 +187,12 @@ module Spree
187
187
  def taxed_lines
188
188
  return_line_items
189
189
  end
190
+
191
+ private
192
+
193
+ def ledger_total(association_name)
194
+ rows = public_send(association_name)
195
+ read_association_in_memory?(association_name) ? rows.sum(0.to_d, &:amount) : rows.sum(:amount)
196
+ end
190
197
  end
191
198
  end
@@ -0,0 +1,162 @@
1
+ module Spree
2
+ # Who buys, on which channel, in which market: what a buyer can be sold, and
3
+ # at what price, depends on all of these together. A market fixes the
4
+ # currency and the languages a sale may use.
5
+ class SalesContext
6
+ include ActiveModel::Model
7
+ include ActiveModel::Attributes
8
+
9
+ attribute :store
10
+ attribute :market
11
+ attribute :channel
12
+ attribute :customer
13
+ attribute :company
14
+ attribute :country
15
+ attribute :currency, :string
16
+ attribute :locale, :string
17
+ # A placed sale keeps the company it was stamped with, even none, so only
18
+ # a sale still being put together looks up the customer's company.
19
+ attribute :resolve_company, :boolean, default: true
20
+
21
+ validate :currency_sold_in_a_market
22
+ validate :currency_matches_market
23
+ validate :locale_supported_by_market
24
+
25
+ # @param order [Spree::Order]
26
+ # @return [Spree::SalesContext]
27
+ def self.from_order(order)
28
+ new(
29
+ store: order.store, market: order.market, channel: order.channel, customer: order.customer,
30
+ company: order.resolved_company, resolve_company: false, country: order.tax_country,
31
+ currency: order.currency, locale: order.locale
32
+ )
33
+ end
34
+
35
+ # The market chosen for the sale, else one selling the requested currency,
36
+ # else the store's default.
37
+ #
38
+ # @return [Spree::Market, nil]
39
+ def market
40
+ chosen = super
41
+ return chosen if chosen
42
+ return @resolved_market if defined?(@resolved_market)
43
+
44
+ @resolved_market = resolve_market
45
+ end
46
+
47
+ def channel
48
+ super || store&.default_channel
49
+ end
50
+
51
+ # @return [String] the requested currency, else the market's, else the store's
52
+ def currency
53
+ requested_currency || market&.currency || store&.default_currency
54
+ end
55
+
56
+ # @return [String, nil] the requested language, else the market's default
57
+ def locale
58
+ super.presence || market&.default_locale
59
+ end
60
+
61
+ # The company chosen for the sale, else the one the customer stands in
62
+ # alone — the same answer pricing and catalog visibility reach.
63
+ #
64
+ # @return [Spree::Company, nil]
65
+ def company
66
+ chosen = super
67
+ return chosen if chosen || !resolve_company
68
+ return @standing_company if defined?(@standing_company)
69
+
70
+ @standing_company =
71
+ if customer.is_a?(Spree.customer_class) && store
72
+ Spree::Company.sole_standing_for(store: store, customer: customer)
73
+ end
74
+ end
75
+
76
+ # Variants the buyer may order on the channel: published there and inside
77
+ # their catalogs.
78
+ #
79
+ # @return [ActiveRecord::Relation<Spree::Variant>]
80
+ def orderable_variants
81
+ @orderable_variants ||= Spree::Variant.for_products(
82
+ Spree.products_for_context_service.call(
83
+ store: store, channel: channel, customer: customer, company: company,
84
+ base: store.products.not_discontinued_on(channel)
85
+ ).value
86
+ )
87
+ end
88
+
89
+ # Orderable variants the buyer can be quoted in the sale's currency: a base
90
+ # price, or a price from a list that applies to them, so a product sold to
91
+ # a buyer only under their agreement is offered to that buyer alone.
92
+ #
93
+ # @return [ActiveRecord::Relation<Spree::Variant>]
94
+ def sellable_variants
95
+ prices = Spree::Price.where(variant_id: orderable_variants.select(:id), currency: currency)
96
+ .where.not(amount: nil)
97
+ .where(price_list_id: [nil, *applicable_price_list_ids])
98
+
99
+ orderable_variants.where(id: prices.select(:variant_id))
100
+ end
101
+
102
+ # @return [Spree::Pricing::Context] the context prices are resolved in
103
+ def pricing_context
104
+ @pricing_context ||= Spree::Pricing::Context.new(
105
+ currency: currency, store: store, country: country, market: market,
106
+ channel: channel, user: customer, company: company
107
+ )
108
+ end
109
+
110
+ private
111
+
112
+ def requested_currency
113
+ attributes['currency'].presence&.upcase
114
+ end
115
+
116
+ def resolve_market
117
+ return unless store
118
+
119
+ candidates = [(Spree::Current.market if Spree::Current.store == store), store.default_market].compact
120
+ return candidates.first unless requested_currency
121
+
122
+ candidates.find { |candidate| candidate.currency == requested_currency } ||
123
+ store.markets.find_by(currency: requested_currency)
124
+ end
125
+
126
+ # Catalog price lists the buyer's catalogs carry, and standalone lists
127
+ # whose rules match the sale, judged the way the internal pricing provider
128
+ # judges them.
129
+ def applicable_price_list_ids
130
+ @applicable_price_list_ids ||= begin
131
+ catalogs = Spree::Catalog.groups_for_buyer(store: store, customer: customer, company: company, channel: channel)
132
+ catalog_lists = catalogs.flatten.filter_map(&:price_list).select do |list|
133
+ list.currently_active? && list.contextual_rules_applicable?(pricing_context)
134
+ end
135
+ standalone_lists = Spree::PriceList.for_context(pricing_context).select { |list| list.applicable?(pricing_context) }
136
+
137
+ (catalog_lists + standalone_lists).map(&:id).uniq
138
+ end
139
+ end
140
+
141
+ def currency_sold_in_a_market
142
+ return if requested_currency.nil? || market || store.nil? || !store.markets.exists?
143
+
144
+ errors.add(:currency, :currency_not_supported_by_store, message: I18n.t('spree.currency_not_supported_by_store'))
145
+ end
146
+
147
+ def currency_matches_market
148
+ return if market.nil? || currency == market.currency
149
+
150
+ errors.add(
151
+ :currency, :currency_does_not_match_market,
152
+ message: I18n.t('spree.currency_does_not_match_market', currency: market.currency)
153
+ )
154
+ end
155
+
156
+ def locale_supported_by_market
157
+ return if market.nil? || locale.blank? || market.supported_locales_list.include?(locale)
158
+
159
+ errors.add(:locale, :locale_not_supported_by_market, message: I18n.t('spree.locale_not_supported_by_market'))
160
+ end
161
+ end
162
+ end
@@ -5,7 +5,7 @@ module Spree
5
5
  # attributes. `price` also sorts descending as `-price`.
6
6
  SORT_FIELDS = %w[price best_selling manual].freeze
7
7
 
8
- attr_reader :store
8
+ attr_reader :store, :audience
9
9
 
10
10
  # Whether this provider requires background indexing jobs.
11
11
  # Override in subclasses. Database provider returns false.
@@ -13,13 +13,18 @@ module Spree
13
13
  false
14
14
  end
15
15
 
16
- def initialize(store)
16
+ # @param store [Spree::Store]
17
+ # @param audience [Symbol, nil] who searches: `:store` (shoppers) limits
18
+ # filters and sorts to what the storefront may see; `nil` is the back
19
+ # office
20
+ def initialize(store, audience: :store)
17
21
  @store = store
22
+ @audience = audience
18
23
  end
19
24
 
20
25
  # @return [CustomFieldSchema]
21
26
  def custom_field_schema
22
- @custom_field_schema ||= CustomFieldSchema.new(store)
27
+ @custom_field_schema ||= CustomFieldSchema.new(store, audience: audience)
23
28
  end
24
29
 
25
30
  # Deprecated 6.0 name, removed in 6.1.