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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1e118b3f3198080c2006abcd4956ae76608cf6a6a99ed7f16df2380839fe98f
4
- data.tar.gz: 8f05ff3e72814788a15c7c145ea62f1f9efb603b5dcd18fcedcd07774274db7a
3
+ metadata.gz: 9a75c8667f68e7a71143adec2c78694c99308dbc73fa9afd625e1c5e339e2fb8
4
+ data.tar.gz: '04229df0b73d148776d498157514ed592db0ffb551dc347a2aca782cafb80f01'
5
5
  SHA512:
6
- metadata.gz: b6b0596bb455d278630cf6df3e1927034c703f15e4319c856cec8eaa798e871ec671597a3bf07f3cb20846b775a460f77df8e828184fc2b1200c4a394760c361
7
- data.tar.gz: c3693f84de9a1b1c26e3572f8c350a498050765242453b3b2a95d3a6956d06476a54db89ec3f21c0ba4bd0eea27edad7da2a2b1ada5f79d9a1eaf70a44084a28
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,7 @@
1
+ module Spree
2
+ # Cart-facing view of an OrderPromotion.
3
+ # Same table, different prefix ID (cp_) for the Cart API.
4
+ class CartPromotion < OrderPromotion
5
+ has_prefix_id :cpromo
6
+ end
7
+ end
@@ -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
 
@@ -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
@@ -1,5 +1,7 @@
1
1
  module Spree
2
2
  class OrderPromotion < Spree.base_class
3
+ has_prefix_id :oprom
4
+
3
5
  belongs_to :order, class_name: 'Spree::Order'
4
6
  belongs_to :promotion, class_name: 'Spree::Promotion'
5
7
 
@@ -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
@@ -354,6 +354,7 @@ module Spree
354
354
  return if order.completed?
355
355
 
356
356
  update_amounts
357
+ reload # reload to pick up cost set by update_columns in update_amounts
357
358
  order.set_shipments_cost
358
359
  end
359
360
 
@@ -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
- # @param user [Spree.user_class, nil] the user to associate with the cart
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: resolved_metadata.to_h
17
+ private_metadata: private_metadata.to_h
31
18
  }
32
19
 
33
- order = nil
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 Cart
3
- # Bulk upsert line items on an order.
2
+ module Carts
3
+ # Bulk upsert line items on a cart.
4
4
  #
5
- # For each entry in +line_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
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 order is recalculated once.
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::Cart::UpsertItems.call(
17
- # order: order,
18
- # line_items: [
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(order:, line_items:)
28
- line_items = Array(line_items)
29
- return success(order) if line_items.empty?
23
+ def call(cart:, items:)
24
+ items = Array(items)
25
+ return success(cart) if items.empty?
30
26
 
31
- store = order.store || Spree::Current.store
27
+ store = cart.store || Spree::Current.store
32
28
 
33
29
  ApplicationRecord.transaction do
34
- line_items.each do |item_params|
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 #{order.currency}") if variant.amount_in(order.currency).nil?
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: order, variant: variant)
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 = order.line_items.new(quantity: quantity, variant: variant, options: { currency: order.currency })
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
- order.update_with_updater!
52
+ cart.update_with_updater!
57
53
  end
58
54
 
59
- success(order)
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
@@ -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',
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '5.4.0.beta6'.freeze
2
+ VERSION = '5.4.0.beta7'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
data/lib/spree/core.rb CHANGED
@@ -109,6 +109,7 @@ module Spree
109
109
  addresses: :default,
110
110
  gift_cards: :default,
111
111
  webhooks: :default,
112
+ payment_webhooks: :default,
112
113
  api_keys: :default
113
114
  )
114
115
  end
@@ -22,6 +22,9 @@ FactoryBot.define do
22
22
  end
23
23
  end
24
24
 
25
+ factory :cart, class: Spree::Order do
26
+ end
27
+
25
28
  factory :order_with_totals do
26
29
  after(:create) do |order, evaluator|
27
30
  create(:line_item, order: order, price: evaluator.line_items_price)
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.beta6
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-11 00:00:00.000000000 Z
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/cart/upsert_items.rb
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.beta6
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.beta6
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