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
@@ -7,14 +7,6 @@ module Spree
7
7
  'best_selling' => :by_best_selling
8
8
  }.freeze
9
9
 
10
- # @param store [Spree::Store]
11
- # @param audience [Symbol, nil] who filters: `:store` (shoppers) limits
12
- # filters to what the storefront may query; `nil` is the back office
13
- def initialize(store, audience: :store)
14
- super(store)
15
- @audience = audience
16
- end
17
-
18
10
  def search_and_filter(scope:, query: nil, filters: {}, sort: nil, page: 1, limit: 25)
19
11
  filters = filters.is_a?(Hash) ? filters.dup : {}
20
12
  option_value_ids = filters.delete('with_option_value_ids') || filters.delete(:with_option_value_ids)
@@ -73,14 +65,14 @@ module Spree
73
65
  private
74
66
 
75
67
  def apply_search_and_filters(scope, query: nil, filters: {})
76
- scope = scope.search(query, store) if query.present?
68
+ scope = scope.search(query, store, audience: audience) if query.present?
77
69
 
78
70
  filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
79
71
  scope, filters = scope.with_custom_field_filters(filters, schema: custom_field_schema)
80
72
 
81
73
  ransack_filters = sanitize_filters(filters)
82
74
  if ransack_filters.present?
83
- search = scope.ransack(ransack_filters, auth_object: @audience)
75
+ search = scope.ransack(ransack_filters, auth_object: audience)
84
76
  scope = search.result(distinct: true)
85
77
  end
86
78
 
@@ -310,10 +310,7 @@ module Spree
310
310
  #
311
311
  # @return [Array<Spree::SellerBalance>]
312
312
  def balances
313
- pairs = seller_transfers.where.not(status: 'failed').
314
- distinct.pluck(:currency, :settled_currency)
315
-
316
- pairs.sort.map { |currency, settlement| Spree::SellerBalance.for(self, currency, settlement) }
313
+ Spree::SellerBalance.all_for(self)
317
314
  end
318
315
 
319
316
  # The seller's account with whichever provider pays them — a Stripe Connect
@@ -399,6 +396,10 @@ module Spree
399
396
  # returns; falls back to any active one, since a seller with none marked
400
397
  # still has to receive what customers send back.
401
398
  def returns_location
399
+ if stock_locations.loaded? || lazy_preload_context
400
+ return @returns_location ||= returns_location_from_loaded
401
+ end
402
+
402
403
  @returns_location ||= stock_locations.active.returns_enabled.order_default.first ||
403
404
  stock_locations.active.order_default.first
404
405
  end
@@ -417,9 +418,24 @@ module Spree
417
418
  location.address
418
419
  end
419
420
 
421
+ # A team is small, so on a list its members are loaded for every seller
422
+ # at once rather than counted one seller at a time.
423
+ #
424
+ # @return [Integer] how many people are on this seller's team
425
+ def users_count
426
+ users.loaded? || lazy_preload_context ? users.to_a.size : users.count
427
+ end
428
+
420
429
  # @return [Integer] how many products this seller lists
421
430
  def products_count
422
- @products_count ||= products.count
431
+ return @products_count if @products_count
432
+
433
+ siblings = lazy_preload_context&.records&.grep(self.class)
434
+ return @products_count = products.count if siblings.blank?
435
+
436
+ counts = Spree::Product.where(seller_id: siblings.map(&:id)).group(:seller_id).count
437
+ siblings.each { |seller| seller.preloaded_products_count = counts.fetch(seller.id, 0) }
438
+ @products_count
423
439
  end
424
440
 
425
441
  # Where this seller stands against the marketplace's checklist
@@ -465,8 +481,22 @@ module Spree
465
481
  onboarding_requirements.none?(&:blocking?)
466
482
  end
467
483
 
484
+ protected
485
+
486
+ def preloaded_products_count=(count)
487
+ @products_count = count
488
+ end
489
+
468
490
  private
469
491
 
492
+ # The same choice as the query in {#returns_location}, made over the
493
+ # preloaded locations.
494
+ def returns_location_from_loaded
495
+ active_locations = stock_locations.select(&:active?).sort_by { |location| [location.default? ? 0 : 1, location.name.to_s] }
496
+
497
+ active_locations.find(&:returns_enabled?) || active_locations.first
498
+ end
499
+
470
500
 
471
501
  def normalize_slug
472
502
  self.slug = (slug.presence || name).to_s.parameterize.presence
@@ -57,6 +57,34 @@ module Spree
57
57
  )
58
58
  end
59
59
 
60
+ # Every position the seller holds, read with one grouped query per figure
61
+ # rather than four per position. See {Spree::Seller#balances}.
62
+ #
63
+ # @param seller [Spree::Seller]
64
+ # @return [Array<Spree::SellerBalance>]
65
+ def self.all_for(seller)
66
+ transfers = seller.seller_transfers.where.not(status: 'failed')
67
+ amounts = transfers.group(:currency, :settled_currency, :status).sum(:amount)
68
+ payable = transfers.completed.group(:currency, :settled_currency).sum(:settled_amount)
69
+ paid = transfers.completed.joins(:payout).merge(Spree::SellerPayout.completed).
70
+ group(:currency, :settled_currency).sum(:settled_amount)
71
+
72
+ pairs = amounts.keys.map { |currency, settlement_currency, _status| [currency, settlement_currency] }.uniq.sort
73
+ pairs.map do |pair|
74
+ amount_in = ->(*statuses) { statuses.sum(BigDecimal('0')) { |status| amounts.fetch([*pair, status], 0) } }
75
+
76
+ new(
77
+ seller: seller,
78
+ currency: pair.first,
79
+ settlement_currency: pair.last,
80
+ earned: amount_in.call('completed'),
81
+ pending: amount_in.call('pending', 'processing', 'unresolved'),
82
+ payable: payable.fetch(pair, 0),
83
+ paid: paid.fetch(pair, 0)
84
+ )
85
+ end
86
+ end
87
+
60
88
  # What the marketplace still owes, in the currency it can actually be sent
61
89
  # in.
62
90
  #
@@ -11,9 +11,27 @@ module Spree
11
11
  # has shared at least one of theirs.
12
12
  class DeliveryMethod < Spree::SellerRequirement
13
13
  def met_by_seller?(seller)
14
- return true if seller.delivery_methods.storefront_visible.exists?
14
+ return true if ships_own_methods?(seller)
15
15
 
16
- store.delivery_methods.shared_with_sellers.storefront_visible.exists?
16
+ operator_shares_methods?
17
+ end
18
+
19
+ private
20
+
21
+ def ships_own_methods?(seller)
22
+ if seller.read_association_in_memory?(:delivery_methods)
23
+ seller.delivery_methods.any?(&:storefront_visible?)
24
+ else
25
+ seller.delivery_methods.storefront_visible.exists?
26
+ end
27
+ end
28
+
29
+ # The same answer for every seller of the store, so a list of them
30
+ # asks once.
31
+ def operator_shares_methods?
32
+ return @operator_shares_methods if defined?(@operator_shares_methods)
33
+
34
+ @operator_shares_methods = store.delivery_methods.shared_with_sellers.storefront_visible.exists?
17
35
  end
18
36
  end
19
37
  end
@@ -28,11 +28,14 @@ module Spree
28
28
  end
29
29
 
30
30
  def build_packages(packages = [])
31
- stock_locations_with_requested_variants.each do |stock_location|
31
+ stock_locations = stock_locations_with_requested_variants.to_a
32
+ stock_levels = Packer.stock_levels_by_location(stock_locations, requested_variant_ids)
33
+
34
+ stock_locations.each do |stock_location|
32
35
  units = allocatable_units_for(stock_location)
33
36
  next if units.empty?
34
37
 
35
- packer = build_packer(stock_location, units)
38
+ packer = build_packer(stock_location, units, stock_levels: stock_levels.fetch(stock_location.id, {}))
36
39
  packages += packer.packages
37
40
  end
38
41
 
@@ -114,8 +117,8 @@ module Spree
114
117
  @estimator ||= Estimator.new(order)
115
118
  end
116
119
 
117
- def build_packer(stock_location, inventory_units)
118
- Packer.new(stock_location, inventory_units, splitters(stock_location), owner: order)
120
+ def build_packer(stock_location, inventory_units, stock_levels: nil)
121
+ Packer.new(stock_location, inventory_units, splitters(stock_location), owner: order, stock_levels: stock_levels)
119
122
  end
120
123
 
121
124
  def splitters(_stock_location)
@@ -162,6 +162,12 @@ module Spree
162
162
  methods = package.eligible_delivery_methods
163
163
  # Storeless orders exist only in specs; real orders always carry one.
164
164
  methods = methods.merge(order.store.delivery_methods) if order.store
165
+ # Read for every method below and when its rates are built. Through the
166
+ # preloader because an overridden package may hand back an Array.
167
+ methods = methods.to_a
168
+ ActiveRecord::Associations::Preloader.new(
169
+ records: methods, associations: %i[delivery_method_rules calculator services delivery_zone]
170
+ ).call
165
171
 
166
172
  # On a marketplace, a package's seller is its stock location's, and it
167
173
  # is quoted by that seller's own methods plus whatever the operator
@@ -11,16 +11,28 @@ module Spree
11
11
  # rather than pointing at whatever order happens to share the cart's
12
12
  # id — a stranger's order whose currency and ship address would then
13
13
  # stand in for the customer's.
14
- @order.line_items.map do |line_item|
14
+ line_items = @order.line_items.to_a
15
+ # Allocation asks each variant how it ships, so the variants and their
16
+ # delivery profiles are read once for the whole cart.
17
+ ActiveRecord::Associations::Preloader.new(
18
+ records: line_items, associations: { variant: [:delivery_profile, { product: :delivery_profile }] }
19
+ ).call
20
+
21
+ line_items.map do |line_item|
15
22
  # They go through multiple splits, avoid loading the
16
23
  # association to order until needed.
17
- Spree::FulfillmentItem.new(
24
+ unit = Spree::FulfillmentItem.new(
18
25
  pending: true,
19
26
  line_item_id: line_item.id,
20
27
  variant_id: line_item.variant_id,
21
28
  quantity: line_item.quantity,
22
29
  order_id: @order.is_a?(Spree::Order) ? @order.id : nil
23
30
  )
31
+ # Set as targets rather than assigned, so the proposed unit is not
32
+ # added to the line item's own fulfillment_items.
33
+ unit.association(:line_item).target = line_item
34
+ unit.association(:variant).target = line_item.variant
35
+ unit
24
36
  end
25
37
  end
26
38
  end
@@ -9,9 +9,25 @@ module Spree
9
9
  # belong to no order yet — so anything reading the buyer through the
10
10
  # package (the packaging tare, the channel and company eligibility
11
11
  # rules) would otherwise see nothing.
12
- def initialize(stock_location, inventory_units, splitters = [Splitter::Base], owner: nil)
12
+ # Every candidate location's levels for the given variants in one query,
13
+ # lowest id first as StockLocation#stock_level reads, for packing several
14
+ # locations without a read per location.
15
+ #
16
+ # @param stock_locations [Array<Spree::StockLocation>]
17
+ # @param variant_ids [Array]
18
+ # @return [Hash{Integer => Hash{Integer => Spree::StockLevel}}] by location, then variant
19
+ def self.stock_levels_by_location(stock_locations, variant_ids)
20
+ Spree::StockLevel.where(stock_location_id: stock_locations.map(&:id), variant_id: variant_ids).
21
+ order(:id).group_by(&:stock_location_id).
22
+ transform_values { |levels| levels.group_by(&:variant_id).transform_values(&:first) }
23
+ end
24
+
25
+ # @param stock_levels [Hash{Integer => Spree::StockLevel}, nil] this
26
+ # location's levels by variant id, when the caller read them already
27
+ def initialize(stock_location, inventory_units, splitters = [Splitter::Base], owner: nil, stock_levels: nil)
13
28
  @stock_location = stock_location
14
29
  @inventory_units = inventory_units
30
+ @stock_levels = stock_levels
15
31
  @splitters = splitters
16
32
  @owner = owner
17
33
  end
@@ -28,13 +44,21 @@ module Spree
28
44
  package = build_package
29
45
 
30
46
  # Group by variant_id as grouping by variant fires cached query.
31
- inventory_units.index_by(&:variant_id).each do |variant_id, inventory_unit|
32
- variant = Spree::Variant.find(variant_id)
47
+ units_by_variant_id = inventory_units.index_by(&:variant_id)
48
+ # The package's variants and their levels here, each in one query
49
+ # rather than one per variant. Lowest id first, as StockLocation#stock_level reads.
50
+ variants = variants_for(units_by_variant_id)
51
+ stock_levels = @stock_levels || stock_location.stock_levels.where(variant_id: units_by_variant_id.keys).order(:id).
52
+ group_by(&:variant_id).transform_values(&:first)
53
+
54
+ units_by_variant_id.each do |variant_id, inventory_unit|
55
+ variant = variants[variant_id] || Spree::Variant.find(variant_id)
33
56
  unit = inventory_unit.dup # Can be used by others, do not use directly
34
57
  if variant.should_track_inventory?
35
- next unless stock_location.stocks? variant
58
+ stock_level = stock_levels[variant_id]
59
+ next if stock_level.nil?
36
60
 
37
- on_hand, backordered = stock_location.fill_status(variant, unit.quantity)
61
+ on_hand, backordered = stock_location.fill_status(variant, unit.quantity, stock_level: stock_level)
38
62
  package.add(FulfillmentItem.split(unit, backordered), :backordered) if backordered.positive?
39
63
  package.add(FulfillmentItem.split(unit, on_hand), :on_hand) if on_hand.positive?
40
64
  else
@@ -57,6 +81,16 @@ module Spree
57
81
 
58
82
  private
59
83
 
84
+ # The units' own variants when they carry them, so a cart's variants are
85
+ # read once however many locations are packed.
86
+ def variants_for(units_by_variant_id)
87
+ variants = units_by_variant_id.each_with_object({}) do |(variant_id, unit), loaded|
88
+ loaded[variant_id] = unit.variant if unit.association(:variant).loaded?
89
+ end
90
+ missing_ids = units_by_variant_id.keys - variants.keys
91
+ missing_ids.empty? ? variants : variants.merge(Spree::Variant.where(id: missing_ids).index_by(&:id))
92
+ end
93
+
60
94
  def build_splitter
61
95
  splitter = nil
62
96
  splitters.reverse_each do |klass|
@@ -3,6 +3,22 @@ module Spree
3
3
  class Quantifier
4
4
  attr_reader :variant, :stock_location, :excluded_order, :provided_stock_levels
5
5
 
6
+ # What a quantifier reads, preloaded for many variants at once, so
7
+ # quantifying each of them reads memory instead of querying per variant.
8
+ # Variants whose levels are loaded already are left as they are.
9
+ #
10
+ # @param variants [Enumerable<Spree::Variant>]
11
+ # @return [void]
12
+ def self.preload(variants)
13
+ unloaded = variants.compact.reject { |variant| variant.association(:stock_levels).loaded? }
14
+ return if unloaded.empty?
15
+
16
+ ActiveRecord::Associations::Preloader.new(
17
+ records: unloaded,
18
+ associations: { stock_levels: [:stock_location, :active_stock_reservations] }
19
+ ).call
20
+ end
21
+
6
22
  # @param excluded_order [Spree::Cart, Spree::Order, nil] when given,
7
23
  # reservations belonging to this cart/order are not counted against
8
24
  # availability. Used when checking a cart's own line items so the
@@ -182,8 +182,10 @@ module Spree
182
182
  #
183
183
  # @param fulfillment [Spree::Fulfillment]
184
184
  # @return [Spree::StockMovement]
185
- def allocate(variant, quantity, fulfillment)
186
- move(variant, quantity, kind: 'allocated', cause: fulfillment)
185
+ # @param stock_level [Spree::StockLevel, nil] the variant's level here, when
186
+ # the caller loaded it already
187
+ def allocate(variant, quantity, fulfillment, stock_level: nil)
188
+ move(variant, quantity, kind: 'allocated', cause: fulfillment, stock_level: stock_level)
187
189
  end
188
190
 
189
191
  # Withdraws a promise — a canceled, relocated or shrunk fulfillment.
@@ -199,21 +201,26 @@ module Spree
199
201
  # a negative quantity writes stock off.
200
202
  #
201
203
  # @param reason [String] audit text, required
204
+ # @param stock_level [Spree::StockLevel, nil] the variant's level here, when
205
+ # the caller holds it already (locked, say), so the movement updates that
206
+ # instance rather than one looked up again
202
207
  # @return [Spree::StockMovement]
203
- def adjust(variant, quantity, reason:)
204
- move(variant, quantity, kind: 'adjusted', reason: reason)
208
+ def adjust(variant, quantity, reason:, stock_level: nil)
209
+ move(variant, quantity, kind: 'adjusted', reason: reason, stock_level: stock_level)
205
210
  end
206
211
 
207
- def move(variant, quantity, kind:, cause: nil, reason: nil, force: false, unit_cost: nil)
208
- stock_level = stock_level_or_create(variant)
212
+ def move(variant, quantity, kind:, cause: nil, reason: nil, force: false, unit_cost: nil, stock_level: nil)
213
+ stock_level ||= stock_level_or_create(variant)
209
214
  attributes = { quantity: quantity, kind: kind, reason: reason, unit_cost: unit_cost,
210
215
  **cause_attributes(cause) }
211
216
 
212
217
  stock_level.stock_movements.create!(attributes) { |movement| movement.force = force }
213
218
  end
214
219
 
215
- def fill_status(variant, quantity)
216
- if item = stock_level_or_create(variant)
220
+ # @param stock_level [Spree::StockLevel, nil] the variant's level here, when
221
+ # the caller loaded it already
222
+ def fill_status(variant, quantity, stock_level: nil)
223
+ if item = stock_level || stock_level_or_create(variant)
217
224
  if item.available_count >= quantity
218
225
  on_hand = quantity
219
226
  backordered = 0
@@ -78,6 +78,22 @@ module Spree
78
78
  # same reason as auto_approve_sellers: what the marketplace lists is what
79
79
  # it vouches for.
80
80
  preference :auto_approve_seller_products, :boolean, default: false
81
+ # Let sellers attach downloadable files to their own products. Off for the
82
+ # same reason again: a file a buyer downloads under the marketplace's name
83
+ # is something the marketplace vouches for, and that should be a choice.
84
+ preference :seller_digital_assets_enabled, :boolean, default: false
85
+ # What a seller's file may be, checked against the file's own signature
86
+ # rather than its name. An entry names one type or, with `*` as the
87
+ # subtype, a whole family. The operator's own uploads are not restricted.
88
+ preference :seller_digital_asset_content_types, :array, of: :string,
89
+ default: Spree::DigitalAsset::DEFAULT_SELLER_CONTENT_TYPES
90
+ # A typo here would refuse every seller upload with a message about the
91
+ # file, so the list is checked for the type/subtype shape when written.
92
+ validate :seller_digital_asset_content_types_are_media_types
93
+ # A file added to or replaced on a seller's product already on sale sends
94
+ # the product back to review, so an approved listing cannot change what it
95
+ # delivers without anyone looking. Moot when seller products auto-approve.
96
+ preference :review_seller_digital_asset_changes, :boolean, default: true
81
97
  # Who pays this store's sellers. Blank means core's record-only provider,
82
98
  # so a marketplace that has connected nothing still keeps a correct ledger
83
99
  # and settles by hand.
@@ -157,6 +173,8 @@ module Spree
157
173
  # B2B store collects it from everyone, a mixed store only offers it.
158
174
  preference :address_requires_company, :boolean, default: false
159
175
  preference :address_requires_phone, :boolean, default: false
176
+ MEDIA_TYPE_FORMAT = %r{\A[a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+*-]*\z}i
177
+
160
178
  # digital assets preferences
161
179
  preference :limit_digital_download_count, :boolean, default: true
162
180
  preference :limit_digital_download_days, :boolean, default: true
@@ -186,7 +204,8 @@ module Spree
186
204
  :pricing_provider, :inventory_provider, :pricing_provider_failure_policy,
187
205
  :inventory_provider_failure_policy, :payout_provider, :default_payouts_schedule_interval,
188
206
  :default_minimum_payout_amount, :auto_approve_sellers, :auto_approve_seller_products,
189
- :send_seller_transactional_emails, :default_commission_tax_rate, :document_number_format,
207
+ :seller_digital_assets_enabled, :seller_digital_asset_content_types,
208
+ :review_seller_digital_asset_changes, :send_seller_transactional_emails, :default_commission_tax_rate, :document_number_format,
190
209
  :order_number_prefix, :order_number_suffix, :order_number_sequence_start,
191
210
  :limit_digital_download_count, :digital_asset_authorized_clicks,
192
211
  :limit_digital_download_days, :digital_asset_authorized_days,
@@ -729,7 +748,9 @@ module Spree
729
748
  # Products without a profile fall back to this one, so it must exist
730
749
  # from the store's first moment.
731
750
  def create_default_delivery_profile
732
- Spree::DeliveryProfiles::Shipping.create!(store: self, name: 'General', default: true)
751
+ Spree::Events.disable do
752
+ Spree::DeliveryProfiles::Shipping.create!(store: self, name: 'General', default: true)
753
+ end
733
754
  end
734
755
 
735
756
  def create_default_policies
@@ -831,5 +852,13 @@ module Spree
831
852
 
832
853
  errors.add(:preferred_order_number_sequence_start, :locked_after_first_number)
833
854
  end
855
+
856
+ def seller_digital_asset_content_types_are_media_types
857
+ bad = Array(preferred_seller_digital_asset_content_types).reject { |type| type.to_s.match?(MEDIA_TYPE_FORMAT) }
858
+ return if bad.empty?
859
+
860
+ errors.add(:preferred_seller_digital_asset_content_types, :invalid,
861
+ message: I18n.t('spree.digital_assets_content_type_list_invalid', types: bad.join(', ')))
862
+ end
834
863
  end
835
864
  end
@@ -3,6 +3,7 @@ module Spree
3
3
  has_prefix_id :credit
4
4
 
5
5
  include Spree::SingleStoreResource
6
+ include Spree::ActedBy
6
7
  include Spree::HasCustomFields
7
8
  include Spree::Metadata
8
9
  include Spree::CanonicalDecimals
@@ -25,7 +26,7 @@ module Spree
25
26
  # to attach it to.
26
27
  belongs_to :customer, class_name: "::#{Spree.customer_class}", optional: true
27
28
  include Spree::DeprecatedCustomerAlias
28
- belongs_to :created_by, class_name: Spree.admin_user_class.to_s, foreign_key: 'created_by_id', optional: true
29
+ acted_by :created_by
29
30
  belongs_to :originator, polymorphic: true, optional: true
30
31
  belongs_to :refunded_order, class_name: 'Spree::Order', optional: true,
31
32
  inverse_of: :store_credit_refunds
@@ -40,8 +41,6 @@ module Spree
40
41
  validate :amount_used_less_than_or_equal_to_amount
41
42
  validate :amount_authorized_less_than_or_equal_to_amount
42
43
 
43
- delegate :email, to: :created_by, prefix: true, allow_nil: true
44
-
45
44
  # Redemption order: the oldest credit is spent first.
46
45
  scope :oldest_first, -> { order(:created_at, :id) }
47
46
  # @deprecated Removed in Spree 6.1 — store credit types are gone; use {.oldest_first}.
@@ -110,7 +109,7 @@ module Spree
110
109
 
111
110
  self.whitelisted_ransackable_attributes = %w[customer_id created_by_id amount currency memo]
112
111
  search_by :memo
113
- self.whitelisted_ransackable_associations = %w[customer created_by]
112
+ self.whitelisted_ransackable_associations = %w[customer]
114
113
  self.whitelisted_ransackable_scopes = %w[outstanding from_gift_card]
115
114
  self.ransackable_scope_types = %w[outstanding from_gift_card].index_with('boolean')
116
115
  # Staff notes and who issued the credit are back-office data.
@@ -283,6 +282,7 @@ module Spree
283
282
  amount: amount,
284
283
  customer_id: customer_id,
285
284
  created_by_id: created_by_id,
285
+ created_by_type: created_by_type,
286
286
  currency: currency,
287
287
  memo: credit_allocation_memo,
288
288
  store: store
@@ -22,10 +22,11 @@ module Spree
22
22
  # core doesn't know about registers its reason here. The bar for a new
23
23
  # value: it distinguishes a treatment that reporting or invoicing has to
24
24
  # tell apart, not merely a different amount.
25
- class_attribute :taxability_reasons,
26
- default: %w[standard_rated reduced_rated zero_rated reverse_charge
27
- intra_community_supply export customer_exempt product_exempt
28
- not_collecting not_subject_to_tax]
25
+ BUILT_IN_TAXABILITY_REASONS = %w[standard_rated reduced_rated zero_rated reverse_charge
26
+ intra_community_supply export customer_exempt product_exempt
27
+ not_collecting not_subject_to_tax].freeze
28
+
29
+ class_attribute :taxability_reasons, default: BUILT_IN_TAXABILITY_REASONS.dup
29
30
 
30
31
  # Provider payloads (jurisdiction breakdowns, external ids) read nil-safe.
31
32
  attribute :data, default: -> { {} }
@@ -393,7 +393,11 @@ module Spree
393
393
  @tax_category ||= if self[:tax_category_id].nil?
394
394
  product.tax_category
395
395
  else
396
- Spree::TaxCategory.find_by(id: self[:tax_category_id]) || product.tax_category
396
+ # The association, so a page of variants loads its
397
+ # categories together; one deleted since it was
398
+ # assigned no longer counts.
399
+ own = super
400
+ own.nil? || own.destroyed? || own.try(:deleted?) ? product.tax_category : own
397
401
  end
398
402
  end
399
403
 
@@ -466,7 +470,7 @@ module Spree
466
470
  # Returns the options text of the variant.
467
471
  # @return [String] the options text of the variant
468
472
  def options_text
469
- @options_text ||= if option_values.loaded?
473
+ @options_text ||= if read_association_in_memory?(:option_values)
470
474
  option_values.sort_by do |ov|
471
475
  ov.option_type.position
472
476
  end.map { |ov| "#{ov.option_type.label}: #{ov.label}" }.to_sentence(words_connector: ', ', two_words_connector: ', ')
@@ -663,7 +667,7 @@ module Spree
663
667
  def price_in(currency)
664
668
  currency = currency&.upcase
665
669
 
666
- price = if read_association_in_memory?(:prices) && prices.any?
670
+ price = if read_association_in_memory?(:prices)
667
671
  prices.detect { |p| p.currency == currency && p.price_list_id.nil? }
668
672
  else
669
673
  prices.base_prices.find_by(currency: currency)
@@ -864,7 +868,12 @@ module Spree
864
868
  # @return [void]
865
869
  def set_stock(count_on_hand, backorderable = nil, stock_location = nil)
866
870
  stock_location ||= default_stock_location
867
- stock_level = stock_levels.find_or_initialize_by(stock_location: stock_location)
871
+ stock_level = if stock_levels.loaded?
872
+ stock_levels.detect { |level| level.stock_location_id == stock_location.id } ||
873
+ stock_levels.build(stock_location: stock_location)
874
+ else
875
+ stock_levels.find_or_initialize_by(stock_location: stock_location)
876
+ end
868
877
  # `unless nil?`, not `if present?` — a caller passing false means it.
869
878
  stock_level.backorderable = backorderable unless backorderable.nil?
870
879
 
@@ -880,7 +889,10 @@ module Spree
880
889
  delta = count_on_hand.to_i - stock_level.count_on_hand.to_i
881
890
  return if delta.zero?
882
891
 
883
- stock_location.adjust(self, delta, reason: Spree::StockMovement.default_adjustment_reason)
892
+ # Through the level already in hand rather than StockLocation#adjust,
893
+ # which would look the same row up again.
894
+ stock_level.stock_movements.create!(quantity: delta, kind: 'adjusted',
895
+ reason: Spree::StockMovement.default_adjustment_reason)
884
896
  end
885
897
 
886
898
  def default_stock_location
@@ -967,6 +979,11 @@ module Spree
967
979
  # Variants carry no store of their own; the product owns it.
968
980
  # @return [Spree::Store, nil]
969
981
  def preference_store
982
+ # The request's store instance when it is this one, so a page of
983
+ # variants reads its settings from one record rather than a copy each.
984
+ current_store = Spree::Current.store
985
+ return current_store if current_store && product&.store_id == current_store.id
986
+
970
987
  product&.store
971
988
  end
972
989
 
@@ -1188,7 +1205,7 @@ module Spree
1188
1205
  end
1189
1206
 
1190
1207
  def set_cost_currency
1191
- self.cost_currency = Spree::Store.default.default_currency if cost_currency.blank?
1208
+ self.cost_currency = (product&.store || Spree::Store.default).default_currency if cost_currency.blank?
1192
1209
  end
1193
1210
 
1194
1211
  # Only the product's own store's locations — a new variant must not grow