Module: Models::Itemizable

Extended by:
ActiveSupport::Concern
Included in:
CreditMemo, Invoice, Order, Quote
Defined in:
app/concerns/models/itemizable.rb

Overview

Mixin shared by Order, Quote, Invoice and CreditMemo that
encapsulates the line-item bag, discount totals and shipping
recalculation logic each of these documents has in common. The host
is expected to expose line_items, currency, customer and the
total columns the SQL aggregator (set_totals / perform_db_total)
writes to.

Instance Attribute Summary collapse

Belongs to collapse

Has many collapse

Instance Method Summary collapse

Instance Attribute Details

#force_total_resetObject

Returns the value of attribute force_total_reset.



29
30
31
# File 'app/concerns/models/itemizable.rb', line 29

def force_total_reset
  @force_total_reset
end

#total_resetObject

Returns the value of attribute total_reset.



29
30
31
# File 'app/concerns/models/itemizable.rb', line 29

def total_reset
  @total_reset
end

Instance Method Details

#account_specialistEmployee

Returns:

See Also:



24
# File 'app/concerns/models/itemizable.rb', line 24

belongs_to :account_specialist, class_name: 'Employee', optional: true

#add_line_item(options) ⇒ LineItem

Adds a CatalogItem to this itemizable, merging quantity into an
existing matching LineItem when possible (same catalog item +
room configuration) unless keep_line_items_separate is set or
the SKU is in the always-separate list. Wraps the change in a
named advisory lock to serialise concurrent cart adds and retries
the inner save up to 3 times on ActiveRecord::Deadlocked.

Parameters:

  • options (Hash)

    attributes for the new LineItem plus
    :do_not_autosave and :keep_line_items_separate flags

Returns:



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
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'app/concerns/models/itemizable.rb', line 52

def add_line_item(options)
  do_not_autosave = options.delete(:do_not_autosave)
  ci = CatalogItem.find options[:catalog_item_id]
  keep_line_items_separate = options.delete(:keep_line_items_separate)
  # any item with sku in SINGLE_QUANTITY_SKUS defaults to be a separate line item unless overridden with keep_line_items_separate option
  keep_line_items_separate ||= Item::SINGLE_QUANTITY_SKUS.include?(ci.sku) if keep_line_items_separate.nil?
  options[:quantity] ||= 1

  # Use advisory lock to prevent deadlocks from concurrent cart modifications
  lock_key = "#{self.class.name.underscore}|#{id}|add_line_item"
  self.class.with_advisory_lock(lock_key, timeout_seconds: 10) do
    # Reload line_items to ensure we have fresh data after acquiring lock
    line_items.reload if persisted?

    line_item = begin
      line_items.find { |li| li.catalog_item_id == options[:catalog_item_id].to_i and (li.room_configuration_id.to_i == options[:room_configuration_id].to_i) }
    rescue StandardError
      nil
    end
    if line_item && !keep_line_items_separate
      line_item.quantity += options[:quantity].to_i
      # Persist quantity changes on existing rows immediately, even when the caller
      # passed do_not_autosave: true. The parent's autosave path is unreliable here
      # because downstream callbacks (e.g. Coupon::ItemizableDiscountCalculator's
      # load_active_lines hits line_items.any?(&:new_record?)) can re-load the
      # association from the DB and discard the in-memory dirty state, causing the
      # quantity bump to silently disappear when adding an item already in the cart.
      if line_item.persisted? && line_item.changed?
        Retryable.retryable(tries: 3, sleep: ->(n) { 2**n }, on: [ActiveRecord::Deadlocked]) do |attempt_number, exception|
          Rails.logger.warn("add_line_item save retry #{attempt_number}: #{exception.class}") if attempt_number > 1
          line_item.save
        end
      end
    else
      seed = LineItem.new(options)
      seed.price ||= seed.catalog_item.amount
      # Reassign so we save (and return) the built line_item that's wired up
      # to this resource via the association — saving `seed` directly would
      # persist an orphan LineItem with resource_id = nil because
      # `belongs_to :resource` is optional.
      line_item = line_items.build(seed.attributes)
      unless do_not_autosave
        Retryable.retryable(tries: 3, sleep: ->(n) { 2**n }, on: [ActiveRecord::Deadlocked]) do |attempt_number, exception|
          Rails.logger.warn("add_line_item save retry #{attempt_number}: #{exception.class}") if attempt_number > 1
          line_item.save
        end
      end
    end
    line_item
  end
end

#additional_itemsObject

GROUPING FUNCTIONS #



150
151
152
# File 'app/concerns/models/itemizable.rb', line 150

def additional_items
  line_items.to_a.select { |li| li.room_configuration_id.nil? }
end

#assign_sequence(line_item) ⇒ Integer

Numbers a freshly-built LineItem with the next sequential position
within this document. Called from line-item form helpers where the
caller hasn't pre-computed sequence.

Parameters:

Returns:

  • (Integer)


38
39
40
# File 'app/concerns/models/itemizable.rb', line 38

def assign_sequence(line_item)
  line_item.sequence = line_items.length + 1
end

#billing_entityParty

The Party actually being billed — the address-level party when
the billing address is owned by a different party (e.g. parent
account, buying group), otherwise the document's own customer.

Returns:



252
253
254
# File 'app/concerns/models/itemizable.rb', line 252

def billing_entity
  billing_address && billing_address.party_id.present? ? billing_address.party : customer
end

#breakdown_of_pricesHash{String => BigDecimal}

Subtotal/shipping/discounts/taxes hash used by the customer-facing
PDF and emails to render a price-breakdown table.

Returns:

  • (Hash{String => BigDecimal})


166
167
168
169
170
171
172
173
# File 'app/concerns/models/itemizable.rb', line 166

def breakdown_of_prices
  breakdown = {}
  breakdown['Item Subtotal'] = line_items.non_shipping.sum('price * quantity')
  breakdown['Shipping'] = line_items.shipping_only.sum('price * quantity')
  breakdown['Discounts'] = discounts.sum('amount')
  breakdown['Taxes'] = tax_total
  breakdown
end

#calculate_actual_insured_valueFloat

Insured-value figure declared to carriers — the larger of the
discounted subtotal and the COGS subtotal (so we never under-insure
a heavily-discounted shipment).

Returns:

  • (Float)


681
682
683
# File 'app/concerns/models/itemizable.rb', line 681

def calculate_actual_insured_value
  [subtotal_discounted_without_shipping.to_f, subtotal_cogs.to_f].max
end

#calculate_discounts(autosave = false) ⇒ Object

this method prioritizes discounts and recalculate each of them



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'app/concerns/models/itemizable.rb', line 230

def calculate_discounts(autosave = false)
  return :not_applicable if is_a?(CreditMemo) || is_a?(Invoice)

  # Allow recalculation if explicitly flagged (e.g., shipping changed), even if locked
  # Otherwise, skip if the order is locked (completed, shipped, invoiced, etc.)
  if respond_to?(:editing_locked?) && editing_locked? && !(respond_to?(:recalculate_discounts) && recalculate_discounts)
    logger.debug 'calculate_discounts: editing_locked? is true and recalculate_discounts is false, skipping discount calculation'
    return :skipped
  end

  logger.debug 'calculate_discounts: recalculate_discounts flag is true, forcing recalculation even though order may be locked' if respond_to?(:recalculate_discounts) && recalculate_discounts

  Coupon::ItemizableDiscountCalculator.new(self).calculate
  save if autosave
  :success
end

#calculate_shipping_costBigDecimal

Sum of price across shipping-only LineItems — used to detect
whether a re-quote produced a different shipping cost.

Returns:

  • (BigDecimal)


664
665
666
# File 'app/concerns/models/itemizable.rb', line 664

def calculate_shipping_cost
  line_items.shipping_only.to_a.sum(&:price)
end

#coupon_search(search_query, role_ids: nil, wild_search: nil) ⇒ Array<Coupon>

Finds Coupons that match search_query and could still be
applied to this document — excludes coupons already on the
discounts list and respects role-based visibility unless the user
is an admin (role 1).

Parameters:

  • search_query (String)
  • role_ids (Array<Integer>, nil) (defaults to: nil)

    caller's role ids for visibility filtering

  • wild_search (Boolean, nil) (defaults to: nil)

    enable fuzzy matching

Returns:



343
344
345
346
347
348
349
350
351
352
353
# File 'app/concerns/models/itemizable.rb', line 343

def coupon_search(search_query, role_ids: nil, wild_search: nil)
  cq = Coupon::Qualifier.new
  exclude_coupon_ids = discounts.pluck(:coupon_id)
  role_ids = nil if role_ids.present? && role_ids.include?(1) # Admin bypass
  cq.find_coupons(itemizable: self,
                  role_ids:,
                  exclude_coupon_ids:,
                  search_query:,
                  searchable_only: true,
                  wild_search:)
end

#couponsActiveRecord::Relation<Coupon>

Returns:

  • (ActiveRecord::Relation<Coupon>)

See Also:



27
# File 'app/concerns/models/itemizable.rb', line 27

has_many    :coupons, through: :discounts

#customer_applied_couponsActiveRecord::Relation<Coupon>

Coupons currently applied to this document that are visible to
the customer (filters out trade-only / internal coupons).

Returns:

  • (ActiveRecord::Relation<Coupon>)


387
388
389
390
# File 'app/concerns/models/itemizable.rb', line 387

def customer_applied_coupons
  current_coupon_ids = discounts.pluck(:coupon_id)
  Coupon.where(id: current_coupon_ids).customer_accessible
end

#customer_can_apply_coupon?(coupon_code) ⇒ Boolean

Returns:

  • (Boolean)


355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
# File 'app/concerns/models/itemizable.rb', line 355

def customer_can_apply_coupon?(coupon_code)
  # puts "customer_can_apply_coupon?"
  coupon = nil
  msg = "Coupon code can't be blank."
  if coupon_code.present?
    current_customer_coupons = customer_applied_coupons
    if current_customer_coupons.any? { |cp| cp.code == coupon_code }
      msg = "Coupon code #{coupon_code} already applied."
    else
      coupon = coupon_search(coupon_code, wild_search: false).first
      if coupon.present? && coupon.public?
        # puts "customer_can_apply_coupon? coupon is present and public"
        msg = "Coupon code #{coupon_code} can be applied."

        if current_customer_coupons.any?(&:exclusive?) ||
           (coupon.exclusive? && discounts.joins(:coupon).merge(Coupon.active.tier3).present?)
          msg = "Coupon code #{coupon_code} cannot be applied in combination with any other offers."
          coupon = nil
        end
      else
        msg = "Coupon code #{coupon_code} not found or does not apply."
        coupon = nil
      end
    end
  end
  { coupon:, message: msg }
end

#discountsActiveRecord::Relation<Discount>

Returns:

See Also:



26
# File 'app/concerns/models/itemizable.rb', line 26

has_many    :discounts, as: :itemizable, dependent: :destroy, inverse_of: :itemizable, autosave: true

#discounts_changed?Boolean

Returns:

  • (Boolean)


448
449
450
451
452
453
# File 'app/concerns/models/itemizable.rb', line 448

def discounts_changed?
  return unless respond_to?(:discounts)
  return unless discounts.loaded?

  discounts.any?(&:changed?) || discounts.any?(&:marked_for_destruction?)
end

#discounts_grouped_by_coupon(return_coupon_object = false) ⇒ Object

TOTALLING FUNCTIONS #



301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
# File 'app/concerns/models/itemizable.rb', line 301

def discounts_grouped_by_coupon(return_coupon_object = false)
  logger.debug "discounts_grouped_by_coupon!! self.id: #{id}"
  discounts_by_code = {}

  # line_item_ids = line_items.pluck(:id)
  logger.debug "discounts_grouped_by_coupon!! self.id: #{id}, line_item_ids: #{line_item_ids.inspect}"

  line_discounts = LineDiscount.joins(:line_item, :coupon, :discount)
                               .merge(line_items)
                               .includes(%i[coupon discount line_item])
                               .order(Coupon[:type], Coupon[:position])
  line_discounts = yield(line_discounts) if block_given?

  discounts_grouped = line_discounts.group_by(&:coupon)

  discounts_grouped.each do |coupon, line_discounts|
    # Collect notes from discounts (e.g., "Discount capped at 40% for item SKU")
    discount_notes = line_discounts.map { |ld| ld.discount.notes }.compact.uniq
    discounts_by_code[coupon.code] = {
      auto_apply: coupon.auto_apply?,
      title: coupon.title,
      amount: line_discounts.sum(&:amount),
      coupon_id: coupon.id,
      serial_number: line_discounts.first.discount.coupon_serial_number_id,
      notes: discount_notes
    }
    discounts_by_code[coupon.code][:coupon] = coupon if return_coupon_object == true
  end

  logger.debug "discounts_grouped_by_coupon!! self.id: #{id}, discounts_by_code: #{discounts_by_code.inspect}"
  discounts_by_code
end

#discounts_subtotal(msrp = false) ⇒ Float

Sum of all applied discount amounts; when msrp is true and the
customer is on a pricing-program tier (trade pro), discounts are
zeroed so the MSRP version of the quote shows the customer's full
retail price.

Parameters:

  • msrp (Boolean) (defaults to: false)

Returns:

  • (Float)


399
400
401
402
403
404
405
406
407
# File 'app/concerns/models/itemizable.rb', line 399

def discounts_subtotal(msrp = false)
  discounts.select { |li| !li.marked_for_destruction? }.sum do |d|
    amt = d.amount || 0
    if msrp && customer.present? && customer.pricing_program_discount > 0
      amt = 0
    end # zero out if doing msrp quote for trade pro's customer
    amt
  end.to_f.round(2)
end

#effective_discountFloat

Effective percentage discount across all line items — (price_total − discounted_total) / price_total * 100. Used by the CRM
discount-summary widget.

Returns:

  • (Float)


222
223
224
225
226
227
# File 'app/concerns/models/itemizable.rb', line 222

def effective_discount
  line_items_array = line_items.to_a
  price_total = line_items_array.sum(&:price_total) || 0.0
  discounted_total = line_items_array.sum(&:discounted_total) || 0.0
  (discounted_total.positive? ? (100.0 - ((discounted_total / price_total) * 100)).round(2) : 0)
end

#effective_shipping_discountFloat

Effective percentage discount applied to shipping — 0 when no
coupon, 100 when shipping is free.

Returns:

  • (Float)


213
214
215
# File 'app/concerns/models/itemizable.rb', line 213

def effective_shipping_discount
  100 - ((shipping_discounted / shipping_cost.to_f.round(2)) * 100)
end

#has_kits?Boolean

Returns:

  • (Boolean)


558
559
560
# File 'app/concerns/models/itemizable.rb', line 558

def has_kits?
  line_items_with_counters.any? { |li| li.children_count.to_i > 0 }
end

#has_kits_or_serial_numbers?Boolean

Returns:

  • (Boolean)


562
563
564
# File 'app/concerns/models/itemizable.rb', line 562

def has_kits_or_serial_numbers?
  has_serial_numbers? || has_kits?
end

#has_serial_numbers?Boolean

Returns:

  • (Boolean)


554
555
556
# File 'app/concerns/models/itemizable.rb', line 554

def has_serial_numbers?
  line_items_with_counters.any? { |li| li.reserved_serial_numbers_count.positive? || li.serial_numbers_count.positive? }
end

#is_credit_order?Boolean

Returns:

  • (Boolean)


526
527
528
# File 'app/concerns/models/itemizable.rb', line 526

def is_credit_order?
  (is_a? Order and order_type == Order::CREDIT_ORDER)
end

#line_items_requiring_serial_numberArray<LineItem>

Active line items that ship a serialised Item and therefore
need a serial-number reservation before the Delivery can pack.

Returns:



158
159
160
# File 'app/concerns/models/itemizable.rb', line 158

def line_items_requiring_serial_number
  line_items.to_a.active_lines.select(&:require_serial_number?)
end

#line_items_with_countersActiveRecord::Relation<LineItem>

Line-items relation with serial-number/reserved counters preloaded
so the kit/serial-number views can render badge counts without
triggering a per-row query.

Returns:



550
551
552
# File 'app/concerns/models/itemizable.rb', line 550

def line_items_with_counters
  line_items.with_reserved_serial_numbers_count.with_serial_numbers_count
end

#line_total_plus_taxBigDecimal

SQL aggregate of discounted_price + tax_total across non-shipping
line items — the line-level total a customer pays excluding freight.

Returns:

  • (BigDecimal)


197
198
199
# File 'app/concerns/models/itemizable.rb', line 197

def line_total_plus_tax
  line_items.non_shipping.sum('line_items.discounted_price + line_items.tax_total')
end

#local_sales_repEmployee

Returns:

See Also:



23
# File 'app/concerns/models/itemizable.rb', line 23

belongs_to :local_sales_rep, class_name: 'Employee', optional: true

#main_repEmployee?

Best sales-rep available for commission attribution — primary,
falling back to secondary. Used by reports and the rep-attribution
mailers.

Returns:



126
127
128
129
130
# File 'app/concerns/models/itemizable.rb', line 126

def main_rep
  main_rep = primary_sales_rep
  main_rep ||= secondary_sales_rep
  main_rep
end

#perform_db_totalBigDecimal?

after_save hook — invokes the calculate_itemizable_total Postgres
function which recomputes line/total/tax columns from current line
items inside the database. Returns the new total. Runs only when
total_reset was flipped during the save.

Returns:

  • (BigDecimal, nil)


654
655
656
657
658
# File 'app/concerns/models/itemizable.rb', line 654

def perform_db_total
  res = ActiveRecord::Base.connection.execute("SELECT calculate_itemizable_total('#{self.class.name}',#{id})")
  res = res.try(:[], 0).try(:[], 'calculate_itemizable_total')
  BigDecimal(res) if res
end

#primary_sales_repEmployee

Returns:

See Also:



21
# File 'app/concerns/models/itemizable.rb', line 21

belongs_to :primary_sales_rep, class_name: 'Employee', optional: true

#purge_empty_quoting_deliveriesObject

After line items are removed, some deliveries may have lost all their
non-shipping content (e.g. a service delivery whose service item was
deleted). Destroy these quoting deliveries so they don't progress
through the state machine as zombie deliveries.



471
472
473
474
475
476
477
478
479
480
481
482
# File 'app/concerns/models/itemizable.rb', line 471

def purge_empty_quoting_deliveries
  return unless respond_to?(:deliveries)

  deliveries.reload.each do |delivery|
    next unless delivery.quoting?
    next if delivery.has_shippable_content?

    logger.info "#{self.class.name}:#{id}:purge_empty_quoting_deliveries -> destroying empty delivery #{delivery.id}"
    delivery.line_items.destroy_all
    delivery.destroy
  end
end

#purge_shipping_when_no_other_linesvoid

This method returns an undefined value.

Removes any orphan shipping LineItems when an Order or Quote
has lost all of its non-shipping lines — prevents "free shipping
with no items" rows from lingering after the last item is removed.



460
461
462
463
464
465
# File 'app/concerns/models/itemizable.rb', line 460

def purge_shipping_when_no_other_lines
  return unless (is_a?(Order) || is_a?(Quote)) && line_items.active_non_shipping_lines.empty?

  logger.debug "#{self.class.name}:#{id}:purge_shipping_when_no_other_lines -> removing line items as no non-shipping lines found"
  line_items.destroy_all # No lines no shipping required
end

#remove_line_item(line_item, quantity = 1) ⇒ LineItem

Decrements quantity on line_item by quantity or destroys the
row outright if quantity covers the whole line. Returns the
destroyed/updated record.

Parameters:

  • line_item (LineItem)
  • quantity (Integer) (defaults to: 1)

    units to remove (default 1)

Returns:



111
112
113
114
115
116
117
118
119
# File 'app/concerns/models/itemizable.rb', line 111

def remove_line_item(line_item, quantity = 1)
  if line_item.quantity > quantity
    line_item.quantity -= quantity
    line_item.save
  else
    line_items.delete(line_item)
    line_item.destroy
  end
end

#require_total_reset?Boolean

Returns:

  • (Boolean)


417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
# File 'app/concerns/models/itemizable.rb', line 417

def require_total_reset?
  logger.tagged("#{self.class.name}:#{id}#require_total_reset?") do
    logger.debug "editing_locked?: #{editing_locked?}"
    # commenting this out as it's preventing orders from invoicing 6/25/19 DW
    # return false if editing_locked? # this is to prevent an order or quote total from changing after it's been shipped, completed, invoiced, etc.

    logger.debug "do_not_set_totals: #{do_not_set_totals}"
    return false if do_not_set_totals

    logger.debug "force_total_reset: #{force_total_reset}"
    return true if force_total_reset

    logger.debug "line_items.lines_changed?: #{line_items.lines_changed?}"
    return true if line_items.lines_changed?

    logger.debug "discounts_changed?: #{discounts_changed?}"
    return true if discounts_changed?

    logger.debug "should_recalculate_shipping?: #{should_recalculate_shipping?}"
    return true if should_recalculate_shipping?

    logger.debug "customer_id_changed?: #{try(:customer_id_changed?)}"
    return true if try(:customer_id_changed?)

    logger.debug "shipping_method_changed?: #{shipping_method_changed?}"
    return true if shipping_method_changed?

    return false
  end
end

#reset_discount(autosave: true, reset_item_pricing: true) ⇒ void

This method returns an undefined value.

Wipes cached totals and re-runs discount/coupon evaluation, then
re-prices each parent line item against the current catalog
(preserving prices set by EDI quotes). Used when a coupon is
added/removed mid-edit so the document reflects the new state.

Parameters:

  • autosave (Boolean) (defaults to: true)

    save after mutation

  • reset_item_pricing (Boolean) (defaults to: true)

    re-quote every line item against catalog



264
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
291
292
293
294
295
# File 'app/concerns/models/itemizable.rb', line 264

def reset_discount(autosave: true, reset_item_pricing: true)
  # Store transfers never reset price
  reset_item_pricing = false if try(:is_store_transfer?).to_b
  update_column(:recalculate_discounts, true) if respond_to?(:recalculate_discounts)
  self.do_not_set_totals = nil
  # Discount recalculation should NEVER trigger shipping recalculation
  # Shipping and discounts are independent concerns
  self.do_not_detect_shipping = true
  self.force_total_reset = true
  self.line_total = nil
  self.total = nil
  self.tax_total = nil
  if customer.present?
    self.pricing_program_discount = customer.pricing_program_discount if respond_to?(:pricing_program_discount=)
    self.pricing_program_description = customer.pricing_program_description if respond_to?(:pricing_program_description=)
  end
  # This is needed when you have older line items with an old price being re-quoted,
  # but we don't want to lose the pricing set by an edi_force_price_match either...
  # hopefully this logic will work for all situations, pending a more intentional explicit
  # way of dealing with price changes and honoring quotes
  if reset_item_pricing
    # Preload associations to prevent N+1 queries:
    # - item: accessed in set_defaults callback
    # - reserved_serial_numbers: validates_associated in Reservable concern
    # - line_discounts: used by discounted_total during validation
    line_items.parents_only.joins(:catalog_item)
              .includes(:catalog_item, :item, :reserved_serial_numbers, :line_discounts).each do |li|
      li.update(price: li.catalog_item.amount) unless li.edi_unit_cost.present? && li.price == li.edi_unit_cost
    end
  end
  save if autosave
end

#secondary_sales_repEmployee

Returns:

See Also:



22
# File 'app/concerns/models/itemizable.rb', line 22

belongs_to :secondary_sales_rep, class_name: 'Employee', optional: true

#set_for_recalc(include_shipping: true) ⇒ void

This method returns an undefined value.

Forces a full total-recalculation on the next save by clearing the
cached line_total / total / tax_total columns and turning on
recalculate_shipping / recalculate_discounts / force_total_reset.
Used by edits that touch shipping inputs or coupons where the
change-detection in #require_total_reset? is insufficient.

Parameters:

  • include_shipping (Boolean) (defaults to: true)

    also flip the recalculate-shipping flag



183
184
185
186
187
188
189
190
191
# File 'app/concerns/models/itemizable.rb', line 183

def set_for_recalc(include_shipping: true)
  self.recalculate_shipping = true if include_shipping && respond_to?(:recalculate_shipping)
  self.recalculate_discounts = true if respond_to?(:recalculate_discounts)
  self.do_not_set_totals = nil
  self.force_total_reset = true
  self.line_total = nil
  self.total = nil
  self.tax_total = nil
end

#set_signature_confirmation_on_shipping_address_changeBoolean

before_save hook — flips signature_confirmation on whenever a
newly-assigned ShippingAddress requires a signature by default
(or on every Direct-Buy customer change). Keeps the carrier flag
consistent with the address's risk profile.

Returns:

  • (Boolean)


639
640
641
642
643
644
645
646
# File 'app/concerns/models/itemizable.rb', line 639

def set_signature_confirmation_on_shipping_address_change
  # here's as good a place as any to apply the default signature_confirmation when a shipping address changes and it has require_signature_by_default true
  if shipping_address_id_changed? && respond_to?(:signature_confirmation) && (shipping_address&.require_signature_by_default? || customer&.is_direct_buy?) # require this by default on Direct Buy
    self.signature_confirmation = true
    need_to_recalculate_shipping
  end
  true # since this is a before_save callback
end

#set_totalsObject

COMMON CALLBACKS



569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
# File 'app/concerns/models/itemizable.rb', line 569

def set_totals
  logger.debug "#{self.class.name}:#{id} set_totals called"
  if (self.total_reset = require_total_reset?)
    purge_empty_line_items if respond_to?(:purge_empty_line_items)
    purge_shipping_when_no_other_lines if respond_to?(:purge_shipping_when_no_other_lines)
    purge_empty_quoting_deliveries if respond_to?(:purge_empty_quoting_deliveries)
    # set_shipping_account_number if respond_to?(:set_shipping_account_number)

    # Capture shipping state before recalculation to detect changes
    # We track both cost AND method because coupon eligibility may depend on the shipping method
    # (e.g., "free shipping on FedEx Ground only" vs "FedEx 2Day")
    old_shipping_cost = calculate_shipping_cost if respond_to?(:calculate_shipping_cost)

    # Capture shipping method from the itemizable (order/quote), not delivery
    # The shipping_method attribute is stored on Order/Quote, not Delivery
    old_shipping_method = shipping_method if respond_to?(:shipping_method)

    retrieve_shipping_costs if respond_to?(:retrieve_shipping_costs) && should_recalculate_shipping?
    sync_shipping_line if respond_to?(:sync_shipping_line)

    # Detect if shipping changed (cost OR method) and ensure discount recalculation happens
    # This is critical because:
    # 1. Shipping discounts must be capped at the actual shipping cost
    # 2. Some coupons only apply to specific shipping methods (filters by method name)
    shipping_changed = false

    if old_shipping_cost && respond_to?(:calculate_shipping_cost)
      new_shipping_cost = calculate_shipping_cost
      if old_shipping_cost != new_shipping_cost
        logger.debug "Shipping cost changed from #{old_shipping_cost} to #{new_shipping_cost}, ensuring discount recalculation"
        shipping_changed = true
      end
    end

    # Check if shipping method changed (on the itemizable itself)
    if old_shipping_method && respond_to?(:shipping_method)
      new_shipping_method = shipping_method
      if old_shipping_method != new_shipping_method
        logger.debug "Shipping method changed from '#{old_shipping_method}' to '#{new_shipping_method}', ensuring discount recalculation"
        shipping_changed = true
      end
    end

    # If shipping changed in any way, set the persistent flag for discount recalculation
    # This database flag survives saves/reloads unlike ephemeral attributes
    self.recalculate_discounts = true if shipping_changed && respond_to?(:recalculate_discounts=)

    # Calculate discounts when line items changed, discounts need recalculating,
    # or discounts haven't been loaded yet. The discounts.loaded? check alone is
    # insufficient because validates_associated :discounts loads the association
    # during validation (before this before_save callback runs), making it always true.
    if respond_to?(:calculate_discounts) && (
         (respond_to?(:recalculate_discounts) && recalculate_discounts) ||
         !discounts.loaded? ||
         line_items.lines_changed?
       )
      calculate_discounts
      # Clear the flag after successful discount calculation
      self.recalculate_discounts = false if respond_to?(:recalculate_discounts) && recalculate_discounts
    end
  end
  true # since this is a before_save callback
end

#shipping_conditions_changed?Boolean

Returns:

  • (Boolean)


511
512
513
514
515
# File 'app/concerns/models/itemizable.rb', line 511

def shipping_conditions_changed?
  return true if shipping_address_id_changed? # before_save

  true if shipping_address&.address_element_changed?
end

#shipping_discountedFloat

Net shipping cost after the shipping coupon — shipping_cost + shipping_coupon (coupon is stored negative). Rounded to 2dp.

Returns:

  • (Float)


205
206
207
# File 'app/concerns/models/itemizable.rb', line 205

def shipping_discounted
  shipping_cost.to_f.round(2) + shipping_coupon.to_f.round(2)
end

#shipping_method_changed?Boolean

Returns:

  • (Boolean)


517
518
519
520
521
522
523
524
# File 'app/concerns/models/itemizable.rb', line 517

def shipping_method_changed?
  return false unless respond_to?(:deliveries)
  return false unless deliveries.loaded? # moot point to check on changed on something unloaded

  deliveries.any?(&:changed?)
  deliveries.any? { |d| d.shipping_costs.any?(&:changed?) }
  deliveries.any? { |d| d.shipping_costs.any?(&:destroyed?) }
end

#should_recalculate_shipping?Boolean

def set_shipping_account_number
return unless respond_to?(:shipping_account_number_id) && respond_to?(:bill_shipping_to_customer)
self.shipping_account_number_id = try(:chosen_shipping_method).try(:shipping_account_number_id)
end

Returns:

  • (Boolean)


489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
# File 'app/concerns/models/itemizable.rb', line 489

def should_recalculate_shipping?
  # Check recalculate_shipping flag first - when explicitly set, always recalculate
  if try(:recalculate_shipping)
    logger.debug 'recalculate_shipping: true'
    return true
  end

  # Then test if state prevents shipping recalculation
  return false if try(:prevent_recalculate_shipping?)
  return false if try(:do_not_detect_shipping)

  if line_items.changed_for_shipping?
    logger.debug 'line_items.changed_for_shipping?: true'
    return true
  elsif shipping_conditions_changed?
    logger.debug 'shipping_conditions_changed?: true'
    reset_deliveries_shipping_option if respond_to?(:reset_deliveries_shipping_option)
    return true
  end
  false
end

#smartinstall_dataHash{String => Object}?

Computes the SmartInstall service price for this document by
bucketing coverage and linear-feet from the line items into
indoor-floating / indoor-thinset / underlayment / outdoor-mat /
outdoor-cable categories, applying minimum coverages and
square-foot rates per category. Returns nil when no SmartInstall
service can be sold (no service-fee SKU matched).

Returns:

  • (Hash{String => Object}, nil)


693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
# File 'app/concerns/models/itemizable.rb', line 693

def smartinstall_data
  indoor_floating_coverage = indoor_thinset_coverage = outdoor_mat_coverage = outdoor_cable_coverage = underlayment_coverage = service_fee = total = 0
  prodeso = false
  res = {}

  if (coverages = total_coverage_by_product_line).present?
    coverages.each do |product_line_name, coverage|
      prodeso = true if product_line_name.include?('Prodeso')
      indoor_floating_coverage += coverage if product_line_name.include?('Environ')
      indoor_thinset_coverage += coverage if product_line_name.include?('TempZone') && product_line_name.exclude?('Cable')
      underlayment_coverage += coverage if product_line_name.include?('Underlayment')
      outdoor_mat_coverage += coverage if product_line_name.include?('Snow Melting > Mat')
    end
  end
  if (linear_foot = total_linear_feet_by_product_line).present?
    linear_foot.each do |product_line_name, lf|
      if product_line_name.include?('TempZone') && product_line_name.include?('Cable')
        coverage = (lf * 0.3125).truncate(1) if prodeso
        coverage = (lf * 0.25).truncate(1) unless prodeso
        indoor_thinset_coverage += coverage
      elsif product_line_name.include?('Snow Melting > Cable')
        coverage = (lf * 0.25).truncate(1)
        outdoor_cable_coverage += coverage
      end
    end
  end

  if indoor_floating_coverage > 0
    min_coverage = 100
    sq_rate = 1.5
    indoor_floating_coverage = (indoor_floating_coverage < min_coverage ? min_coverage : indoor_floating_coverage)
    res['SmartInstall Floating $/Square Foot'] = [indoor_floating_coverage, indoor_floating_coverage * sq_rate]
    total += indoor_floating_coverage * sq_rate
    rate = Item.find_by(sku: 'SII_FLOATING_FIXRATE').catalog_items.main_catalogs.first.amount
    service_fee = rate unless service_fee > rate
  end
  if indoor_thinset_coverage > 0
    min_coverage = 50
    sq_rate = 2
    indoor_thinset_coverage = (indoor_thinset_coverage < min_coverage ? min_coverage : indoor_thinset_coverage)
    res['SmartInstall Thinset/SelfLeveling Applications $/Square Foot'] = [indoor_thinset_coverage, indoor_thinset_coverage * sq_rate]
    total += indoor_thinset_coverage * sq_rate
    rate = Item.find_by(sku: 'SII_THINSET_FIXRATE').catalog_items.main_catalogs.first.amount
    service_fee = rate unless service_fee > rate
  end
  if underlayment_coverage > 0
    sq_rate = 2
    res['SmartInstall Underlayment $/Square Foot'] = [underlayment_coverage, underlayment_coverage * sq_rate]
    total += underlayment_coverage * sq_rate
    rate = Item.find_by(sku: 'SII_THINSET_FIXRATE').catalog_items.main_catalogs.first.amount
    service_fee = rate unless service_fee > rate
  end
  if outdoor_mat_coverage > 0
    sq_rate = 2.5
    res['SmartInstall Outdoor Mats $/Square Foot'] = [outdoor_mat_coverage, outdoor_mat_coverage * sq_rate]
    total += outdoor_mat_coverage * sq_rate
    rate = Item.find_by(sku: 'SIO_MAT_FIXRATE').catalog_items.main_catalogs.first.amount
    service_fee = rate unless service_fee > rate
  end
  if outdoor_cable_coverage > 0
    sq_rate = 2.5
    res['SmartInstall Outdoor Cables $/Square Foot'] = [outdoor_cable_coverage, outdoor_cable_coverage * sq_rate]
    total += outdoor_cable_coverage * sq_rate
    rate = Item.find_by(sku: 'SIO_CABLE_FIXRATE').catalog_items.main_catalogs.first.amount
    service_fee = rate unless service_fee > rate
  end
  res['SmartInstall Service Fee'] = service_fee
  total += service_fee
  res['Total'] = total
  return nil if service_fee == 0

  res
end

#smartsupport_data(distance) ⇒ Hash{String => Object}?

Computes the SmartSupport (onsite + remote) service price for this
document, given the distance from the nearest tech in miles. Onsite
is only offered within SMART_SERVICES_MAX_DISTANCE miles; the
first 100 are free, additional miles bill at the per-mile catalog
rate. Remote support is always quoted. Returns nil when there's
nothing electrically heated to support.

Parameters:

  • distance (Numeric)

    miles from the nearest tech

Returns:

  • (Hash{String => Object}, nil)


776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
# File 'app/concerns/models/itemizable.rb', line 776

def smartsupport_data(distance)
  return nil unless electrical_heating_elements.any? && distance.present?

  res = {}

  if distance <= SMART_SERVICES_MAX_DISTANCE # Onsite support only offered within 100 miles
    osf = Item.find_by(sku: 'SGS_ONSITE_FIXRATE')
    omr = Item.find_by(sku: 'SGS_ONSITE_PER_MILE')
    onsite_service_fee = osf.catalog_items.active.main_catalogs.first&.amount
    onsite_mile_rate = omr.catalog_items.active.main_catalogs.first&.amount
    if onsite_service_fee && onsite_mile_rate
      distance_price = (distance - SMART_SERVICES_MAX_DISTANCE) < 0 ? 0 : ((distance - SMART_SERVICES_MAX_DISTANCE) * onsite_mile_rate) # First 100 miles free
      res[osf.name] = onsite_service_fee
      res[omr.name] = [distance, distance_price]
      res['SmartSupport Onsite Total'] = distance_price + onsite_service_fee
    else
      ErrorReporting.warning("onsite_mile_rate and onsite_service_fee not found in catalog for #{self.class.name} : #{id}")
    end
  end

  rfr = Item.find_by(sku: 'SGS_REMOTE_FIXRATE')
  if (remote_service_fee = rfr.catalog_items.active.main_catalogs.first&.amount)
    res[rfr.name] = remote_service_fee
    res['SmartSupport Remote Total'] = remote_service_fee
  else
    ErrorReporting.warning("remote_service_fee not found in catalog for #{self.class.name} : #{id}")
  end
  res
end

#storeStore?

Resolves the Store that should fulfil this document — the
customer's store when set, otherwise the canonical store for the
document's currency. Used by inventory and pricing lookups.

Returns:



137
138
139
140
141
142
143
144
# File 'app/concerns/models/itemizable.rb', line 137

def store
  # At some point orders will need their own store id to base their fulfillment from, then we don't need to determine based on currency
  (begin
    customer.store
  rescue StandardError
    nil
  end) || Store.by_currency(currency)
end

#subtotal_cogsBigDecimal

Cost-of-goods total for active non-shipping parent lines, with
catalog/store-item eager-loaded so current_line_cogs doesn't N+1.

Returns:

  • (BigDecimal)


672
673
674
# File 'app/concerns/models/itemizable.rb', line 672

def subtotal_cogs
  line_items.without_children.includes(catalog_item: :store_item).active_non_shipping_lines.sum(&:current_line_cogs)
end

#sync_shipping_lineObject

Retrieve shipping costs might create a shipping line. We need to make sure our line items collection is aware of it
Otherwise our discounts or other calculation might have no clue.



532
533
534
535
536
537
538
539
540
541
542
543
# File 'app/concerns/models/itemizable.rb', line 532

def sync_shipping_line
  return unless respond_to? :deliveries

  correct_shipping_lines = deliveries.reload.map { |dq| dq.line_items.active_shipping_lines }.flatten
  existing_shipping_lines = line_items.active_shipping_lines
  # missing lines get added
  missing_lines = correct_shipping_lines - existing_shipping_lines
  return if missing_lines.blank?

  line_items.reload
  self.line_items += missing_lines
end

#total_cogsBigDecimal

Cost-of-goods total across every LineItem (children too) not
marked for destruction. Used by margin/profit calculations.

Returns:

  • (BigDecimal)


413
414
415
# File 'app/concerns/models/itemizable.rb', line 413

def total_cogs
  line_items.select { |li| !li.marked_for_destruction? }.map(&:total_cogs).sum
end