Class: Coupon::MsrpAllocator

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

Overview

Service object: msrp allocator.

Instance Method Summary collapse

Constructor Details

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

Returns a new instance of MsrpAllocator.

Parameters:

  • line_item_extractor (ProductFilter::LineExtractor)

    line item extractor

  • options (Hash) (defaults to: {})

    accepted for interface compatibility; no keys are currently read

Options Hash (options):

  • :reserved (Object)

    reserved for future use — no keys are currently read



8
9
10
11
# File 'app/services/coupon/msrp_allocator.rb', line 8

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



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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'app/services/coupon/msrp_allocator.rb', line 17

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.reject { |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
  # Exact per-line headroom, in LINE-TOTAL terms, tracked here instead of re-read from
  # line_item.discounted_price. That column is decimal(10,2), so ActiveRecord rounds
  # every assignment to the cent: a -39.99 line total over 4 units is -9.9975 each,
  # which stores as 0.00 and makes a line with a cent still on it look fully consumed,
  # so the remainder pass skips it and the coupon under-bills. Keyed by identity
  # because the line items are mutated while they are keys.
   = Hash.new(BigDecimal("0")).compare_by_identity

  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
    # Both terms are exact (2dp value × integer quantity, and a 2dp line total), so
    # this is the line's true remaining value — no rounding has been applied to it.
    # In add_mode we are adding value rather than consuming it, so nothing is spent.
    [line_item] = add_mode ? line_discounted_total : line_discounted_total - 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, headroom_by_line) ⇒ Object

this method figures out the best line to allocate a remainder.
headroom_by_line maps each line to the exact value it has left, in LINE-TOTAL
terms — see the comment where it is built for why it cannot be derived from
discounted_price.



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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'app/services/coupon/msrp_allocator.rb', line 90

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 remaining value 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] >= 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] >= BigDecimal("0.01") }
                                     .sort_by { |li| [-[li], 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] < BigDecimal("0.01")

      line_allocator = Coupon::LineItemDiscountAllocator.new(line)
      # line_total_override: the penny is one cent off the LINE total. Without it
      # the allocator treats -0.01 as per-unit and bills quantity × 0.01, while
      # the counter below only ever decrements a single cent — so a quantity > 1
      # line overshoots by (quantity - 1) cents and can drive the order negative.
      line_allocator.allocate(discount, BigDecimal("-0.01"), { preserve_amount: true,
                                                               respect_catalog_maximum: false,
                                                               line_total_override: BigDecimal("-0.01") })
      [line] -= BigDecimal("0.01")
      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] }

  # Fallback: use the line with the most value left if no single-quantity line
  line_for_remainder = single_lines.try(:first) || candidate_lines.max_by { |li| [li] }
  return unless line_for_remainder

  line_total_for_remainder = add_mode ? remaining_to_allocate : -remaining_to_allocate
  line_allocator = Coupon::LineItemDiscountAllocator.new(line_for_remainder)
  # remaining_to_allocate is a LINE total, so pin it with line_total_override —
  # single_lines usually wins above, but the max_by fallback can be a quantity > 1
  # line, which would otherwise bill quantity × the remainder.
  line_allocator.allocate(discount, line_total_for_remainder, { preserve_amount: true,
                                                                respect_catalog_maximum: false,
                                                                line_total_override: line_total_for_remainder })
end