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.

Belongs to collapse

Has many collapse

Instance Attribute Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#account_specialistEmployee?

Account specialist assigned to this document.

Returns:



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

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

#couponsActiveRecord::Associations::CollectionProxy<Coupon>

Coupons backing the applied discounts.

Returns:

  • (ActiveRecord::Associations::CollectionProxy<Coupon>)


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

has_many    :coupons, through: :discounts

#discountsActiveRecord::Associations::CollectionProxy<Discount>

Applied discounts attached to this document.

Returns:

  • (ActiveRecord::Associations::CollectionProxy<Discount>)


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

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

#force_total_resetObject

Returns the value of attribute force_total_reset.



47
48
49
# File 'app/concerns/models/itemizable.rb', line 47

def force_total_reset
  @force_total_reset
end

#local_sales_repEmployee?

Local sales rep credited for this document.

Returns:



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

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

#primary_sales_repEmployee?

Primary sales rep credited for this document.

Returns:



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

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

#secondary_sales_repEmployee?

Secondary sales rep credited for this document.

Returns:



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

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

#total_resetObject

Returns the value of attribute total_reset.



47
48
49
# File 'app/concerns/models/itemizable.rb', line 47

def total_reset
  @total_reset
end

Instance Method Details

#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

Options Hash (options):

  • catalog_item_id (Integer)

    id of the CatalogItem to add (required)

  • room_configuration_id (Integer, nil)

    room the item belongs to

  • quantity (Integer)

    quantity to add (defaults to 1)

  • do_not_autosave (Boolean)

    skip saving the built line item

  • keep_line_items_separate (Boolean)

    never merge into an existing matching line item

Returns:



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
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'app/concerns/models/itemizable.rb', line 75

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_itemsArray<LineItem>

Returns line items not attached to any room configuration

Returns:



175
176
177
# File 'app/concerns/models/itemizable.rb', line 175

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)


56
57
58
# File 'app/concerns/models/itemizable.rb', line 56

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:



283
284
285
# File 'app/concerns/models/itemizable.rb', line 283

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})


191
192
193
194
195
196
197
198
# File 'app/concerns/models/itemizable.rb', line 191

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)


785
786
787
# File 'app/concerns/models/itemizable.rb', line 785

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

#calculate_discounts(autosave = false) ⇒ Symbol

Recalculates and applies coupon discounts in priority order via
Coupon::ItemizableDiscountCalculator. Skipped entirely on
CreditMemo and Invoice, and skipped on editing-locked documents
unless recalculate_discounts was explicitly flagged.

Parameters:

  • autosave (Boolean) (defaults to: false)

    save the document after recalculating

Returns:

  • (Symbol)

    :success, :skipped, or :not_applicable



261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
# File 'app/concerns/models/itemizable.rb', line 261

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)


768
769
770
# File 'app/concerns/models/itemizable.rb', line 768

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:



391
392
393
394
395
396
397
398
399
400
401
# File 'app/concerns/models/itemizable.rb', line 391

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

#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>)


443
444
445
446
# File 'app/concerns/models/itemizable.rb', line 443

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) ⇒ Hash

Validates whether the customer-facing shopper may apply
coupon_code to this document — checks for blank codes, duplicates,
public visibility, and exclusivity conflicts with already-applied
coupons.

Parameters:

  • coupon_code (String)

    coupon code the customer typed in

Returns:

  • (Hash)

    { coupon: Coupon, nil, message: String } — the
    coupon when applicable (else nil) plus a customer-facing message



411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
# File 'app/concerns/models/itemizable.rb', line 411

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

#discounts_changed?Boolean?

Whether any loaded discount has unsaved changes or is marked for
destruction — one of the triggers for #require_total_reset?.

Returns:

  • (Boolean, nil)

    nil when discounts aren't loaded



515
516
517
518
519
520
# File 'app/concerns/models/itemizable.rb', line 515

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) {|line_discounts| ... } ⇒ Hash{String => Hash}

Groups applied discounts by coupon code, returning per-coupon
title, summed amount, serial-number id and collected discount notes.
An optional block can narrow the LineDiscount relation first.

Parameters:

  • return_coupon_object (Boolean) (defaults to: false)

    include the Coupon object under the :coupon key

Yields:

  • (line_discounts)

    optional scope transformer

Yield Parameters:

  • line_discounts (ActiveRecord::Relation<LineDiscount>)

    discounts about to be grouped

Returns:

  • (Hash{String => Hash})

    grouped discount metadata keyed by coupon code



349
350
351
352
353
354
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
# File 'app/concerns/models/itemizable.rb', line 349

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)


455
456
457
458
459
460
461
462
463
# File 'app/concerns/models/itemizable.rb', line 455

def discounts_subtotal(msrp = false)
  discounts.reject(&: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)


247
248
249
250
251
252
# File 'app/concerns/models/itemizable.rb', line 247

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)


238
239
240
# File 'app/concerns/models/itemizable.rb', line 238

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

#has_kits?Boolean

Whether any line item has child lines (i.e. is a kit) — drives the
kit badge/section in kit views.

Returns:

  • (Boolean)


650
651
652
# File 'app/concerns/models/itemizable.rb', line 650

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

#has_kits_or_serial_numbers?Boolean

Convenience OR of #has_serial_numbers? and #has_kits? for views
that render either section.

Returns:

  • (Boolean)


658
659
660
# File 'app/concerns/models/itemizable.rb', line 658

def has_kits_or_serial_numbers?
  has_serial_numbers? || has_kits?
end

#has_serial_numbers?Boolean

Whether any line item carries reserved or assigned serial numbers —
drives the serial-number badge/section in kit views.

Returns:

  • (Boolean)


642
643
644
# File 'app/concerns/models/itemizable.rb', line 642

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

Whether this document is an Order of credit-order type — used to
pick return/refund flows over normal checkout behaviour.

Returns:

  • (Boolean)


608
609
610
# File 'app/concerns/models/itemizable.rb', line 608

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:



183
184
185
# File 'app/concerns/models/itemizable.rb', line 183

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:



634
635
636
# File 'app/concerns/models/itemizable.rb', line 634

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)


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

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

#main_repEmployee?

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

Returns:



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

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)


758
759
760
761
762
# File 'app/concerns/models/itemizable.rb', line 758

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

#purge_empty_quoting_deliveriesvoid

This method returns an undefined value.

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.



540
541
542
543
544
545
546
547
548
549
550
551
# File 'app/concerns/models/itemizable.rb', line 540

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.



527
528
529
530
531
532
# File 'app/concerns/models/itemizable.rb', line 527

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:



134
135
136
137
138
139
140
141
142
# File 'app/concerns/models/itemizable.rb', line 134

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

Whether the cached totals must be recalculated on the next save —
true when totals were forced (force_total_reset), line items or
discounts changed, shipping needs re-quoting, or the customer or
shipping method changed. Returns false early when do_not_set_totals
is set.

Returns:

  • (Boolean)


480
481
482
483
484
485
486
487
488
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 480

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



295
296
297
298
299
300
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
333
334
335
# File 'app/concerns/models/itemizable.rb', line 295

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
    # Line items on an invoiced (locked) delivery are financially frozen: the
    # deferred `locked_delivery_line_items_guard` DB trigger rejects any write.
    # On a partially-invoiced split order, re-pricing a live delivery must not
    # touch the frozen one — otherwise this `li.update` aborts the whole
    # transaction at commit (mirrors the guard in
    # Coupon::ItemizableDiscountCalculator#reject_locked_delivery_lines).
    locked_delivery_ids = Delivery.locked_ids_among(line_items.where.not(delivery_id: nil).select(:delivery_id))
    # 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|
      next if li.delivery_id && locked_delivery_ids.include?(li.delivery_id)

      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

#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



208
209
210
211
212
213
214
215
216
# File 'app/concerns/models/itemizable.rb', line 208

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 Address 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)


743
744
745
746
747
748
749
750
# File 'app/concerns/models/itemizable.rb', line 743

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_totalsBoolean

COMMON CALLBACKS

before_save hook — when #require_total_reset? flips
total_reset, purges empty/shipping-orphan lines and zombie
quoting deliveries, re-quotes shipping when needed, syncs shipping
lines, flags discount recalculation when shipping cost or method
changed, then recalculates discounts. Always returns true so the
save proceeds.

Returns:

  • (Boolean)

    true (before_save must not halt)



673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
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
# File 'app/concerns/models/itemizable.rb', line 673

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?

Whether inputs to shipping pricing changed — the shipping address
id, or any address element on the current shipping address.

Returns:

  • (Boolean, nil)

    nil/falsey when nothing relevant changed



585
586
587
588
589
# File 'app/concerns/models/itemizable.rb', line 585

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)


230
231
232
# File 'app/concerns/models/itemizable.rb', line 230

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

#shipping_method_changed?Boolean

Whether any loaded delivery or its shipping costs changed — one of
the triggers for #require_total_reset?.

Returns:

  • (Boolean)

    false when the document has no deliveries relation



595
596
597
598
599
600
601
602
# File 'app/concerns/models/itemizable.rb', line 595

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

Whether shipping should be recalculated for this record

Returns:

  • (Boolean)


559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
# File 'app/concerns/models/itemizable.rb', line 559

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)


797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
# File 'app/concerns/models/itemizable.rb', line 797

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)


880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
# File 'app/concerns/models/itemizable.rb', line 880

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:



160
161
162
163
164
165
166
167
# File 'app/concerns/models/itemizable.rb', line 160

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)


776
777
778
# File 'app/concerns/models/itemizable.rb', line 776

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

#sync_shipping_linevoid

This method returns an undefined value.

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.



616
617
618
619
620
621
622
623
624
625
626
627
# File 'app/concerns/models/itemizable.rb', line 616

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)


469
470
471
# File 'app/concerns/models/itemizable.rb', line 469

def total_cogs
  line_items.reject(&:marked_for_destruction?).sum(&:total_cogs)
end