Class: Coupon::MsrpAllocator

Inherits:
Object
  • Object
show all
Defined in:
app/services/coupon/msrp_allocator.rb

Instance Method Summary collapse

Constructor Details

#initialize(line_item_extractor, options = {}) ⇒ MsrpAllocator

Returns a new instance of MsrpAllocator.



2
3
4
5
# File 'app/services/coupon/msrp_allocator.rb', line 2

def initialize(line_item_extractor, options = {})
  @line_item_extractor = line_item_extractor
  @options = options
end

Instance Method Details

#allocate(discount, amount_to_allocate) ⇒ Object

Allocates a lump sump amongst all the line items discountable
Since there is no specific item, the distribution will be based on proportions
allocated according to the original msrp value
amount is the full value



11
12
13
14
15
16
17
18
19
20
21
22
23
24
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
# File 'app/services/coupon/msrp_allocator.rb', line 11

def allocate(discount, amount_to_allocate)
  # Select all the lines with a value (use in-memory discounted_price, not database discounted_total)
  eligible_lines = @line_item_extractor.discountable_line_items.select { |li| (li.discounted_price * li.quantity) != 0 }
  total_amount_available = eligible_lines.sum { |li| li.discounted_price * li.quantity }
  add_mode = amount_to_allocate > 0
  remaining_to_allocate = amount_to_allocate.abs

  # Handle case where no eligible lines exist
  if eligible_lines.empty?
    # For manually adjustable discounts with existing line_discounts, preserve them
    # instead of deleting. This handles cases like F3-A shipping adjustments where
    # the shipping line may have discounted_price==0 during recalculation.
    if discount.user_amount.present? && discount.line_discounts.any?
      Rails.logger.info "[MsrpAllocator] Preserving existing line_discounts for manual discount #{discount.coupon&.code} (id: #{discount.id}) even though no eligible lines found"
      return
    end
    # Otherwise delete all existing line line_discounts
    discount.line_discounts.destroy_all
    return
  end

  # Handle case where eligible lines exist but their total sums to zero
  # (e.g., opposite credit/debit lines that cancel out)
  # Dividing by zero would produce Infinity which PostgreSQL cannot store
  if total_amount_available.zero?
    Rails.logger.warn "[MsrpAllocator] Cannot allocate discount #{discount.coupon&.code} (id: #{discount.id}) - eligible lines sum to zero (possible opposite credit/debit lines)"
    discount.line_discounts.destroy_all
    return
  end
  eligible_lines.each do |line_item|
    # Skip lines with zero quantity to avoid division by zero
    next if line_item.quantity.zero?

    # Use in-memory discounted_price for allocation factor (not database discounted_total)
    line_discounted_total = line_item.discounted_price * line_item.quantity
    allocation_factor = line_discounted_total / total_amount_available
    # Calculate the line total allocation first (this is what matters for billing)
    line_total_allocation = BigDecimal(allocation_factor.to_s) * BigDecimal(amount_to_allocate.to_s)
    # Round toward zero at the LINE level to avoid overshooting the total discount
    rounded_line_total = if amount_to_allocate < 0
                           line_total_allocation.round(2, BigDecimal::ROUND_CEILING)  # toward zero for negative
                         else
                           line_total_allocation.round(2, BigDecimal::ROUND_FLOOR)    # toward zero for positive
                         end
    # Derive the per-unit allocation for discounted_price calculation
    unit_allocation = rounded_line_total / BigDecimal(line_item.quantity.to_s)
    remaining_to_allocate -= rounded_line_total.abs
    line_allocator = Coupon::LineItemDiscountAllocator.new(line_item)
    # Pass line_total_override to ensure exact line total (prevents per-unit rounding errors)
    line_allocator.allocate(discount, unit_allocation, {
      respect_catalog_maximum: false,
      line_total_override: rounded_line_total
    })
  end
  allocate_remainder(eligible_lines, remaining_to_allocate, discount, add_mode)
end

#allocate_remainder(eligible_lines, remaining_to_allocate, discount, add_mode) ⇒ Object

this method figures out the best line to allocate a remainder



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
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
# File 'app/services/coupon/msrp_allocator.rb', line 69

def allocate_remainder(eligible_lines, remaining_to_allocate, discount, add_mode)
  # Ignore sub-cent remainders (rounding artifacts)
  return unless remaining_to_allocate.abs >= 0.01

  # When discounting, only candidates whose current discounted_price can absorb the
  # remainder without going negative. A rounding artifact pushing an already-zero item
  # to -$0.01 propagates through delivery line_discounts and breaks invoice
  # total_is_positive validation (even though the penny is immaterial for billing).
  candidate_lines = if add_mode
                      eligible_lines
                    else
                      eligible_lines.select { |li| li.discounted_price >= remaining_to_allocate }
                    end

  # When no single line can absorb the full remainder (e.g. 3 × $10 items with a $30
  # discount leaves each line at $0.01 but the remainder is $0.03), distribute
  # penny-by-penny across lines that have at least $0.01 of headroom.
  if candidate_lines.empty? && !add_mode
    penny_candidates = eligible_lines.select { |li| li.discounted_price >= BigDecimal("0.01") }
                                     .sort_by { |li| [-li.discounted_price, li.id || 0] }

    if penny_candidates.empty?
      Rails.logger.warn "[MsrpAllocator] No line has sufficient discounted_price to absorb " \
                        "remainder #{remaining_to_allocate} for coupon #{discount.coupon&.code} " \
                        "(id: #{discount.id}). Skipping remainder to avoid negative line item."
      return
    end

    penny_candidates.cycle do |line|
      break if remaining_to_allocate < BigDecimal("0.01")
      break if line.discounted_price < BigDecimal("0.01")

      line_allocator = Coupon::LineItemDiscountAllocator.new(line)
      line_allocator.allocate(discount, BigDecimal("-0.01"), { preserve_amount: true, respect_catalog_maximum: false })
      remaining_to_allocate -= BigDecimal("0.01")
    end
    return
  end

  # If all items were already brought to zero (full-discount edge case in add_mode),
  # skip the remainder and log a warning.
  if candidate_lines.empty?
    Rails.logger.warn "[MsrpAllocator] No line has sufficient discounted_price to absorb " \
                      "remainder #{remaining_to_allocate} for coupon #{discount.coupon&.code} " \
                      "(id: #{discount.id}). Skipping remainder to avoid negative line item."
    return
  end

  # Prefer a line with quantity of 1 (cleanest allocation)
  single_lines = candidate_lines.select { |line_item| line_item.quantity.abs == 1 }
                                .sort_by { |li| -li.discounted_price }

  # Fallback: use the line with highest discounted price if no single-quantity line
  line_for_remainder = single_lines.try(:first) || candidate_lines.max_by { |li| li.discounted_price }
  return unless line_for_remainder

  unit_allocation = add_mode ? remaining_to_allocate : -remaining_to_allocate
  line_allocator = Coupon::LineItemDiscountAllocator.new(line_for_remainder)
  line_allocator.allocate(discount, unit_allocation, { preserve_amount: true, respect_catalog_maximum: false })
end