Class: Coupon::LineItemDiscountAllocator

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

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(line_item, options = {}) ⇒ LineItemDiscountAllocator

Returns a new instance of LineItemDiscountAllocator.



4
5
6
7
# File 'app/services/coupon/line_item_discount_allocator.rb', line 4

def initialize(line_item, options = {})
  @line_item = line_item
  @logger = options[:logger] || Rails.logger
end

Instance Attribute Details

#line_itemObject (readonly)

Returns the value of attribute line_item.



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

def line_item
  @line_item
end

#loggerObject (readonly)

Returns the value of attribute logger.



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

def logger
  @logger
end

#max_discountObject (readonly)

Returns the value of attribute max_discount.



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

def max_discount
  @max_discount
end

Instance Method Details

#allocate(discount, allocated_amount, options = {}) ⇒ Object

Allocate a computed discount to a line
line_item : instance of LineItem the line item to build discount lines upon
discount : A reference to the Discount instance in use
allocated_amount : The per-unit discount allocated. If its a discount, should be a negative number
preserve_amount : useful for calculation methods which will take multiple
pass at updating the same line discount. Essentially instead of resetting
to zero it will continue adding to it. MsrpAllocator makes uses of it
also this method leaves discount amount untouched
line_total_override : if provided, uses this exact amount for the line_discount instead of calculating
from per-unit. This prevents rounding errors when distributing fixed-amount discounts.



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

def allocate(discount, allocated_amount, options = {})
  allocated_amount = allocated_amount.round(2)
  options = { preserve_amount: false, respect_catalog_maximum: true }.merge(options)
  line_discount = prep_line_discount(discount)
  new_allocated_amount = options[:respect_catalog_maximum] ? allowable_amount(allocated_amount, discount) : allocated_amount

  # Handle line_total_override for exact billing amounts
  if options[:line_total_override]
    line_total_allocated_amount = options[:line_total_override].round(2)
    # Avoid division by zero if quantity is 0
    if @line_item.quantity.zero?
      logger.warn "[LineItemDiscountAllocator] Cannot allocate discount - line_item #{@line_item.id} has zero quantity"
      return 0
    end
    # Adjust per-unit amount to ensure discounted_price × quantity equals the exact line total
    # This prevents rounding errors in order total calculations
    adjusted_per_unit = BigDecimal(line_total_allocated_amount.to_s) / BigDecimal(@line_item.quantity.to_s)
    rounded_per_unit = adjusted_per_unit.round(2, adjusted_per_unit < 0 ? BigDecimal::ROUND_CEILING : BigDecimal::ROUND_FLOOR)
    @line_item.discounted_price += rounded_per_unit
  else
    # Legacy: use the provided per-unit amount
    @line_item.discounted_price += new_allocated_amount
    line_total_allocated_amount = @line_item.quantity.to_i * new_allocated_amount
  end

  if new_allocated_amount == 0
    discount.line_discounts.delete(line_discount)
  elsif options[:preserve_amount]
    line_discount.amount ||= 0
    line_discount.amount += line_total_allocated_amount
  else
    line_discount.amount = line_total_allocated_amount
  end
  new_allocated_amount
end

#allowable_amount(allocated_amount, discount = nil) ⇒ Object

method to enforce max discount set on catalog item



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

def allowable_amount(allocated_amount, discount = nil)
  new_discounted_price = line_item.discounted_price + allocated_amount
  new_allocated_amount = allocated_amount
  # Perform this check for classic discounts
  if allocated_amount < 0 && (max_discount = line_item.max_discount)
    # Annotate discount
    minimum_discounted_price = (line_item.price * ((100 - max_discount).to_f / 100)).round(2)
    # Only allow a discount up to the minimum discoutned
    allowable_discounted_price = [new_discounted_price, minimum_discounted_price].max
    discount.notes = if discount && allowable_discounted_price != new_discounted_price
                       "Discount capped at #{max_discount}% for item #{line_item.sku}"
                     else
                       nil
                     end
    # translate this effect back to the allocated amount
    new_allocated_amount = allowable_discounted_price - line_item.discounted_price
  end

  # Prevent over-discount: discounted_price must never go below zero.
  # Guards against stacked coupons (e.g. a 35% auto-discount + a full-price manual
  # comp) where the combined discount exceeds 100% of the item price, which would
  # make delivery.total negative and block shipping (delivery.rb:506).
  if new_allocated_amount < 0 && (line_item.discounted_price + new_allocated_amount) < 0
    new_allocated_amount = -line_item.discounted_price
  end

  new_allocated_amount
end

#prep_line_discount(discount) ⇒ Object

Prepare a new or existing line discount



86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'app/services/coupon/line_item_discount_allocator.rb', line 86

def prep_line_discount(discount)
  # Reuse existing line discounts if available
  line_discounts = discount.line_discounts.select { |ld| ld.line_item and ld.line_item == @line_item }
  # line_discounts = discount.line_discounts.joins(:line_item).includes(:line_item).where(LineDiscount[:line_item_id] == @line_item.id).to_a
  logger.debug " Found #{line_discounts.size} line discounts in discount linked to this line item #{@line_item.id} #{@line_item.item_id}"
  # In case we have multiple line discounts, only the top one will be the survivor
  line_discount = line_discounts.pop # Survivor
  line_discounts.each(&:destroy) # In case of duplicates, destroy the rest
  # If none present, build one
  line_discount ||= discount.line_discounts.build(line_item: @line_item,
                                                  coupon: discount.coupon)
  line_discount
end