spree_core 5.4.0.beta6 → 5.4.0.beta7
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 +22 -0
- data/app/models/concerns/spree/user_methods.rb +1 -0
- data/app/models/spree/cart_promotion.rb +7 -0
- data/app/models/spree/checkout/default_requirements.rb +51 -0
- data/app/models/spree/checkout/registry.rb +112 -0
- data/app/models/spree/checkout/requirement.rb +49 -0
- data/app/models/spree/checkout/requirements.rb +56 -0
- data/app/models/spree/checkout/step.rb +52 -0
- data/app/models/spree/order/checkout.rb +18 -0
- data/app/models/spree/order.rb +3 -0
- data/app/models/spree/order_promotion.rb +2 -0
- data/app/models/spree/payment_method.rb +34 -0
- data/app/models/spree/payment_session.rb +18 -0
- data/app/models/spree/shipment.rb +1 -0
- data/app/models/spree/store.rb +1 -0
- data/app/services/spree/cart/create.rb +3 -30
- data/app/services/spree/carts/complete.rb +46 -0
- data/app/services/spree/carts/create.rb +32 -0
- data/app/services/spree/carts/update.rb +115 -0
- data/app/services/spree/{cart → carts}/upsert_items.rb +19 -23
- data/app/services/spree/payments/handle_webhook.rb +58 -0
- data/config/locales/en.yml +7 -2
- data/lib/spree/core/dependencies.rb +4 -2
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +1 -0
- data/lib/spree/testing_support/factories/order_factory.rb +3 -0
- metadata +16 -6
- data/app/services/spree/orders/update.rb +0 -121
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9a75c8667f68e7a71143adec2c78694c99308dbc73fa9afd625e1c5e339e2fb8
|
|
4
|
+
data.tar.gz: '04229df0b73d148776d498157514ed592db0ffb551dc347a2aca782cafb80f01'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b237c3ae83b130df90180833997bd0301335689e620e2da1956f67881bb44031962895c5de052d807d886ae00682a35c3b1c6fea5541acdbde4347abd871875a
|
|
7
|
+
data.tar.gz: d57fc57c99f2a3466e482965bc1a6bec9a40f91b277669ae2c24dfa93d1b68790f95bb3d50f15f2648abb32b1cf5c3f99cd646bfecf7b34c3d0949417550e25f
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Payments
|
|
3
|
+
class HandleWebhookJob < Spree::BaseJob
|
|
4
|
+
queue_as Spree.queues.payment_webhooks
|
|
5
|
+
|
|
6
|
+
retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3
|
|
7
|
+
retry_on ActiveRecord::LockWaitTimeout, wait: 5.seconds, attempts: 3
|
|
8
|
+
discard_on ActiveRecord::RecordNotFound
|
|
9
|
+
|
|
10
|
+
def perform(payment_method_id:, action:, payment_session_id:)
|
|
11
|
+
payment_method = Spree::PaymentMethod.find(payment_method_id)
|
|
12
|
+
payment_session = Spree::PaymentSession.find(payment_session_id)
|
|
13
|
+
|
|
14
|
+
Spree::Dependencies.payments_handle_webhook_service.constantize.call(
|
|
15
|
+
payment_method: payment_method,
|
|
16
|
+
action: action.to_sym,
|
|
17
|
+
payment_session: payment_session
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -35,6 +35,7 @@ module Spree
|
|
|
35
35
|
has_many :promotion_rule_users, class_name: 'Spree::PromotionRuleUser', foreign_key: :user_id, dependent: :destroy
|
|
36
36
|
has_many :promotion_rules, through: :promotion_rule_users, class_name: 'Spree::PromotionRule'
|
|
37
37
|
has_many :orders, foreign_key: :user_id, class_name: 'Spree::Order'
|
|
38
|
+
has_many :carts, -> { incomplete }, foreign_key: :user_id, class_name: 'Spree::Order'
|
|
38
39
|
has_many :completed_orders, -> { complete }, foreign_key: :user_id, class_name: 'Spree::Order'
|
|
39
40
|
has_many :store_credits, class_name: 'Spree::StoreCredit', foreign_key: :user_id, dependent: :destroy
|
|
40
41
|
has_many :wishlists, class_name: 'Spree::Wishlist', foreign_key: :user_id, dependent: :destroy
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Checkout
|
|
3
|
+
# Built-in checkout requirements that map to the standard Spree checkout flow.
|
|
4
|
+
#
|
|
5
|
+
# Checks line items, email, shipping address, shipping method, and payment.
|
|
6
|
+
# In Spree 6 these same checks will read from the +Cart+ model instead of
|
|
7
|
+
# the state machine — the API contract stays identical.
|
|
8
|
+
#
|
|
9
|
+
# @see Requirements
|
|
10
|
+
class DefaultRequirements
|
|
11
|
+
# @param order [Spree::Order]
|
|
12
|
+
def initialize(order)
|
|
13
|
+
@order = order
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Array<Hash{Symbol => String}>] unmet default requirements as
|
|
17
|
+
# +{ step:, field:, message: }+ hashes
|
|
18
|
+
def call
|
|
19
|
+
[].tap do |r|
|
|
20
|
+
r << req('cart', 'line_items', Spree.t('checkout_requirements.line_items_required')) unless @order.line_items.any?
|
|
21
|
+
r << req('address', 'email', Spree.t('checkout_requirements.email_required')) unless @order.email.present?
|
|
22
|
+
r << req('address', 'ship_address', Spree.t('checkout_requirements.ship_address_required')) if @order.requires_ship_address? && @order.ship_address.blank?
|
|
23
|
+
r << req('delivery', 'shipping_method', Spree.t('checkout_requirements.shipping_method_required')) if delivery_required? && !shipping_method_selected?
|
|
24
|
+
r << req('payment', 'payment', Spree.t('checkout_requirements.payment_required')) if payment_required? && !payment_satisfied?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def delivery_required?
|
|
31
|
+
@order.has_checkout_step?('delivery') && @order.delivery_required?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def shipping_method_selected?
|
|
35
|
+
@order.shipments.any? && @order.shipments.all? { |s| s.shipping_method.present? }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def payment_required?
|
|
39
|
+
@order.has_checkout_step?('payment') && @order.payment_required?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def payment_satisfied?
|
|
43
|
+
@order.payments.valid.any?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def req(step, field, message)
|
|
47
|
+
{ step: step, field: field, message: message }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Checkout
|
|
3
|
+
# Global registry for custom checkout steps and requirements.
|
|
4
|
+
#
|
|
5
|
+
# Provides a composable extension point so developers can add, remove, or
|
|
6
|
+
# reorder checkout steps and attach extra requirements to existing steps —
|
|
7
|
+
# all without subclassing or monkey-patching.
|
|
8
|
+
#
|
|
9
|
+
# Registered steps and requirements are evaluated by {Requirements} at
|
|
10
|
+
# serialization time to produce the +requirements+ array on the Cart API.
|
|
11
|
+
#
|
|
12
|
+
# @example Add a custom step
|
|
13
|
+
# Spree::Checkout::Registry.register_step(
|
|
14
|
+
# name: :loyalty,
|
|
15
|
+
# before: :payment,
|
|
16
|
+
# satisfied: ->(order) { order.loyalty_verified? },
|
|
17
|
+
# requirements: ->(order) { [{ step: 'loyalty', field: 'loyalty_number', message: 'Required' }] }
|
|
18
|
+
# )
|
|
19
|
+
#
|
|
20
|
+
# @example Add a requirement to an existing step
|
|
21
|
+
# Spree::Checkout::Registry.add_requirement(
|
|
22
|
+
# step: :payment,
|
|
23
|
+
# field: :po_number,
|
|
24
|
+
# message: 'PO number is required',
|
|
25
|
+
# satisfied: ->(order) { order.po_number.present? }
|
|
26
|
+
# )
|
|
27
|
+
#
|
|
28
|
+
# @example Remove a step
|
|
29
|
+
# Spree::Checkout::Registry.remove_step(:loyalty)
|
|
30
|
+
class Registry
|
|
31
|
+
class << self
|
|
32
|
+
# Register a new custom checkout step.
|
|
33
|
+
#
|
|
34
|
+
# @param name [String, Symbol] unique step identifier
|
|
35
|
+
# @param satisfied [Proc] lambda accepting an order, returns true when step is complete
|
|
36
|
+
# @param requirements [Proc] lambda accepting an order, returns Array of requirement hashes
|
|
37
|
+
# @param options [Hash] additional options forwarded to {Step} (+:before+, +:after+, +:applicable+)
|
|
38
|
+
# @return [Array<Step>] the updated steps list
|
|
39
|
+
def register_step(name:, satisfied:, requirements:, **options)
|
|
40
|
+
steps << Step.new(name: name, satisfied: satisfied, requirements: requirements, **options)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Add an extra requirement to an existing checkout step.
|
|
44
|
+
#
|
|
45
|
+
# @param step [String, Symbol] checkout step this requirement belongs to
|
|
46
|
+
# @param field [String, Symbol] field identifier
|
|
47
|
+
# @param message [String] human-readable validation message
|
|
48
|
+
# @param satisfied [Proc] lambda accepting an order, returns true when met
|
|
49
|
+
# @param options [Hash] additional options forwarded to {Requirement} (+:applicable+)
|
|
50
|
+
# @return [Array<Requirement>] the updated requirements list
|
|
51
|
+
def add_requirement(step:, field:, message:, satisfied:, **options)
|
|
52
|
+
requirements << Requirement.new(step: step, field: field, message: message, satisfied: satisfied, **options)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Remove a previously registered step by name.
|
|
56
|
+
#
|
|
57
|
+
# @param name [String, Symbol] step name to remove
|
|
58
|
+
# @return [Array<Step>] the updated steps list
|
|
59
|
+
def remove_step(name)
|
|
60
|
+
steps.reject! { |s| s.name == name.to_s }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Remove a previously registered requirement by step and field.
|
|
64
|
+
#
|
|
65
|
+
# @param step [String, Symbol] checkout step the requirement belongs to
|
|
66
|
+
# @param field [String, Symbol] field identifier
|
|
67
|
+
# @return [Array<Requirement>] the updated requirements list
|
|
68
|
+
def remove_requirement(step:, field:)
|
|
69
|
+
requirements.reject! { |r| r.step == step.to_s && r.field == field.to_s }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns steps sorted by +before+/+after+ constraints relative to the checkout flow.
|
|
73
|
+
#
|
|
74
|
+
# The sort order is derived from {Spree::Order.checkout_step_names} so it
|
|
75
|
+
# stays in sync with any customizations to the checkout state machine.
|
|
76
|
+
# Steps with +before:+/+after:+ anchors are ordered by the anchor's position;
|
|
77
|
+
# steps without constraints are appended at the end.
|
|
78
|
+
#
|
|
79
|
+
# @return [Array<Step>] steps in display order
|
|
80
|
+
def ordered_steps
|
|
81
|
+
return steps if steps.empty?
|
|
82
|
+
|
|
83
|
+
step_order = Spree::Order.checkout_step_names.map(&:to_s)
|
|
84
|
+
positioned, unpositioned = steps.partition { |s| s.before || s.after }
|
|
85
|
+
|
|
86
|
+
sorted = positioned.sort_by do |s|
|
|
87
|
+
anchor = s.before || s.after
|
|
88
|
+
idx = step_order.index(anchor)
|
|
89
|
+
# before: inserts just before the anchor, after: just after
|
|
90
|
+
idx ? (s.before ? idx - 0.5 : idx + 0.5) : Float::INFINITY
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
sorted + unpositioned
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [Array<Step>] all registered steps
|
|
97
|
+
def steps = (@steps ||= [])
|
|
98
|
+
|
|
99
|
+
# @return [Array<Requirement>] all registered requirements
|
|
100
|
+
def requirements = (@requirements ||= [])
|
|
101
|
+
|
|
102
|
+
# Clear all registered steps and requirements. Intended for testing.
|
|
103
|
+
#
|
|
104
|
+
# @return [void]
|
|
105
|
+
def reset!
|
|
106
|
+
@steps = []
|
|
107
|
+
@requirements = []
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Checkout
|
|
3
|
+
# Value object representing a single additional requirement registered via {Registry}.
|
|
4
|
+
#
|
|
5
|
+
# Unlike {Step}, a Requirement attaches extra validation to an *existing* checkout step
|
|
6
|
+
# rather than introducing a new step.
|
|
7
|
+
#
|
|
8
|
+
# @example Require a PO number for B2B orders at the payment step
|
|
9
|
+
# Spree::Checkout::Registry.add_requirement(
|
|
10
|
+
# step: :payment,
|
|
11
|
+
# field: :po_number,
|
|
12
|
+
# message: 'PO number is required for business accounts',
|
|
13
|
+
# satisfied: ->(order) { order.po_number.present? },
|
|
14
|
+
# applicable: ->(order) { order.account&.business? }
|
|
15
|
+
# )
|
|
16
|
+
class Requirement
|
|
17
|
+
# @return [String] checkout step this requirement belongs to
|
|
18
|
+
attr_reader :step
|
|
19
|
+
|
|
20
|
+
# @return [String] field identifier (e.g. +"po_number"+, +"tax_id"+)
|
|
21
|
+
attr_reader :field
|
|
22
|
+
|
|
23
|
+
# @return [String] human-readable message shown when the requirement is not met
|
|
24
|
+
attr_reader :message
|
|
25
|
+
|
|
26
|
+
# @param step [String, Symbol] checkout step this requirement belongs to
|
|
27
|
+
# @param field [String, Symbol] field identifier
|
|
28
|
+
# @param message [String] human-readable validation message
|
|
29
|
+
# @param satisfied [Proc] lambda accepting an order, returns true when met
|
|
30
|
+
# @param applicable [Proc] lambda accepting an order, returns true when this requirement applies
|
|
31
|
+
# (defaults to always applicable)
|
|
32
|
+
def initialize(step:, field:, message:, satisfied:, applicable: ->(_) { true })
|
|
33
|
+
@step = step.to_s
|
|
34
|
+
@field = field.to_s
|
|
35
|
+
@message = message
|
|
36
|
+
@satisfied_proc = satisfied
|
|
37
|
+
@applicable_proc = applicable
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param order [Spree::Order]
|
|
41
|
+
# @return [Boolean] whether the requirement has been met
|
|
42
|
+
def satisfied?(order) = @satisfied_proc.call(order)
|
|
43
|
+
|
|
44
|
+
# @param order [Spree::Order]
|
|
45
|
+
# @return [Boolean] whether this requirement applies to the given order
|
|
46
|
+
def applicable?(order) = @applicable_proc.call(order)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Checkout
|
|
3
|
+
# Aggregates all checkout requirements for an order.
|
|
4
|
+
#
|
|
5
|
+
# Combines built-in checks from {DefaultRequirements} with custom steps and
|
|
6
|
+
# requirements registered in {Registry}. The resulting array of hashes is
|
|
7
|
+
# exposed on the Cart API as the +requirements+ attribute.
|
|
8
|
+
#
|
|
9
|
+
# Each requirement hash has the shape:
|
|
10
|
+
# { step: String, field: String, message: String }
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# reqs = Spree::Checkout::Requirements.new(order)
|
|
14
|
+
# reqs.call # => [{ step: "address", field: "email", message: "Email address is required" }]
|
|
15
|
+
# reqs.met? # => false
|
|
16
|
+
class Requirements
|
|
17
|
+
# @param order [Spree::Order]
|
|
18
|
+
def initialize(order)
|
|
19
|
+
@order = order
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Array<Hash{Symbol => String}>] all unmet requirements
|
|
23
|
+
def call
|
|
24
|
+
default + from_registered_steps + from_additional_requirements
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Boolean] true when all requirements are satisfied
|
|
28
|
+
def met?
|
|
29
|
+
call.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# @return [Array<Hash>] built-in checkout requirements
|
|
35
|
+
def default
|
|
36
|
+
DefaultRequirements.new(@order).call
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Array<Hash>] requirements from unsatisfied registered steps
|
|
40
|
+
def from_registered_steps
|
|
41
|
+
Registry.ordered_steps
|
|
42
|
+
.select { |s| s.applicable?(@order) }
|
|
43
|
+
.reject { |s| s.satisfied?(@order) }
|
|
44
|
+
.flat_map { |s| s.requirements(@order) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Array<Hash>] requirements from unsatisfied registered requirements
|
|
48
|
+
def from_additional_requirements
|
|
49
|
+
Registry.requirements
|
|
50
|
+
.select { |r| r.applicable?(@order) }
|
|
51
|
+
.reject { |r| r.satisfied?(@order) }
|
|
52
|
+
.map { |r| { step: r.step, field: r.field, message: r.message } }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Checkout
|
|
3
|
+
# Value object representing a custom checkout step registered via {Registry}.
|
|
4
|
+
#
|
|
5
|
+
# @example Register a loyalty step before payment
|
|
6
|
+
# Spree::Checkout::Registry.register_step(
|
|
7
|
+
# name: :loyalty,
|
|
8
|
+
# before: :payment,
|
|
9
|
+
# satisfied: ->(order) { order.loyalty_verified? },
|
|
10
|
+
# requirements: ->(order) { [{ step: 'loyalty', field: 'loyalty_number', message: 'Enter loyalty number' }] }
|
|
11
|
+
# )
|
|
12
|
+
class Step
|
|
13
|
+
# @return [String] step name
|
|
14
|
+
attr_reader :name
|
|
15
|
+
|
|
16
|
+
# @return [String, nil] name of the checkout step this should be placed after
|
|
17
|
+
attr_reader :after
|
|
18
|
+
|
|
19
|
+
# @return [String, nil] name of the checkout step this should be placed before
|
|
20
|
+
attr_reader :before
|
|
21
|
+
|
|
22
|
+
# @param name [String, Symbol] unique step identifier
|
|
23
|
+
# @param satisfied [Proc] lambda accepting an order, returns true when the step is complete
|
|
24
|
+
# @param requirements [Proc] lambda accepting an order, returns Array of requirement hashes
|
|
25
|
+
# (+{ step:, field:, message: }+) describing what is still needed
|
|
26
|
+
# @param applicable [Proc] lambda accepting an order, returns true when this step applies
|
|
27
|
+
# (defaults to always applicable)
|
|
28
|
+
# @param after [String, Symbol, nil] place this step after the named checkout step
|
|
29
|
+
# @param before [String, Symbol, nil] place this step before the named checkout step
|
|
30
|
+
def initialize(name:, satisfied:, requirements:, applicable: ->(_) { true }, after: nil, before: nil)
|
|
31
|
+
@name = name.to_s
|
|
32
|
+
@after = after&.to_s
|
|
33
|
+
@before = before&.to_s
|
|
34
|
+
@satisfied_proc = satisfied
|
|
35
|
+
@requirements_proc = requirements
|
|
36
|
+
@applicable_proc = applicable
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param order [Spree::Order]
|
|
40
|
+
# @return [Boolean] whether the step's conditions have been met
|
|
41
|
+
def satisfied?(order) = @satisfied_proc.call(order)
|
|
42
|
+
|
|
43
|
+
# @param order [Spree::Order]
|
|
44
|
+
# @return [Array<Hash{Symbol => String}>] outstanding requirement hashes (+{ step:, field:, message: }+)
|
|
45
|
+
def requirements(order) = @requirements_proc.call(order)
|
|
46
|
+
|
|
47
|
+
# @param order [Spree::Order]
|
|
48
|
+
# @return [Boolean] whether this step applies to the given order
|
|
49
|
+
def applicable?(order) = @applicable_proc.call(order)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -227,6 +227,24 @@ module Spree
|
|
|
227
227
|
self.checkout_steps.index(step).to_i
|
|
228
228
|
end
|
|
229
229
|
|
|
230
|
+
# Customer-facing checkout step derived from the internal state machine state.
|
|
231
|
+
# Maps +'cart'+ to +'address'+ since cart is not a user-facing checkout step.
|
|
232
|
+
#
|
|
233
|
+
# @return [String] current checkout step name (e.g. +"address"+, +"delivery"+, +"payment"+)
|
|
234
|
+
def current_checkout_step
|
|
235
|
+
state == 'cart' ? 'address' : state
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Checkout steps that have already been completed, i.e. all steps before
|
|
239
|
+
# {#current_checkout_step}. Does not include +'complete'+.
|
|
240
|
+
#
|
|
241
|
+
# @return [Array<String>] completed step names in order
|
|
242
|
+
def completed_checkout_steps
|
|
243
|
+
steps = checkout_steps.reject { |s| s == 'complete' }
|
|
244
|
+
idx = steps.index(current_checkout_step) || 0
|
|
245
|
+
steps.first(idx)
|
|
246
|
+
end
|
|
247
|
+
|
|
230
248
|
def can_go_to_state?(state)
|
|
231
249
|
return false unless has_checkout_step?(self.state) && has_checkout_step?(state)
|
|
232
250
|
|
data/app/models/spree/order.rb
CHANGED
|
@@ -139,6 +139,7 @@ module Spree
|
|
|
139
139
|
inverse_of: :order
|
|
140
140
|
|
|
141
141
|
has_many :order_promotions, class_name: 'Spree::OrderPromotion'
|
|
142
|
+
has_many :cart_promotions, class_name: 'Spree::CartPromotion', foreign_key: :order_id
|
|
142
143
|
has_many :promotions, through: :order_promotions, class_name: 'Spree::Promotion'
|
|
143
144
|
|
|
144
145
|
has_many :shipments, class_name: 'Spree::Shipment', dependent: :destroy, inverse_of: :order do
|
|
@@ -148,6 +149,8 @@ module Spree
|
|
|
148
149
|
end
|
|
149
150
|
has_many :shipment_adjustments, through: :shipments, source: :adjustments
|
|
150
151
|
|
|
152
|
+
alias items line_items
|
|
153
|
+
|
|
151
154
|
accepts_nested_attributes_for :line_items
|
|
152
155
|
accepts_nested_attributes_for :bill_address
|
|
153
156
|
accepts_nested_attributes_for :ship_address
|
|
@@ -71,10 +71,44 @@ module Spree
|
|
|
71
71
|
|
|
72
72
|
# Completes a payment session via the provider.
|
|
73
73
|
# Override in gateway subclasses to implement provider-specific session completion.
|
|
74
|
+
#
|
|
75
|
+
# Responsibilities:
|
|
76
|
+
# - Verify payment status with the provider
|
|
77
|
+
# - Create/update the Spree::Payment record
|
|
78
|
+
# - Patch order data from provider (e.g. wallet billing address)
|
|
79
|
+
# - Transition payment session to completed/failed
|
|
80
|
+
#
|
|
81
|
+
# Must NOT complete the order — that is handled by Carts::Complete
|
|
82
|
+
# (called by the frontend or by the webhook handler).
|
|
74
83
|
def complete_payment_session(payment_session:, params: {})
|
|
75
84
|
raise ::NotImplementedError, 'You must implement complete_payment_session method for this gateway.'
|
|
76
85
|
end
|
|
77
86
|
|
|
87
|
+
# Parses an incoming webhook payload from the payment provider.
|
|
88
|
+
# Override in gateway subclasses to implement provider-specific webhook parsing.
|
|
89
|
+
#
|
|
90
|
+
# @param raw_body [String] the raw request body
|
|
91
|
+
# @param headers [Hash] the request headers
|
|
92
|
+
# @return [Hash, nil] normalized result or nil for unsupported events
|
|
93
|
+
# { action: :captured/:authorized/:failed/:canceled,
|
|
94
|
+
# payment_session: <Spree::PaymentSession>,
|
|
95
|
+
# metadata: {} }
|
|
96
|
+
# @raise [Spree::PaymentMethod::WebhookSignatureError] if signature is invalid
|
|
97
|
+
def parse_webhook_event(raw_body, headers)
|
|
98
|
+
raise ::NotImplementedError, 'You must implement parse_webhook_event method for this gateway.'
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Returns the webhook URL for this payment method.
|
|
102
|
+
# @return [String, nil]
|
|
103
|
+
def webhook_url
|
|
104
|
+
store = stores.first
|
|
105
|
+
return nil unless store
|
|
106
|
+
|
|
107
|
+
"#{store.url_or_custom_domain}/api/v3/webhooks/payments/#{prefixed_id}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
class WebhookSignatureError < StandardError; end
|
|
111
|
+
|
|
78
112
|
# Whether this payment method supports setup sessions (saving payment methods for future use).
|
|
79
113
|
# Override in gateway subclasses that support tokenization without a payment.
|
|
80
114
|
def setup_session_supported?
|
|
@@ -76,6 +76,24 @@ module Spree
|
|
|
76
76
|
expires_at.present? && expires_at <= Time.current
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
# Creates or finds the Spree::Payment record for this session.
|
|
80
|
+
# Gateway subclasses can override this in their PaymentSession subclass
|
|
81
|
+
# to handle gateway-specific source creation (credit cards, wallets, etc).
|
|
82
|
+
#
|
|
83
|
+
# @param metadata [Hash] gateway-specific metadata
|
|
84
|
+
# @return [Spree::Payment] the payment record
|
|
85
|
+
def find_or_create_payment!(metadata = {})
|
|
86
|
+
return payment if payment.present?
|
|
87
|
+
|
|
88
|
+
order.payments.find_or_create_by!(
|
|
89
|
+
payment_method: payment_method,
|
|
90
|
+
response_code: external_id,
|
|
91
|
+
) do |p|
|
|
92
|
+
p.amount = amount
|
|
93
|
+
p.skip_source_requirement = true
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
79
97
|
private
|
|
80
98
|
|
|
81
99
|
def publish_processing_event
|
data/app/models/spree/store.rb
CHANGED
|
@@ -58,6 +58,7 @@ module Spree
|
|
|
58
58
|
#
|
|
59
59
|
# Associations
|
|
60
60
|
#
|
|
61
|
+
has_many :carts, -> { incomplete }, class_name: 'Spree::Order', inverse_of: :store
|
|
61
62
|
has_many :checkouts, -> { incomplete }, class_name: 'Spree::Order', inverse_of: :store
|
|
62
63
|
has_many :orders, class_name: 'Spree::Order'
|
|
63
64
|
has_many :line_items, through: :orders, class_name: 'Spree::LineItem'
|
|
@@ -3,49 +3,22 @@ module Spree
|
|
|
3
3
|
class Create
|
|
4
4
|
prepend Spree::ServiceModule::Base
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
# @param store [Spree::Store] the store for the cart
|
|
8
|
-
# @param currency [String, nil] ISO currency code, defaults to store's default currency
|
|
9
|
-
# @param locale [String, nil] locale for the cart (e.g. 'en', 'fr'), defaults to Spree::Current.locale
|
|
10
|
-
# @param public_metadata [Hash] public metadata for the order
|
|
11
|
-
# @param private_metadata [Hash] private metadata for the order
|
|
12
|
-
# @param order_params [Hash] additional order attributes
|
|
13
|
-
# @param line_items [Array<Hash>] line items to add, each with :variant_id (prefixed) and :quantity
|
|
14
|
-
# @return [Spree::ServiceModule::Result]
|
|
15
|
-
def call(user:, store:, currency:, locale: nil, metadata: {}, public_metadata: {}, private_metadata: {}, order_params: {}, line_items: [])
|
|
6
|
+
def call(user:, store:, currency:, public_metadata: {}, private_metadata: {}, order_params: {})
|
|
16
7
|
order_params ||= {}
|
|
17
|
-
line_items ||= []
|
|
18
8
|
|
|
19
9
|
# we cannot create an order without store
|
|
20
10
|
return failure(:store_is_required) if store.nil?
|
|
21
11
|
|
|
22
|
-
resolved_metadata = metadata.presence || private_metadata
|
|
23
|
-
|
|
24
12
|
default_params = {
|
|
25
13
|
user: user,
|
|
26
14
|
currency: currency || store.default_currency,
|
|
27
|
-
locale: locale || Spree::Current.locale,
|
|
28
15
|
token: Spree::GenerateToken.new.call(Spree::Order),
|
|
29
16
|
public_metadata: public_metadata.to_h,
|
|
30
|
-
private_metadata:
|
|
17
|
+
private_metadata: private_metadata.to_h
|
|
31
18
|
}
|
|
32
19
|
|
|
33
|
-
order =
|
|
34
|
-
|
|
35
|
-
ApplicationRecord.transaction do
|
|
36
|
-
order = store.orders.create!(default_params.merge(order_params))
|
|
37
|
-
|
|
38
|
-
if line_items.present?
|
|
39
|
-
result = Spree.cart_upsert_items_service.call(order: order, line_items: line_items)
|
|
40
|
-
raise StandardError, result.error.to_s if result.failure?
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
20
|
+
order = store.orders.create!(default_params.merge(order_params))
|
|
44
21
|
success(order)
|
|
45
|
-
rescue ActiveRecord::RecordNotFound
|
|
46
|
-
raise
|
|
47
|
-
rescue StandardError => e
|
|
48
|
-
failure(order, e.message)
|
|
49
22
|
end
|
|
50
23
|
end
|
|
51
24
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# In Spree 6 this servoice will complete the Spree::Cart, and create a Spree::Order
|
|
2
|
+
# created based on the contents of the cart.
|
|
3
|
+
module Spree
|
|
4
|
+
module Carts
|
|
5
|
+
class Complete
|
|
6
|
+
prepend Spree::ServiceModule::Base
|
|
7
|
+
|
|
8
|
+
# Completes the cart and creates a Spree::Order based on its contents.
|
|
9
|
+
# @return [Spree::Order]
|
|
10
|
+
def call(cart:)
|
|
11
|
+
return success(cart) if cart.completed?
|
|
12
|
+
return failure(cart, 'Order is canceled') if cart.canceled?
|
|
13
|
+
|
|
14
|
+
cart.with_lock do
|
|
15
|
+
process_payments!(cart) if cart.payment_required?
|
|
16
|
+
|
|
17
|
+
return failure(cart, cart.errors.full_messages.to_sentence) if cart.errors.any?
|
|
18
|
+
|
|
19
|
+
advance_to_complete!(cart)
|
|
20
|
+
|
|
21
|
+
if cart.reload.complete?
|
|
22
|
+
success(cart)
|
|
23
|
+
else
|
|
24
|
+
failure(cart, cart.errors.full_messages.to_sentence.presence || 'Could not complete checkout')
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def process_payments!(cart)
|
|
32
|
+
# If payments were already processed by the payment session
|
|
33
|
+
# (e.g. Stripe charged the card during complete_payment_session),
|
|
34
|
+
# skip re-processing. Only process unprocessed (checkout state) payments.
|
|
35
|
+
return if cart.payment_total >= cart.total
|
|
36
|
+
return if cart.payments.valid.any?(&:completed?) && cart.unprocessed_payments.empty?
|
|
37
|
+
|
|
38
|
+
cart.process_payments!
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def advance_to_complete!(cart)
|
|
42
|
+
cart.next until cart.complete? || cart.errors.present?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Carts
|
|
3
|
+
class Create
|
|
4
|
+
prepend Spree::ServiceModule::Base
|
|
5
|
+
|
|
6
|
+
def call(params: {})
|
|
7
|
+
@params = params.to_h.deep_symbolize_keys
|
|
8
|
+
|
|
9
|
+
store = @params.delete(:store)
|
|
10
|
+
return failure(:store_is_required) if store.nil?
|
|
11
|
+
|
|
12
|
+
cart = store.carts.create!(
|
|
13
|
+
user: @params.delete(:user),
|
|
14
|
+
currency: @params.delete(:currency) || store.default_currency,
|
|
15
|
+
locale: @params.delete(:locale) || Spree::Current.locale
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Delegate all attribute/address/item processing to Carts::Update
|
|
19
|
+
if @params.present?
|
|
20
|
+
result = Spree::Carts::Update.call(cart: cart, params: @params)
|
|
21
|
+
return result if result.failure?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
success(cart.reload)
|
|
25
|
+
rescue ActiveRecord::RecordNotFound
|
|
26
|
+
raise
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
failure(nil, e.message)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Carts
|
|
3
|
+
class Update
|
|
4
|
+
prepend Spree::ServiceModule::Base
|
|
5
|
+
|
|
6
|
+
def call(cart:, params:)
|
|
7
|
+
@cart = cart
|
|
8
|
+
@params = params.to_h.deep_symbolize_keys
|
|
9
|
+
|
|
10
|
+
ApplicationRecord.transaction do
|
|
11
|
+
assign_cart_attributes
|
|
12
|
+
assign_address(:ship_address)
|
|
13
|
+
assign_address(:bill_address)
|
|
14
|
+
|
|
15
|
+
cart.save!
|
|
16
|
+
|
|
17
|
+
process_items
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
try_advance
|
|
21
|
+
|
|
22
|
+
success(cart)
|
|
23
|
+
rescue ActiveRecord::RecordNotFound
|
|
24
|
+
raise
|
|
25
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
26
|
+
failure(cart, e.record.errors.full_messages.to_sentence)
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
failure(cart, e.message)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :cart, :params
|
|
34
|
+
|
|
35
|
+
def assign_cart_attributes
|
|
36
|
+
cart.email = params[:email] if params[:email].present?
|
|
37
|
+
cart.special_instructions = params[:special_instructions] if params.key?(:special_instructions)
|
|
38
|
+
cart.currency = params[:currency].upcase if params[:currency].present?
|
|
39
|
+
cart.locale = params[:locale] if params[:locale].present?
|
|
40
|
+
cart.metadata = cart.metadata.merge(params[:metadata].to_h) if params[:metadata].present?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def assign_address(address_type)
|
|
44
|
+
address_id_param = params[:"#{address_type}_id"]
|
|
45
|
+
address_params = params[address_type]
|
|
46
|
+
|
|
47
|
+
if address_id_param.present?
|
|
48
|
+
address_id = resolve_address_id(address_id_param)
|
|
49
|
+
cart.public_send(:"#{address_type}_id=", address_id) if address_id
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
return unless address_params.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
if address_params[:id].present?
|
|
56
|
+
address_id = resolve_address_id(address_params[:id])
|
|
57
|
+
cart.public_send(:"#{address_type}_id=", address_id) if address_id
|
|
58
|
+
else
|
|
59
|
+
# Only revert to address state when shipping address changes.
|
|
60
|
+
# Billing address updates (e.g. during payment) should not
|
|
61
|
+
# destroy shipments and reset the checkout flow.
|
|
62
|
+
revert_to_address_state if address_type == :ship_address && cart.has_checkout_step?('address')
|
|
63
|
+
cart.public_send(:"#{address_type}_attributes=", address_params)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def process_items
|
|
68
|
+
return unless params[:items].is_a?(Array)
|
|
69
|
+
|
|
70
|
+
result = Spree::Carts::UpsertItems.call(
|
|
71
|
+
cart: cart,
|
|
72
|
+
items: params[:items]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
raise StandardError, result.error.to_s if result.failure?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def resolve_address_id(prefixed_id)
|
|
79
|
+
return unless cart.user
|
|
80
|
+
|
|
81
|
+
decoded = Spree::Address.decode_prefixed_id(prefixed_id)
|
|
82
|
+
decoded ? cart.user.addresses.find_by(id: decoded)&.id : nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def revert_to_address_state
|
|
86
|
+
return if ['cart', 'address'].include?(cart.state)
|
|
87
|
+
|
|
88
|
+
cart.state = 'address'
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Auto-advance as far as the checkout state machine allows.
|
|
92
|
+
# Loops cart.next until the cart can't progress further (e.g. missing
|
|
93
|
+
# payment) or reaches confirm/complete. Stops at the first step whose
|
|
94
|
+
# before_transition guard fails — the `requirements` array in the
|
|
95
|
+
# serialized response tells the frontend what's still missing.
|
|
96
|
+
# Failure is swallowed — the update itself already succeeded.
|
|
97
|
+
def try_advance
|
|
98
|
+
return if cart.complete? || cart.canceled?
|
|
99
|
+
|
|
100
|
+
loop do
|
|
101
|
+
break unless cart.next
|
|
102
|
+
break if cart.confirm? || cart.complete?
|
|
103
|
+
end
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
Rails.error.report(e, context: { order_id: cart.id, state: cart.state }, source: 'spree.checkout')
|
|
106
|
+
ensure
|
|
107
|
+
begin
|
|
108
|
+
cart.reload
|
|
109
|
+
rescue StandardError # rubocop:disable Lint/SuppressedException
|
|
110
|
+
# reload failure must not mask the original result
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
module Spree
|
|
2
|
-
module
|
|
3
|
-
# Bulk upsert line items on
|
|
2
|
+
module Carts
|
|
3
|
+
# Bulk upsert line items on a cart.
|
|
4
4
|
#
|
|
5
|
-
# For each entry in +
|
|
6
|
-
# - If a line item for the variant already exists
|
|
7
|
-
# - If no line item exists
|
|
5
|
+
# For each entry in +items+:
|
|
6
|
+
# - If a line item for the variant already exists -> sets its quantity
|
|
7
|
+
# - If no line item exists -> creates one with the given quantity
|
|
8
8
|
#
|
|
9
|
-
# After all items are processed the
|
|
10
|
-
#
|
|
11
|
-
# Price calculation and tax adjustments are handled by LineItem model callbacks
|
|
12
|
-
# (copy_price, update_adjustments, update_tax_charge), so we only need to
|
|
13
|
-
# save each item and run a single order recalculation at the end.
|
|
9
|
+
# After all items are processed the cart is recalculated once.
|
|
14
10
|
#
|
|
15
11
|
# @example
|
|
16
|
-
# Spree::
|
|
17
|
-
#
|
|
18
|
-
#
|
|
12
|
+
# Spree::Carts::UpsertItems.new.call(
|
|
13
|
+
# cart: cart,
|
|
14
|
+
# items: [
|
|
19
15
|
# { variant_id: "variant_k5nR8xLq", quantity: 2 },
|
|
20
16
|
# { variant_id: "variant_m3Rp9wXz", quantity: 1 }
|
|
21
17
|
# ]
|
|
@@ -24,39 +20,39 @@ module Spree
|
|
|
24
20
|
class UpsertItems
|
|
25
21
|
prepend Spree::ServiceModule::Base
|
|
26
22
|
|
|
27
|
-
def call(
|
|
28
|
-
|
|
29
|
-
return success(
|
|
23
|
+
def call(cart:, items:)
|
|
24
|
+
items = Array(items)
|
|
25
|
+
return success(cart) if items.empty?
|
|
30
26
|
|
|
31
|
-
store =
|
|
27
|
+
store = cart.store || Spree::Current.store
|
|
32
28
|
|
|
33
29
|
ApplicationRecord.transaction do
|
|
34
|
-
|
|
30
|
+
items.each do |item_params|
|
|
35
31
|
item_params = item_params.to_h.deep_symbolize_keys
|
|
36
32
|
variant = resolve_variant(store, item_params[:variant_id])
|
|
37
33
|
next unless variant
|
|
38
34
|
|
|
39
35
|
quantity = (item_params[:quantity] || 1).to_i
|
|
40
36
|
|
|
41
|
-
return failure(variant, "#{variant.name} is not available in #{
|
|
37
|
+
return failure(variant, "#{variant.name} is not available in #{cart.currency}") if variant.amount_in(cart.currency).nil?
|
|
42
38
|
|
|
43
|
-
line_item = Spree.line_item_by_variant_finder.new.execute(order:
|
|
39
|
+
line_item = Spree.line_item_by_variant_finder.new.execute(order: cart, variant: variant)
|
|
44
40
|
|
|
45
41
|
if line_item
|
|
46
42
|
line_item.quantity = quantity
|
|
47
43
|
line_item.metadata = line_item.metadata.merge(item_params[:metadata].to_h) if item_params[:metadata].present?
|
|
48
44
|
else
|
|
49
|
-
line_item =
|
|
45
|
+
line_item = cart.items.new(quantity: quantity, variant: variant, options: { currency: cart.currency })
|
|
50
46
|
line_item.metadata = item_params[:metadata].to_h if item_params[:metadata].present?
|
|
51
47
|
end
|
|
52
48
|
|
|
53
49
|
return failure(line_item) unless line_item.save
|
|
54
50
|
end
|
|
55
51
|
|
|
56
|
-
|
|
52
|
+
cart.update_with_updater!
|
|
57
53
|
end
|
|
58
54
|
|
|
59
|
-
success(
|
|
55
|
+
success(cart)
|
|
60
56
|
end
|
|
61
57
|
|
|
62
58
|
private
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Payments
|
|
3
|
+
class HandleWebhook
|
|
4
|
+
prepend Spree::ServiceModule::Base
|
|
5
|
+
|
|
6
|
+
# @param payment_method [Spree::PaymentMethod] the payment method that received the webhook
|
|
7
|
+
# @param action [Symbol] normalized action (:captured, :authorized, :failed, :canceled)
|
|
8
|
+
# @param payment_session [Spree::PaymentSession] the payment session associated with the webhook
|
|
9
|
+
# @param metadata [Hash] gateway-specific metadata (e.g. charge data, psp reference)
|
|
10
|
+
def call(payment_method:, action:, payment_session:, metadata: {})
|
|
11
|
+
return success(nil) if payment_session.nil?
|
|
12
|
+
|
|
13
|
+
order = payment_session.order
|
|
14
|
+
|
|
15
|
+
case action
|
|
16
|
+
when :captured, :authorized
|
|
17
|
+
handle_success(payment_session, order, metadata)
|
|
18
|
+
when :failed
|
|
19
|
+
payment_session.fail if payment_session.can_fail?
|
|
20
|
+
success(payment_session)
|
|
21
|
+
when :canceled
|
|
22
|
+
payment_session.cancel if payment_session.can_cancel?
|
|
23
|
+
success(payment_session)
|
|
24
|
+
else
|
|
25
|
+
failure(payment_session, "Unknown webhook action: #{action}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def handle_success(payment_session, order, metadata)
|
|
32
|
+
order.with_lock do
|
|
33
|
+
# Ensure payment record exists
|
|
34
|
+
payment = payment_session.find_or_create_payment!(metadata)
|
|
35
|
+
|
|
36
|
+
# Mark payment as completed — the webhook confirms the gateway processed it
|
|
37
|
+
if payment.present? && !payment.completed?
|
|
38
|
+
payment.started_processing! if payment.checkout?
|
|
39
|
+
payment.complete! if payment.can_complete?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Mark session as completed
|
|
43
|
+
payment_session.complete if payment_session.can_complete?
|
|
44
|
+
|
|
45
|
+
# Complete order if not already done
|
|
46
|
+
unless order.reload.completed?
|
|
47
|
+
Spree::Dependencies.carts_complete_service.constantize.call(cart: order)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
success(payment_session)
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
Rails.error.report(e, context: { payment_session_id: payment_session.id, order_id: order.id }, source: 'spree.payments.webhook')
|
|
54
|
+
failure(payment_session, e.message)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/config/locales/en.yml
CHANGED
|
@@ -837,6 +837,7 @@ en:
|
|
|
837
837
|
card_type: Brand
|
|
838
838
|
card_type_is: Card type is
|
|
839
839
|
cart: Cart
|
|
840
|
+
cart_already_updated: The cart has already been updated.
|
|
840
841
|
cart_line_item:
|
|
841
842
|
discontinued: "%{li_name} was removed because it was discontinued"
|
|
842
843
|
out_of_stock: "%{li_name} was removed because it was sold out"
|
|
@@ -864,6 +865,12 @@ en:
|
|
|
864
865
|
charged: Charged
|
|
865
866
|
checkout: Checkout
|
|
866
867
|
checkout_message: Checkout message
|
|
868
|
+
checkout_requirements:
|
|
869
|
+
email_required: Email address is required
|
|
870
|
+
line_items_required: Add at least one item to your cart
|
|
871
|
+
payment_required: Add a payment method
|
|
872
|
+
ship_address_required: Shipping address is required
|
|
873
|
+
shipping_method_required: Select a shipping method for all shipments
|
|
867
874
|
choose_a_customer: Choose a customer
|
|
868
875
|
choose_a_taxon_to_sort_products_for: Choose a taxon to sort products for
|
|
869
876
|
choose_currency: Choose Currency
|
|
@@ -1559,8 +1566,6 @@ en:
|
|
|
1559
1566
|
order: Order
|
|
1560
1567
|
order_adjustments: Order adjustments
|
|
1561
1568
|
order_again: Order again
|
|
1562
|
-
order_already_completed: Order already completed
|
|
1563
|
-
order_already_updated: The order has already been updated.
|
|
1564
1569
|
order_approved: Order approved
|
|
1565
1570
|
order_canceled: Order canceled
|
|
1566
1571
|
order_details: Order Details
|
|
@@ -16,7 +16,6 @@ module Spree
|
|
|
16
16
|
cart_remove_item_service: 'Spree::Cart::RemoveItem',
|
|
17
17
|
cart_remove_line_item_service: 'Spree::Cart::RemoveLineItem',
|
|
18
18
|
cart_set_item_quantity_service: 'Spree::Cart::SetQuantity',
|
|
19
|
-
cart_upsert_items_service: 'Spree::Cart::UpsertItems',
|
|
20
19
|
cart_estimate_shipping_rates_service: 'Spree::Cart::EstimateShippingRates',
|
|
21
20
|
cart_empty_service: 'Spree::Cart::Empty',
|
|
22
21
|
cart_destroy_service: 'Spree::Cart::Destroy',
|
|
@@ -24,6 +23,9 @@ module Spree
|
|
|
24
23
|
cart_change_currency_service: 'Spree::Cart::ChangeCurrency',
|
|
25
24
|
cart_remove_out_of_stock_items_service: 'Spree::Cart::RemoveOutOfStockItems',
|
|
26
25
|
|
|
26
|
+
# carts
|
|
27
|
+
carts_complete_service: 'Spree::Carts::Complete',
|
|
28
|
+
|
|
27
29
|
# checkout
|
|
28
30
|
checkout_next_service: 'Spree::Checkout::Next',
|
|
29
31
|
checkout_advance_service: 'Spree::Checkout::Advance',
|
|
@@ -42,7 +44,6 @@ module Spree
|
|
|
42
44
|
# order
|
|
43
45
|
order_approve_service: 'Spree::Orders::Approve',
|
|
44
46
|
order_cancel_service: 'Spree::Orders::Cancel',
|
|
45
|
-
order_update_service: 'Spree::Orders::Update',
|
|
46
47
|
order_updater: 'Spree::OrderUpdater',
|
|
47
48
|
|
|
48
49
|
# shipment
|
|
@@ -87,6 +88,7 @@ module Spree
|
|
|
87
88
|
line_item_destroy_service: 'Spree::LineItems::Destroy',
|
|
88
89
|
|
|
89
90
|
payment_create_service: 'Spree::Payments::Create',
|
|
91
|
+
payments_handle_webhook_service: 'Spree::Payments::HandleWebhook',
|
|
90
92
|
|
|
91
93
|
# finders
|
|
92
94
|
address_finder: 'Spree::Addresses::Find',
|
data/lib/spree/core/version.rb
CHANGED
data/lib/spree/core.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: spree_core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.4.0.
|
|
4
|
+
version: 5.4.0.beta7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sean Schofield
|
|
@@ -10,7 +10,7 @@ authors:
|
|
|
10
10
|
autorequire:
|
|
11
11
|
bindir: bin
|
|
12
12
|
cert_chain: []
|
|
13
|
-
date: 2026-03-
|
|
13
|
+
date: 2026-03-15 00:00:00.000000000 Z
|
|
14
14
|
dependencies:
|
|
15
15
|
- !ruby/object:Gem::Dependency
|
|
16
16
|
name: i18n-tasks
|
|
@@ -840,6 +840,7 @@ files:
|
|
|
840
840
|
- app/jobs/spree/images/save_from_url_job.rb
|
|
841
841
|
- app/jobs/spree/imports/create_rows_job.rb
|
|
842
842
|
- app/jobs/spree/imports/process_rows_job.rb
|
|
843
|
+
- app/jobs/spree/payments/handle_webhook_job.rb
|
|
843
844
|
- app/jobs/spree/products/auto_match_taxons_job.rb
|
|
844
845
|
- app/jobs/spree/products/refresh_metrics_job.rb
|
|
845
846
|
- app/jobs/spree/products/touch_taxons_job.rb
|
|
@@ -921,7 +922,13 @@ files:
|
|
|
921
922
|
- app/models/spree/calculator/shipping/price_sack.rb
|
|
922
923
|
- app/models/spree/calculator/tiered_flat_rate.rb
|
|
923
924
|
- app/models/spree/calculator/tiered_percent.rb
|
|
925
|
+
- app/models/spree/cart_promotion.rb
|
|
924
926
|
- app/models/spree/category.rb
|
|
927
|
+
- app/models/spree/checkout/default_requirements.rb
|
|
928
|
+
- app/models/spree/checkout/registry.rb
|
|
929
|
+
- app/models/spree/checkout/requirement.rb
|
|
930
|
+
- app/models/spree/checkout/requirements.rb
|
|
931
|
+
- app/models/spree/checkout/step.rb
|
|
925
932
|
- app/models/spree/classification.rb
|
|
926
933
|
- app/models/spree/country.rb
|
|
927
934
|
- app/models/spree/coupon_code.rb
|
|
@@ -1201,7 +1208,10 @@ files:
|
|
|
1201
1208
|
- app/services/spree/cart/remove_out_of_stock_items.rb
|
|
1202
1209
|
- app/services/spree/cart/set_quantity.rb
|
|
1203
1210
|
- app/services/spree/cart/update.rb
|
|
1204
|
-
- app/services/spree/
|
|
1211
|
+
- app/services/spree/carts/complete.rb
|
|
1212
|
+
- app/services/spree/carts/create.rb
|
|
1213
|
+
- app/services/spree/carts/update.rb
|
|
1214
|
+
- app/services/spree/carts/upsert_items.rb
|
|
1205
1215
|
- app/services/spree/checkout/add_store_credit.rb
|
|
1206
1216
|
- app/services/spree/checkout/advance.rb
|
|
1207
1217
|
- app/services/spree/checkout/complete.rb
|
|
@@ -1233,9 +1243,9 @@ files:
|
|
|
1233
1243
|
- app/services/spree/orders/approve.rb
|
|
1234
1244
|
- app/services/spree/orders/cancel.rb
|
|
1235
1245
|
- app/services/spree/orders/create_user_account.rb
|
|
1236
|
-
- app/services/spree/orders/update.rb
|
|
1237
1246
|
- app/services/spree/orders/update_contact_information.rb
|
|
1238
1247
|
- app/services/spree/payments/create.rb
|
|
1248
|
+
- app/services/spree/payments/handle_webhook.rb
|
|
1239
1249
|
- app/services/spree/products/auto_match_taxons.rb
|
|
1240
1250
|
- app/services/spree/products/duplicator.rb
|
|
1241
1251
|
- app/services/spree/products/prepare_nested_attributes.rb
|
|
@@ -1656,9 +1666,9 @@ licenses:
|
|
|
1656
1666
|
- BSD-3-Clause
|
|
1657
1667
|
metadata:
|
|
1658
1668
|
bug_tracker_uri: https://github.com/spree/spree/issues
|
|
1659
|
-
changelog_uri: https://github.com/spree/spree/releases/tag/v5.4.0.
|
|
1669
|
+
changelog_uri: https://github.com/spree/spree/releases/tag/v5.4.0.beta7
|
|
1660
1670
|
documentation_uri: https://docs.spreecommerce.org/
|
|
1661
|
-
source_code_uri: https://github.com/spree/spree/tree/v5.4.0.
|
|
1671
|
+
source_code_uri: https://github.com/spree/spree/tree/v5.4.0.beta7
|
|
1662
1672
|
post_install_message:
|
|
1663
1673
|
rdoc_options: []
|
|
1664
1674
|
require_paths:
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
module Spree
|
|
2
|
-
module Orders
|
|
3
|
-
# Core order update service with modern conventions:
|
|
4
|
-
# - Flat parameter structure (no wrapping in "order" key)
|
|
5
|
-
# - snake_case field names without "_attributes" suffix
|
|
6
|
-
# - Automatic state management based on what's being updated
|
|
7
|
-
# - Support for line_items with prefixed variant IDs
|
|
8
|
-
#
|
|
9
|
-
# @example Update order with line items
|
|
10
|
-
# Spree::Orders::Update.call(
|
|
11
|
-
# order: order,
|
|
12
|
-
# params: {
|
|
13
|
-
# email: "customer@example.com",
|
|
14
|
-
# line_items: [
|
|
15
|
-
# { variant_id: "variant_123", quantity: 2 },
|
|
16
|
-
# { variant_id: "variant_456", quantity: 1 }
|
|
17
|
-
# ],
|
|
18
|
-
# ship_address: {
|
|
19
|
-
# firstname: "John",
|
|
20
|
-
# lastname: "Doe",
|
|
21
|
-
# address1: "123 Main St",
|
|
22
|
-
# city: "New York",
|
|
23
|
-
# zipcode: "10001",
|
|
24
|
-
# country_iso: "US",
|
|
25
|
-
# state_abbr: "NY"
|
|
26
|
-
# }
|
|
27
|
-
# }
|
|
28
|
-
# )
|
|
29
|
-
#
|
|
30
|
-
class Update
|
|
31
|
-
prepend Spree::ServiceModule::Base
|
|
32
|
-
|
|
33
|
-
def call(order:, params:)
|
|
34
|
-
@order = order
|
|
35
|
-
@params = params.to_h.deep_symbolize_keys
|
|
36
|
-
|
|
37
|
-
ApplicationRecord.transaction do
|
|
38
|
-
assign_order_attributes
|
|
39
|
-
assign_address(:ship_address)
|
|
40
|
-
assign_address(:bill_address)
|
|
41
|
-
|
|
42
|
-
order.save!
|
|
43
|
-
|
|
44
|
-
process_line_items
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
success(order.reload)
|
|
48
|
-
rescue ActiveRecord::RecordNotFound
|
|
49
|
-
raise
|
|
50
|
-
rescue ActiveRecord::RecordInvalid => e
|
|
51
|
-
failure(order, e.record.errors.full_messages.to_sentence)
|
|
52
|
-
rescue StandardError => e
|
|
53
|
-
failure(order, e.message)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
private
|
|
57
|
-
|
|
58
|
-
attr_reader :order, :params
|
|
59
|
-
|
|
60
|
-
def assign_order_attributes
|
|
61
|
-
order.email = params[:email] if params[:email].present?
|
|
62
|
-
order.special_instructions = params[:special_instructions] if params.key?(:special_instructions)
|
|
63
|
-
order.currency = params[:currency].upcase if params[:currency].present?
|
|
64
|
-
order.locale = params[:locale] if params[:locale].present?
|
|
65
|
-
order.metadata = order.metadata.merge(params[:metadata].to_h) if params[:metadata].present?
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def assign_address(address_type)
|
|
69
|
-
address_id_param = params[:"#{address_type}_id"]
|
|
70
|
-
address_params = params[address_type]
|
|
71
|
-
|
|
72
|
-
# Priority 1: Direct address ID reference (ship_address_id / bill_address_id)
|
|
73
|
-
if address_id_param.present?
|
|
74
|
-
address_id = resolve_address_id(address_id_param)
|
|
75
|
-
order.public_send(:"#{address_type}_id=", address_id) if address_id
|
|
76
|
-
return
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# Priority 2: Nested address params (ship_address / bill_address)
|
|
80
|
-
return unless address_params.is_a?(Hash)
|
|
81
|
-
|
|
82
|
-
if address_params[:id].present?
|
|
83
|
-
# Using existing address by ID within nested params
|
|
84
|
-
address_id = resolve_address_id(address_params[:id])
|
|
85
|
-
order.public_send(:"#{address_type}_id=", address_id) if address_id
|
|
86
|
-
else
|
|
87
|
-
# Creating/updating address with provided attributes
|
|
88
|
-
revert_to_address_state if order.has_checkout_step?('address')
|
|
89
|
-
order.public_send(:"#{address_type}_attributes=", address_params)
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def process_line_items
|
|
94
|
-
return unless params[:line_items].is_a?(Array)
|
|
95
|
-
|
|
96
|
-
result = Spree.cart_upsert_items_service.call(
|
|
97
|
-
order: order,
|
|
98
|
-
line_items: params[:line_items]
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
raise StandardError, result.error.to_s if result.failure?
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# Translate prefixed ID to internal id
|
|
105
|
-
def resolve_address_id(prefixed_id)
|
|
106
|
-
return unless order.user
|
|
107
|
-
|
|
108
|
-
decoded = Spree::Address.decode_prefixed_id(prefixed_id)
|
|
109
|
-
decoded ? order.user.addresses.find_by(id: decoded)&.id : nil
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# Revert order state to 'address' when address changes
|
|
113
|
-
# This ensures shipments are recreated when transitioning back to delivery
|
|
114
|
-
def revert_to_address_state
|
|
115
|
-
return if ['cart', 'address'].include?(order.state)
|
|
116
|
-
|
|
117
|
-
order.state = 'address'
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
end
|