spree_core 6.0.0.rc5 → 6.0.0.rc7

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 (98) 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 +19 -4
  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/spree/admin_user.rb +1 -0
  17. data/app/models/spree/base.rb +33 -1
  18. data/app/models/spree/calculator/shipping/digital_delivery.rb +3 -1
  19. data/app/models/spree/calculator/shipping/flat_rate.rb +3 -1
  20. data/app/models/spree/calculator/shipping/per_item.rb +3 -1
  21. data/app/models/spree/cart.rb +4 -1
  22. data/app/models/spree/category.rb +76 -5
  23. data/app/models/spree/channel.rb +79 -40
  24. data/app/models/spree/company.rb +45 -2
  25. data/app/models/spree/customer.rb +1 -1
  26. data/app/models/spree/customer_group.rb +59 -41
  27. data/app/models/spree/delivery_method.rb +18 -8
  28. data/app/models/spree/delivery_profile.rb +18 -0
  29. data/app/models/spree/digital_asset.rb +14 -0
  30. data/app/models/spree/event.rb +20 -5
  31. data/app/models/spree/fulfillment.rb +8 -1
  32. data/app/models/spree/fulfillment_changer.rb +3 -1
  33. data/app/models/spree/fulfillment_item.rb +11 -0
  34. data/app/models/spree/import.rb +20 -2
  35. data/app/models/spree/order_routing/strategy/rules.rb +4 -1
  36. data/app/models/spree/price_list.rb +15 -0
  37. data/app/models/spree/product/channels.rb +16 -8
  38. data/app/models/spree/product.rb +7 -2
  39. data/app/models/spree/return.rb +8 -1
  40. data/app/models/spree/search_provider/base.rb +8 -3
  41. data/app/models/spree/search_provider/database.rb +2 -10
  42. data/app/models/spree/seller.rb +35 -5
  43. data/app/models/spree/seller_balance.rb +28 -0
  44. data/app/models/spree/seller_requirements/delivery_method.rb +20 -2
  45. data/app/models/spree/stock/coordinator.rb +7 -4
  46. data/app/models/spree/stock/estimator.rb +6 -0
  47. data/app/models/spree/stock/inventory_unit_builder.rb +14 -2
  48. data/app/models/spree/stock/packer.rb +39 -5
  49. data/app/models/spree/stock/quantifier.rb +16 -0
  50. data/app/models/spree/stock_location.rb +15 -8
  51. data/app/models/spree/store.rb +31 -2
  52. data/app/models/spree/store_credit.rb +4 -4
  53. data/app/models/spree/variant.rb +23 -6
  54. data/app/models/spree/webhook_endpoint.rb +45 -11
  55. data/app/services/spree/carts/price_items.rb +3 -0
  56. data/app/services/spree/carts/split_by_seller.rb +3 -1
  57. data/app/services/spree/catalogs/set_product_terms.rb +8 -3
  58. data/app/services/spree/collections/add_products.rb +1 -1
  59. data/app/services/spree/collections/regenerate_products.rb +1 -3
  60. data/app/services/spree/collections/remove_products.rb +1 -1
  61. data/app/services/spree/newsletter/subscribe.rb +9 -1
  62. data/app/services/spree/order_groups/allocate_payments.rb +7 -5
  63. data/app/services/spree/orders/update.rb +19 -0
  64. data/app/services/spree/product_memberships.rb +16 -6
  65. data/app/services/spree/products/duplicator.rb +12 -4
  66. data/app/services/spree/search_provider/custom_field_schema.rb +10 -2
  67. data/app/services/spree/stock_levels/correct.rb +11 -4
  68. data/app/services/spree/stock_reservations/reserve.rb +1 -1
  69. data/app/services/spree/stores/provision_defaults.rb +11 -7
  70. data/app/services/spree/tags/bulk_add.rb +16 -6
  71. data/app/workflows/spree/carts/complete.rb +25 -9
  72. data/app/workflows/spree/carts/upsert_items.rb +31 -4
  73. data/app/workflows/spree/customers/anonymize.rb +2 -2
  74. data/app/workflows/spree/digital_assets/create.rb +56 -0
  75. data/app/workflows/spree/digital_assets/update.rb +44 -0
  76. data/app/workflows/spree/digital_assets/writing.rb +108 -0
  77. data/app/workflows/spree/imports/start_mapping.rb +3 -1
  78. data/app/workflows/spree/orders/complete.rb +12 -5
  79. data/app/workflows/spree/payments/capture.rb +12 -0
  80. data/app/workflows/spree/products/nested_attributes.rb +12 -28
  81. data/app/workflows/spree/products/propose.rb +9 -4
  82. data/app/workflows/spree/stock_levels/bulk_upsert.rb +28 -13
  83. data/app/workflows/spree/variants/nested_attributes.rb +35 -10
  84. data/config/locales/en.yml +10 -0
  85. data/db/migrate/20261022000001_store_credit_actor_polymorphic.rb +13 -0
  86. data/db/migrate/20261023100001_add_deactivated_at_to_spree_admin_users.rb +7 -0
  87. data/lib/generators/spree/dummy/templates/rails/test.rb +87 -0
  88. data/lib/spree/checkout/default_requirements.rb +12 -2
  89. data/lib/spree/core/dependencies.rb +5 -0
  90. data/lib/spree/core/permissions/default_catalog.rb +7 -2
  91. data/lib/spree/core/version.rb +1 -1
  92. data/lib/spree/reporting/default_vocabulary.rb +1 -1
  93. data/lib/spree/testing_support/factories/product_factory.rb +10 -4
  94. data/lib/spree/testing_support/factories/store_factory.rb +4 -1
  95. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +10 -0
  96. data/lib/tasks/backfill_tagging_tenants.rake +35 -0
  97. data/lib/tasks/seller_sample_data.rake +114 -112
  98. metadata +13 -6
@@ -422,10 +422,21 @@ module Spree
422
422
  # provider a question whose answer is discarded — and for an external
423
423
  # engine that is a billable remote call per line item, inside the
424
424
  # completion lock.
425
+ #
426
+ # Each copy is handed the cart line's variant, loaded once for every
427
+ # line, so validating the copies reads its product, stock and tax
428
+ # category for all of them at once rather than per line.
425
429
  def copy_line_items!(cart, order)
426
- cart.line_items.reload.index_with do |cart_line_item|
430
+ cart_line_items = cart.line_items.reload.to_a
431
+ ActiveRecord::Associations::Preloader.new(
432
+ records: cart_line_items,
433
+ associations: { variant: [{ product: %i[store product_publications tax_category] }, { stock_levels: %i[stock_location active_stock_reservations] }] }
434
+ ).call
435
+
436
+ cart_line_items.index_with do |cart_line_item|
427
437
  attributes = cart_line_item.attributes.except('id', 'cart_id', 'created_at', 'updated_at')
428
438
  line_item = order.line_items.new(attributes.merge('order_id' => order.id))
439
+ line_item.variant = cart_line_item.variant if cart_line_item.variant
429
440
  line_item.skip_tax_estimation = true
430
441
  line_item.save!
431
442
  line_item
@@ -434,9 +445,12 @@ module Spree
434
445
 
435
446
  # @return [Hash{Integer => Integer}] cart fulfillment id → order fulfillment id
436
447
  def copy_fulfillments!(cart, order, line_item_map)
437
- line_item_id_map = line_item_map.transform_keys(&:id).transform_values(&:id)
448
+ line_items_by_cart_line_item_id = line_item_map.transform_keys(&:id)
449
+
450
+ cart_fulfillments = cart.fulfillments.reload.to_a
451
+ ActiveRecord::Associations::Preloader.new(records: cart_fulfillments, associations: %i[selected_delivery_rate fulfillment_items]).call
438
452
 
439
- cart.fulfillments.reload.each_with_object({}) do |cart_fulfillment, map|
453
+ cart_fulfillments.each_with_object({}) do |cart_fulfillment, map|
440
454
  attributes = cart_fulfillment.attributes.except('id', 'cart_id', 'number', 'tracking', 'created_at', 'updated_at')
441
455
  fulfillment = order.fulfillments.create!(attributes.merge('order_id' => order.id, 'address_id' => order.ship_address_id))
442
456
 
@@ -447,13 +461,15 @@ module Spree
447
461
 
448
462
  cart_fulfillment.fulfillment_items.each do |item|
449
463
  item_attributes = item.attributes.except('id', 'created_at', 'updated_at')
450
- fulfillment.fulfillment_items.create!(
451
- item_attributes.merge(
452
- 'fulfillment_id' => fulfillment.id,
453
- 'order_id' => order.id,
454
- 'line_item_id' => line_item_id_map.fetch(item.line_item_id)
455
- )
464
+ line_item = line_items_by_cart_line_item_id.fetch(item.line_item_id)
465
+ fulfillment_item = fulfillment.fulfillment_items.new(
466
+ item_attributes.merge('fulfillment_id' => fulfillment.id, 'order_id' => order.id)
456
467
  )
468
+ # Objects rather than ids, so validating the copy does not look
469
+ # each of them up again.
470
+ fulfillment_item.line_item = line_item
471
+ fulfillment_item.variant = line_item.variant if line_item.variant_id == item.variant_id
472
+ fulfillment_item.save!
457
473
  end
458
474
 
459
475
  map[cart_fulfillment.id] = fulfillment.id
@@ -90,9 +90,10 @@ module Spree
90
90
  # applied.
91
91
  def resolve_items
92
92
  store = cart.store || Spree::Current.store
93
+ requested_items = Array(items).map { |item_params| item_params.to_h.deep_symbolize_keys }
94
+ prefetch_added_variants(store, requested_items)
93
95
 
94
- @resolved_items = Array(items).filter_map do |item_params|
95
- item_params = item_params.to_h.deep_symbolize_keys
96
+ @resolved_items = requested_items.filter_map do |item_params|
96
97
  quantity = (item_params[:quantity] || 1).to_i
97
98
  variant = resolve_variant(store, item_params[:variant_id], removing: quantity <= 0)
98
99
  next if variant.nil?
@@ -357,18 +358,44 @@ module Spree
357
358
  cart.line_items.detect { |line_item| line_item.variant_id.to_s == variant_id.to_s }&.variant ||
358
359
  Spree::Variant.with_deleted.find_by_param(variant_id)
359
360
  else
360
- variant = store.variants.find_by_param(variant_id)
361
+ variant = @added_variants[variant_param_id(variant_id)]
361
362
  # +orderable_variants+ narrows what a request may add. A line already
362
363
  # in the cart is a quantity edit and resolves as before — checkout
363
364
  # judges it — so one stale line cannot fail a whole cart's update.
364
365
  if variant && orderable_variants && cart.line_items.none? { |line_item| line_item.variant_id == variant.id }
365
- variant = orderable_variants.find_by(id: variant.id)
366
+ variant = nil unless @orderable_added_variant_ids.include?(variant.id)
366
367
  end
367
368
 
368
369
  variant || raise(ActiveRecord::RecordNotFound.new("Variant '#{variant_id}' not found in this store", 'Spree::Variant', 'id', variant_id))
369
370
  end
370
371
  end
371
372
 
373
+ # Every variant the batch adds, looked up through the store in one
374
+ # query rather than one per item, and which of them the buyer may order.
375
+ def prefetch_added_variants(store, requested_items)
376
+ ids = requested_items.filter_map do |item_params|
377
+ variant_param_id(item_params[:variant_id]) if (item_params[:quantity] || 1).to_i.positive?
378
+ end
379
+
380
+ @added_variants = ids.empty? ? {} : store.variants.where(id: ids.uniq).preload_associations_lazily.index_by(&:id)
381
+ Spree::Stock::Quantifier.preload(@added_variants.values)
382
+ @orderable_added_variant_ids = if orderable_variants && @added_variants.any?
383
+ orderable_variants.where(id: @added_variants.keys).pluck(:id).to_set
384
+ else
385
+ Set.new
386
+ end
387
+ end
388
+
389
+ # The id a variant param names: a prefixed id, or a bare integer id
390
+ # kept for backwards compatibility.
391
+ #
392
+ # @return [Integer, nil]
393
+ def variant_param_id(param)
394
+ return nil if param.blank?
395
+
396
+ Spree::Variant.decode_prefixed_id(param) || (param.to_s.to_i if param.to_s.match?(/\A\d+\z/))
397
+ end
398
+
372
399
  def warn(index, item, code, message)
373
400
  warnings << Spree::Carts::ItemWarning.new(item_index: index, variant: item.variant, code: code, message: message)
374
401
  end
@@ -318,7 +318,7 @@ module Spree
318
318
  }
319
319
 
320
320
  owner_ids.each do |owner_type, ids|
321
- Spree::ShippingLabel.where(owner_type: owner_type, owner_id: ids).find_each do |label|
321
+ Spree::ShippingLabel.where(owner_type: owner_type, owner_id: ids).with_attached_file.find_each do |label|
322
322
  purge_after_commit(label.file)
323
323
  end
324
324
  end
@@ -408,7 +408,7 @@ module Spree
408
408
  # complete copy of everything being erased, sitting behind a link that
409
409
  # stays live for days. Rewriting the row's email would leave the
410
410
  # payload itself untouched and still fetchable.
411
- requests.each do |data_request|
411
+ requests.with_attached_export_file.each do |data_request|
412
412
  purge_after_commit(data_request.export_file)
413
413
  end
414
414
 
@@ -0,0 +1,56 @@
1
+ module Spree
2
+ module DigitalAssets
3
+ # Adds a downloadable file (or a provider-backed deliverable) to a product.
4
+ #
5
+ # The single gate for new assets: the operator's and the seller's nested
6
+ # endpoints and the `digital_assets` list on a product write all run
7
+ # through it, so a :validate handler sees every file a catalog gains.
8
+ # A host that scans uploads registers on `digital_assets.create.validate`
9
+ # for a quick check, or on `after_create` to queue a slower one; the bytes
10
+ # are already in storage when either fires.
11
+ #
12
+ # Passing `seller:` applies the marketplace's rules for a seller's file —
13
+ # whether sellers may upload at all, which content types, and whether an
14
+ # approved listing goes back to review. The operator's own writes pass none.
15
+ class Create < Spree::Workflow
16
+ include Spree::DigitalAssets::Writing
17
+
18
+ hooks :validate, :after_create
19
+
20
+ # The unsaved record a :validate handler reads.
21
+ attr_reader :digital_asset
22
+
23
+ # @param product [Spree::Product]
24
+ # @param attributes [Hash] `signed_id`, `variant_id`, the download limits
25
+ # and, for the operator, the provider fields
26
+ # @param seller [Spree::Seller, nil] the seller this file is written for
27
+ # @param submitted_by [Spree.admin_user_class, nil] who made the change,
28
+ # named on the review it may reopen
29
+ # @return [Spree::ServiceModule::Result] value is the digital asset
30
+ def perform(product:, attributes: {}, seller: nil, submitted_by: nil)
31
+ super
32
+
33
+ step :build_digital_asset
34
+ step :resolve_upload
35
+ step :enforce_seller_rules
36
+ run_hooks :validate
37
+
38
+ ApplicationRecord.transaction do
39
+ step :attach_upload
40
+ step :save_digital_asset
41
+ step :reopen_review
42
+ run_hooks :after_create
43
+ end
44
+
45
+ success(digital_asset)
46
+ end
47
+
48
+ private
49
+
50
+ def build_digital_asset
51
+ @digital_asset = Spree::DigitalAsset.new
52
+ assign_attributes
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,44 @@
1
+ module Spree
2
+ module DigitalAssets
3
+ # Changes a digital asset: its download limits, the variant it hangs off,
4
+ # or the file itself. Replacing the file keeps every already-issued link
5
+ # working, since links resolve through the asset rather than the blob.
6
+ #
7
+ # Handlers reading `digital_asset.changes` in :validate see the pending
8
+ # edit; `blob` is the replacement file, when the payload carries one.
9
+ class Update < Spree::Workflow
10
+ include Spree::DigitalAssets::Writing
11
+
12
+ hooks :validate, :after_update
13
+
14
+ # @param digital_asset [Spree::DigitalAsset]
15
+ # @param attributes [Hash] see Spree::DigitalAssets::Create
16
+ # @param seller [Spree::Seller, nil] the seller this change is written for
17
+ # @param submitted_by [Spree.admin_user_class, nil] who made the change
18
+ # @return [Spree::ServiceModule::Result] value is the digital asset
19
+ def perform(digital_asset:, attributes: {}, seller: nil, submitted_by: nil)
20
+ super
21
+
22
+ step :assign_attributes
23
+ step :resolve_upload
24
+ step :enforce_seller_rules
25
+ run_hooks :validate
26
+
27
+ ApplicationRecord.transaction do
28
+ step :attach_upload
29
+ step :save_digital_asset
30
+ step :reopen_review
31
+ run_hooks :after_update
32
+ end
33
+
34
+ success(digital_asset)
35
+ end
36
+
37
+ private
38
+
39
+ def product
40
+ digital_asset.product
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,108 @@
1
+ module Spree
2
+ module DigitalAssets
3
+ # The steps a file write shares between Create and Update: the upload the
4
+ # payload names, the marketplace's rules for a seller's file, and sending
5
+ # an approved listing back to review once what it delivers has changed.
6
+ module Writing
7
+ extend ActiveSupport::Concern
8
+
9
+ # Why a product reopened review: recorded on the submission row so the
10
+ # operator's queue says what changed.
11
+ REVIEW_REASON = 'digital_asset_changed'.freeze
12
+
13
+ included do
14
+ # The uploaded file the payload names, resolved before it is attached.
15
+ attr_reader :blob
16
+ end
17
+
18
+ private
19
+
20
+ def assign_attributes
21
+ attrs = attributes.to_h.with_indifferent_access
22
+ @signed_id = attrs.delete(:signed_id)
23
+ variant_id = attrs.delete(:variant_id)
24
+
25
+ digital_asset.assign_attributes(attrs)
26
+ # Through the product's own variants, so an asset reached through one
27
+ # product can never point at another product's variant.
28
+ digital_asset.variant = product.variants.find_by_param!(variant_id) if variant_id.present?
29
+ digital_asset.variant ||= product.default_variant
30
+ end
31
+
32
+ def resolve_upload
33
+ return if @signed_id.blank?
34
+
35
+ @blob = ActiveStorage::Blob.find_signed(@signed_id)
36
+ return refuse(:digital_assets_upload_not_found) if blob.nil?
37
+
38
+ # Attaching never moves a blob between storage services, so a file
39
+ # uploaded to the public bucket would stay publicly readable while
40
+ # looking attached.
41
+ unless blob.service_name.to_s == Spree.private_storage_service_name.to_s
42
+ return refuse(:digital_assets_attachment_must_be_private)
43
+ end
44
+
45
+ # A provider-backed asset delivers through its provider, so a file on
46
+ # it would show in the panel but never reach a buyer. An unregistered
47
+ # provider is left to the model's validation rather than asked.
48
+ return unless Spree.digital_asset_providers.include?(digital_asset.provider_class)
49
+
50
+ refuse(:digital_assets_provider_takes_no_file) unless digital_asset.provider_class.requires_attachment?
51
+ end
52
+
53
+ # What the marketplace lets a seller attach. The operator's own writes
54
+ # are not subject to these: the operator is who the rules protect.
55
+ def enforce_seller_rules
56
+ return if seller.nil?
57
+
58
+ refuse(:digital_assets_sellers_disabled) unless store.preferred_seller_digital_assets_enabled
59
+ return if blob.nil?
60
+
61
+ # Identifying reads the file's own signature where it has one, so a
62
+ # renamed executable cannot pass as a PDF; a format with no signature
63
+ # (plain text) keeps the type the client declared.
64
+ blob.identify_without_saving
65
+ return if content_type_accepted?(blob.content_type)
66
+
67
+ refuse(:digital_assets_content_type_not_accepted, content_type: blob.content_type)
68
+ end
69
+
70
+ # An entry names one type (`audio/mpeg`) or a whole family (`audio/*`).
71
+ def content_type_accepted?(content_type)
72
+ family = "#{content_type.to_s.split('/').first}/*"
73
+ store.preferred_seller_digital_asset_content_types.any? { |accepted| accepted.casecmp?(content_type.to_s) || accepted == family }
74
+ end
75
+
76
+ def attach_upload
77
+ digital_asset.attachment.attach(blob) if blob
78
+ end
79
+
80
+ def save_digital_asset
81
+ failure(digital_asset) unless digital_asset.save
82
+ end
83
+
84
+ # A seller changing what an approved listing delivers reopens its review.
85
+ # The operator's own change does not: they are the reviewer.
86
+ def reopen_review
87
+ return if seller.nil? || blob.nil? || !product.active?
88
+ return unless store.preferred_review_seller_digital_asset_changes
89
+ return if store.preferred_auto_approve_seller_products
90
+
91
+ result = Spree.product_propose_workflow.call(product: product, submitted_by: submitted_by, reason: REVIEW_REASON)
92
+ return if result.success?
93
+
94
+ digital_asset.errors.add(:base, result.error.to_s)
95
+ failure(digital_asset)
96
+ end
97
+
98
+ def store
99
+ product.store
100
+ end
101
+
102
+ def refuse(key, **options)
103
+ digital_asset.errors.add(:base, I18n.t("spree.#{key}", **options))
104
+ failure(digital_asset)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -33,8 +33,10 @@ module Spree
33
33
  # TODO: seed from the previous import of the same type, so a merchant
34
34
  # doesn't map the same columns twice.
35
35
  def build_mappings
36
+ existing = import.mappings.index_by(&:schema_field)
37
+
36
38
  import.schema_fields.each do |schema_field|
37
- mapping = import.mappings.find_or_create_by!(schema_field: schema_field[:name])
39
+ mapping = existing[schema_field[:name]] || import.mappings.build(schema_field: schema_field[:name])
38
40
  mapping.try_to_auto_assign_file_column(import.csv_headers)
39
41
  mapping.save!
40
42
  end
@@ -140,11 +140,16 @@ module Spree
140
140
  end
141
141
 
142
142
  def allocate_fulfillment_stock(fulfillment)
143
- fulfillment.manifest.each do |item|
144
- next unless item.variant.track_inventory?
145
- next unless item.quantity.positive?
143
+ items = fulfillment.manifest.select { |item| item.variant.track_inventory? && item.quantity.positive? }
144
+ return if items.empty?
146
145
 
147
- fulfillment.stock_location.allocate(item.variant, item.quantity, fulfillment)
146
+ stock_location = fulfillment.stock_location
147
+ # Lowest id first, as StockLocation#stock_level reads.
148
+ stock_levels = stock_location.stock_levels.where(variant_id: items.map { |item| item.variant.id }).order(:id).
149
+ group_by(&:variant_id).transform_values(&:first)
150
+
151
+ items.each do |item|
152
+ stock_location.allocate(item.variant, item.quantity, fulfillment, stock_level: stock_levels[item.variant.id])
148
153
  end
149
154
  end
150
155
 
@@ -240,7 +245,9 @@ module Spree
240
245
  # wildcard subscribers dedupe on the metadata marker.
241
246
  def publish_order_placed
242
247
  metadata = { notify_customer: order.notify_customer }
243
- payload = order.event_payload
248
+ # The payload renders every line and parcel, read back through a lazily
249
+ # preloading query so each association loads once rather than per row.
250
+ payload = Spree::Order.where(id: order.id).preload_associations_lazily.take!.event_payload
244
251
  order.publish_event('order.placed', payload, metadata)
245
252
  order.publish_event('order.completed', payload, metadata.merge(deprecated_alias_of: 'order.placed'))
246
253
  end
@@ -23,6 +23,7 @@ module Spree
23
23
  @amount = capture_amount(amount)
24
24
 
25
25
  step :ensure_capturable
26
+ step :ensure_within_payment_amount
26
27
  # Veto point — fraud holds, manual review. Before any money moves.
27
28
  run_hooks :validate
28
29
  run_hooks :before_capture
@@ -69,6 +70,17 @@ module Spree
69
70
  failure(payment, :payment_not_capturable)
70
71
  end
71
72
 
73
+ # A capture takes some or all of what was authorized, never more: a
74
+ # gateway may let a larger figure through, and the books would then
75
+ # carry a capture the authorization never covered. Nor nothing, or less
76
+ # than nothing — every caller gets the same rule, not only the API.
77
+ def ensure_within_payment_amount
78
+ failure(payment, :capture_amount_not_positive) unless @amount.positive?
79
+ return if @amount <= payment.amount
80
+
81
+ failure(payment, :capture_exceeds_payment_amount)
82
+ end
83
+
72
84
  def claim
73
85
  halt!(payment) unless payment.started_processing!
74
86
  rescue Spree::Core::GatewayError => error
@@ -195,6 +195,10 @@ module Spree
195
195
  # accumulate copies. Omitting an entry leaves it alone; removal is the
196
196
  # nested digital_assets DELETE endpoint's job.
197
197
  #
198
+ # Delegates to the digital asset workflows, so a file written as part of
199
+ # a product payload passes the same gate — and the same :validate
200
+ # handlers — as one written through the digital assets endpoint.
201
+ #
198
202
  # @param product [Spree::Product]
199
203
  # @param digital_assets_params [Array<Hash>, nil]
200
204
  # @return [void]
@@ -203,38 +207,18 @@ module Spree
203
207
 
204
208
  digital_assets_params.each do |raw|
205
209
  attrs = (raw.respond_to?(:to_h) ? raw.to_h : raw).with_indifferent_access
206
-
207
210
  asset_id = attrs.delete(:id)
208
- signed_id = attrs.delete(:signed_id)
209
- variant_id = attrs.delete(:variant_id)
210
-
211
- # An id patches an existing asset; without one this is a new asset on
212
- # the named variant (or the default). A patch for an asset that is
213
- # gone is skipped rather than recreated.
214
- if asset_id.present?
215
- asset = product.digital_assets.find_by_param(asset_id) || next
216
- else
217
- target = variant_id.present? ? product.variants.find_by_param!(variant_id) : product.default_variant
218
- next unless target
219
211
 
220
- asset = target.digital_assets.build
221
- end
222
-
223
- asset.assign_attributes(attrs)
224
-
225
- if signed_id.present?
226
- # Attaching never moves a blob between storage services, so a file
227
- # uploaded to the public bucket would stay publicly readable. Refuse
228
- # it rather than attaching it wrongly.
229
- blob = ActiveStorage::Blob.find_signed(signed_id)
230
- unless blob && blob.service_name.to_s == Spree.private_storage_service_name.to_s
231
- asset.errors.add(:attachment, :digital_assets_attachment_must_be_private, message: I18n.t('spree.digital_assets_attachment_must_be_private'))
232
- raise ActiveRecord::RecordInvalid.new(asset)
212
+ result =
213
+ if asset_id.present?
214
+ # A patch for an asset that is gone is skipped rather than recreated.
215
+ asset = product.digital_assets.find_by_param(asset_id) || next
216
+ Spree.digital_asset_update_workflow.call(digital_asset: asset, attributes: attrs)
217
+ else
218
+ Spree.digital_asset_create_workflow.call(product: product, attributes: attrs)
233
219
  end
234
220
 
235
- asset.attachment.attach(signed_id)
236
- end
237
- asset.save!
221
+ raise ActiveRecord::RecordInvalid, result.value if result.failure?
238
222
  end
239
223
  end
240
224
  end
@@ -13,16 +13,20 @@ module Spree
13
13
 
14
14
  # @param product [Spree::Product]
15
15
  # @param submitted_by [Spree.admin_user_class, nil] who asked
16
+ # @param reason [String, nil] why a listing already on sale goes back to
17
+ # review — what changed on it. Recorded on the submission row. Without
18
+ # one, only a draft or a rejected product can be submitted.
16
19
  # @return [Spree::ServiceModule::Result] value is the product
17
- def perform(product:, submitted_by: nil)
20
+ def perform(product:, submitted_by: nil, reason: nil)
18
21
  super
19
22
 
20
23
  # Only a listing that is being worked on can be submitted. Guarding
21
24
  # `active?` alone let an archived product through — and with
22
25
  # auto-approval on, a withdrawn listing went straight back on sale
23
26
  # without anybody reviewing it. Re-submitting one already in review
24
- # is equally meaningless.
25
- unless product.draft? || product.rejected?
27
+ # is equally meaningless. An active listing reopens review only when
28
+ # the caller says what changed, which no seller-facing action does.
29
+ unless product.draft? || product.rejected? || (reason.present? && product.active?)
26
30
  reject!(:cannot_propose, message: I18n.t('activerecord.errors.models.spree/product.attributes.base.cannot_propose'))
27
31
  end
28
32
 
@@ -51,7 +55,8 @@ module Spree
51
55
  # one: the trail is how many times this was asked for, and rewriting
52
56
  # the last answer would erase that.
53
57
  def open_submission
54
- product.submissions.create!(status: 'pending', submitted_by: submitted_by)
58
+ product.submissions.create!(status: 'pending', submitted_by: submitted_by,
59
+ metadata: reason.present? ? { 'reason' => reason } : {})
55
60
  end
56
61
 
57
62
  def auto_approve?
@@ -35,9 +35,10 @@ module Spree
35
35
  # means the later line, not the sum of both.
36
36
  deduped = keyed.reverse.uniq { |row| [row[:variant_id].to_s, row[:stock_location_id].to_s] }.reverse
37
37
 
38
+ existing = existing_stock_levels(deduped)
38
39
  count = 0
39
40
  Spree::StockLevel.transaction do
40
- deduped.each { |row| count += 1 if apply_row(row) }
41
+ deduped.each { |row| count += 1 if apply_row(row, existing) }
41
42
  end
42
43
 
43
44
  success(stock_level_count: count)
@@ -52,27 +53,41 @@ module Spree
52
53
  # happens to name — including another merchant's. One caller decoding
53
54
  # ids correctly does not make the workflow safe for the next one.
54
55
  def resolve(rows)
55
- variants = scoped_ids(store_variants, Spree::Variant, rows.map { |row| row[:variant_id] })
56
- locations = scoped_ids(store_stock_locations, Spree::StockLocation,
57
- rows.map { |row| row[:stock_location_id] })
56
+ @variants = scoped_records(store_variants, Spree::Variant, rows.map { |row| row[:variant_id] })
57
+ @stock_locations = scoped_records(store_stock_locations, Spree::StockLocation,
58
+ rows.map { |row| row[:stock_location_id] })
59
+ @variants_by_id = @variants.values.index_by(&:id)
60
+ @stock_locations_by_id = @stock_locations.values.index_by(&:id)
58
61
 
59
62
  rows.map do |row|
60
63
  row.merge(
61
- variant_id: variants[row[:variant_id]],
62
- stock_location_id: locations[row[:stock_location_id]]
64
+ variant_id: @variants[row[:variant_id]]&.id,
65
+ stock_location_id: @stock_locations[row[:stock_location_id]]&.id
63
66
  ).compact
64
67
  end
65
68
  end
66
69
 
67
70
  # Accepts a prefixed id or a raw one: the Admin API speaks prefixed,
68
71
  # while the importer and seeds hold records they already loaded.
69
- def scoped_ids(scope, model, values)
72
+ #
73
+ # @return [Hash{Object => ActiveRecord::Base}] each value as given => this store's record
74
+ def scoped_records(scope, model, values)
70
75
  decoded = values.compact.uniq.index_with do |value|
71
76
  model.decode_own_prefixed_id(value) || value
72
77
  end
73
- found = scope.where(id: decoded.values.compact).pluck(:id).map(&:to_s).to_set
78
+ found = scope.where(id: decoded.values.compact).index_by { |record| record.id.to_s }
79
+
80
+ decoded.transform_values { |id| found[id.to_s] if id }.compact
81
+ end
74
82
 
75
- decoded.transform_values { |id| id if id && found.include?(id.to_s) }
83
+ # Every shelf the feed names, in one query rather than one per row.
84
+ # Lowest id wins, as an unordered lookup per pair would return.
85
+ def existing_stock_levels(rows)
86
+ Spree::StockLevel.
87
+ where(variant_id: rows.map { |row| row[:variant_id] }.uniq,
88
+ stock_location_id: rows.map { |row| row[:stock_location_id] }.uniq).
89
+ order(id: :desc).
90
+ index_by { |stock_level| [stock_level.variant_id.to_s, stock_level.stock_location_id.to_s] }
76
91
  end
77
92
 
78
93
  def store_variants
@@ -83,10 +98,10 @@ module Spree
83
98
  @store.stock_locations
84
99
  end
85
100
 
86
- def apply_row(row)
87
- stock_level = Spree::StockLevel.find_or_initialize_by(
88
- variant_id: row[:variant_id], stock_location_id: row[:stock_location_id]
89
- )
101
+ def apply_row(row, existing)
102
+ stock_level = existing[[row[:variant_id].to_s, row[:stock_location_id].to_s]] || Spree::StockLevel.new
103
+ stock_level.variant = @variants_by_id.fetch(row[:variant_id])
104
+ stock_level.stock_location = @stock_locations_by_id.fetch(row[:stock_location_id])
90
105
  stock_level.backorderable = row[:backorderable] if row.key?(:backorderable)
91
106
  stock_level.save! if stock_level.new_record? || stock_level.changed?
92
107
 
@@ -48,6 +48,8 @@ module Spree
48
48
  return if prices_params.nil?
49
49
 
50
50
  currencies = []
51
+ # Loaded once so each currency's write finds its row in memory.
52
+ variant.prices.load if variant.persisted?
51
53
 
52
54
  Array(prices_params).each do |raw|
53
55
  price = raw.to_h.with_indifferent_access
@@ -59,7 +61,8 @@ module Spree
59
61
 
60
62
  return unless variant.persisted?
61
63
 
62
- variant.prices.base_prices.where.not(currency: currencies).destroy_all
64
+ stale = variant.prices.select { |price| price.persisted? && price.price_list_id.nil? && currencies.exclude?(price.currency) }
65
+ variant.prices.destroy(*stale) if stale.any?
63
66
  end
64
67
 
65
68
  # @param variant [Spree::Variant]
@@ -68,22 +71,44 @@ module Spree
68
71
  def apply_stock_levels(variant, stock_levels_params)
69
72
  return if stock_levels_params.blank?
70
73
 
71
- Array(stock_levels_params).each do |raw|
72
- row = raw.to_h.with_indifferent_access
73
- next if row[:stock_location_id].blank?
74
+ rows = Array(stock_levels_params).map { |raw| raw.to_h.with_indifferent_access }
75
+ rows.reject! { |row| row[:stock_location_id].blank? }
76
+ return if rows.empty?
74
77
 
75
- # Scoped through the product's own store: Spree::StockLocation
76
- # lookup is global, so an id belonging to another store would
77
- # otherwise put this variant's stock in that store's warehouse.
78
- location = stock_location_for(variant, row[:stock_location_id])
78
+ locations = stock_locations_by_param(variant, rows.map { |row| row[:stock_location_id] })
79
+ # Loaded once so each row's write finds its level in memory.
80
+ variant.stock_levels.load if variant.persisted?
81
+
82
+ rows.each do |row|
83
+ location = locations[row[:stock_location_id].to_s]
79
84
  next if location.nil?
80
85
 
81
86
  variant.set_stock(row[:count_on_hand], row[:backorderable], location)
82
87
  end
88
+
89
+ # A count change locks and reloads its level, so the locations the
90
+ # response's stock figures read are loaded for every level afterwards.
91
+ ActiveRecord::Associations::Preloader.new(records: variant.stock_levels.to_a, associations: :stock_location).call
83
92
  end
84
93
 
85
- def stock_location_for(variant, param)
86
- variant.product&.store&.stock_locations&.find_by_param(param)
94
+ # Resolves every stock location the payload names in one read. Scoped
95
+ # through the product's own store: Spree::StockLocation lookup is
96
+ # global, so an id belonging to another store would otherwise put this
97
+ # variant's stock in that store's warehouse. A store keeps few
98
+ # locations, so loading them beats a lookup per row.
99
+ #
100
+ # @return [Hash{String => Spree::StockLocation}] keyed by both the
101
+ # prefixed id and the raw id, which is what the payload may carry
102
+ def stock_locations_by_param(variant, params)
103
+ scope = variant.product&.store&.stock_locations
104
+ return {} if scope.nil? || params.empty?
105
+
106
+ wanted = params.map(&:to_s).uniq
107
+ scope.each_with_object({}) do |location, memo|
108
+ [location.prefixed_id.to_s, location.id.to_s].each do |key|
109
+ memo[key] = location if wanted.include?(key)
110
+ end
111
+ end
87
112
  end
88
113
  end
89
114
  end