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.
- checksums.yaml +4 -4
- data/app/jobs/spree/payments/handle_webhook_job.rb +1 -0
- data/app/jobs/spree/sample_data/load_job.rb +7 -2
- data/app/jobs/spree/seller_transfers/execute_pending_job.rb +1 -0
- data/app/mailers/spree/admin_user_mailer.rb +2 -2
- data/app/models/concerns/spree/customer_methods.rb +17 -1
- data/app/models/concerns/spree/has_number.rb +3 -2
- data/app/models/concerns/spree/prefixed_id.rb +4 -1
- data/app/models/concerns/spree/purchase/totals.rb +3 -1
- data/app/models/concerns/spree/ransackable_attributes.rb +38 -6
- data/app/models/concerns/spree/translatable_resource.rb +6 -7
- data/app/models/concerns/spree/webhook_payload_redaction.rb +132 -21
- data/app/models/spree/adjusters/promotion.rb +1 -1
- data/app/models/spree/authentication/lockout.rb +34 -0
- data/app/models/spree/authentication/strategies/email_password_strategy.rb +4 -12
- data/app/models/spree/authentication/strategies/oidc_strategy.rb +1 -1
- data/app/models/spree/claim.rb +1 -0
- data/app/models/spree/commission_line.rb +4 -2
- data/app/models/spree/commission_rule.rb +1 -1
- data/app/models/spree/coupon_code.rb +14 -2
- data/app/models/spree/current.rb +15 -0
- data/app/models/spree/exchange.rb +1 -0
- data/app/models/spree/export.rb +8 -5
- data/app/models/spree/exports/customers.rb +2 -2
- data/app/models/spree/fulfillment_group.rb +127 -0
- data/app/models/spree/gift_card.rb +9 -0
- data/app/models/spree/invitation.rb +18 -5
- data/app/models/spree/number_generators/sequential.rb +2 -2
- data/app/models/spree/option_value.rb +7 -0
- data/app/models/spree/order.rb +35 -12
- data/app/models/spree/order_group.rb +78 -6
- data/app/models/spree/payment_source.rb +1 -1
- data/app/models/spree/payment_split.rb +24 -0
- data/app/models/spree/price.rb +74 -0
- data/app/models/spree/product.rb +4 -0
- data/app/models/spree/product_option_type.rb +11 -0
- data/app/models/spree/promotion/actions/create_line_items.rb +92 -30
- data/app/models/spree/promotion/rules/category.rb +14 -9
- data/app/models/spree/promotion/rules/product.rb +17 -1
- data/app/models/spree/promotion.rb +1 -1
- data/app/models/spree/promotion_action.rb +12 -1
- data/app/models/spree/promotion_handler/coupon.rb +50 -5
- data/app/models/spree/promotion_rule.rb +14 -0
- data/app/models/spree/return.rb +16 -6
- data/app/models/spree/return_line_item.rb +19 -0
- data/app/models/spree/search_provider/database.rb +10 -2
- data/app/models/spree/seller_payout.rb +3 -1
- data/app/models/spree/seller_transfer.rb +3 -1
- data/app/models/spree/store_credit.rb +4 -0
- data/app/models/spree/translations/batch.rb +4 -2
- data/app/models/spree/user_identity.rb +10 -6
- data/app/models/spree/variant.rb +8 -3
- data/app/models/spree/webhook_delivery.rb +28 -0
- data/app/models/spree/webhook_endpoint.rb +14 -0
- data/app/presenters/spree/csv/order_line_item_presenter.rb +2 -2
- data/app/services/spree/carts/split_by_seller.rb +22 -12
- data/app/services/spree/companies/add_member.rb +8 -2
- data/app/services/spree/imports/row_processors/price_list_price.rb +6 -0
- data/app/services/spree/imports/row_processors/product_translation.rb +1 -1
- data/app/services/spree/imports/row_processors/product_variant.rb +17 -11
- data/app/services/spree/order_groups/allocate_payments.rb +3 -0
- data/app/services/spree/orders/build_fulfillments.rb +6 -0
- data/app/services/spree/orders/update_statuses.rb +8 -2
- data/app/services/spree/prices/bulk_upsert.rb +186 -47
- data/app/services/spree/sample_data/import_builder.rb +1 -1
- data/app/services/spree/sample_data/loader.rb +54 -40
- data/app/services/spree/seeds/all.rb +1 -26
- data/app/services/spree/seeds/allowed_origins.rb +5 -4
- data/app/services/spree/seeds/api_keys.rb +10 -9
- data/app/services/spree/seeds/channels.rb +9 -8
- data/app/services/spree/seeds/commission_rates.rb +21 -20
- data/app/services/spree/seeds/customer_groups.rb +5 -4
- data/app/services/spree/seeds/digital_delivery.rb +7 -8
- data/app/services/spree/seeds/payment_methods.rb +12 -11
- data/app/services/spree/seeds/product_types.rb +7 -8
- data/app/services/spree/seeds/returns_environment.rb +12 -11
- data/app/services/spree/seeds/roles.rb +8 -7
- data/app/services/spree/seeds/saved_reports.rb +19 -20
- data/app/services/spree/seeds/seller_requirements.rb +5 -4
- data/app/services/spree/seeds/store_resources.rb +41 -0
- data/app/services/spree/seeds/store_scoped.rb +15 -0
- data/app/services/spree/seeds/tax_categories.rb +10 -9
- data/app/services/spree/stock_locations/stock_items/create.rb +1 -1
- data/app/services/spree/stock_locations/stock_levels/create.rb +19 -3
- data/app/subscribers/spree/order_status_subscriber.rb +15 -7
- data/app/workflows/spree/carts/add_item.rb +5 -7
- data/app/workflows/spree/carts/complete.rb +20 -64
- data/app/workflows/spree/carts/recalculate_totals.rb +3 -1
- data/app/workflows/spree/claims/create.rb +31 -1
- data/app/workflows/spree/claims/resolve.rb +7 -11
- data/app/workflows/spree/exchanges/create.rb +11 -0
- data/app/workflows/spree/exchanges/fulfill.rb +21 -13
- data/app/workflows/spree/orders/complete.rb +99 -2
- data/app/workflows/spree/price_lists/update.rb +17 -0
- data/app/workflows/spree/refunds/order_payments.rb +55 -1
- data/app/workflows/spree/returns/create.rb +9 -15
- data/app/workflows/spree/returns/refund.rb +15 -24
- data/app/workflows/spree/returns/returnable_quantity.rb +53 -0
- data/app/workflows/spree/sellers/invite.rb +1 -1
- data/config/locales/en.yml +7 -119
- data/db/migrate/20260916000001_add_email_delivery_flags_to_spree_order_groups.rb +14 -0
- data/db/migrate/20260922000001_add_refunded_order_to_spree_store_credits.rb +5 -0
- data/db/migrate/20260923000001_change_spree_user_identities_tokens_to_text.rb +10 -0
- data/db/sample_data/channels.rb +1 -1
- data/db/sample_data/collections.rb +1 -1
- data/db/sample_data/custom_field_definitions.rb +1 -1
- data/db/sample_data/digital_products.rb +1 -1
- data/db/sample_data/fulfillment.rb +1 -1
- data/db/sample_data/options.rb +4 -2
- data/db/sample_data/orders.rb +9 -10
- data/db/sample_data/payment_methods.rb +18 -18
- data/db/sample_data/product_type_categories.rb +1 -1
- data/db/sample_data/product_types.rb +2 -2
- data/db/sample_data/promotions.rb +2 -4
- data/db/sample_data/wholesale.rb +1 -1
- data/lib/generators/spree/api_resource/api_resource_generator.rb +58 -1
- data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +6 -5
- data/lib/generators/spree/api_resource/templates/permissions.en.yml.tt +6 -0
- data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +2 -2
- data/lib/generators/spree/dummy/templates/rails/test.rb +7 -0
- data/lib/generators/spree/subscriber/subscriber_generator.rb +3 -3
- data/lib/spree/core/engine.rb +8 -8
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/iso_data.rb +23 -4
- data/lib/spree/store_scope_guard.rb +16 -1
- data/lib/spree/testing_support/factories/invitation_factory.rb +1 -0
- data/lib/spree/testing_support/factories/order_group_factory.rb +49 -0
- data/lib/spree/testing_support/factories/payment_factory.rb +7 -2
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
- data/lib/tasks/sample_data.rake +8 -2
- data/lib/tasks/upgrade.rake +6 -21
- 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:
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
#
|
|
114
|
-
#
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
#
|
|
419
|
-
#
|
|
420
|
-
#
|
|
421
|
-
#
|
|
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
|
-
#
|
|
424
|
-
#
|
|
425
|
-
# the
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
54
|
+
settled_difference.negative?
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
def credit_amount
|
|
58
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|