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.
- checksums.yaml +4 -4
- data/app/jobs/spree/payments/handle_webhook_job.rb +1 -0
- data/app/jobs/spree/sample_data/load_job.rb +18 -0
- 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/acted_by.rb +210 -0
- data/app/models/concerns/spree/actor.rb +51 -0
- data/app/models/concerns/spree/admin_user_methods.rb +48 -17
- 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/payment_processing.rb +1 -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/api_key.rb +5 -3
- 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/catalog.rb +50 -13
- data/app/models/spree/claim.rb +3 -1
- 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 +56 -13
- data/app/models/spree/exchange.rb +3 -1
- 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/line_item.rb +1 -1
- 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 +89 -18
- 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/pricing_provider/internal.rb +37 -15
- 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/refund.rb +2 -1
- data/app/models/spree/return.rb +18 -7
- 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/stock_receipt.rb +7 -2
- data/app/models/spree/store.rb +4 -0
- 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/approve.rb +3 -2
- 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/cancel.rb +3 -2
- data/app/workflows/spree/orders/complete.rb +99 -2
- data/app/workflows/spree/price_lists/update.rb +17 -0
- data/app/workflows/spree/refunds/create.rb +1 -1
- data/app/workflows/spree/refunds/order_payments.rb +55 -1
- data/app/workflows/spree/returns/approve.rb +1 -1
- data/app/workflows/spree/returns/create.rb +11 -17
- data/app/workflows/spree/returns/refund.rb +16 -25
- data/app/workflows/spree/returns/returnable_quantity.rb +53 -0
- data/app/workflows/spree/sellers/invite.rb +1 -1
- data/config/locales/en.yml +8 -119
- data/db/migrate/20260916000001_add_email_delivery_flags_to_spree_order_groups.rb +14 -0
- data/db/migrate/20260916000001_order_operation_actors_polymorphic.rb +30 -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 +121 -6
- data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +8 -2
- data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +3 -0
- 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/api_resource/templates/store_controller_spec.rb.tt +4 -1
- data/lib/generators/spree/dummy/templates/rails/test.rb +7 -0
- data/lib/generators/spree/model/model_generator.rb +40 -1
- data/lib/generators/spree/model/templates/create_table_migration.rb.tt +9 -0
- data/lib/generators/spree/model/templates/model.rb.tt +10 -4
- data/lib/generators/spree/subscriber/subscriber_generator.rb +3 -3
- data/lib/spree/core/configuration.rb +25 -25
- data/lib/spree/core/engine.rb +35 -8
- data/lib/spree/core/preferences/runtime_configuration.rb +76 -2
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +27 -0
- 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/testing_support/factories/user_factory.rb +0 -1
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
- data/lib/tasks/backfill_actor_types.rake +35 -0
- data/lib/tasks/sample_data.rake +8 -2
- data/lib/tasks/store_settings.rake +1 -0
- data/lib/tasks/upgrade.rake +6 -21
- metadata +23 -9
- /data/{app/models → lib}/spree/checkout/default_requirements.rb +0 -0
- /data/{app/models → lib}/spree/checkout/registry.rb +0 -0
- /data/{app/models → lib}/spree/checkout/requirement.rb +0 -0
- /data/{app/models → lib}/spree/checkout/requirements.rb +0 -0
- /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
|
-
|
|
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
|
|
@@ -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]
|
|
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
|
|
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
|
-
|
|
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]
|
|
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
|
|
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]
|
|
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]
|
|
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
|
|
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
|
-
|
|
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]
|
|
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
|
|
56
|
-
#
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
|
|
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 >
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
customer: return_record.order.customer,
|
|
98
|
+
@refunds = issue_refund_store_credit(
|
|
99
|
+
order: return_record.order,
|
|
107
100
|
amount: @amount_to_refund,
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|