spree_core 6.0.0.beta2 → 6.0.0.beta3

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/payments/handle_webhook_job.rb +1 -0
  3. data/app/jobs/spree/sample_data/load_job.rb +7 -2
  4. data/app/jobs/spree/seller_transfers/execute_pending_job.rb +1 -0
  5. data/app/mailers/spree/admin_user_mailer.rb +2 -2
  6. data/app/models/concerns/spree/customer_methods.rb +17 -1
  7. data/app/models/concerns/spree/has_number.rb +3 -2
  8. data/app/models/concerns/spree/prefixed_id.rb +4 -1
  9. data/app/models/concerns/spree/purchase/totals.rb +3 -1
  10. data/app/models/concerns/spree/ransackable_attributes.rb +38 -6
  11. data/app/models/concerns/spree/translatable_resource.rb +6 -7
  12. data/app/models/concerns/spree/webhook_payload_redaction.rb +132 -21
  13. data/app/models/spree/adjusters/promotion.rb +1 -1
  14. data/app/models/spree/authentication/lockout.rb +34 -0
  15. data/app/models/spree/authentication/strategies/email_password_strategy.rb +4 -12
  16. data/app/models/spree/authentication/strategies/oidc_strategy.rb +1 -1
  17. data/app/models/spree/claim.rb +1 -0
  18. data/app/models/spree/commission_line.rb +4 -2
  19. data/app/models/spree/commission_rule.rb +1 -1
  20. data/app/models/spree/coupon_code.rb +14 -2
  21. data/app/models/spree/current.rb +15 -0
  22. data/app/models/spree/exchange.rb +1 -0
  23. data/app/models/spree/export.rb +8 -5
  24. data/app/models/spree/exports/customers.rb +2 -2
  25. data/app/models/spree/fulfillment_group.rb +127 -0
  26. data/app/models/spree/gift_card.rb +9 -0
  27. data/app/models/spree/invitation.rb +18 -5
  28. data/app/models/spree/number_generators/sequential.rb +2 -2
  29. data/app/models/spree/option_value.rb +7 -0
  30. data/app/models/spree/order.rb +35 -12
  31. data/app/models/spree/order_group.rb +78 -6
  32. data/app/models/spree/payment_source.rb +1 -1
  33. data/app/models/spree/payment_split.rb +24 -0
  34. data/app/models/spree/price.rb +74 -0
  35. data/app/models/spree/product.rb +4 -0
  36. data/app/models/spree/product_option_type.rb +11 -0
  37. data/app/models/spree/promotion/actions/create_line_items.rb +92 -30
  38. data/app/models/spree/promotion/rules/category.rb +14 -9
  39. data/app/models/spree/promotion/rules/product.rb +17 -1
  40. data/app/models/spree/promotion.rb +1 -1
  41. data/app/models/spree/promotion_action.rb +12 -1
  42. data/app/models/spree/promotion_handler/coupon.rb +50 -5
  43. data/app/models/spree/promotion_rule.rb +14 -0
  44. data/app/models/spree/return.rb +16 -6
  45. data/app/models/spree/return_line_item.rb +19 -0
  46. data/app/models/spree/search_provider/database.rb +10 -2
  47. data/app/models/spree/seller_payout.rb +3 -1
  48. data/app/models/spree/seller_transfer.rb +3 -1
  49. data/app/models/spree/store_credit.rb +4 -0
  50. data/app/models/spree/translations/batch.rb +4 -2
  51. data/app/models/spree/user_identity.rb +10 -6
  52. data/app/models/spree/variant.rb +8 -3
  53. data/app/models/spree/webhook_delivery.rb +28 -0
  54. data/app/models/spree/webhook_endpoint.rb +14 -0
  55. data/app/presenters/spree/csv/order_line_item_presenter.rb +2 -2
  56. data/app/services/spree/carts/split_by_seller.rb +22 -12
  57. data/app/services/spree/companies/add_member.rb +8 -2
  58. data/app/services/spree/imports/row_processors/price_list_price.rb +6 -0
  59. data/app/services/spree/imports/row_processors/product_translation.rb +1 -1
  60. data/app/services/spree/imports/row_processors/product_variant.rb +17 -11
  61. data/app/services/spree/order_groups/allocate_payments.rb +3 -0
  62. data/app/services/spree/orders/build_fulfillments.rb +6 -0
  63. data/app/services/spree/orders/update_statuses.rb +8 -2
  64. data/app/services/spree/prices/bulk_upsert.rb +186 -47
  65. data/app/services/spree/sample_data/import_builder.rb +1 -1
  66. data/app/services/spree/sample_data/loader.rb +54 -40
  67. data/app/services/spree/seeds/all.rb +1 -26
  68. data/app/services/spree/seeds/allowed_origins.rb +5 -4
  69. data/app/services/spree/seeds/api_keys.rb +10 -9
  70. data/app/services/spree/seeds/channels.rb +9 -8
  71. data/app/services/spree/seeds/commission_rates.rb +21 -20
  72. data/app/services/spree/seeds/customer_groups.rb +5 -4
  73. data/app/services/spree/seeds/digital_delivery.rb +7 -8
  74. data/app/services/spree/seeds/payment_methods.rb +12 -11
  75. data/app/services/spree/seeds/product_types.rb +7 -8
  76. data/app/services/spree/seeds/returns_environment.rb +12 -11
  77. data/app/services/spree/seeds/roles.rb +8 -7
  78. data/app/services/spree/seeds/saved_reports.rb +19 -20
  79. data/app/services/spree/seeds/seller_requirements.rb +5 -4
  80. data/app/services/spree/seeds/store_resources.rb +41 -0
  81. data/app/services/spree/seeds/store_scoped.rb +15 -0
  82. data/app/services/spree/seeds/tax_categories.rb +10 -9
  83. data/app/services/spree/stock_locations/stock_items/create.rb +1 -1
  84. data/app/services/spree/stock_locations/stock_levels/create.rb +19 -3
  85. data/app/subscribers/spree/order_status_subscriber.rb +15 -7
  86. data/app/workflows/spree/carts/add_item.rb +5 -7
  87. data/app/workflows/spree/carts/complete.rb +20 -64
  88. data/app/workflows/spree/carts/recalculate_totals.rb +3 -1
  89. data/app/workflows/spree/claims/create.rb +31 -1
  90. data/app/workflows/spree/claims/resolve.rb +7 -11
  91. data/app/workflows/spree/exchanges/create.rb +11 -0
  92. data/app/workflows/spree/exchanges/fulfill.rb +21 -13
  93. data/app/workflows/spree/orders/complete.rb +99 -2
  94. data/app/workflows/spree/price_lists/update.rb +17 -0
  95. data/app/workflows/spree/refunds/order_payments.rb +55 -1
  96. data/app/workflows/spree/returns/create.rb +9 -15
  97. data/app/workflows/spree/returns/refund.rb +15 -24
  98. data/app/workflows/spree/returns/returnable_quantity.rb +53 -0
  99. data/app/workflows/spree/sellers/invite.rb +1 -1
  100. data/config/locales/en.yml +7 -119
  101. data/db/migrate/20260916000001_add_email_delivery_flags_to_spree_order_groups.rb +14 -0
  102. data/db/migrate/20260922000001_add_refunded_order_to_spree_store_credits.rb +5 -0
  103. data/db/migrate/20260923000001_change_spree_user_identities_tokens_to_text.rb +10 -0
  104. data/db/sample_data/channels.rb +1 -1
  105. data/db/sample_data/collections.rb +1 -1
  106. data/db/sample_data/custom_field_definitions.rb +1 -1
  107. data/db/sample_data/digital_products.rb +1 -1
  108. data/db/sample_data/fulfillment.rb +1 -1
  109. data/db/sample_data/options.rb +4 -2
  110. data/db/sample_data/orders.rb +9 -10
  111. data/db/sample_data/payment_methods.rb +18 -18
  112. data/db/sample_data/product_type_categories.rb +1 -1
  113. data/db/sample_data/product_types.rb +2 -2
  114. data/db/sample_data/promotions.rb +2 -4
  115. data/db/sample_data/wholesale.rb +1 -1
  116. data/lib/generators/spree/api_resource/api_resource_generator.rb +58 -1
  117. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +6 -5
  118. data/lib/generators/spree/api_resource/templates/permissions.en.yml.tt +6 -0
  119. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +2 -2
  120. data/lib/generators/spree/dummy/templates/rails/test.rb +7 -0
  121. data/lib/generators/spree/subscriber/subscriber_generator.rb +3 -3
  122. data/lib/spree/core/engine.rb +8 -8
  123. data/lib/spree/core/version.rb +1 -1
  124. data/lib/spree/iso_data.rb +23 -4
  125. data/lib/spree/store_scope_guard.rb +16 -1
  126. data/lib/spree/testing_support/factories/invitation_factory.rb +1 -0
  127. data/lib/spree/testing_support/factories/order_group_factory.rb +49 -0
  128. data/lib/spree/testing_support/factories/payment_factory.rb +7 -2
  129. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
  130. data/lib/tasks/sample_data.rake +8 -2
  131. data/lib/tasks/upgrade.rake +6 -21
  132. metadata +13 -4
@@ -10,7 +10,7 @@ module Spree
10
10
  # @param stock_location [Spree::StockLocation]
11
11
  # @param variants_scope [ActiveRecord::Relation]
12
12
  # @return [Spree::ServiceModule::Base::Result]
13
- def call(stock_location:, variants_scope: Spree::Variant)
13
+ def call(stock_location:, variants_scope: nil)
14
14
  Spree::Deprecation.warn('Spree::StockLocations::StockItems::Create is deprecated and will be removed in Spree 6.1. Use Spree::StockLocations::StockLevels::Create instead.')
15
15
  Spree::StockLocations::StockLevels::Create.call(stock_location: stock_location, variants_scope: variants_scope)
16
16
  end
@@ -4,8 +4,13 @@ module Spree
4
4
  class Create
5
5
  prepend Spree::ServiceModule::Base
6
6
 
7
- def call(stock_location:, variants_scope: Spree::Variant)
8
- prepared_stock_levels = variants_scope.ids.map do |variant_id|
7
+ # @param stock_location [Spree::StockLocation]
8
+ # @param variants_scope [ActiveRecord::Relation, nil] defaults to what the
9
+ # location may stock; always narrowed to it, so no caller can seed rows
10
+ # for another store's or another seller's variants
11
+ def call(stock_location:, variants_scope: nil)
12
+ variant_ids = propagatable_variants(stock_location, variants_scope).ids
13
+ prepared_stock_levels = variant_ids.map do |variant_id|
9
14
  Hash[
10
15
  'stock_location_id', stock_location.id,
11
16
  'variant_id', variant_id,
@@ -16,9 +21,20 @@ module Spree
16
21
  end
17
22
  if prepared_stock_levels.any?
18
23
  stock_location.stock_levels.insert_all(prepared_stock_levels)
19
- variants_scope.touch_all
24
+ # By id: MySQL refuses an UPDATE whose own table appears in a subquery.
25
+ Spree::Variant.where(id: variant_ids).touch_all
20
26
  end
21
27
  end
28
+
29
+ private
30
+
31
+ # A location stocks its own store's variants, and a seller's location
32
+ # only the variants that seller sells.
33
+ def propagatable_variants(stock_location, variants_scope)
34
+ scope = Spree::Variant.where(id: (variants_scope || Spree::Variant).select(:id))
35
+ scope = scope.for_seller(stock_location.seller_id) if stock_location.seller_id.present?
36
+ scope.joins(:product).where(Spree::Product.table_name => { store_id: stock_location.store_id })
37
+ end
22
38
  end
23
39
  end
24
40
  end
@@ -1,6 +1,6 @@
1
1
  module Spree
2
2
  # The trigger side of derive-then-persist statuses: any payment, refund,
3
- # fulfillment or return change recomputes the owning order's
3
+ # store credit, fulfillment or return change recomputes the owning order's
4
4
  # payment_status / fulfillment_status through the single writer
5
5
  # (Spree::Orders::UpdateStatuses). Synchronous so API responses right
6
6
  # after a capture/void/fulfill already carry the fresh status. Cart-owned
@@ -15,12 +15,15 @@ module Spree
15
15
  subscribes_to 'payment.created', 'payment.updated', 'payment.deleted',
16
16
  'payment.completed', 'payment.captured', 'payment.voided',
17
17
  'refund.created', 'refund.updated',
18
+ 'store_credit.created', 'store_credit.updated', 'store_credit.deleted',
18
19
  'fulfillment.created', 'fulfillment.updated', 'fulfillment.deleted',
19
20
  'return.received', 'return.refunded', 'return.canceled',
20
21
  async: false
21
22
 
22
23
  # Events whose payload changes what the customer has actually paid, so
23
24
  # payment_total has to be re-summed before statuses derive from it.
25
+ # Store credit is not one of them: the money stayed with the store, and
26
+ # payment_total is what came in through payments.
24
27
  MONEY_EVENTS = %w[
25
28
  payment.created payment.updated payment.deleted payment.completed
26
29
  payment.voided refund.created refund.updated
@@ -63,19 +66,24 @@ module Spree
63
66
  resource_class = {
64
67
  'payment' => Spree::Payment,
65
68
  'refund' => Spree::Refund,
69
+ 'store_credit' => Spree::StoreCredit,
66
70
  'fulfillment' => Spree::Fulfillment,
67
71
  'return' => Spree::Return
68
72
  }[event.resource_type]
69
73
  return if resource_class.nil?
70
74
 
71
- record = resource_class.find_by_prefix_id(event.payload['id'])
72
- # None of these models are paranoid, so a .deleted event arrives with
73
- # its row already gone and a payload carrying no owner — nothing can
74
- # lead back to the order. The models whose deletion changes order
75
- # money recalculate from their own after_destroy instead.
75
+ # A paranoid model's .deleted event leaves the row in place but out of
76
+ # default scope, and a credit that has been withdrawn is exactly the
77
+ # kind of change the order has to learn about.
78
+ record = (resource_class.try(:with_deleted) || resource_class).
79
+ find_by_prefix_id(event.payload['id'])
80
+ # Neither Payment nor Fulfillment is paranoid, so their .deleted events
81
+ # arrive with the row already gone and a payload carrying no owner —
82
+ # nothing can lead back to the order. The models whose deletion changes
83
+ # order money recalculate from their own after_destroy instead.
76
84
  return if record.nil?
77
85
 
78
- record.try(:owner) || record.try(:order) ||
86
+ record.try(:owner) || record.try(:order) || record.try(:refunded_order) ||
79
87
  record.try(:payment)&.try(:owner)
80
88
  end
81
89
  end
@@ -1,9 +1,6 @@
1
1
  module Spree
2
2
  module Carts
3
3
  class AddItem < Spree::Workflow
4
- # Line item attributes a caller may set through `options`.
5
- ITEM_OPTIONS = [:id, :variant_id, :quantity].freeze
6
-
7
4
  hooks :validate, :after_item_added
8
5
 
9
6
  # Hook handlers read these plus the argument readers. Both are nil
@@ -110,10 +107,11 @@ module Spree
110
107
 
111
108
  if @line_item.nil?
112
109
  # `LineItem#options=` mass-assigns, so the caller's hash is narrowed
113
- # first — `options` must not be a way to set arbitrary attributes.
114
- # Extensions widen this through the model's permitted attributes.
115
- writable = ITEM_OPTIONS +
116
- ::Spree::LineItem.additional_permitted_attributes.flat_map { |a| a.is_a?(Hash) ? a.keys : a }
110
+ # to the attributes extensions declare. The variant and quantity are
111
+ # never among them: both were checked and priced above, and an
112
+ # `options` value replacing them would sell something else.
113
+ writable = ::Spree::LineItem.additional_permitted_attributes.flat_map { |a| a.is_a?(Hash) ? a.keys : a }.
114
+ map(&:to_sym) - [:id, :variant_id, :quantity]
117
115
 
118
116
  opts = item_options.symbolize_keys.slice(*writable).
119
117
  merge(currency: cart.currency).
@@ -207,6 +207,13 @@ module Spree
207
207
  # typed money lines re-pointed, fulfillments + selected rates, address
208
208
  # copies); money records (payments, sessions, reservations, coupon
209
209
  # codes) re-point — external transaction references must never fork.
210
+ #
211
+ # The cart's metadata is copied whole: storefronts and checkout
212
+ # requirements keep what they collected about the purchase there, and it
213
+ # has to outlive the cart. The order is new at this point, so there is
214
+ # nothing on it to merge with — the cart's hash simply becomes the
215
+ # order's. A deep copy, so a later edit to either record can never reach
216
+ # the other through a shared nested hash.
210
217
  def create_draft_order!(cart)
211
218
  order = nil
212
219
  ApplicationRecord.transaction do
@@ -228,6 +235,7 @@ module Spree
228
235
  po_number: cart.po_number,
229
236
  gift_card: cart.gift_card,
230
237
  last_ip_address: cart.last_ip_address,
238
+ metadata: cart.metadata.to_h.deep_dup,
231
239
  ship_address: cart.ship_address&.snapshot,
232
240
  bill_address: cart.bill_address&.snapshot
233
241
  )
@@ -407,82 +415,30 @@ module Spree
407
415
  # resumes like any other finalize failure.
408
416
  def finalize!(cart, order)
409
417
  @order = order
410
- step :split_by_seller
411
- step :allocate_payment_splits
412
418
  step :complete_orders
413
419
  step :complete_cart
414
420
  step :mark_coupon_codes_used
415
421
  external_step :commit_tax
416
422
  end
417
423
 
418
- # Divides the paid draft order into one order per seller, under a group.
419
- # A cart of one partition — all first-party, or all one seller — is left
420
- # exactly as it is: no group, no splits, and the order it already built
421
- # is the order the customer gets.
424
+ # Places what the checkout produced, through the order-side workflow
425
+ # that owns placement — including dividing the basket between sellers,
426
+ # which an order raised from the admin needs just as much and which only
427
+ # that workflow can give both of them.
422
428
  #
423
- # The draft order built in PREPARE becomes the group's first child rather
424
- # than being replaced. It is the record the payment was taken against and
425
- # the row carrying the unique cart_id that makes completion replayable,
426
- # so it is kept and narrowed to its own partition; the remaining
427
- # partitions become new siblings beside it.
428
- def split_by_seller
429
- @order_group = cart.order_group
430
- return if order_group.present?
431
- return if order.placed?
432
-
433
- partitions = Spree::Carts::PartitionBySeller.call(purchase: order).value
434
-
435
- if partitions.one?
436
- # Nothing to divide, but the sale still belongs to whoever made it:
437
- # a basket entirely from one seller is that seller's order, and the
438
- # column is what their own order list reads.
439
- order.update_columns(seller_id: partitions.first.seller_id) if order.seller_id != partitions.first.seller_id
440
- return
441
- end
442
-
443
- return if partitions.empty?
429
+ # It answers with the group when the basket divided, and the first of
430
+ # its children is the order this checkout carries on with. No child holds
431
+ # the confirmation: the customer is confirmed from the group.
432
+ def complete_orders
433
+ result = Spree.order_complete_workflow.call(order: order, payment_pending: payment_pending)
434
+ failure(cart, code: 'completion_failed', message: result.error) if result.failure?
444
435
 
445
- result = Spree::Carts::SplitBySeller.call(cart: cart, order: order, partitions: partitions)
446
- failure(cart, code: 'split_failed', message: result.error) if result.failure?
436
+ return unless result.value.is_a?(Spree::OrderGroup)
447
437
 
448
438
  @order_group = result.value
449
- # Ordered, because which child comes first decides which one carries
450
- # the confirmation email — that must not depend on how the rows come
451
- # back.
452
439
  @order = order_group.orders.order(:id).first
453
440
  end
454
441
 
455
- def allocate_payment_splits
456
- return if order_group.nil?
457
-
458
- result = Spree::OrderGroups::AllocatePayments.call(group: order_group)
459
- failure(cart, code: 'split_failed', message: result.error) if result.failure?
460
- end
461
-
462
- # Places what the checkout produced — the children of a split, or the one
463
- # order otherwise. Each child publishes its own order.placed, which is
464
- # what gets every seller their own commission lines with no marketplace
465
- # code in the commission engine.
466
- #
467
- # A split checkout's payments were already taken against the whole basket
468
- # and moved onto the group, so the children place without processing them
469
- # again. One purchase means one confirmation email: the first child
470
- # carries the notification and its siblings place silently.
471
- def complete_orders
472
- split = order_group.present?
473
-
474
- placed_orders.each_with_index do |child, index|
475
- result = Spree.order_complete_workflow.call(
476
- order: child,
477
- payment_pending: split,
478
- notify_customer: split && !index.zero? ? false : nil
479
- )
480
- failure(cart, code: 'completion_failed', message: result.error) if result.failure?
481
- end
482
-
483
- order_group&.publish_event('order_group.completed')
484
- end
485
-
486
442
  # Tells the tax engine the sale is final. A no-op for the built-in
487
443
  # provider, whose rows are already the record; external engines file the
488
444
  # document that a tax return is later built from. Outside the
@@ -499,7 +455,7 @@ module Spree
499
455
  # otherwise. The single answer to "which orders came out of here", so
500
456
  # placement and tax filing can never disagree about the set.
501
457
  def placed_orders
502
- order_group.present? ? order_group.orders.to_a : [order]
458
+ order_group.present? ? order_group.orders.to_a.sort_by(&:id) : [order]
503
459
  end
504
460
 
505
461
  def complete_cart
@@ -137,7 +137,9 @@ module Spree
137
137
  # @return [BigDecimal]
138
138
  def paid_so_far
139
139
  if cart.is_a?(Spree::Order) && cart.grouped?
140
- return cart.payment_splits.to_a.sum(&:net_captured_amount)
140
+ # Reloaded: a share moved since this instance loaded them would be
141
+ # re-summed at its old figure and persisted back over the correction.
142
+ return cart.payment_splits.reload.to_a.sum(&:net_captured_amount)
141
143
  end
142
144
 
143
145
  cart.payments.completed.includes(:refunds).inject(0) do |sum, payment|
@@ -39,6 +39,9 @@ module Spree
39
39
  failure(order, :no_items_to_claim) if items.blank?
40
40
  end
41
41
 
42
+ # Bounded by what earlier claims left: each claim can pay out what was
43
+ # paid for its units, so claiming the same units again would pay twice.
44
+ # A denied or canceled claim settled nothing and frees its units.
42
45
  def normalize_items
43
46
  @normalized_items = items.map do |item|
44
47
  line_item = item[:line_item]
@@ -46,13 +49,40 @@ module Spree
46
49
 
47
50
  failure(order, :invalid_quantity) unless quantity.positive?
48
51
  failure(order, :item_not_on_order) unless line_item&.order_id == order.id
49
- failure(order, :invalid_quantity) if quantity > line_item.quantity.to_i
50
52
 
51
53
  item.merge(line_item: line_item, quantity: quantity)
52
54
  end
55
+
56
+ ensure_claimable_quantities
57
+ end
58
+
59
+ def ensure_claimable_quantities
60
+ requested = Hash.new(0)
61
+
62
+ @normalized_items.each do |item|
63
+ line_item = item[:line_item]
64
+ failure(order, :invalid_quantity) if item[:quantity] > claimable_quantity_for(line_item) - requested[line_item.id]
65
+
66
+ requested[line_item.id] += item[:quantity]
67
+ end
68
+ end
69
+
70
+ def claimable_quantity_for(line_item)
71
+ claimed = Spree::ClaimLineItem.
72
+ joins(:claim).
73
+ where(line_item_id: line_item.id).
74
+ where.not(Spree::Claim.table_name => { status: %w[denied canceled] }).
75
+ sum(:quantity)
76
+
77
+ line_item.quantity.to_i - claimed
53
78
  end
54
79
 
55
80
  def build_claim
81
+ # Again under the order's row lock, so two requests racing for the same
82
+ # units cannot both pass the check above.
83
+ Spree::Order.lock.find(order.id)
84
+ ensure_claimable_quantities
85
+
56
86
  @claim = order.claims.new(
57
87
  store: order.store,
58
88
  reason: reason,
@@ -130,17 +130,13 @@ module Spree
130
130
  end
131
131
 
132
132
  def issue_store_credit
133
- @refunds = [
134
- Spree::StoreCredit.create!(
135
- store: claim.store,
136
- customer: claim.order.customer,
137
- amount: @amount_to_refund,
138
- currency: claim.currency,
139
- created_by: resolver,
140
- originator: claim,
141
- memo: "Claim #{claim.number}"
142
- )
143
- ]
133
+ @refunds = issue_refund_store_credit(
134
+ order: claim.order,
135
+ amount: @amount_to_refund,
136
+ record: claim,
137
+ memo: "Claim #{claim.number}",
138
+ refunder: resolver
139
+ )
144
140
  end
145
141
 
146
142
  def refund_at_gateway
@@ -3,6 +3,8 @@ module Spree
3
3
  # Opens an exchange request: items coming back, and what should go out
4
4
  # in their place.
5
5
  class Create < Spree::Workflow
6
+ include Spree::Returns::ReturnableQuantity
7
+
6
8
  hooks :validate, :after_create
7
9
 
8
10
  attr_reader :exchange
@@ -55,9 +57,18 @@ module Spree
55
57
 
56
58
  { fulfillment_item: fulfillment_item, new_variant: new_variant, quantity: quantity }
57
59
  end
60
+
61
+ # The credit an exchange pays out is priced on these quantities, so
62
+ # they can never exceed what was shipped and not yet sent back.
63
+ ensure_returnable_quantities(@normalized_items, action: 'exchanged')
58
64
  end
59
65
 
60
66
  def build_exchange
67
+ # Again under the order's row lock, so two requests racing for the same
68
+ # units cannot both pass the check above.
69
+ Spree::Order.lock.find(order.id)
70
+ ensure_returnable_quantities(@normalized_items, action: 'exchanged')
71
+
61
72
  @exchange = order.exchanges.new(
62
73
  store: order.store,
63
74
  stock_location: stock_location || default_stock_location,
@@ -51,11 +51,23 @@ module Spree
51
51
  # Negative price difference means the replacements cost less than what
52
52
  # came back, so the customer is owed the difference.
53
53
  def credit_due?
54
- exchange.price_difference.to_d.negative?
54
+ settled_difference.negative?
55
55
  end
56
56
 
57
57
  def credit_amount
58
- exchange.price_difference.to_d.abs
58
+ settled_difference.abs
59
+ end
60
+
61
+ # The difference on the units that actually came back — the same units
62
+ # the replacements are shipped for. Priced on the requested quantity, an
63
+ # exchange that asked for more than arrived would pay credit for goods
64
+ # nobody returned.
65
+ def settled_difference
66
+ @settled_difference ||= exchange.exchange_line_items.sum(0.to_d) do |line|
67
+ next 0.to_d if line.quantity.to_i.zero?
68
+
69
+ line.price_difference.to_d / line.quantity.to_i * line.received_quantity.to_i
70
+ end
59
71
  end
60
72
 
61
73
  def ensure_fulfillable
@@ -112,17 +124,13 @@ module Spree
112
124
  end
113
125
 
114
126
  def issue_store_credit
115
- @refunds = [
116
- Spree::StoreCredit.create!(
117
- store: exchange.store,
118
- customer: exchange.order.customer,
119
- amount: credit_amount,
120
- currency: exchange.currency,
121
- created_by: refunder,
122
- originator: exchange,
123
- memo: "Exchange #{exchange.number}"
124
- )
125
- ]
127
+ @refunds = issue_refund_store_credit(
128
+ order: exchange.order,
129
+ amount: credit_amount,
130
+ record: exchange,
131
+ memo: "Exchange #{exchange.number}",
132
+ refunder: refunder
133
+ )
126
134
  end
127
135
 
128
136
  def refund_at_gateway
@@ -7,6 +7,9 @@ module Spree
7
7
  # may still need to process payments). Idempotent: an already-placed
8
8
  # order halts successfully, so interrupted completions replay safely.
9
9
  class Complete < Spree::Workflow
10
+ # Set only when the order divided between sellers.
11
+ attr_reader :order_group
12
+
10
13
  # @param order [Spree::Order]
11
14
  # @param payment_pending [Boolean] when true the order places without
12
15
  # processing payments (B2B / invoice-later). With no payment rows the
@@ -15,17 +18,29 @@ module Spree
15
18
  # rollup work's call (docs/plans/6.0-b2b-wholesale-shipping.md phase 7)
16
19
  # @param notify_customer [Boolean, nil] admin drafts pass false to
17
20
  # complete silently; nil (checkout) leaves customer notification on
21
+ # @return [Spree::ServiceModule::Result] value is the order, or the
22
+ # Spree::OrderGroup when it divided between sellers
18
23
  def perform(order:, payment_pending: false, notify_customer: nil)
19
24
  super
20
25
 
21
26
  order.notify_customer = notify_customer unless notify_customer.nil?
22
27
 
23
- halt!(order) if order.completed?
28
+ # Replaying finishes what an interrupted division started — a sibling
29
+ # left in draft is an order nobody will ever ship — and answers with
30
+ # what the first run produced, rather than reporting one seller's order
31
+ # where it first reported the whole purchase.
32
+ if order.completed?
33
+ @order_group = order.order_group
34
+ step :complete_sibling_orders
35
+ halt!(order_group || order)
36
+ end
37
+
24
38
  step :ensure_not_canceled
25
39
 
26
40
  order.with_lock do
27
41
  unless order.reload.placed?
28
42
  step :process_payments unless payment_pending || !order.payment_required?
43
+ step :split_by_seller
29
44
  step :finalize_fulfillments
30
45
  step :place_order
31
46
  step :use_coupon_codes
@@ -39,8 +54,9 @@ module Spree
39
54
  step :release_stock_reservations
40
55
  step :update_statuses
41
56
  step :publish_order_placed
57
+ step :complete_sibling_orders
42
58
 
43
- success(order)
59
+ success(order_group || order)
44
60
  end
45
61
 
46
62
  private
@@ -56,6 +72,56 @@ module Spree
56
72
  failure(order, Spree.t(:payment_processing_failed)) unless payment_covered?
57
73
  end
58
74
 
75
+ # Files the sale under whoever made it, dividing the order into one per
76
+ # seller when it holds several sellers' goods.
77
+ #
78
+ # Here because this is the one home both entry points share. Commission
79
+ # is charged from the line item's seller while the ledger credits the
80
+ # order's, so an order that skipped this — as one raised from the admin
81
+ # used to — is charged for and credited to nobody.
82
+ #
83
+ # Runs after payment so the money is settled once against the whole
84
+ # basket, and before placement so each seller's order is placed in its
85
+ # own right.
86
+ def split_by_seller
87
+ # A sibling arrives already divided.
88
+ return if order.order_group_id.present?
89
+
90
+ partitions = Spree::Carts::PartitionBySeller.call(purchase: order).value
91
+ return if partitions.empty?
92
+ return stamp_seller(partitions.first.seller_id) if partitions.one?
93
+
94
+ # The cart travels with it: the group becomes what that cart completed
95
+ # into, and the row carrying its id is the replay anchor.
96
+ result = Spree::Carts::SplitBySeller.call(order: order, partitions: partitions, cart: order.cart)
97
+ failure(order, result.error) if result.failure?
98
+
99
+ # The division adopts this very order as the group's first child and
100
+ # reloads it, so it stays the row this workflow locked and the object
101
+ # carrying the caller's notify_customer, which a freshly loaded one
102
+ # would not.
103
+ @order_group = result.value
104
+ # No child order is the purchase, this one included, so the customer is
105
+ # confirmed from the group instead and every child places silently.
106
+ @notify_customer_of_purchase = order.notify_customer
107
+ order.notify_customer = false
108
+ step :allocate_payment_splits
109
+ end
110
+
111
+ # Nothing to divide, but the sale still belongs to whoever made it: an
112
+ # order entirely from one seller is that seller's, and the column is what
113
+ # their own order list reads.
114
+ def stamp_seller(seller_id)
115
+ return if order.seller_id == seller_id
116
+
117
+ order.update_columns(seller_id: seller_id, updated_at: Time.current)
118
+ end
119
+
120
+ def allocate_payment_splits
121
+ result = Spree::OrderGroups::AllocatePayments.call(group: order_group)
122
+ failure(order, result.error) if result.failure?
123
+ end
124
+
59
125
  # Typed adjustment rows are frozen once completed — the totals
60
126
  # recalculation only re-sums them, never regenerates (see
61
127
  # Spree::Carts::RecalculateTotals) — so no per-row locking is needed.
@@ -160,6 +226,37 @@ module Spree
160
226
  order.publish_event('order.completed', payload, { deprecated_alias_of: 'order.placed' })
161
227
  end
162
228
 
229
+ # Places the orders the division produced beside this one.
230
+ #
231
+ # They place without processing payments, because the money was taken
232
+ # against the whole basket before the division and the group now holds
233
+ # it, and silently, as every child of a division does. Only the ones
234
+ # still in draft, so a replay finishes an interrupted division instead of
235
+ # re-placing what already went out.
236
+ def complete_sibling_orders
237
+ return if order_group.nil?
238
+
239
+ pending = order_group.orders.where.not(id: order.id).where(status: 'draft').order(:id)
240
+ pending.each { |sibling| place_sibling(sibling) }
241
+
242
+ # The division loaded these children before placing them, and the rows
243
+ # just placed are not those objects — anything reading the group now
244
+ # would see drafts that no longer exist.
245
+ order_group.orders.reset
246
+
247
+ return unless order_group.orders.all?(&:placed?)
248
+
249
+ order_group.publish_event(
250
+ 'order_group.completed',
251
+ order_group.event_payload.merge(notify_customer: @notify_customer_of_purchase)
252
+ )
253
+ end
254
+
255
+ def place_sibling(sibling)
256
+ result = Spree.order_complete_workflow.call(order: sibling, payment_pending: true, notify_customer: false)
257
+ failure(order, result.error) if result.failure?
258
+ end
259
+
163
260
  def payment_covered?
164
261
  order.payments.reset
165
262
  order.payments.valid.where(status: %w[pending processing completed]).sum(:amount) >= order.amount_due_at_checkout
@@ -65,6 +65,12 @@ module Spree
65
65
  price_list.errors.add(:base, :negative_price)
66
66
  elsif refusal[:invalid_quantities].present?
67
67
  price_list.errors.add(:base, :invalid_quantity)
68
+ elsif refusal[:quantities_on_base_prices].present?
69
+ price_list.errors.add(:base, :quantity_on_base_price)
70
+ elsif refusal[:duplicate_quantities].present?
71
+ price_list.errors.add(:base, :duplicate_quantity)
72
+ elsif refusal[:rising_ladders].present?
73
+ price_list.errors.add(:base, :price_rises_with_quantity)
68
74
  else
69
75
  price_list.errors.add(:base, :too_many_breaks, count: Spree::Price::MAXIMUM_BREAKS_PER_VARIANT)
70
76
  end
@@ -80,7 +86,18 @@ module Spree
80
86
  touch_variants(variant_ids)
81
87
  end
82
88
 
89
+ # Rows are written with upsert_all, which checks nothing, so a variant
90
+ # outside the list's store is dropped here rather than trusted to every
91
+ # caller to filter first.
83
92
  def price_rows
93
+ rows = raw_price_rows
94
+ store_variant_ids = price_list.store.variants.where(id: rows.map { |row| row[:variant_id] }).
95
+ pluck(:id).map(&:to_s).to_set
96
+
97
+ rows.select { |row| store_variant_ids.include?(row[:variant_id].to_s) }
98
+ end
99
+
100
+ def raw_price_rows
84
101
  Array(@prices).filter_map do |raw|
85
102
  row = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h.with_indifferent_access : raw.with_indifferent_access
86
103