spree_core 6.0.0.beta1 → 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 (164) 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 +18 -0
  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/acted_by.rb +210 -0
  7. data/app/models/concerns/spree/actor.rb +51 -0
  8. data/app/models/concerns/spree/admin_user_methods.rb +48 -17
  9. data/app/models/concerns/spree/customer_methods.rb +17 -1
  10. data/app/models/concerns/spree/has_number.rb +3 -2
  11. data/app/models/concerns/spree/prefixed_id.rb +4 -1
  12. data/app/models/concerns/spree/purchase/payment_processing.rb +1 -1
  13. data/app/models/concerns/spree/purchase/totals.rb +3 -1
  14. data/app/models/concerns/spree/ransackable_attributes.rb +38 -6
  15. data/app/models/concerns/spree/translatable_resource.rb +6 -7
  16. data/app/models/concerns/spree/webhook_payload_redaction.rb +132 -21
  17. data/app/models/spree/adjusters/promotion.rb +1 -1
  18. data/app/models/spree/api_key.rb +5 -3
  19. data/app/models/spree/authentication/lockout.rb +34 -0
  20. data/app/models/spree/authentication/strategies/email_password_strategy.rb +4 -12
  21. data/app/models/spree/authentication/strategies/oidc_strategy.rb +1 -1
  22. data/app/models/spree/catalog.rb +50 -13
  23. data/app/models/spree/claim.rb +3 -1
  24. data/app/models/spree/commission_line.rb +4 -2
  25. data/app/models/spree/commission_rule.rb +1 -1
  26. data/app/models/spree/coupon_code.rb +14 -2
  27. data/app/models/spree/current.rb +56 -13
  28. data/app/models/spree/exchange.rb +3 -1
  29. data/app/models/spree/export.rb +8 -5
  30. data/app/models/spree/exports/customers.rb +2 -2
  31. data/app/models/spree/fulfillment_group.rb +127 -0
  32. data/app/models/spree/gift_card.rb +9 -0
  33. data/app/models/spree/invitation.rb +18 -5
  34. data/app/models/spree/line_item.rb +1 -1
  35. data/app/models/spree/number_generators/sequential.rb +2 -2
  36. data/app/models/spree/option_value.rb +7 -0
  37. data/app/models/spree/order.rb +89 -18
  38. data/app/models/spree/order_group.rb +78 -6
  39. data/app/models/spree/payment_source.rb +1 -1
  40. data/app/models/spree/payment_split.rb +24 -0
  41. data/app/models/spree/price.rb +74 -0
  42. data/app/models/spree/pricing_provider/internal.rb +37 -15
  43. data/app/models/spree/product.rb +4 -0
  44. data/app/models/spree/product_option_type.rb +11 -0
  45. data/app/models/spree/promotion/actions/create_line_items.rb +92 -30
  46. data/app/models/spree/promotion/rules/category.rb +14 -9
  47. data/app/models/spree/promotion/rules/product.rb +17 -1
  48. data/app/models/spree/promotion.rb +1 -1
  49. data/app/models/spree/promotion_action.rb +12 -1
  50. data/app/models/spree/promotion_handler/coupon.rb +50 -5
  51. data/app/models/spree/promotion_rule.rb +14 -0
  52. data/app/models/spree/refund.rb +2 -1
  53. data/app/models/spree/return.rb +18 -7
  54. data/app/models/spree/return_line_item.rb +19 -0
  55. data/app/models/spree/search_provider/database.rb +10 -2
  56. data/app/models/spree/seller_payout.rb +3 -1
  57. data/app/models/spree/seller_transfer.rb +3 -1
  58. data/app/models/spree/stock_receipt.rb +7 -2
  59. data/app/models/spree/store.rb +4 -0
  60. data/app/models/spree/store_credit.rb +4 -0
  61. data/app/models/spree/translations/batch.rb +4 -2
  62. data/app/models/spree/user_identity.rb +10 -6
  63. data/app/models/spree/variant.rb +8 -3
  64. data/app/models/spree/webhook_delivery.rb +28 -0
  65. data/app/models/spree/webhook_endpoint.rb +14 -0
  66. data/app/presenters/spree/csv/order_line_item_presenter.rb +2 -2
  67. data/app/services/spree/carts/split_by_seller.rb +22 -12
  68. data/app/services/spree/companies/add_member.rb +8 -2
  69. data/app/services/spree/imports/row_processors/price_list_price.rb +6 -0
  70. data/app/services/spree/imports/row_processors/product_translation.rb +1 -1
  71. data/app/services/spree/imports/row_processors/product_variant.rb +17 -11
  72. data/app/services/spree/order_groups/allocate_payments.rb +3 -0
  73. data/app/services/spree/orders/approve.rb +3 -2
  74. data/app/services/spree/orders/build_fulfillments.rb +6 -0
  75. data/app/services/spree/orders/update_statuses.rb +8 -2
  76. data/app/services/spree/prices/bulk_upsert.rb +186 -47
  77. data/app/services/spree/sample_data/import_builder.rb +1 -1
  78. data/app/services/spree/sample_data/loader.rb +54 -40
  79. data/app/services/spree/seeds/all.rb +1 -26
  80. data/app/services/spree/seeds/allowed_origins.rb +5 -4
  81. data/app/services/spree/seeds/api_keys.rb +10 -9
  82. data/app/services/spree/seeds/channels.rb +9 -8
  83. data/app/services/spree/seeds/commission_rates.rb +21 -20
  84. data/app/services/spree/seeds/customer_groups.rb +5 -4
  85. data/app/services/spree/seeds/digital_delivery.rb +7 -8
  86. data/app/services/spree/seeds/payment_methods.rb +12 -11
  87. data/app/services/spree/seeds/product_types.rb +7 -8
  88. data/app/services/spree/seeds/returns_environment.rb +12 -11
  89. data/app/services/spree/seeds/roles.rb +8 -7
  90. data/app/services/spree/seeds/saved_reports.rb +19 -20
  91. data/app/services/spree/seeds/seller_requirements.rb +5 -4
  92. data/app/services/spree/seeds/store_resources.rb +41 -0
  93. data/app/services/spree/seeds/store_scoped.rb +15 -0
  94. data/app/services/spree/seeds/tax_categories.rb +10 -9
  95. data/app/services/spree/stock_locations/stock_items/create.rb +1 -1
  96. data/app/services/spree/stock_locations/stock_levels/create.rb +19 -3
  97. data/app/subscribers/spree/order_status_subscriber.rb +15 -7
  98. data/app/workflows/spree/carts/add_item.rb +5 -7
  99. data/app/workflows/spree/carts/complete.rb +20 -64
  100. data/app/workflows/spree/carts/recalculate_totals.rb +3 -1
  101. data/app/workflows/spree/claims/create.rb +31 -1
  102. data/app/workflows/spree/claims/resolve.rb +7 -11
  103. data/app/workflows/spree/exchanges/create.rb +11 -0
  104. data/app/workflows/spree/exchanges/fulfill.rb +21 -13
  105. data/app/workflows/spree/orders/cancel.rb +3 -2
  106. data/app/workflows/spree/orders/complete.rb +99 -2
  107. data/app/workflows/spree/price_lists/update.rb +17 -0
  108. data/app/workflows/spree/refunds/create.rb +1 -1
  109. data/app/workflows/spree/refunds/order_payments.rb +55 -1
  110. data/app/workflows/spree/returns/approve.rb +1 -1
  111. data/app/workflows/spree/returns/create.rb +11 -17
  112. data/app/workflows/spree/returns/refund.rb +16 -25
  113. data/app/workflows/spree/returns/returnable_quantity.rb +53 -0
  114. data/app/workflows/spree/sellers/invite.rb +1 -1
  115. data/config/locales/en.yml +8 -119
  116. data/db/migrate/20260916000001_add_email_delivery_flags_to_spree_order_groups.rb +14 -0
  117. data/db/migrate/20260916000001_order_operation_actors_polymorphic.rb +30 -0
  118. data/db/migrate/20260922000001_add_refunded_order_to_spree_store_credits.rb +5 -0
  119. data/db/migrate/20260923000001_change_spree_user_identities_tokens_to_text.rb +10 -0
  120. data/db/sample_data/channels.rb +1 -1
  121. data/db/sample_data/collections.rb +1 -1
  122. data/db/sample_data/custom_field_definitions.rb +1 -1
  123. data/db/sample_data/digital_products.rb +1 -1
  124. data/db/sample_data/fulfillment.rb +1 -1
  125. data/db/sample_data/options.rb +4 -2
  126. data/db/sample_data/orders.rb +9 -10
  127. data/db/sample_data/payment_methods.rb +18 -18
  128. data/db/sample_data/product_type_categories.rb +1 -1
  129. data/db/sample_data/product_types.rb +2 -2
  130. data/db/sample_data/promotions.rb +2 -4
  131. data/db/sample_data/wholesale.rb +1 -1
  132. data/lib/generators/spree/api_resource/api_resource_generator.rb +121 -6
  133. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +8 -2
  134. data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +3 -0
  135. data/lib/generators/spree/api_resource/templates/permissions.en.yml.tt +6 -0
  136. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +2 -2
  137. data/lib/generators/spree/api_resource/templates/store_controller_spec.rb.tt +4 -1
  138. data/lib/generators/spree/dummy/templates/rails/test.rb +7 -0
  139. data/lib/generators/spree/model/model_generator.rb +40 -1
  140. data/lib/generators/spree/model/templates/create_table_migration.rb.tt +9 -0
  141. data/lib/generators/spree/model/templates/model.rb.tt +10 -4
  142. data/lib/generators/spree/subscriber/subscriber_generator.rb +3 -3
  143. data/lib/spree/core/configuration.rb +25 -25
  144. data/lib/spree/core/engine.rb +35 -8
  145. data/lib/spree/core/preferences/runtime_configuration.rb +76 -2
  146. data/lib/spree/core/version.rb +1 -1
  147. data/lib/spree/core.rb +27 -0
  148. data/lib/spree/iso_data.rb +23 -4
  149. data/lib/spree/store_scope_guard.rb +16 -1
  150. data/lib/spree/testing_support/factories/invitation_factory.rb +1 -0
  151. data/lib/spree/testing_support/factories/order_group_factory.rb +49 -0
  152. data/lib/spree/testing_support/factories/payment_factory.rb +7 -2
  153. data/lib/spree/testing_support/factories/user_factory.rb +0 -1
  154. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
  155. data/lib/tasks/backfill_actor_types.rake +35 -0
  156. data/lib/tasks/sample_data.rake +8 -2
  157. data/lib/tasks/store_settings.rake +1 -0
  158. data/lib/tasks/upgrade.rake +6 -21
  159. metadata +23 -9
  160. /data/{app/models → lib}/spree/checkout/default_requirements.rb +0 -0
  161. /data/{app/models → lib}/spree/checkout/registry.rb +0 -0
  162. /data/{app/models → lib}/spree/checkout/requirement.rb +0 -0
  163. /data/{app/models → lib}/spree/checkout/requirements.rb +0 -0
  164. /data/{app/models → lib}/spree/checkout/step.rb +0 -0
@@ -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
@@ -11,7 +11,8 @@ module Spree
11
11
  hooks :before_cancel, :after_cancel
12
12
 
13
13
  # @param order [Spree::Order]
14
- # @param canceler [Object, nil] the user/admin who initiated the cancellation
14
+ # @param canceler [Object, nil] who initiated it — an admin user or an
15
+ # API key (see Spree.actor_classes)
15
16
  # @param canceled_at [Time, nil] timestamp (defaults to Time.current)
16
17
  # @param reason [Spree::OrderCancellationReason, nil] the merchant's own
17
18
  # vocabulary; must belong to the order's store
@@ -102,7 +103,7 @@ module Spree
102
103
 
103
104
  def mark_canceled
104
105
  changes = { status: 'canceled', canceled_at: @decided_at, cancel_reason_id: reason&.id, cancel_note: note }
105
- changes[:canceler_id] = canceler.id if canceler.present?
106
+ changes.merge!(Spree::ActedBy.columns_for(:canceler, canceler)) if canceler.present?
106
107
  order.update_columns(changes)
107
108
  end
108
109
 
@@ -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
 
@@ -19,7 +19,7 @@ module Spree
19
19
  # creditable balance
20
20
  # @param reason [Spree::RefundReason, nil] defaults to the store's
21
21
  # return-processing reason
22
- # @param refunder [Object, nil] the admin issuing the refund
22
+ # @param refunder [Object, nil] who is issuing it (see Spree.actor_classes)
23
23
  # @param originator [Object, nil] what triggered the refund — a
24
24
  # Spree::Return, Exchange or Claim; nil for a manual refund
25
25
  # @param order [Spree::Order, nil] which order is being put right.
@@ -2,7 +2,8 @@
2
2
 
3
3
  module Spree
4
4
  module Refunds
5
- # Gives a post-sale workflow one way to put money back on an order.
5
+ # Gives a post-sale workflow its two ways to put money back on an order:
6
+ # back to what paid for it, or as store credit.
6
7
  #
7
8
  # Returns, claims and exchanges all owe the customer money for the same
8
9
  # reason — goods that are going back, or were never right — and all three
@@ -22,6 +23,57 @@ module Spree
22
23
 
23
24
  private
24
25
 
26
+ # Puts `amount` back as store credit.
27
+ #
28
+ # Credit is its own ledger and writes no {Spree::Refund} row, so the
29
+ # credit names the order it settles — without that the order could not
30
+ # tell it had given anything back, and its payment status stayed `paid`
31
+ # on money the customer had already been made whole for.
32
+ #
33
+ # @param order [Spree::Order] the order being put right
34
+ # @param amount [BigDecimal] how much to give back
35
+ # @param record [Spree::Return, Spree::Claim, Spree::Exchange] what asked
36
+ # for it; it originates the credit
37
+ # @param memo [String] what the customer reads on the credit
38
+ # @param refunder [Object, nil] whoever is issuing it
39
+ # @return [Array<Spree::StoreCredit>] the one credit written, shaped like
40
+ # {#refund_order_payments} so both branches answer the same way
41
+ def issue_refund_store_credit(order:, amount:, record:, memo:, refunder: nil)
42
+ failure(record, :refund_exceeds_paid) if amount.to_d > refundable_balance(order)
43
+
44
+ [
45
+ Spree::StoreCredit.create!(
46
+ store: record.store,
47
+ customer: order.customer,
48
+ refunded_order: order,
49
+ amount: amount,
50
+ currency: record.currency,
51
+ created_by: refunder,
52
+ originator: record,
53
+ memo: memo
54
+ )
55
+ ]
56
+ end
57
+
58
+ # What the order can still give back, as store credit or to its
59
+ # payments: what its payments can still refund, less credit already
60
+ # issued against it. Credit writes no refund row, so without the second
61
+ # half every return, claim and exchange on one order could each hand back
62
+ # the full amount paid, and a refund to the card could follow a credit.
63
+ #
64
+ # The order row is locked first, so two settlements racing on one order
65
+ # cannot both read the same headroom.
66
+ #
67
+ # @param order [Spree::Order]
68
+ # @param shares [Hash{Spree::Payment => BigDecimal}, nil] already read
69
+ # @return [BigDecimal]
70
+ def refundable_balance(order, shares = nil)
71
+ Spree::Order.lock.find(order.id)
72
+
73
+ (shares || refundable_shares(order)).values.sum(0.to_d) -
74
+ Spree::StoreCredit.where(refunded_order: order).sum(:amount).to_d
75
+ end
76
+
25
77
  # Puts `amount` back on `order`, oldest payment first, until it is
26
78
  # covered.
27
79
  #
@@ -44,6 +96,8 @@ module Spree
44
96
  remaining = amount.to_d
45
97
  refunds = []
46
98
  shares = refundable_shares(order)
99
+ # With no payment to draw on the caller reports that instead.
100
+ failure(record, :refund_exceeds_paid) if shares.any? && remaining > refundable_balance(order, shares)
47
101
  # Resolved once rather than per payment — but only once there is
48
102
  # something to refund, since it is a find_or_create_by and a run that
49
103
  # refunds nothing should leave no reason row behind.
@@ -13,7 +13,7 @@ module Spree
13
13
  hooks :validate, :after_approve
14
14
 
15
15
  # @param return_record [Spree::Return]
16
- # @param approver [Object, nil] the admin approving it
16
+ # @param approver [Object, nil] who is approving it (see Spree.actor_classes)
17
17
  def perform(return_record:, approver: nil)
18
18
  super
19
19
 
@@ -7,6 +7,8 @@ module Spree
7
7
  # regional consumer rights. That policy is the most-customized rule in
8
8
  # commerce and deliberately does not live in core.
9
9
  class Create < Spree::Workflow
10
+ include Spree::Returns::ReturnableQuantity
11
+
10
12
  hooks :validate, :after_create
11
13
 
12
14
  # The created return — hook handlers read it (nil while :validate runs).
@@ -18,8 +20,8 @@ module Spree
18
20
  # back to; defaults to the location that shipped them
19
21
  # @param reason [Spree::ReturnReason, nil]
20
22
  # @param memo [String, nil] customer- or staff-supplied note
21
- # @param created_by [Object, nil] the admin opening it; nil for
22
- # customer self-service
23
+ # @param created_by [Object, nil] who is opening it (see
24
+ # Spree.actor_classes); nil for customer self-service
23
25
  def perform(order:, items:, stock_location: nil, reason: nil, memo: nil, created_by: nil)
24
26
  super
25
27
 
@@ -48,7 +50,7 @@ module Spree
48
50
  end
49
51
 
50
52
  # Quantities are validated against what is actually still returnable —
51
- # units already returned on an earlier request must not come back twice.
53
+ # units already returned or exchanged must not come back twice.
52
54
  def normalize_items
53
55
  @normalized_items = items.map do |item|
54
56
  fulfillment_item = item[:fulfillment_item]
@@ -57,26 +59,18 @@ module Spree
57
59
  failure(order, :invalid_quantity) unless quantity.positive?
58
60
  failure(order, :item_not_on_order) unless fulfillment_item&.order_id == order.id
59
61
 
60
- available = returnable_quantity_for(fulfillment_item)
61
- if quantity > available
62
- failure(order, "Only #{available} of #{fulfillment_item.variant.name} can be returned")
63
- end
64
-
65
62
  { fulfillment_item: fulfillment_item, quantity: quantity }
66
63
  end
67
- end
68
64
 
69
- def returnable_quantity_for(fulfillment_item)
70
- already_requested = Spree::ReturnLineItem.
71
- joins(:return).
72
- where(fulfillment_item_id: fulfillment_item.id).
73
- where.not(spree_returns: { status: 'canceled' }).
74
- sum(:quantity)
75
-
76
- fulfillment_item.quantity.to_i - already_requested
65
+ ensure_returnable_quantities(@normalized_items, action: 'returned')
77
66
  end
78
67
 
79
68
  def build_return
69
+ # Again under the order's row lock, so two requests racing for the same
70
+ # units cannot both pass the check above.
71
+ Spree::Order.lock.find(order.id)
72
+ ensure_returnable_quantities(@normalized_items, action: 'returned')
73
+
80
74
  @return_record = order.returns.new(
81
75
  store: order.store,
82
76
  stock_location: stock_location || default_stock_location,
@@ -19,7 +19,7 @@ module Spree
19
19
  # @param amount [BigDecimal, Numeric, nil] defaults to what the return
20
20
  # is still owed
21
21
  # @param refund_method [String] 'original_payment' or 'store_credit'
22
- # @param refunder [Object, nil] the admin issuing it
22
+ # @param refunder [Object, nil] who is issuing it (see Spree.actor_classes)
23
23
  def perform(return_record:, amount: nil, refund_method: 'original_payment', refunder: nil)
24
24
  super
25
25
 
@@ -52,15 +52,14 @@ module Spree
52
52
  # original supply date rather than today's — the rate that applied then is
53
53
  # the rate to credit back.
54
54
  #
55
- # Only lines that actually arrived are credited, matching what
56
- # +received_total+ refunds: a customer who announced three items and sent
57
- # two must not have tax credited on the third.
55
+ # Only lines that actually arrived are credited: a customer who announced
56
+ # three items and sent two must not have tax credited on the third.
58
57
  #
59
58
  # The refunded amount goes with them, because it can be less than those
60
59
  # lines are worth — a restocking fee, or an agreed part-refund. Without it
61
60
  # a provider would credit the tax on goods whose value the merchant kept.
62
61
  def refund_tax
63
- received = return_record.return_line_items.select { |line| line.received_quantity.to_i.positive? }
62
+ received = return_record.return_line_items.select { |line| line.refund_amount.positive? }
64
63
  return if received.empty?
65
64
 
66
65
  order = return_record.order
@@ -83,34 +82,26 @@ module Spree
83
82
  failure(return_record, :not_received) unless return_record.received?
84
83
  end
85
84
 
86
- # Only what actually came back is refundable — a customer who sent two
87
- # of three items gets two items' worth.
85
+ # Only what actually came back is refundable — a customer who sent two of
86
+ # three items gets two items' worth. One figure serves as both the
87
+ # default and the ceiling, so a caller naming an amount cannot ask for
88
+ # more than a caller who names none would get.
88
89
  def resolve_amount
89
- @amount_to_refund = (amount || received_total).to_d
90
+ refundable = return_record.refundable_total.to_d
91
+ @amount_to_refund = amount ? amount.to_d : refundable
90
92
 
91
93
  failure(return_record, :nothing_to_refund) unless @amount_to_refund.positive?
92
- failure(return_record, :refund_exceeds_balance) if @amount_to_refund > return_record.refundable_total.to_d
93
- end
94
-
95
- def received_total
96
- return_record.return_line_items.sum do |line|
97
- next 0 if line.quantity.to_i.zero?
98
-
99
- (line.pre_tax_amount / line.quantity) * line.received_quantity.to_i
100
- end
94
+ failure(return_record, :refund_exceeds_balance) if @amount_to_refund > refundable
101
95
  end
102
96
 
103
97
  def issue_store_credit
104
- credit = Spree::StoreCredit.create!(
105
- store: return_record.store,
106
- customer: return_record.order.customer,
98
+ @refunds = issue_refund_store_credit(
99
+ order: return_record.order,
107
100
  amount: @amount_to_refund,
108
- currency: return_record.currency,
109
- created_by: refunder,
110
- originator: return_record,
111
- memo: "Return #{return_record.number}"
101
+ record: return_record,
102
+ memo: "Return #{return_record.number}",
103
+ refunder: refunder
112
104
  )
113
- @refunds = [credit]
114
105
  end
115
106
 
116
107
  # Each refund row commits, then Refund#perform! credits it at the
@@ -0,0 +1,53 @@
1
+ module Spree
2
+ module Returns
3
+ # How many units of a shipped item can still go back.
4
+ #
5
+ # A return and an exchange both take the same units off the customer, so
6
+ # each counts the other: without that, the units a customer returned could
7
+ # be exchanged again, and the credit for the exchange paid out a second
8
+ # time. Units named twice in one request count twice too.
9
+ module ReturnableQuantity
10
+ extend ActiveSupport::Concern
11
+
12
+ private
13
+
14
+ # @param fulfillment_item [Spree::FulfillmentItem]
15
+ # @param requested [Hash{Integer => Integer}] units already asked for in
16
+ # this request, keyed by fulfillment item id
17
+ # @return [Integer]
18
+ def returnable_quantity_for(fulfillment_item, requested: {})
19
+ returned = Spree::ReturnLineItem.
20
+ joins(:return).
21
+ where(fulfillment_item_id: fulfillment_item.id).
22
+ where.not(Spree::Return.table_name => { status: 'canceled' }).
23
+ sum(:quantity)
24
+ exchanged = Spree::ExchangeLineItem.
25
+ joins(:exchange).
26
+ where(fulfillment_item_id: fulfillment_item.id).
27
+ where.not(Spree::Exchange.table_name => { status: 'canceled' }).
28
+ sum(:quantity)
29
+
30
+ fulfillment_item.quantity.to_i - returned - exchanged - requested.fetch(fulfillment_item.id, 0)
31
+ end
32
+
33
+ # Checks each item's quantity against what is still returnable and
34
+ # fails with the item's name when it is not.
35
+ #
36
+ # @param items [Array<Hash>] `[{ fulfillment_item:, quantity: }]`
37
+ # @param action [String] 'returned' or 'exchanged', for the message
38
+ def ensure_returnable_quantities(items, action:)
39
+ requested = Hash.new(0)
40
+
41
+ items.each do |item|
42
+ fulfillment_item = item[:fulfillment_item]
43
+ available = returnable_quantity_for(fulfillment_item, requested: requested)
44
+ if item[:quantity] > available
45
+ failure(order, "Only #{[available, 0].max} of #{fulfillment_item.variant.name} can be #{action}")
46
+ end
47
+
48
+ requested[fulfillment_item.id] += item[:quantity]
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -48,7 +48,7 @@ module Spree
48
48
  # role naming somewhere else comes back as a 422 rather than access
49
49
  # granted elsewhere.
50
50
  def send_invitation
51
- @invitation = seller.invitations.new(email: email, role: role, inviter: inviter)
51
+ @invitation = seller.invitations.new(email: email, role: role || seller.default_user_role, inviter: inviter)
52
52
 
53
53
  failure(@invitation, @invitation.errors) unless @invitation.save
54
54
  end