Class: Shipping::UpdateShippingMethod

Inherits:
Object
  • Object
show all
Defined in:
app/services/shipping/update_shipping_method.rb

Overview

Service to handle shipping method changes and ensure all dependent calculations are updated
This ensures that when shipping method changes:

  1. Shipping costs are recalculated
  2. Discounts are recalculated (some coupons only apply to specific methods)
  3. Totals are updated

Usage 1: Direct shipping method update
result = Shipping::UpdateShippingMethod.new.perform(delivery, new_method)
if result.success?
# shipping method updated and all calculations completed
else
flash[:error] = result.errors
end

Usage 2: After nested attributes update (in controllers)
result = Shipping::UpdateShippingMethod.new.detect_and_handle_changes(itemizable)

Detects which deliveries changed via previous_changes and sets recalculate_discounts flag

Defined Under Namespace

Classes: Result

Instance Method Summary collapse

Instance Method Details

#perform(delivery, shipping_method, options = {}) ⇒ Object



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'app/services/shipping/update_shipping_method.rb', line 25

def perform(delivery, shipping_method, options = {})
  @delivery = delivery
  @itemizable = delivery.try(:order) || delivery.try(:quote)
  @new_method = shipping_method
  @options = options
  @messages = []

  # Validate inputs
  return failure('Delivery is required') unless @delivery
  return failure('Shipping method is required') unless @new_method
  return failure('Itemizable (order/quote) not found') unless @itemizable

  # Capture old shipping method after validation
  @old_method = @itemizable.shipping_method if @itemizable.respond_to?(:shipping_method)

  # Update shipping method on the itemizable (not the delivery)
  # The shipping_method attribute lives on Order/Quote, not Delivery
  @itemizable.shipping_method = @new_method if @itemizable.respond_to?(:shipping_method=)

  # If the method actually changed, ensure full recalculation
  if @old_method != @new_method
    Rails.logger.info "Shipping method changed from #{@old_method} to #{@new_method} for #{@itemizable.class.name}:#{@itemizable.id}"
    @messages << "Shipping method changed from '#{@old_method}' to '#{@new_method}'"

    # Force shipping recalculation
    @itemizable.need_to_recalculate_shipping if @itemizable.respond_to?(:need_to_recalculate_shipping)

    # Set persistent flag for discount recalculation
    # This is critical because some coupons only apply to specific shipping methods
    # Using database flag instead of ephemeral attribute so it survives saves/reloads
    @itemizable.recalculate_discounts = true
  end

  # Save the delivery and itemizable
  if @delivery.save
    # Save the itemizable BEFORE reload to persist shipping_method change
    if @itemizable.changed?
      return failure(@itemizable.errors.full_messages.join(', ')) unless @itemizable.save
    end

    # Explicitly trigger discount recalculation after shipping changes
    # This ensures coupons that filter by shipping method are re-evaluated
    @itemizable.reload.reset_discount if @old_method != @new_method && @itemizable.respond_to?(:reset_discount)

    # Save the itemizable again after discount recalculation
    @itemizable.save if @itemizable.changed?

    success
  else
    failure(@delivery.errors.full_messages.join(', '))
  end
rescue StandardError => e
  Rails.logger.error "Failed to update shipping method: #{e.message}"
  Rails.logger.error e.backtrace.join("\n")
  failure("Failed to update shipping method: #{e.message}")
end

#update_economy_status(itemizable, ships_economy) ⇒ Object

Handle economy shipping status changes

This method handles changes to the ships_economy flag and ensures:

  1. The flag is updated on the itemizable
  2. Free economy shipping coupons are applied/removed via discount recalculation
  3. All related totals are updated

Usage in controllers (e.g., my_carts_controller.rb):
ships_economy = dq.shipping_option&.is_override? && !dq.ships_ltl_freight?
result = Shipping::UpdateShippingMethod.new.update_economy_status(@cart, ships_economy)
if result.success?
# Economy status updated, discounts recalculated if needed
result.messages.each { |msg| flash[:info] = msg }
else
flash[:error] = result.errors
end



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'app/services/shipping/update_shipping_method.rb', line 198

def update_economy_status(itemizable, ships_economy)
  @itemizable = itemizable
  @messages = []

  return failure('Itemizable is required') unless @itemizable
  return failure('Itemizable does not respond to ships_economy') unless @itemizable.respond_to?(:ships_economy)
  return failure('Itemizable is not a cart') unless @itemizable.state == 'cart'

  old_ships_economy = @itemizable.ships_economy
  @itemizable.ships_economy = ships_economy

  # If economy status changed, ensure discount recalculation
  # This is critical because FREE_ECONOMY_ONLINE coupons only apply when ships_economy=true
  if old_ships_economy != ships_economy
    Rails.logger.info "#{@itemizable.class.name}:#{@itemizable.id} ships_economy changed from #{old_ships_economy} to #{ships_economy}"

    @messages << if ships_economy
                   'Economy shipping enabled - free shipping coupon will be applied if qualified'
                 else
                   'Economy shipping disabled - free shipping coupon will be removed if present'
                 end

    # Set flag to ensure discount recalculation
    @itemizable.recalculate_discounts = true
  end

  # Save and recalculate if changed
  if @itemizable.save
    if old_ships_economy != ships_economy
      # CRITICAL: Ensure shipping line items exist before discount recalculation
      # The FREE_ECONOMY_ONLINE coupon needs a shipping line to discount.
      # Without this, reset_discount calculates $0 discount and deletes it.
      @itemizable.reload
      ensure_shipping_lines_exist(@itemizable)
      @itemizable.reset_discount
      Rails.logger.info 'Discounts recalculated after ships_economy change'
    end
    success
  else
    failure(@itemizable.errors.full_messages.join(', '))
  end
rescue StandardError => e
  Rails.logger.error "Failed to update economy status: #{e.message}"
  Rails.logger.error e.backtrace.join("\n")
  failure("Failed to update economy status: #{e.message}")
end

#update_from_params(itemizable, params) ⇒ Object

Handle shipping changes from params (unified method for all controller flows)

This method intelligently handles two different param structures:

  1. CART/CHECKOUT FLOW: Direct shipping cost selection
    params = { cart: { shipping_cost_id: 123, delivery_id: 456 } }
    OR
    params = { order: { shipping_cost_id: 123, delivery_id: 456 } }

  2. CRM FORM FLOW: Nested attributes
    params = { order: { deliveries_attributes: { '0' => { shipping_option_id: 789 } } } }

Usage in controllers:
result = Shipping::UpdateShippingMethod.new.update_from_params(@cart, params)
if result.success?
result.messages.each { |msg| flash[:info] = msg }
else
flash[:error] = result.errors
end



265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
# File 'app/services/shipping/update_shipping_method.rb', line 265

def update_from_params(itemizable, params)
  @itemizable = itemizable
  @params = params
  @messages = []

  return failure('Itemizable is required') unless @itemizable
  return failure('Params are required') unless @params

  itemizable_type = @itemizable.class.name.underscore.to_sym
  # For public cart, params come in as { cart: { shipping_cost_id: ... } }
  # For CRM, params come in as { order: { ... } } or { quote: { ... } }
  # Try cart first for backwards compatibility, then itemizable type
  itemizable_params = (@params[:cart] || @params['cart'] || @params[itemizable_type] || @params)&.to_h

  # Check if this is a DIRECT shipping cost selection (cart/checkout flow)
  # vs NESTED ATTRIBUTES (CRM form flow)
  if direct_shipping_cost_selection?(itemizable_params)
    handle_direct_shipping_cost_selection(itemizable_params)
  else
    handle_nested_attributes_update(itemizable_params)
  end
rescue StandardError => e
  Rails.logger.error "Failed to update shipping from params: #{e.message}"
  Rails.logger.error e.backtrace.join("\n")
  failure("Failed to update shipping: #{e.message}")
end

#update_from_shipping_cost(delivery, shipping_cost, itemizable = nil) ⇒ Object

Handle shipping cost selection from cart (combines method + economy status update)

This is the primary method for cart/checkout flows where a customer selects a shipping option.
It handles everything in one transaction:

  1. Updates delivery with selected shipping cost
  2. Updates itemizable shipping_method
  3. Updates itemizable ships_economy flag
  4. Triggers discount recalculation ONCE (not twice like separate calls would)

Usage in controllers (e.g., my_carts_controller.rb):
result = Shipping::UpdateShippingMethod.new.update_from_shipping_cost(dq, sc, @cart)
if result.success?
result.messages.each { |msg| Rails.logger.info msg }
else
Rails.logger.error result.errors
end



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'app/services/shipping/update_shipping_method.rb', line 99

def update_from_shipping_cost(delivery, shipping_cost, itemizable = nil)
  @delivery = delivery
  @shipping_cost = shipping_cost
  @itemizable = itemizable || delivery.try(:order) || delivery.try(:quote)
  @messages = []

  return failure('Delivery is required') unless @delivery
  return failure('Shipping cost is required') unless @shipping_cost
  return failure('Itemizable (order/quote) not found') unless @itemizable

  # Capture old state for change detection
  old_ships_economy = @itemizable.ships_economy if @itemizable.respond_to?(:ships_economy)
  old_selected_shipping_cost_id = @delivery.selected_shipping_cost_id

  # Update delivery with selected shipping cost
  # Use update_columns to bypass the set_proper_shipping_cost callback
  # which would override the user's explicit selection
  @delivery.update_columns(
    selected_shipping_cost_id: @shipping_cost.id,
    shipping_option_id: @shipping_cost.shipping_option_id,
    shipping_cost: @shipping_cost.cost,
    updated_at: Time.current
  )

  # Reload delivery to get fresh shipping_option association
  @delivery.reload

  # Ensure shipping line and related state reflect the explicit selection
  # Delegate to Delivery to apply the selection consistently
  @delivery.apply_selected_shipping_cost!(@delivery.selected_shipping_cost, previous_selected_id: old_selected_shipping_cost_id, persist: true) if @delivery.selected_shipping_cost

  # Update shipping method on itemizable
  new_shipping_method = @delivery.shipping_option&.name
  if new_shipping_method.present? && @itemizable.respond_to?(:shipping_method=)
    @itemizable.shipping_method = new_shipping_method
    # Add message if the selected shipping cost changed (more accurate than just method name)
    @messages << "Shipping option changed: #{@shipping_cost.description || new_shipping_method}" if old_selected_shipping_cost_id != @shipping_cost.id
  end

  # Update economy status on itemizable
  new_ships_economy = @delivery.shipping_option&.is_override? && !@delivery.ships_ltl_freight?
  if @itemizable.respond_to?(:ships_economy=) && (@itemizable.state == 'cart')
    @itemizable.ships_economy = new_ships_economy
    if old_ships_economy != new_ships_economy
      @messages << if new_ships_economy
                     'Economy shipping enabled - free shipping coupon will be applied if qualified'
                   else
                     'Economy shipping disabled - free shipping coupon will be removed if present'
                   end
    end
  end

  # Determine if discount recalculation is needed
  # Check if the actual selected shipping cost changed (not just the method name)
  needs_discount_recalc = (old_selected_shipping_cost_id != @shipping_cost.id) || (old_ships_economy != new_ships_economy)

  if needs_discount_recalc
    @itemizable.recalculate_discounts = true
    Rails.logger.info "#{@itemizable.class.name}:#{@itemizable.id} - Shipping changed, will recalculate discounts"
  end

  # Prevent shipping recalculation during this save to preserve explicit selection
  @itemizable.do_not_detect_shipping = true

  # Save itemizable and recalculate discounts if needed
  if @itemizable.save
    if needs_discount_recalc
      # Ensure the itemizable has the shipping line in its collection
      @itemizable.sync_shipping_line if @itemizable.respond_to?(:sync_shipping_line)
      @itemizable.reload.reset_discount
      Rails.logger.info 'Discounts recalculated after shipping change'
    end
    success
  else
    failure(@itemizable.errors.full_messages.join(', '))
  end
rescue StandardError => e
  Rails.logger.error "Failed to update from shipping cost: #{e.message}"
  Rails.logger.error e.backtrace.join("\n")
  failure("Failed to update shipping: #{e.message}")
end